# 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 %} {% 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:

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

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**