```html

Building the Real-Time Event Photo Gallery: Infrastructure, Lambda Orchestration, and the "Sailor Board" Pattern

What We Built

Over this session, we completed the photo upload, moderation, and gallery infrastructure for Queen of San Diego's post-charter experience. The "Sailor Board" — despite the marketing name suggesting an aggregate leaderboard — is actually a per-event photo gallery that lives at /g/{event_id} on each charter's guest page. We wired together:

  • A Lambda-backed REST API for photo uploads with presigned S3 URLs
  • Event-code-gated uploads (instant publish vs. moderation queue)
  • 24-photo-per-batch upload limits with client-side UI hints
  • Real-time Instagram hashtag feeds (#jada, #queenofsandiego) fetched server-side and rendered into the gallery
  • CloudFront distribution invalidation on gallery updates
  • A new /sailor-board/ staging page for A/B testing the feature rollout
  • A "Book a Sail" modal auto-open mechanism tied to deep-link query params

Architecture: Lambda + S3 + CloudFront

The photo upload flow starts in the guest page (/g/{event_id}/), served from S3 CloudFront. When a user selects photos:

  1. Client requests presigned URL from GET /api/g/{event_id}/presign (Lambda, DNS-routed through shipcaptaincrew.queenofsandiego.com)
  2. Lambda returns time-limited S3 PUT URL and metadata (bucket, key, conditions)
  3. Browser uploads directly to S3 using presigned URL (bypasses Lambda, faster uploads)
  4. S3 triggers an event notification to the photo-processing Lambda
  5. Photo Lambda processes metadata, calls Instagram API, updates DynamoDB gallery index, invalidates CloudFront cache

This architecture decouples upload throughput from Lambda cold-start latency — guests don't wait for Lambda to finish processing before seeing "upload complete."

Key File Changes

Lambda handler: tools/shipcaptaincrew/lambda_function.py (multiple iterations in this session)

  • Line 2030: Upload-success message: "Your photo is live. Welcome to the sailor board."
  • Line 2066: Moderation email link text, pointing to /g/{event_id}
  • Line 2107, 2433: Moderation reply copy mentioning the sailor board
  • Presign endpoint: Generates S3 PUT policy, returns temporary credentials
  • Photo fetch endpoint (/api/g/{event_id}/photos): Returns gallery metadata + Instagram feed data from DynamoDB

Guest page: /g/2026-05-24-keely-afternoon/index.html (deployed to S3, no local source checked in)

  • Line 354: Event code input (#g-code) — copy reads "The code keeps strangers from posting to your charter page."
  • Line 360: Upload UI hint: "up to 24 at once"
  • Line 362: File input accepts JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM
  • Line 424: readCode() function — event code enters DynamoDB check before publish
  • Line 452–459: #ig-grid renders Instagram posts from d.instagram array (populated by Lambda)
  • Line 514: handleFiles() orchestrates upload flow, applies 24-item limit via Array.from(files).slice(0, 24)

Homepage: index.html and sailor-board/index.html (new, staging)

  • Added feature token <!-- SAILOR_BOARD_READY --> to homepage nav
  • Created sailor-board/index.html as soft launch landing page

Booking widget: booking-widget.js (new)

  • Extracted Stripe checkout JS + modal CSS from homepage
  • Supports deep-link query param ?open-booking=1 to auto-open modal on page load
  • Prevents homepage navigation on "Book a Sail" button tap — modal opens inline instead

Infrastructure & Deployments

S3 buckets:

  • queenofsandiego.com — public website content (CloudFront origin)
  • queenofsandiego-photos — guest-uploaded media (private, CORS-enabled for upload origins)
  • CORS policy updated to allow presigned PUT/POST from queenofsandiego.com and staging domains

CloudFront distributions:

  • Prod: queenofsandiego.com (origin: S3 + Route53 alias)
  • Staging: Distribution alias for soft-launching sailor-board feature (separate from prod to prevent cache collision)

Lambda deployments:

cd tools/shipcaptaincrew
zip -r lambda_function.zip lambda_function.py dependencies/
aws lambda update-function-code \
  --function-name shipcaptaincrew-prod \
  --zip-file fileb://lambda_function.zip \
  --region us-west-2

CloudFront invalidation (triggered on gallery update):

aws cloudfront create-invalidation \
  --distribution-id E1234ABCD5678EFG \
  --paths "/g/*" "/sailor-board/*"

DynamoDB schema for photos:

  • Table: QOS_Events
  • Partition key: event_id (e.g., 2026-05-24-keely-afternoon)
  • Sort key: timestamp (photo upload time)
  • Attributes: photo_url, uploader_code_match (boolean), instagram_source (boolean), status (live/pending/rejected)

Why This Architecture?

Presigned