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 %} + + {% 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. +

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label }} + {{ form.name(class="form-control") }} + {% if form.name.errors %} +
    + {% for error in form.name.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + {{ form.name.description }} +
+ +
+ {{ form.gift_ideas.label }} + {{ form.gift_ideas(class="form-control") }} + {% if form.gift_ideas.errors %} +
    + {% for error in form.gift_ideas.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + {{ form.gift_ideas.description }} + + {{ (form.gift_ideas.data or '')|length }} / 10,000 characters + +
+ +
+ {{ form.submit(class="button button-primary") }} + + Cancel + +
+
+
+ + +{% 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 + +
+

Email Reminders

+
+ {{ reminder_form.hidden_tag() }} +
+ {{ reminder_form.reminder_enabled() }} + {{ reminder_form.reminder_enabled.label }} +
+ {{ reminder_form.submit(class="button button-secondary") }} +
+
+``` + +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:

+ +
+ +

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

+ +
+ +
+ {{ form.hidden_tag() }} + +
+ + {% if form.confirm.errors %} +
    + {% for error in form.confirm.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.submit(class="button button-danger") }} + + Cancel + +
+
+
+{% 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 %} +
+ + +
+

Email Reminders

+
+ {{ reminder_form.hidden_tag() }} +
+ {{ reminder_form.reminder_enabled() }} + {{ reminder_form.reminder_enabled.label }} +
+ {{ reminder_form.submit(class="button button-secondary") }} +
+
+ + +
+

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. +

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label }} + {{ form.name(class="form-control") }} + {% if form.name.errors %} +
    + {% for error in form.name.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + {{ form.name.description }} +
+ +
+ {{ form.gift_ideas.label }} + {{ form.gift_ideas(class="form-control") }} + {% if form.gift_ideas.errors %} +
    + {% for error in form.gift_ideas.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} + {{ form.gift_ideas.description }} + + {{ (form.gift_ideas.data or '')|length }} / 10,000 characters + +
+ +
+ {{ form.submit(class="button button-primary") }} + + Cancel + +
+
+
+ + +{% 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
  • +
+
+ +
+ {{ form.hidden_tag() }} + +
+ + {% if form.confirm.errors %} +
    + {% for error in form.confirm.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+ {{ form.submit(class="button button-danger") }} + + Cancel + +
+
+
+{% 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