```html

Building the Sailor Board: Real-Time Guest Photo Curation & Instagram Integration for Charter Events

This post walks through the architecture, deployment strategy, and design decisions behind Queen of San Diego's guest photo upload system — specifically the "Sailor Board" feature that lets charter guests instantly publish photos to a per-event gallery, with optional Instagram hashtag integration and server-side moderation.

What Was Built

The Sailor Board is a per-event photo gallery living at /g/{event_id} on the public website. It serves three core functions:

  • Guest photo upload: Charter attendees upload up to 24 photos/videos at once without authentication
  • Event code gating: An optional per-event code allows instant publication; uploads without the code go into a moderation queue
  • Instagram hashtag aggregation: Server-side Lambda fetches same-day posts tagged #jada or #queenofsandiego and displays them alongside guest uploads

Live example: https://queenofsandiego.com/g/2026-05-24-keely-afternoon (post-event gallery for the May 24 Keely charter).

Architecture Overview

Frontend: Per-Event Guest Page

The guest page is a static HTML file deployed to S3 and served through CloudFront. Each charter gets its own URL slug based on event date and name:

s3://queenofsandiego.com/g/2026-05-24-keely-afternoon/index.html

The page contains:

  • Pre-sail state (before event UTC flip time): Marketing copy, event details, "Book a Sail" button
  • Post-sail state (after flip time): File upload form, event code input, photo grid

The flip time is defined in the HTML as FLIP_UTC (e.g., new Date('2026-05-25T00:00:00Z')) and controls when the upload UI becomes visible.

File Upload Flow

When a guest selects files (up to 24 at once), the client-side handleFiles() function (line 514 in the guest page) calls:

GET /api/presign?event_id=2026-05-24-keely-afternoon&filename=photo.jpg

This endpoint, hosted on shipcaptaincrew.queenofsandiego.com, returns a signed S3 URL valid for 15 minutes. The guest's browser then uploads directly to S3, bypassing the Lambda:

PUT {signed_url} + file bytes

On successful upload, a CloudWatch event or S3 trigger invokes the shipcaptaincrew Lambda, which:

  • Validates the event code (if provided)
  • Extracts EXIF metadata (timestamp, location)
  • Generates thumbnail images using Pillow
  • Stores metadata in DynamoDB (PhotoMetadata table)
  • Publishes to a moderation queue (SNS) if no valid code was supplied

Backend: Lambda Handlers

All backend logic lives in /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. Key handlers:

  • presign_handler() — Generates signed S3 upload URLs
  • process_upload() — Triggered on S3 PUT; validates code, extracts metadata, generates thumbnails
  • get_event_photos() — Returns JSON list of approved photos + Instagram feed for a given event_id
  • moderate_photo() — Admin endpoint to approve/reject uploads

Instagram Integration

The get_event_photos() handler queries Instagram's Graph API for posts matching the event date and hashtags:

GET https://graph.instagram.com/v18.0/ig_hashtag_search?user_id={user_id}&fields=id,name
GET https://graph.instagram.com/v18.0/{hashtag_id}/recent_media?fields=id,caption,media_type,media_url&limit=20

Results are filtered to same-day posts only and merged into the response JSON under the instagram key. The guest page renders these in a separate grid section, visually distinguished from guest uploads.

Why server-side? Instagram's client-side SDK doesn't expose hashtag search; we needed backend access to the Graph API with a long-lived app token.

Infrastructure: S3, DynamoDB, Lambda

S3 Buckets

  • queenofsandiego.com — Public site bucket; serves the guest pages and final photos
  • queenofsandiego.com-uploads-staging — Staging environment uploads (for testing before prod)
  • queenofsandiego.com-photos — Raw uploads land here temporarily; moved to public bucket after processing

DynamoDB Table: PhotoMetadata

Schema:

  • PK: event_id (e.g., "2026-05-24-keely-afternoon")
  • SK: photo_key (S3 object key, e.g., "g/2026-05-24-keely-afternoon/photo_123.jpg")
  • Attributes: guest_name, guest_code_valid, timestamp, exif_location, status (approved/pending/rejected), thumb_small_url, thumb_large_url

Why DynamoDB? Event-keyed access patterns; no complex joins; scales to thousands of photos per event without hot partitions.

Lambda Configuration

Function: shipcaptaincrew

  • Runtime: Python 3.11
  • Memory: 1024 MB (required for Pillow thumbnail generation)
  • Timeout: 60 seconds
  • Layers: python-pillow (custom layer with compiled Pillow + libjpeg + libpng)
  • Env vars: INSTAGRAM_APP_ID, INSTAGRAM_APP_TOKEN (in Secrets Manager, not inline)

API Gateway Routes

Endpoint