```html

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 #jada or #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-code field (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