chore: initial project setup
Initialize Sneaky Klaus project with: - uv package management and pyproject.toml - Flask application structure (app.py, config.py) - SQLAlchemy models for Admin and Exchange - Alembic database migrations - Pre-commit hooks configuration - Development tooling (pytest, ruff, mypy) Initial structure follows design documents in docs/: - src/app.py: Application factory with Flask extensions - src/config.py: Environment-based configuration - src/models/: Admin and Exchange models - migrations/: Alembic migration setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
722
docs/BACKLOG.md
Normal file
722
docs/BACKLOG.md
Normal file
@@ -0,0 +1,722 @@
|
||||
# Sneaky Klaus - Product Backlog
|
||||
|
||||
## Overview
|
||||
|
||||
This backlog contains user stories for Sneaky Klaus, organized by epic. Stories follow the format:
|
||||
|
||||
> **As a** [role], **I want** [capability], **so that** [benefit].
|
||||
|
||||
Each story includes acceptance criteria to define "done."
|
||||
|
||||
---
|
||||
|
||||
## Epic 1: Admin Account Management
|
||||
|
||||
### 1.1 Initial Admin Setup
|
||||
|
||||
**As a** first-time user, **I want** to create an admin account during initial setup, **so that** I can manage gift exchanges.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Setup screen appears on first application access
|
||||
- Requires email address and password
|
||||
- Password must meet minimum security requirements
|
||||
- After setup, user is logged in as admin
|
||||
- Setup screen is not accessible after initial admin creation
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Admin Login
|
||||
|
||||
**As an** admin, **I want** to log in with my email and password, **so that** I can access the admin dashboard.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Login form accepts email and password
|
||||
- Invalid credentials show appropriate error message
|
||||
- Successful login redirects to admin dashboard
|
||||
- Session persists across browser refreshes
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Admin Password Recovery
|
||||
|
||||
**As an** admin, **I want** to reset my password via email, **so that** I can regain access if I forget my password.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Forgot password" link on login page
|
||||
- Entering admin email sends reset link
|
||||
- Reset link expires after reasonable time period
|
||||
- Reset link can only be used once
|
||||
- New password must meet security requirements
|
||||
- Confirmation shown after successful reset
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Admin Logout
|
||||
|
||||
**As an** admin, **I want** to log out of my account, **so that** I can secure my session.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Logout option available from admin interface
|
||||
- Logout clears session
|
||||
- Redirects to login page after logout
|
||||
|
||||
---
|
||||
|
||||
## Epic 2: Exchange Management
|
||||
|
||||
### 2.1 Create Exchange
|
||||
|
||||
**As an** admin, **I want** to create a new gift exchange, **so that** I can organize a Secret Santa event.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Form to create exchange with fields:
|
||||
- Name (required)
|
||||
- Description (optional)
|
||||
- Gift budget/price range (required)
|
||||
- Maximum participants (required, minimum 3)
|
||||
- Registration close date (required)
|
||||
- Exchange date (required)
|
||||
- Timezone (required)
|
||||
- Exchange created in "Draft" state
|
||||
- Exchange appears in admin dashboard after creation
|
||||
|
||||
---
|
||||
|
||||
### 2.2 View Exchange List
|
||||
|
||||
**As an** admin, **I want** to see all my gift exchanges, **so that** I can manage them.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Dashboard shows list of all exchanges
|
||||
- Each exchange displays: name, state, participant count, exchange date
|
||||
- Exchanges sorted by exchange date (upcoming first)
|
||||
- Visual indicator for exchange state
|
||||
|
||||
---
|
||||
|
||||
### 2.3 View Exchange Details
|
||||
|
||||
**As an** admin, **I want** to view full details of an exchange, **so that** I can see its current status and participants.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Clicking an exchange opens detail view
|
||||
- Shows all exchange information
|
||||
- Shows list of registered participants
|
||||
- Shows current state
|
||||
- Shows registration link (when applicable)
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Edit Exchange
|
||||
|
||||
**As an** admin, **I want** to edit exchange details, **so that** I can correct mistakes or update information.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- All fields editable when exchange is in Draft, Registration Open, or Registration Closed state
|
||||
- Cannot edit after matching has occurred
|
||||
- Changes saved immediately
|
||||
- Confirmation message after save
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Delete Exchange
|
||||
|
||||
**As an** admin, **I want** to delete an exchange, **so that** I can remove cancelled or test exchanges.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Delete option available for any exchange
|
||||
- Confirmation required before deletion
|
||||
- Deletion removes all associated data (participants, matches)
|
||||
- Exchange no longer appears in dashboard
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Generate Registration Link
|
||||
|
||||
**As an** admin, **I want** a shareable registration link for each exchange, **so that** I can invite participants.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Unique link generated for each exchange
|
||||
- Link is copyable to clipboard
|
||||
- Link is displayed when exchange is in appropriate state
|
||||
- Link leads to registration page for that specific exchange
|
||||
|
||||
---
|
||||
|
||||
## Epic 3: Exchange State Management
|
||||
|
||||
### 3.1 Open Registration
|
||||
|
||||
**As an** admin, **I want** to open registration for an exchange, **so that** participants can join.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Open Registration" action available from Draft state
|
||||
- Exchange state changes to "Registration Open"
|
||||
- Registration link becomes active
|
||||
- Participants can now access registration form
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Close Registration
|
||||
|
||||
**As an** admin, **I want** to close registration, **so that** I can prepare for matching.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Close Registration" action available from Registration Open state
|
||||
- Exchange state changes to "Registration Closed"
|
||||
- Registration link no longer accepts new registrations
|
||||
- Existing participants can still access their profile via magic link
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Reopen Registration (Pre-Matching)
|
||||
|
||||
**As an** admin, **I want** to reopen registration before matching, **so that** I can allow late additions.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Reopen Registration" action available from Registration Closed state
|
||||
- Exchange state changes back to "Registration Open"
|
||||
- New participants can register
|
||||
- Existing participants retained
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Reopen Registration (Post-Matching)
|
||||
|
||||
**As an** admin, **I want** to reopen registration after matching, **so that** I can add participants who were missed.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Reopen Registration" action available from Matched state
|
||||
- Warning displayed that existing matches will be cleared
|
||||
- Confirmation required
|
||||
- All matches cleared upon confirmation
|
||||
- Exchange state changes to "Registration Open"
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Mark Exchange Complete
|
||||
|
||||
**As an** admin, **I want** to mark an exchange as complete, **so that** it's clear the event has concluded.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Mark Complete" action available from Matched state
|
||||
- Exchange state changes to "Completed"
|
||||
- Exchange moves to completed section in dashboard
|
||||
- 30-day retention countdown begins
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Automatic Exchange Completion
|
||||
|
||||
**As a** system, **I want** to automatically mark exchanges as complete after the exchange date, **so that** data retention policies are enforced.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Exchanges in Matched state auto-transition to Completed after exchange date passes
|
||||
- 30-day retention countdown begins automatically
|
||||
- Admin can still access completed exchange data during retention period
|
||||
|
||||
---
|
||||
|
||||
## Epic 4: Participant Registration
|
||||
|
||||
### 4.1 Access Registration Page
|
||||
|
||||
**As a** potential participant, **I want** to access the registration page via shared link, **so that** I can join an exchange.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Registration link opens registration page
|
||||
- Page displays exchange name, description, budget, and exchange date
|
||||
- Shows registration form if registration is open
|
||||
- Shows appropriate message if registration is closed
|
||||
- Shows appropriate message if exchange doesn't exist
|
||||
|
||||
---
|
||||
|
||||
### 4.2 New Participant Registration
|
||||
|
||||
**As a** potential participant, **I want** to register for an exchange, **so that** I can participate in Secret Santa.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Registration form with fields:
|
||||
- Name (required)
|
||||
- Email (required, valid email format)
|
||||
- Gift ideas (optional, multi-line text)
|
||||
- Opt-in for reminder emails (checkbox)
|
||||
- Email uniqueness checked within exchange
|
||||
- Confirmation message after successful registration
|
||||
- Confirmation email sent to participant
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Returning Participant Detection
|
||||
|
||||
**As a** returning participant, **I want** to be recognized when I click the registration link again, **so that** I don't accidentally create duplicate registrations.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- After clicking registration link, option to enter email to check existing registration
|
||||
- If email found, prompt to send magic link
|
||||
- If email not found, show registration form
|
||||
- Clear messaging about what's happening
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Admin Self-Registration
|
||||
|
||||
**As an** admin, **I want** to register myself as a participant in an exchange I created, **so that** I can participate in my own Secret Santa.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Option for admin to add themselves as participant
|
||||
- Uses admin's email by default (editable)
|
||||
- Admin registered same as any other participant
|
||||
- Admin clearly marked in participant list (for admin view only)
|
||||
|
||||
---
|
||||
|
||||
### 4.5 View Participant List (Pre-Matching)
|
||||
|
||||
**As a** registered participant, **I want** to see who else has registered, **so that** I know who's participating.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Participant list visible after logging in via magic link
|
||||
- Shows display names only
|
||||
- Does not show email addresses
|
||||
- Does not indicate any match information
|
||||
- Updates as new participants register
|
||||
|
||||
---
|
||||
|
||||
## Epic 5: Participant Authentication
|
||||
|
||||
### 5.1 Magic Link Request
|
||||
|
||||
**As a** registered participant, **I want** to request a login link via email, **so that** I can access my exchange information.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Access my registration" or similar option on registration page
|
||||
- Enter email to request magic link
|
||||
- If email registered, magic link sent
|
||||
- If email not registered, appropriate message shown (without revealing registration status for security)
|
||||
- Magic link expires after reasonable time period
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Magic Link Login
|
||||
|
||||
**As a** participant, **I want** to log in by clicking the magic link, **so that** I can access my information without a password.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Clicking valid magic link logs participant in
|
||||
- Redirects to participant dashboard
|
||||
- Magic link can only be used once
|
||||
- Expired link shows appropriate message with option to request new link
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Participant Session
|
||||
|
||||
**As a** logged-in participant, **I want** my session to persist, **so that** I don't have to log in repeatedly.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Session persists across browser refreshes
|
||||
- Session expires after reasonable inactivity period
|
||||
- Participant can manually log out
|
||||
|
||||
---
|
||||
|
||||
## Epic 6: Participant Self-Management
|
||||
|
||||
### 6.1 Update Profile
|
||||
|
||||
**As a** registered participant, **I want** to update my gift ideas, **so that** my Secret Santa has current information.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Edit option available when logged in
|
||||
- Can update name and gift ideas
|
||||
- Cannot change email (request admin help)
|
||||
- Only available before matching occurs
|
||||
- Confirmation after save
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Withdraw from Exchange
|
||||
|
||||
**As a** registered participant, **I want** to withdraw from an exchange, **so that** I can opt out if my circumstances change.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Withdraw" option available before registration closes
|
||||
- Confirmation required
|
||||
- Participant removed from exchange
|
||||
- Confirmation email sent
|
||||
- Admin notified (if notifications enabled)
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Update Reminder Preferences
|
||||
|
||||
**As a** participant, **I want** to change my reminder email preferences, **so that** I can control notifications.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Option to enable/disable reminder emails
|
||||
- Available before exchange completes
|
||||
- Changes take effect immediately
|
||||
|
||||
---
|
||||
|
||||
## Epic 7: Exclusion Rules
|
||||
|
||||
### 7.1 View Participants for Exclusions
|
||||
|
||||
**As an** admin, **I want** to see all participants when configuring exclusions, **so that** I can set up appropriate rules.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- List of all participants displayed
|
||||
- Available after registration is closed
|
||||
- Shows participant names clearly
|
||||
|
||||
---
|
||||
|
||||
### 7.2 Add Exclusion Rule
|
||||
|
||||
**As an** admin, **I want** to add exclusion rules, **so that** certain participants won't be matched together.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Interface to select two participants who shouldn't be matched
|
||||
- Exclusion is bidirectional (A won't give to B, B won't give to A)
|
||||
- Multiple exclusions can be added
|
||||
- Visual confirmation of added exclusion
|
||||
|
||||
---
|
||||
|
||||
### 7.3 Remove Exclusion Rule
|
||||
|
||||
**As an** admin, **I want** to remove exclusion rules, **so that** I can correct mistakes.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- List of current exclusions displayed
|
||||
- Option to remove each exclusion
|
||||
- Removal is immediate
|
||||
- Confirmation shown
|
||||
|
||||
---
|
||||
|
||||
### 7.4 Exclusion Validation
|
||||
|
||||
**As an** admin, **I want** to know if my exclusions make matching impossible, **so that** I can adjust before attempting to match.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Warning displayed if exclusions may prevent valid matching
|
||||
- Explanation of which exclusions are problematic
|
||||
- Matching blocked if mathematically impossible
|
||||
|
||||
---
|
||||
|
||||
## Epic 8: Matching
|
||||
|
||||
### 8.1 Trigger Matching
|
||||
|
||||
**As an** admin, **I want** to trigger the matching process, **so that** participants are assigned their recipients.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Match Participants" action available from Registration Closed state
|
||||
- Minimum 3 participants required
|
||||
- Matching algorithm runs respecting all exclusions
|
||||
- Exchange state changes to "Matched" on success
|
||||
- Admin notified of success
|
||||
|
||||
---
|
||||
|
||||
### 8.2 Matching Algorithm
|
||||
|
||||
**As a** system, **I want** to randomly match participants following Secret Santa best practices, **so that** the exchange is fair and secret.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- No participant is matched to themselves
|
||||
- All exclusion rules are honored
|
||||
- Each participant gives exactly one gift
|
||||
- Each participant receives exactly one gift
|
||||
- Single cycle preferred (A→B→C→A) when possible
|
||||
- Randomization ensures unpredictability
|
||||
|
||||
---
|
||||
|
||||
### 8.3 Matching Failure Handling
|
||||
|
||||
**As an** admin, **I want** to be informed if matching fails, **so that** I can resolve the issue.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Clear error message if matching impossible
|
||||
- Explanation of why (e.g., "Too many exclusions")
|
||||
- Suggestions for resolution
|
||||
- Exchange remains in Registration Closed state
|
||||
|
||||
---
|
||||
|
||||
### 8.4 View Matches (Admin)
|
||||
|
||||
**As an** admin, **I want** to view all matches, **so that** I can troubleshoot issues or handle disputes.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Admin can see full match list
|
||||
- Shows who is giving to whom
|
||||
- Only visible to admin
|
||||
- Available after matching
|
||||
|
||||
---
|
||||
|
||||
### 8.5 Re-Match Participants
|
||||
|
||||
**As an** admin, **I want** to trigger a fresh re-match, **so that** I can resolve issues with current assignments.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- "Re-match" option available in Matched state
|
||||
- Confirmation required (warns all matches will be cleared)
|
||||
- New random matching performed
|
||||
- New notification emails sent to all participants
|
||||
- Old matches completely replaced
|
||||
|
||||
---
|
||||
|
||||
### 8.6 Manual Match Override
|
||||
|
||||
**As an** admin, **I want** to manually assign or change specific matches, **so that** I can handle special circumstances.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Interface to change individual assignments
|
||||
- Validation prevents invalid states (no self-matching, everyone has exactly one recipient)
|
||||
- Changes saved immediately
|
||||
- Affected participants receive updated notification
|
||||
|
||||
---
|
||||
|
||||
## Epic 9: Participant Removal (Post-Registration Close)
|
||||
|
||||
### 9.1 Remove Participant (Admin)
|
||||
|
||||
**As an** admin, **I want** to remove a participant at any stage, **so that** I can handle dropouts.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Remove option available for any participant
|
||||
- Confirmation required
|
||||
- If before matching: participant simply removed
|
||||
- If after matching: triggers re-match requirement
|
||||
- Removed participant notified via email
|
||||
|
||||
---
|
||||
|
||||
### 9.2 Handle Post-Match Participant Removal
|
||||
|
||||
**As a** system, **I want** to handle participant removal after matching, **so that** the exchange can continue.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- When participant removed after matching, admin notified
|
||||
- Option to auto-rematch (system re-runs matching)
|
||||
- Option to manually reassign affected matches only
|
||||
- All affected participants notified of changes
|
||||
|
||||
---
|
||||
|
||||
## Epic 10: Notifications
|
||||
|
||||
### 10.1 Registration Confirmation Email
|
||||
|
||||
**As a** newly registered participant, **I want** to receive a confirmation email, **so that** I know my registration was successful.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Email sent immediately after registration
|
||||
- Includes exchange name and date
|
||||
- Includes magic link to access registration
|
||||
- Includes confirmation of provided details
|
||||
|
||||
---
|
||||
|
||||
### 10.2 Match Notification Email
|
||||
|
||||
**As a** participant, **I want** to receive an email with my match assignment, **so that** I know who to buy for.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Email sent immediately after matching
|
||||
- Includes recipient's name
|
||||
- Includes recipient's gift ideas
|
||||
- Includes gift budget
|
||||
- Includes exchange date
|
||||
- Includes magic link to view in app
|
||||
|
||||
---
|
||||
|
||||
### 10.3 Reminder Emails
|
||||
|
||||
**As a** participant who opted in, **I want** to receive reminder emails before the exchange, **so that** I don't forget to buy a gift.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Reminders sent only to opted-in participants
|
||||
- Reminder schedule configurable by admin
|
||||
- Includes recipient info and exchange date
|
||||
- Includes magic link to view full details
|
||||
|
||||
---
|
||||
|
||||
### 10.4 Admin Notification: New Registration
|
||||
|
||||
**As an** admin, **I want** to be notified of new registrations, **so that** I can track participation.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Email sent when new participant registers
|
||||
- Only if admin has enabled this notification
|
||||
- Includes participant name and exchange name
|
||||
|
||||
---
|
||||
|
||||
### 10.5 Admin Notification: Participant Withdrawal
|
||||
|
||||
**As an** admin, **I want** to be notified when a participant withdraws, **so that** I can follow up if needed.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Email sent when participant withdraws
|
||||
- Only if admin has enabled this notification
|
||||
- Includes participant name and exchange name
|
||||
|
||||
---
|
||||
|
||||
### 10.6 Admin Notification: Matching Complete
|
||||
|
||||
**As an** admin, **I want** to be notified when matching completes, **so that** I know the exchange is ready.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Email sent after successful matching
|
||||
- Only if admin has enabled this notification
|
||||
- Includes exchange name and participant count
|
||||
|
||||
---
|
||||
|
||||
### 10.7 Configure Admin Notifications
|
||||
|
||||
**As an** admin, **I want** to configure which notifications I receive, **so that** I'm not overwhelmed with emails.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Settings page for notification preferences
|
||||
- Toggle for each notification type
|
||||
- Can set globally or per-exchange
|
||||
- Changes take effect immediately
|
||||
|
||||
---
|
||||
|
||||
## Epic 11: Participant Dashboard
|
||||
|
||||
### 11.1 View My Assignment
|
||||
|
||||
**As a** matched participant, **I want** to view who I'm buying for, **so that** I can purchase an appropriate gift.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Dashboard shows assigned recipient's name
|
||||
- Shows recipient's gift ideas
|
||||
- Shows gift budget
|
||||
- Shows exchange date
|
||||
- Available after matching occurs
|
||||
|
||||
---
|
||||
|
||||
### 11.2 View Exchange Information
|
||||
|
||||
**As a** participant, **I want** to view exchange details, **so that** I have all the information I need.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Shows exchange name and description
|
||||
- Shows gift budget
|
||||
- Shows exchange date and timezone
|
||||
- Shows registration status/state
|
||||
|
||||
---
|
||||
|
||||
### 11.3 View Participant List (Post-Matching)
|
||||
|
||||
**As a** participant, **I want** to see who's in the exchange, **so that** I know the group.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- List of all participant names
|
||||
- Does not reveal who is matched to whom
|
||||
- Does not reveal who my Secret Santa is
|
||||
|
||||
---
|
||||
|
||||
## Epic 12: Data Retention
|
||||
|
||||
### 12.1 Automatic Data Purge
|
||||
|
||||
**As a** system, **I want** to automatically purge exchange data after 30 days, **so that** participant data isn't retained indefinitely.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Completed exchanges tracked with completion date
|
||||
- 30 days after completion, all data purged automatically
|
||||
- Purge includes: participants, matches, gift ideas, emails
|
||||
- Admin notified before purge (e.g., 7 days warning)
|
||||
|
||||
---
|
||||
|
||||
### 12.2 View Data Retention Status
|
||||
|
||||
**As an** admin, **I want** to see when exchange data will be purged, **so that** I can export if needed.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Completed exchanges show days until purge
|
||||
- Clear indication of purge date
|
||||
|
||||
---
|
||||
|
||||
## Epic 13: Responsive Design
|
||||
|
||||
### 13.1 Mobile-Friendly Participant Experience
|
||||
|
||||
**As a** participant on a mobile device, **I want** the interface to work well on my phone, **so that** I can easily register and view my assignment.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Registration form usable on mobile
|
||||
- Dashboard readable on mobile
|
||||
- Touch targets appropriately sized
|
||||
- No horizontal scrolling required
|
||||
|
||||
---
|
||||
|
||||
### 13.2 Mobile-Friendly Admin Experience
|
||||
|
||||
**As an** admin on a mobile device, **I want** to manage exchanges from my phone, **so that** I can administer on the go.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Admin dashboard usable on mobile
|
||||
- Exchange creation/editing works on mobile
|
||||
- Participant management works on mobile
|
||||
- All critical functions accessible
|
||||
|
||||
---
|
||||
|
||||
## Epic 14: Reminder Configuration
|
||||
|
||||
### 14.1 Configure Reminder Schedule
|
||||
|
||||
**As an** admin, **I want** to configure when reminder emails are sent, **so that** I can customize the experience.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- Settings to define reminder intervals (e.g., 7 days, 3 days, 1 day before)
|
||||
- Can be set per-exchange or as default
|
||||
- Only affects participants who opted in
|
||||
|
||||
---
|
||||
|
||||
## Story Status Key
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| 📋 Backlog | Not yet started |
|
||||
| 🔄 In Progress | Currently being worked on |
|
||||
| ✅ Done | Completed and tested |
|
||||
| 🚫 Blocked | Cannot proceed due to dependency |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Stories are roughly ordered by dependency and priority within each epic
|
||||
- Epics 1-4 represent core MVP functionality
|
||||
- Epics 5-6 complete the participant experience
|
||||
- Epics 7-9 handle the matching complexity
|
||||
- Epics 10-14 are supporting features that enhance the experience
|
||||
- Story estimates and sprint assignments to be added during planning sessions
|
||||
231
docs/PROJECT_OVERVIEW.md
Normal file
231
docs/PROJECT_OVERVIEW.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# Sneaky Klaus - Product Overview
|
||||
|
||||
## Vision
|
||||
|
||||
Sneaky Klaus is a simple, self-hosted web application for organizing Secret Santa gift exchanges. It removes the friction of coordinating gift exchanges by handling participant registration, random matching, and email notifications—all while maintaining the secrecy that makes Secret Santa fun.
|
||||
|
||||
The application is designed for individuals, families, or small organizations who want full control over their data without relying on third-party services.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Simplicity first**: Participants should be able to join an exchange in under a minute
|
||||
- **No participant accounts**: Participants authenticate via email magic links, not passwords
|
||||
- **Privacy by design**: Only the gifter knows who they're buying for; the system never reveals matches to anyone else
|
||||
- **Self-hosted**: All data stays on infrastructure you control
|
||||
- **Mobile-friendly**: Works seamlessly on phones, tablets, and desktops
|
||||
|
||||
---
|
||||
|
||||
## User Roles
|
||||
|
||||
### Administrator
|
||||
- Single admin account for the entire installation
|
||||
- Creates and manages all gift exchanges
|
||||
- May optionally participate in exchanges they create
|
||||
- Authenticates with email and password
|
||||
- Can recover password via email reset link
|
||||
|
||||
### Participant
|
||||
- Joins exchanges via shareable registration links
|
||||
- No password required—authentication via email magic link
|
||||
- Can participate in multiple exchanges with the same email address
|
||||
- Can view their assigned recipient and the participant list (but not other matches)
|
||||
|
||||
---
|
||||
|
||||
## Gift Exchange Lifecycle
|
||||
|
||||
An exchange progresses through the following states:
|
||||
|
||||
```
|
||||
Draft → Registration Open → Registration Closed → Matched → Completed
|
||||
```
|
||||
|
||||
### State Descriptions
|
||||
|
||||
1. **Draft**: Exchange is created but not yet accepting registrations
|
||||
2. **Registration Open**: Participants can join via the shareable link
|
||||
3. **Registration Closed**: No new registrations; admin configures exclusions and triggers matching
|
||||
4. **Matched**: Participants have been assigned recipients; notifications sent
|
||||
5. **Completed**: Exchange date has passed; data retained for 30 days then purged
|
||||
|
||||
### State Transitions
|
||||
|
||||
- Forward progression through states is the normal flow
|
||||
- Backward movement is permitted:
|
||||
- Reopening registration after closing (before matching) adds new participants
|
||||
- Reopening registration after matching clears all existing matches and requires fresh re-matching
|
||||
- Admin controls all state transitions manually (except automatic completion based on date)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Exchange Management (Admin)
|
||||
|
||||
#### Creating an Exchange
|
||||
- Name/title for the exchange
|
||||
- Description (optional)
|
||||
- Suggested gift budget/price range
|
||||
- Maximum participant limit (configurable per exchange; minimum is always 3)
|
||||
- Expected close date (when registration should end)
|
||||
- Exchange date (when gifts should be exchanged)
|
||||
- Timezone for all dates
|
||||
|
||||
#### Editing an Exchange
|
||||
- All fields are editable until matching has occurred
|
||||
- After matching, exchange details are locked
|
||||
|
||||
#### Registration Link
|
||||
- Each exchange has a unique, shareable registration link
|
||||
- Link can be shared via any channel (email, messaging apps, etc.)
|
||||
- Link remains active while registration is open
|
||||
|
||||
#### Closing Registration & Matching
|
||||
- Admin manually closes registration when ready
|
||||
- Before matching, admin can define exclusion rules (e.g., "Person A should not be matched with Person B")
|
||||
- Admin triggers the matching process
|
||||
- System randomly assigns each participant a recipient following Secret Santa best practices:
|
||||
- No self-matching
|
||||
- Exclusion rules are honored
|
||||
- Single cycle preferred (A→B→C→A) to ensure everyone gives and receives exactly once
|
||||
|
||||
#### Handling Issues
|
||||
- **Re-matching**: If needed, admin can trigger a complete re-match
|
||||
- **Manual assignment**: Admin can manually override or assign specific matches
|
||||
- **Participant removal**: Admin can remove participants at any stage
|
||||
|
||||
### Participant Registration
|
||||
|
||||
#### Joining an Exchange
|
||||
- Participant clicks the shareable link
|
||||
- System checks if their email is already registered for this exchange
|
||||
- If yes: prompts to send magic link to access their registration
|
||||
- If no: shows registration form
|
||||
|
||||
#### Registration Form
|
||||
- Name (display name for other participants)
|
||||
- Email address
|
||||
- Gift ideas (single multi-line text field for wishlist/preferences)
|
||||
|
||||
#### Self-Management
|
||||
- Participants can withdraw from an exchange before registration closes
|
||||
- Participants can update their gift ideas before registration closes
|
||||
- After matching, profile information is locked
|
||||
|
||||
### Matching & Notifications
|
||||
|
||||
#### Matching Algorithm
|
||||
- Implements proper Secret Santa matching:
|
||||
- Creates a single cycle where possible (ensures no small isolated groups)
|
||||
- Honors all exclusion rules
|
||||
- Guarantees everyone gives exactly one gift and receives exactly one gift
|
||||
- If matching is impossible due to exclusion rules, admin is notified with explanation
|
||||
|
||||
#### Participant Notifications
|
||||
- Upon matching, each participant receives an email containing:
|
||||
- Name of their assigned recipient
|
||||
- Recipient's gift ideas
|
||||
- Gift budget for the exchange
|
||||
- Exchange date
|
||||
- Email includes a magic link to view this information in the app
|
||||
|
||||
### Participant Experience (Post-Matching)
|
||||
|
||||
#### Viewing Assignment
|
||||
- Participants can log in via magic link at any time
|
||||
- Dashboard shows:
|
||||
- Who they are buying for
|
||||
- That person's gift ideas
|
||||
- Gift budget
|
||||
- Exchange date
|
||||
- List of all participants in the exchange (names only, not matches)
|
||||
|
||||
### Reminders
|
||||
|
||||
- Automatic reminder emails as exchange date approaches
|
||||
- Reminders are opt-in only (participant chooses during registration)
|
||||
- Suggested reminder schedule: configurable by admin (e.g., 7 days, 3 days, 1 day before)
|
||||
|
||||
### Admin Notifications
|
||||
|
||||
- Admin can opt-in to receive notifications for:
|
||||
- New participant registration
|
||||
- Participant withdrawal
|
||||
- Registration closed confirmation
|
||||
- Matching complete
|
||||
- Matching failures/issues
|
||||
- Notification preferences are configurable per exchange or globally
|
||||
|
||||
---
|
||||
|
||||
## Data Management
|
||||
|
||||
### Retention Policy
|
||||
- Exchange data (participants, matches, gift ideas) is retained for 30 days after the exchange date
|
||||
- After 30 days, all exchange data is automatically purged
|
||||
- Admin can manually delete exchanges at any time
|
||||
|
||||
### Privacy Considerations
|
||||
- Participant email addresses are only visible to the admin
|
||||
- Participants see each other's display names only
|
||||
- Match assignments are never visible to anyone except:
|
||||
- The gifter (sees their own recipient)
|
||||
- The admin (for troubleshooting/manual assignment only)
|
||||
|
||||
---
|
||||
|
||||
## Admin Account Management
|
||||
|
||||
### Initial Setup
|
||||
- First-time setup creates the admin account
|
||||
- Requires email and password
|
||||
|
||||
### Password Recovery
|
||||
- Admin can request password reset via email
|
||||
- Reset link sent to admin email address
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following features are explicitly **not** included in Sneaky Klaus:
|
||||
|
||||
- **Multiple admin accounts**: Single admin only
|
||||
- **Participant-to-participant messaging**: No anonymous or direct messaging between participants
|
||||
- **Reveal feature**: No mechanism to reveal who was whose Secret Santa after the exchange
|
||||
- **Payment processing**: No built-in payment or gift card purchasing
|
||||
- **Gift tracking**: No tracking of whether gifts were purchased or delivered
|
||||
- **Social features**: No comments, reactions, or social sharing within the app
|
||||
- **Wishlist links**: No integration with external wishlists (Amazon, etc.)
|
||||
- **Calendar integration**: No .ics exports or calendar app integration
|
||||
- **Multi-language support**: English only (initial release)
|
||||
- **Themeing/customization**: No custom branding per exchange
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
A successful implementation of Sneaky Klaus will:
|
||||
|
||||
1. Allow an admin to create an exchange and have participants registered within 5 minutes
|
||||
2. Enable participants to complete registration in under 60 seconds
|
||||
3. Successfully match any valid participant set (where exclusions don't make matching impossible)
|
||||
4. Deliver all notification emails reliably via Resend
|
||||
5. Work smoothly on mobile devices without requiring a native app
|
||||
6. Maintain complete secrecy of match assignments until intentionally revealed by gifters
|
||||
|
||||
---
|
||||
|
||||
## Glossary
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Exchange | A single Secret Santa event with its own participants, dates, and matches |
|
||||
| Participant | A person registered in an exchange who will give and receive a gift |
|
||||
| Match/Assignment | The pairing of a gifter to their recipient |
|
||||
| Exclusion | A rule preventing two specific participants from being matched |
|
||||
| Magic Link | A single-use, time-limited URL sent via email for passwordless authentication |
|
||||
| Registration Link | The shareable URL used to join an exchange |
|
||||
321
docs/ROADMAP.md
Normal file
321
docs/ROADMAP.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Sneaky Klaus - Implementation Roadmap
|
||||
|
||||
## Overview
|
||||
|
||||
This roadmap defines the phased implementation approach for Sneaky Klaus, a self-hosted Secret Santa organization application. The roadmap progresses from foundational infrastructure through core features to enhancement and polish.
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
The following decisions have been approved and are fixed:
|
||||
|
||||
- **Frontend**: Pure server-side rendering with Jinja2 templates (no JavaScript framework)
|
||||
- **Background Jobs**: APScheduler (in-process, no separate job queue)
|
||||
- **Password Requirements**: Minimum 12 characters, no complexity requirements
|
||||
- **Magic Link Expiration**: 1 hour for links, 7-day sliding window for sessions
|
||||
- **Reminder Defaults**: 7 days and 1 day before exchange date (configurable per exchange)
|
||||
- **Minimum Participants**: Hard requirement of 3 participants per exchange
|
||||
|
||||
## Phase Definitions
|
||||
|
||||
### Phase 0: Foundation and Architecture
|
||||
|
||||
**Goal**: Establish technical foundation and architectural design
|
||||
|
||||
**Deliverables**:
|
||||
- Architecture Decision Records (ADRs) for core technology choices
|
||||
- System design documentation (v0.1.0)
|
||||
- Database schema design
|
||||
- API/route specifications
|
||||
- Component design documentation
|
||||
- Development environment setup
|
||||
|
||||
**Stories**: None (pre-implementation architectural work)
|
||||
|
||||
**Dependencies**: None
|
||||
|
||||
**MVP Status**: Prerequisite for MVP
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Core Admin & Exchange Management (MVP)
|
||||
|
||||
**Goal**: Enable admin to create and manage gift exchanges
|
||||
|
||||
**Stories**:
|
||||
- 1.1 Initial Admin Setup
|
||||
- 1.2 Admin Login
|
||||
- 1.4 Admin Logout
|
||||
- 2.1 Create Exchange
|
||||
- 2.2 View Exchange List
|
||||
- 2.3 View Exchange Details
|
||||
- 2.6 Generate Registration Link
|
||||
- 3.1 Open Registration
|
||||
|
||||
**Dependencies**: Phase 0 complete
|
||||
|
||||
**MVP Status**: **This is the MVP**
|
||||
|
||||
**Exit Criteria**:
|
||||
- Admin can create account, log in, and log out
|
||||
- Admin can create exchanges with all required fields
|
||||
- Admin can view list of exchanges
|
||||
- Admin can generate and copy registration links
|
||||
- Admin can open registration for exchanges
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Participant Registration & Authentication
|
||||
|
||||
**Goal**: Enable participants to join exchanges and authenticate
|
||||
|
||||
**Stories**:
|
||||
- 4.1 Access Registration Page
|
||||
- 4.2 New Participant Registration
|
||||
- 4.3 Returning Participant Detection
|
||||
- 5.1 Magic Link Request
|
||||
- 5.2 Magic Link Login
|
||||
- 5.3 Participant Session
|
||||
- 10.1 Registration Confirmation Email
|
||||
|
||||
**Dependencies**: Phase 1 complete
|
||||
|
||||
**Exit Criteria**:
|
||||
- Participants can access registration page via link
|
||||
- Participants can register with name, email, and gift ideas
|
||||
- Returning participants are detected and can request magic link
|
||||
- Magic links authenticate participants correctly
|
||||
- Participant sessions persist appropriately
|
||||
- Confirmation emails sent successfully
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Exchange State Management & Matching
|
||||
|
||||
**Goal**: Enable complete exchange lifecycle from registration to matching
|
||||
|
||||
**Stories**:
|
||||
- 2.4 Edit Exchange
|
||||
- 3.2 Close Registration
|
||||
- 3.3 Reopen Registration (Pre-Matching)
|
||||
- 7.1 View Participants for Exclusions
|
||||
- 7.2 Add Exclusion Rule
|
||||
- 7.3 Remove Exclusion Rule
|
||||
- 7.4 Exclusion Validation
|
||||
- 8.1 Trigger Matching
|
||||
- 8.2 Matching Algorithm
|
||||
- 8.3 Matching Failure Handling
|
||||
- 8.4 View Matches (Admin)
|
||||
- 10.2 Match Notification Email
|
||||
|
||||
**Dependencies**: Phase 2 complete
|
||||
|
||||
**Exit Criteria**:
|
||||
- Admin can edit exchange details
|
||||
- Admin can close and reopen registration
|
||||
- Admin can configure exclusion rules
|
||||
- Matching algorithm successfully creates valid assignments
|
||||
- Match notification emails sent to all participants
|
||||
- Admin can view all matches for troubleshooting
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Participant Dashboard & Self-Management
|
||||
|
||||
**Goal**: Enable participants to view assignments and manage profiles
|
||||
|
||||
**Stories**:
|
||||
- 4.5 View Participant List (Pre-Matching)
|
||||
- 6.1 Update Profile
|
||||
- 6.2 Withdraw from Exchange
|
||||
- 6.3 Update Reminder Preferences
|
||||
- 11.1 View My Assignment
|
||||
- 11.2 View Exchange Information
|
||||
- 11.3 View Participant List (Post-Matching)
|
||||
|
||||
**Dependencies**: Phase 3 complete
|
||||
|
||||
**Exit Criteria**:
|
||||
- Participants can view and update their profiles
|
||||
- Participants can withdraw before registration closes
|
||||
- Participants can view their assignment after matching
|
||||
- Participants can view exchange information and participant lists
|
||||
- Participants can manage reminder preferences
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Advanced Matching & Admin Features
|
||||
|
||||
**Goal**: Handle edge cases and provide advanced administrative control
|
||||
|
||||
**Stories**:
|
||||
- 2.5 Delete Exchange
|
||||
- 3.4 Reopen Registration (Post-Matching)
|
||||
- 4.4 Admin Self-Registration
|
||||
- 8.5 Re-Match Participants
|
||||
- 8.6 Manual Match Override
|
||||
- 9.1 Remove Participant (Admin)
|
||||
- 9.2 Handle Post-Match Participant Removal
|
||||
|
||||
**Dependencies**: Phase 4 complete
|
||||
|
||||
**Exit Criteria**:
|
||||
- Admin can delete exchanges
|
||||
- Admin can reopen registration after matching (with match clearing)
|
||||
- Admin can participate in their own exchanges
|
||||
- Admin can trigger re-matching
|
||||
- Admin can manually override individual matches
|
||||
- Admin can remove participants with appropriate re-matching
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Notifications & Reminders
|
||||
|
||||
**Goal**: Complete notification system with reminders and admin notifications
|
||||
|
||||
**Stories**:
|
||||
- 10.3 Reminder Emails
|
||||
- 10.4 Admin Notification: New Registration
|
||||
- 10.5 Admin Notification: Participant Withdrawal
|
||||
- 10.6 Admin Notification: Matching Complete
|
||||
- 10.7 Configure Admin Notifications
|
||||
- 14.1 Configure Reminder Schedule
|
||||
|
||||
**Dependencies**: Phase 4 complete (can run parallel to Phase 5)
|
||||
|
||||
**Exit Criteria**:
|
||||
- Reminder emails sent according to configured schedule
|
||||
- Admin receives opt-in notifications for key events
|
||||
- Admin can configure notification preferences globally and per-exchange
|
||||
- Admin can configure reminder schedule per exchange
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Data Management & Lifecycle
|
||||
|
||||
**Goal**: Implement data retention and exchange lifecycle completion
|
||||
|
||||
**Stories**:
|
||||
- 1.3 Admin Password Recovery
|
||||
- 3.5 Mark Exchange Complete
|
||||
- 3.6 Automatic Exchange Completion
|
||||
- 12.1 Automatic Data Purge
|
||||
- 12.2 View Data Retention Status
|
||||
|
||||
**Dependencies**: Phase 5 complete
|
||||
|
||||
**Exit Criteria**:
|
||||
- Admin can recover password via email
|
||||
- Exchanges automatically complete after exchange date
|
||||
- Exchange data purged 30 days after completion
|
||||
- Admin can view retention status and purge timeline
|
||||
- Admin receives warning before data purge
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Polish & Responsive Design
|
||||
|
||||
**Goal**: Ensure excellent user experience across all devices
|
||||
|
||||
**Stories**:
|
||||
- 13.1 Mobile-Friendly Participant Experience
|
||||
- 13.2 Mobile-Friendly Admin Experience
|
||||
|
||||
**Dependencies**: Phase 7 complete
|
||||
|
||||
**Exit Criteria**:
|
||||
- All participant flows work well on mobile devices
|
||||
- All admin flows work well on mobile devices
|
||||
- Touch targets appropriately sized
|
||||
- No horizontal scrolling required
|
||||
- Tested across common mobile browsers
|
||||
|
||||
---
|
||||
|
||||
## Phase Dependencies Graph
|
||||
|
||||
```
|
||||
Phase 0 (Foundation)
|
||||
↓
|
||||
Phase 1 (MVP: Core Admin & Exchange Management)
|
||||
↓
|
||||
Phase 2 (Participant Registration & Authentication)
|
||||
↓
|
||||
Phase 3 (Exchange State Management & Matching)
|
||||
↓
|
||||
Phase 4 (Participant Dashboard & Self-Management)
|
||||
↓
|
||||
├── Phase 5 (Advanced Matching & Admin Features)
|
||||
└── Phase 6 (Notifications & Reminders) [parallel]
|
||||
↓
|
||||
Phase 7 (Data Management & Lifecycle)
|
||||
↓
|
||||
Phase 8 (Polish & Responsive Design)
|
||||
```
|
||||
|
||||
## MVP Definition
|
||||
|
||||
**The MVP is Phase 1**: Core Admin & Exchange Management
|
||||
|
||||
An implementation reaches MVP when:
|
||||
1. An admin can create an account
|
||||
2. An admin can log in and out securely
|
||||
3. An admin can create gift exchanges with all required fields
|
||||
4. An admin can view a list of their exchanges
|
||||
5. An admin can generate shareable registration links
|
||||
6. The application is deployable via Docker
|
||||
|
||||
This represents the minimum foundation for a functional Secret Santa application, even though it doesn't yet support participant registration or matching.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Story Prioritization Within Phases
|
||||
|
||||
Stories listed within each phase are roughly prioritized, but developers should:
|
||||
- Implement database models and schemas before UI
|
||||
- Implement authentication before protected routes
|
||||
- Consider natural groupings (e.g., all CRUD operations for a feature together)
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
Each phase should include:
|
||||
- Unit tests for business logic
|
||||
- Integration tests for database operations
|
||||
- End-to-end tests for critical user flows
|
||||
- Tests written before or alongside implementation (TDD encouraged)
|
||||
|
||||
### Documentation Updates
|
||||
|
||||
As each phase completes:
|
||||
- Update design docs if implementation reveals necessary changes
|
||||
- Document any deviations from original design with rationale
|
||||
- Update ADRs if architectural decisions change
|
||||
|
||||
### Deployment Considerations
|
||||
|
||||
- Docker image should be buildable and runnable from Phase 1 onward
|
||||
- Each phase should result in a deployable, functional (if limited) application
|
||||
- Database migrations should be reversible where possible
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The implementation is complete when:
|
||||
1. All 8 phases are delivered
|
||||
2. All acceptance criteria for all stories are met
|
||||
3. Application passes end-to-end testing
|
||||
4. Documentation is complete and accurate
|
||||
5. Application is production-ready for self-hosting
|
||||
|
||||
## Timeline Estimates
|
||||
|
||||
Actual timeline depends on development velocity. Rough estimates:
|
||||
- Phase 0: 2-3 days (design work)
|
||||
- Phase 1: 3-5 days (MVP)
|
||||
- Phase 2: 3-4 days
|
||||
- Phase 3: 5-7 days (complex matching logic)
|
||||
- Phase 4: 2-3 days
|
||||
- Phase 5: 3-4 days
|
||||
- Phase 6: 2-3 days
|
||||
- Phase 7: 2-3 days
|
||||
- Phase 8: 2-3 days
|
||||
|
||||
**Total estimate**: 24-37 days of development time
|
||||
179
docs/decisions/0001-core-technology-stack.md
Normal file
179
docs/decisions/0001-core-technology-stack.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# 0001. Core Technology Stack
|
||||
|
||||
Date: 2025-12-22
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (Updated 2025-12-22)
|
||||
|
||||
## Context
|
||||
|
||||
Sneaky Klaus is a self-hosted Secret Santa organization application designed for individuals, families, and small organizations who want full control over their data. The application must be:
|
||||
|
||||
- Easy to self-host via containerization
|
||||
- Simple to deploy and maintain
|
||||
- Minimal in external dependencies
|
||||
- Suitable for small-scale usage (dozens of participants, not thousands)
|
||||
- Functional without complex infrastructure
|
||||
|
||||
Key requirements that inform technology choices:
|
||||
|
||||
1. **Self-hosting first**: Users should be able to deploy with a single container
|
||||
2. **Simplicity**: The tech stack should be straightforward and well-documented
|
||||
3. **No participant accounts**: Authentication must support passwordless magic links
|
||||
4. **Email delivery**: Must send transactional emails reliably
|
||||
5. **Background jobs**: Must handle scheduled tasks (reminders, data purging)
|
||||
6. **Data persistence**: Must store user data reliably but doesn't need high-scale database features
|
||||
|
||||
## Decision
|
||||
|
||||
We will use the following core technology stack:
|
||||
|
||||
| Component | Technology | Version Constraint |
|
||||
|-----------|------------|-------------------|
|
||||
| **Backend Framework** | Flask | ^3.0 |
|
||||
| **Language** | Python | ^3.11 |
|
||||
| **Database** | SQLite | ^3.40 (via Python stdlib) |
|
||||
| **Email Service** | Resend | Latest SDK |
|
||||
| **Deployment** | Docker | Latest |
|
||||
| **Package Manager** | uv | Latest |
|
||||
| **Background Jobs** | APScheduler | ^3.10 |
|
||||
| **Template Engine** | Jinja2 | ^3.1 (Flask default) |
|
||||
| **WSGI Server** | Gunicorn | ^21.0 (production) |
|
||||
| **Form Handling** | Flask-WTF (includes WTForms) | ^1.2 |
|
||||
| **Session Management** | Flask-Session | ^0.8 |
|
||||
| **Timezone Validation** | pytz | Latest |
|
||||
| **CSS Framework** | Pico CSS | Latest (via CDN) |
|
||||
|
||||
### Key Technology Rationale
|
||||
|
||||
**Flask**: Lightweight, well-documented, excellent for small-to-medium applications. Large ecosystem, straightforward patterns, and no unnecessary complexity.
|
||||
|
||||
**Python 3.11+**: Modern Python with performance improvements, excellent type hinting support, and active security support.
|
||||
|
||||
**SQLite**: Perfect for self-hosted applications. Zero-configuration, single-file database, excellent for read-heavy workloads with occasional writes. Eliminates need for separate database server.
|
||||
|
||||
**Resend**: Modern transactional email API with excellent deliverability, simple API, and reasonable pricing for small-scale usage.
|
||||
|
||||
**Docker**: Industry-standard containerization. Single container deployment simplifies self-hosting significantly.
|
||||
|
||||
**uv**: Fast, modern Python package manager and project manager. Significantly faster than pip, with better dependency resolution and lockfile support.
|
||||
|
||||
**APScheduler**: In-process job scheduling. Eliminates need for separate job queue infrastructure (Redis, Celery) while still supporting background tasks like reminder emails and data purging.
|
||||
|
||||
**Jinja2**: Flask's default templating engine. Server-side rendering eliminates need for frontend JavaScript framework, simplifying deployment and maintenance.
|
||||
|
||||
**Gunicorn**: Production-ready WSGI server for Flask applications. Well-tested, stable, and appropriate for the scale of this application.
|
||||
|
||||
**Flask-WTF**: Integrates WTForms with Flask, providing form validation, CSRF protection, and secure form handling. Industry-standard for Flask applications.
|
||||
|
||||
**Flask-Session**: Server-side session management for Flask. Stores session data in SQLite, providing secure session handling without client-side storage concerns.
|
||||
|
||||
**pytz**: Standard Python library for timezone validation and handling. Required for validating IANA timezone names in exchange configurations.
|
||||
|
||||
**Pico CSS**: Minimal, classless CSS framework delivered via CDN. Provides clean, semantic styling without requiring a build step or complex class names. Fully responsive and accessible out of the box.
|
||||
|
||||
### Frontend Approach
|
||||
|
||||
**Pure server-side rendering** with Jinja2 templates. No JavaScript framework (React, Vue, etc.). This decision:
|
||||
|
||||
- Eliminates build tooling complexity
|
||||
- Reduces deployment artifacts (no separate frontend bundle)
|
||||
- Simplifies security (no client-side state management)
|
||||
- Ensures full functionality without JavaScript enabled
|
||||
- Maintains mobile-friendliness through responsive CSS
|
||||
|
||||
Progressive enhancement with minimal JavaScript for interactivity (copy-to-clipboard, form validation) is acceptable but not required for core functionality.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Simple deployment**: Single container with no external service dependencies (except Resend for email)
|
||||
- **Low resource requirements**: SQLite and in-process job scheduling minimize memory and CPU usage
|
||||
- **Fast development**: Flask's simplicity and Jinja2's straightforward templating accelerate development
|
||||
- **Easy debugging**: All code runs in single process, simplifying troubleshooting
|
||||
- **Predictable performance**: Server-side rendering is fast and consistent
|
||||
- **No build step**: Templates render directly; no frontend compilation required
|
||||
- **Security by default**: Server-side rendering reduces attack surface compared to client-side SPAs
|
||||
- **Excellent for scale target**: Perfect for dozens to hundreds of participants per deployment
|
||||
|
||||
### Negative
|
||||
|
||||
- **SQLite limitations**: Not suitable if application needs to scale to thousands of concurrent users (not a concern for target use case)
|
||||
- **No horizontal scaling**: Single SQLite file prevents multi-instance deployment (acceptable trade-off for simplicity)
|
||||
- **Email vendor lock-in**: Resend is the only supported email provider (could be abstracted later if needed)
|
||||
- **APScheduler constraints**: Job scheduling tied to application process lifetime; jobs don't survive application restarts (acceptable for reminder scheduling)
|
||||
- **Less interactive UI**: Server-side rendering means no SPA-style instant interactivity (acceptable trade-off for simplicity)
|
||||
|
||||
### Neutral
|
||||
|
||||
- **Python expertise required**: Development requires Python knowledge (expected for Flask application)
|
||||
- **Database portability**: SQLite schema could be migrated to PostgreSQL if scaling needs change, but would require development effort
|
||||
- **Email testing**: Requires Resend account for development (free tier available) or mocking in tests
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Database Considerations
|
||||
|
||||
SQLite will be configured with:
|
||||
- WAL (Write-Ahead Logging) mode for better concurrency
|
||||
- Foreign keys enabled
|
||||
- Appropriate timeout for locked database scenarios
|
||||
- Regular backups recommended via volume mounts
|
||||
|
||||
### Job Scheduling Considerations
|
||||
|
||||
APScheduler will run in-process with:
|
||||
- JobStore backed by SQLite for job persistence across restarts (for scheduled jobs)
|
||||
- Executor using thread pool for background tasks
|
||||
- Misfire grace time configured appropriately for reminders
|
||||
|
||||
### Email Configuration
|
||||
|
||||
Resend integration will:
|
||||
- Store API key in environment variable (not in code)
|
||||
- Support template-based emails
|
||||
- Handle failures gracefully with logging
|
||||
- Rate limit appropriately
|
||||
|
||||
### Development vs Production
|
||||
|
||||
- **Development**: Flask development server, SQLite in local file
|
||||
- **Production**: Gunicorn with multiple workers, SQLite in mounted volume, proper logging
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Database Alternatives
|
||||
|
||||
**PostgreSQL**: More scalable but requires separate database container/service, significantly complicating self-hosting. Overkill for target scale.
|
||||
|
||||
**MySQL/MariaDB**: Same drawbacks as PostgreSQL for this use case.
|
||||
|
||||
### Job Queue Alternatives
|
||||
|
||||
**Celery + Redis**: More robust job processing but requires Redis container, significantly complicating deployment. Overkill for reminder emails and daily data purging tasks.
|
||||
|
||||
**Cron + separate script**: Could work but fragments application logic and complicates deployment.
|
||||
|
||||
### Email Service Alternatives
|
||||
|
||||
**SendGrid**: Viable alternative but more complex API and pricing structure.
|
||||
|
||||
**Amazon SES**: Requires AWS account and more complex setup. Higher barrier for self-hosters.
|
||||
|
||||
**SMTP**: Requires users to configure their own SMTP server, significantly increasing setup complexity and deliverability issues.
|
||||
|
||||
### Frontend Alternatives
|
||||
|
||||
**React/Vue SPA**: Considered but rejected. Would require build tooling, increase deployment complexity, and provide minimal benefit for the application's relatively simple UI needs.
|
||||
|
||||
**HTMX**: Considered for progressive enhancement. May be added later but not required for MVP.
|
||||
|
||||
## References
|
||||
|
||||
- Flask documentation: https://flask.palletsprojects.com/
|
||||
- SQLite documentation: https://www.sqlite.org/docs.html
|
||||
- Resend documentation: https://resend.com/docs
|
||||
- APScheduler documentation: https://apscheduler.readthedocs.io/
|
||||
- uv documentation: https://docs.astral.sh/uv/
|
||||
298
docs/decisions/0002-authentication-strategy.md
Normal file
298
docs/decisions/0002-authentication-strategy.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# 0002. Authentication Strategy
|
||||
|
||||
Date: 2025-12-22
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Sneaky Klaus has two distinct user types with different authentication needs:
|
||||
|
||||
1. **Administrator**: Single admin account for entire installation. Needs persistent access to manage exchanges. Must be able to recover access if password is forgotten.
|
||||
|
||||
2. **Participants**: Multiple participants across multiple exchanges. Should have frictionless authentication without password management burden. Same participant may join multiple exchanges using same email.
|
||||
|
||||
Key requirements:
|
||||
|
||||
- **Security**: Authentication must be secure and follow best practices
|
||||
- **Simplicity for participants**: No password required; minimal friction to access information
|
||||
- **Admin control**: Admin needs traditional authenticated session for management tasks
|
||||
- **Password recovery**: Admin must be able to recover access via email
|
||||
- **Session management**: Sessions should persist appropriately but expire for security
|
||||
- **Email verification**: Participant email addresses must be verified (implicit via magic link)
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a **dual authentication strategy**:
|
||||
|
||||
### Admin Authentication: Password-Based
|
||||
|
||||
**Login Flow**:
|
||||
1. Admin enters email and password
|
||||
2. Password hashed with bcrypt, compared to stored hash
|
||||
3. On success, session created with admin role
|
||||
4. Session cookie set with appropriate security flags
|
||||
|
||||
**Password Requirements**:
|
||||
- Minimum 12 characters
|
||||
- No complexity requirements (no mandatory special chars, numbers, etc.)
|
||||
- This follows modern NIST guidance: length matters more than complexity
|
||||
|
||||
**Password Recovery Flow**:
|
||||
1. Admin requests password reset from login page
|
||||
2. System sends time-limited reset token (1 hour expiration) to admin email
|
||||
3. Reset link directs to password reset form
|
||||
4. Token validated, new password set
|
||||
5. Token invalidated after single use
|
||||
|
||||
**Session Management**:
|
||||
- Server-side sessions stored in database or cache
|
||||
- 7-day sliding expiration window (extends on activity)
|
||||
- Secure, HTTP-only session cookies
|
||||
- SameSite=Lax for CSRF protection
|
||||
- Logout explicitly destroys session
|
||||
|
||||
### Participant Authentication: Magic Links
|
||||
|
||||
**Magic Link Flow**:
|
||||
1. Participant requests access (from registration page or email)
|
||||
2. System generates cryptographically random token (256-bit)
|
||||
3. Token stored in database with 1-hour expiration
|
||||
4. Email sent with magic link: `/participant/auth/{token}`
|
||||
5. Clicking link validates token and creates session
|
||||
6. Token invalidated after single use
|
||||
|
||||
**Session Management**:
|
||||
- Server-side sessions stored in database
|
||||
- 7-day sliding expiration window (extends on activity)
|
||||
- Secure, HTTP-only session cookies
|
||||
- SameSite=Lax for CSRF protection
|
||||
- Sessions scoped to participant's exchanges only
|
||||
- No explicit logout needed (session expires naturally)
|
||||
|
||||
**Token Generation**:
|
||||
- Use Python's `secrets` module for cryptographic randomness
|
||||
- Tokens are 32-byte random values, URL-safe base64 encoded
|
||||
- Tokens stored as hashed values in database (using SHA-256)
|
||||
- Original token never stored in plain text
|
||||
|
||||
### Security Measures
|
||||
|
||||
**Password Storage**:
|
||||
- bcrypt with cost factor 12 (adjustable)
|
||||
- Passwords never logged or exposed in error messages
|
||||
- Password reset tokens hashed before storage
|
||||
|
||||
**Session Security**:
|
||||
- Session IDs are cryptographically random
|
||||
- Sessions stored server-side (not client-side JWTs)
|
||||
- Session data includes: user ID, role (admin/participant), creation time, last activity
|
||||
- Cookie flags: `Secure=True` (HTTPS only), `HttpOnly=True`, `SameSite=Lax`
|
||||
|
||||
**Rate Limiting**:
|
||||
- Login attempts: 5 per email per 15 minutes
|
||||
- Magic link requests: 3 per email per hour
|
||||
- Password reset requests: 3 per email per hour
|
||||
- Implemented at application level, tracked in database or cache
|
||||
|
||||
**Token Expiration**:
|
||||
- Magic link tokens: 1 hour
|
||||
- Password reset tokens: 1 hour
|
||||
- Admin sessions: 7 days (sliding window)
|
||||
- Participant sessions: 7 days (sliding window)
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Participant convenience**: No password to remember; access via email
|
||||
- **Email verification**: Magic links implicitly verify participant email addresses
|
||||
- **Admin security**: Traditional password-based auth provides familiar security model
|
||||
- **Password recovery**: Admin can self-serve password reset without external support
|
||||
- **Sliding sessions**: Activity extends session, reducing re-authentication friction
|
||||
- **Security best practices**: Modern password requirements (length over complexity)
|
||||
- **CSRF protection**: SameSite cookies prevent cross-site request forgery
|
||||
- **Token security**: One-time-use tokens prevent replay attacks
|
||||
|
||||
### Negative
|
||||
|
||||
- **Email dependency**: Magic links require working email delivery (mitigated by Resend reliability)
|
||||
- **Token expiration UX**: 1-hour expiration may frustrate slow email checkers (acceptable trade-off for security)
|
||||
- **Session storage**: Server-side sessions require database/cache storage (minimal overhead)
|
||||
- **No remember-me for admin**: 7-day max session requires re-login (acceptable for security)
|
||||
|
||||
### Neutral
|
||||
|
||||
- **Dual auth complexity**: Maintaining two auth flows adds implementation complexity (necessary for different user needs)
|
||||
- **Rate limiting overhead**: Requires tracking attempts per user (minimal performance impact)
|
||||
- **Session cleanup**: Expired sessions must be periodically purged (handled via background job)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Database Schema
|
||||
|
||||
**Admin User**:
|
||||
```python
|
||||
class Admin(Model):
|
||||
id: int
|
||||
email: str (unique, indexed)
|
||||
password_hash: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
**Participant** (simplified for auth):
|
||||
```python
|
||||
class Participant(Model):
|
||||
id: int
|
||||
email: str (indexed)
|
||||
exchange_id: int (foreign key)
|
||||
# ... other fields
|
||||
```
|
||||
|
||||
**Session**:
|
||||
```python
|
||||
class Session(Model):
|
||||
id: str (session ID, primary key)
|
||||
user_id: int
|
||||
user_type: str ('admin' | 'participant')
|
||||
created_at: datetime
|
||||
last_activity: datetime
|
||||
expires_at: datetime
|
||||
data: JSON (optional additional session data)
|
||||
```
|
||||
|
||||
**Auth Token** (magic links and password reset):
|
||||
```python
|
||||
class AuthToken(Model):
|
||||
id: int
|
||||
token_hash: str (indexed)
|
||||
token_type: str ('magic_link' | 'password_reset')
|
||||
email: str
|
||||
participant_id: int (nullable, for magic links)
|
||||
exchange_id: int (nullable, for magic links)
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
used_at: datetime (nullable)
|
||||
```
|
||||
|
||||
**Rate Limit**:
|
||||
```python
|
||||
class RateLimit(Model):
|
||||
id: int
|
||||
key: str (e.g., "login:admin@example.com", indexed)
|
||||
attempts: int
|
||||
window_start: datetime
|
||||
expires_at: datetime
|
||||
```
|
||||
|
||||
### Flask Session Configuration
|
||||
|
||||
```python
|
||||
app.config['SESSION_TYPE'] = 'sqlalchemy' # Server-side sessions
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
|
||||
app.config['SESSION_COOKIE_SECURE'] = True # HTTPS only
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
app.config['SESSION_REFRESH_EACH_REQUEST'] = True # Sliding window
|
||||
```
|
||||
|
||||
### Authentication Decorators
|
||||
|
||||
```python
|
||||
@login_required # Requires any authenticated user
|
||||
@admin_required # Requires admin role
|
||||
@participant_required # Requires participant role
|
||||
```
|
||||
|
||||
### URL Structure
|
||||
|
||||
**Admin**:
|
||||
- `/admin/login` - Login form
|
||||
- `/admin/logout` - Logout
|
||||
- `/admin/forgot-password` - Request password reset
|
||||
- `/admin/reset-password/{token}` - Reset password form
|
||||
|
||||
**Participant**:
|
||||
- `/participant/auth/{token}` - Magic link endpoint
|
||||
- `/participant/logout` - Optional logout
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### OAuth/Social Login
|
||||
|
||||
**Rejected**: Adds external dependencies, complicates self-hosting, and provides minimal benefit for a self-hosted application where users control the deployment.
|
||||
|
||||
### JWT Tokens
|
||||
|
||||
**Rejected for sessions**: JWTs are stateless, making them difficult to invalidate (e.g., on logout or security incident). Server-side sessions provide better control.
|
||||
|
||||
**Considered for magic links**: Could use JWTs for magic links, but custom tokens are simpler and equally secure.
|
||||
|
||||
### Passkeys/WebAuthn
|
||||
|
||||
**Deferred**: Modern and secure but adds implementation complexity. Could be added in future version for admin auth.
|
||||
|
||||
### Email Verification Codes
|
||||
|
||||
**Rejected**: 6-digit codes are less secure than magic links and require users to manually copy/paste, reducing convenience.
|
||||
|
||||
### Participant Passwords
|
||||
|
||||
**Rejected**: Violates core principle of frictionless participant experience. Participants joining Secret Santa events shouldn't need to manage yet another password.
|
||||
|
||||
### Longer Magic Link Expiration
|
||||
|
||||
**Rejected**: 1 hour balances security with usability. Longer expiration increases risk if email account is compromised.
|
||||
|
||||
### Shorter Session Duration
|
||||
|
||||
**Considered**: 24-hour sessions would be more secure but require frequent re-authentication. 7-day sliding window balances security with convenience.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Password Reset Token Timing Attack
|
||||
|
||||
To prevent email enumeration via timing attacks:
|
||||
- Always show "If an account exists, you'll receive an email" message
|
||||
- Perform same-time operations regardless of email existence
|
||||
- Don't reveal whether email is registered
|
||||
|
||||
### Magic Link Security
|
||||
|
||||
- Tokens are single-use and time-limited
|
||||
- Token hashing prevents database compromise from exposing valid tokens
|
||||
- Rate limiting prevents brute force token guessing
|
||||
- Tokens scoped to specific participant and exchange
|
||||
|
||||
### Session Fixation Prevention
|
||||
|
||||
- New session ID generated on login
|
||||
- Old session destroyed on logout
|
||||
- Session ID rotated on privilege elevation
|
||||
|
||||
### Brute Force Protection
|
||||
|
||||
- Rate limiting on all auth endpoints
|
||||
- Progressive delays on repeated failures (optional enhancement)
|
||||
- Account lockout not implemented (single admin, participant magic links)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
|
||||
1. **Admin 2FA**: Time-based OTP for additional admin security
|
||||
2. **Passkeys**: WebAuthn support for passwordless admin auth
|
||||
3. **Session device tracking**: Show admin active sessions and allow revocation
|
||||
4. **Remember-me for admin**: Optional extended session with re-authentication for sensitive actions
|
||||
5. **Magic link preview protection**: Use confirmation step before activating magic link
|
||||
|
||||
## References
|
||||
|
||||
- NIST Password Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
|
||||
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
|
||||
- Flask Session Management: https://flask.palletsprojects.com/en/latest/quickstart/#sessions
|
||||
- Python secrets module: https://docs.python.org/3/library/secrets.html
|
||||
1512
docs/designs/v0.1.0/api-spec.md
Normal file
1512
docs/designs/v0.1.0/api-spec.md
Normal file
File diff suppressed because it is too large
Load Diff
862
docs/designs/v0.1.0/components/auth.md
Normal file
862
docs/designs/v0.1.0/components/auth.md
Normal file
@@ -0,0 +1,862 @@
|
||||
# Authentication - v0.1.0
|
||||
|
||||
**Version**: 0.1.0
|
||||
**Date**: 2025-12-22
|
||||
**Status**: Initial Design
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the authentication and authorization flows for Sneaky Klaus. The system supports two types of users with different authentication mechanisms:
|
||||
|
||||
1. **Admin**: Single administrator account with password-based authentication
|
||||
2. **Participants**: Passwordless authentication via magic links
|
||||
|
||||
Session management is handled by Flask-Session, which stores session data server-side in SQLite.
|
||||
|
||||
## Authentication Flows
|
||||
|
||||
### Initial Admin Setup Flow
|
||||
|
||||
The first-run experience for new Sneaky Klaus installations.
|
||||
|
||||
#### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Browser
|
||||
participant App as Flask App
|
||||
participant DB as SQLite Database
|
||||
participant Session as Flask-Session
|
||||
|
||||
User->>Browser: Navigate to application
|
||||
Browser->>App: GET /
|
||||
App->>DB: Check if admin exists
|
||||
DB-->>App: No admin found
|
||||
App->>Browser: Redirect to /setup
|
||||
Browser->>App: GET /setup
|
||||
App-->>Browser: Render setup form
|
||||
|
||||
User->>Browser: Fill form (email, password)
|
||||
Browser->>App: POST /setup
|
||||
App->>App: Validate form (Flask-WTF)
|
||||
App->>App: Hash password (bcrypt)
|
||||
App->>DB: INSERT INTO admin
|
||||
DB-->>App: Admin created (id=1)
|
||||
App->>Session: Create admin session
|
||||
Session-->>App: Session ID
|
||||
App->>Browser: Set session cookie
|
||||
App->>Browser: Flash success message
|
||||
App->>Browser: Redirect to /admin/dashboard
|
||||
Browser->>App: GET /admin/dashboard
|
||||
App->>Session: Validate session
|
||||
Session-->>App: Admin authenticated
|
||||
App-->>Browser: Render dashboard
|
||||
|
||||
Note over Browser,App: Success message auto-dismisses after 5 seconds
|
||||
```
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**First-Run Detection**:
|
||||
- Check `SELECT COUNT(*) FROM admin` on application startup
|
||||
- If count == 0, set `app.config['REQUIRES_SETUP'] = True`
|
||||
- Before request handler checks flag and redirects to `/setup` if True
|
||||
|
||||
**Route**: `/setup`
|
||||
|
||||
**Methods**: GET, POST
|
||||
|
||||
**Authorization**:
|
||||
- Accessible only when no admin exists
|
||||
- Returns 404 if admin already exists
|
||||
|
||||
**Form Fields** (Flask-WTF):
|
||||
- `email`: EmailField, required, validated with email validator
|
||||
- `password`: PasswordField, required, min length 12 characters
|
||||
- `password_confirm`: PasswordField, required, must match password
|
||||
- CSRF token (automatic via Flask-WTF)
|
||||
|
||||
**POST Workflow**:
|
||||
1. Validate form with Flask-WTF
|
||||
2. Double-check no admin exists (prevent race condition)
|
||||
3. Hash password with bcrypt (cost factor 12)
|
||||
4. Insert admin record
|
||||
5. Create session (Flask-Session)
|
||||
6. Set session cookie (HttpOnly, Secure, SameSite=Lax)
|
||||
7. Flash success message: "Admin account created successfully!"
|
||||
8. Redirect to `/admin/dashboard`
|
||||
|
||||
**Auto-login**:
|
||||
- Session created immediately after admin creation
|
||||
- No separate login step required
|
||||
- User automatically authenticated
|
||||
|
||||
---
|
||||
|
||||
### Admin Login Flow
|
||||
|
||||
Standard password-based authentication for the admin user.
|
||||
|
||||
#### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Browser
|
||||
participant App as Flask App
|
||||
participant RateLimit as Rate Limiter
|
||||
participant DB as SQLite Database
|
||||
participant Session as Flask-Session
|
||||
|
||||
User->>Browser: Navigate to /admin/login
|
||||
Browser->>App: GET /admin/login
|
||||
App-->>Browser: Render login form
|
||||
|
||||
User->>Browser: Fill form (email, password)
|
||||
Browser->>App: POST /admin/login
|
||||
App->>App: Validate form (Flask-WTF)
|
||||
|
||||
App->>RateLimit: Check rate limit for email
|
||||
alt Rate limit exceeded
|
||||
RateLimit-->>App: Too many attempts
|
||||
App->>Browser: Flash error message
|
||||
App->>Browser: Render login form (429 status)
|
||||
Note over Browser: Error message persists until dismissed
|
||||
else Within rate limit
|
||||
RateLimit-->>App: Allowed
|
||||
App->>DB: SELECT * FROM admin WHERE email = ?
|
||||
DB-->>App: Admin record
|
||||
|
||||
alt Invalid credentials
|
||||
App->>App: Verify password (bcrypt)
|
||||
App->>RateLimit: Increment failure count
|
||||
App->>Browser: Flash error message
|
||||
App->>Browser: Render login form
|
||||
Note over Browser: Error message persists until dismissed
|
||||
else Valid credentials
|
||||
App->>App: Verify password (bcrypt)
|
||||
App->>RateLimit: Reset rate limit counter
|
||||
App->>Session: Create admin session
|
||||
Session-->>App: Session ID
|
||||
App->>Browser: Set session cookie
|
||||
App->>Browser: Flash success message
|
||||
App->>Browser: Redirect to /admin/dashboard
|
||||
Browser->>App: GET /admin/dashboard
|
||||
App->>Session: Validate session
|
||||
Session-->>App: Admin authenticated
|
||||
App-->>Browser: Render dashboard
|
||||
Note over Browser: Success message auto-dismisses after 5 seconds
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**Route**: `/admin/login`
|
||||
|
||||
**Methods**: GET, POST
|
||||
|
||||
**Authorization**:
|
||||
- Accessible to unauthenticated users only
|
||||
- Redirects to dashboard if already authenticated
|
||||
|
||||
**Form Fields** (Flask-WTF):
|
||||
- `email`: EmailField, required
|
||||
- `password`: PasswordField, required
|
||||
- `remember_me`: BooleanField, optional (extends session duration)
|
||||
- CSRF token (automatic via Flask-WTF)
|
||||
|
||||
**Rate Limiting**:
|
||||
- **Policy**: 5 attempts per 15 minutes per email
|
||||
- **Key**: `login:admin:{email_lowercase}`
|
||||
- **Implementation**: Check `rate_limit` table before authentication
|
||||
- **Failure Handling**: Increment attempt counter on failed login
|
||||
- **Success Handling**: Reset counter on successful login
|
||||
- **Lockout Message**: "Too many login attempts. Please try again in {minutes} minutes."
|
||||
|
||||
**POST Workflow**:
|
||||
1. Validate form with Flask-WTF
|
||||
2. Normalize email to lowercase
|
||||
3. Check rate limit for `login:admin:{email}`
|
||||
4. If rate limited: flash error, return 429
|
||||
5. Query admin by email
|
||||
6. If admin not found or password invalid:
|
||||
- Increment rate limit counter
|
||||
- Flash error: "Invalid email or password"
|
||||
- Re-render form
|
||||
7. If credentials valid:
|
||||
- Reset rate limit counter
|
||||
- Create session via Flask-Session
|
||||
- Set session expiration based on `remember_me`:
|
||||
- Checked: 30 days
|
||||
- Unchecked: 7 days (default)
|
||||
- Set session cookie
|
||||
- Flash success: "Welcome back!"
|
||||
- Redirect to `/admin/dashboard`
|
||||
|
||||
**Session Cookie Configuration**:
|
||||
- `HttpOnly`: True (prevent JavaScript access)
|
||||
- `Secure`: True (HTTPS only in production)
|
||||
- `SameSite`: Lax (CSRF protection)
|
||||
- `Max-Age`: Based on remember_me
|
||||
|
||||
---
|
||||
|
||||
### Admin Logout Flow
|
||||
|
||||
Terminates the admin session.
|
||||
|
||||
#### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Browser
|
||||
participant App as Flask App
|
||||
participant Session as Flask-Session
|
||||
|
||||
User->>Browser: Click logout
|
||||
Browser->>App: POST /admin/logout
|
||||
App->>Session: Get current session ID
|
||||
App->>Session: Delete session
|
||||
Session-->>App: Session deleted
|
||||
App->>Browser: Clear session cookie
|
||||
App->>Browser: Flash success message
|
||||
App->>Browser: Redirect to /
|
||||
Note over Browser: Success message auto-dismisses after 5 seconds
|
||||
```
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**Route**: `/admin/logout`
|
||||
|
||||
**Methods**: POST (GET redirects to dashboard)
|
||||
|
||||
**Authorization**: Requires active admin session
|
||||
|
||||
**POST Workflow**:
|
||||
1. Validate CSRF token
|
||||
2. Delete session from Flask-Session store
|
||||
3. Clear session cookie
|
||||
4. Flash success: "You have been logged out"
|
||||
5. Redirect to `/`
|
||||
|
||||
---
|
||||
|
||||
### Participant Magic Link Authentication Flow
|
||||
|
||||
Passwordless authentication for participants using time-limited magic links.
|
||||
|
||||
#### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Participant
|
||||
participant Browser
|
||||
participant App as Flask App
|
||||
participant RateLimit as Rate Limiter
|
||||
participant DB as SQLite Database
|
||||
participant Email as Resend
|
||||
participant Session as Flask-Session
|
||||
|
||||
Note over Participant,Browser: Participant requests access
|
||||
Participant->>Browser: Navigate to /exchange/{slug}
|
||||
Browser->>App: GET /exchange/{slug}
|
||||
App->>DB: SELECT * FROM exchange WHERE slug = ?
|
||||
DB-->>App: Exchange record
|
||||
App-->>Browser: Render participant login form
|
||||
|
||||
Participant->>Browser: Enter email
|
||||
Browser->>App: POST /exchange/{slug}/auth
|
||||
App->>App: Validate form (Flask-WTF)
|
||||
App->>RateLimit: Check rate limit for email
|
||||
|
||||
alt Rate limit exceeded
|
||||
RateLimit-->>App: Too many requests
|
||||
App->>Browser: Flash error message
|
||||
App->>Browser: Render form (429 status)
|
||||
Note over Browser: Error message persists until dismissed
|
||||
else Within rate limit
|
||||
RateLimit-->>App: Allowed
|
||||
App->>DB: SELECT * FROM participant WHERE email = ? AND exchange_id = ?
|
||||
|
||||
alt Participant not found
|
||||
App->>Browser: Flash error message
|
||||
App->>Browser: Render form
|
||||
Note over Browser: Generic error for security
|
||||
else Participant found
|
||||
DB-->>App: Participant record
|
||||
App->>App: Generate magic token (32 bytes, secrets module)
|
||||
App->>App: Hash token (SHA-256)
|
||||
App->>DB: INSERT INTO magic_token
|
||||
App->>Email: Send magic link email
|
||||
Email-->>Participant: Email with magic link
|
||||
App->>RateLimit: Increment request counter
|
||||
App->>Browser: Flash success message
|
||||
App->>Browser: Redirect to /exchange/{slug}/auth/sent
|
||||
Note over Browser: Success message auto-dismisses after 5 seconds
|
||||
end
|
||||
end
|
||||
|
||||
Note over Participant,Email: Participant clicks magic link
|
||||
Participant->>Email: Click link
|
||||
Email->>Browser: Open /exchange/{slug}/auth/verify?token={token}
|
||||
Browser->>App: GET /exchange/{slug}/auth/verify?token={token}
|
||||
App->>App: Hash token
|
||||
App->>DB: SELECT * FROM magic_token WHERE token_hash = ?
|
||||
|
||||
alt Token invalid/expired/used
|
||||
DB-->>App: No token found / Token expired
|
||||
App->>Browser: Flash error message
|
||||
App->>Browser: Redirect to /exchange/{slug}
|
||||
Note over Browser: Error message persists until dismissed
|
||||
else Token valid
|
||||
DB-->>App: Token record
|
||||
App->>DB: UPDATE magic_token SET used_at = NOW()
|
||||
App->>DB: SELECT * FROM participant WHERE id = ?
|
||||
DB-->>App: Participant record
|
||||
App->>Session: Create participant session
|
||||
Session-->>App: Session ID
|
||||
App->>Browser: Set session cookie
|
||||
App->>Browser: Flash success message
|
||||
App->>Browser: Redirect to /exchange/{slug}/dashboard
|
||||
Browser->>App: GET /exchange/{slug}/dashboard
|
||||
App->>Session: Validate session
|
||||
Session-->>App: Participant authenticated
|
||||
App-->>Browser: Render participant dashboard
|
||||
Note over Browser: Success message auto-dismisses after 5 seconds
|
||||
end
|
||||
```
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**Route**: `/exchange/{slug}/auth`
|
||||
|
||||
**Methods**: GET, POST
|
||||
|
||||
**Authorization**: Public (unauthenticated)
|
||||
|
||||
**Form Fields** (Flask-WTF):
|
||||
- `email`: EmailField, required
|
||||
- CSRF token (automatic via Flask-WTF)
|
||||
|
||||
**Rate Limiting**:
|
||||
- **Policy**: 3 requests per hour per email
|
||||
- **Key**: `magic_link:{email_lowercase}`
|
||||
- **Lockout Message**: "Too many magic link requests. Please try again in {minutes} minutes."
|
||||
|
||||
**POST Workflow** (Request Magic Link):
|
||||
1. Validate form with Flask-WTF
|
||||
2. Normalize email to lowercase
|
||||
3. Check rate limit for `magic_link:{email}`
|
||||
4. If rate limited: flash error, return 429
|
||||
5. Query participant by email and exchange_id
|
||||
6. If participant not found:
|
||||
- Flash generic error: "If this email is registered, you will receive a magic link."
|
||||
- Return success response (prevent email enumeration)
|
||||
- Do NOT send email
|
||||
7. If participant found but withdrawn:
|
||||
- Same as not found (prevent information disclosure)
|
||||
8. If participant found and active:
|
||||
- Generate token: 32 bytes from `secrets.token_urlsafe()`
|
||||
- Hash token: SHA-256
|
||||
- Store hash in `magic_token` table with:
|
||||
- `token_type`: 'magic_link'
|
||||
- `email`: participant email
|
||||
- `participant_id`: participant ID
|
||||
- `exchange_id`: exchange ID
|
||||
- `expires_at`: NOW() + 1 hour
|
||||
- Send email with magic link
|
||||
- Increment rate limit counter
|
||||
- Flash success: "Check your email for a magic link!"
|
||||
- Redirect to `/exchange/{slug}/auth/sent`
|
||||
|
||||
**Route**: `/exchange/{slug}/auth/verify`
|
||||
|
||||
**Methods**: GET
|
||||
|
||||
**Query Parameters**:
|
||||
- `token`: Magic token (URL-safe base64)
|
||||
|
||||
**GET Workflow** (Verify Token):
|
||||
1. Extract token from query string
|
||||
2. Hash token with SHA-256
|
||||
3. Query `magic_token` table by hash
|
||||
4. Validate token:
|
||||
- Exists in database
|
||||
- `expires_at` > NOW()
|
||||
- `used_at` IS NULL
|
||||
- `token_type` = 'magic_link'
|
||||
5. If invalid:
|
||||
- Flash error: "This magic link is invalid or has expired."
|
||||
- Redirect to `/exchange/{slug}`
|
||||
6. If valid:
|
||||
- Mark token as used: `UPDATE magic_token SET used_at = NOW()`
|
||||
- Load participant record
|
||||
- Create session via Flask-Session with:
|
||||
- `user_id`: participant.id
|
||||
- `user_type`: 'participant'
|
||||
- `exchange_id`: exchange.id
|
||||
- Set session cookie (7-day expiration)
|
||||
- Flash success: "Welcome, {participant.name}!"
|
||||
- Redirect to `/exchange/{slug}/dashboard`
|
||||
|
||||
**Token Generation**:
|
||||
```python
|
||||
import secrets
|
||||
import hashlib
|
||||
|
||||
# Generate token
|
||||
token = secrets.token_urlsafe(32) # 32 bytes = 43 URL-safe characters
|
||||
|
||||
# Hash for storage
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
# Magic link URL
|
||||
magic_link = f"{base_url}/exchange/{slug}/auth/verify?token={token}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Participant Logout Flow
|
||||
|
||||
Terminates the participant session.
|
||||
|
||||
#### Sequence Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Participant
|
||||
participant Browser
|
||||
participant App as Flask App
|
||||
participant Session as Flask-Session
|
||||
|
||||
Participant->>Browser: Click logout
|
||||
Browser->>App: POST /exchange/{slug}/logout
|
||||
App->>Session: Get current session ID
|
||||
App->>Session: Delete session
|
||||
Session-->>App: Session deleted
|
||||
App->>Browser: Clear session cookie
|
||||
App->>Browser: Flash success message
|
||||
App->>Browser: Redirect to /exchange/{slug}
|
||||
Note over Browser: Success message auto-dismisses after 5 seconds
|
||||
```
|
||||
|
||||
#### Implementation Details
|
||||
|
||||
**Route**: `/exchange/{slug}/logout`
|
||||
|
||||
**Methods**: POST
|
||||
|
||||
**Authorization**: Requires active participant session for this exchange
|
||||
|
||||
**POST Workflow**:
|
||||
1. Validate CSRF token
|
||||
2. Verify session belongs to participant for this exchange
|
||||
3. Delete session from Flask-Session store
|
||||
4. Clear session cookie
|
||||
5. Flash success: "You have been logged out"
|
||||
6. Redirect to `/exchange/{slug}`
|
||||
|
||||
---
|
||||
|
||||
## Session Management
|
||||
|
||||
### Flask-Session Configuration
|
||||
|
||||
**Backend**: SQLAlchemy (SQLite)
|
||||
|
||||
**Table**: `sessions` (created and managed by Flask-Session)
|
||||
|
||||
**Configuration**:
|
||||
```python
|
||||
app.config['SESSION_TYPE'] = 'sqlalchemy'
|
||||
app.config['SESSION_SQLALCHEMY'] = db # SQLAlchemy instance
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
app.config['SESSION_USE_SIGNER'] = True # Sign session cookies
|
||||
app.config['SESSION_KEY_PREFIX'] = 'sk:' # Prefix for session keys
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Default
|
||||
```
|
||||
|
||||
### Session Data Structure
|
||||
|
||||
**Admin Session**:
|
||||
```python
|
||||
{
|
||||
'user_id': 1, # Admin ID
|
||||
'user_type': 'admin',
|
||||
'_fresh': True, # For Flask-Login compatibility (future)
|
||||
'_permanent': True
|
||||
}
|
||||
```
|
||||
|
||||
**Participant Session**:
|
||||
```python
|
||||
{
|
||||
'user_id': 123, # Participant ID
|
||||
'user_type': 'participant',
|
||||
'exchange_id': 456, # Exchange participant is authenticated for
|
||||
'_fresh': True,
|
||||
'_permanent': True
|
||||
}
|
||||
```
|
||||
|
||||
### Session Validation
|
||||
|
||||
**Before Request Handler**:
|
||||
```python
|
||||
@app.before_request
|
||||
def load_session_user():
|
||||
if 'user_id' in session and 'user_type' in session:
|
||||
if session['user_type'] == 'admin':
|
||||
g.admin = Admin.query.get(session['user_id'])
|
||||
elif session['user_type'] == 'participant':
|
||||
g.participant = Participant.query.get(session['user_id'])
|
||||
g.exchange_id = session.get('exchange_id')
|
||||
```
|
||||
|
||||
### Session Expiration
|
||||
|
||||
**Default Expiration**: 7 days from last activity
|
||||
|
||||
**Remember Me** (Admin only): 30 days from last activity
|
||||
|
||||
**Sliding Window**: Flask-Session automatically updates `last_activity` on each request
|
||||
|
||||
**Cleanup**: Flask-Session handles expired session cleanup automatically
|
||||
|
||||
---
|
||||
|
||||
## Flash Messages
|
||||
|
||||
Flash messages provide user feedback for authentication actions.
|
||||
|
||||
### Flash Message Types
|
||||
|
||||
**Success Messages**:
|
||||
- Category: `'success'`
|
||||
- Auto-dismiss: 5 seconds
|
||||
- Examples:
|
||||
- "Admin account created successfully!"
|
||||
- "Welcome back!"
|
||||
- "You have been logged out"
|
||||
- "Check your email for a magic link!"
|
||||
- "Welcome, {name}!"
|
||||
|
||||
**Error Messages**:
|
||||
- Category: `'error'`
|
||||
- Auto-dismiss: Manual (user must dismiss)
|
||||
- Examples:
|
||||
- "Invalid email or password"
|
||||
- "Too many login attempts. Please try again in {minutes} minutes."
|
||||
- "This magic link is invalid or has expired."
|
||||
|
||||
### Implementation
|
||||
|
||||
**Backend** (Flask):
|
||||
```python
|
||||
from flask import flash
|
||||
|
||||
# Success message
|
||||
flash("Admin account created successfully!", "success")
|
||||
|
||||
# Error message
|
||||
flash("Invalid email or password", "error")
|
||||
```
|
||||
|
||||
**Frontend** (Jinja2 + JavaScript):
|
||||
```html
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash-message flash-{{ category }}"
|
||||
{% if category == 'success' %}data-auto-dismiss="5000"{% endif %}>
|
||||
{{ message }}
|
||||
<button class="flash-dismiss" aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<script>
|
||||
// Auto-dismiss success messages after 5 seconds
|
||||
document.querySelectorAll('[data-auto-dismiss]').forEach(el => {
|
||||
const delay = parseInt(el.dataset.autoDismiss);
|
||||
setTimeout(() => {
|
||||
el.style.opacity = '0';
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, delay);
|
||||
});
|
||||
|
||||
// Manual dismiss for all messages
|
||||
document.querySelectorAll('.flash-dismiss').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const message = btn.parentElement;
|
||||
message.style.opacity = '0';
|
||||
setTimeout(() => message.remove(), 300);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authorization Patterns
|
||||
|
||||
### Route Protection Decorators
|
||||
|
||||
**Admin-Only Routes**:
|
||||
```python
|
||||
from functools import wraps
|
||||
from flask import session, redirect, url_for, flash, g
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session or session.get('user_type') != 'admin':
|
||||
flash("You must be logged in as admin to access this page.", "error")
|
||||
return redirect(url_for('admin_login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@app.route('/admin/dashboard')
|
||||
@admin_required
|
||||
def admin_dashboard():
|
||||
return render_template('admin/dashboard.html')
|
||||
```
|
||||
|
||||
**Participant-Only Routes**:
|
||||
```python
|
||||
def participant_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(slug, *args, **kwargs):
|
||||
if 'user_id' not in session or session.get('user_type') != 'participant':
|
||||
flash("You must be logged in to access this page.", "error")
|
||||
return redirect(url_for('exchange_auth', slug=slug))
|
||||
|
||||
# Verify participant is authenticated for this exchange
|
||||
exchange = Exchange.query.filter_by(slug=slug).first_or_404()
|
||||
if session.get('exchange_id') != exchange.id:
|
||||
flash("You are not authorized to access this exchange.", "error")
|
||||
return redirect(url_for('exchange_auth', slug=slug))
|
||||
|
||||
return f(slug, *args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@app.route('/exchange/<slug>/dashboard')
|
||||
@participant_required
|
||||
def participant_dashboard(slug):
|
||||
return render_template('participant/dashboard.html')
|
||||
```
|
||||
|
||||
### Setup Requirement Check
|
||||
|
||||
**Before Request Handler**:
|
||||
```python
|
||||
@app.before_request
|
||||
def check_setup_required():
|
||||
# Skip check for setup route and static files
|
||||
if request.endpoint in ['setup', 'static']:
|
||||
return
|
||||
|
||||
# Check if admin exists
|
||||
if app.config.get('REQUIRES_SETUP'):
|
||||
admin_count = Admin.query.count()
|
||||
if admin_count == 0:
|
||||
return redirect(url_for('setup'))
|
||||
else:
|
||||
app.config['REQUIRES_SETUP'] = False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Password Security
|
||||
|
||||
**Hashing Algorithm**: bcrypt with cost factor 12
|
||||
|
||||
**Minimum Password Length**: 12 characters
|
||||
|
||||
**Password Validation**:
|
||||
- Enforced at form level (Flask-WTF)
|
||||
- No complexity requirements (length is primary security measure)
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
from flask_bcrypt import Bcrypt
|
||||
|
||||
bcrypt = Bcrypt(app)
|
||||
|
||||
# Hash password
|
||||
password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
|
||||
# Verify password
|
||||
bcrypt.check_password_hash(admin.password_hash, password)
|
||||
```
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
**Implementation**: Flask-WTF automatic CSRF protection
|
||||
|
||||
**All Forms**: Include CSRF token automatically via `{{ form.csrf_token }}`
|
||||
|
||||
**All POST Routes**: Validate CSRF token automatically via Flask-WTF
|
||||
|
||||
**Exempt Routes**: None (all state-changing operations require CSRF token)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**Storage**: SQLite `rate_limit` table
|
||||
|
||||
**Policies**:
|
||||
- Admin login: 5 attempts / 15 minutes / email
|
||||
- Magic link request: 3 requests / hour / email
|
||||
|
||||
**Bypass**: None (applies to all users including admin)
|
||||
|
||||
**Cleanup**: Expired entries purged daily via background job
|
||||
|
||||
### Session Security
|
||||
|
||||
**Cookie Settings**:
|
||||
- `HttpOnly`: True (prevent XSS)
|
||||
- `Secure`: True in production (HTTPS only)
|
||||
- `SameSite`: Lax (CSRF protection)
|
||||
|
||||
**Session Signing**: Enabled via `SESSION_USE_SIGNER`
|
||||
|
||||
**Secret Key**: Loaded from environment variable `SECRET_KEY`
|
||||
|
||||
### Magic Link Security
|
||||
|
||||
**Token Generation**: `secrets.token_urlsafe(32)` (cryptographically secure)
|
||||
|
||||
**Token Storage**: SHA-256 hash only (original token never stored)
|
||||
|
||||
**Token Expiration**: 1 hour from creation
|
||||
|
||||
**Single Use**: Marked as used immediately upon verification
|
||||
|
||||
**Email Enumeration Prevention**: Generic success message for all email submissions
|
||||
|
||||
### Timing Attack Prevention
|
||||
|
||||
**Password Verification**: bcrypt naturally resistant to timing attacks
|
||||
|
||||
**Magic Link Lookup**: Use constant-time comparison for token hashes (handled by database query)
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
**Invalid Credentials**:
|
||||
- HTTP Status: 200 (re-render form)
|
||||
- Flash Message: "Invalid email or password"
|
||||
- Rate Limit: Increment counter
|
||||
|
||||
**Rate Limit Exceeded**:
|
||||
- HTTP Status: 429 Too Many Requests
|
||||
- Flash Message: "Too many {action} attempts. Please try again in {minutes} minutes."
|
||||
- Behavior: Re-render form with error
|
||||
|
||||
**Expired Magic Link**:
|
||||
- HTTP Status: 200 (redirect to login)
|
||||
- Flash Message: "This magic link is invalid or has expired."
|
||||
- Behavior: Redirect to exchange login page
|
||||
|
||||
**CSRF Validation Failure**:
|
||||
- HTTP Status: 400 Bad Request
|
||||
- Flash Message: "Security validation failed. Please try again."
|
||||
- Behavior: Redirect to form
|
||||
|
||||
### Session Errors
|
||||
|
||||
**Session Expired**:
|
||||
- HTTP Status: 302 (redirect to login)
|
||||
- Flash Message: "Your session has expired. Please log in again."
|
||||
- Behavior: Redirect to appropriate login page
|
||||
|
||||
**Invalid Session**:
|
||||
- HTTP Status: 302 (redirect to login)
|
||||
- Flash Message: "You must be logged in to access this page."
|
||||
- Behavior: Redirect to appropriate login page
|
||||
|
||||
---
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Setup Flow**:
|
||||
- Test admin creation with valid data
|
||||
- Test duplicate admin prevention
|
||||
- Test auto-login after setup
|
||||
- Test setup route 404 when admin exists
|
||||
|
||||
**Admin Login**:
|
||||
- Test successful login with valid credentials
|
||||
- Test failed login with invalid credentials
|
||||
- Test rate limiting after 5 failed attempts
|
||||
- Test rate limit reset after successful login
|
||||
- Test remember_me session duration
|
||||
|
||||
**Magic Link**:
|
||||
- Test magic link generation for valid participant
|
||||
- Test rate limiting after 3 requests
|
||||
- Test token verification with valid token
|
||||
- Test token expiration after 1 hour
|
||||
- Test single-use token enforcement
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Full Authentication Flows**:
|
||||
- Test setup → login → logout flow
|
||||
- Test magic link request → email → verify → dashboard flow
|
||||
- Test concurrent session handling
|
||||
- Test session persistence across requests
|
||||
|
||||
### Security Tests
|
||||
|
||||
**CSRF Protection**:
|
||||
- Test all POST routes require valid CSRF token
|
||||
- Test CSRF token validation
|
||||
|
||||
**Rate Limiting**:
|
||||
- Test rate limits are enforced correctly
|
||||
- Test rate limit window expiration
|
||||
|
||||
**Session Security**:
|
||||
- Test session cookie settings (HttpOnly, Secure, SameSite)
|
||||
- Test session expiration
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
|
||||
1. **Two-Factor Authentication**: TOTP for admin login
|
||||
2. **Password Reset**: Admin password reset via email
|
||||
3. **Session Management UI**: Admin view of active sessions
|
||||
4. **Account Lockout**: Temporary lockout after repeated failed logins
|
||||
5. **Audit Logging**: Track all authentication events
|
||||
6. **Multiple Admins**: Support for multiple admin accounts with roles
|
||||
7. **OAuth Integration**: Social login options for participants
|
||||
|
||||
These enhancements are out of scope for v0.1.0.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Flask-Session Documentation](https://flask-session.readthedocs.io/)
|
||||
- [Flask-WTF Documentation](https://flask-wtf.readthedocs.io/)
|
||||
- [bcrypt Documentation](https://github.com/pyca/bcrypt/)
|
||||
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
||||
- [ADR-0001: Core Technology Stack](../../decisions/0001-core-technology-stack.md)
|
||||
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
|
||||
- [Data Model v0.1.0](../data-model.md)
|
||||
1017
docs/designs/v0.1.0/components/background-jobs.md
Normal file
1017
docs/designs/v0.1.0/components/background-jobs.md
Normal file
File diff suppressed because it is too large
Load Diff
732
docs/designs/v0.1.0/components/matching.md
Normal file
732
docs/designs/v0.1.0/components/matching.md
Normal file
@@ -0,0 +1,732 @@
|
||||
# Matching Component Design - v0.1.0
|
||||
|
||||
**Version**: 0.1.0
|
||||
**Date**: 2025-12-22
|
||||
**Status**: Initial Design
|
||||
|
||||
## Introduction
|
||||
|
||||
This document defines the Secret Santa matching algorithm for Sneaky Klaus. The matching algorithm is responsible for assigning each participant a recipient while respecting exclusion rules and following Secret Santa best practices.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **One-to-One Assignment**: Each participant gives exactly one gift and receives exactly one gift
|
||||
2. **No Self-Matching**: No participant is assigned to themselves
|
||||
3. **Exclusion Compliance**: All exclusion rules must be honored
|
||||
4. **Randomization**: Assignments must be unpredictable and fair
|
||||
5. **Single Cycle Preferred**: When possible, create a single cycle (A→B→C→...→Z→A)
|
||||
6. **Validation**: Detect impossible matching scenarios before attempting
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
1. **Performance**: Complete matching in <1 second for up to 100 participants
|
||||
2. **Reliability**: Deterministic failure detection (no random timeouts)
|
||||
3. **Transparency**: Clear error messages when matching fails
|
||||
4. **Testability**: Algorithm must be unit-testable with reproducible results
|
||||
|
||||
## Algorithm Overview
|
||||
|
||||
The matching algorithm uses a **graph-based approach with randomized cycle generation**.
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Trigger Matching]) --> Validate[Validate Preconditions]
|
||||
Validate -->|Invalid| Error1[Return Error: Validation Failed]
|
||||
Validate -->|Valid| BuildGraph[Build Assignment Graph]
|
||||
BuildGraph --> CheckFeasibility[Check Matching Feasibility]
|
||||
CheckFeasibility -->|Impossible| Error2[Return Error: Impossible to Match]
|
||||
CheckFeasibility -->|Possible| Attempt[Attempt to Find Valid Cycle]
|
||||
Attempt --> MaxAttempts{Max attempts<br/>reached?}
|
||||
MaxAttempts -->|Yes| Error3[Return Error: Could Not Find Match]
|
||||
MaxAttempts -->|No| GenerateCycle[Generate Random Cycle]
|
||||
GenerateCycle --> ValidateCycle{Cycle valid?}
|
||||
ValidateCycle -->|No| MaxAttempts
|
||||
ValidateCycle -->|Yes| CreateMatches[Create Match Records]
|
||||
CreateMatches --> SendNotifications[Send Notifications]
|
||||
SendNotifications --> Success([Matching Complete])
|
||||
|
||||
Error1 --> End([End])
|
||||
Error2 --> End
|
||||
Error3 --> End
|
||||
Success --> End
|
||||
```
|
||||
|
||||
## Precondition Validation
|
||||
|
||||
Before attempting matching, validate the following:
|
||||
|
||||
### Validation Checks
|
||||
|
||||
```python
|
||||
def validate_matching_preconditions(exchange_id: int) -> ValidationResult:
|
||||
"""
|
||||
Validate that exchange is ready for matching.
|
||||
|
||||
Returns:
|
||||
ValidationResult with is_valid and error_message
|
||||
"""
|
||||
checks = [
|
||||
check_exchange_state(exchange_id),
|
||||
check_minimum_participants(exchange_id),
|
||||
check_no_withdrawn_participants(exchange_id),
|
||||
check_graph_connectivity(exchange_id)
|
||||
]
|
||||
|
||||
for check in checks:
|
||||
if not check.is_valid:
|
||||
return check
|
||||
|
||||
return ValidationResult(is_valid=True)
|
||||
```
|
||||
|
||||
### Check 1: Exchange State
|
||||
|
||||
**Rule**: Exchange must be in "registration_closed" state
|
||||
|
||||
**Error Message**: "Exchange is not ready for matching. Please close registration first."
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
def check_exchange_state(exchange_id: int) -> ValidationResult:
|
||||
exchange = Exchange.query.get(exchange_id)
|
||||
if exchange.state != ExchangeState.REGISTRATION_CLOSED:
|
||||
return ValidationResult(
|
||||
is_valid=False,
|
||||
error_message="Exchange is not ready for matching. Please close registration first."
|
||||
)
|
||||
return ValidationResult(is_valid=True)
|
||||
```
|
||||
|
||||
### Check 2: Minimum Participants
|
||||
|
||||
**Rule**: At least 3 non-withdrawn participants required
|
||||
|
||||
**Error Message**: "At least 3 participants are required for matching. Current count: {count}"
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
def check_minimum_participants(exchange_id: int) -> ValidationResult:
|
||||
participants = Participant.query.filter_by(
|
||||
exchange_id=exchange_id,
|
||||
withdrawn_at=None
|
||||
).all()
|
||||
|
||||
if len(participants) < 3:
|
||||
return ValidationResult(
|
||||
is_valid=False,
|
||||
error_message=f"At least 3 participants are required for matching. Current count: {len(participants)}"
|
||||
)
|
||||
return ValidationResult(is_valid=True)
|
||||
```
|
||||
|
||||
### Check 3: Graph Connectivity
|
||||
|
||||
**Rule**: Exclusion rules must not make matching impossible
|
||||
|
||||
**Error Message**: Specific message based on connectivity issue
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
def check_graph_connectivity(exchange_id: int) -> ValidationResult:
|
||||
"""
|
||||
Check if a valid Hamiltonian cycle is theoretically possible.
|
||||
This doesn't guarantee a cycle exists, but rules out obvious impossibilities.
|
||||
"""
|
||||
participants = get_active_participants(exchange_id)
|
||||
exclusions = get_exclusions(exchange_id)
|
||||
|
||||
# Build graph where edge (A, B) exists if A can give to B
|
||||
graph = build_assignment_graph(participants, exclusions)
|
||||
|
||||
# Check 1: Each participant must have at least one possible recipient
|
||||
for participant in participants:
|
||||
possible_recipients = graph.get_outgoing_edges(participant.id)
|
||||
if len(possible_recipients) == 0:
|
||||
return ValidationResult(
|
||||
is_valid=False,
|
||||
error_message=f"Participant '{participant.name}' has no valid recipients. Please adjust exclusion rules."
|
||||
)
|
||||
|
||||
# Check 2: Each participant must be a possible recipient for at least one other
|
||||
for participant in participants:
|
||||
possible_givers = graph.get_incoming_edges(participant.id)
|
||||
if len(possible_givers) == 0:
|
||||
return ValidationResult(
|
||||
is_valid=False,
|
||||
error_message=f"Participant '{participant.name}' cannot receive from anyone. Please adjust exclusion rules."
|
||||
)
|
||||
|
||||
# Check 3: Detect common impossible scenarios
|
||||
# Example: In a group of 3, if A excludes B, B excludes C, C excludes A,
|
||||
# no valid cycle exists
|
||||
if not has_potential_hamiltonian_cycle(graph):
|
||||
return ValidationResult(
|
||||
is_valid=False,
|
||||
error_message="The current exclusion rules make a valid assignment impossible. Please reduce the number of exclusions."
|
||||
)
|
||||
|
||||
return ValidationResult(is_valid=True)
|
||||
```
|
||||
|
||||
## Assignment Graph Construction
|
||||
|
||||
### Graph Representation
|
||||
|
||||
Build a directed graph where:
|
||||
- **Nodes**: Participants
|
||||
- **Edges**: Valid assignments (A→B means A can give to B)
|
||||
|
||||
**Edge exists if**:
|
||||
- A ≠ B (no self-matching)
|
||||
- No exclusion rule prevents A→B
|
||||
|
||||
### Implementation
|
||||
|
||||
```python
|
||||
class AssignmentGraph:
|
||||
"""Directed graph representing valid gift assignments."""
|
||||
|
||||
def __init__(self, participants: list[Participant], exclusions: list[ExclusionRule]):
|
||||
self.nodes = {p.id: p for p in participants}
|
||||
self.edges = {} # {giver_id: [receiver_id, ...]}
|
||||
self._build_graph(participants, exclusions)
|
||||
|
||||
def _build_graph(self, participants, exclusions):
|
||||
"""Build adjacency list with exclusion rules applied."""
|
||||
# Build exclusion lookup (bidirectional)
|
||||
excluded_pairs = set()
|
||||
for exclusion in exclusions:
|
||||
excluded_pairs.add((exclusion.participant_a_id, exclusion.participant_b_id))
|
||||
excluded_pairs.add((exclusion.participant_b_id, exclusion.participant_a_id))
|
||||
|
||||
# Build edges
|
||||
for giver in participants:
|
||||
self.edges[giver.id] = []
|
||||
for receiver in participants:
|
||||
# Can assign if: not self and not excluded
|
||||
if giver.id != receiver.id and (giver.id, receiver.id) not in excluded_pairs:
|
||||
self.edges[giver.id].append(receiver.id)
|
||||
|
||||
def get_outgoing_edges(self, node_id: int) -> list[int]:
|
||||
"""Get all possible recipients for a giver."""
|
||||
return self.edges.get(node_id, [])
|
||||
|
||||
def get_incoming_edges(self, node_id: int) -> list[int]:
|
||||
"""Get all possible givers for a receiver."""
|
||||
incoming = []
|
||||
for giver_id, receivers in self.edges.items():
|
||||
if node_id in receivers:
|
||||
incoming.append(giver_id)
|
||||
return incoming
|
||||
```
|
||||
|
||||
## Cycle Generation Algorithm
|
||||
|
||||
### Strategy: Randomized Hamiltonian Cycle Search
|
||||
|
||||
Goal: Find a Hamiltonian cycle (visits each node exactly once) in the assignment graph.
|
||||
|
||||
**Why Single Cycle?**
|
||||
- Ensures everyone gives and receives exactly once
|
||||
- Prevents orphaned participants or small isolated loops
|
||||
- Traditional Secret Santa structure
|
||||
|
||||
### Algorithm: Randomized Backtracking with Early Termination
|
||||
|
||||
```python
|
||||
def generate_random_cycle(graph: AssignmentGraph, max_attempts: int = 100) -> Optional[list[tuple[int, int]]]:
|
||||
"""
|
||||
Attempt to find a valid Hamiltonian cycle.
|
||||
|
||||
Args:
|
||||
graph: Assignment graph with nodes and edges
|
||||
max_attempts: Maximum number of randomized attempts
|
||||
|
||||
Returns:
|
||||
List of (giver_id, receiver_id) tuples representing the cycle,
|
||||
or None if no cycle found within max_attempts
|
||||
"""
|
||||
nodes = list(graph.nodes.keys())
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
# Randomize starting point and node order for variety
|
||||
random.shuffle(nodes)
|
||||
start_node = nodes[0]
|
||||
|
||||
cycle = _backtrack_cycle(graph, start_node, nodes, [], set())
|
||||
|
||||
if cycle is not None:
|
||||
return cycle
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _backtrack_cycle(
|
||||
graph: AssignmentGraph,
|
||||
current_node: int,
|
||||
all_nodes: list[int],
|
||||
path: list[int],
|
||||
visited: set[int]
|
||||
) -> Optional[list[tuple[int, int]]]:
|
||||
"""
|
||||
Recursive backtracking to find Hamiltonian cycle.
|
||||
|
||||
Args:
|
||||
current_node: Current node being processed
|
||||
all_nodes: All nodes in graph
|
||||
path: Current path taken
|
||||
visited: Set of visited nodes
|
||||
|
||||
Returns:
|
||||
List of edges forming cycle, or None if no cycle from this path
|
||||
"""
|
||||
# Add current node to path
|
||||
path.append(current_node)
|
||||
visited.add(current_node)
|
||||
|
||||
# Base case: All nodes visited
|
||||
if len(visited) == len(all_nodes):
|
||||
# Check if we can return to start (complete the cycle)
|
||||
start_node = path[0]
|
||||
if start_node in graph.get_outgoing_edges(current_node):
|
||||
# Success! Build edge list
|
||||
edges = []
|
||||
for i in range(len(path)):
|
||||
giver = path[i]
|
||||
receiver = path[(i + 1) % len(path)] # Wrap around for cycle
|
||||
edges.append((giver, receiver))
|
||||
return edges
|
||||
else:
|
||||
# Can't complete cycle, backtrack
|
||||
path.pop()
|
||||
visited.remove(current_node)
|
||||
return None
|
||||
|
||||
# Recursive case: Try each unvisited neighbor
|
||||
neighbors = graph.get_outgoing_edges(current_node)
|
||||
random.shuffle(neighbors) # Randomize for variety
|
||||
|
||||
for neighbor in neighbors:
|
||||
if neighbor not in visited:
|
||||
result = _backtrack_cycle(graph, neighbor, all_nodes, path, visited)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# No valid path found, backtrack
|
||||
path.pop()
|
||||
visited.remove(current_node)
|
||||
return None
|
||||
```
|
||||
|
||||
### Algorithm Complexity
|
||||
|
||||
**Time Complexity**:
|
||||
- Worst case: O(n!) for Hamiltonian cycle problem (NP-complete)
|
||||
- In practice: O(n²) to O(n³) for typical Secret Santa scenarios
|
||||
- Max attempts limit prevents excessive computation
|
||||
|
||||
**Space Complexity**: O(n) for recursion stack and visited set
|
||||
|
||||
### Why Randomization?
|
||||
|
||||
1. **Fairness**: Each valid assignment has equal probability
|
||||
2. **Unpredictability**: Prevents gaming the system
|
||||
3. **Variety**: Re-matching produces different results
|
||||
|
||||
## Validation & Error Handling
|
||||
|
||||
### Cycle Validation
|
||||
|
||||
After generating a cycle, validate it before creating database records:
|
||||
|
||||
```python
|
||||
def validate_cycle(cycle: list[tuple[int, int]], graph: AssignmentGraph) -> ValidationResult:
|
||||
"""
|
||||
Validate that cycle is valid.
|
||||
|
||||
Checks:
|
||||
1. Each node appears exactly once as giver
|
||||
2. Each node appears exactly once as receiver
|
||||
3. All edges exist in graph (no exclusions violated)
|
||||
4. No self-assignments
|
||||
"""
|
||||
givers = set()
|
||||
receivers = set()
|
||||
|
||||
for giver_id, receiver_id in cycle:
|
||||
# Check for duplicates
|
||||
if giver_id in givers:
|
||||
return ValidationResult(is_valid=False, error_message=f"Duplicate giver: {giver_id}")
|
||||
if receiver_id in receivers:
|
||||
return ValidationResult(is_valid=False, error_message=f"Duplicate receiver: {receiver_id}")
|
||||
|
||||
givers.add(giver_id)
|
||||
receivers.add(receiver_id)
|
||||
|
||||
# Check no self-assignment
|
||||
if giver_id == receiver_id:
|
||||
return ValidationResult(is_valid=False, error_message="Self-assignment detected")
|
||||
|
||||
# Check edge exists (no exclusion violated)
|
||||
if receiver_id not in graph.get_outgoing_edges(giver_id):
|
||||
return ValidationResult(is_valid=False, error_message=f"Invalid assignment: {giver_id} → {receiver_id}")
|
||||
|
||||
# Check all nodes present
|
||||
if givers != set(graph.nodes.keys()) or receivers != set(graph.nodes.keys()):
|
||||
return ValidationResult(is_valid=False, error_message="Not all participants included in cycle")
|
||||
|
||||
return ValidationResult(is_valid=True)
|
||||
```
|
||||
|
||||
### Error Scenarios
|
||||
|
||||
| Scenario | Detection | Error Message |
|
||||
|----------|-----------|---------------|
|
||||
| Too few participants | Precondition check | "At least 3 participants required" |
|
||||
| Participant isolated by exclusions | Graph connectivity check | "Participant '{name}' has no valid recipients" |
|
||||
| Too many exclusions | Graph connectivity check | "Current exclusion rules make matching impossible" |
|
||||
| Cannot find cycle | Max attempts reached | "Unable to find valid assignment. Try reducing exclusions." |
|
||||
| Invalid state | Precondition check | "Exchange is not ready for matching" |
|
||||
|
||||
## Database Transaction
|
||||
|
||||
Matching operation must be atomic (all-or-nothing):
|
||||
|
||||
```python
|
||||
def execute_matching(exchange_id: int) -> MatchingResult:
|
||||
"""
|
||||
Execute complete matching operation within transaction.
|
||||
|
||||
Returns:
|
||||
MatchingResult with success status, matches, or error message
|
||||
"""
|
||||
from sqlalchemy import orm
|
||||
|
||||
# Begin transaction
|
||||
with db.session.begin_nested():
|
||||
try:
|
||||
# 1. Validate preconditions
|
||||
validation = validate_matching_preconditions(exchange_id)
|
||||
if not validation.is_valid:
|
||||
return MatchingResult(success=False, error=validation.error_message)
|
||||
|
||||
# 2. Get participants and exclusions
|
||||
participants = get_active_participants(exchange_id)
|
||||
exclusions = get_exclusions(exchange_id)
|
||||
|
||||
# 3. Build graph
|
||||
graph = AssignmentGraph(participants, exclusions)
|
||||
|
||||
# 4. Generate cycle
|
||||
cycle = generate_random_cycle(graph, max_attempts=100)
|
||||
if cycle is None:
|
||||
return MatchingResult(
|
||||
success=False,
|
||||
error="Unable to find valid assignment. Please reduce exclusion rules or add more participants."
|
||||
)
|
||||
|
||||
# 5. Validate cycle
|
||||
validation = validate_cycle(cycle, graph)
|
||||
if not validation.is_valid:
|
||||
return MatchingResult(success=False, error=validation.error_message)
|
||||
|
||||
# 6. Create match records
|
||||
matches = []
|
||||
for giver_id, receiver_id in cycle:
|
||||
match = Match(
|
||||
exchange_id=exchange_id,
|
||||
giver_id=giver_id,
|
||||
receiver_id=receiver_id
|
||||
)
|
||||
db.session.add(match)
|
||||
matches.append(match)
|
||||
|
||||
# 7. Update exchange state
|
||||
exchange = Exchange.query.get(exchange_id)
|
||||
exchange.state = ExchangeState.MATCHED
|
||||
|
||||
# Commit nested transaction
|
||||
db.session.commit()
|
||||
|
||||
return MatchingResult(success=True, matches=matches)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Matching failed for exchange {exchange_id}: {str(e)}")
|
||||
return MatchingResult(
|
||||
success=False,
|
||||
error="An unexpected error occurred during matching. Please try again."
|
||||
)
|
||||
```
|
||||
|
||||
## Re-Matching
|
||||
|
||||
When admin triggers re-match, all existing matches must be cleared:
|
||||
|
||||
```python
|
||||
def execute_rematching(exchange_id: int) -> MatchingResult:
|
||||
"""
|
||||
Clear existing matches and generate new assignments.
|
||||
"""
|
||||
with db.session.begin_nested():
|
||||
try:
|
||||
# 1. Validate exchange is in matched state
|
||||
exchange = Exchange.query.get(exchange_id)
|
||||
if exchange.state != ExchangeState.MATCHED:
|
||||
return MatchingResult(success=False, error="Exchange is not in matched state")
|
||||
|
||||
# 2. Delete existing matches
|
||||
Match.query.filter_by(exchange_id=exchange_id).delete()
|
||||
|
||||
# 3. Revert state to registration_closed
|
||||
exchange.state = ExchangeState.REGISTRATION_CLOSED
|
||||
db.session.flush()
|
||||
|
||||
# 4. Run matching again
|
||||
result = execute_matching(exchange_id)
|
||||
|
||||
if result.success:
|
||||
db.session.commit()
|
||||
else:
|
||||
db.session.rollback()
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Re-matching failed for exchange {exchange_id}: {str(e)}")
|
||||
return MatchingResult(success=False, error="Re-matching failed. Please try again.")
|
||||
```
|
||||
|
||||
## Integration with Notification Service
|
||||
|
||||
After successful matching, trigger notifications:
|
||||
|
||||
```python
|
||||
def complete_matching_workflow(exchange_id: int) -> WorkflowResult:
|
||||
"""
|
||||
Complete matching and send notifications.
|
||||
"""
|
||||
# Execute matching
|
||||
matching_result = execute_matching(exchange_id)
|
||||
|
||||
if not matching_result.success:
|
||||
return WorkflowResult(success=False, error=matching_result.error)
|
||||
|
||||
# Send notifications to all participants
|
||||
try:
|
||||
notification_service = NotificationService()
|
||||
notification_service.send_match_notifications(exchange_id)
|
||||
|
||||
# Notify admin (if enabled)
|
||||
notification_service.send_admin_notification(
|
||||
exchange_id,
|
||||
NotificationType.MATCHING_COMPLETE
|
||||
)
|
||||
|
||||
return WorkflowResult(success=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send match notifications for exchange {exchange_id}: {str(e)}")
|
||||
# Matching succeeded but notification failed
|
||||
# Return success but log the notification failure
|
||||
return WorkflowResult(
|
||||
success=True,
|
||||
warning="Matching complete but some notifications failed to send. Please check email service."
|
||||
)
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```python
|
||||
class TestMatchingAlgorithm(unittest.TestCase):
|
||||
|
||||
def test_minimum_viable_matching(self):
|
||||
"""Test matching with 3 participants, no exclusions."""
|
||||
participants = create_test_participants(3)
|
||||
exclusions = []
|
||||
graph = AssignmentGraph(participants, exclusions)
|
||||
cycle = generate_random_cycle(graph)
|
||||
|
||||
self.assertIsNotNone(cycle)
|
||||
self.assertEqual(len(cycle), 3)
|
||||
validation = validate_cycle(cycle, graph)
|
||||
self.assertTrue(validation.is_valid)
|
||||
|
||||
def test_matching_with_exclusions(self):
|
||||
"""Test matching with valid exclusions."""
|
||||
participants = create_test_participants(5)
|
||||
exclusions = [
|
||||
create_exclusion(participants[0], participants[1])
|
||||
]
|
||||
graph = AssignmentGraph(participants, exclusions)
|
||||
cycle = generate_random_cycle(graph)
|
||||
|
||||
self.assertIsNotNone(cycle)
|
||||
# Verify exclusion is respected
|
||||
for giver_id, receiver_id in cycle:
|
||||
self.assertNotEqual((giver_id, receiver_id), (participants[0].id, participants[1].id))
|
||||
|
||||
def test_impossible_matching_detected(self):
|
||||
"""Test that impossible matching is detected in validation."""
|
||||
# Create 3 participants where each excludes the next
|
||||
# A excludes B, B excludes C, C excludes A
|
||||
# No Hamiltonian cycle possible
|
||||
participants = create_test_participants(3)
|
||||
exclusions = [
|
||||
create_exclusion(participants[0], participants[1]),
|
||||
create_exclusion(participants[1], participants[2]),
|
||||
create_exclusion(participants[2], participants[0])
|
||||
]
|
||||
|
||||
graph = AssignmentGraph(participants, exclusions)
|
||||
validation = check_graph_connectivity_with_graph(graph)
|
||||
self.assertFalse(validation.is_valid)
|
||||
|
||||
def test_no_self_matching(self):
|
||||
"""Ensure no participant is matched to themselves."""
|
||||
participants = create_test_participants(10)
|
||||
exclusions = []
|
||||
graph = AssignmentGraph(participants, exclusions)
|
||||
cycle = generate_random_cycle(graph)
|
||||
|
||||
for giver_id, receiver_id in cycle:
|
||||
self.assertNotEqual(giver_id, receiver_id)
|
||||
|
||||
def test_everyone_gives_and_receives_once(self):
|
||||
"""Ensure each participant gives and receives exactly once."""
|
||||
participants = create_test_participants(10)
|
||||
exclusions = []
|
||||
graph = AssignmentGraph(participants, exclusions)
|
||||
cycle = generate_random_cycle(graph)
|
||||
|
||||
givers = set(giver for giver, _ in cycle)
|
||||
receivers = set(receiver for _, receiver in cycle)
|
||||
|
||||
self.assertEqual(len(givers), 10)
|
||||
self.assertEqual(len(receivers), 10)
|
||||
self.assertEqual(givers, {p.id for p in participants})
|
||||
self.assertEqual(receivers, {p.id for p in participants})
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```python
|
||||
class TestMatchingIntegration(TestCase):
|
||||
|
||||
def test_full_matching_workflow(self):
|
||||
"""Test complete matching workflow from database to notifications."""
|
||||
# Setup
|
||||
exchange = create_test_exchange()
|
||||
participants = [create_test_participant(exchange) for _ in range(5)]
|
||||
|
||||
# Execute
|
||||
result = complete_matching_workflow(exchange.id)
|
||||
|
||||
# Assert
|
||||
self.assertTrue(result.success)
|
||||
exchange = Exchange.query.get(exchange.id)
|
||||
self.assertEqual(exchange.state, ExchangeState.MATCHED)
|
||||
|
||||
matches = Match.query.filter_by(exchange_id=exchange.id).all()
|
||||
self.assertEqual(len(matches), 5)
|
||||
|
||||
def test_rematching_clears_old_matches(self):
|
||||
"""Test that re-matching replaces old assignments."""
|
||||
# Setup
|
||||
exchange = create_matched_exchange_with_5_participants()
|
||||
old_matches = Match.query.filter_by(exchange_id=exchange.id).all()
|
||||
old_match_ids = {m.id for m in old_matches}
|
||||
|
||||
# Execute
|
||||
result = execute_rematching(exchange.id)
|
||||
|
||||
# Assert
|
||||
self.assertTrue(result.success)
|
||||
new_matches = Match.query.filter_by(exchange_id=exchange.id).all()
|
||||
new_match_ids = {m.id for m in new_matches}
|
||||
|
||||
# Old match records should be deleted
|
||||
self.assertEqual(len(old_match_ids.intersection(new_match_ids)), 0)
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Expected Performance
|
||||
|
||||
| Participants | Exclusions | Expected Time | Notes |
|
||||
|--------------|------------|---------------|-------|
|
||||
| 3-10 | 0-5 | <10ms | Instant |
|
||||
| 10-50 | 0-20 | <100ms | Very fast |
|
||||
| 50-100 | 0-50 | <500ms | Fast enough |
|
||||
| 100+ | Any | Variable | May exceed max attempts |
|
||||
|
||||
### Optimization Strategies
|
||||
|
||||
1. **Graph Pruning**: Remove impossible edges early
|
||||
2. **Heuristic Ordering**: Start with most constrained nodes
|
||||
3. **Adaptive Max Attempts**: Increase attempts for larger groups
|
||||
4. **Fallback to Multiple Cycles**: If single cycle fails, allow 2-3 small cycles
|
||||
|
||||
### Max Attempts Configuration
|
||||
|
||||
```python
|
||||
def get_max_attempts(num_participants: int) -> int:
|
||||
"""Adaptive max attempts based on participant count."""
|
||||
if num_participants <= 10:
|
||||
return 50
|
||||
elif num_participants <= 50:
|
||||
return 100
|
||||
else:
|
||||
return 200
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Randomization Source
|
||||
|
||||
- Use `secrets.SystemRandom()` for cryptographic randomness
|
||||
- Prevents predictable assignments
|
||||
- Important for preventing manipulation
|
||||
|
||||
```python
|
||||
import secrets
|
||||
random_generator = secrets.SystemRandom()
|
||||
|
||||
def shuffle(items: list):
|
||||
"""Cryptographically secure shuffle."""
|
||||
random_generator.shuffle(items)
|
||||
```
|
||||
|
||||
### Match Confidentiality
|
||||
|
||||
- Matches only visible to:
|
||||
- Giver (sees their own recipient)
|
||||
- Admin (for troubleshooting)
|
||||
- Never expose matches in logs
|
||||
- Database queries filtered by permissions
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
|
||||
1. **Multi-Cycle Support**: Allow multiple small cycles if single cycle impossible
|
||||
2. **Preference Weighting**: Allow participants to indicate preferences
|
||||
3. **Historical Avoidance**: Avoid repeating matches from previous years
|
||||
4. **Couple Pairing**: Assign couples to same family/group
|
||||
5. **Performance Metrics**: Track matching time and success rate
|
||||
6. **Manual Override**: Allow admin to manually adjust specific assignments
|
||||
|
||||
## References
|
||||
|
||||
- [Hamiltonian Cycle Problem](https://en.wikipedia.org/wiki/Hamiltonian_path_problem)
|
||||
- [Graph Theory Basics](https://en.wikipedia.org/wiki/Graph_theory)
|
||||
- [Backtracking Algorithm](https://en.wikipedia.org/wiki/Backtracking)
|
||||
- [Data Model Specification](../data-model.md)
|
||||
- [API Specification](../api-spec.md)
|
||||
979
docs/designs/v0.1.0/components/notifications.md
Normal file
979
docs/designs/v0.1.0/components/notifications.md
Normal file
@@ -0,0 +1,979 @@
|
||||
# Notifications Component Design - v0.1.0
|
||||
|
||||
**Version**: 0.1.0
|
||||
**Date**: 2025-12-22
|
||||
**Status**: Initial Design
|
||||
|
||||
## Introduction
|
||||
|
||||
This document defines the email notification system for Sneaky Klaus. The notification service handles all transactional and reminder emails sent to participants and administrators using Resend as the email delivery provider.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **Transactional Emails**: Send immediate emails in response to user actions
|
||||
2. **Reminder Emails**: Send scheduled reminder emails before exchange date
|
||||
3. **Admin Notifications**: Notify admin of important exchange events (opt-in)
|
||||
4. **Magic Link Delivery**: Include authentication tokens in emails
|
||||
5. **Template Management**: Maintain consistent branded email templates
|
||||
6. **Error Handling**: Gracefully handle email delivery failures
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
1. **Reliability**: Guarantee delivery or retry on failure
|
||||
2. **Performance**: Send emails asynchronously without blocking requests
|
||||
3. **Auditability**: Log all email send attempts
|
||||
4. **Deliverability**: Follow email best practices (SPF, DKIM, unsubscribe links)
|
||||
|
||||
## Email Types
|
||||
|
||||
### Participant Emails
|
||||
|
||||
| Email Type | Trigger | Recipient | Time-Sensitive |
|
||||
|------------|---------|-----------|----------------|
|
||||
| Registration Confirmation | Participant registers | Participant | Yes (immediate) |
|
||||
| Magic Link | Participant requests access | Participant | Yes (immediate) |
|
||||
| Match Notification | Matching complete | All participants | Yes (immediate) |
|
||||
| Reminder Email | Scheduled (pre-exchange) | Opted-in participants | Yes (scheduled) |
|
||||
| Withdrawal Confirmation | Participant withdraws | Participant | Yes (immediate) |
|
||||
|
||||
### Admin Emails
|
||||
|
||||
| Email Type | Trigger | Recipient | Time-Sensitive |
|
||||
|------------|---------|-----------|----------------|
|
||||
| Password Reset | Admin requests reset | Admin | Yes (immediate) |
|
||||
| New Registration | Participant registers | Admin | No (opt-in) |
|
||||
| Participant Withdrawal | Participant withdraws | Admin | No (opt-in) |
|
||||
| Matching Complete | Matching succeeds | Admin | No (opt-in) |
|
||||
| Data Purge Warning | 7 days before purge | Admin | No |
|
||||
|
||||
## Notification Service Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "Application Layer"
|
||||
Route[Route Handler]
|
||||
Service[Business Logic]
|
||||
end
|
||||
|
||||
subgraph "Notification Service"
|
||||
NS[NotificationService]
|
||||
TemplateEngine[Template Renderer]
|
||||
EmailQueue[Email Queue]
|
||||
end
|
||||
|
||||
subgraph "External Services"
|
||||
Resend[Resend API]
|
||||
end
|
||||
|
||||
subgraph "Storage"
|
||||
DB[(Database)]
|
||||
Templates[Email Templates]
|
||||
end
|
||||
|
||||
Route --> Service
|
||||
Service --> NS
|
||||
NS --> TemplateEngine
|
||||
TemplateEngine --> Templates
|
||||
NS --> EmailQueue
|
||||
EmailQueue --> Resend
|
||||
NS --> DB
|
||||
Resend --> DB
|
||||
|
||||
style Resend fill:#f9f,stroke:#333
|
||||
style Templates fill:#bfb,stroke:#333
|
||||
```
|
||||
|
||||
## Implementation Structure
|
||||
|
||||
### Service Class
|
||||
|
||||
```python
|
||||
class NotificationService:
|
||||
"""
|
||||
Centralized service for all email notifications.
|
||||
"""
|
||||
|
||||
def __init__(self, resend_client: ResendClient = None):
|
||||
self.resend = resend_client or ResendClient(api_key=get_resend_api_key())
|
||||
self.template_renderer = EmailTemplateRenderer()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Participant Emails
|
||||
def send_registration_confirmation(self, participant_id: int) -> EmailResult
|
||||
def send_magic_link(self, participant_id: int, token: str) -> EmailResult
|
||||
def send_match_notification(self, participant_id: int) -> EmailResult
|
||||
def send_reminder_email(self, participant_id: int) -> EmailResult
|
||||
def send_withdrawal_confirmation(self, participant_id: int) -> EmailResult
|
||||
|
||||
# Admin Emails
|
||||
def send_password_reset(self, admin_email: str, token: str) -> EmailResult
|
||||
def send_admin_notification(self, exchange_id: int, notification_type: NotificationType) -> EmailResult
|
||||
def send_data_purge_warning(self, exchange_id: int) -> EmailResult
|
||||
|
||||
# Batch Operations
|
||||
def send_match_notifications_batch(self, exchange_id: int) -> BatchEmailResult
|
||||
|
||||
# Internal Methods
|
||||
def _send_email(self, email_request: EmailRequest) -> EmailResult
|
||||
def _log_email_send(self, email_request: EmailRequest, result: EmailResult)
|
||||
```
|
||||
|
||||
## Email Templates
|
||||
|
||||
### Template Structure
|
||||
|
||||
All email templates use Jinja2 with HTML and plain text versions:
|
||||
|
||||
**Directory Structure**:
|
||||
```
|
||||
templates/emails/
|
||||
├── base.html # Base template with header/footer
|
||||
├── base.txt # Plain text base
|
||||
├── participant/
|
||||
│ ├── registration_confirmation.html
|
||||
│ ├── registration_confirmation.txt
|
||||
│ ├── magic_link.html
|
||||
│ ├── magic_link.txt
|
||||
│ ├── match_notification.html
|
||||
│ ├── match_notification.txt
|
||||
│ ├── reminder.html
|
||||
│ ├── reminder.txt
|
||||
│ └── withdrawal_confirmation.html
|
||||
│ └── withdrawal_confirmation.txt
|
||||
└── admin/
|
||||
├── password_reset.html
|
||||
├── password_reset.txt
|
||||
├── new_registration.html
|
||||
├── new_registration.txt
|
||||
├── participant_withdrawal.html
|
||||
├── participant_withdrawal.txt
|
||||
├── matching_complete.html
|
||||
├── matching_complete.txt
|
||||
├── data_purge_warning.html
|
||||
└── data_purge_warning.txt
|
||||
```
|
||||
|
||||
### Base Template
|
||||
|
||||
**base.html**:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Sneaky Klaus{% endblock %}</title>
|
||||
<style>
|
||||
/* Inline CSS for email client compatibility */
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background-color: #d32f2f; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 30px; background-color: #f9f9f9; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #d32f2f; color: white; text-decoration: none; border-radius: 4px; }
|
||||
.footer { text-align: center; font-size: 12px; color: #666; padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎅 Sneaky Klaus</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>You received this email because you're part of a Sneaky Klaus Secret Santa exchange.</p>
|
||||
{% block unsubscribe %}{% endblock %}
|
||||
<p>© {{ current_year }} Sneaky Klaus</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**base.txt**:
|
||||
```text
|
||||
SNEAKY KLAUS
|
||||
=============
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
---
|
||||
You received this email because you're part of a Sneaky Klaus Secret Santa exchange.
|
||||
{% block unsubscribe %}{% endblock %}
|
||||
|
||||
© {{ current_year }} Sneaky Klaus
|
||||
```
|
||||
|
||||
## Participant Email Specifications
|
||||
|
||||
### 1. Registration Confirmation
|
||||
|
||||
**Trigger**: Immediately after participant registration
|
||||
|
||||
**Subject**: "Welcome to {exchange_name}!"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"participant_name": str,
|
||||
"exchange_name": str,
|
||||
"exchange_date": datetime,
|
||||
"budget": str,
|
||||
"magic_link_url": str,
|
||||
"app_url": str
|
||||
}
|
||||
```
|
||||
|
||||
**Content** (HTML version):
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>Welcome to {{ exchange_name }}!</h2>
|
||||
|
||||
<p>Hi {{ participant_name }},</p>
|
||||
|
||||
<p>You've successfully registered for the Secret Santa exchange!</p>
|
||||
|
||||
<p><strong>Exchange Details:</strong></p>
|
||||
<ul>
|
||||
<li><strong>Event Date:</strong> {{ exchange_date|format_date }}</li>
|
||||
<li><strong>Gift Budget:</strong> {{ budget }}</li>
|
||||
</ul>
|
||||
|
||||
<p>You can update your gift ideas or view participant information anytime using the link below:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ magic_link_url }}" class="button">Access My Registration</a>
|
||||
</p>
|
||||
|
||||
<p><small>This link will expire in 1 hour. You can request a new one anytime from the registration page.</small></p>
|
||||
|
||||
<p>When participants are matched, you'll receive another email with your Secret Santa assignment.</p>
|
||||
|
||||
<p>Happy gifting!</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
**Plain Text Version**: Similar content without HTML formatting
|
||||
|
||||
---
|
||||
|
||||
### 2. Magic Link
|
||||
|
||||
**Trigger**: Participant requests access to registration
|
||||
|
||||
**Subject**: "Access Your Sneaky Klaus Registration"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"participant_name": str,
|
||||
"exchange_name": str,
|
||||
"magic_link_url": str,
|
||||
"expiration_minutes": int # 60
|
||||
}
|
||||
```
|
||||
|
||||
**Content**:
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>Access Your Registration</h2>
|
||||
|
||||
<p>Hi {{ participant_name }},</p>
|
||||
|
||||
<p>You requested access to your registration for <strong>{{ exchange_name }}</strong>.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ magic_link_url }}" class="button">Access My Registration</a>
|
||||
</p>
|
||||
|
||||
<p><small>This link will expire in {{ expiration_minutes }} minutes and can only be used once.</small></p>
|
||||
|
||||
<p>If you didn't request this link, you can safely ignore this email.</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Match Notification
|
||||
|
||||
**Trigger**: Matching complete (sent to all participants)
|
||||
|
||||
**Subject**: "Your Secret Santa Assignment for {exchange_name}"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"participant_name": str,
|
||||
"exchange_name": str,
|
||||
"exchange_date": datetime,
|
||||
"budget": str,
|
||||
"recipient_name": str,
|
||||
"recipient_gift_ideas": str,
|
||||
"magic_link_url": str,
|
||||
"participant_count": int
|
||||
}
|
||||
```
|
||||
|
||||
**Content**:
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>Your Secret Santa Assignment</h2>
|
||||
|
||||
<p>Hi {{ participant_name }},</p>
|
||||
|
||||
<p>Participants have been matched for <strong>{{ exchange_name }}</strong>! 🎁</p>
|
||||
|
||||
<div style="background-color: white; padding: 20px; border-radius: 8px; border-left: 4px solid #d32f2f; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">You're buying for:</h3>
|
||||
<p style="font-size: 18px; font-weight: bold; margin: 10px 0;">{{ recipient_name }}</p>
|
||||
|
||||
{% if recipient_gift_ideas %}
|
||||
<p><strong>Gift Ideas:</strong></p>
|
||||
<p style="white-space: pre-wrap;">{{ recipient_gift_ideas }}</p>
|
||||
{% else %}
|
||||
<p><em>No gift ideas provided yet.</em></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p><strong>Exchange Details:</strong></p>
|
||||
<ul>
|
||||
<li><strong>Gift Budget:</strong> {{ budget }}</li>
|
||||
<li><strong>Exchange Date:</strong> {{ exchange_date|format_date }}</li>
|
||||
<li><strong>Total Participants:</strong> {{ participant_count }}</li>
|
||||
</ul>
|
||||
|
||||
<p>You can view this information anytime by clicking the link below:</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ magic_link_url }}" class="button">View My Assignment</a>
|
||||
</p>
|
||||
|
||||
<p><strong>Remember:</strong> Keep your assignment secret! The fun is in the surprise. 🤫</p>
|
||||
|
||||
<p>Happy shopping!</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Reminder Email
|
||||
|
||||
**Trigger**: Scheduled (based on admin configuration, e.g., 7 days, 3 days, 1 day before exchange)
|
||||
|
||||
**Subject**: "Reminder: {exchange_name} is {days_until} days away!"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"participant_name": str,
|
||||
"exchange_name": str,
|
||||
"exchange_date": datetime,
|
||||
"days_until": int,
|
||||
"recipient_name": str,
|
||||
"recipient_gift_ideas": str,
|
||||
"budget": str,
|
||||
"magic_link_url": str
|
||||
}
|
||||
```
|
||||
|
||||
**Content**:
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>Don't Forget! {{ exchange_name }} is Coming Up</h2>
|
||||
|
||||
<p>Hi {{ participant_name }},</p>
|
||||
|
||||
<p>This is a friendly reminder that <strong>{{ exchange_name }}</strong> is only <strong>{{ days_until }} day{{ 's' if days_until != 1 else '' }}</strong> away!</p>
|
||||
|
||||
<p>You're buying for: <strong>{{ recipient_name }}</strong></p>
|
||||
|
||||
{% if recipient_gift_ideas %}
|
||||
<p><strong>Their Gift Ideas:</strong></p>
|
||||
<p style="white-space: pre-wrap; background-color: white; padding: 15px; border-radius: 4px;">{{ recipient_gift_ideas }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p><strong>Gift Budget:</strong> {{ budget }}</p>
|
||||
<p><strong>Exchange Date:</strong> {{ exchange_date|format_date }}</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ magic_link_url }}" class="button">View Full Details</a>
|
||||
</p>
|
||||
|
||||
<p>Happy shopping! 🎁</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block unsubscribe %}
|
||||
<p><a href="{{ unsubscribe_url }}">Don't want reminders? Update your preferences</a></p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Withdrawal Confirmation
|
||||
|
||||
**Trigger**: Participant withdraws from exchange
|
||||
|
||||
**Subject**: "You've withdrawn from {exchange_name}"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"participant_name": str,
|
||||
"exchange_name": str
|
||||
}
|
||||
```
|
||||
|
||||
**Content**:
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>Withdrawal Confirmed</h2>
|
||||
|
||||
<p>Hi {{ participant_name }},</p>
|
||||
|
||||
<p>You've successfully withdrawn from <strong>{{ exchange_name }}</strong>.</p>
|
||||
|
||||
<p>You will no longer receive any emails about this exchange.</p>
|
||||
|
||||
<p>If this was a mistake, please contact the exchange organizer to re-register.</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
## Admin Email Specifications
|
||||
|
||||
### 1. Password Reset
|
||||
|
||||
**Trigger**: Admin requests password reset
|
||||
|
||||
**Subject**: "Password Reset Request - Sneaky Klaus"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"reset_link_url": str,
|
||||
"expiration_minutes": int # 60
|
||||
}
|
||||
```
|
||||
|
||||
**Content**:
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>Password Reset Request</h2>
|
||||
|
||||
<p>You requested a password reset for your Sneaky Klaus admin account.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ reset_link_url }}" class="button">Reset Password</a>
|
||||
</p>
|
||||
|
||||
<p><small>This link will expire in {{ expiration_minutes }} minutes and can only be used once.</small></p>
|
||||
|
||||
<p>If you didn't request this reset, you can safely ignore this email. Your password will not be changed.</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. New Registration (Admin Notification)
|
||||
|
||||
**Trigger**: Participant registers (if admin has enabled this notification)
|
||||
|
||||
**Subject**: "New Participant in {exchange_name}"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"exchange_name": str,
|
||||
"participant_name": str,
|
||||
"participant_email": str,
|
||||
"participant_count": int,
|
||||
"max_participants": int,
|
||||
"exchange_url": str
|
||||
}
|
||||
```
|
||||
|
||||
**Content**:
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>New Participant Registered</h2>
|
||||
|
||||
<p>A new participant has joined <strong>{{ exchange_name }}</strong>:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Name:</strong> {{ participant_name }}</li>
|
||||
<li><strong>Email:</strong> {{ participant_email }}</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Participant Count:</strong> {{ participant_count }} / {{ max_participants }}</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ exchange_url }}" class="button">View Exchange</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Participant Withdrawal (Admin Notification)
|
||||
|
||||
**Trigger**: Participant withdraws (if admin has enabled this notification)
|
||||
|
||||
**Subject**: "Participant Withdrew from {exchange_name}"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"exchange_name": str,
|
||||
"participant_name": str,
|
||||
"participant_count": int,
|
||||
"exchange_url": str
|
||||
}
|
||||
```
|
||||
|
||||
**Content**:
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>Participant Withdrew</h2>
|
||||
|
||||
<p><strong>{{ participant_name }}</strong> has withdrawn from <strong>{{ exchange_name }}</strong>.</p>
|
||||
|
||||
<p><strong>Remaining Participants:</strong> {{ participant_count }}</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ exchange_url }}" class="button">View Exchange</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Matching Complete (Admin Notification)
|
||||
|
||||
**Trigger**: Matching succeeds (if admin has enabled this notification)
|
||||
|
||||
**Subject**: "Matching Complete for {exchange_name}"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"exchange_name": str,
|
||||
"participant_count": int,
|
||||
"exchange_date": datetime,
|
||||
"exchange_url": str
|
||||
}
|
||||
```
|
||||
|
||||
**Content**:
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>Matching Complete! 🎉</h2>
|
||||
|
||||
<p>Participants have been successfully matched for <strong>{{ exchange_name }}</strong>.</p>
|
||||
|
||||
<p><strong>Details:</strong></p>
|
||||
<ul>
|
||||
<li><strong>Participants Matched:</strong> {{ participant_count }}</li>
|
||||
<li><strong>Exchange Date:</strong> {{ exchange_date|format_date }}</li>
|
||||
</ul>
|
||||
|
||||
<p>All participants have been notified of their assignments via email.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ exchange_url }}" class="button">View Exchange</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Data Purge Warning
|
||||
|
||||
**Trigger**: 7 days before exchange data is purged (30 days after completion)
|
||||
|
||||
**Subject**: "Data Purge Scheduled for {exchange_name}"
|
||||
|
||||
**Template Variables**:
|
||||
```python
|
||||
{
|
||||
"exchange_name": str,
|
||||
"purge_date": datetime,
|
||||
"days_until_purge": int,
|
||||
"exchange_url": str
|
||||
}
|
||||
```
|
||||
|
||||
**Content**:
|
||||
```html
|
||||
{% extends "emails/base.html" %}
|
||||
{% block content %}
|
||||
<h2>Data Purge Scheduled</h2>
|
||||
|
||||
<p>The exchange <strong>{{ exchange_name }}</strong> will be automatically deleted in <strong>{{ days_until_purge }} days</strong> ({{ purge_date|format_date }}).</p>
|
||||
|
||||
<p>All participant data, matches, and exchange details will be permanently removed as per the 30-day retention policy.</p>
|
||||
|
||||
<p>If you need to keep this data, please export it before the purge date.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ exchange_url }}" class="button">View Exchange</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
## Resend Integration
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
import resend
|
||||
|
||||
class ResendClient:
|
||||
"""Wrapper for Resend API."""
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
resend.api_key = api_key
|
||||
self.from_email = "noreply@sneakyklaus.app" # Configured domain
|
||||
self.from_name = "Sneaky Klaus"
|
||||
|
||||
def send_email(self, request: EmailRequest) -> EmailResult:
|
||||
"""
|
||||
Send email via Resend API.
|
||||
|
||||
Args:
|
||||
request: EmailRequest with to, subject, html, text
|
||||
|
||||
Returns:
|
||||
EmailResult with success status and message ID
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"from": f"{self.from_name} <{self.from_email}>",
|
||||
"to": [request.to_email],
|
||||
"subject": request.subject,
|
||||
"html": request.html_body,
|
||||
"text": request.text_body,
|
||||
}
|
||||
|
||||
# Optional: Add tags for tracking
|
||||
if request.tags:
|
||||
params["tags"] = request.tags
|
||||
|
||||
response = resend.Emails.send(params)
|
||||
|
||||
return EmailResult(
|
||||
success=True,
|
||||
message_id=response["id"],
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
except resend.exceptions.ResendError as e:
|
||||
logger.error(f"Resend API error: {str(e)}")
|
||||
return EmailResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error sending email: {str(e)}")
|
||||
return EmailResult(
|
||||
success=False,
|
||||
error="Internal error",
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
```
|
||||
|
||||
### Email Request Model
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EmailRequest:
|
||||
"""Email send request."""
|
||||
to_email: str
|
||||
subject: str
|
||||
html_body: str
|
||||
text_body: str
|
||||
tags: Optional[dict] = None # For analytics/tracking
|
||||
|
||||
@dataclass
|
||||
class EmailResult:
|
||||
"""Email send result."""
|
||||
success: bool
|
||||
message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||
```
|
||||
|
||||
### Email Tags (Optional)
|
||||
|
||||
For analytics and troubleshooting:
|
||||
|
||||
```python
|
||||
tags = {
|
||||
"type": "match_notification",
|
||||
"exchange_id": "123",
|
||||
"environment": "production"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling & Retries
|
||||
|
||||
### Retry Strategy
|
||||
|
||||
```python
|
||||
def send_email_with_retry(request: EmailRequest, max_retries: int = 3) -> EmailResult:
|
||||
"""
|
||||
Send email with exponential backoff retry.
|
||||
|
||||
Args:
|
||||
request: EmailRequest to send
|
||||
max_retries: Maximum retry attempts
|
||||
|
||||
Returns:
|
||||
EmailResult
|
||||
"""
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(max_retries),
|
||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||
retry=retry_if_exception_type(resend.exceptions.ResendError)
|
||||
)
|
||||
def _send():
|
||||
return resend_client.send_email(request)
|
||||
|
||||
try:
|
||||
return _send()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email after {max_retries} attempts: {str(e)}")
|
||||
return EmailResult(success=False, error=f"Failed after {max_retries} retries")
|
||||
```
|
||||
|
||||
### Failure Logging
|
||||
|
||||
All email send attempts logged to database for audit:
|
||||
|
||||
```python
|
||||
class EmailLog(db.Model):
|
||||
"""Audit log for email sends."""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
to_email = db.Column(db.String(255), nullable=False)
|
||||
subject = db.Column(db.String(500), nullable=False)
|
||||
email_type = db.Column(db.String(50), nullable=False)
|
||||
success = db.Column(db.Boolean, nullable=False)
|
||||
message_id = db.Column(db.String(255), nullable=True)
|
||||
error = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
```
|
||||
|
||||
## Batch Email Sending
|
||||
|
||||
For sending to multiple recipients (e.g., match notifications):
|
||||
|
||||
```python
|
||||
def send_match_notifications_batch(self, exchange_id: int) -> BatchEmailResult:
|
||||
"""
|
||||
Send match notifications to all participants in exchange.
|
||||
|
||||
Args:
|
||||
exchange_id: Exchange ID
|
||||
|
||||
Returns:
|
||||
BatchEmailResult with success count and failures
|
||||
"""
|
||||
participants = Participant.query.filter_by(
|
||||
exchange_id=exchange_id,
|
||||
withdrawn_at=None
|
||||
).all()
|
||||
|
||||
results = []
|
||||
failed = []
|
||||
|
||||
for participant in participants:
|
||||
result = self.send_match_notification(participant.id)
|
||||
results.append(result)
|
||||
|
||||
if not result.success:
|
||||
failed.append({
|
||||
"participant_id": participant.id,
|
||||
"participant_email": participant.email,
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
# Rate limit: Small delay between sends
|
||||
time.sleep(0.1)
|
||||
|
||||
return BatchEmailResult(
|
||||
total=len(participants),
|
||||
successful=len([r for r in results if r.success]),
|
||||
failed=len(failed),
|
||||
failures=failed
|
||||
)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Email Testing in Development
|
||||
|
||||
**Option 1: Resend Test Mode**
|
||||
- Use Resend's test API key
|
||||
- Emails sent to test mode, not delivered
|
||||
|
||||
**Option 2: MailHog / MailCatcher**
|
||||
- Local SMTP server for testing
|
||||
- View emails in web UI
|
||||
|
||||
**Option 3: Mock in Unit Tests**
|
||||
```python
|
||||
from unittest.mock import patch
|
||||
|
||||
class TestNotificationService(unittest.TestCase):
|
||||
|
||||
@patch('notification_service.ResendClient.send_email')
|
||||
def test_send_registration_confirmation(self, mock_send):
|
||||
mock_send.return_value = EmailResult(success=True, message_id="test-123")
|
||||
|
||||
service = NotificationService()
|
||||
result = service.send_registration_confirmation(participant_id=1)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
mock_send.assert_called_once()
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```python
|
||||
class TestNotificationIntegration(TestCase):
|
||||
|
||||
def test_full_match_notification_workflow(self):
|
||||
"""Test complete match notification workflow."""
|
||||
# Setup
|
||||
exchange = create_test_exchange()
|
||||
participants = [create_test_participant(exchange) for _ in range(5)]
|
||||
execute_matching(exchange.id)
|
||||
|
||||
# Execute
|
||||
service = NotificationService()
|
||||
result = service.send_match_notifications_batch(exchange.id)
|
||||
|
||||
# Assert
|
||||
self.assertEqual(result.total, 5)
|
||||
self.assertEqual(result.successful, 5)
|
||||
self.assertEqual(result.failed, 0)
|
||||
|
||||
# Verify emails logged
|
||||
logs = EmailLog.query.filter_by(email_type="match_notification").all()
|
||||
self.assertEqual(len(logs), 5)
|
||||
```
|
||||
|
||||
## Deliverability Best Practices
|
||||
|
||||
### SPF, DKIM, DMARC
|
||||
|
||||
Configure DNS records for Resend domain:
|
||||
- **SPF**: Authorize Resend to send on your behalf
|
||||
- **DKIM**: Sign emails cryptographically
|
||||
- **DMARC**: Define policy for failed authentication
|
||||
|
||||
**Example DNS Configuration**:
|
||||
```
|
||||
TXT @ "v=spf1 include:_spf.resend.com ~all"
|
||||
CNAME resend._domainkey resend.domainkey.resend.com
|
||||
TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:admin@example.com"
|
||||
```
|
||||
|
||||
### Unsubscribe Links
|
||||
|
||||
For reminder emails, include unsubscribe link:
|
||||
|
||||
```python
|
||||
unsubscribe_url = f"{app_url}/participant/exchange/{exchange_id}/edit"
|
||||
```
|
||||
|
||||
Participants can disable reminders via profile edit.
|
||||
|
||||
### Email Content Best Practices
|
||||
|
||||
1. **Clear Subject Lines**: Descriptive and concise
|
||||
2. **Plain Text Alternative**: Always include text version
|
||||
3. **Inline CSS**: Email clients strip external stylesheets
|
||||
4. **Mobile Responsive**: Use responsive design techniques
|
||||
5. **Clear Call-to-Action**: Prominent buttons/links
|
||||
6. **Avoid Spam Triggers**: No all-caps, excessive punctuation, spam keywords
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Asynchronous Sending
|
||||
|
||||
For non-critical emails, send asynchronously:
|
||||
|
||||
```python
|
||||
from threading import Thread
|
||||
|
||||
def send_email_async(email_request: EmailRequest):
|
||||
"""Send email in background thread."""
|
||||
thread = Thread(target=lambda: notification_service.send_email(email_request))
|
||||
thread.start()
|
||||
```
|
||||
|
||||
**Note**: For production, use proper background job queue (see background-jobs.md)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Resend has rate limits (depends on plan):
|
||||
- Free: 100 emails/day
|
||||
- Paid: Higher limits
|
||||
|
||||
**Mitigation**:
|
||||
- Batch operations with delays between sends
|
||||
- Implement queue for large batches
|
||||
- Monitor usage and implement backoff
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Inclusion
|
||||
|
||||
Magic links and password reset tokens:
|
||||
- **URL Structure**: `{app_url}/auth/participant/magic/{token}`
|
||||
- **Token Format**: 32-byte random, base64url encoded
|
||||
- **Security**: Tokens hashed in database, original never stored
|
||||
|
||||
### Email Spoofing Prevention
|
||||
|
||||
- Use authenticated Resend domain
|
||||
- Configure SPF/DKIM/DMARC
|
||||
- Never allow user-controlled "from" addresses
|
||||
|
||||
### Sensitive Data
|
||||
|
||||
- **Never include**: Passwords, full tokens (only links)
|
||||
- **Include only necessary**: Participant names, gift ideas (expected in context)
|
||||
- **Audit log**: Track all emails sent
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **HTML Email Builder**: Visual template editor for admin
|
||||
2. **Localization**: Multi-language email templates
|
||||
3. **A/B Testing**: Test different email content for engagement
|
||||
4. **Analytics**: Track open rates, click rates (Resend webhooks)
|
||||
5. **Custom Branding**: Allow admin to customize email header/colors
|
||||
6. **Email Queue Dashboard**: Admin view of pending/failed emails
|
||||
|
||||
## References
|
||||
|
||||
- [Resend Documentation](https://resend.com/docs)
|
||||
- [Jinja2 Template Documentation](https://jinja.palletsprojects.com/)
|
||||
- [Email Deliverability Best Practices](https://www.mailgun.com/blog/email/email-deliverability-best-practices/)
|
||||
- [Data Model Specification](../data-model.md)
|
||||
- [API Specification](../api-spec.md)
|
||||
775
docs/designs/v0.1.0/data-model.md
Normal file
775
docs/designs/v0.1.0/data-model.md
Normal file
@@ -0,0 +1,775 @@
|
||||
# Data Model - v0.1.0
|
||||
|
||||
**Version**: 0.1.0
|
||||
**Date**: 2025-12-22
|
||||
**Status**: Initial Design
|
||||
|
||||
## Introduction
|
||||
|
||||
This document defines the complete database schema for Sneaky Klaus. The schema is designed for SQLite with SQLAlchemy ORM, optimized for read-heavy workloads with occasional writes, and structured to support all user stories in the product backlog.
|
||||
|
||||
**Note**: Session storage is managed by Flask-Session, which creates and manages its own session table. The custom Session table previously defined has been removed in favor of Flask-Session's implementation.
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Admin ||--o{ Exchange : creates
|
||||
Exchange ||--o{ Participant : contains
|
||||
Exchange ||--o{ ExclusionRule : defines
|
||||
Exchange ||--o{ NotificationPreference : configures
|
||||
Participant ||--o{ Match : "gives to"
|
||||
Participant ||--o{ Match : "receives from"
|
||||
Participant ||--o{ MagicToken : authenticates_with
|
||||
Participant }o--o{ ExclusionRule : excluded_from
|
||||
Exchange {
|
||||
int id PK
|
||||
string slug UK
|
||||
string name
|
||||
text description
|
||||
string budget
|
||||
int max_participants
|
||||
datetime registration_close_date
|
||||
datetime exchange_date
|
||||
string timezone
|
||||
string state
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime completed_at
|
||||
}
|
||||
Admin {
|
||||
int id PK
|
||||
string email UK
|
||||
string password_hash
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
}
|
||||
Participant {
|
||||
int id PK
|
||||
int exchange_id FK
|
||||
string name
|
||||
string email
|
||||
text gift_ideas
|
||||
boolean reminder_enabled
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
datetime withdrawn_at
|
||||
}
|
||||
Match {
|
||||
int id PK
|
||||
int exchange_id FK
|
||||
int giver_id FK
|
||||
int receiver_id FK
|
||||
datetime created_at
|
||||
}
|
||||
ExclusionRule {
|
||||
int id PK
|
||||
int exchange_id FK
|
||||
int participant_a_id FK
|
||||
int participant_b_id FK
|
||||
datetime created_at
|
||||
}
|
||||
MagicToken {
|
||||
int id PK
|
||||
string token_hash UK
|
||||
string token_type
|
||||
string email
|
||||
int participant_id FK
|
||||
int exchange_id FK
|
||||
datetime created_at
|
||||
datetime expires_at
|
||||
datetime used_at
|
||||
}
|
||||
PasswordResetToken {
|
||||
int id PK
|
||||
string token_hash UK
|
||||
string email
|
||||
datetime created_at
|
||||
datetime expires_at
|
||||
datetime used_at
|
||||
}
|
||||
RateLimit {
|
||||
int id PK
|
||||
string key UK
|
||||
int attempts
|
||||
datetime window_start
|
||||
datetime expires_at
|
||||
}
|
||||
NotificationPreference {
|
||||
int id PK
|
||||
int exchange_id FK
|
||||
boolean new_registration
|
||||
boolean participant_withdrawal
|
||||
boolean matching_complete
|
||||
datetime created_at
|
||||
datetime updated_at
|
||||
}
|
||||
```
|
||||
|
||||
## Entity Definitions
|
||||
|
||||
### Admin
|
||||
|
||||
The administrator account for the entire installation. Only one admin exists per deployment.
|
||||
|
||||
**Table**: `admin`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
|
||||
| `email` | VARCHAR(255) | UNIQUE, NOT NULL | Admin email address |
|
||||
| `password_hash` | VARCHAR(255) | NOT NULL | bcrypt password hash |
|
||||
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Account creation timestamp |
|
||||
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_admin_email` on `email` (unique)
|
||||
|
||||
**Constraints**:
|
||||
- Email format validation at application level
|
||||
- Only one admin record should exist (enforced at application level)
|
||||
|
||||
**Notes**:
|
||||
- Password hash uses bcrypt with cost factor 12
|
||||
- `updated_at` automatically updated on any modification
|
||||
|
||||
---
|
||||
|
||||
### Exchange
|
||||
|
||||
Represents a single Secret Santa exchange event.
|
||||
|
||||
**Table**: `exchange`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
|
||||
| `slug` | VARCHAR(12) | UNIQUE, NOT NULL | URL-safe identifier for exchange |
|
||||
| `name` | VARCHAR(255) | NOT NULL | Exchange name/title |
|
||||
| `description` | TEXT | NULLABLE | Optional description |
|
||||
| `budget` | VARCHAR(100) | NOT NULL | Gift budget (e.g., "$20-30") |
|
||||
| `max_participants` | INTEGER | NOT NULL, CHECK >= 3 | Maximum participant limit |
|
||||
| `registration_close_date` | TIMESTAMP | NOT NULL | When registration ends |
|
||||
| `exchange_date` | TIMESTAMP | NOT NULL | When gifts are exchanged |
|
||||
| `timezone` | VARCHAR(50) | NOT NULL | Timezone for dates (e.g., "America/New_York") |
|
||||
| `state` | VARCHAR(20) | NOT NULL | Current state (see states below) |
|
||||
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Exchange creation timestamp |
|
||||
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp |
|
||||
| `completed_at` | TIMESTAMP | NULLABLE | When exchange was marked complete |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_exchange_slug` on `slug` (unique)
|
||||
- `idx_exchange_state` on `state`
|
||||
- `idx_exchange_exchange_date` on `exchange_date`
|
||||
- `idx_exchange_completed_at` on `completed_at`
|
||||
|
||||
**States** (enum enforced at application level):
|
||||
- `draft`: Exchange created but not accepting registrations
|
||||
- `registration_open`: Participants can register
|
||||
- `registration_closed`: Registration ended, ready for matching
|
||||
- `matched`: Participants have been assigned recipients
|
||||
- `completed`: Exchange date has passed
|
||||
|
||||
**State Transitions**:
|
||||
- `draft` → `registration_open`
|
||||
- `registration_open` → `registration_closed`
|
||||
- `registration_closed` → `registration_open` (reopen)
|
||||
- `registration_closed` → `matched` (after matching)
|
||||
- `matched` → `registration_open` (reopen, clears matches)
|
||||
- `matched` → `completed`
|
||||
|
||||
**Constraints**:
|
||||
- `registration_close_date` must be before `exchange_date` (validated at application level)
|
||||
- `max_participants` minimum value: 3
|
||||
- Timezone must be valid IANA timezone (validated at application level)
|
||||
- `slug` must be unique across all exchanges
|
||||
|
||||
**Slug Generation**:
|
||||
- Generated on exchange creation using `secrets.choice()` from Python's secrets module
|
||||
- 12 URL-safe alphanumeric characters (a-z, A-Z, 0-9)
|
||||
- Immutable once generated (never changes)
|
||||
- Used in public URLs for exchange registration and participant access
|
||||
|
||||
**Cascade Behavior**:
|
||||
- Deleting exchange cascades to: participants, matches, exclusion rules, notification preferences
|
||||
|
||||
---
|
||||
|
||||
### Participant
|
||||
|
||||
A person registered in a specific exchange.
|
||||
|
||||
**Table**: `participant`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
|
||||
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NOT NULL | Associated exchange |
|
||||
| `name` | VARCHAR(255) | NOT NULL | Display name |
|
||||
| `email` | VARCHAR(255) | NOT NULL | Email address |
|
||||
| `gift_ideas` | TEXT | NULLABLE | Wishlist/gift preferences |
|
||||
| `reminder_enabled` | BOOLEAN | NOT NULL, DEFAULT TRUE | Opt-in for reminder emails |
|
||||
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Registration timestamp |
|
||||
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp |
|
||||
| `withdrawn_at` | TIMESTAMP | NULLABLE | Withdrawal timestamp (soft delete) |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_participant_exchange_id` on `exchange_id`
|
||||
- `idx_participant_email` on `email`
|
||||
- `idx_participant_exchange_email` on `(exchange_id, email)` (composite unique)
|
||||
|
||||
**Constraints**:
|
||||
- Email must be unique within an exchange (composite unique index)
|
||||
- Email format validation at application level
|
||||
- Cannot modify after matching except gift_ideas and reminder_enabled (enforced at application level)
|
||||
|
||||
**Cascade Behavior**:
|
||||
- Deleting exchange cascades to delete participants
|
||||
- Deleting participant cascades to: matches (as giver or receiver), exclusion rules, magic tokens
|
||||
|
||||
**Soft Delete**:
|
||||
- Withdrawal sets `withdrawn_at` instead of hard delete
|
||||
- Withdrawn participants excluded from matching and participant lists
|
||||
- Withdrawn participants cannot be re-activated (must re-register)
|
||||
|
||||
---
|
||||
|
||||
### Match
|
||||
|
||||
Represents a giver-receiver assignment in an exchange.
|
||||
|
||||
**Table**: `match`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
|
||||
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NOT NULL | Associated exchange |
|
||||
| `giver_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | Participant giving gift |
|
||||
| `receiver_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | Participant receiving gift |
|
||||
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Match creation timestamp |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_match_exchange_id` on `exchange_id`
|
||||
- `idx_match_giver_id` on `giver_id`
|
||||
- `idx_match_receiver_id` on `receiver_id`
|
||||
- `idx_match_exchange_giver` on `(exchange_id, giver_id)` (composite unique)
|
||||
|
||||
**Constraints**:
|
||||
- Each participant can be a giver exactly once per exchange (composite unique)
|
||||
- Each participant can be a receiver exactly once per exchange (enforced at application level)
|
||||
- `giver_id` cannot equal `receiver_id` (no self-matching, enforced at application level)
|
||||
- Both giver and receiver must belong to same exchange (enforced at application level)
|
||||
|
||||
**Cascade Behavior**:
|
||||
- Deleting exchange cascades to delete matches
|
||||
- Deleting participant cascades to delete matches (triggers re-match requirement)
|
||||
|
||||
**Validation**:
|
||||
- All participants in exchange must have exactly one match as giver and one as receiver
|
||||
- No exclusion rules violated (enforced during matching)
|
||||
|
||||
---
|
||||
|
||||
### ExclusionRule
|
||||
|
||||
Defines pairs of participants who should not be matched together.
|
||||
|
||||
**Table**: `exclusion_rule`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
|
||||
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NOT NULL | Associated exchange |
|
||||
| `participant_a_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | First participant |
|
||||
| `participant_b_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | Second participant |
|
||||
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Rule creation timestamp |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_exclusion_exchange_id` on `exchange_id`
|
||||
- `idx_exclusion_participants` on `(exchange_id, participant_a_id, participant_b_id)` (composite unique)
|
||||
|
||||
**Constraints**:
|
||||
- `participant_a_id` and `participant_b_id` must be different (enforced at application level)
|
||||
- Both participants must belong to same exchange (enforced at application level)
|
||||
- Exclusion is bidirectional: A→B exclusion also means B→A (handled at application level)
|
||||
- Prevent duplicate rules with swapped participant IDs (enforced by ordering IDs: `participant_a_id < participant_b_id`)
|
||||
|
||||
**Cascade Behavior**:
|
||||
- Deleting exchange cascades to delete exclusion rules
|
||||
- Deleting participant cascades to delete related exclusion rules
|
||||
|
||||
**Application Logic**:
|
||||
- When adding exclusion, always store with lower ID as participant_a, higher ID as participant_b
|
||||
- Matching algorithm treats exclusion as bidirectional
|
||||
|
||||
---
|
||||
|
||||
### MagicToken
|
||||
|
||||
Time-limited tokens for participant passwordless authentication and admin password reset.
|
||||
|
||||
**Table**: `magic_token`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
|
||||
| `token_hash` | VARCHAR(255) | UNIQUE, NOT NULL | SHA-256 hash of token |
|
||||
| `token_type` | VARCHAR(20) | NOT NULL | 'magic_link' or 'password_reset' |
|
||||
| `email` | VARCHAR(255) | NOT NULL | Email address token sent to |
|
||||
| `participant_id` | INTEGER | FOREIGN KEY → participant.id, NULLABLE | For magic links only |
|
||||
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NULLABLE | For magic links only |
|
||||
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Token creation timestamp |
|
||||
| `expires_at` | TIMESTAMP | NOT NULL | Token expiration (1 hour from creation) |
|
||||
| `used_at` | TIMESTAMP | NULLABLE | When token was consumed |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_magic_token_hash` on `token_hash` (unique)
|
||||
- `idx_magic_token_type_email` on `(token_type, email)`
|
||||
- `idx_magic_token_expires_at` on `expires_at`
|
||||
|
||||
**Constraints**:
|
||||
- `token_type` must be 'magic_link' or 'password_reset' (enforced at application level)
|
||||
- For magic_link: `participant_id` and `exchange_id` must be NOT NULL
|
||||
- For password_reset: `participant_id` and `exchange_id` must be NULL
|
||||
- Tokens expire 1 hour after creation
|
||||
- Tokens are single-use (validated via `used_at`)
|
||||
|
||||
**Token Generation**:
|
||||
- Token: 32-byte random value from `secrets` module, base64url encoded
|
||||
- Hash: SHA-256 hash of token (stored in database)
|
||||
- Original token sent in email, never stored
|
||||
|
||||
**Validation**:
|
||||
- Token valid if: hash matches, not expired, not used
|
||||
- On successful validation: set `used_at` timestamp, create session
|
||||
|
||||
**Cleanup**:
|
||||
- Expired or used tokens purged hourly via background job
|
||||
|
||||
**Cascade Behavior**:
|
||||
- Deleting participant cascades to delete magic tokens
|
||||
- Deleting exchange cascades to delete related magic tokens
|
||||
|
||||
---
|
||||
|
||||
### RateLimit
|
||||
|
||||
Tracks authentication attempt rate limits per email/key.
|
||||
|
||||
**Table**: `rate_limit`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
|
||||
| `key` | VARCHAR(255) | UNIQUE, NOT NULL | Rate limit key (e.g., "login:email@example.com") |
|
||||
| `attempts` | INTEGER | NOT NULL, DEFAULT 0 | Number of attempts in current window |
|
||||
| `window_start` | TIMESTAMP | NOT NULL, DEFAULT NOW | Start of current rate limit window |
|
||||
| `expires_at` | TIMESTAMP | NOT NULL | When rate limit resets |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_rate_limit_key` on `key` (unique)
|
||||
- `idx_rate_limit_expires_at` on `expires_at`
|
||||
|
||||
**Rate Limit Policies**:
|
||||
- Admin login: 5 attempts per 15 minutes per email
|
||||
- Participant magic link: 3 requests per hour per email
|
||||
- Password reset: 3 requests per hour per email
|
||||
|
||||
**Key Format**:
|
||||
- Admin login: `login:admin:{email}`
|
||||
- Magic link: `magic_link:{email}`
|
||||
- Password reset: `password_reset:{email}`
|
||||
|
||||
**Workflow**:
|
||||
1. Check if key exists and within window
|
||||
2. If attempts exceeded: reject request
|
||||
3. If within limits: increment attempts
|
||||
4. If window expired: reset attempts and window
|
||||
|
||||
**Cleanup**:
|
||||
- Expired rate limit entries purged daily via background job
|
||||
|
||||
---
|
||||
|
||||
### NotificationPreference
|
||||
|
||||
Admin's email notification preferences per exchange or globally.
|
||||
|
||||
**Table**: `notification_preference`
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| `id` | INTEGER | PRIMARY KEY | Auto-increment ID |
|
||||
| `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NULLABLE | Specific exchange (NULL = global default) |
|
||||
| `new_registration` | BOOLEAN | NOT NULL, DEFAULT TRUE | Notify on new participant registration |
|
||||
| `participant_withdrawal` | BOOLEAN | NOT NULL, DEFAULT TRUE | Notify on participant withdrawal |
|
||||
| `matching_complete` | BOOLEAN | NOT NULL, DEFAULT TRUE | Notify on successful matching |
|
||||
| `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Preference creation timestamp |
|
||||
| `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp |
|
||||
|
||||
**Indexes**:
|
||||
- `idx_notification_exchange_id` on `exchange_id` (unique)
|
||||
|
||||
**Constraints**:
|
||||
- Only one notification preference per exchange (unique index)
|
||||
- Only one global preference (exchange_id = NULL)
|
||||
|
||||
**Application Logic**:
|
||||
- If exchange-specific preference exists, use it
|
||||
- Otherwise, fall back to global preference
|
||||
- If no preferences exist, default all to TRUE
|
||||
|
||||
**Cascade Behavior**:
|
||||
- Deleting exchange cascades to delete notification preferences
|
||||
|
||||
---
|
||||
|
||||
## Data Types & Conventions
|
||||
|
||||
### Timestamps
|
||||
|
||||
- **Type**: `TIMESTAMP` (SQLite stores as ISO 8601 string)
|
||||
- **Timezone**: All timestamps stored in UTC
|
||||
- **Application Layer**: Convert to exchange timezone for display
|
||||
- **Automatic Fields**: `created_at`, `updated_at` managed by SQLAlchemy
|
||||
|
||||
### Email Addresses
|
||||
|
||||
- **Type**: `VARCHAR(255)`
|
||||
- **Validation**: RFC 5322 format validation at application level
|
||||
- **Storage**: Lowercase normalized
|
||||
- **Indexing**: Indexed for lookup performance
|
||||
|
||||
### Enumerations
|
||||
|
||||
SQLite doesn't support native enums. Enforced at application level:
|
||||
|
||||
**ExchangeState**:
|
||||
- `draft`
|
||||
- `registration_open`
|
||||
- `registration_closed`
|
||||
- `matched`
|
||||
- `completed`
|
||||
|
||||
**UserType** (Session):
|
||||
- `admin`
|
||||
- `participant`
|
||||
|
||||
**TokenType** (MagicToken):
|
||||
- `magic_link`
|
||||
- `password_reset`
|
||||
|
||||
### JSON Fields
|
||||
|
||||
- **Type**: `TEXT` (SQLite)
|
||||
- **Serialization**: JSON string
|
||||
- **Access**: SQLAlchemy JSON type provides automatic serialization/deserialization
|
||||
- **Usage**: Session data, future extensibility
|
||||
|
||||
### Boolean Fields
|
||||
|
||||
- **Type**: `BOOLEAN` (SQLite stores as INTEGER: 0 or 1)
|
||||
- **Default**: Explicit defaults defined per field
|
||||
- **Access**: SQLAlchemy Boolean type handles conversion
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Indexes Summary
|
||||
|
||||
| Table | Index Name | Columns | Type | Purpose |
|
||||
|-------|------------|---------|------|---------|
|
||||
| admin | idx_admin_email | email | UNIQUE | Login lookup |
|
||||
| exchange | idx_exchange_slug | slug | UNIQUE | Exchange lookup by slug |
|
||||
| exchange | idx_exchange_state | state | INDEX | Filter by state |
|
||||
| exchange | idx_exchange_exchange_date | exchange_date | INDEX | Auto-completion job |
|
||||
| exchange | idx_exchange_completed_at | completed_at | INDEX | Purge job |
|
||||
| participant | idx_participant_exchange_id | exchange_id | INDEX | List participants |
|
||||
| participant | idx_participant_email | email | INDEX | Lookup by email |
|
||||
| participant | idx_participant_exchange_email | exchange_id, email | UNIQUE | Email uniqueness per exchange |
|
||||
| match | idx_match_exchange_id | exchange_id | INDEX | List matches |
|
||||
| match | idx_match_giver_id | giver_id | INDEX | Lookup giver's match |
|
||||
| match | idx_match_receiver_id | receiver_id | INDEX | Lookup receivers |
|
||||
| match | idx_match_exchange_giver | exchange_id, giver_id | UNIQUE | Prevent duplicate givers |
|
||||
| exclusion_rule | idx_exclusion_exchange_id | exchange_id | INDEX | List exclusions |
|
||||
| exclusion_rule | idx_exclusion_participants | exchange_id, participant_a_id, participant_b_id | UNIQUE | Prevent duplicates |
|
||||
| magic_token | idx_magic_token_hash | token_hash | UNIQUE | Token validation |
|
||||
| magic_token | idx_magic_token_type_email | token_type, email | INDEX | Rate limit checks |
|
||||
| magic_token | idx_magic_token_expires_at | expires_at | INDEX | Cleanup job |
|
||||
| rate_limit | idx_rate_limit_key | key | UNIQUE | Rate limit lookup |
|
||||
| rate_limit | idx_rate_limit_expires_at | expires_at | INDEX | Cleanup job |
|
||||
| notification_preference | idx_notification_exchange_id | exchange_id | UNIQUE | Preference lookup |
|
||||
|
||||
### Query Optimization Strategies
|
||||
|
||||
1. **Eager Loading**: Use SQLAlchemy `joinedload()` for relationships to prevent N+1 queries
|
||||
2. **Batch Operations**: Use bulk insert/update for matching operations
|
||||
3. **Filtered Queries**: Always filter by exchange_id first (most selective)
|
||||
4. **Connection Pooling**: Disabled for SQLite (single file, no benefit)
|
||||
5. **WAL Mode**: Enabled for better read concurrency
|
||||
|
||||
### SQLite Configuration
|
||||
|
||||
```python
|
||||
# Connection string
|
||||
DATABASE_URL = 'sqlite:///data/sneaky-klaus.db'
|
||||
|
||||
# Pragmas (set on connection)
|
||||
PRAGMA journal_mode = WAL; # Write-Ahead Logging
|
||||
PRAGMA foreign_keys = ON; # Enforce foreign keys
|
||||
PRAGMA synchronous = NORMAL; # Balance safety and performance
|
||||
PRAGMA temp_store = MEMORY; # Temporary tables in memory
|
||||
PRAGMA cache_size = -64000; # 64MB cache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Validation Rules
|
||||
|
||||
### Exchange
|
||||
|
||||
- `name`: 1-255 characters, required
|
||||
- `slug`: Exactly 12 URL-safe alphanumeric characters, auto-generated, unique
|
||||
- `budget`: 1-100 characters, required (freeform text)
|
||||
- `max_participants`: ≥ 3, required
|
||||
- `registration_close_date` < `exchange_date`: validated at application level
|
||||
- `timezone`: Valid IANA timezone name
|
||||
|
||||
### Participant
|
||||
|
||||
- `name`: 1-255 characters, required
|
||||
- `email`: Valid email format, unique per exchange
|
||||
- `gift_ideas`: 0-10,000 characters (optional)
|
||||
|
||||
### Match
|
||||
|
||||
- Validation at matching time:
|
||||
- All participants have exactly one match (as giver and receiver)
|
||||
- No self-matches
|
||||
- No exclusion rules violated
|
||||
- Single cycle preferred
|
||||
|
||||
### ExclusionRule
|
||||
|
||||
- `participant_a_id` < `participant_b_id`: enforced when creating
|
||||
- Both participants must be in same exchange
|
||||
- Cannot exclude participant from themselves
|
||||
|
||||
### MagicToken
|
||||
|
||||
- Token expiration: 1 hour from creation
|
||||
- Single use only
|
||||
- Token hash must be unique
|
||||
|
||||
---
|
||||
|
||||
## Audit Trail
|
||||
|
||||
### Change Tracking
|
||||
|
||||
- All tables have `created_at` timestamp
|
||||
- Most tables have `updated_at` timestamp (auto-updated)
|
||||
- Soft deletes used where appropriate (`withdrawn_at` for participants)
|
||||
|
||||
### Future Audit Enhancements
|
||||
|
||||
If detailed audit trail is needed:
|
||||
- Create `audit_log` table
|
||||
- Track: entity type, entity ID, action, user, timestamp, changes
|
||||
- Trigger-based or application-level logging
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Initial Schema Creation
|
||||
|
||||
Use Alembic for database migrations:
|
||||
|
||||
```bash
|
||||
# Create initial migration
|
||||
alembic revision --autogenerate -m "Initial schema"
|
||||
|
||||
# Apply migration
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### Schema Versioning
|
||||
|
||||
- Alembic tracks migration version in `alembic_version` table
|
||||
- Each schema change creates new migration file
|
||||
- Migrations can be rolled back if needed
|
||||
|
||||
### Future Schema Changes
|
||||
|
||||
When adding fields or tables:
|
||||
1. Create Alembic migration
|
||||
2. Test migration on copy of production database
|
||||
3. Run migration during deployment
|
||||
4. Update SQLAlchemy models
|
||||
|
||||
---
|
||||
|
||||
## Sample Data Relationships
|
||||
|
||||
### Example: Complete Exchange Flow
|
||||
|
||||
1. **Exchange Created** (state: draft)
|
||||
- 1 exchange record created
|
||||
|
||||
2. **Registration Opens** (state: registration_open)
|
||||
- Exchange state updated
|
||||
- Participants register (3-100 participant records)
|
||||
|
||||
3. **Registration Closes** (state: registration_closed)
|
||||
- Exchange state updated
|
||||
- Admin adds exclusion rules (0-N exclusion_rule records)
|
||||
|
||||
4. **Matching Occurs** (state: matched)
|
||||
- Exchange state updated
|
||||
- Match records created (N matches for N participants)
|
||||
- Magic tokens created for notifications
|
||||
|
||||
5. **Exchange Completes** (state: completed)
|
||||
- Exchange state updated
|
||||
- `completed_at` timestamp set
|
||||
- 30-day retention countdown begins
|
||||
|
||||
6. **Data Purge** (30 days after completion)
|
||||
- Exchange deleted (cascades to participants, matches, exclusions)
|
||||
|
||||
---
|
||||
|
||||
## Database Size Estimates
|
||||
|
||||
### Assumptions
|
||||
|
||||
- 50 exchanges per installation
|
||||
- Average 20 participants per exchange
|
||||
- 3 exclusion rules per exchange
|
||||
- 30-day retention = 5 active exchanges + 5 completed
|
||||
|
||||
### Storage Calculation
|
||||
|
||||
| Entity | Records | Avg Size | Total |
|
||||
|--------|---------|----------|-------|
|
||||
| Admin | 1 | 500 B | 500 B |
|
||||
| Exchange | 50 | 1 KB | 50 KB |
|
||||
| Participant | 1,000 | 2 KB | 2 MB |
|
||||
| Match | 1,000 | 200 B | 200 KB |
|
||||
| ExclusionRule | 150 | 200 B | 30 KB |
|
||||
| Flask-Session (managed) | 50 | 500 B | 25 KB |
|
||||
| MagicToken | 200 | 500 B | 100 KB |
|
||||
| RateLimit | 100 | 300 B | 30 KB |
|
||||
| NotificationPreference | 50 | 300 B | 15 KB |
|
||||
|
||||
**Total Estimated Size**: ~3 MB (excluding indexes and overhead)
|
||||
|
||||
**With Indexes & Overhead**: ~10 MB typical, ~50 MB maximum
|
||||
|
||||
SQLite database file size well within acceptable limits for self-hosted deployment.
|
||||
|
||||
---
|
||||
|
||||
## Backup & Recovery
|
||||
|
||||
### Backup Strategy
|
||||
|
||||
**Database File Location**: `/app/data/sneaky-klaus.db`
|
||||
|
||||
**Backup Methods**:
|
||||
|
||||
1. **Volume Snapshot**: Recommended for Docker deployments
|
||||
```bash
|
||||
docker run --rm -v sneaky-klaus-data:/data -v $(pwd):/backup \
|
||||
alpine tar czf /backup/sneaky-klaus-backup-$(date +%Y%m%d).tar.gz /data
|
||||
```
|
||||
|
||||
2. **SQLite Backup Command**: Online backup without downtime
|
||||
```bash
|
||||
sqlite3 sneaky-klaus.db ".backup sneaky-klaus-backup.db"
|
||||
```
|
||||
|
||||
3. **File Copy**: Requires application shutdown
|
||||
```bash
|
||||
docker stop sneaky-klaus
|
||||
cp /app/data/sneaky-klaus.db /backups/
|
||||
docker start sneaky-klaus
|
||||
```
|
||||
|
||||
**Backup Frequency**: Daily (automated via cron or external backup tool)
|
||||
|
||||
**Retention**: 30 days of backups
|
||||
|
||||
### Recovery Procedure
|
||||
|
||||
1. Stop application container
|
||||
2. Replace database file with backup
|
||||
3. Restart application
|
||||
4. Verify health endpoint
|
||||
5. Test basic functionality (login, view exchanges)
|
||||
|
||||
---
|
||||
|
||||
## Constraints Summary
|
||||
|
||||
### Foreign Key Relationships
|
||||
|
||||
| Child Table | Column | Parent Table | Parent Column | On Delete |
|
||||
|-------------|--------|--------------|---------------|-----------|
|
||||
| participant | exchange_id | exchange | id | CASCADE |
|
||||
| match | exchange_id | exchange | id | CASCADE |
|
||||
| match | giver_id | participant | id | CASCADE |
|
||||
| match | receiver_id | participant | id | CASCADE |
|
||||
| exclusion_rule | exchange_id | exchange | id | CASCADE |
|
||||
| exclusion_rule | participant_a_id | participant | id | CASCADE |
|
||||
| exclusion_rule | participant_b_id | participant | id | CASCADE |
|
||||
| magic_token | participant_id | participant | id | CASCADE |
|
||||
| magic_token | exchange_id | exchange | id | CASCADE |
|
||||
| notification_preference | exchange_id | exchange | id | CASCADE |
|
||||
|
||||
**Flask-Session**: Managed by Flask-Session extension (no foreign keys in application schema)
|
||||
|
||||
**RateLimit**: No foreign keys (key-based, not entity-based)
|
||||
|
||||
### Unique Constraints
|
||||
|
||||
- `admin.email`: UNIQUE
|
||||
- `exchange.slug`: UNIQUE
|
||||
- `participant.(exchange_id, email)`: UNIQUE (composite)
|
||||
- `match.(exchange_id, giver_id)`: UNIQUE (composite)
|
||||
- `exclusion_rule.(exchange_id, participant_a_id, participant_b_id)`: UNIQUE (composite)
|
||||
- `magic_token.token_hash`: UNIQUE
|
||||
- `rate_limit.key`: UNIQUE
|
||||
- `notification_preference.exchange_id`: UNIQUE (one preference per exchange)
|
||||
|
||||
### Check Constraints
|
||||
|
||||
- `exchange.max_participants >= 3`
|
||||
- Application-level validation for additional constraints (state transitions, date ordering, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Future Schema Enhancements
|
||||
|
||||
Potential additions for future versions:
|
||||
|
||||
1. **Audit Log Table**: Track all changes for compliance/debugging
|
||||
2. **Email Queue Table**: Decouple email sending from transactional operations
|
||||
3. **Participant Groups**: Support for couple/family groupings (exclude from each other automatically)
|
||||
4. **Multiple Admins**: Add admin roles and permissions
|
||||
5. **Exchange Templates**: Reusable exchange configurations
|
||||
6. **Custom Fields**: User-defined participant attributes
|
||||
|
||||
These enhancements would require schema changes but are out of scope for v0.1.0.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
|
||||
- [SQLite Documentation](https://www.sqlite.org/docs.html)
|
||||
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
|
||||
- [ADR-0001: Core Technology Stack](../../decisions/0001-core-technology-stack.md)
|
||||
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
|
||||
592
docs/designs/v0.1.0/overview.md
Normal file
592
docs/designs/v0.1.0/overview.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# System Architecture Overview - v0.1.0
|
||||
|
||||
**Version**: 0.1.0
|
||||
**Date**: 2025-12-22
|
||||
**Status**: Initial Design
|
||||
|
||||
## Introduction
|
||||
|
||||
This document describes the high-level architecture for Sneaky Klaus, a self-hosted Secret Santa organization application. The architecture prioritizes simplicity, ease of deployment, and minimal external dependencies while maintaining security and reliability.
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Deployment Model
|
||||
|
||||
Sneaky Klaus is deployed as a **single Docker container** containing all application components:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Docker Container"
|
||||
direction TB
|
||||
Flask[Flask Application]
|
||||
Gunicorn[Gunicorn WSGI Server]
|
||||
APScheduler[APScheduler Background Jobs]
|
||||
SQLite[(SQLite Database)]
|
||||
|
||||
Gunicorn --> Flask
|
||||
Flask --> SQLite
|
||||
APScheduler --> Flask
|
||||
APScheduler --> SQLite
|
||||
end
|
||||
|
||||
subgraph "External Services"
|
||||
Resend[Resend Email API]
|
||||
end
|
||||
|
||||
subgraph "Clients"
|
||||
AdminBrowser[Admin Browser]
|
||||
ParticipantBrowser[Participant Browser]
|
||||
end
|
||||
|
||||
AdminBrowser -->|HTTPS| Gunicorn
|
||||
ParticipantBrowser -->|HTTPS| Gunicorn
|
||||
Flask -->|HTTPS| Resend
|
||||
|
||||
subgraph "Persistent Storage"
|
||||
DBVolume[Database Volume]
|
||||
SQLite -.->|Mounted| DBVolume
|
||||
end
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Responsibility | Technology |
|
||||
|-----------|----------------|------------|
|
||||
| **Gunicorn** | HTTP request handling, worker process management | Gunicorn 21.x |
|
||||
| **Flask Application** | Request routing, business logic, template rendering | Flask 3.x |
|
||||
| **SQLite Database** | Data persistence, transactional storage | SQLite 3.40+ |
|
||||
| **APScheduler** | Background job scheduling (reminders, data purging) | APScheduler 3.10+ |
|
||||
| **Jinja2** | Server-side HTML template rendering | Jinja2 3.1+ |
|
||||
| **Resend** | Transactional email delivery | Resend API |
|
||||
|
||||
### Application Architecture
|
||||
|
||||
The Flask application follows a layered architecture:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Presentation Layer"
|
||||
Routes[Route Handlers]
|
||||
Templates[Jinja2 Templates]
|
||||
Forms[WTForms Validation]
|
||||
end
|
||||
|
||||
subgraph "Business Logic Layer"
|
||||
Services[Service Layer]
|
||||
Auth[Authentication Service]
|
||||
Matching[Matching Algorithm]
|
||||
Notifications[Notification Service]
|
||||
end
|
||||
|
||||
subgraph "Data Access Layer"
|
||||
Models[SQLAlchemy Models]
|
||||
Repositories[Repository Pattern]
|
||||
end
|
||||
|
||||
subgraph "Infrastructure"
|
||||
Database[(SQLite)]
|
||||
EmailProvider[Resend Email]
|
||||
Scheduler[APScheduler]
|
||||
end
|
||||
|
||||
Routes --> Services
|
||||
Routes --> Forms
|
||||
Routes --> Templates
|
||||
Services --> Models
|
||||
Services --> Auth
|
||||
Services --> Matching
|
||||
Services --> Notifications
|
||||
Models --> Repositories
|
||||
Repositories --> Database
|
||||
Notifications --> EmailProvider
|
||||
Scheduler --> Services
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### Flask Application Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.py # Application factory
|
||||
├── config.py # Configuration management
|
||||
├── models/ # SQLAlchemy models
|
||||
│ ├── admin.py
|
||||
│ ├── exchange.py
|
||||
│ ├── participant.py
|
||||
│ ├── match.py
|
||||
│ ├── session.py
|
||||
│ └── auth_token.py
|
||||
├── routes/ # Route handlers (blueprints)
|
||||
│ ├── admin.py
|
||||
│ ├── participant.py
|
||||
│ ├── exchange.py
|
||||
│ └── auth.py
|
||||
├── services/ # Business logic
|
||||
│ ├── auth_service.py
|
||||
│ ├── exchange_service.py
|
||||
│ ├── matching_service.py
|
||||
│ ├── notification_service.py
|
||||
│ └── scheduler_service.py
|
||||
├── templates/ # Jinja2 templates
|
||||
│ ├── admin/
|
||||
│ ├── participant/
|
||||
│ ├── auth/
|
||||
│ └── layouts/
|
||||
├── static/ # Static assets (CSS, minimal JS)
|
||||
│ ├── css/
|
||||
│ └── js/
|
||||
└── utils/ # Utility functions
|
||||
├── email.py
|
||||
├── security.py
|
||||
└── validators.py
|
||||
```
|
||||
|
||||
### Database Layer
|
||||
|
||||
**ORM**: SQLAlchemy for database abstraction and model definition
|
||||
|
||||
**Migration**: Alembic for schema versioning and migrations
|
||||
|
||||
**Configuration**:
|
||||
- WAL mode enabled for better concurrency
|
||||
- Foreign keys enabled
|
||||
- Connection pooling disabled (single file, single process benefit)
|
||||
- Appropriate timeouts for locked database scenarios
|
||||
|
||||
### Background Job Scheduler
|
||||
|
||||
**APScheduler Configuration**:
|
||||
- JobStore: SQLAlchemyJobStore (persists jobs across restarts)
|
||||
- Executor: ThreadPoolExecutor (4 workers)
|
||||
- Timezone-aware scheduling
|
||||
|
||||
**Scheduled Jobs**:
|
||||
1. **Reminder Emails**: Cron jobs scheduled per exchange based on configured intervals
|
||||
2. **Exchange Completion**: Daily check for exchanges past their exchange date
|
||||
3. **Data Purging**: Daily check for completed exchanges past 30-day retention
|
||||
4. **Session Cleanup**: Daily purge of expired sessions
|
||||
5. **Token Cleanup**: Hourly purge of expired auth tokens
|
||||
|
||||
### Email Service
|
||||
|
||||
**Provider**: Resend API via official Python SDK
|
||||
|
||||
**Email Types**:
|
||||
- Participant registration confirmation
|
||||
- Magic link authentication
|
||||
- Match notification (post-matching)
|
||||
- Reminder emails (configurable schedule)
|
||||
- Admin notifications (opt-in)
|
||||
- Password reset
|
||||
|
||||
**Template Strategy**:
|
||||
- HTML templates stored in `templates/emails/`
|
||||
- Rendered using Jinja2 before sending
|
||||
- Plain text alternatives for all emails
|
||||
- Unsubscribe links where appropriate
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Purpose | Required | Default |
|
||||
|----------|---------|----------|---------|
|
||||
| `SECRET_KEY` | Flask session encryption | Yes | - |
|
||||
| `DATABASE_URL` | SQLite database file path | No | `sqlite:///data/sneaky-klaus.db` |
|
||||
| `RESEND_API_KEY` | Resend API authentication | Yes | - |
|
||||
| `APP_URL` | Base URL for links in emails | Yes | - |
|
||||
| `ADMIN_EMAIL` | Initial admin email (setup) | Setup only | - |
|
||||
| `ADMIN_PASSWORD` | Initial admin password (setup) | Setup only | - |
|
||||
| `LOG_LEVEL` | Logging verbosity | No | `INFO` |
|
||||
| `TZ` | Container timezone | No | `UTC` |
|
||||
|
||||
### Configuration Files
|
||||
|
||||
**config.py**: Python-based configuration with environment variable overrides
|
||||
|
||||
**Environment-based configs**:
|
||||
- `DevelopmentConfig`: Debug mode, verbose logging
|
||||
- `ProductionConfig`: Security headers, minimal logging
|
||||
- `TestConfig`: In-memory database, mocked email
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Docker Container
|
||||
|
||||
**Base Image**: `python:3.11-slim`
|
||||
|
||||
**Exposed Ports**:
|
||||
- `8000`: HTTP (Gunicorn)
|
||||
|
||||
**Volumes**:
|
||||
- `/app/data`: Database and uploaded files (if any)
|
||||
|
||||
**Health Check**:
|
||||
- Endpoint: `/health`
|
||||
- Interval: 30 seconds
|
||||
- Timeout: 5 seconds
|
||||
|
||||
**Dockerfile Structure**:
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install uv
|
||||
RUN pip install uv
|
||||
|
||||
# Copy application
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
|
||||
# Install dependencies
|
||||
RUN uv sync --frozen
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Run application
|
||||
CMD ["uv", "run", "gunicorn", "-c", "gunicorn.conf.py", "src.app:create_app()"]
|
||||
```
|
||||
|
||||
### Reverse Proxy Configuration
|
||||
|
||||
**Recommended**: Deploy behind reverse proxy (Nginx, Traefik, Caddy) for:
|
||||
- HTTPS termination
|
||||
- Rate limiting (additional layer beyond app-level)
|
||||
- Static file caching
|
||||
- Request buffering
|
||||
|
||||
**Example Nginx Config**:
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name secretsanta.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://sneaky-klaus:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static {
|
||||
proxy_pass http://sneaky-klaus:8000/static;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
See [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md) for detailed authentication design.
|
||||
|
||||
**Summary**:
|
||||
- Admin: Password-based authentication with bcrypt hashing
|
||||
- Participant: Magic link authentication with time-limited tokens
|
||||
- Server-side sessions with secure cookies
|
||||
- Rate limiting on all authentication endpoints
|
||||
|
||||
### Security Headers
|
||||
|
||||
Flask-Talisman configured with:
|
||||
- `Content-Security-Policy`: Restrict script sources
|
||||
- `X-Frame-Options`: Prevent clickjacking
|
||||
- `X-Content-Type-Options`: Prevent MIME sniffing
|
||||
- `Strict-Transport-Security`: Enforce HTTPS
|
||||
- `Referrer-Policy`: Control referrer information
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
Flask-WTF provides CSRF tokens for all forms:
|
||||
- Tokens embedded in forms automatically
|
||||
- Tokens validated on POST/PUT/DELETE requests
|
||||
- SameSite cookie attribute provides additional protection
|
||||
|
||||
### Input Validation
|
||||
|
||||
- WTForms for form validation
|
||||
- SQLAlchemy parameterized queries prevent SQL injection
|
||||
- Jinja2 auto-escaping prevents XSS
|
||||
- Email validation for all email inputs
|
||||
|
||||
### Secrets Management
|
||||
|
||||
- All secrets stored in environment variables
|
||||
- Never committed to version control
|
||||
- Docker secrets or .env file for local development
|
||||
- Secret rotation supported through environment updates
|
||||
|
||||
## Data Flow Examples
|
||||
|
||||
### Participant Registration Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as Participant Browser
|
||||
participant F as Flask App
|
||||
participant DB as SQLite Database
|
||||
participant E as Resend Email
|
||||
|
||||
P->>F: GET /exchange/{id}/register
|
||||
F->>DB: Query exchange details
|
||||
DB-->>F: Exchange data
|
||||
F-->>P: Registration form
|
||||
|
||||
P->>F: POST /exchange/{id}/register (name, email, gift ideas)
|
||||
F->>F: Validate form
|
||||
F->>DB: Check email uniqueness in exchange
|
||||
F->>DB: Insert participant record
|
||||
DB-->>F: Participant created
|
||||
F->>F: Generate magic link token
|
||||
F->>DB: Store auth token
|
||||
F->>E: Send confirmation email with magic link
|
||||
E-->>F: Email accepted
|
||||
F-->>P: Registration success page
|
||||
```
|
||||
|
||||
### Matching Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Admin Browser
|
||||
participant F as Flask App
|
||||
participant M as Matching Service
|
||||
participant DB as SQLite Database
|
||||
participant E as Resend Email
|
||||
|
||||
A->>F: POST /admin/exchange/{id}/match
|
||||
F->>DB: Get all participants
|
||||
F->>DB: Get exclusion rules
|
||||
F->>M: Execute matching algorithm
|
||||
M->>M: Generate valid assignments
|
||||
M-->>F: Match assignments
|
||||
F->>DB: Store matches (transaction)
|
||||
DB-->>F: Matches saved
|
||||
F->>E: Send match notification to each participant
|
||||
E-->>F: Emails queued
|
||||
F->>DB: Update exchange state to "Matched"
|
||||
F-->>A: Matching complete
|
||||
```
|
||||
|
||||
### Reminder Email Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as APScheduler
|
||||
participant F as Flask App
|
||||
participant DB as SQLite Database
|
||||
participant E as Resend Email
|
||||
|
||||
S->>F: Trigger reminder job
|
||||
F->>DB: Query exchanges needing reminders today
|
||||
DB-->>F: Exchange list
|
||||
loop For each exchange
|
||||
F->>DB: Get opted-in participants
|
||||
DB-->>F: Participant list
|
||||
loop For each participant
|
||||
F->>DB: Get participant's match
|
||||
F->>E: Send reminder email
|
||||
E-->>F: Email sent
|
||||
end
|
||||
end
|
||||
F->>DB: Log reminder job completion
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Expected Load
|
||||
|
||||
- **Concurrent Users**: 10-50 typical, 100 maximum
|
||||
- **Exchanges**: 10-100 per installation
|
||||
- **Participants per Exchange**: 3-100 typical, 500 maximum
|
||||
- **Database Size**: <100MB typical, <1GB maximum
|
||||
|
||||
### Scaling Strategy
|
||||
|
||||
**Vertical Scaling**: Increase container resources (CPU, memory) as needed
|
||||
|
||||
**Horizontal Scaling**: Not supported due to SQLite limitation. If horizontal scaling becomes necessary:
|
||||
1. Migrate database to PostgreSQL
|
||||
2. Externalize session storage (Redis)
|
||||
3. Deploy multiple application instances behind load balancer
|
||||
|
||||
For the target use case (self-hosted Secret Santa), vertical scaling is sufficient.
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
**Initial Version**: No caching layer (premature optimization)
|
||||
|
||||
**Future Optimization** (if needed):
|
||||
- Flask-Caching for expensive queries (participant lists, exchange details)
|
||||
- Redis for session storage (if horizontal scaling needed)
|
||||
- Reverse proxy caching for static assets
|
||||
|
||||
### Database Optimization
|
||||
|
||||
- Indexes on frequently queried fields (email, exchange_id, token_hash)
|
||||
- WAL mode for improved read concurrency
|
||||
- VACUUM scheduled periodically (after data purges)
|
||||
- Query optimization through SQLAlchemy query analysis
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Logging
|
||||
|
||||
**Python logging module** with structured logging:
|
||||
|
||||
- **Log Levels**: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
- **Log Format**: JSON for production, human-readable for development
|
||||
- **Log Outputs**: stdout (captured by Docker)
|
||||
|
||||
**Logged Events**:
|
||||
- Authentication attempts (success and failure)
|
||||
- Exchange state transitions
|
||||
- Matching operations (start, success, failure)
|
||||
- Email send operations
|
||||
- Background job execution
|
||||
- Error exceptions with stack traces
|
||||
|
||||
### Metrics (Future)
|
||||
|
||||
Potential metrics to track:
|
||||
- Request count and latency by endpoint
|
||||
- Authentication success/failure rates
|
||||
- Email delivery success rates
|
||||
- Background job execution duration
|
||||
- Database query performance
|
||||
|
||||
**Implementation**: Prometheus metrics endpoint (optional enhancement)
|
||||
|
||||
### Health Checks
|
||||
|
||||
**`/health` endpoint** returns:
|
||||
- HTTP 200: Application healthy
|
||||
- HTTP 503: Application unhealthy (database unreachable, critical failure)
|
||||
|
||||
**Checks**:
|
||||
- Database connectivity
|
||||
- Email service reachability (optional, cached)
|
||||
- Scheduler running status
|
||||
|
||||
## Disaster Recovery
|
||||
|
||||
### Backup Strategy
|
||||
|
||||
**Database Backup**:
|
||||
- SQLite file located at `/app/data/sneaky-klaus.db`
|
||||
- Backup via volume snapshots or file copy
|
||||
- Recommended frequency: Daily automatic backups
|
||||
- Retention: 30 days
|
||||
|
||||
**Backup Methods**:
|
||||
1. Volume snapshots (Docker volume backup)
|
||||
2. `sqlite3 .backup` command (online backup)
|
||||
3. File copy (requires application shutdown for consistency)
|
||||
|
||||
### Restore Procedure
|
||||
|
||||
1. Stop container
|
||||
2. Replace database file with backup
|
||||
3. Start container
|
||||
4. Verify application health
|
||||
|
||||
### Data Export
|
||||
|
||||
**Future Enhancement**: Admin export functionality
|
||||
- CSV export of participants and exchanges
|
||||
- JSON export for full data portability
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/user/sneaky-klaus.git
|
||||
cd sneaky-klaus
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Set up environment variables
|
||||
cp .env.example .env
|
||||
# Edit .env with local values
|
||||
|
||||
# Run database migrations
|
||||
uv run alembic upgrade head
|
||||
|
||||
# Run development server
|
||||
uv run flask run
|
||||
```
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
**Test Levels**:
|
||||
1. **Unit Tests**: Business logic, utilities (pytest)
|
||||
2. **Integration Tests**: Database operations, email sending (pytest + fixtures)
|
||||
3. **End-to-End Tests**: Full user flows (Playwright or Selenium)
|
||||
|
||||
**Test Coverage Target**: 80%+ for business logic
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
**Continuous Integration**:
|
||||
- Run tests on every commit
|
||||
- Lint code (ruff, mypy)
|
||||
- Build Docker image
|
||||
- Security scanning (bandit, safety)
|
||||
|
||||
**Continuous Deployment**:
|
||||
- Tag releases (semantic versioning)
|
||||
- Push Docker image to registry
|
||||
- Update deployment documentation
|
||||
|
||||
## Future Architectural Considerations
|
||||
|
||||
### Potential Enhancements
|
||||
|
||||
1. **Multi-tenancy**: Support multiple isolated admin accounts (requires significant schema changes)
|
||||
2. **PostgreSQL Support**: Optional PostgreSQL backend for larger deployments
|
||||
3. **Horizontal Scaling**: Redis session storage, multi-instance deployment
|
||||
4. **API**: REST API for programmatic access or mobile apps
|
||||
5. **Webhooks**: Notify external systems of events
|
||||
6. **Internationalization**: Multi-language support
|
||||
|
||||
### Migration Paths
|
||||
|
||||
If the application needs to scale beyond SQLite:
|
||||
|
||||
1. **Database Migration**: Alembic migrations can be adapted for PostgreSQL
|
||||
2. **Session Storage**: Move to Redis for distributed sessions
|
||||
3. **Job Queue**: Move to Celery + Redis for distributed background jobs
|
||||
4. **File Storage**: Move to S3-compatible storage if file uploads are added
|
||||
|
||||
These migrations would be disruptive and are not planned for initial versions.
|
||||
|
||||
## Conclusion
|
||||
|
||||
This architecture prioritizes simplicity and ease of self-hosting while maintaining security, reliability, and maintainability. The single-container deployment model minimizes operational complexity, making Sneaky Klaus accessible to non-technical users who want to self-host their Secret Santa exchanges.
|
||||
|
||||
The design is deliberately conservative, avoiding premature optimization and complex infrastructure. Future enhancements can be added incrementally without requiring fundamental architectural changes.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-0001: Core Technology Stack](../../decisions/0001-core-technology-stack.md)
|
||||
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
|
||||
- [Flask Documentation](https://flask.palletsprojects.com/)
|
||||
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
|
||||
- [APScheduler Documentation](https://apscheduler.readthedocs.io/)
|
||||
Reference in New Issue
Block a user