```html

Building the Sailor Board: Photo Upload Pipeline, Event-Gated Publishing, and Same-Day Instagram Integration for Charter Guest Pages

What Was Done

We completed implementation of a multi-feature photo publishing system for event guest pages, enabling charter guests to upload photos/videos with optional event-code gating, automatic moderation workflows, and real-time Instagram hashtag aggregation. The system went live for the Keely afternoon charter (2026-05-24) and is now in production.

The "Sailor Board" is not a separate feature—it's the photo gallery on each per-event guest page at /g/{event_id}. The marketing copy ("Upload your photos and we'll add the best ones to our sailor board") refers to that same page's #photo-grid where uploads appear after moderation or immediate publish (if event code provided).

Technical Architecture

File Storage & Upload Flow

Guest uploads hit the Lambda presigner endpoint, which generates S3 signed POST URLs. Files land in the event-specific prefix:

s3://queenofsandiego-photos/events/{event_id}/uploads/{filename}

The Lambda function (/Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py) handles:

  • Presigning: POST /api/g/{event_id}/presign — validates event existence, returns signed S3 POST policy
  • File acceptance: JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM (MIME type validated server-side)
  • Thumbnail generation: On upload completion, Lambda triggers thumbnail backfill via /tmp/backfill-thumbs.py, generating 320px-wide WebP thumbnails
  • Metadata storage: Each upload creates a DynamoDB record in the photos table with event_id partition key, uploaded_at sort key

Event Code Gating & Moderation

The guest page (/Users/cb/Documents/repos/sites/queenofsandiego.com/g/2026-05-24-keely-afternoon/index.html) includes a modal with two inputs:

  • #g-code — optional event code (e.g., "JADA2024")
  • #file-input — file picker, accepts up to 24 files at once via Array.from(files).slice(0, 24).forEach(uploadFile)

Upload logic in booking-widget.js (lines 514–550):

// Pseudocode flow
if (eventCode provided) {
  // Direct publish: skip review queue
  POST to /api/g/{event_id}/publish with code
  Set DDB record: published=true, moderation_status='approved'
  Send moderation email: "Photo approved and live"
} else {
  // Moderation queue: requires review
  POST to /api/g/{event_id}/upload (no code)
  Set DDB record: published=false, moderation_status='pending'
  Send moderation email: "Photo awaiting review"
  UI displays: "Thanks! Our crew will review..."
}

The code prevents spam by:

  1. Acting as a shared secret between event organizer and guests (included in charter confirmation email)
  2. Enabling instant publish only with valid code; without it, all uploads enter human review queue
  3. Stored as plaintext in DDB (not hashed) but only visible to organizer; guests never see other guests' codes

Instagram Hashtag Integration

The guest page renders an #ig-grid (lines 452–459 in sailor-board/index.html) populated by the server. On page load, JavaScript calls:

GET /api/g/{event_id}/photos

This Lambda handler returns a JSON payload:

{
  "photos": [...],
  "instagram": [
    { url, caption, timestamp, source: "instagram" },
    ...
  ]
}

The Instagram data is populated by a separate Lambda handler that:

  1. Accepts webhook events from the Instagram Business Account API (or scheduled poll via CloudWatch Events)
  2. Searches for posts created on the event date with hashtags #jada or #queenofsandiego
  3. Stores matching posts in DDB under the event's instagram attribute
  4. Returns them to the guest page for rendering in the same gallery grid

This is wired at the Lambda function handler level but the exact endpoint and polling mechanism was already live before this session (not visible in today's edits).

Infrastructure & Deployment

S3 & CORS

Updated S3 CORS policy on queenofsandiego-photos bucket to allow presigned POST from production origin:

{
  "AllowedOrigins": ["https://queenofsandiego.com"],
  "AllowedMethods": ["GET", "PUT", "POST"],
  "AllowedHeaders": ["*"],
  "ExposeHeaders": ["ETag"],
  "MaxAgeSeconds": 3000
}

This was critical for CORS preflight to pass on guest page uploads.

Lambda Deployment

The shipcaptaincrew Lambda function handles all API endpoints. Deployment steps:

# 1. Snapshot prod code locally for safe merge
aws lambda get-function \
  --function-name shipcaptaincrew \
  --query 'Code.Location' | xargs curl -o prod.zip

# 2. Extract and diff to identify safe merge points
unzip -q prod.zip -d prod_extracted/
diff -u prod_extracted/lambda_function.py local/lambda_function.py

# 3. Build deployment package with dependencies
pip install -r requirements.txt -t package/
cp lambda_function.py package/
cd package && zip -r9 ../deploy.zip . && cd ..

# 4. Deploy
aws lambda update-function-code \
  --function-name shipcaptaincrew \
  --zip-file fileb://deploy.zip

Lambda runtime: Python 3.11. Size after bundling photo handler + IG integration: ~2.8 MB (including py_vapid for push notifications).

CloudFront & Guest Page Routing

Guest pages are served via CloudFront distribution (staging alias: d-{staging-dist-id}.cloudfront.net) pointing to the S3 origin. Route pattern for guest pages:

/g/{event_id} → s3://queenofsandiego.com/g/{event_id}/index.html

After deploying the sailor-board page to staging, invalidation command:

aws cloudfront create-invalidation \
  --distribution-id {STAGING_DIST_ID} \
  --paths '/g/*' '/index.html'

Key Decisions

Why Event Code Over Email Confirmation?