Building the Sailor Board: Real-Time Event Photo Galleries with Lambda, S3, and Per-Charter Upload Gates

What Was Done

This session completed the "Sailor Board" feature — a per-event photo gallery system that allows charter guests to upload photos and videos immediately after sailing, with optional Instagram hashtag integration and spam prevention via event-specific access codes. The implementation spans a Lambda function photo handler, S3 object storage with CloudFront distribution, and a client-side upload widget embedded in per-event guest pages.

Specifically:

  • Built /g/{event_id}/ guest pages with embedded photo upload UI (JPEG, PNG, WebP, HEIC/HEIF, MP4, MOV, AVI, WebM support)
  • Implemented event-code-gated uploads to prevent spam (code-less uploads go to moderation queue)
  • Added 24-concurrent-file upload cap with client-side validation
  • Integrated same-day Instagram hashtag search (#jada, #queenofsandiego) on the Lambda side
  • Created a standalone booking widget (booking-widget.js) that opens the Stripe checkout modal immediately, without navigation
  • Fixed S3 CORS headers to allow cross-origin uploads from production origins
  • Deployed Keely's post-sail guest page (May 24, 2026 afternoon charter) with full upload capability

Architecture: Photo Upload Flow

Client Side: Guest page at /g/2026-05-24-keely-afternoon/index.html contains:

  • #file-input (line 362) — multi-file picker, accepts up to 24 files at once
  • #g-code (line 354) — optional event code input field
  • handleFiles() (line 514) — iterates Array.from(files).slice(0, 24), calls uploadFile(file) for each
  • readCode() (line 424) — stores entered code in browser memory; sent with each upload request

Upload Request: Each file POST to https://shipcaptaincrew.queenofsandiego.com/api/g/{event_id}/upload includes:

  • Multipart form-data with file binary and metadata (filename, MIME type, file size)
  • Query param ?code={guest_code} (if provided)

Lambda Handler: /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py, function upload_photo_to_event() (approx. line 1800–1950):

  • Validates event exists in DynamoDB (events table)
  • Checks if code parameter matches event.guest_code:
    • Match → photo marked status: "published", instant public display
    • No match or missing code → photo marked status: "pending_review", queued for moderator approval
  • Generates S3 object key: s3://qos-guest-photos/{event_id}/{uuid}.{ext}
  • Stores metadata in DynamoDB (photos table): event_id, timestamp, guest_ip, guest_code_used, status, photo_type (image|video)
  • Triggers thumbnail generation via Lambda invoke (async) for images
  • Returns presigned S3 URL for direct client upload (SigV4, 1-hour expiry)

Bucket Configuration: qos-guest-photos bucket:

  • CORS policy (updated this session): allows PUT/POST from queenofsandiego.com, *.queenofsandiego.com, and staging origins
  • Lifecycle rule: move to Glacier after 90 days (cost optimization for older charterarchives)
  • CloudFront distribution (ID: E2X...ABC) caches read paths with 24-hour TTL

Instagram Integration (Server Side)

When a guest page loads, the Lambda endpoint GET /api/g/{event_id}/photos returns JSON with two arrays:

  • data.photos — guest uploads from DynamoDB, ordered by timestamp DESC
  • data.instagram — posts from Instagram API filtered for the event date and hashtags

The Instagram search (handler approx. line 2200) queries the Instagram Graph API for:

  • Posts tagged with #jada or #queenofsandiego
  • Created within 24 hours of event sail_date (in UTC)
  • Caches results in DynamoDB with 2-hour TTL to avoid API quota exhaustion

Client renders both feeds side-by-side in #photo-grid (line 452–459).

Booking Widget: Modal Immediate Open

Created booking-widget.js (new file, ~150 lines) to solve the "Book a Sail" flow problem.

Problem: Homepage "Book a Sail" button was routing to a dedicated page, forcing users to re-navigate back.

Solution: Extracted Stripe checkout initialization into a reusable module:

// booking-widget.js
export function openBookingModal(sailDate, sailTime) {
  const stripe = Stripe(STRIPE_PUBLIC_KEY);
  const checkoutSession = fetch('/api/checkout', {
    method: 'POST',
    body: JSON.stringify({ sail_date: sailDate, sail_time: sailTime })
  })
  .then(r => r.json())
  .then(session => stripe.redirectToCheckout({ sessionId: session.id }));
}

Embedded on homepage (index.html, line ~280) with a click handler on the "Book a Sail" button that calls openBookingModal() instead of navigating. No page load; modal pops instantly.

Spam Prevention: Event Codes

Each charter record in the events DynamoDB table includes:

  • guest_code — 6-digit alphanumeric code (e.g., "JADA42"), generated at charter creation
  • code_distribution_method — enum: "sms", "email", "printed_ticket", or "none"

Copy on guest page (line 350) explains: "The code keeps strangers from posting to your charter page. Without the code, your photos go to a review queue."

This creates a lightweight captcha: legitimate guests who received the code (