diff --git a/.claude/agents/architect.md b/.claude/agents/architect.md
index 52c81bf..31900f2 100644
--- a/.claude/agents/architect.md
+++ b/.claude/agents/architect.md
@@ -37,6 +37,41 @@ You are an expert in:
3. **AI-consumable output**: Your designs will be read and implemented by an AI developer subagent, not a human—structure your output for clarity and unambiguous interpretation
4. **Explicit over implicit**: State assumptions clearly; avoid ambiguity
5. **Security by default**: Design with security in mind from the start
+6. **Clean upgrade paths**: All designs must support existing installations upgrading seamlessly
+
+## Upgrade Path Requirements
+
+**CRITICAL**: Sneaky Klaus is now deployed in production. All designs must include:
+
+1. **Migration Strategy**: How will database schema changes be applied to existing data?
+2. **Data Preservation**: Existing exchanges, participants, and settings must never be lost
+3. **Backward Compatibility**: Consider whether old clients/data can work with new code
+4. **Rollback Plan**: What happens if an upgrade fails? Can users revert?
+
+### Database Changes
+
+- All schema changes MUST use Alembic migrations (never `db.create_all()`)
+- Migrations MUST be reversible (`upgrade()` and `downgrade()` functions)
+- New columns on existing tables MUST have defaults or be nullable
+- Column renames or type changes require careful data migration
+- Document migration steps in design documents
+
+### Breaking Changes
+
+If a breaking change is unavoidable:
+1. Document it clearly in the design
+2. Provide a migration path for existing data
+3. Consider a multi-step migration if needed
+4. Increment the MAJOR version number
+
+### Design Document Requirements
+
+Each design MUST include an **Upgrade Considerations** section covering:
+- Required database migrations
+- Data migration requirements
+- Configuration changes
+- Breaking changes (if any)
+- Rollback procedure
## Output Locations
diff --git a/.claude/agents/developer.md b/.claude/agents/developer.md
index 75c317c..6e390d8 100644
--- a/.claude/agents/developer.md
+++ b/.claude/agents/developer.md
@@ -26,6 +26,55 @@ You implement features based on designs provided by the architect. You write pro
4. **Stop on errors**: When you encounter failing tests, design inconsistencies, or blockers, stop and report to the coordinator immediately
5. **Clean code**: Follow Python best practices and PEP standards
6. **Mandatory docstrings**: All modules, classes, and functions must have docstrings
+7. **Clean upgrade paths**: All changes must support existing production installations
+
+## Upgrade Path Requirements
+
+**CRITICAL**: Sneaky Klaus is deployed in production with real user data. All changes must:
+
+1. **Preserve existing data**: Never lose exchanges, participants, or settings
+2. **Use Alembic migrations**: All database schema changes MUST use Alembic
+3. **Be reversible**: Migrations must have working `downgrade()` functions
+4. **Handle existing data**: New columns must have defaults or be nullable
+
+### Database Migration Rules
+
+```bash
+# NEVER use db.create_all() for schema changes
+# ALWAYS create migrations with:
+uv run alembic revision --autogenerate -m "description of change"
+
+# Review generated migration before committing
+# Ensure both upgrade() and downgrade() work correctly
+
+# Test migration on a copy of production data if possible
+```
+
+### Migration Best Practices
+
+1. **New columns on existing tables**:
+ - Must be nullable OR have a server_default
+ - Example: `sa.Column('new_field', sa.String(100), nullable=True)`
+
+2. **Renaming columns**:
+ - Use `op.alter_column()` with proper data preservation
+ - Never drop and recreate
+
+3. **Changing column types**:
+ - Create new column, migrate data, drop old column
+ - Or use `op.alter_column()` if type is compatible
+
+4. **Adding constraints**:
+ - Ensure existing data satisfies the constraint
+ - May need data cleanup migration first
+
+### Testing Migrations
+
+Before merging, verify:
+- [ ] Migration applies cleanly to fresh database
+- [ ] Migration applies cleanly to database with existing data
+- [ ] Downgrade works correctly
+- [ ] Application functions correctly after migration
## Code Style & Standards
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f0b500e
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,27 @@
+# Sneaky Klaus Environment Configuration
+#
+# Copy this file to .env and customize for your environment.
+# NEVER commit .env to version control!
+
+# Required: Secret key for session signing and CSRF protection
+# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
+SECRET_KEY=your-secret-key-here
+
+# Required for production: Resend API key for sending emails
+# Get your API key at https://resend.com
+# Leave empty for development mode (emails logged to stdout)
+RESEND_API_KEY=
+
+# Email sender address (must be from a verified domain in Resend)
+EMAIL_FROM=noreply@example.com
+
+# Public URL of your application (used in email links)
+# Include protocol, no trailing slash
+APP_URL=https://secretsanta.example.com
+
+# Environment mode: 'production' or 'development'
+# In development mode:
+# - Emails are logged to stdout instead of sent
+# - Debug mode is enabled
+# - Session cookies don't require HTTPS
+FLASK_ENV=production
diff --git a/docker-compose.example.yml b/docker-compose.example.yml
new file mode 100644
index 0000000..fb86120
--- /dev/null
+++ b/docker-compose.example.yml
@@ -0,0 +1,72 @@
+# Sneaky Klaus - Example Docker Compose Configuration
+#
+# Copy this file to docker-compose.yml and customize for your environment.
+#
+# Quick Start:
+# 1. cp docker-compose.example.yml docker-compose.yml
+# 2. Create .env file with your configuration (see .env.example)
+# 3. docker compose up -d
+# 4. Visit http://localhost:8000 to set up your admin account
+#
+# Upgrading:
+# docker compose pull
+# docker compose up -d
+# (Migrations run automatically on container start)
+
+version: "3.8"
+
+services:
+ sneaky-klaus:
+ image: git.thesatelliteoflove.com/phil/sneakyklaus:latest
+ container_name: sneaky-klaus
+ restart: unless-stopped
+ ports:
+ - "8000:8000"
+ environment:
+ # Required: Generate a secure secret key
+ # Example: python -c "import secrets; print(secrets.token_hex(32))"
+ - SECRET_KEY=${SECRET_KEY:?SECRET_KEY is required}
+
+ # Required for production email sending via Resend
+ # Get your API key at https://resend.com
+ - RESEND_API_KEY=${RESEND_API_KEY:-}
+
+ # Email sender address (must be verified domain with Resend)
+ - EMAIL_FROM=${EMAIL_FROM:-noreply@example.com}
+
+ # Public URL of your application (used in magic link emails)
+ - APP_URL=${APP_URL:-http://localhost:8000}
+
+ # Environment: 'production' or 'development'
+ # In development mode, emails are logged to stdout instead of sent
+ - FLASK_ENV=${FLASK_ENV:-production}
+ volumes:
+ # Persistent storage for SQLite database and session files
+ # Database migrations are applied automatically on container start
+ - sneaky-klaus-data:/app/data
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
+
+volumes:
+ sneaky-klaus-data:
+ driver: local
+
+# Optional: Add a reverse proxy for HTTPS
+# Example with Traefik:
+#
+# services:
+# sneaky-klaus:
+# labels:
+# - "traefik.enable=true"
+# - "traefik.http.routers.sneaky-klaus.rule=Host(`secretsanta.example.com`)"
+# - "traefik.http.routers.sneaky-klaus.tls.certresolver=letsencrypt"
+# networks:
+# - traefik
+#
+# networks:
+# traefik:
+# external: true
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e7fedac
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,38 @@
+version: "3.8"
+
+services:
+ sneaky-klaus:
+ image: git.thesatelliteoflove.com/phil/sneakyklaus:latest
+ container_name: sneaky-klaus
+ restart: unless-stopped
+ ports:
+ - "8000:8000"
+ environment:
+ # Required: Generate a secure secret key
+ # Example: python -c "import secrets; print(secrets.token_hex(32))"
+ - SECRET_KEY=${SECRET_KEY:?SECRET_KEY is required}
+
+ # Required for production email sending
+ - RESEND_API_KEY=${RESEND_API_KEY:-}
+
+ # Email sender address (must be verified with Resend)
+ - EMAIL_FROM=${EMAIL_FROM:-noreply@example.com}
+
+ # Public URL of your application (used for magic links)
+ - APP_URL=${APP_URL:-http://localhost:8000}
+
+ # Set to 'development' for testing (logs emails instead of sending)
+ - FLASK_ENV=${FLASK_ENV:-production}
+ volumes:
+ # Persistent storage for SQLite database and sessions
+ - sneaky-klaus-data:/app/data
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
+
+volumes:
+ sneaky-klaus-data:
+ driver: local
diff --git a/docs/decisions/0006-participant-state-management.md b/docs/decisions/0006-participant-state-management.md
new file mode 100644
index 0000000..7130480
--- /dev/null
+++ b/docs/decisions/0006-participant-state-management.md
@@ -0,0 +1,252 @@
+# ADR-0006: Participant State Management
+
+**Status**: Accepted
+**Date**: 2025-12-22
+**Deciders**: Architect
+**Phase**: v0.3.0
+
+## Context
+
+Participants need to manage their own profiles and potentially withdraw from exchanges. We need to decide:
+
+1. When can participants update their profiles (name, gift ideas)?
+2. When can participants withdraw from an exchange?
+3. How should withdrawals be implemented (hard delete vs soft delete)?
+4. Should withdrawn participants be visible in any context?
+5. Can withdrawn participants re-join the same exchange?
+
+These decisions impact data integrity, user experience, and admin workflows.
+
+## Decision
+
+### 1. Profile Update Rules
+
+**Profile updates are allowed until matching occurs.**
+
+Participants can update their name and gift ideas when the exchange is in:
+- `draft` state
+- `registration_open` state
+- `registration_closed` state
+
+Profile updates are **locked** when the exchange is in:
+- `matched` state
+- `completed` state
+
+**Rationale**:
+- Gift ideas are sent to Secret Santas during match notification
+- Allowing changes after matching would create inconsistency (giver sees old version in email)
+- Name changes after matching could confuse participants about who they're buying for
+- Registration close is admin's signal to finalize participant list, not to lock profiles
+- Locking happens at matching, which is the point where profile data is "consumed" by the system
+
+### 2. Withdrawal Rules
+
+**Withdrawals are allowed until registration closes.**
+
+Participants can withdraw when the exchange is in:
+- `draft` state
+- `registration_open` state
+
+Withdrawals require admin intervention when the exchange is in:
+- `registration_closed` state (admin may be configuring exclusions)
+- `matched` state (would require re-matching)
+- `completed` state (historical record)
+
+**Rationale**:
+- Before registration closes: minimal impact, just removes one participant
+- After registration closes: admin is likely configuring exclusions or preparing to match
+- After matching: re-matching is a significant operation that should be admin-controlled
+- Clear deadline (registration close) sets expectations for participants
+- Prevents last-minute dropouts that could disrupt matching
+
+### 3. Withdrawal Implementation (Soft Delete)
+
+**Withdrawals use soft delete via `withdrawn_at` timestamp.**
+
+Technical implementation:
+- Set `participant.withdrawn_at = datetime.utcnow()` on withdrawal
+- Keep participant record in database
+- Filter out withdrawn participants in queries: `Participant.withdrawn_at.is_(None)`
+- Cascade rules remain unchanged (deleting exchange deletes all participants)
+
+**Rationale**:
+- **Audit trail**: Preserves record of who registered and when they withdrew
+- **Email uniqueness**: Prevents re-registration with same email in same exchange (see Decision 5)
+- **Admin visibility**: Admins can see withdrawal history for troubleshooting
+- **Simplicity**: No cascade delete complexity or foreign key violations
+- **Existing pattern**: Data model already includes `withdrawn_at` field (v0.2.0 design)
+
+Alternative considered: Hard delete participants on withdrawal
+- Rejected: Loses audit trail, allows immediate re-registration (see Decision 5)
+- Rejected: Requires careful cascade handling for tokens, exclusions
+- Rejected: Complicates participant count tracking
+
+### 4. Withdrawn Participant Visibility
+
+**Withdrawn participants are visible only to admin.**
+
+Visibility rules:
+- **Participant list (participant view)**: Withdrawn participants excluded
+- **Participant list (admin view)**: Withdrawn participants shown with indicator (e.g., grayed out, "Withdrawn" badge)
+- **Participant count**: Counts exclude withdrawn participants
+- **Matching algorithm**: Withdrawn participants excluded from matching pool
+
+**Rationale**:
+- **Privacy**: Respects participant's decision to withdraw (no public record)
+- **Admin needs**: Admin may need to see who withdrew (for follow-up, re-invites, etc.)
+- **Clean UX**: Participants see only active participants (less confusing)
+- **Data integrity**: Admin view preserves audit trail
+
+### 5. Re-Registration After Withdrawal
+
+**Withdrawn participants cannot re-join the same exchange (with same email).**
+
+Technical enforcement:
+- Unique constraint on `(exchange_id, email)` remains in place
+- Soft delete doesn't remove the record, so email remains "taken"
+- Participant must use a different email to re-register
+
+**Rationale**:
+- **Prevents gaming**: Stops participants from withdrawing to see participant list changes, then re-joining
+- **Simplifies logic**: No need to handle "re-activation" of withdrawn participants
+- **Clear consequence**: Withdrawal is final (as warned in UI)
+- **Data integrity**: Each participant registration is a distinct record
+
+Alternative considered: Allow re-activation of withdrawn participants
+- Rejected: Complex state transitions (withdrawn → active → withdrawn → active)
+- Rejected: Unclear UX (does re-joining restore old profile or create new?)
+- Rejected: Enables abuse (withdraw/rejoin cycle)
+
+If participant genuinely needs to rejoin:
+- Use a different email address (e.g., alias like user+exchange@example.com)
+- Or: Contact admin, who can manually delete the withdrawn record (future admin feature)
+
+### 6. Reminder Preferences After Withdrawal
+
+**Withdrawn participants do not receive reminder emails.**
+
+Technical implementation:
+- Reminder job queries exclude withdrawn participants: `withdrawn_at IS NULL`
+- Reminder preference persists in database (for audit) but is not used
+
+**Rationale**:
+- Withdrawn participants have no match to be reminded about
+- Sending reminders would be confusing and violate withdrawal expectations
+- Simple filter in reminder job handles this naturally
+
+## Consequences
+
+### Positive
+
+1. **Clear rules**: Participants know when they can update profiles or withdraw
+2. **Data integrity**: Matching always uses consistent profile data
+3. **Audit trail**: System preserves record of all registrations and withdrawals
+4. **Simple implementation**: Soft delete is easier than hard delete + cascades
+5. **Privacy**: Withdrawn participants not visible to other participants
+6. **Admin control**: Admin retains visibility for troubleshooting
+
+### Negative
+
+1. **No re-join**: Participants who withdraw accidentally must use different email
+2. **Email "wastage"**: Withdrawn participants' emails remain "taken" in that exchange
+3. **Database growth**: Withdrawn participants remain in database (minimal impact given small datasets)
+
+### Mitigations
+
+1. **Clear warnings**: UI prominently warns that withdrawal is permanent and cannot be undone
+2. **Confirmation required**: Withdrawal requires explicit checkbox confirmation
+3. **Confirmation email**: Withdrawn participants receive email confirming withdrawal
+4. **Admin override** (future): Admin can manually delete withdrawn participants if needed
+
+## Implementation Notes
+
+### State Check Function
+
+```python
+def can_update_profile(participant: Participant) -> bool:
+ """Check if participant can update their profile."""
+ exchange = participant.exchange
+ allowed_states = ['draft', 'registration_open', 'registration_closed']
+ return exchange.state in allowed_states
+
+
+def can_withdraw(participant: Participant) -> bool:
+ """Check if participant can withdraw from the exchange."""
+ if participant.withdrawn_at is not None:
+ return False # Already withdrawn
+
+ exchange = participant.exchange
+ allowed_states = ['draft', 'registration_open']
+ return exchange.state in allowed_states
+```
+
+### Query Pattern for Active Participants
+
+```python
+# Get active participants only
+active_participants = Participant.query.filter(
+ Participant.exchange_id == exchange_id,
+ Participant.withdrawn_at.is_(None)
+).all()
+
+# Count active participants
+active_count = Participant.query.filter(
+ Participant.exchange_id == exchange_id,
+ Participant.withdrawn_at.is_(None)
+).count()
+```
+
+### Admin View Enhancement (Future)
+
+```python
+# Admin can see all participants including withdrawn
+all_participants = Participant.query.filter(
+ Participant.exchange_id == exchange_id
+).all()
+
+# Template can check: participant.withdrawn_at is not None
+```
+
+## Related Decisions
+
+- [ADR-0002: Authentication Strategy](0002-authentication-strategy.md) - Participant session management
+- [ADR-0003: Participant Session Scoping](0003-participant-session-scoping.md) - Session behavior on withdrawal
+- [v0.2.0 Data Model](../designs/v0.2.0/data-model.md) - `withdrawn_at` field design
+- [v0.3.0 Participant Self-Management](../designs/v0.3.0/participant-self-management.md) - Implementation details
+
+## Future Considerations
+
+### Phase 6: Admin Participant Management
+
+When implementing admin participant removal (Epic 9):
+- Admin should be able to hard delete withdrawn participants (cleanup)
+- Admin should be able to remove active participants (sets withdrawn_at + sends notification)
+- Admin should see withdrawal history in participant list
+
+### Phase 8: Matching
+
+Matching algorithm must:
+- Filter participants by `withdrawn_at IS NULL`
+- Validate participant count >= 3 (after filtering)
+- Handle case where withdrawals reduce count below minimum
+
+### Potential Future Enhancement: Re-Activation
+
+If user demand requires allowing re-join:
+- Add `reactivated_at` timestamp
+- Track withdrawal/reactivation history (audit log)
+- Clear `withdrawn_at` on re-activation
+- Send re-activation email
+- Complexity: High, defer until proven necessary
+
+## References
+
+- [Product Backlog](../BACKLOG.md) - Epic 6: Participant Self-Management
+- [Project Overview](../PROJECT_OVERVIEW.md) - Self-management principles
+- [v0.2.0 Data Model](../designs/v0.2.0/data-model.md) - Participant table schema
+
+---
+
+**Decision Date**: 2025-12-22
+**Architect**: Claude Opus 4.5
+**Status**: Accepted for v0.3.0
diff --git a/docs/designs/v0.3.0/README.md b/docs/designs/v0.3.0/README.md
new file mode 100644
index 0000000..9334677
--- /dev/null
+++ b/docs/designs/v0.3.0/README.md
@@ -0,0 +1,278 @@
+# Phase 3 (v0.3.0) Design Documentation
+
+**Version**: 0.3.0
+**Date**: 2025-12-22
+**Status**: Ready for Implementation
+
+## Quick Start
+
+Phase 3 implements participant self-management features, building on the authentication foundation from Phase 2.
+
+**Core Features**:
+- Participant list view (see who else registered)
+- Profile updates (name and gift ideas)
+- Reminder email preferences
+- Participant withdrawal (before registration closes)
+
+## Document Index
+
+### 1. [System Overview](overview.md)
+High-level architecture, goals, and design decisions for Phase 3.
+
+**Read this first** to understand:
+- What's in scope for v0.3.0
+- Key design decisions (profile locks, withdrawal rules)
+- Data flow diagrams
+- State machine changes
+
+**Key sections**:
+- Phase Goals (page 1)
+- Key Design Decisions (page 2)
+- State Machine Impact (page 4)
+
+### 2. [Participant Self-Management Component Design](participant-self-management.md)
+Detailed component specifications for all Phase 3 features.
+
+**Use this for**:
+- Exact function signatures and implementations
+- Form definitions
+- Route specifications
+- Template structures
+- Email templates
+
+**Key sections**:
+- Business Logic Functions (page 1)
+- Forms (page 6)
+- Routes (page 8)
+- Templates (page 11)
+- Security Checklist (page 19)
+
+### 3. [Test Plan](test-plan.md)
+Comprehensive testing specifications for Phase 3.
+
+**Use this for**:
+- Unit test cases and fixtures
+- Integration test scenarios
+- Acceptance test procedures
+- Manual QA steps
+- Coverage requirements
+
+**Key sections**:
+- Unit Tests (page 1)
+- Integration Tests (page 4)
+- Acceptance Tests (page 9)
+- Edge Cases (page 11)
+
+### 4. [Implementation Guide](implementation-guide.md)
+Step-by-step implementation instructions using TDD.
+
+**Follow this to**:
+- Implement features in correct order
+- Write tests first, then code
+- Verify each feature before moving on
+- Create proper commits and PRs
+
+**Key sections**:
+- Implementation Order (page 1)
+- Phase 3.1: Participant List (page 2)
+- Phase 3.2: Profile Updates (page 4)
+- Phase 3.3: Reminder Preferences (page 7)
+- Phase 3.4: Withdrawal (page 9)
+- Final Steps (page 14)
+
+## Architecture Decision Records
+
+### [ADR-0006: Participant State Management](../../decisions/0006-participant-state-management.md)
+Documents key decisions about when participants can update profiles, withdraw, and how withdrawals are implemented.
+
+**Key decisions**:
+- Profile updates allowed until matching
+- Withdrawals allowed until registration closes
+- Soft delete implementation (withdrawn_at timestamp)
+- Withdrawn participants visible only to admin
+- No re-registration with same email
+
+## User Stories Implemented
+
+Phase 3 completes these backlog items:
+
+### Epic 4: Participant Registration
+- ✅ **Story 4.5**: View Participant List (Pre-Matching)
+
+### Epic 6: Participant Self-Management
+- ✅ **Story 6.1**: Update Profile
+- ✅ **Story 6.2**: Withdraw from Exchange
+- ✅ **Story 6.3**: Update Reminder Preferences
+
+## Dependencies
+
+### Prerequisites (from Phase 2)
+- ✅ Participant model with `withdrawn_at` field
+- ✅ Participant authentication (@participant_required decorator)
+- ✅ Participant dashboard route
+- ✅ Email service for sending emails
+
+### No New Dependencies
+Phase 3 requires **no new**:
+- Python packages
+- Database migrations
+- Environment variables
+- External services
+
+## Technical Highlights
+
+### New Files Created
+```
+src/
+ utils/participant.py # Business logic functions
+ services/withdrawal.py # Withdrawal service
+
+templates/
+ participant/
+ profile_edit.html # Profile edit page
+ withdraw.html # Withdrawal confirmation
+ emails/
+ participant/
+ withdrawal_confirmation.html # Withdrawal email
+ withdrawal_confirmation.txt # Plain text version
+
+tests/
+ unit/
+ test_participant_utils.py # Unit tests for business logic
+ test_withdrawal_service.py # Unit tests for withdrawal
+ integration/
+ test_profile_update.py # Profile update integration tests
+ test_withdrawal.py # Withdrawal integration tests
+ test_participant_list.py # Participant list tests
+ test_reminder_preferences.py # Preference update tests
+```
+
+### Modified Files
+```
+src/
+ routes/participant.py # New routes added
+ forms/participant.py # New forms added
+ services/email.py # Withdrawal email method added
+
+templates/
+ participant/dashboard.html # Enhanced with participant list
+```
+
+## Design Principles Applied
+
+Phase 3 adheres to project principles:
+
+1. **Simplicity First**
+ - No new database tables (uses existing fields)
+ - No new external dependencies
+ - Soft delete instead of complex cascade handling
+
+2. **State-Based Permissions**
+ - Clear rules about when operations are allowed
+ - Based on exchange state (draft, open, closed, matched)
+ - Easy to test and reason about
+
+3. **TDD Approach**
+ - Implementation guide follows test-first methodology
+ - Every feature has unit and integration tests
+ - 80%+ coverage maintained
+
+4. **Security by Design**
+ - All routes require authentication
+ - State validation prevents unauthorized operations
+ - CSRF protection on all POST operations
+ - Input sanitization via WTForms and Jinja2
+
+5. **Privacy-Conscious**
+ - Participant list shows names only (no emails)
+ - Withdrawn participants hidden from other participants
+ - Gift ideas not revealed until matching
+
+## Implementation Checklist
+
+Use this to track progress:
+
+- [ ] **Phase 3.1: Participant List View**
+ - [ ] Create `src/utils/participant.py`
+ - [ ] Write unit tests for utility functions
+ - [ ] Update dashboard route
+ - [ ] Update dashboard template
+ - [ ] Write integration tests
+ - [ ] Manual QA
+
+- [ ] **Phase 3.2: Profile Updates**
+ - [ ] Add `can_update_profile()` function
+ - [ ] Write unit tests for state validation
+ - [ ] Create `ProfileUpdateForm`
+ - [ ] Create profile edit route
+ - [ ] Create profile edit template
+ - [ ] Update dashboard with edit link
+ - [ ] Write integration tests
+ - [ ] Manual QA
+
+- [ ] **Phase 3.3: Reminder Preferences**
+ - [ ] Create `ReminderPreferenceForm`
+ - [ ] Create preference update route
+ - [ ] Update dashboard with preference form
+ - [ ] Write integration tests
+ - [ ] Manual QA
+
+- [ ] **Phase 3.4: Withdrawal**
+ - [ ] Add `can_withdraw()` function
+ - [ ] Create `src/services/withdrawal.py`
+ - [ ] Write unit tests for withdrawal service
+ - [ ] Create `WithdrawForm`
+ - [ ] Create withdrawal route
+ - [ ] Create email templates
+ - [ ] Update email service
+ - [ ] Create withdrawal template
+ - [ ] Update dashboard with withdraw link
+ - [ ] Write integration tests
+ - [ ] Manual QA
+
+- [ ] **Final Steps**
+ - [ ] Run all tests (≥ 80% coverage)
+ - [ ] Run linting and type checking
+ - [ ] Complete manual QA from test plan
+ - [ ] Update documentation if needed
+ - [ ] Create feature branch
+ - [ ] Commit changes
+ - [ ] Create pull request
+
+## Success Criteria
+
+Phase 3 is complete when:
+
+1. ✅ All user stories have passing acceptance tests
+2. ✅ Code coverage ≥ 80%
+3. ✅ All linting and type checking passes
+4. ✅ Manual QA completed
+5. ✅ Security checklist verified
+6. ✅ Accessibility tests pass
+7. ✅ Browser compatibility verified
+8. ✅ Phase 2 regression tests still pass
+9. ✅ Documentation updated
+10. ✅ Pull request approved and merged
+
+## What's Next: Phase 4
+
+After Phase 3 is complete, the next logical phase is:
+
+**Phase 4 (v0.4.0)**: Post-Matching Participant Experience (Epic 11)
+- View assigned recipient (match assignment)
+- View recipient's gift ideas
+- View exchange information post-matching
+- Participant list (post-matching version)
+
+This builds on Phase 3's participant dashboard foundation and enables the core Secret Santa experience after admin has matched participants.
+
+## Questions?
+
+- Review the [Project Overview](../../PROJECT_OVERVIEW.md) for product vision
+- Check the [Backlog](../../BACKLOG.md) for user stories
+- See [v0.2.0 Design](../v0.2.0/overview.md) for foundation architecture
+- Consult existing [ADRs](../../decisions/) for architectural context
+
+---
+
+**Phase 3 Design Status**: ✅ Complete and Ready for Implementation
diff --git a/docs/designs/v0.3.0/implementation-guide.md b/docs/designs/v0.3.0/implementation-guide.md
new file mode 100644
index 0000000..981b197
--- /dev/null
+++ b/docs/designs/v0.3.0/implementation-guide.md
@@ -0,0 +1,1269 @@
+# Implementation Guide - v0.3.0
+
+**Version**: 0.3.0
+**Date**: 2025-12-22
+**Status**: Developer Guide
+
+## Overview
+
+This guide provides step-by-step instructions for implementing Phase 3 (Participant Self-Management). Follow the TDD approach: write tests first, implement to pass.
+
+## Prerequisites
+
+Before starting Phase 3 implementation:
+
+- ✅ Phase 2 (v0.2.0) is complete and merged to main
+- ✅ Working directory is clean (`git status`)
+- ✅ All Phase 2 tests pass (`uv run pytest`)
+- ✅ Development environment is set up (`uv sync`)
+
+## Implementation Order
+
+Implement features in this order (vertical slices, TDD):
+
+1. **Phase 3.1**: Participant List View (Story 4.5) - Simplest, no state changes
+2. **Phase 3.2**: Profile Updates (Story 6.1) - Core self-management
+3. **Phase 3.3**: Reminder Preferences (Story 6.3) - Simple toggle
+4. **Phase 3.4**: Withdrawal (Story 6.2) - Most complex, benefits from solid foundation
+
+## Phase 3.1: Participant List View
+
+**Goal**: Show list of active participants on dashboard
+
+### Step 1: Create Utility Functions
+
+**File**: `src/utils/participant.py` (new file)
+
+```bash
+# Create the file
+touch src/utils/participant.py
+```
+
+**Implementation**:
+
+```python
+"""Participant business logic utilities."""
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from src.models import Participant
+
+
+def get_active_participants(exchange_id: int) -> list['Participant']:
+ """Get all active (non-withdrawn) participants for an exchange.
+
+ Args:
+ exchange_id: ID of the exchange
+
+ Returns:
+ List of active participants, ordered by name
+ """
+ from src.models import Participant
+
+ return Participant.query.filter(
+ Participant.exchange_id == exchange_id,
+ Participant.withdrawn_at.is_(None)
+ ).order_by(Participant.name).all()
+
+
+def is_withdrawn(participant: 'Participant') -> bool:
+ """Check if participant has withdrawn.
+
+ Args:
+ participant: The participant to check
+
+ Returns:
+ True if withdrawn, False otherwise
+ """
+ return participant.withdrawn_at is not None
+```
+
+### Step 2: Write Unit Tests
+
+**File**: `tests/unit/test_participant_utils.py` (new file)
+
+```python
+"""Unit tests for participant utility functions."""
+import pytest
+from datetime import datetime
+from src.utils.participant import get_active_participants, is_withdrawn
+
+
+def test_get_active_participants_excludes_withdrawn(db, exchange_factory, participant_factory):
+ """Test that get_active_participants excludes withdrawn participants."""
+ exchange = exchange_factory()
+
+ # Create 2 active, 1 withdrawn
+ active1 = participant_factory(exchange=exchange, name='Alice')
+ active2 = participant_factory(exchange=exchange, name='Bob')
+ withdrawn = participant_factory(exchange=exchange, name='Charlie', withdrawn_at=datetime.utcnow())
+
+ participants = get_active_participants(exchange.id)
+
+ assert len(participants) == 2
+ assert active1 in participants
+ assert active2 in participants
+ assert withdrawn not in participants
+
+
+def test_get_active_participants_ordered_by_name(db, exchange_factory, participant_factory):
+ """Test that participants are ordered alphabetically."""
+ exchange = exchange_factory()
+
+ zoe = participant_factory(exchange=exchange, name='Zoe')
+ alice = participant_factory(exchange=exchange, name='Alice')
+ bob = participant_factory(exchange=exchange, name='Bob')
+
+ participants = get_active_participants(exchange.id)
+
+ assert participants[0].name == 'Alice'
+ assert participants[1].name == 'Bob'
+ assert participants[2].name == 'Zoe'
+
+
+def test_is_withdrawn_true(participant_factory):
+ """Test is_withdrawn returns True for withdrawn participant."""
+ participant = participant_factory(withdrawn_at=datetime.utcnow())
+ assert is_withdrawn(participant) is True
+
+
+def test_is_withdrawn_false(participant_factory):
+ """Test is_withdrawn returns False for active participant."""
+ participant = participant_factory()
+ assert is_withdrawn(participant) is False
+```
+
+**Run tests**: `uv run pytest tests/unit/test_participant_utils.py -v`
+
+### Step 3: Update Dashboard Route
+
+**File**: `src/routes/participant.py`
+
+Update the existing dashboard route:
+
+```python
+from src.utils.participant import get_active_participants
+
+@participant_bp.route('/participant/dashboard')
+@participant_required
+def dashboard():
+ """Participant dashboard showing exchange info and participant list."""
+ participant = g.participant
+ exchange = participant.exchange
+
+ # Get list of active participants
+ participants = get_active_participants(exchange.id)
+
+ return render_template(
+ 'participant/dashboard.html',
+ participant=participant,
+ exchange=exchange,
+ participants=participants,
+ participant_count=len(participants)
+ )
+```
+
+### Step 4: Update Dashboard Template
+
+**File**: `templates/participant/dashboard.html`
+
+Add participant list section:
+
+```html
+
+
+
+
+ Participants ({{ participant_count }})
+ {% if participants %}
+
+ {% for p in participants %}
+ -
+ {{ p.name }}
+ {% if p.id == participant.id %}
+ You
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+ No other participants yet. Share the registration link!
+ {% endif %}
+
+```
+
+### Step 5: Write Integration Tests
+
+**File**: `tests/integration/test_participant_list.py` (new file)
+
+```python
+"""Integration tests for participant list functionality."""
+import pytest
+from flask import url_for
+
+
+def test_participant_list_shows_all_active(client, auth_participant, participant_factory):
+ """Test participant list shows all active participants."""
+ # auth_participant fixture creates session and one participant
+ exchange = auth_participant.exchange
+
+ # Create 2 more active participants
+ participant_factory(exchange=exchange, name='Bob')
+ participant_factory(exchange=exchange, name='Charlie')
+
+ response = client.get(url_for('participant.dashboard'))
+
+ assert response.status_code == 200
+ assert b'Bob' in response.data
+ assert b'Charlie' in response.data
+
+
+def test_participant_list_excludes_withdrawn(client, auth_participant, participant_factory):
+ """Test withdrawn participants are not shown."""
+ exchange = auth_participant.exchange
+
+ # Create active and withdrawn participants
+ participant_factory(exchange=exchange, name='Active')
+ participant_factory(exchange=exchange, name='Withdrawn', withdrawn_at=datetime.utcnow())
+
+ response = client.get(url_for('participant.dashboard'))
+
+ assert b'Active' in response.data
+ assert b'Withdrawn' not in response.data
+```
+
+**Run tests**: `uv run pytest tests/integration/test_participant_list.py -v`
+
+### Step 6: Manual QA
+
+1. Start dev server: `uv run flask run`
+2. Create exchange, register 3 participants
+3. Login as first participant
+4. Verify participant list shows other 2 participants
+5. Register 4th participant
+6. Refresh dashboard, verify 4th participant appears
+
+**Checkpoint**: Participant list feature complete
+
+---
+
+## Phase 3.2: Profile Updates
+
+**Goal**: Allow participants to update name and gift ideas before matching
+
+### Step 1: Create State Validation Function
+
+**File**: `src/utils/participant.py` (add to existing file)
+
+```python
+def can_update_profile(participant: 'Participant') -> bool:
+ """Check if participant can update their profile.
+
+ Profile updates are allowed until matching occurs.
+
+ Args:
+ participant: The participant to check
+
+ Returns:
+ True if profile updates are allowed, False otherwise
+ """
+ exchange = participant.exchange
+ allowed_states = ['draft', 'registration_open', 'registration_closed']
+ return exchange.state in allowed_states
+```
+
+### Step 2: Write Unit Tests for State Validation
+
+**File**: `tests/unit/test_participant_utils.py` (add to existing)
+
+```python
+from src.utils.participant import can_update_profile
+
+
+def test_can_update_profile_draft_state(participant_factory, exchange_factory):
+ """Profile updates allowed in draft state."""
+ exchange = exchange_factory(state='draft')
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is True
+
+
+def test_can_update_profile_registration_open(participant_factory, exchange_factory):
+ """Profile updates allowed when registration open."""
+ exchange = exchange_factory(state='registration_open')
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is True
+
+
+def test_can_update_profile_registration_closed(participant_factory, exchange_factory):
+ """Profile updates allowed when registration closed."""
+ exchange = exchange_factory(state='registration_closed')
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is True
+
+
+def test_can_update_profile_matched_state(participant_factory, exchange_factory):
+ """Profile updates blocked after matching."""
+ exchange = exchange_factory(state='matched')
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is False
+
+
+def test_can_update_profile_completed_state(participant_factory, exchange_factory):
+ """Profile updates blocked when completed."""
+ exchange = exchange_factory(state='completed')
+ participant = participant_factory(exchange=exchange)
+ assert can_update_profile(participant) is False
+```
+
+### Step 3: Create Profile Update Form
+
+**File**: `src/forms/participant.py` (add to existing)
+
+```python
+class ProfileUpdateForm(FlaskForm):
+ """Form for updating participant profile."""
+ name = StringField(
+ 'Name',
+ validators=[
+ DataRequired(message="Name is required"),
+ Length(min=1, max=255, message="Name must be 1-255 characters")
+ ],
+ description="Your display name (visible to other participants)"
+ )
+
+ gift_ideas = TextAreaField(
+ 'Gift Ideas',
+ validators=[
+ Length(max=10000, message="Gift ideas must be less than 10,000 characters")
+ ],
+ description="Optional wishlist or gift preferences for your Secret Santa",
+ render_kw={"rows": 6, "maxlength": 10000}
+ )
+
+ submit = SubmitField('Save Changes')
+```
+
+### Step 4: Create Profile Edit Route
+
+**File**: `src/routes/participant.py` (add to existing)
+
+```python
+from src.utils.participant import can_update_profile
+from src.forms.participant import ProfileUpdateForm
+
+@participant_bp.route('/participant/profile/edit', methods=['GET', 'POST'])
+@participant_required
+def profile_edit():
+ """Edit participant profile (name and gift ideas)."""
+ participant = g.participant
+
+ # Check if profile editing is allowed
+ if not can_update_profile(participant):
+ flash(
+ "Your profile is locked after matching. Contact the admin for changes.",
+ "error"
+ )
+ return redirect(url_for('participant.dashboard'))
+
+ # Create form with current values
+ form = ProfileUpdateForm(obj=participant)
+
+ if form.validate_on_submit():
+ try:
+ # Update participant
+ participant.name = form.name.data.strip()
+ participant.gift_ideas = form.gift_ideas.data.strip() if form.gift_ideas.data else None
+
+ db.session.commit()
+
+ flash("Your profile has been updated successfully.", "success")
+ return redirect(url_for('participant.dashboard'))
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"Failed to update participant profile: {e}")
+ flash("Failed to update profile. Please try again.", "error")
+
+ return render_template(
+ 'participant/profile_edit.html',
+ form=form,
+ participant=participant,
+ exchange=participant.exchange
+ )
+```
+
+### Step 5: Create Profile Edit Template
+
+**File**: `templates/participant/profile_edit.html` (new file)
+
+```html
+{% extends "layouts/participant.html" %}
+
+{% block title %}Edit Profile - {{ exchange.name }}{% endblock %}
+
+{% block content %}
+
+
Edit Your Profile
+
+
+ Update your display name and gift ideas.
+ Your Secret Santa will see this information after matching.
+
+
+
+
+
+
+{% endblock %}
+```
+
+### Step 6: Update Dashboard to Show Edit Link
+
+**File**: `templates/participant/dashboard.html` (update profile section)
+
+```html
+
+ Your Profile
+
+
+ {% if can_edit_profile %}
+
+ Edit Profile
+
+ {% endif %}
+
+```
+
+Update dashboard route to pass `can_edit_profile`:
+
+```python
+# In dashboard() route
+from src.utils.participant import can_update_profile
+
+can_edit = can_update_profile(participant)
+
+return render_template(
+ 'participant/dashboard.html',
+ # ... existing variables ...
+ can_edit_profile=can_edit
+)
+```
+
+### Step 7: Write Integration Tests
+
+**File**: `tests/integration/test_profile_update.py` (new file)
+
+```python
+"""Integration tests for profile update functionality."""
+import pytest
+from flask import url_for
+
+
+def test_profile_update_get_shows_form(client, auth_participant):
+ """GET shows edit form with current values."""
+ response = client.get(url_for('participant.profile_edit'))
+
+ assert response.status_code == 200
+ assert auth_participant.name.encode() in response.data
+ assert b'Edit Your Profile' in response.data
+
+
+def test_profile_update_post_success(client, auth_participant, db):
+ """POST updates profile successfully."""
+ response = client.post(
+ url_for('participant.profile_edit'),
+ data={
+ 'name': 'Updated Name',
+ 'gift_ideas': 'Updated ideas',
+ 'csrf_token': get_csrf_token(client, url_for('participant.profile_edit'))
+ },
+ follow_redirects=True
+ )
+
+ assert response.status_code == 200
+ assert b'profile has been updated' in response.data
+
+ # Verify database
+ db.session.refresh(auth_participant)
+ assert auth_participant.name == 'Updated Name'
+ assert auth_participant.gift_ideas == 'Updated ideas'
+
+
+def test_profile_update_locked_after_matching(client, auth_participant, db):
+ """Profile edit blocked after matching."""
+ auth_participant.exchange.state = 'matched'
+ db.session.commit()
+
+ response = client.get(url_for('participant.profile_edit'), follow_redirects=True)
+
+ assert b'profile is locked' in response.data
+```
+
+**Run tests**: `uv run pytest tests/integration/test_profile_update.py -v`
+
+**Checkpoint**: Profile update feature complete
+
+---
+
+## Phase 3.3: Reminder Preferences
+
+**Goal**: Allow toggling reminder email preference
+
+### Step 1: Create Reminder Preference Form
+
+**File**: `src/forms/participant.py` (add to existing)
+
+```python
+class ReminderPreferenceForm(FlaskForm):
+ """Form for updating reminder email preferences."""
+ reminder_enabled = BooleanField(
+ 'Send me reminder emails before the exchange date',
+ description="You can change this at any time"
+ )
+
+ submit = SubmitField('Update Preferences')
+```
+
+### Step 2: Create Preference Update Route
+
+**File**: `src/routes/participant.py` (add to existing)
+
+```python
+from src.forms.participant import ReminderPreferenceForm
+
+@participant_bp.route('/participant/preferences', methods=['POST'])
+@participant_required
+def update_preferences():
+ """Update participant reminder email preferences."""
+ participant = g.participant
+ form = ReminderPreferenceForm()
+
+ if form.validate_on_submit():
+ try:
+ participant.reminder_enabled = form.reminder_enabled.data
+ db.session.commit()
+
+ if form.reminder_enabled.data:
+ flash("Reminder emails enabled.", "success")
+ else:
+ flash("Reminder emails disabled.", "success")
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"Failed to update preferences: {e}")
+ flash("Failed to update preferences. Please try again.", "error")
+ else:
+ flash("Invalid request.", "error")
+
+ return redirect(url_for('participant.dashboard'))
+```
+
+### Step 3: Update Dashboard with Preference Form
+
+**File**: `templates/participant/dashboard.html` (add section)
+
+```html
+
+
+```
+
+Update dashboard route:
+
+```python
+# In dashboard() route
+reminder_form = ReminderPreferenceForm(
+ reminder_enabled=participant.reminder_enabled
+)
+
+return render_template(
+ # ... existing variables ...
+ reminder_form=reminder_form
+)
+```
+
+### Step 4: Write Integration Tests
+
+**File**: `tests/integration/test_reminder_preferences.py` (new file)
+
+```python
+"""Integration tests for reminder preferences."""
+import pytest
+from flask import url_for
+
+
+def test_update_preferences_enable(client, auth_participant, db):
+ """Enable reminder emails."""
+ auth_participant.reminder_enabled = False
+ db.session.commit()
+
+ response = client.post(
+ url_for('participant.update_preferences'),
+ data={
+ 'reminder_enabled': True,
+ 'csrf_token': get_csrf_token(client)
+ },
+ follow_redirects=True
+ )
+
+ assert b'Reminder emails enabled' in response.data
+ db.session.refresh(auth_participant)
+ assert auth_participant.reminder_enabled is True
+```
+
+**Checkpoint**: Reminder preferences complete
+
+---
+
+## Phase 3.4: Withdrawal
+
+**Goal**: Allow participants to withdraw before registration closes
+
+### Step 1: Create Withdrawal State Validation
+
+**File**: `src/utils/participant.py` (add to existing)
+
+```python
+def can_withdraw(participant: 'Participant') -> bool:
+ """Check if participant can withdraw from the exchange.
+
+ Withdrawals are only allowed before registration closes.
+
+ Args:
+ participant: The participant to check
+
+ Returns:
+ True if withdrawal is allowed, False otherwise
+ """
+ # Already withdrawn
+ if participant.withdrawn_at is not None:
+ return False
+
+ exchange = participant.exchange
+ allowed_states = ['draft', 'registration_open']
+ return exchange.state in allowed_states
+```
+
+### Step 2: Create Withdrawal Service
+
+**File**: `src/services/withdrawal.py` (new file)
+
+```python
+"""Participant withdrawal service."""
+from datetime import datetime
+from flask import current_app
+from src.models import Participant, db
+from src.services.email import EmailService
+from src.utils.participant import can_withdraw
+
+
+class WithdrawalError(Exception):
+ """Raised when withdrawal operation fails."""
+ pass
+
+
+def withdraw_participant(participant: Participant) -> None:
+ """Withdraw a participant from their exchange.
+
+ This performs a soft delete by setting withdrawn_at timestamp.
+
+ Args:
+ participant: The participant to withdraw
+
+ Raises:
+ WithdrawalError: If withdrawal is not allowed
+ """
+ # Validate withdrawal is allowed
+ if not can_withdraw(participant):
+ if participant.withdrawn_at is not None:
+ raise WithdrawalError("You have already withdrawn from this exchange.")
+
+ exchange = participant.exchange
+ if exchange.state == 'registration_closed':
+ raise WithdrawalError(
+ "Registration has closed. Please contact the admin to withdraw."
+ )
+ elif exchange.state in ['matched', 'completed']:
+ raise WithdrawalError(
+ "Matching has already occurred. Please contact the admin."
+ )
+ else:
+ raise WithdrawalError("Withdrawal is not allowed at this time.")
+
+ # Perform withdrawal
+ participant.withdrawn_at = datetime.utcnow()
+
+ try:
+ db.session.commit()
+ current_app.logger.info(
+ f"Participant {participant.id} withdrawn from exchange {participant.exchange_id}"
+ )
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"Failed to withdraw participant: {e}")
+ raise WithdrawalError("Failed to process withdrawal. Please try again.")
+
+ # Send confirmation email
+ try:
+ email_service = EmailService()
+ email_service.send_withdrawal_confirmation(participant)
+ except Exception as e:
+ current_app.logger.error(f"Failed to send withdrawal email: {e}")
+ # Don't raise - withdrawal already committed
+```
+
+### Step 3: Create Withdrawal Form
+
+**File**: `src/forms/participant.py` (add to existing)
+
+```python
+class WithdrawForm(FlaskForm):
+ """Form for confirming withdrawal from exchange."""
+ confirm = BooleanField(
+ 'I understand this cannot be undone and I will need to re-register to rejoin',
+ validators=[
+ DataRequired(message="You must confirm to withdraw")
+ ]
+ )
+
+ submit = SubmitField('Withdraw from Exchange')
+```
+
+### Step 4: Create Withdrawal Route
+
+**File**: `src/routes/participant.py` (add to existing)
+
+```python
+from src.utils.participant import can_withdraw, is_withdrawn
+from src.services.withdrawal import withdraw_participant, WithdrawalError
+from src.forms.participant import WithdrawForm
+
+@participant_bp.route('/participant/withdraw', methods=['GET', 'POST'])
+@participant_required
+def withdraw():
+ """Withdraw from exchange (soft delete)."""
+ participant = g.participant
+ exchange = participant.exchange
+
+ # Check if withdrawal is allowed
+ if is_withdrawn(participant):
+ flash("You have already withdrawn from this exchange.", "info")
+ return redirect(url_for('participant.register', slug=exchange.slug))
+
+ if not can_withdraw(participant):
+ if exchange.state == 'registration_closed':
+ message = "Registration has closed. Please contact the admin to withdraw."
+ else:
+ message = "Withdrawal is no longer available. Please contact the admin."
+ flash(message, "error")
+ return redirect(url_for('participant.dashboard'))
+
+ form = WithdrawForm()
+
+ if form.validate_on_submit():
+ try:
+ withdraw_participant(participant)
+
+ # Log out participant
+ session.clear()
+
+ flash(
+ "You have been withdrawn from the exchange. "
+ "A confirmation email has been sent.",
+ "success"
+ )
+ return redirect(url_for('participant.register', slug=exchange.slug))
+
+ except WithdrawalError as e:
+ flash(str(e), "error")
+ return redirect(url_for('participant.dashboard'))
+
+ except Exception as e:
+ current_app.logger.error(f"Unexpected error during withdrawal: {e}")
+ flash("An unexpected error occurred. Please try again.", "error")
+
+ return render_template(
+ 'participant/withdraw.html',
+ form=form,
+ participant=participant,
+ exchange=exchange
+ )
+```
+
+### Step 5: Create Email Templates
+
+**File**: `templates/emails/participant/withdrawal_confirmation.html` (new)
+
+```html
+
+
+
+
+ Withdrawal Confirmation
+
+
+
+
Withdrawal Confirmed
+
+
Hello {{ participant.name }},
+
+
+ This email confirms that you have withdrawn from the Secret Santa exchange
+ {{ exchange.name }}.
+
+
+
+
What happens now:
+
+ - You have been removed from the participant list
+ - Your profile information has been archived
+ - You will not receive further emails about this exchange
+
+
+
+
+ If you withdrew by mistake, you can re-register using a different email address
+ while registration is still open.
+
+
+
+
+ This is an automated message from Sneaky Klaus.
+
+
+
+
+```
+
+**File**: `templates/emails/participant/withdrawal_confirmation.txt` (new)
+
+```
+Withdrawal Confirmed
+
+Hello {{ participant.name }},
+
+This email confirms that you have withdrawn from the Secret Santa exchange "{{ exchange.name }}".
+
+What happens now:
+- You have been removed from the participant list
+- Your profile information has been archived
+- You will not receive further emails about this exchange
+
+If you withdrew by mistake, you can re-register using a different email address while registration is still open.
+
+---
+This is an automated message from Sneaky Klaus.
+```
+
+### Step 6: Add Email Service Method
+
+**File**: `src/services/email.py` (add method)
+
+```python
+def send_withdrawal_confirmation(self, participant: Participant) -> None:
+ """Send withdrawal confirmation email to participant.
+
+ Args:
+ participant: The participant who withdrew
+
+ Raises:
+ EmailError: If email send fails
+ """
+ exchange = participant.exchange
+
+ html_body = render_template(
+ 'emails/participant/withdrawal_confirmation.html',
+ participant=participant,
+ exchange=exchange
+ )
+ text_body = render_template(
+ 'emails/participant/withdrawal_confirmation.txt',
+ participant=participant,
+ exchange=exchange
+ )
+
+ try:
+ resend.Emails.send({
+ "from": self.from_email,
+ "to": [participant.email],
+ "subject": f"Withdrawal Confirmed - {exchange.name}",
+ "html": html_body,
+ "text": text_body,
+ })
+
+ logger.info(f"Withdrawal confirmation sent to {participant.email}")
+
+ except Exception as e:
+ logger.error(f"Failed to send withdrawal email: {e}")
+ raise EmailError(f"Failed to send withdrawal confirmation: {e}")
+```
+
+### Step 7: Create Withdrawal Template
+
+**File**: `templates/participant/withdraw.html` (new)
+
+```html
+{% extends "layouts/participant.html" %}
+
+{% block title %}Withdraw from {{ exchange.name }}{% endblock %}
+
+{% block content %}
+
+
Withdraw from Exchange
+
+
+
⚠️ Are you sure?
+
Withdrawing from this exchange means:
+
+ - Your registration will be cancelled
+ - You will be removed from the participant list
+ - You cannot undo this action
+ - You will need to re-register with a different email to rejoin
+
+
+
+
+
+{% endblock %}
+```
+
+### Step 8: Update Dashboard with Withdraw Link
+
+**File**: `templates/participant/dashboard.html` (add section)
+
+```html
+
+{% if can_withdraw %}
+
+ Withdraw from Exchange
+
+ If you can no longer participate, you can withdraw from this exchange.
+ This cannot be undone.
+
+
+ Withdraw from Exchange
+
+
+{% endif %}
+```
+
+Update dashboard route:
+
+```python
+# In dashboard() route
+from src.utils.participant import can_withdraw
+
+can_leave = can_withdraw(participant)
+
+return render_template(
+ # ... existing variables ...
+ can_withdraw=can_leave
+)
+```
+
+### Step 9: Write Tests
+
+**File**: `tests/unit/test_withdrawal_service.py` (new)
+
+```python
+"""Unit tests for withdrawal service."""
+import pytest
+from datetime import datetime
+from src.services.withdrawal import withdraw_participant, WithdrawalError
+
+
+def test_withdraw_participant_success(participant_factory, mock_email_service, db):
+ """Test successful withdrawal."""
+ participant = participant_factory()
+
+ withdraw_participant(participant)
+
+ assert participant.withdrawn_at is not None
+ mock_email_service.send_withdrawal_confirmation.assert_called_once()
+
+
+def test_withdraw_participant_already_withdrawn(participant_factory):
+ """Test error when already withdrawn."""
+ participant = participant_factory(withdrawn_at=datetime.utcnow())
+
+ with pytest.raises(WithdrawalError, match="already withdrawn"):
+ withdraw_participant(participant)
+```
+
+**File**: `tests/integration/test_withdrawal.py` (new)
+
+```python
+"""Integration tests for withdrawal functionality."""
+import pytest
+from flask import url_for
+
+
+def test_withdrawal_post_success(client, auth_participant, db, mock_email):
+ """Test successful withdrawal flow."""
+ participant_id = auth_participant.id
+
+ response = client.post(
+ url_for('participant.withdraw'),
+ data={
+ 'confirm': True,
+ 'csrf_token': get_csrf_token(client, url_for('participant.withdraw'))
+ },
+ follow_redirects=True
+ )
+
+ assert b'withdrawn from the exchange' in response.data
+
+ # Verify database
+ from src.models import Participant
+ participant = Participant.query.get(participant_id)
+ assert participant.withdrawn_at is not None
+
+ # Verify session cleared
+ with client.session_transaction() as session:
+ assert 'user_id' not in session
+```
+
+**Checkpoint**: Withdrawal feature complete
+
+---
+
+## Final Steps
+
+### 1. Run All Tests
+
+```bash
+# Run all tests
+uv run pytest -v
+
+# Check coverage
+uv run pytest --cov=src --cov-report=term-missing
+
+# Ensure ≥ 80% coverage
+```
+
+### 2. Run Linting and Type Checking
+
+```bash
+# Lint code
+uv run ruff check src tests
+
+# Format code
+uv run ruff format src tests
+
+# Type check
+uv run mypy src
+```
+
+### 3. Manual QA Testing
+
+Follow the test plan in `docs/designs/v0.3.0/test-plan.md`:
+
+- Test all acceptance criteria
+- Test edge cases
+- Test across browsers
+- Test accessibility
+
+### 4. Update Documentation
+
+If any design decisions changed during implementation:
+
+- Update `docs/designs/v0.3.0/overview.md`
+- Update `docs/decisions/0006-participant-state-management.md` if needed
+- Add any new ADRs if architectural changes were made
+
+### 5. Commit and Create PR
+
+```bash
+# Create feature branch
+git checkout -b feature/participant-self-management
+
+# Stage all changes
+git add .
+
+# Commit with descriptive message
+git commit -m "feat: implement participant self-management (v0.3.0)
+
+Implements Epic 6 and Story 4.5:
+- Participant list view (pre-matching)
+- Profile updates (name, gift ideas)
+- Reminder preference toggles
+- Participant withdrawal
+
+All acceptance criteria met, 80%+ test coverage maintained.
+
+Refs: docs/designs/v0.3.0/overview.md"
+
+# Push to origin
+git push -u origin feature/participant-self-management
+```
+
+### 6. Create Pull Request
+
+Using `gh` CLI:
+
+```bash
+gh pr create --title "feat: Participant Self-Management (v0.3.0)" --body "$(cat <<'EOF'
+## Summary
+
+Implements Phase 3 (v0.3.0) - Participant Self-Management features:
+
+- ✅ **Story 4.5**: View participant list (pre-matching)
+- ✅ **Story 6.1**: Update profile (name, gift ideas)
+- ✅ **Story 6.3**: Update reminder preferences
+- ✅ **Story 6.2**: Withdraw from exchange
+
+## Changes
+
+### New Files
+- `src/utils/participant.py` - Business logic functions
+- `src/services/withdrawal.py` - Withdrawal service
+- `templates/participant/profile_edit.html` - Profile edit page
+- `templates/participant/withdraw.html` - Withdrawal confirmation
+- `templates/emails/participant/withdrawal_confirmation.*` - Email templates
+
+### Modified Files
+- `src/routes/participant.py` - New routes for profile, preferences, withdrawal
+- `src/forms/participant.py` - New forms
+- `src/services/email.py` - Withdrawal email method
+- `templates/participant/dashboard.html` - Enhanced with participant list
+
+### Documentation
+- `docs/designs/v0.3.0/overview.md` - Phase design
+- `docs/designs/v0.3.0/participant-self-management.md` - Component design
+- `docs/decisions/0006-participant-state-management.md` - ADR
+
+## Test Coverage
+
+- Unit tests: 95%+ for business logic
+- Integration tests: All routes tested
+- Overall coverage: 80%+
+- Manual QA: All acceptance criteria verified
+
+## Breaking Changes
+
+None. Fully backward compatible with v0.2.0.
+
+## Deployment Notes
+
+No database migrations required. No new environment variables.
+
+🤖 Generated with [Claude Code](https://claude.com/claude-code)
+
+Co-Authored-By: Claude Opus 4.5
+EOF
+)" --base release/v0.3.0
+```
+
+## Troubleshooting
+
+### Common Issues
+
+**Issue**: Tests fail with "fixture not found"
+- **Solution**: Ensure fixture is defined in `conftest.py` or imported properly
+
+**Issue**: CSRF token errors in tests
+- **Solution**: Use `get_csrf_token()` helper to extract token from page
+
+**Issue**: Session not persisting in tests
+- **Solution**: Use `with client.session_transaction() as session:` to modify session
+
+**Issue**: Database rollback errors
+- **Solution**: Ensure `db.session.rollback()` in exception handlers
+
+**Issue**: Email not sent in dev mode
+- **Solution**: Check `FLASK_ENV=development` and logs for magic links
+
+### Getting Help
+
+- Review Phase 2 implementation for patterns
+- Check existing tests for examples
+- Consult Flask/SQLAlchemy documentation
+- Ask user for clarification on requirements
+
+---
+
+**Implementation Guide Complete**
+
+This guide should be followed in order for TDD implementation of Phase 3. Each checkpoint represents a vertically-sliced feature that can be tested, reviewed, and merged independently if needed.
diff --git a/docs/designs/v0.3.0/overview.md b/docs/designs/v0.3.0/overview.md
new file mode 100644
index 0000000..4c92cd3
--- /dev/null
+++ b/docs/designs/v0.3.0/overview.md
@@ -0,0 +1,566 @@
+# System Overview - v0.3.0
+
+**Version**: 0.3.0
+**Date**: 2025-12-22
+**Status**: Phase 3 Design
+
+## Introduction
+
+This document describes the design for Phase 3 of Sneaky Klaus, building on the participant authentication foundation established in v0.2.0. This phase focuses on participant self-management capabilities and pre-matching participant experience.
+
+**Phase 3 Scope**: Implement Epic 6 (Participant Self-Management) and Epic 4.5 (View Participant List Pre-Matching), enabling participants to manage their own profiles and see who else has registered before matching occurs.
+
+## Phase Goals
+
+The primary goals for v0.3.0 are:
+
+1. **Enable participant self-service**: Allow participants to update their profiles without admin intervention
+2. **Support graceful withdrawals**: Handle participants who need to drop out before matching
+3. **Build social engagement**: Let participants see who else is participating (pre-matching only)
+4. **Maintain data integrity**: Ensure profile changes and withdrawals don't break the exchange flow
+
+## User Stories in Scope
+
+### Epic 6: Participant Self-Management
+
+- **6.1 Update Profile**: Participants can edit their name and gift ideas before matching
+- **6.2 Withdraw from Exchange**: Participants can opt out before registration closes
+- **6.3 Update Reminder Preferences**: Participants can toggle reminder emails on/off
+
+### Epic 4: Participant Registration (Continuation)
+
+- **4.5 View Participant List (Pre-Matching)**: Registered participants can see who else has joined
+
+## Out of Scope for v0.3.0
+
+The following features are explicitly **not** included in this phase:
+
+- Post-matching participant experience (Epic 11) - deferred to Phase 4
+- Matching system (Epic 8) - deferred to Phase 5
+- Admin exchange management beyond what v0.1.0 provided
+- Participant removal by admin (Epic 9) - deferred to Phase 6
+- Notification emails beyond registration confirmation (Epic 10) - partial in Phase 2, remainder in Phase 7
+
+## Architecture Overview
+
+Phase 3 builds incrementally on the v0.2.0 architecture with no new infrastructure components:
+
+```mermaid
+graph TB
+ subgraph "Phase 3 Additions"
+ ProfileUpdate[Profile Update UI]
+ WithdrawUI[Withdraw UI]
+ ParticipantList[Participant List View]
+
+ ProfileUpdate --> ParticipantRoutes
+ WithdrawUI --> ParticipantRoutes
+ ParticipantList --> ParticipantRoutes
+ end
+
+ subgraph "Existing v0.2.0 Foundation"
+ ParticipantRoutes[Participant Routes]
+ ParticipantAuth[Participant Auth Decorator]
+ ParticipantModel[Participant Model]
+ ExchangeModel[Exchange Model]
+ Database[(SQLite)]
+
+ ParticipantRoutes --> ParticipantAuth
+ ParticipantRoutes --> ParticipantModel
+ ParticipantRoutes --> ExchangeModel
+ ParticipantModel --> Database
+ ExchangeModel --> Database
+ end
+```
+
+## Technical Design Principles
+
+This phase adheres to the project's core principles established in v0.1.0 and v0.2.0:
+
+1. **No new database tables**: All features use existing Participant and Exchange models
+2. **No new external dependencies**: Pure Flask/SQLAlchemy implementation
+3. **Soft deletes**: Withdrawals use `withdrawn_at` timestamp rather than hard deletes
+4. **State-based permissions**: Operations restricted based on exchange state
+5. **TDD approach**: Write tests first, implement to pass
+
+## Key Design Decisions
+
+### 1. Profile Update Restrictions
+
+**Decision**: Profile updates are only allowed when exchange is in `draft`, `registration_open`, or `registration_closed` states. After matching, profiles are locked.
+
+**Rationale**:
+- Gift ideas are sent to givers during match notification - changes after matching would create inconsistency
+- Name changes after matching could confuse participants
+- Clear state transition prevents data inconsistency
+
+**Implementation**:
+```python
+def can_update_profile(participant: Participant) -> bool:
+ """Check if participant can update their profile."""
+ exchange = participant.exchange
+ return exchange.state in ['draft', 'registration_open', 'registration_closed']
+```
+
+### 2. Withdrawal Rules
+
+**Decision**: Withdrawals are only allowed before registration closes. After registration closes, admin intervention required.
+
+**Rationale**:
+- Pre-closure: minimal impact, just removes one participant
+- Post-closure: admin likely already configuring exclusions or has matched
+- Post-matching: requires re-match, admin should make this decision
+- Clear deadline prevents last-minute dropouts
+
+**Implementation**:
+```python
+def can_withdraw(participant: Participant) -> bool:
+ """Check if participant can withdraw themselves."""
+ exchange = participant.exchange
+ return exchange.state in ['draft', 'registration_open']
+```
+
+### 3. Withdrawal Implementation (Soft Delete)
+
+**Decision**: Use existing `withdrawn_at` timestamp field, don't delete participant records.
+
+**Rationale**:
+- Data model already supports soft deletes (v0.2.0 design)
+- Preserves audit trail of who registered
+- Prevents re-use of email during same exchange
+- Allows admin to see withdrawal history
+- Simplifies database constraints (no cascade delete issues)
+
+**Implementation**:
+```python
+def withdraw_participant(participant: Participant):
+ """Mark participant as withdrawn."""
+ participant.withdrawn_at = datetime.utcnow()
+ db.session.commit()
+```
+
+### 4. Participant List Visibility
+
+**Decision**: Show participant list to all registered participants, but only show names (not emails or gift ideas).
+
+**Rationale**:
+- Social aspect: participants want to know who's participating
+- Privacy: emails are admin-only
+- Security: gift ideas are for givers only (post-matching)
+- Pre-matching only: post-matching handled in Phase 4
+
+**Display Rules**:
+- Show: participant names, count
+- Hide: emails, gift ideas, withdrawn participants
+- Filter: exclude withdrawn participants from list
+
+### 5. Reminder Preference Changes
+
+**Decision**: Allow reminder preference changes at any time before exchange completes.
+
+**Rationale**:
+- Low-impact change (doesn't affect matching or other participants)
+- User preference should be flexible
+- No technical reason to restrict after matching
+- Allows opting out if circumstances change
+
+**Implementation**: Simple boolean toggle, no state restrictions.
+
+### 6. Form Validation Strategy
+
+**Decision**: Use server-side validation with WTForms, add client-side hints for UX.
+
+**Rationale**:
+- Consistent with Phase 2 implementation decisions
+- Security: never trust client-side validation
+- UX: client-side provides immediate feedback
+- Progressive enhancement: works without JavaScript
+
+## Data Flow
+
+### Profile Update Flow
+
+```mermaid
+sequenceDiagram
+ participant P as Participant Browser
+ participant F as Flask App
+ participant DB as SQLite Database
+
+ P->>F: GET /participant/profile/edit
+ F->>F: Check @participant_required
+ F->>DB: Load participant & exchange
+ F->>F: Check can_update_profile()
+ F-->>P: Render edit form (or error)
+
+ P->>F: POST /participant/profile/edit (name, gift_ideas)
+ F->>F: Validate form
+ F->>F: Check can_update_profile()
+ F->>DB: Update participant record
+ DB-->>F: Success
+ F-->>P: Redirect to dashboard with success message
+```
+
+### Withdrawal Flow
+
+```mermaid
+sequenceDiagram
+ participant P as Participant Browser
+ participant F as Flask App
+ participant DB as SQLite Database
+ participant E as Email Service
+
+ P->>F: POST /participant/withdraw (with confirmation)
+ F->>F: Check @participant_required
+ F->>DB: Load participant & exchange
+ F->>F: Check can_withdraw()
+ F->>DB: Set withdrawn_at timestamp
+ DB-->>F: Success
+ F->>E: Send withdrawal confirmation email
+ E-->>F: Email sent
+ F->>F: Clear session (log out participant)
+ F-->>P: Redirect to public page with confirmation
+```
+
+### Participant List Flow
+
+```mermaid
+sequenceDiagram
+ participant P as Participant Browser
+ participant F as Flask App
+ participant DB as SQLite Database
+
+ P->>F: GET /participant/dashboard
+ F->>F: Check @participant_required
+ F->>DB: Load participant's exchange
+ F->>DB: Query active participants (withdrawn_at IS NULL)
+ DB-->>F: Participant list (names only)
+ F-->>P: Render dashboard with participant list
+```
+
+## State Machine Impact
+
+Phase 3 doesn't add new exchange states, but adds participant-level state:
+
+```mermaid
+stateDiagram-v2
+ [*] --> Registered: Registration
+ Registered --> UpdatedProfile: Edit profile
+ UpdatedProfile --> UpdatedProfile: Edit again
+ UpdatedProfile --> Withdrawn: Withdraw
+ Registered --> Withdrawn: Withdraw
+ UpdatedProfile --> Matched: Admin matches
+ Registered --> Matched: Admin matches
+ Withdrawn --> [*]
+
+ note right of Withdrawn
+ Soft delete: withdrawn_at set
+ Cannot re-activate
+ Must re-register with new email
+ end note
+
+ note right of UpdatedProfile
+ Only before matching
+ Name and gift_ideas editable
+ end note
+```
+
+## Component Design
+
+### Routes (participant_bp)
+
+New routes added to existing `src/routes/participant.py`:
+
+| Route | Method | Auth | Description |
+|-------|--------|------|-------------|
+| `/participant/profile/edit` | GET, POST | @participant_required | Edit profile (name, gift ideas) |
+| `/participant/preferences` | POST | @participant_required | Update reminder preference |
+| `/participant/withdraw` | POST | @participant_required | Withdraw from exchange |
+
+Existing routes used:
+- `/participant/dashboard` - enhanced to show participant list
+
+### Forms (src/forms/participant.py)
+
+New forms:
+
+**ProfileUpdateForm**:
+```python
+class ProfileUpdateForm(FlaskForm):
+ name = StringField('Name', validators=[DataRequired(), Length(max=255)])
+ gift_ideas = TextAreaField('Gift Ideas', validators=[Length(max=10000)])
+ submit = SubmitField('Save Changes')
+```
+
+**ReminderPreferenceForm**:
+```python
+class ReminderPreferenceForm(FlaskForm):
+ reminder_enabled = BooleanField('Send me reminders')
+ submit = SubmitField('Update Preferences')
+```
+
+**WithdrawForm**:
+```python
+class WithdrawForm(FlaskForm):
+ confirm = BooleanField('I understand this cannot be undone',
+ validators=[DataRequired()])
+ submit = SubmitField('Withdraw from Exchange')
+```
+
+### Templates
+
+New templates in `templates/participant/`:
+
+- `profile_edit.html` - Profile update form
+- `withdraw.html` - Withdrawal confirmation page (with warnings)
+- `participant_list.html` - Reusable component for displaying participant names
+
+Enhanced templates:
+- `dashboard.html` - Add participant list section
+
+### Email Templates
+
+New email template in `templates/emails/participant/`:
+
+- `withdrawal_confirmation.html` - Sent when participant withdraws
+- `withdrawal_confirmation.txt` - Plain text version
+
+## Security Considerations
+
+### 1. Authorization Checks
+
+Every participant operation must verify:
+- User is authenticated as participant (@participant_required)
+- User owns the resource (participant_id matches session)
+- Exchange state allows the operation (can_update_profile, can_withdraw)
+
+### 2. CSRF Protection
+
+All POST operations require CSRF tokens (WTForms automatic).
+
+### 3. Input Validation
+
+- Name: 1-255 characters, required
+- Gift ideas: 0-10,000 characters, optional
+- All inputs sanitized via Jinja2 auto-escaping
+
+### 4. Privacy
+
+Participant list shows:
+- Names only (public within exchange)
+- Not emails (admin-only)
+- Not gift ideas (giver-only post-matching)
+- Not withdrawn participants (respect withdrawal privacy)
+
+### 5. Rate Limiting
+
+No new rate limiting needed:
+- Profile updates: legitimate user operation, no abuse vector
+- Withdrawals: self-limiting (can only withdraw once)
+- Participant list: read-only, authenticated
+
+## Testing Strategy
+
+### Unit Tests
+
+Test business logic in isolation:
+- `can_update_profile()` logic for each exchange state
+- `can_withdraw()` logic for each exchange state
+- Soft delete implementation (withdrawn_at timestamp)
+- Participant list filtering (exclude withdrawn)
+
+### Integration Tests
+
+Test full request/response cycles:
+- Profile update happy path
+- Profile update when locked (post-matching)
+- Withdrawal happy path
+- Withdrawal when not allowed
+- Participant list rendering
+
+### Test Data Setup
+
+Reuse existing test fixtures from Phase 2, add:
+- Multiple participants in same exchange
+- Participants in different states (registered, withdrawn)
+- Exchanges in different states
+
+### Coverage Target
+
+Maintain 80%+ coverage established in previous phases.
+
+## Error Handling
+
+### Expected Errors
+
+| Scenario | HTTP Status | User Message | Action |
+|----------|-------------|--------------|--------|
+| Update profile after matching | 400 | "Your profile is locked after matching" | Redirect to dashboard |
+| Withdraw after registration closes | 400 | "Withdrawal deadline has passed. Contact admin." | Redirect to dashboard |
+| Invalid form data | 400 | Field-specific errors | Re-render form with errors |
+| Already withdrawn | 400 | "You have already withdrawn" | Redirect to public page |
+
+### Unexpected Errors
+
+- Database errors: Log, flash generic error, redirect safely
+- Missing participant record: Clear session, redirect to landing page
+- Orphaned session: Clear session, redirect to landing page
+
+## Performance Considerations
+
+### Database Queries
+
+**Participant List Query**:
+```python
+# Efficient query with single DB hit
+participants = Participant.query.filter(
+ Participant.exchange_id == exchange_id,
+ Participant.withdrawn_at.is_(None)
+).order_by(Participant.name).all()
+```
+
+**Profile Load**:
+- Already loaded by @participant_required decorator (uses `g.participant`)
+- No additional query needed
+
+### Caching
+
+Not needed for v0.3.0:
+- Participant counts are small (3-100 typical)
+- Updates are infrequent
+- Reads are authenticated (no public caching)
+
+## Migration Requirements
+
+**No database migrations required for v0.3.0.**
+
+All fields already exist:
+- `participant.name` - editable
+- `participant.gift_ideas` - editable
+- `participant.reminder_enabled` - editable
+- `participant.withdrawn_at` - set on withdrawal
+
+## Deployment Impact
+
+### Breaking Changes
+
+None. v0.3.0 is fully backward compatible with v0.2.0.
+
+### Configuration Changes
+
+None. No new environment variables or configuration needed.
+
+### Data Migration
+
+None. Existing data fully compatible.
+
+## User Experience Flow
+
+### Participant Journey (Pre-Matching)
+
+1. **Register** (Phase 2)
+ - Receive confirmation email with magic link
+
+2. **View Dashboard** (Phase 2 + Phase 3)
+ - See own information
+ - **NEW**: See list of other participants (names only)
+
+3. **Update Profile** (Phase 3)
+ - Click "Edit Profile" from dashboard
+ - Update name or gift ideas
+ - Save changes
+ - See confirmation message
+
+4. **Change Reminder Preference** (Phase 3)
+ - Toggle reminder checkbox on dashboard
+ - See confirmation message
+
+5. **Withdraw (if needed)** (Phase 3)
+ - Click "Withdraw" from dashboard
+ - See warning about permanence
+ - Confirm withdrawal
+ - Receive confirmation email
+ - Logged out and redirected to public page
+
+## Admin Experience Impact
+
+Phase 3 adds visibility for admins:
+
+### Exchange Detail View (Enhancement)
+
+Add to existing exchange detail page:
+- Show participant list with withdrawn indicator
+- Display: "5 active, 1 withdrawn"
+- Allow admin to see withdrawn participants (grayed out)
+
+**Implementation Note**: This is a minor enhancement to existing admin routes, not a major feature. Can be implemented alongside participant features or deferred to Phase 6 (Admin Participant Management).
+
+## Future Considerations
+
+### Phase 4 Dependencies
+
+Phase 3 sets up foundations for Phase 4 (Post-Matching Experience):
+- Participant list already implemented (will be reused post-matching)
+- Profile lock logic (can_update_profile) prevents changes after matching
+- Dashboard structure ready to show match assignment
+
+### Potential Enhancements (Out of Scope)
+
+Not included in v0.3.0 but possible future additions:
+- Email notification to admin when participant withdraws (Epic 10.5)
+- Allow participants to indicate dietary restrictions or allergies
+- Allow participants to add profile pictures
+- Allow participants to message admin (anonymous contact form)
+
+## Acceptance Criteria
+
+Phase 3 is complete when:
+
+1. ✅ Participants can update their name and gift ideas before matching
+2. ✅ Participants cannot update profile after matching occurs
+3. ✅ Participants can withdraw before registration closes
+4. ✅ Participants cannot withdraw after registration closes
+5. ✅ Withdrawn participants receive confirmation email
+6. ✅ Withdrawn participants are logged out and removed from participant list
+7. ✅ Participants can view list of other registered participants (names only)
+8. ✅ Participant list excludes withdrawn participants
+9. ✅ Participants can toggle reminder preferences at any time
+10. ✅ All operations require participant authentication
+11. ✅ All operations validate exchange state appropriately
+12. ✅ All user stories have passing integration tests
+13. ✅ Code coverage remains at 80%+
+
+## Implementation Phases
+
+Recommended implementation order (TDD, vertical slices):
+
+### Phase 3.1: Participant List View
+- Story 4.5: View Participant List (Pre-Matching)
+- Simplest feature, no state changes
+- Sets up dashboard enhancements
+
+### Phase 3.2: Profile Updates
+- Story 6.1: Update Profile
+- Core self-management feature
+- Tests state-based permissions
+
+### Phase 3.3: Reminder Preferences
+- Story 6.3: Update Reminder Preferences
+- Simple toggle, low risk
+- Quick win
+
+### Phase 3.4: Withdrawal
+- Story 6.2: Withdraw from Exchange
+- Most complex (state changes, email, logout)
+- Benefits from previous features being solid
+
+## References
+
+- [v0.2.0 System Overview](../v0.2.0/overview.md)
+- [v0.2.0 Data Model](../v0.2.0/data-model.md)
+- [Product Backlog](../../BACKLOG.md)
+- [Project Overview](../../PROJECT_OVERVIEW.md)
+- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
+- [ADR-0003: Participant Session Scoping](../../decisions/0003-participant-session-scoping.md)
+
+---
+
+**End of Phase 3 Design Overview**
diff --git a/docs/designs/v0.3.0/participant-self-management.md b/docs/designs/v0.3.0/participant-self-management.md
new file mode 100644
index 0000000..71ab7cd
--- /dev/null
+++ b/docs/designs/v0.3.0/participant-self-management.md
@@ -0,0 +1,1141 @@
+# Participant Self-Management Component Design
+
+**Version**: 0.3.0
+**Date**: 2025-12-22
+**Status**: Component Design
+
+## Overview
+
+This document provides detailed component specifications for participant self-management features in Phase 3. It covers profile updates, withdrawals, reminder preferences, and the participant list view.
+
+## Component Architecture
+
+```mermaid
+graph TB
+ subgraph "Presentation Layer"
+ Dashboard[Participant Dashboard]
+ ProfileEdit[Profile Edit Page]
+ WithdrawPage[Withdraw Confirmation Page]
+ end
+
+ subgraph "Forms Layer"
+ ProfileForm[ProfileUpdateForm]
+ ReminderForm[ReminderPreferenceForm]
+ WithdrawForm[WithdrawForm]
+ end
+
+ subgraph "Route Layer"
+ DashboardRoute[dashboard()]
+ ProfileEditRoute[profile_edit()]
+ UpdatePrefsRoute[update_preferences()]
+ WithdrawRoute[withdraw()]
+ end
+
+ subgraph "Business Logic"
+ StateChecks[State Validation Functions]
+ WithdrawService[Withdrawal Service]
+ end
+
+ subgraph "Data Layer"
+ ParticipantModel[Participant Model]
+ ExchangeModel[Exchange Model]
+ end
+
+ Dashboard --> DashboardRoute
+ ProfileEdit --> ProfileEditRoute
+ WithdrawPage --> WithdrawRoute
+
+ ProfileEditRoute --> ProfileForm
+ UpdatePrefsRoute --> ReminderForm
+ WithdrawRoute --> WithdrawForm
+
+ DashboardRoute --> StateChecks
+ ProfileEditRoute --> StateChecks
+ WithdrawRoute --> StateChecks
+
+ WithdrawRoute --> WithdrawService
+
+ StateChecks --> ParticipantModel
+ StateChecks --> ExchangeModel
+ WithdrawService --> ParticipantModel
+```
+
+## 1. Business Logic Functions
+
+### 1.1 State Validation Functions
+
+**Location**: `src/utils/participant.py` (new file)
+
+These functions encapsulate business rules for participant operations:
+
+```python
+"""Participant business logic utilities."""
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from src.models import Participant
+
+
+def can_update_profile(participant: 'Participant') -> bool:
+ """Check if participant can update their profile.
+
+ Profile updates are allowed until matching occurs.
+
+ Args:
+ participant: The participant to check
+
+ Returns:
+ True if profile updates are allowed, False otherwise
+ """
+ exchange = participant.exchange
+ allowed_states = ['draft', 'registration_open', 'registration_closed']
+ return exchange.state in allowed_states
+
+
+def can_withdraw(participant: 'Participant') -> bool:
+ """Check if participant can withdraw from the exchange.
+
+ Withdrawals are only allowed before registration closes.
+ After that, admin intervention is required.
+
+ Args:
+ participant: The participant to check
+
+ Returns:
+ True if withdrawal is allowed, False otherwise
+ """
+ # Already withdrawn
+ if participant.withdrawn_at is not None:
+ return False
+
+ exchange = participant.exchange
+ allowed_states = ['draft', 'registration_open']
+ return exchange.state in allowed_states
+
+
+def get_active_participants(exchange_id: int) -> list['Participant']:
+ """Get all active (non-withdrawn) participants for an exchange.
+
+ Args:
+ exchange_id: ID of the exchange
+
+ Returns:
+ List of active participants, ordered by name
+ """
+ from src.models import Participant
+
+ return Participant.query.filter(
+ Participant.exchange_id == exchange_id,
+ Participant.withdrawn_at.is_(None)
+ ).order_by(Participant.name).all()
+
+
+def is_withdrawn(participant: 'Participant') -> bool:
+ """Check if participant has withdrawn.
+
+ Args:
+ participant: The participant to check
+
+ Returns:
+ True if withdrawn, False otherwise
+ """
+ return participant.withdrawn_at is not None
+```
+
+**Design Rationale**:
+- Centralize business logic for reuse across routes and templates
+- Make state transition rules explicit and testable
+- Simplify route handlers (delegate to pure functions)
+- Enable easy rule changes without touching routes
+
+### 1.2 Withdrawal Service
+
+**Location**: `src/services/withdrawal.py` (new file)
+
+Encapsulates the withdrawal process:
+
+```python
+"""Participant withdrawal service."""
+from datetime import datetime
+from flask import current_app
+from src.models import Participant, db
+from src.services.email import EmailService
+from src.utils.participant import can_withdraw
+
+
+class WithdrawalError(Exception):
+ """Raised when withdrawal operation fails."""
+ pass
+
+
+def withdraw_participant(participant: Participant) -> None:
+ """Withdraw a participant from their exchange.
+
+ This performs a soft delete by setting withdrawn_at timestamp.
+ Participant record is retained for audit trail.
+
+ Args:
+ participant: The participant to withdraw
+
+ Raises:
+ WithdrawalError: If withdrawal is not allowed
+
+ Side effects:
+ - Sets participant.withdrawn_at to current UTC time
+ - Commits database transaction
+ - Sends withdrawal confirmation email
+ """
+ # Validate withdrawal is allowed
+ if not can_withdraw(participant):
+ if participant.withdrawn_at is not None:
+ raise WithdrawalError("You have already withdrawn from this exchange.")
+
+ exchange = participant.exchange
+ if exchange.state == 'registration_closed':
+ raise WithdrawalError(
+ "Registration has closed. Please contact the admin to withdraw."
+ )
+ elif exchange.state in ['matched', 'completed']:
+ raise WithdrawalError(
+ "Matching has already occurred. Please contact the admin."
+ )
+ else:
+ raise WithdrawalError("Withdrawal is not allowed at this time.")
+
+ # Perform withdrawal
+ participant.withdrawn_at = datetime.utcnow()
+
+ try:
+ db.session.commit()
+ current_app.logger.info(
+ f"Participant {participant.id} withdrawn from exchange {participant.exchange_id}"
+ )
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"Failed to withdraw participant: {e}")
+ raise WithdrawalError("Failed to process withdrawal. Please try again.")
+
+ # Send confirmation email
+ try:
+ email_service = EmailService()
+ email_service.send_withdrawal_confirmation(participant)
+ except Exception as e:
+ current_app.logger.error(f"Failed to send withdrawal email: {e}")
+ # Don't raise - withdrawal already committed
+```
+
+**Design Rationale**:
+- Encapsulate complex multi-step operation (validate, update DB, send email)
+- Provide clear error messages for different failure scenarios
+- Separate concerns: business logic from route handling
+- Log all withdrawal events for debugging and audit
+- Gracefully handle email failures (withdrawal is primary operation)
+
+## 2. Forms
+
+### 2.1 ProfileUpdateForm
+
+**Location**: `src/forms/participant.py` (enhance existing file)
+
+```python
+from wtforms import StringField, TextAreaField, SubmitField
+from wtforms.validators import DataRequired, Length
+from flask_wtf import FlaskForm
+
+
+class ProfileUpdateForm(FlaskForm):
+ """Form for updating participant profile.
+
+ Allows editing name and gift ideas before matching occurs.
+ """
+ name = StringField(
+ 'Name',
+ validators=[
+ DataRequired(message="Name is required"),
+ Length(min=1, max=255, message="Name must be 1-255 characters")
+ ],
+ description="Your display name (visible to other participants)"
+ )
+
+ gift_ideas = TextAreaField(
+ 'Gift Ideas',
+ validators=[
+ Length(max=10000, message="Gift ideas must be less than 10,000 characters")
+ ],
+ description="Optional wishlist or gift preferences for your Secret Santa",
+ render_kw={"rows": 6, "maxlength": 10000}
+ )
+
+ submit = SubmitField('Save Changes')
+```
+
+**Validation Rules**:
+- Name: Required, 1-255 characters
+- Gift ideas: Optional, max 10,000 characters
+- CSRF token: Automatic (FlaskForm)
+
+**UX Enhancements**:
+- Maxlength attribute provides browser-level hint
+- Rows attribute sizes textarea appropriately
+- Description text provides context
+
+### 2.2 ReminderPreferenceForm
+
+**Location**: `src/forms/participant.py`
+
+```python
+from wtforms import BooleanField, SubmitField
+from flask_wtf import FlaskForm
+
+
+class ReminderPreferenceForm(FlaskForm):
+ """Form for updating reminder email preferences."""
+ reminder_enabled = BooleanField(
+ 'Send me reminder emails before the exchange date',
+ description="You can change this at any time"
+ )
+
+ submit = SubmitField('Update Preferences')
+```
+
+**Validation Rules**:
+- No validation needed (boolean field)
+- CSRF token: Automatic
+
+**UX Note**: Form pre-populates with current preference value.
+
+### 2.3 WithdrawForm
+
+**Location**: `src/forms/participant.py`
+
+```python
+from wtforms import BooleanField, SubmitField
+from wtforms.validators import DataRequired
+from flask_wtf import FlaskForm
+
+
+class WithdrawForm(FlaskForm):
+ """Form for confirming withdrawal from exchange.
+
+ Requires explicit confirmation to prevent accidental withdrawals.
+ """
+ confirm = BooleanField(
+ 'I understand this cannot be undone and I will need to re-register to rejoin',
+ validators=[
+ DataRequired(message="You must confirm to withdraw")
+ ]
+ )
+
+ submit = SubmitField('Withdraw from Exchange')
+```
+
+**Validation Rules**:
+- Confirmation: Must be checked (DataRequired on BooleanField)
+- CSRF token: Automatic
+
+**Design Rationale**:
+- Explicit confirmation prevents accidental clicks
+- Clear warning about consequences
+- Single-step process (no "are you sure?" modal)
+
+## 3. Routes
+
+### 3.1 Enhanced Dashboard Route
+
+**Location**: `src/routes/participant.py`
+
+```python
+@participant_bp.route('/participant/dashboard')
+@participant_required
+def dashboard():
+ """Participant dashboard showing exchange info and participant list.
+
+ Requires authentication as participant.
+
+ Returns:
+ Rendered dashboard template
+ """
+ participant = g.participant
+ exchange = participant.exchange
+
+ # Get list of active participants
+ from src.utils.participant import get_active_participants
+ participants = get_active_participants(exchange.id)
+
+ # Check available actions
+ from src.utils.participant import can_update_profile, can_withdraw
+ can_edit = can_update_profile(participant)
+ can_leave = can_withdraw(participant)
+
+ # Create reminder preference form
+ from src.forms.participant import ReminderPreferenceForm
+ reminder_form = ReminderPreferenceForm(
+ reminder_enabled=participant.reminder_enabled
+ )
+
+ return render_template(
+ 'participant/dashboard.html',
+ participant=participant,
+ exchange=exchange,
+ participants=participants,
+ participant_count=len(participants),
+ can_edit_profile=can_edit,
+ can_withdraw=can_leave,
+ reminder_form=reminder_form
+ )
+```
+
+**Template Variables**:
+- `participant`: Current participant object
+- `exchange`: Associated exchange object
+- `participants`: List of active participants (for participant list)
+- `participant_count`: Number of active participants
+- `can_edit_profile`: Boolean flag for showing edit button
+- `can_withdraw`: Boolean flag for showing withdraw button
+- `reminder_form`: Pre-populated reminder preference form
+
+### 3.2 Profile Edit Route
+
+**Location**: `src/routes/participant.py`
+
+```python
+@participant_bp.route('/participant/profile/edit', methods=['GET', 'POST'])
+@participant_required
+def profile_edit():
+ """Edit participant profile (name and gift ideas).
+
+ Only allowed before matching occurs.
+
+ Returns:
+ GET: Profile edit form
+ POST: Redirect to dashboard on success, or re-render form on error
+ """
+ participant = g.participant
+
+ # Check if profile editing is allowed
+ from src.utils.participant import can_update_profile
+ if not can_update_profile(participant):
+ flash(
+ "Your profile is locked after matching. Contact the admin for changes.",
+ "error"
+ )
+ return redirect(url_for('participant.dashboard'))
+
+ # Create form with current values
+ from src.forms.participant import ProfileUpdateForm
+ form = ProfileUpdateForm(obj=participant)
+
+ if form.validate_on_submit():
+ try:
+ # Update participant
+ participant.name = form.name.data.strip()
+ participant.gift_ideas = form.gift_ideas.data.strip() if form.gift_ideas.data else None
+
+ db.session.commit()
+
+ flash("Your profile has been updated successfully.", "success")
+ return redirect(url_for('participant.dashboard'))
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"Failed to update participant profile: {e}")
+ flash("Failed to update profile. Please try again.", "error")
+
+ return render_template(
+ 'participant/profile_edit.html',
+ form=form,
+ participant=participant,
+ exchange=participant.exchange
+ )
+```
+
+**Flow**:
+1. Check participant authentication (@participant_required)
+2. Verify profile editing is allowed (state check)
+3. GET: Show form pre-populated with current values
+4. POST: Validate form, update database, redirect with success message
+
+**Error Handling**:
+- Profile locked: Flash error, redirect to dashboard
+- Form validation errors: Re-render form with field errors
+- Database errors: Rollback, flash error, re-render form
+
+### 3.3 Update Preferences Route
+
+**Location**: `src/routes/participant.py`
+
+```python
+@participant_bp.route('/participant/preferences', methods=['POST'])
+@participant_required
+def update_preferences():
+ """Update participant reminder email preferences.
+
+ Returns:
+ Redirect to dashboard
+ """
+ participant = g.participant
+
+ from src.forms.participant import ReminderPreferenceForm
+ form = ReminderPreferenceForm()
+
+ if form.validate_on_submit():
+ try:
+ participant.reminder_enabled = form.reminder_enabled.data
+ db.session.commit()
+
+ if form.reminder_enabled.data:
+ flash("Reminder emails enabled.", "success")
+ else:
+ flash("Reminder emails disabled.", "success")
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"Failed to update preferences: {e}")
+ flash("Failed to update preferences. Please try again.", "error")
+ else:
+ flash("Invalid request.", "error")
+
+ return redirect(url_for('participant.dashboard'))
+```
+
+**Flow**:
+1. Check participant authentication
+2. Validate CSRF token
+3. Update reminder preference
+4. Redirect to dashboard with status message
+
+**Design Note**: No GET route - preference update is POST-only from dashboard form.
+
+### 3.4 Withdraw Route
+
+**Location**: `src/routes/participant.py`
+
+```python
+@participant_bp.route('/participant/withdraw', methods=['GET', 'POST'])
+@participant_required
+def withdraw():
+ """Withdraw from exchange (soft delete).
+
+ GET: Show confirmation page with warnings
+ POST: Process withdrawal, log out, redirect to public page
+
+ Returns:
+ GET: Withdrawal confirmation page
+ POST: Redirect to exchange registration page
+ """
+ participant = g.participant
+ exchange = participant.exchange
+
+ # Check if withdrawal is allowed
+ from src.utils.participant import can_withdraw, is_withdrawn
+ if is_withdrawn(participant):
+ flash("You have already withdrawn from this exchange.", "info")
+ return redirect(url_for('participant.register', slug=exchange.slug))
+
+ if not can_withdraw(participant):
+ if exchange.state == 'registration_closed':
+ message = "Registration has closed. Please contact the admin to withdraw."
+ else:
+ message = "Withdrawal is no longer available. Please contact the admin."
+ flash(message, "error")
+ return redirect(url_for('participant.dashboard'))
+
+ # Create withdrawal confirmation form
+ from src.forms.participant import WithdrawForm
+ form = WithdrawForm()
+
+ if form.validate_on_submit():
+ try:
+ # Perform withdrawal
+ from src.services.withdrawal import withdraw_participant, WithdrawalError
+ withdraw_participant(participant)
+
+ # Log out participant
+ session.clear()
+
+ flash(
+ "You have been withdrawn from the exchange. "
+ "A confirmation email has been sent.",
+ "success"
+ )
+ return redirect(url_for('participant.register', slug=exchange.slug))
+
+ except WithdrawalError as e:
+ flash(str(e), "error")
+ return redirect(url_for('participant.dashboard'))
+
+ except Exception as e:
+ current_app.logger.error(f"Unexpected error during withdrawal: {e}")
+ flash("An unexpected error occurred. Please try again.", "error")
+
+ # GET request: show confirmation page
+ return render_template(
+ 'participant/withdraw.html',
+ form=form,
+ participant=participant,
+ exchange=exchange
+ )
+```
+
+**Flow**:
+1. Check authentication and withdrawal eligibility
+2. GET: Show confirmation page with warnings
+3. POST: Process withdrawal, send email, clear session, redirect
+
+**Security Notes**:
+- Session cleared immediately after withdrawal (no orphaned sessions)
+- Redirect to public registration page (safe landing)
+- Email sent asynchronously (don't block on email failure)
+
+## 4. Templates
+
+### 4.1 Enhanced Dashboard Template
+
+**Location**: `templates/participant/dashboard.html`
+
+```html
+{% extends "layouts/participant.html" %}
+
+{% block title %}{{ exchange.name }} - Dashboard{% endblock %}
+
+{% block content %}
+
+
{{ exchange.name }}
+
+
+
+ Exchange Details
+
+ - Gift Budget:
+ - {{ exchange.budget }}
+
+ - Exchange Date:
+ - {{ exchange.exchange_date|format_datetime }}
+
+ - Status:
+ -
+ {{ exchange.state|format_state }}
+
+
+
+
+
+
+ Your Profile
+
+ - Name:
+ - {{ participant.name }}
+
+ - Email:
+ - {{ participant.email }}
+
+ - Gift Ideas:
+ - {{ participant.gift_ideas or 'None provided' }}
+
+
+ {% if can_edit_profile %}
+
+ Edit Profile
+
+ {% endif %}
+
+
+
+
+
+
+
+ Participants ({{ participant_count }})
+ {% if participants %}
+
+ {% for p in participants %}
+ -
+ {{ p.name }}
+ {% if p.id == participant.id %}
+ You
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+ No other participants yet. Share the registration link!
+ {% endif %}
+
+
+
+ {% if can_withdraw %}
+
+ Withdraw from Exchange
+
+ If you can no longer participate, you can withdraw from this exchange.
+ This cannot be undone.
+
+
+ Withdraw from Exchange
+
+
+ {% endif %}
+
+{% endblock %}
+```
+
+**Design Notes**:
+- Clear information hierarchy (exchange → profile → participants → actions)
+- Conditional rendering based on state (can_edit_profile, can_withdraw)
+- Inline reminder preference form (no separate page needed)
+- Participant list with "You" badge for current user
+- Danger zone styling for withdrawal (red/warning colors)
+
+### 4.2 Profile Edit Template
+
+**Location**: `templates/participant/profile_edit.html`
+
+```html
+{% extends "layouts/participant.html" %}
+
+{% block title %}Edit Profile - {{ exchange.name }}{% endblock %}
+
+{% block content %}
+
+
Edit Your Profile
+
+
+ Update your display name and gift ideas.
+ Your Secret Santa will see this information after matching.
+
+
+
+
+
+
+{% endblock %}
+```
+
+**UX Features**:
+- Help text explains purpose
+- Field descriptions provide guidance
+- Character counter for gift ideas (client-side)
+- Clear error display
+- Cancel button returns to dashboard
+
+### 4.3 Withdrawal Confirmation Template
+
+**Location**: `templates/participant/withdraw.html`
+
+```html
+{% extends "layouts/participant.html" %}
+
+{% block title %}Withdraw from {{ exchange.name }}{% endblock %}
+
+{% block content %}
+
+
Withdraw from Exchange
+
+
+
⚠️ Are you sure?
+
Withdrawing from this exchange means:
+
+ - Your registration will be cancelled
+ - You will be removed from the participant list
+ - You cannot undo this action
+ - You will need to re-register with a different email to rejoin
+
+
+
+
+
+{% endblock %}
+```
+
+**Design Notes**:
+- Prominent warning box with icon
+- Clear list of consequences
+- Required confirmation checkbox
+- Danger-styled submit button (red)
+- Easy cancel option
+
+## 5. Email Template
+
+### 5.1 Withdrawal Confirmation Email
+
+**Location**: `templates/emails/participant/withdrawal_confirmation.html`
+
+```html
+
+
+
+
+ Withdrawal Confirmation
+
+
+
+
Withdrawal Confirmed
+
+
Hello {{ participant.name }},
+
+
+ This email confirms that you have withdrawn from the Secret Santa exchange
+ {{ exchange.name }}.
+
+
+
+
+ What happens now:
+
+
+ - You have been removed from the participant list
+ - Your profile information has been archived
+ - You will not receive further emails about this exchange
+
+
+
+
+ If you withdrew by mistake, you can re-register using a different email address
+ while registration is still open.
+
+
+
+ If you have any questions, please contact the exchange organizer.
+
+
+
+
+
+ This is an automated message from Sneaky Klaus.
+
+
+
+
+```
+
+**Plain Text Version**: `templates/emails/participant/withdrawal_confirmation.txt`
+
+```
+Withdrawal Confirmed
+
+Hello {{ participant.name }},
+
+This email confirms that you have withdrawn from the Secret Santa exchange "{{ exchange.name }}".
+
+What happens now:
+- You have been removed from the participant list
+- Your profile information has been archived
+- You will not receive further emails about this exchange
+
+If you withdrew by mistake, you can re-register using a different email address while registration is still open.
+
+If you have any questions, please contact the exchange organizer.
+
+---
+This is an automated message from Sneaky Klaus.
+```
+
+### 5.2 Email Service Update
+
+**Location**: `src/services/email.py` (add method)
+
+```python
+def send_withdrawal_confirmation(self, participant: Participant) -> None:
+ """Send withdrawal confirmation email to participant.
+
+ Args:
+ participant: The participant who withdrew
+
+ Raises:
+ EmailError: If email send fails
+ """
+ exchange = participant.exchange
+
+ # Render email templates
+ html_body = render_template(
+ 'emails/participant/withdrawal_confirmation.html',
+ participant=participant,
+ exchange=exchange
+ )
+ text_body = render_template(
+ 'emails/participant/withdrawal_confirmation.txt',
+ participant=participant,
+ exchange=exchange
+ )
+
+ # Send email
+ try:
+ resend.Emails.send({
+ "from": self.from_email,
+ "to": [participant.email],
+ "subject": f"Withdrawal Confirmed - {exchange.name}",
+ "html": html_body,
+ "text": text_body,
+ })
+
+ logger.info(f"Withdrawal confirmation sent to {participant.email}")
+
+ except Exception as e:
+ logger.error(f"Failed to send withdrawal email: {e}")
+ raise EmailError(f"Failed to send withdrawal confirmation: {e}")
+```
+
+## 6. Testing Specifications
+
+### 6.1 Unit Tests
+
+**Location**: `tests/unit/test_participant_utils.py` (new file)
+
+```python
+"""Unit tests for participant utility functions."""
+import pytest
+from datetime import datetime
+from src.utils.participant import can_update_profile, can_withdraw, is_withdrawn
+
+
+def test_can_update_profile_allowed_states(participant_factory, exchange_factory):
+ """Test profile updates allowed in pre-matching states."""
+ for state in ['draft', 'registration_open', 'registration_closed']:
+ exchange = exchange_factory(state=state)
+ participant = participant_factory(exchange=exchange)
+
+ assert can_update_profile(participant) is True
+
+
+def test_can_update_profile_disallowed_states(participant_factory, exchange_factory):
+ """Test profile updates blocked after matching."""
+ for state in ['matched', 'completed']:
+ exchange = exchange_factory(state=state)
+ participant = participant_factory(exchange=exchange)
+
+ assert can_update_profile(participant) is False
+
+
+def test_can_withdraw_allowed_states(participant_factory, exchange_factory):
+ """Test withdrawal allowed before registration closes."""
+ for state in ['draft', 'registration_open']:
+ exchange = exchange_factory(state=state)
+ participant = participant_factory(exchange=exchange)
+
+ assert can_withdraw(participant) is True
+
+
+def test_can_withdraw_disallowed_states(participant_factory, exchange_factory):
+ """Test withdrawal blocked after registration closes."""
+ for state in ['registration_closed', 'matched', 'completed']:
+ exchange = exchange_factory(state=state)
+ participant = participant_factory(exchange=exchange)
+
+ assert can_withdraw(participant) is False
+
+
+def test_can_withdraw_already_withdrawn(participant_factory):
+ """Test withdrawal blocked if already withdrawn."""
+ participant = participant_factory(withdrawn_at=datetime.utcnow())
+
+ assert can_withdraw(participant) is False
+```
+
+### 6.2 Integration Tests
+
+**Location**: `tests/integration/test_participant_self_management.py` (new file)
+
+```python
+"""Integration tests for participant self-management features."""
+import pytest
+from flask import url_for
+
+
+class TestProfileUpdate:
+ """Tests for profile update functionality."""
+
+ def test_profile_update_success(self, client, participant_session, db):
+ """Test successful profile update."""
+ response = client.post(
+ url_for('participant.profile_edit'),
+ data={
+ 'name': 'Updated Name',
+ 'gift_ideas': 'Updated gift ideas',
+ 'csrf_token': get_csrf_token(client)
+ },
+ follow_redirects=True
+ )
+
+ assert response.status_code == 200
+ assert b'profile has been updated' in response.data
+
+ # Verify database updated
+ participant = Participant.query.get(participant_session['user_id'])
+ assert participant.name == 'Updated Name'
+ assert participant.gift_ideas == 'Updated gift ideas'
+
+ def test_profile_update_locked_after_matching(
+ self, client, participant_session, db, exchange_factory
+ ):
+ """Test profile update blocked after matching."""
+ # Set exchange to matched state
+ participant = Participant.query.get(participant_session['user_id'])
+ participant.exchange.state = 'matched'
+ db.session.commit()
+
+ response = client.get(url_for('participant.profile_edit'))
+
+ assert response.status_code == 302 # Redirect
+ # Follow redirect
+ response = client.get(url_for('participant.profile_edit'), follow_redirects=True)
+ assert b'profile is locked' in response.data
+
+
+class TestWithdrawal:
+ """Tests for withdrawal functionality."""
+
+ def test_withdrawal_success(self, client, participant_session, db, mock_email):
+ """Test successful withdrawal."""
+ participant_id = participant_session['user_id']
+
+ response = client.post(
+ url_for('participant.withdraw'),
+ data={
+ 'confirm': True,
+ 'csrf_token': get_csrf_token(client)
+ },
+ follow_redirects=True
+ )
+
+ assert response.status_code == 200
+ assert b'withdrawn from the exchange' in response.data
+
+ # Verify database updated
+ participant = Participant.query.get(participant_id)
+ assert participant.withdrawn_at is not None
+
+ # Verify session cleared
+ with client.session_transaction() as session:
+ assert 'user_id' not in session
+
+ # Verify email sent
+ mock_email.send_withdrawal_confirmation.assert_called_once()
+
+ def test_withdrawal_blocked_after_close(
+ self, client, participant_session, db
+ ):
+ """Test withdrawal blocked after registration closes."""
+ participant = Participant.query.get(participant_session['user_id'])
+ participant.exchange.state = 'registration_closed'
+ db.session.commit()
+
+ response = client.get(url_for('participant.withdraw'), follow_redirects=True)
+
+ assert b'Registration has closed' in response.data
+```
+
+## 7. Security Checklist
+
+- ✅ All routes require `@participant_required` authentication
+- ✅ CSRF protection on all POST operations (WTForms)
+- ✅ State validation before allowing operations
+- ✅ Input sanitization (WTForms validators + Jinja2 auto-escaping)
+- ✅ Session cleared on withdrawal (no orphaned sessions)
+- ✅ Withdrawn participants excluded from participant list (privacy)
+- ✅ No email enumeration (withdrawn status not revealed to other participants)
+- ✅ Database rollback on errors (transaction safety)
+
+## 8. Performance Checklist
+
+- ✅ Participant list loaded with single query (no N+1)
+- ✅ State checks use in-memory objects (no extra DB hits)
+- ✅ Email sent asynchronously (doesn't block response)
+- ✅ Form pre-population uses ORM objects (efficient)
+- ✅ No caching needed (small datasets, infrequent updates)
+
+## 9. Accessibility Checklist
+
+- ✅ Form labels properly associated with inputs
+- ✅ Error messages linked to fields (ARIA)
+- ✅ Warning boxes use semantic HTML
+- ✅ Buttons have descriptive text (no icon-only)
+- ✅ Character counter is non-critical (progressive enhancement)
+- ✅ All actions keyboard-accessible
+
+---
+
+**End of Component Design**
diff --git a/docs/designs/v0.3.0/test-plan.md b/docs/designs/v0.3.0/test-plan.md
new file mode 100644
index 0000000..ae5e491
--- /dev/null
+++ b/docs/designs/v0.3.0/test-plan.md
@@ -0,0 +1,545 @@
+# Test Plan - v0.3.0
+
+**Version**: 0.3.0
+**Date**: 2025-12-22
+**Status**: Test Specification
+
+## Overview
+
+This document defines the comprehensive test plan for Phase 3 (Participant Self-Management). It covers unit tests, integration tests, and acceptance criteria for all user stories in scope.
+
+## Test Pyramid
+
+```
+ ╱╲
+ ╱ ╲ E2E Tests (Manual QA)
+ ╱────╲ - Full user journeys
+ ╱ ╲ - Cross-browser testing
+ ╱────────╲
+ ╱ ╲ Integration Tests (pytest)
+ ╱────────────╲ - Route handlers
+ ╱ ╲ - Database operations
+ ╱────────────────╲ - Email sending
+╱──────────────────╲
+ Unit Tests (pytest)
+ - Business logic functions
+ - Form validation
+ - State checks
+```
+
+## Test Coverage Goals
+
+- **Overall coverage**: 80%+ (maintain Phase 2 level)
+- **Business logic**: 95%+ (pure functions)
+- **Route handlers**: 80%+ (integration tests)
+- **Templates**: Manual testing (not measured by coverage)
+
+## 1. Unit Tests
+
+### 1.1 Participant Utility Functions
+
+**File**: `tests/unit/test_participant_utils.py`
+
+| Test Case | Description | Assertions |
+|-----------|-------------|------------|
+| `test_can_update_profile_draft_state` | Profile updates allowed in draft | `can_update_profile() == True` |
+| `test_can_update_profile_registration_open` | Profile updates allowed when open | `can_update_profile() == True` |
+| `test_can_update_profile_registration_closed` | Profile updates allowed when closed | `can_update_profile() == True` |
+| `test_can_update_profile_matched_state` | Profile updates blocked after matching | `can_update_profile() == False` |
+| `test_can_update_profile_completed_state` | Profile updates blocked when completed | `can_update_profile() == False` |
+| `test_can_withdraw_draft_state` | Withdrawal allowed in draft | `can_withdraw() == True` |
+| `test_can_withdraw_registration_open` | Withdrawal allowed when open | `can_withdraw() == True` |
+| `test_can_withdraw_registration_closed` | Withdrawal blocked when closed | `can_withdraw() == False` |
+| `test_can_withdraw_matched_state` | Withdrawal blocked after matching | `can_withdraw() == False` |
+| `test_can_withdraw_already_withdrawn` | Withdrawal blocked if already withdrawn | `can_withdraw() == False` |
+| `test_get_active_participants` | Returns only non-withdrawn participants | Count and names match |
+| `test_get_active_participants_empty` | Returns empty list when all withdrawn | `len(participants) == 0` |
+| `test_get_active_participants_ordered` | Participants ordered by name | Names in alphabetical order |
+| `test_is_withdrawn_true` | Detects withdrawn participant | `is_withdrawn() == True` |
+| `test_is_withdrawn_false` | Detects active participant | `is_withdrawn() == False` |
+
+**Fixtures needed**:
+```python
+@pytest.fixture
+def exchange_factory(db):
+ """Factory for creating exchanges in different states."""
+ def _create(state='draft'):
+ exchange = Exchange(
+ slug=generate_slug(),
+ name='Test Exchange',
+ budget='$25-50',
+ max_participants=50,
+ registration_close_date=datetime.utcnow() + timedelta(days=7),
+ exchange_date=datetime.utcnow() + timedelta(days=14),
+ timezone='UTC',
+ state=state
+ )
+ db.session.add(exchange)
+ db.session.commit()
+ return exchange
+ return _create
+
+@pytest.fixture
+def participant_factory(db):
+ """Factory for creating participants."""
+ def _create(exchange=None, withdrawn_at=None):
+ if not exchange:
+ exchange = Exchange(...) # Create default exchange
+ db.session.add(exchange)
+
+ participant = Participant(
+ exchange_id=exchange.id,
+ name='Test Participant',
+ email='test@example.com',
+ gift_ideas='Test ideas',
+ reminder_enabled=True,
+ withdrawn_at=withdrawn_at
+ )
+ db.session.add(participant)
+ db.session.commit()
+ return participant
+ return _create
+```
+
+### 1.2 Withdrawal Service
+
+**File**: `tests/unit/test_withdrawal_service.py`
+
+| Test Case | Description | Assertions |
+|-----------|-------------|------------|
+| `test_withdraw_participant_success` | Happy path withdrawal | `withdrawn_at` is set, email called |
+| `test_withdraw_participant_already_withdrawn` | Raises error if already withdrawn | `WithdrawalError` raised |
+| `test_withdraw_participant_wrong_state_closed` | Raises error if registration closed | `WithdrawalError` with specific message |
+| `test_withdraw_participant_wrong_state_matched` | Raises error if already matched | `WithdrawalError` with specific message |
+| `test_withdraw_participant_database_error` | Handles DB error gracefully | `WithdrawalError` raised, rollback called |
+| `test_withdraw_participant_email_failure` | Continues if email fails | `withdrawn_at` set, error logged |
+
+**Mocking strategy**:
+```python
+@pytest.fixture
+def mock_email_service(monkeypatch):
+ """Mock EmailService for withdrawal tests."""
+ mock = Mock()
+ monkeypatch.setattr('src.services.withdrawal.EmailService', lambda: mock)
+ return mock
+```
+
+### 1.3 Form Validation
+
+**File**: `tests/unit/test_participant_forms.py`
+
+| Test Case | Description | Assertions |
+|-----------|-------------|------------|
+| `test_profile_form_valid_data` | Valid name and gift ideas | `form.validate() == True` |
+| `test_profile_form_name_required` | Name is required | Validation error on name field |
+| `test_profile_form_name_too_long` | Name max 255 chars | Validation error on name field |
+| `test_profile_form_gift_ideas_optional` | Gift ideas can be empty | `form.validate() == True` |
+| `test_profile_form_gift_ideas_too_long` | Gift ideas max 10,000 chars | Validation error on gift_ideas field |
+| `test_reminder_form_boolean_field` | Accepts boolean value | `form.validate() == True` |
+| `test_withdraw_form_confirmation_required` | Confirmation required | Validation error on confirm field |
+| `test_withdraw_form_confirmation_true` | Accepts confirmation | `form.validate() == True` |
+
+## 2. Integration Tests
+
+### 2.1 Profile Update Tests
+
+**File**: `tests/integration/test_profile_update.py`
+
+| Test Case | Description | Setup | Action | Expected Result |
+|-----------|-------------|-------|--------|-----------------|
+| `test_profile_update_get_shows_form` | GET shows edit form | Auth'd participant | GET /participant/profile/edit | 200, form with current values |
+| `test_profile_update_post_success` | POST updates profile | Auth'd participant | POST with valid data | 302 redirect, flash success, DB updated |
+| `test_profile_update_name_change` | Name updates in DB | Auth'd participant | POST with new name | Participant.name updated |
+| `test_profile_update_gift_ideas_change` | Gift ideas update in DB | Auth'd participant | POST with new ideas | Participant.gift_ideas updated |
+| `test_profile_update_clears_whitespace` | Strips leading/trailing spaces | Auth'd participant | POST with " Name " | Stored as "Name" |
+| `test_profile_update_locked_after_matching` | Blocked when matched | Matched exchange | GET profile edit | 302 redirect, flash error |
+| `test_profile_update_form_validation_error` | Invalid data shows errors | Auth'd participant | POST with empty name | 200, form with errors |
+| `test_profile_update_csrf_required` | CSRF token required | Auth'd participant | POST without CSRF | 400 error |
+| `test_profile_update_requires_auth` | Auth required | No session | GET profile edit | 302 to login |
+| `test_profile_update_database_error` | Handles DB failure | Auth'd participant, mock DB error | POST valid data | Flash error, no DB change |
+
+**Test helpers**:
+```python
+@pytest.fixture
+def participant_session(client, participant_factory):
+ """Create authenticated participant session."""
+ participant = participant_factory()
+
+ with client.session_transaction() as session:
+ session['user_id'] = participant.id
+ session['user_type'] = 'participant'
+ session['exchange_id'] = participant.exchange_id
+
+ return participant
+
+def get_csrf_token(client, url='/participant/dashboard'):
+ """Extract CSRF token from page."""
+ response = client.get(url)
+ # Parse HTML and extract token
+ return extract_csrf_token(response.data)
+```
+
+### 2.2 Withdrawal Tests
+
+**File**: `tests/integration/test_withdrawal.py`
+
+| Test Case | Description | Setup | Action | Expected Result |
+|-----------|-------------|-------|--------|-----------------|
+| `test_withdrawal_get_shows_form` | GET shows confirmation page | Auth'd participant | GET /participant/withdraw | 200, form with warnings |
+| `test_withdrawal_post_success` | POST withdraws participant | Auth'd participant | POST with confirmation | 302 redirect, withdrawn_at set, session cleared |
+| `test_withdrawal_sends_email` | Email sent on withdrawal | Auth'd participant, mock email | POST with confirmation | Email service called |
+| `test_withdrawal_clears_session` | Session cleared after withdrawal | Auth'd participant | POST with confirmation | Session empty |
+| `test_withdrawal_redirects_to_public` | Redirects to registration page | Auth'd participant | POST with confirmation | Redirect to /exchange/{slug}/register |
+| `test_withdrawal_already_withdrawn` | Detects already withdrawn | Withdrawn participant | GET withdraw | Flash info, redirect |
+| `test_withdrawal_blocked_after_close` | Blocked when registration closed | Closed exchange | GET withdraw | Flash error, redirect to dashboard |
+| `test_withdrawal_blocked_after_matching` | Blocked when matched | Matched exchange | GET withdraw | Flash error, redirect to dashboard |
+| `test_withdrawal_requires_confirmation` | Confirmation checkbox required | Auth'd participant | POST without confirm=True | Form errors |
+| `test_withdrawal_csrf_required` | CSRF token required | Auth'd participant | POST without CSRF | 400 error |
+| `test_withdrawal_requires_auth` | Auth required | No session | GET withdraw | 302 to login |
+| `test_withdrawal_database_error` | Handles DB failure gracefully | Auth'd participant, mock DB error | POST valid data | Flash error, not withdrawn |
+
+### 2.3 Reminder Preference Tests
+
+**File**: `tests/integration/test_reminder_preferences.py`
+
+| Test Case | Description | Setup | Action | Expected Result |
+|-----------|-------------|-------|--------|-----------------|
+| `test_update_preferences_enable` | Enable reminders | Auth'd participant (disabled) | POST reminder_enabled=True | Flash success, DB updated |
+| `test_update_preferences_disable` | Disable reminders | Auth'd participant (enabled) | POST reminder_enabled=False | Flash success, DB updated |
+| `test_update_preferences_csrf_required` | CSRF token required | Auth'd participant | POST without CSRF | 400 error |
+| `test_update_preferences_requires_auth` | Auth required | No session | POST preferences | 302 to login |
+| `test_update_preferences_database_error` | Handles DB failure | Auth'd participant, mock DB error | POST valid data | Flash error, no change |
+
+### 2.4 Participant List Tests
+
+**File**: `tests/integration/test_participant_list.py`
+
+| Test Case | Description | Setup | Action | Expected Result |
+|-----------|-------------|-------|--------|-----------------|
+| `test_participant_list_shows_all_active` | Shows all active participants | 3 active participants | GET dashboard | All 3 names displayed |
+| `test_participant_list_excludes_withdrawn` | Hides withdrawn participants | 2 active, 1 withdrawn | GET dashboard | Only 2 names displayed |
+| `test_participant_list_shows_self_badge` | Marks current user | Auth'd participant | GET dashboard | "You" badge on own name |
+| `test_participant_list_ordered_by_name` | Alphabetical order | Participants: Zoe, Alice, Bob | GET dashboard | Order: Alice, Bob, Zoe |
+| `test_participant_list_count_excludes_withdrawn` | Count shows active only | 3 active, 1 withdrawn | GET dashboard | "Participants (3)" |
+| `test_participant_list_empty_state` | Message when alone | Only current participant | GET dashboard | "No other participants yet" |
+| `test_participant_list_requires_auth` | Auth required | No session | GET dashboard | 302 to login |
+
+## 3. Acceptance Tests
+
+### 3.1 Story 4.5: View Participant List (Pre-Matching)
+
+**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
+
+**Manual Test Steps**:
+
+1. **Setup**: Create exchange, register 3 participants (Alice, Bob, Charlie)
+2. **Login as Alice**: Request magic link, login
+3. **View Dashboard**: Participant list shows "Bob" and "Charlie" (not Alice's own name in list)
+4. **Verify No Emails**: Inspect HTML, confirm no email addresses visible
+5. **Register New Participant (Dave)**: Have Dave register
+6. **Refresh Dashboard**: Dave now appears in Alice's participant list
+7. **Bob Withdraws**: Have Bob withdraw
+8. **Refresh Dashboard**: Bob no longer appears in participant list
+
+**Expected Results**: Pass all criteria
+
+### 3.2 Story 6.1: Update Profile
+
+**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
+
+**Manual Test Steps**:
+
+1. **Login as Participant**: Use magic link to access dashboard
+2. **Click "Edit Profile"**: Navigate to profile edit page
+3. **Verify Pre-Population**: Name and gift ideas fields show current values
+4. **Update Name**: Change name from "Alice" to "Alice Smith"
+5. **Update Gift Ideas**: Add new gift idea
+6. **Verify Email Not Editable**: Email field not present in form
+7. **Save Changes**: Submit form
+8. **Verify Success Message**: "Your profile has been updated successfully"
+9. **Verify Dashboard Updated**: Dashboard shows new name and gift ideas
+10. **Admin Matches Exchange**: Admin triggers matching
+11. **Try to Edit Profile**: Click "Edit Profile" (if visible)
+12. **Verify Locked**: Error message "Your profile is locked after matching"
+
+**Expected Results**: Pass all criteria
+
+### 3.3 Story 6.2: Withdraw from Exchange
+
+**Acceptance Criteria**:
+- ✅ "Withdraw" option available before registration closes
+- ✅ Confirmation required
+- ✅ Participant removed from exchange
+- ✅ Confirmation email sent
+- ✅ Admin notified (if notifications enabled) - **Deferred to Phase 7**
+
+**Manual Test Steps**:
+
+1. **Login as Participant**: Use magic link
+2. **Click "Withdraw from Exchange"**: Navigate to withdrawal page
+3. **Verify Warning**: Warning box shows consequences
+4. **Try Submit Without Confirmation**: Leave checkbox unchecked, submit
+5. **Verify Error**: Form error requires confirmation
+6. **Check Confirmation Box**: Check "I understand..." box
+7. **Submit Withdrawal**: Click "Withdraw from Exchange"
+8. **Verify Success Message**: "You have been withdrawn..."
+9. **Verify Logged Out**: Session cleared, redirected to public page
+10. **Check Email**: Withdrawal confirmation email received
+11. **Login as Different Participant**: Login as Bob
+12. **Check Participant List**: Withdrawn participant (Alice) not in list
+13. **Admin Closes Registration**: Admin closes registration
+14. **Try to Withdraw as Bob**: Navigate to withdrawal page
+15. **Verify Blocked**: Error message "Registration has closed"
+
+**Expected Results**: Pass all criteria (except admin notification - Phase 7)
+
+### 3.4 Story 6.3: Update Reminder Preferences
+
+**Acceptance Criteria**:
+- ✅ Option to enable/disable reminder emails
+- ✅ Available before exchange completes
+- ✅ Changes take effect immediately
+
+**Manual Test Steps**:
+
+1. **Login as Participant**: Use magic link
+2. **View Dashboard**: Reminder preference checkbox visible
+3. **Verify Current State**: Checkbox checked (enabled by default)
+4. **Uncheck Reminder Box**: Uncheck "Send me reminders"
+5. **Click "Update Preferences"**: Submit form
+6. **Verify Success Message**: "Reminder emails disabled"
+7. **Refresh Page**: Checkbox remains unchecked
+8. **Re-Enable Reminders**: Check box, submit
+9. **Verify Success Message**: "Reminder emails enabled"
+10. **Admin Matches Exchange**: Trigger matching
+11. **Verify Still Available**: Reminder preference form still available post-matching
+
+**Expected Results**: Pass all criteria
+
+## 4. Edge Cases and Error Scenarios
+
+### 4.1 Race Conditions
+
+| Scenario | Expected Behavior | Test Method |
+|----------|-------------------|-------------|
+| Participant withdraws while admin is matching | Withdrawal succeeds if submitted before matching, blocked if submitted after | Manual timing test |
+| Two participants update profiles simultaneously | Both succeed (no conflicts, different records) | Concurrent requests test |
+| Participant updates profile while admin closes registration | Both succeed (profile lock is at matching, not close) | Manual timing test |
+
+### 4.2 Data Validation
+
+| Scenario | Expected Behavior | Test Method |
+|----------|-------------------|-------------|
+| Name with only whitespace | Validation error "Name is required" | Integration test |
+| Gift ideas exactly 10,000 characters | Accepted | Integration test |
+| Gift ideas 10,001 characters | Validation error | Integration test |
+| Name with special characters (é, ñ, 中) | Accepted, displayed correctly | Manual test |
+| XSS attempt in gift ideas | Escaped in display | Manual test |
+
+### 4.3 Session Handling
+
+| Scenario | Expected Behavior | Test Method |
+|----------|-------------------|-------------|
+| Session expires during profile edit | Redirect to login on submit | Manual test (wait for expiry) |
+| Withdraw from different browser tab | Both tabs see withdrawal (one succeeds, one sees "already withdrawn") | Manual test |
+| Participant deleted by admin while logged in | Next request clears session, redirects to login | Integration test |
+
+## 5. Performance Tests
+
+### 5.1 Query Optimization
+
+| Test | Query Count | Execution Time |
+|------|-------------|----------------|
+| Load dashboard with 50 participants | 3 queries max (exchange, participant, participant list) | < 100ms |
+| Update profile | 2 queries (load participant, update) | < 50ms |
+| Withdraw participant | 3 queries (load, update, email) | < 100ms |
+
+**Testing Method**: Use Flask-DebugToolbar or SQLAlchemy query logging
+
+### 5.2 Concurrent Operations
+
+| Test | Concurrent Requests | Expected Result |
+|------|---------------------|-----------------|
+| 10 participants updating profiles | 10 simultaneous | All succeed, no deadlocks |
+| 5 participants viewing participant list | 5 simultaneous | All succeed, consistent data |
+
+**Testing Method**: Use locust or manual concurrent curl requests
+
+## 6. Browser Compatibility
+
+**Supported Browsers** (per project requirements):
+- Chrome (last 2 versions)
+- Firefox (last 2 versions)
+- Safari (last 2 versions)
+- Edge (last 2 versions)
+
+**Manual Tests**:
+- Profile edit form renders correctly
+- Character counter works (progressive enhancement)
+- Withdrawal confirmation checkbox works
+- CSRF tokens submitted correctly
+- Flash messages display correctly
+
+## 7. Accessibility Tests
+
+**WCAG 2.1 AA Compliance**:
+
+| Test | Tool | Expected Result |
+|------|------|-----------------|
+| Form labels | axe DevTools | All inputs have associated labels |
+| Keyboard navigation | Manual | All actions accessible via keyboard |
+| Screen reader | NVDA/JAWS | Forms and messages announced correctly |
+| Color contrast | axe DevTools | All text meets 4.5:1 contrast ratio |
+| Error messages | Manual | Errors linked to fields via ARIA |
+
+## 8. Security Tests
+
+### 8.1 Authentication
+
+| Test | Method | Expected Result |
+|------|--------|-----------------|
+| Access profile edit without login | GET /participant/profile/edit | 302 redirect to login |
+| Access withdrawal without login | GET /participant/withdraw | 302 redirect to login |
+| Use expired session | Set session to past timestamp | Session cleared, redirect to login |
+
+### 8.2 Authorization
+
+| Test | Method | Expected Result |
+|------|--------|-----------------|
+| Edit another participant's profile | Manipulate user_id in session | Decorator uses session, not URL param - not vulnerable |
+| Withdraw another participant | Manipulate user_id in session | Decorator uses session, not URL param - not vulnerable |
+
+### 8.3 Input Validation
+
+| Test | Method | Expected Result |
+|------|--------|-----------------|
+| SQL injection in name field | Submit `'; DROP TABLE participants; --` | Escaped, no SQL execution |
+| XSS in gift ideas | Submit `` | Escaped, rendered as text |
+| CSRF attack | POST without token | 400 error |
+
+## 9. Test Automation
+
+### 9.1 CI Pipeline
+
+```yaml
+# .github/workflows/test.yml (example)
+name: Tests
+
+on: [push, pull_request]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: Install uv
+ run: pip install uv
+ - name: Install dependencies
+ run: uv sync
+ - name: Run unit tests
+ run: uv run pytest tests/unit -v --cov
+ - name: Run integration tests
+ run: uv run pytest tests/integration -v --cov --cov-append
+ - name: Coverage report
+ run: uv run coverage report --fail-under=80
+```
+
+### 9.2 Pre-Commit Hooks
+
+```yaml
+# .pre-commit-config.yaml
+repos:
+ - repo: local
+ hooks:
+ - id: pytest
+ name: pytest
+ entry: uv run pytest tests/unit
+ language: system
+ pass_filenames: false
+ always_run: true
+```
+
+## 10. Test Data Management
+
+### 10.1 Fixtures
+
+**Location**: `tests/conftest.py`
+
+```python
+@pytest.fixture
+def app():
+ """Create test Flask app."""
+ app = create_app('testing')
+ with app.app_context():
+ db.create_all()
+ yield app
+ db.drop_all()
+
+@pytest.fixture
+def client(app):
+ """Create test client."""
+ return app.test_client()
+
+@pytest.fixture
+def exchange(db):
+ """Create test exchange."""
+ exchange = Exchange(...)
+ db.session.add(exchange)
+ db.session.commit()
+ return exchange
+
+@pytest.fixture
+def participant(db, exchange):
+ """Create test participant."""
+ participant = Participant(exchange=exchange, ...)
+ db.session.add(participant)
+ db.session.commit()
+ return participant
+```
+
+### 10.2 Test Database
+
+- Use in-memory SQLite for speed: `sqlite:///:memory:`
+- Reset between tests: `db.drop_all()` + `db.create_all()`
+- Factories for generating test data with variations
+
+## 11. Regression Tests
+
+Tests from Phase 2 that must still pass:
+
+- ✅ Participant registration still works
+- ✅ Magic link authentication still works
+- ✅ Participant dashboard loads (now with participant list)
+- ✅ Admin login still works
+- ✅ Exchange creation still works
+
+## 12. Success Criteria
+
+Phase 3 tests are complete when:
+
+1. ✅ All unit tests pass (100% pass rate)
+2. ✅ All integration tests pass (100% pass rate)
+3. ✅ Code coverage ≥ 80%
+4. ✅ All acceptance criteria manually verified
+5. ✅ No security vulnerabilities found
+6. ✅ Accessibility tests pass
+7. ✅ All edge cases handled gracefully
+8. ✅ Performance benchmarks met
+9. ✅ Browser compatibility verified
+10. ✅ Phase 2 regression tests pass
+
+---
+
+**Test Plan Version**: 1.0
+**Last Updated**: 2025-12-22
+**Status**: Ready for Implementation