```html

Building Keely's Guest Photo Upload Page: Architecture, Lambda Integration, and Real-Time Instagram Feed Sync

What We Built

Keely's guest page (https://queenofsandiego.com/g/2026-05-24-keely-afternoon) is a post-charter photo submission and curation system that combines client-side form handling, serverless Lambda processing, S3 storage, and real-time Instagram hashtag integration. The page went live May 25, 2026, just after her afternoon sail completed.

The system addresses a critical gap in the charter experience: guests want to share photos immediately post-sail, but operators need spam protection and photo curation before public display. We solved this with event-scoped access codes, asynchronous Lambda processing, and an optional same-day Instagram feed pull tied to charter hashtags.

Architecture Overview

Frontend: Per-Event Guest Page

The guest page lives in S3 at s3://queenofsandiego-web/g/2026-05-24-keely-afternoon/index.html (22.5 KB). It's a standalone HTML file with embedded CSS and vanilla JavaScript—no build step, no dependencies beyond the browser and AWS SDK.

  • Upload form (#file-input, line 362): Accepts JPEG, PNG, WebP, HEIC, HEIF, MP4, MOV, AVI, WebM. Enforces 24-file-at-once limit via Array.from(files).slice(0, 24).forEach(uploadFile) (line 515).
  • Spam gate (#g-code, line 354): Optional event-scoped code input. Code present → immediate publish to #photo-grid. Code absent → submission queued for operator review in DynamoDB with status pending_review.
  • Instagram grid (#ig-grid, lines 452–459): Server-rendered list of same-day Instagram posts matching charter hashtags (#jada, #queenofsandiego). Data supplied by Lambda at request time and baked into the page HTML.
  • Gallery (#photo-grid): Live-updates via client-side polling or manual refresh after uploads complete.

Backend: shipcaptaincrew Lambda

The workhorse is /Users/cb/Documents/repos/sites/queenofsandiego.com/tools/shipcaptaincrew/lambda_function.py. It's a single Python file deployed as a Lambda function behind API Gateway, handling multiple routes:

  • POST /api/g/{event_id}/presign: Returns a presigned S3 PUT URL so the browser can upload directly to the bucket without backend mediation.
  • GET /api/g/{event_id}/photos: Returns all approved photos + same-day Instagram posts for that charter event (merged into a single d.instagram array on the client).
  • POST /api/g/{event_id}/submit: Accepts photo metadata (filename, caption, code) and inserts a record into DynamoDB with status approved (if code matches) or pending_review (if code missing/wrong).
  • POST /api/g/{event_id}/moderate: Operator endpoint to approve/reject pending submissions and send notification emails.

Data Flow: Upload → Publish

Happy path (code provided):

  1. Guest selects files and enters event code in the form.
  2. Frontend calls readCode() (line 424) to validate format and trim whitespace.
  3. For each file, frontend calls POST /presign with the event code. Lambda verifies code against DynamoDB event record (table: Events, key event_id#2026-05-24-keely-afternoon) and returns a presigned URL valid for 10 minutes.
  4. Frontend uploads file directly to S3 (bucket: queenofsandiego-photos, key: {event_id}/{uuid}.{ext}).
  5. Frontend calls POST /submit with metadata. Lambda verifies code again and inserts a photo record into DynamoDB table Photos with status = "approved" and ttl = event_end_time + 90 days.
  6. Photo appears in gallery within seconds.

Spam path (no code or wrong code):

  1. Guest uploads without entering a code.
  2. Lambda inserts record with status = "pending_review".
  3. Photo is hidden from the gallery until operator reviews and approves via the POST /moderate endpoint.
  4. Approval email sent to guest: "Your photo is live. Welcome to the sailor board." with a link back to the charter page.

Instagram Integration: Server-Side Hashtag Fetch

The GET /api/g/{event_id}/photos endpoint does more than return S3 photos. It also queries Instagram (via the Instagram Graph API, credentials stored in Secrets Manager) for posts matching #jada and #queenofsandiego from the same calendar day as the charter event.

The results are merged into a single response object:

{
  "photos": [
    {"id": "...", "url": "...", "status": "approved", "source": "s3"},
    ...
  ],
  "instagram": [
    {"id": "...", "url": "...", "caption": "...", "source": "instagram"},
    ...
  ]
}

The client renders both arrays side-by-side in the grid, so guests see a unified feed: their own uploaded photos plus any public Instagram posts from that day with the charter hashtags. This creates social proof and encourages tag participation.

Infrastructure & Deployment

S3 Buckets

  • queenofsandiego-web: Holds all guest pages, index pages, and static assets. CORS configured to allow origin https://queenofsandiego.com for presigned uploads.
  • queenofsandiego-photos: Raw photo storage. Private, no public read ACL. Access only via CloudFront distribution photos.queenofsandiego.com (distribution ID: E2V3K..., masked) or presigned URLs.

DynamoDB Tables

  • Photos: Schema = event_id#timestamp (pk), photo_id (sk), status, source, ttl, metadata. TTL attribute set to 90 days post-event for automatic cleanup.
  • Events: Schema = event_id (pk), event_code, event_start, event_end, charter_name. Seed this table when creating a new charter in the booking system.

Lambda & API Gateway

Function: shipcaptaincrew (Python 3.11 runtime, 512 MB memory, 60