Building the Sailor Board: Real-Time Photo Aggregation for Post-Charter Guest Galleries
This session focused on completing the photo upload and moderation pipeline for Queen of San Diego's guest event pages, specifically validating and extending the "Sailor Board" feature—a per-event gallery that surfaces guest photos, videos, and Instagram content captured during charter experiences.
What Was Built
The core deliverable is a unified guest experience layer that combines three data streams into a single post-event gallery:
- Direct uploads: Photos and videos from guests using the event's dedicated upload interface
- Moderation pipeline: Human review (with optional event code bypass for instant publication) before photos appear in the gallery
- Instagram hashtag aggregation: Same-day posts tagged with
#jadaor#queenofsandiego, automatically surfaced alongside guest uploads
The implementation spans three architectural layers: the Lambda-based photo API (/tools/shipcaptaincrew/lambda_function.py), the per-event HTML guest page template (now deployed to /g/{event_date}-{charter_name}/index.html), and a new client-side booking widget that integrates payment and scheduling flows directly into the guest experience.
Technical Architecture
Photo Upload & Storage
Guest uploads flow through a presigned POST mechanism that writes directly to S3 buckets segregated by event ID. The Lambda handler at shipcaptaincrew/lambda_function.py:1400-1450 (approximately) generates time-limited presigned URLs that prevent unauthorized uploads while keeping the upload operation off the Lambda hot path.
The S3 bucket structure mirrors the DynamoDB event schema:
s3://photos-prod/events/{event_id}/{upload_id}/{original.jpg|original.mp4}
s3://photos-prod/events/{event_id}/{upload_id}/thumb_360.jpg
s3://photos-prod/events/{event_id}/{upload_id}/thumb_720.jpg
Thumbnail generation happens asynchronously via a backfill script (/tmp/backfill-thumbs.py) that processes uploads in batches. This approach avoids blocking the upload response path while ensuring consistent thumbnail quality across formats (JPEG, PNG, WebP, HEIC/HEIF for images; MP4, MOV, AVI, WebM for video).
Moderation & Publish Control
The guest page (/sailor-board/index.html) implements a two-tier publication model:
- Code-gated instant publish: Guests who enter the correct event code (sent via email) see their uploads appear immediately in the gallery
- Moderation queue: Uploads without a code enter a DynamoDB review table, flagged for manual approval. Moderators use the Lambda-based email workflow to accept/reject with templated replies
This design choice balances user experience (most guests get instant gratification) with content control (prevents spam and off-topic uploads without friction). The code itself is not cryptographic—it's a simple string match—which is sufficient because it's (a) event-specific, (b) shared only with registered attendees, and (c) the barrier to entry is low enough that attackers would target easier vectors.
Instagram Feed Integration
The GET /api/g/{event_id}/photos endpoint (around line 2200 in lambda_function.py) queries Instagram's public API during request time, filtering for posts created on the charter date with the required hashtags. Results are cached in memory for 5 minutes to avoid rate-limiting during heavy traffic (e.g., when guests share the page immediately after returning from a sail).
The filtering logic is strict: posts must be timestamped on the event date in Pacific time, and must include either #jada or #queenofsandiego in the caption. This prevents weeks-old posts from appearing and keeps the feed contextually relevant.
Infrastructure Changes
S3 & CloudFront Deployment
New guest pages are deployed to the production S3 bucket (queenofsandiego.com) under the /g/ prefix. The CloudFront distribution (ID visible in deployment logs) invalidates the cache for these paths automatically post-upload, ensuring guests see their galleries within seconds of the event ending.
CORS configuration was updated on the S3 bucket to permit uploads from https://queenofsandiego.com origins:
AllowedOrigins: ["https://queenofsandiego.com"]
AllowedMethods: ["GET", "PUT", "POST"]
AllowedHeaders: ["*"]
MaxAgeSeconds: 3000
This allows the presigned POST to work cross-origin without exposing credentials in the browser.
Lambda Deployment
The shipcaptaincrew Lambda was updated with the new photo handler logic and redeployed via zip packaging. The deployment process:
cd /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew
zip -r lambda_deploy.zip lambda_function.py lib/ config/
aws lambda update-function-code --function-name shipcaptaincrew --zip-file fileb://lambda_deploy.zip
The function includes external dependencies (e.g., py_vapid for web push notifications, Pillow for thumbnail generation) bundled into the zip, keeping the cold-start overhead minimal.
Guest Experience Layer
Sailor Board Page Template
The new /sailor-board/index.html serves as the per-event gallery page. Key UI elements:
- Code input:
#g-codefield (line 354) accepts the event code, triggering instant-publish mode - File input:
#file-input(line 362) accepts up to 24 files at once, with client-side validation for MIME types - Upload handler:
handleFiles()(line 514) batches presigned POST requests, managing parallel uploads and retry logic - Gallery rendering: Two-column grid (responsive to mobile) displays thumbnails with lazy-load behavior
- Instagram feed: Injected from the API response into
#ig-grid(lines 452–459) with the same thumbnail and link styling
Booking Widget Integration
A new /booking-widget.js file decouples the payment/scheduling modal from page navigation. Previously, the "Book a Sail" call-to-action on the homepage would navigate to a checkout page. Now:
document.getElementById('book-sail-btn').addEventListener('click', function(e) {
e.preventDefault();
showBookingModal();
// Stripe/Calendly embed initializes inside modal, not full page nav
});
This keeps users on the guest page while allowing them to complete a booking without losing their upload progress or gallery context.
Key Decisions & Trade-offs
- Synchronous Instagram fetch vs. async backfill: Instagram results are fetched on-demand during the gallery render, not pre-fetched into DynamoDB. This keeps the data fresh (same