```html

Building Out the Sailor Board: Guest Photo Upload, Event Gating, and Instagram Integration for Charter Events

Over the past development session, we solidified the guest photo upload experience for chartered sailing events—specifically Keely's May 24 afternoon charter. What started as a marketing placeholder ("sailor board") became a fully functional, production-deployed per-event gallery with upload capability, spam prevention, batch processing, and Instagram hashtag integration. Here's what we built, why we built it that way, and the infrastructure decisions that made it work.

The Guest Page Stack: What's Actually Running

The guest page lives at a predictable, event-specific URL pattern:

https://queenofsandiego.com/g/{DATE}-{CHARTER_ID}

For Keely's event, that's https://queenofsandiego.com/g/2026-05-24-keely-afternoon. The page itself is deployed to S3 under the root bucket and served through CloudFront with cache busting via URL invalidation.

The HTML file (22.5 KB) contains:

  • A pre-sail / post-sail state machine that flips at a hardcoded UTC timestamp (FLIP_UTC = new Date("2026-05-25T00:00:00Z"))
  • File input form with drag-and-drop (lines 354–360 in the deployed page) accepting JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, and WebM
  • Event code input field (#g-code) that gates uploads and bypasses moderation review
  • Photo grid renderer that pulls from the Lambda API
  • Instagram feed grid that renders hashtag results for the same day

The page is pure client-side HTML + vanilla JavaScript—no framework dependencies, which keeps the cold-start latency down when served through CloudFront.

The Upload Flow: Client Side to Lambda

When a guest selects files, the JavaScript function handleFiles() (line 514) does the following:

  1. Slices the file array to a maximum of 24 items (line 515: Array.from(files).slice(0, 24))
  2. For each file, calls uploadFile(file)
  3. uploadFile() makes a GET request to the presigned URL endpoint: GET /api/g/{event_id}/presign?filename=X&mimetype=Y
  4. The Lambda function shipcaptaincrew/lambda_function.py (handler: presign_photo) returns a time-limited S3 presigned POST URL
  5. The client then POSTs directly to S3 with the file, bypassing Lambda (this is crucial for large video files—don't want Lambda to buffer them)

Why presigned URLs? We don't want to store AWS credentials in client code. The Lambda generates time-limited POST policies (~15 minutes) scoped to a specific S3 key prefix and MIME type. The guest's browser talks directly to S3, and Lambda never touches the file bits.

S3 bucket structure:

s3://queenofsandiego-content-prod/
  g/
    2026-05-24-keely-afternoon/
      upload/
        {guest-id}/{file-id}.jpg
        {guest-id}/{file-id}.mp4

After upload completes, the client hits the Lambda endpoint GET /api/g/{event_id}/confirm, which triggers a DynamoDB record creation and kicks off the moderation workflow.

Event Code and Spam Prevention

The page includes a text input field (#g-code, line 354) where guests can enter an event code. This code is checked in readCode() (line 424).

The logic:

  • If a valid code is provided: the photo is marked with status: "live" and published immediately to the gallery
  • If no code (or wrong code): the photo lands in a status: "pending_review" queue, and a moderation email is sent to the charter organizer
  • Copy on the page reads: "The code keeps strangers from posting to your charter page."

The event code itself is generated per-charter and stored in the event metadata in DynamoDB. It's a simple string (e.g., a 6–8 character alphanumeric code). The Lambda verifies it in the confirm handler before accepting the upload record.

The Lambda Handler: shipcaptaincrew/lambda_function.py

The core upload logic lives in tools/shipcaptaincrew/lambda_function.py. Key handlers:

  • presign_photo(event, context) — Generates presigned S3 POST URL
  • confirm_photo(event, context) — Records the photo metadata in DynamoDB, triggers moderation queue or live publish
  • get_photos(event, context) — Fetches photos for the gallery (filters by event_id, status=live)
  • fetch_instagram_for_event(event_id, event_date) — Queries Instagram API for posts matching #jada or #queenofsandiego from the same calendar day, returns as a JSON array

The Instagram integration is server-side. The guest page requests GET /api/g/{event_id}/photos?include_instagram=true, and the Lambda returns a response object with both photos (guest uploads) and instagram (hashtag results) arrays. The client then renders both into separate grids using simple template literals.

Why fetch Instagram on the server? The Instagram API requires authentication with app credentials. We can't expose those to the client. The Lambda has them in environment variables (encrypted in AWS Secrets Manager) and can make authenticated calls server-to-server.

DynamoDB Schema

Photos are stored in a table (default name pattern: queenofsandiego-photos-prod) with the following attributes:

  • event_id (partition key) — e.g., 2026-05-24-keely-afternoon
  • photo_id (sort key) — UUID generated by the upload handler
  • guest_id — parsed from the presign request or form submission
  • s3_key — full path in S3 (e.g., g/2026-05-24-keely-afternoon/upload/guest-123/photo-uuid.jpg)
  • status — "pending_review", "live", or "rejected"
  • mime_type — the file's MIME type (used for gallery filtering)
  • created_at — Unix timestamp
  • event_code_used — boolean; whether the upload used the bypass code