# 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 %}
No other participants yet. Share the registration link!
{% endif %}If you can no longer participate, you can withdraw from this exchange. This cannot be undone.
Withdraw from ExchangeUpdate your display name and gift ideas. Your Secret Santa will see this information after matching.
Withdrawing from this exchange means:
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.