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