Adding Payment Logging to the Ship Captain Crew Admin Dashboard: Lambda Handlers, Gmail Integration, and CloudFront Routing Fixes
What Was Done
This session implemented a complete payment-logging feature for the Ship Captain Crew (SCC) admin dashboard, including:
- Two new Lambda handlers (
handle_admin_log_paymentandhandle_admin_payment_cleared) in/Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py - Gmail helper functions for sending payment confirmation emails via AWS SES
- A new "Log Payment" modal UI in the dispatch SPA (
index.html) - Environment variable injection for Gmail OAuth credentials into the Lambda function configuration
- Staging deployment and validation before production rollout
- Diagnosis of a CloudFront routing bug affecting waiver PDF access (deferred for next session)
Technical Details: Lambda Handler Architecture
The payment logging system follows the existing event-routing pattern in lambda_function.py. The Lambda handler already had a routing dispatcher that looks for request paths and delegates to specific handlers. We inserted two new handlers before the main lambda_handler function:
def handle_admin_log_payment(event, context):
"""
POST /api/admin/log-payment
Logs a payment against an event in DynamoDB and sends confirmation email.
"""
# Extract admin token from Authorization header
# Validate against ADMIN_PASS_HASH env var
# Parse JSON body: {event_id, amount, notes, patron_name, patron_email}
# Update DynamoDB event item with payment record
# Send confirmation via Gmail/SES
# Return 200 with confirmation details
def handle_admin_payment_cleared(event, context):
"""
POST /api/admin/payment-cleared
Marks an event as fully paid in DynamoDB.
"""
# Extract admin token
# Validate token
# Mark event.payment_cleared = True in DDB
# Send notification email to patron
# Return 200 confirmation
Why this pattern: The existing Lambda already validates admin tokens by comparing request headers against the ADMIN_PASS_HASH environment variable. By reusing this pattern, we keep authentication centralized and consistent. The handlers delegate email sending to helper functions rather than embedding SMTP logic inline, following single-responsibility principles.
Gmail / SES Integration
We added three helper functions to handle email operations:
get_gmail_service()— Constructs a Gmail API service object using OAuth2 credentials stored in Lambda environment variables (GMAIL_TOKEN,GMAIL_REFRESH_TOKEN, etc.)send_payment_confirmation_email(patron_email, patron_name, amount, event_id)— Formats and sends a confirmation email to the patronsend_admin_notification(admin_email, event_summary)— Sends a summary to the admin for record-keeping
Rather than using raw SMTP, we use AWS SES (Simple Email Service) via the Gmail API credentials because:
- Lambda already has IAM permissions for SES
- Gmail OAuth tokens were already configured in prior work
- SES is region-aware and integrates with CloudWatch for delivery tracking
- No additional secrets management needed beyond what's already in Lambda env vars
Frontend: Payment Modal in the Dispatch SPA
The dispatch HTML (index.html) already had modal infrastructure for other admin functions. We added a new modal following the existing pattern:
<div id="modal-log-payment" class="admin-modal" style="display: none;">
<h3>Log Payment</h3>
<form id="payment-form">
<input type="hidden" id="payment-event-id" />
<input type="text" placeholder="Patron Name" id="payment-patron-name" />
<input type="email" placeholder="Email" id="payment-patron-email" />
<input type="number" placeholder="Amount ($)" id="payment-amount" step="0.01" />
<textarea placeholder="Notes" id="payment-notes"></textarea>
<button type="submit">Log Payment</button>
</form>
</div>
The modal is toggled by a "Log Payment" button on each event card (rendered in renderEventCard()). When submitted, the form calls:
apiFetch('/api/admin/log-payment', {
method: 'POST',
headers: { 'Authorization': `Bearer ${adminToken}` },
body: JSON.stringify({
event_id: eventId,
patron_name: form.name.value,
patron_email: form.email.value,
amount: parseFloat(form.amount.value),
notes: form.notes.value
})
})
Why this approach: Modals follow the existing modal display pattern (active class toggle vs. inline styles) in the SPA. By embedding the modal in the static dispatch HTML, we avoid dynamic DOM injection, keeping the JavaScript simpler and the page fully functional server-side if needed.
Infrastructure: Environment Variables and Lambda Deployment
Gmail credentials are injected into the Lambda function via environment variables. We built a merged payload that includes:
GMAIL_TOKEN— OAuth2 access tokenGMAIL_REFRESH_TOKEN— Refresh token for long-lived sessionsGMAIL_CLIENT_IDandGMAIL_CLIENT_SECRET— OAuth app credentialsADMIN_PASS_HASH— Existing admin token hash (retained from prod)- All other existing env vars (retained to prevent overwrite)
We snapshotted the production Lambda configuration before any update, ensuring rollback capability. The deployment process:
- Read current prod Lambda env vars via AWS CLI
- Merge new Gmail vars into the set
- Update Lambda config:
aws lambda update-function-configuration --function-name shipcaptaincrew --environment Variables={...} - Wait for the config update to settle (~2 seconds)
- Build the Python code into a deployment zip
- Deploy code:
aws lambda update-function-code --function-name shipcaptaincrew --zip-file fileb://deploy.zip - Wait for code update to settle
Why this order: Updating environment variables first ensures that if the code deploy fails, the Lambda still has valid secrets. If we deployed code first and it fails, the function would crash trying to access unset env vars.
Testing and Staging
Before production, we deployed to a staging environment:
- Dispatch HTML was deployed to the
/_staging/*