Building the Sailor Board: Photo Upload, Moderation, and Real-Time Instagram Integration for Event Guest Pages

This post covers the architecture, deployment, and feature implementation for Queen of San Diego's guest event pages—specifically the photo upload pipeline, event-scoped moderation, and same-day Instagram hashtag integration that powers the "Sailor Board" experience.

What Was Built

Guest event pages (e.g., /g/2026-05-24-keely-afternoon) now support:

  • Multi-file upload (up to 24 at once): JPEG, PNG, WebP, HEIC/HEIF, MP4, MOV, AVI, WebM
  • Event code gate: Guests with the event code bypass moderation; uploads without code go to a review queue
  • Same-day Instagram feed: Automatic hashtag search (#jada, #queenofsandiego) populated server-side and rendered into the guest page
  • Thumbnail generation: Automatic image optimization and thumb backfill for existing uploads
  • Modal booking integration: "Book a Sail" now opens the Stripe checkout modal inline instead of navigating away

Technical Architecture

Frontend: Guest Page Structure

Guest pages are static HTML deployed to S3 at paths like:

/g/{YYYY-MM-DD}-{event-slug}/index.html

For Keely's charter (May 24, 2026), the file is located at:

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

The page is a self-contained HTML document with embedded JavaScript and CSS. Key DOM elements:

  • #file-input (line 362): Multi-file upload input, accepts all supported media types
  • #g-code (line 354): Event code input for moderation bypass
  • #photo-grid (lines 452–459): Container for both uploaded photos and Instagram feed
  • #upload-status (line 350): Real-time feedback during upload (validation, progress, success/error)

The page implements a state machine driven by FLIP_UTC (event flip time). Before the flip, the page shows event details; after the flip, the upload UI appears. For Keely's event:

FLIP_UTC = "2026-05-25T00:00:00Z"  // May 24, 5 PM PT

Upload Handler: Lambda + S3 Presigned URLs

File uploads use AWS Lambda to generate presigned URLs, avoiding hard-coded S3 credentials in the browser. The flow:

  1. Frontend calls GET /api/g/{event_id}/presign with filename and MIME type
  2. Lambda validates the event code (if provided) against DynamoDB
  3. Lambda generates a time-limited presigned URL (15-minute expiry)
  4. Frontend uploads directly to S3 using the presigned URL
  5. S3 POST triggers a Lambda event processor

Lambda handler: tools/shipcaptaincrew/lambda_function.py, function name presign_guest_upload (approx. line 1400).

Key validation:

  • Event ID format: /g/{YYYY-MM-DD}-{slug} regex
  • Event code check: Query DynamoDB table qos-events, key {event_id}, attribute guest_code
  • MIME type whitelist: Enforced on presigned URL policy and re-validated on S3 POST

S3 bucket for uploads: s3://qos-guest-uploads-prod/

Photo Processing: Moderation Queue and Thumbnails

When a file lands in S3, an object creation event triggers a second Lambda function:

tools/shipcaptaincrew/lambda_function.py::process_guest_photo

This function:

  1. Pulls event metadata from DynamoDB to determine if the uploader provided a valid code
  2. Generates thumbnails (48px, 240px, 480px) using Pillow, stores alongside original in S3
  3. Routes to moderation or live:
    • If code is valid: Adds photo metadata to DynamoDB qos-guest-photos with status = "live"
    • If no code: Creates entry with status = "pending", sends Slack notification to moderation channel
  4. Invalidates CloudFront cache for the guest page and photo API endpoints

DynamoDB table schema for photos (qos-guest-photos):

PK: event_id (String)
SK: photo_id (String)
Attributes:
  - s3_key (String): path to original file
  - thumb_48 (String): presigned URL or S3 key
  - thumb_240 (String): presigned URL or S3 key
  - status (String): "live" | "pending" | "rejected"
  - uploaded_at (Number): Unix timestamp
  - uploader_name (String): optional metadata

Thumbnail backfill for existing photos without thumbs is handled by a separate utility script:

/tmp/backfill-thumbs.py

This script queries DynamoDB, downloads missing thumb sizes, uploads to S3, and updates metadata.

Instagram Integration: Server-Side Hashtag Search

The guest page includes an Instagram feed fetched at page-render time (not client-side). The Lambda endpoint:

GET /api/g/{event_id}/photos

Handler: tools/shipcaptaincrew/lambda_function.py::get_guest_photos (approx. line 1600).

This endpoint returns a JSON object with two arrays:

{
  "photos": [ /* user-uploaded photos, filtered by status */ ],
  "instagram": [
    {
      "url": "https://instagram.com/...",
      "thumb": "https://...",
      "caption": "...",
      "posted_at": "2026-05-24T14:30:00Z"
    },
    ...
  ]
}

Instagram hashtag search is performed by querying an Instagram Basic Display API endpoint (or a cached fetch) with hashtags #jada and #queenofsandiego, filtered to posts made on the event date. The actual Instagram credential and API setup are managed outside the Lambda handler (likely as environment variables or Secrets Manager).

The guest page JSON-embeds this response and renders it into #ig-grid at page load: