Adding Patron Payment Logging to Ship Captain Crew: Lambda + CloudFront Routing Architecture
What Was Done
We implemented a payment logging feature for the Ship Captain Crew event management system, enabling crew administrators to record patron payments directly through the dispatch interface. The implementation involved three core changes: (1) extending the Lambda function with Gmail credential handling and payment event handlers, (2) adding a modal UI component to the dispatch HTML for payment entry, and (3) fixing CloudFront routing to properly handle waiver requests that were previously falling through to the S3 SPA fallback.
Technical Architecture
Lambda Function Enhancement
The core application logic lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. We added two critical helper functions before the lambda_handler entry point:
- Gmail credential management: Helper functions to validate and retrieve Gmail API credentials from Lambda environment variables. These credentials are essential for sending payment confirmation emails to patrons.
- Payment event handlers: A new
handle_payment_logfunction that validates payment submissions, writes to the DynamoDB events table, and triggers confirmation emails via SES (Simple Email Service).
The payment handler follows the same routing pattern as existing handlers (e.g., handle_waiver_get at line 1697). It accepts POST requests to /api/events/{event_id}/payment, validates the patron record exists, computes running totals, and creates an immutable payment record in DynamoDB.
Why this approach: By keeping payment logic server-side and using environment variables for credentials, we maintain security separation. The crew dispatch interface never holds Gmail tokens—only the Lambda function does. This prevents accidental credential exposure through browser dev tools or network inspection.
Dispatch HTML Payment Modal
The dispatch interface at /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/index.html (currently ~2463 lines after S3 sync) received a new modal component. Rather than inline the payment form on event cards, we follow the existing modal pattern used elsewhere (admin login, event details, etc.) for consistency.
- Modal trigger: A "Log Payment" button appears on event cards for authenticated crew members with admin privileges.
- Form fields: Patron name (autocomplete from event roster), amount, payment method (cash/card/check), and optional notes.
- Client-side validation: Amount validation, duplicate-prevention (checks recent payments), and user feedback via toast notifications.
- API integration: Form submission calls
apiFetch('/api/events/' + event_id + '/payment', { method: 'POST', body: {...} }), leveraging the existing auth token header injection.
Why modal over inline: The dispatch interface is information-dense. Each event card shows date, location, crew roster, status, and action buttons. A modal keeps the payment workflow separate and prevents accidental clicks while preserving screen real estate.
Infrastructure & Deployment
Environment Variables
We merged two configuration sources into Lambda's environment via the AWS Lambda console:
GMAIL_SENDER_EMAIL— the SES-verified sender address for payment confirmationsGMAIL_REFRESH_TOKEN— credentials for Gmail API (stored encrypted in Lambda environment)- Existing variables like
ADMIN_PASS_HASH,DYNAMODB_TABLE_NAME(shipcaptaincrew-events), andEVENTS_S3_BUCKET
We took a snapshot of production Lambda config before any changes, allowing safe rollback if needed.
CloudFront Routing Fix
During testing, we discovered that waiver requests (e.g., /g/2026-05-23/waiver) were returning "Could not load event" errors. Root cause: CloudFront was routing /g/*/waiver to the S3 origin (dispatch SPA), which fell back to index.html. The SPA then parsed the path as an event ID, fetched /api/g/2026-05-23/waiver, received HTML from Lambda, and crashed on r.json().
Fix: Add a CloudFront behavior (higher priority than the catch-all SPA rule) routing /g/*/waiver to the Lambda Function URL. This ensures waiver requests bypass S3 and hit the Lambda handler directly at handle_waiver_get (line 1697 of lambda_function.py).
CloudFront distribution ID and exact behavior order:
Priority 1: /g/*/waiver → Lambda Function URL (NEW)
Priority 2: /api/* → Lambda Function URL (existing)
Priority 3: /charter/* → Lambda Function URL (existing)
Priority 4: /* → S3 origin with SPA index.html fallback (catch-all)
Why not fix in S3 routing: S3 doesn't support conditional routing to different origins. CloudFront behaviors are the proper layer for origin routing logic.
Testing & Validation
- Admin authentication: Confirmed staging admin login works with new
ADMIN_PASS_HASHenvironment variable. - API endpoints: Probed
/api/eventsresponse and verified Lambda logs show proper request handling (401 unauthorized for missing tokens, 404 for non-existent routes, 200 for valid requests). - Staging deployment: Pushed updated dispatch HTML to the S3 staging slot and invalidated CloudFront cache (
/_staging/*pattern). - Waiver routing: Once the CloudFront behavior is added, test
GET /g/{event_id}/waiverreturns HTML (not a JSON parsing error).
Key Decisions & Trade-offs
- Modal vs. inline form: Modal keeps the dispatch interface clean and prevents accidental payment submissions. Trade-off: one extra click to log payment.
- Server-side credential storage: Lambda environment variables (encrypted at rest) hold Gmail tokens, not browser storage. Slightly more complex auth flow, but eliminates credential exposure risk.
- DynamoDB for payment records: Payments are immutable log entries (append-only) in the events table, not mutable fields on patron records. This preserves audit trails and prevents accidental overwrites.
- CloudFront routing priority: New waiver behavior runs *before* the S3 catch-all, ensuring proper origin selection. Order matters in CloudFront; we validated existing API and charter routes don't conflict.
Bonus Finding: Event ID Slugs
The event ID 2026-05-23 violates the documented convention of YYYY-MM-DD-firstname-[period] (