AI Event Photo Discovery Platform

Build a facial recognition platform that matches attendees to their event photos.

Introduction

30 Second Summary

Think about the last big event you attended. Hundreds of photos were taken, but finding the ones with your face means scrolling through every single one.

In this project, you will build and deploy a full-stack AI platform that uses facial recognition to automatically match event attendees to their photos. Hosts upload event photos, the system detects faces and generates vector embeddings, and attendees find every photo they appear in by simply submitting a selfie.

What You'll Build

A production-deployed AI Event Photo Discovery Platform powered by InsightFace facial recognition, Qdrant vector search, a FastAPI backend with Celery async processing, and a Next.js frontend, all deployed on AWS.

By the end of this project, you'll have:

  • A FastAPI backend with JWT authentication, role-based access control, event management, bulk photo upload to S3, and a face processing pipeline that generates 512-dimensional embeddings.
  • An async Celery worker that processes photos through InsightFace facial recognition and upserts embeddings into Qdrant for instant vector similarity search.
  • A polished Next.js 15 frontend with host dashboard, attendee selfie capture, personalized photo gallery, and real-time processing status updates.
  • A full AWS deployment (EC2 for backend, RDS PostgreSQL, S3 for photos, Amplify for frontend) with start/stop scripts for cost management.
  • Secret Mission: Face clustering analytics that shows hosts how many unique people attended and who appears in the most photos.

Are there any prerequisites?

This project assumes intermediate Python and JavaScript/TypeScript experience, along with basic Docker knowledge. You will also need an AWS account with remaining credits (created after July 15, 2025).

Set Up the Local Development Environment

Before you can build an AI photo discovery platform, you need the right foundation. This project has four major components that all need to work together locally: a Python backend with ML capabilities, a vector database for fast similarity search, a task queue for async processing, and a React frontend.

In this step, you will set up all four pillars of the development environment. By the end, you will have the backend running with facial recognition ready, Docker services providing vector search and caching, and a Next.js frontend scaffolded and waiting.

In this step, get ready to:
  • Set up the Python backend project with all dependencies installed.
  • Launch Qdrant and Redis via Docker and verify InsightFace detects faces.
  • Initialize the Next.js frontend with TypeScript and Tailwind CSS.
Install Python dependencies and set up the backend project structure

The backend handles everything from user authentication to face detection. It needs a clean project structure with a virtual environment to isolate its dependencies from your system Python.

  • In your terminal, navigate to your project folder and create the backend directory structure by running these commands:
mkdir -p backend/app
touch backend/app/__init__.py
cd backend

What does this create?

The backend/ directory is your Python project root. The app/ subdirectory will hold all your FastAPI code. The empty __init__.py file marks app/ as a Python package so you can import modules from it.

  • Create a Python virtual environment by running:
python3 -m venv venv
  • Activate the virtual environment:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you ran the activation command from inside the backend/ directory. If you see a "file not found" error, confirm the venv/ folder exists by running ls.

help me activate my Python virtual environment

  • Create the requirements.txt file inside your backend/ directory with all the project dependencies:
fastapi
uvicorn
sqlmodel
psycopg2-binary
alembic
celery
redis
insightface
onnxruntime
qdrant-client
python-jose[cryptography]
bcrypt
passlib
python-multipart
boto3
qrcode[pil]
Pillow
imagehash
numpy
opencv-python-headless

What are these packages for?

  • fastapi and uvicorn power the web API server.
  • insightface and onnxruntime handle face detection and embedding generation.
  • qdrant-client connects to the vector database for similarity search.
  • celery and redis manage async background tasks for photo processing.
  • sqlmodel and psycopg2-binary handle the PostgreSQL database layer.
  • python-jose and bcrypt provide JWT authentication and password hashing.
  • Save requirements.txt.
  • Install all the dependencies by running:
pip install -r requirements.txt

This will take a few minutes. The insightface and onnxruntime packages are large due to the ML inference libraries they include.

Installation failing?

If you see errors about cmake or build tools, you may need to install system-level build dependencies. On macOS, run xcode-select --install. On Ubuntu, run sudo apt-get install build-essential.

help me fix pip install errors for insightface or onnxruntime

✔️ Awesome, I've got everything!

Great. Double check you have saved requirements.txt and all packages installed without errors.

ⓧ I'd like to double check the full code

fastapi
uvicorn
sqlmodel
psycopg2-binary
alembic
celery
redis
insightface
onnxruntime
qdrant-client
python-jose[cryptography]
bcrypt
passlib
python-multipart
boto3
qrcode[pil]
Pillow
imagehash
numpy
opencv-python-headless
Launch Qdrant and Redis via Docker

Your platform needs two infrastructure services running locally. Qdrant is a vector database that stores face embeddings and enables lightning-fast similarity search. Redis acts as the message broker for Celery background tasks.

  • Navigate back to your project root (one level up from backend/) by running:
cd ..
  • Create a file called docker-compose.dev.yml in your project root with the following content:
version: '3.8'
services:
  qdrant:
    image: qdrant/qdrant:latest
    ports:
      - '6333:6333'
    volumes:
      - qdrant_data:/qdrant/storage
  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'
volumes:
  qdrant_data:

What does this Docker Compose file do?

  • The qdrant service runs the vector database on port 6333 with persistent storage via a named volume.
  • The redis service runs the lightweight Alpine-based Redis on port 6379 for Celery task queuing.
  • The qdrant_data volume ensures your face embeddings persist even if you restart the containers.
  • Save docker-compose.dev.yml.
  • Start both services in the background by running:
docker compose -f docker-compose.dev.yml up -d
  • Verify both containers are running:
docker compose -f docker-compose.dev.yml ps

You should see both qdrant and redis listed with a status of "running" or "Up".

Check the Qdrant dashboard

Open http://localhost:6333/dashboard in your browser. You should see the Qdrant web interface, confirming the vector database is ready.

Containers not starting?

Make sure Docker is running on your machine. If you see port conflict errors, another service may already be using port 6333 or 6379. Stop the conflicting service or change the host port in the compose file.

help me troubleshoot Docker Compose services not starting

✔️ Awesome, I've got everything!

Great. Make sure docker-compose.dev.yml is saved and both services show as running.

ⓧ I'd like to double check the full code

version: '3.8'
services:
  qdrant:
    image: qdrant/qdrant:latest
    ports:
      - '6333:6333'
    volumes:
      - qdrant_data:/qdrant/storage
  redis:
    image: redis:7-alpine
    ports:
      - '6379:6379'
volumes:
  qdrant_data:
Verify InsightFace loads and detects faces

The InsightFace library provides the facial recognition model your platform uses. The first time it runs, it downloads the buffalo_l model pack (about 326MB) which includes face detection and 512-dimensional embedding generation.

  • Switch back to the backend/ directory where your virtual environment is active:
cd backend
  • Create a temporary test script called test_insightface.py to verify the model downloads and runs inference:
from insightface.app import FaceAnalysis
import numpy as np

# Initialize the face analysis model (downloads buffalo_l on first run)
app = FaceAnalysis(providers=['CPUExecutionProvider'])
app.prepare(ctx_id=0, det_size=(640, 640))

print("InsightFace buffalo_l model loaded successfully!")
print(f"Available models: {[m.__class__.__name__ for m in app.models.values()]}")

# Test with a blank image (should find 0 faces)
test_image = np.zeros((640, 640, 3), dtype=np.uint8)
faces = app.get(test_image)
print(f"Test detection found {len(faces)} faces (expected 0 on blank image)")
print("Face detection pipeline verified!")

What does this script do?

  • FaceAnalysis(providers=['CPUExecutionProvider']) initializes InsightFace using CPU inference via onnxruntime.
  • app.prepare(ctx_id=0, det_size=(640, 640)) loads the detection model with a 640x640 input size.
  • The blank image test confirms the full pipeline (detect, align, embed) runs without crashing.
  • Run the verification script:
python test_insightface.py

The first run downloads the model, so expect a short wait. You should see output confirming the model loaded and detected 0 faces on the blank test image.

Model download failing or OOM errors?

If the download stalls, check your internet connection. If you see memory errors, close other heavy applications. The model needs about 1GB of RAM during loading.

help me get InsightFace buffalo_l model working

  • Once verified, delete the test script:
rm test_insightface.py
Initialize the Next.js frontend project

The frontend is a Next.js 15 application using the App Router, TypeScript for type safety, and Tailwind CSS 4 for styling. You will scaffold it using the official create-next-app CLI.

  • Navigate back to your project root:
cd ..
  • Scaffold the frontend project by running this command:
npx create-next-app@15 frontend --typescript --tailwind --no-linter --app --use-npm

What do these flags mean?

  • --typescript initializes the project with TypeScript support.
  • --tailwind includes Tailwind CSS configuration.
  • --no-linter skips the linter setup to keep the project focused.
  • --app uses the App Router (the recommended Next.js architecture).
  • --use-npm uses npm as the package manager.

Because you are passing configuration flags, the CLI will skip interactive prompts and use sensible defaults for any remaining options.

The command downloads and installs Next.js 15, React, TypeScript, and Tailwind CSS into a new frontend/ directory.

  • Verify the frontend project was created by running the dev server:
cd frontend
npm run dev

You should see output indicating the dev server started on http://localhost:3000. Open that URL in your browser to confirm you see the default Next.js welcome page.

  • Press Ctrl+C in your terminal to stop the dev server. You will not need it running for the next steps.

Create-next-app failed or frontend won't start?

Confirm Node.js is version 20 or higher by running node --version. If npx hangs, try clearing the npm cache with npm cache clean --force and running the command again.

help me troubleshoot my Next.js frontend setup

Your full development environment is now ready. You have a Python backend with ML capabilities, two Docker services for vector search and task queuing, and a modern React frontend. Next up, you will build the database schema and authentication system that ties everything together.

Build the Database Schema and Auth System

Your development infrastructure is running. Qdrant and Redis are handling vector storage and message brokering. Now the platform needs a memory layer and a security gate.

In this step, you'll define the PostgreSQL database schema that stores users, events, photos, and face records. You'll also build the JWT authentication system that controls who can access what.

In this step, get ready to:
  • Define SQLModel database models for users, events, photos, faces, and memberships.
  • Create JWT token generation and password hashing utilities.
  • Implement registration and login endpoints with role-based route protection.
Design the PostgreSQL database models

Every platform needs structured storage for its core entities. Your event photo platform needs to track users, events, uploaded photos, detected faces, and which attendees belong to which events.

  • Start a PostgreSQL container for local development by running this command:
docker run -d --name eventphotos-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=eventphotos -p 5432:5432 postgres:16-alpine

What does this command do?

  • `-d` runs the container in the background.
  • `POSTGRES_PASSWORD=postgres` sets the superuser password.
  • `POSTGRES_DB=eventphotos` creates a database named eventphotos on first startup.
  • `-p 5432:5432` maps the container's PostgreSQL port to your machine.
  • Verify PostgreSQL is running by running:
docker ps --filter name=eventphotos-postgres

You should see the container listed with status "Up".

Container not starting?

If you see a port conflict error, another process is using port 5432. Stop the conflicting process or change the host port mapping to -p 5433:5432.

Help me debug my PostgreSQL Docker container

  • Create the database connection module at backend/app/database.py by adding the following code:
from sqlmodel import SQLModel, Session, create_engine

# PostgreSQL connection string for local development
DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/eventphotos'

engine = create_engine(DATABASE_URL)

def create_db_and_tables():
    SQLModel.metadata.create_all(engine)

def get_session():
    with Session(engine) as session:
        yield session

What does this code do?

  • `create_engine` establishes the connection pool to PostgreSQL.
  • `create_db_and_tables` uses SQLModel's metadata to create all registered table models in the database.
  • `get_session` is a FastAPI dependency that provides one database session per request.
  • Save backend/app/database.py.

Now create the models that define the shape of your data. SQLModel combines SQLAlchemy table definitions with Pydantic validation in a single class.

  • Create the models file at backend/app/models.py with the user and role definitions:
from sqlmodel import SQLModel, Field
from typing import Optional
from datetime import datetime
import enum

class UserRole(str, enum.Enum):
    host = 'host'
    attendee = 'attendee'

class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    email: str = Field(unique=True, index=True)
    hashed_password: str
    role: UserRole
    created_at: datetime = Field(default_factory=datetime.utcnow)

What does this code do?

  • `UserRole` is a Python enum restricting roles to host or attendee. Hosts upload event photos. Attendees submit selfies to find their photos.
  • `User` stores login credentials and role. The table=True flag tells SQLModel to create an actual database table for this class.
  • `Field(unique=True, index=True)` on email prevents duplicate accounts and speeds up lookups.
  • Add the event, photo, face record, and membership models below the User class in the same file:
class Event(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    host_id: int = Field(foreign_key='user.id')
    name: str
    description: Optional[str] = None
    created_at: datetime = Field(default_factory=datetime.utcnow)

class Photo(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    event_id: int = Field(foreign_key='event.id')
    s3_key: str
    status: str = 'pending'
    created_at: datetime = Field(default_factory=datetime.utcnow)

class FaceRecord(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    photo_id: int = Field(foreign_key='photo.id')
    event_id: int = Field(foreign_key='event.id')
    embedding_id: str
    quality_score: float
    bbox: str

class EventMembership(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    user_id: int = Field(foreign_key='user.id')
    event_id: int = Field(foreign_key='event.id')
    joined_at: datetime = Field(default_factory=datetime.utcnow)

What do these models represent?

  • `Event` is owned by a host and groups photos together.
  • `Photo` tracks each uploaded image. The status field starts as pending and updates as the face processing pipeline works through it.
  • `FaceRecord` links a detected face back to its photo and stores the Qdrant embedding_id for vector search. The bbox stores the bounding box coordinates as a JSON string.
  • `EventMembership` tracks which attendees have joined which events.
  • Save backend/app/models.py.
  • Create the database tables by running this command from the backend/ directory:
cd backend
python -c "from app.models import *; from app.database import create_db_and_tables; create_db_and_tables()"

If the command completes without errors, your tables were created successfully in the eventphotos database.

Seeing a connection refused error?

Make sure the PostgreSQL container is running with docker ps. If it exited, restart it with docker start eventphotos-postgres.

Help me fix my database connection

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

from sqlmodel import SQLModel, Field
from typing import Optional
from datetime import datetime
import enum

class UserRole(str, enum.Enum):
    host = 'host'
    attendee = 'attendee'

class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    email: str = Field(unique=True, index=True)
    hashed_password: str
    role: UserRole
    created_at: datetime = Field(default_factory=datetime.utcnow)

class Event(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    host_id: int = Field(foreign_key='user.id')
    name: str
    description: Optional[str] = None
    created_at: datetime = Field(default_factory=datetime.utcnow)

class Photo(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    event_id: int = Field(foreign_key='event.id')
    s3_key: str
    status: str = 'pending'
    created_at: datetime = Field(default_factory=datetime.utcnow)

class FaceRecord(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    photo_id: int = Field(foreign_key='photo.id')
    event_id: int = Field(foreign_key='event.id')
    embedding_id: str
    quality_score: float
    bbox: str

class EventMembership(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    user_id: int = Field(foreign_key='user.id')
    event_id: int = Field(foreign_key='event.id')
    joined_at: datetime = Field(default_factory=datetime.utcnow)
Create password hashing and JWT utilities

Authentication needs two capabilities. First, you need to hash passwords so they are never stored in plain text. Second, you need to generate and verify JWT tokens that prove a user's identity on every request.

  • Create the auth utilities file at backend/app/auth.py with the following code:
from passlib.context import CryptContext
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import Optional

# Secret key for signing tokens - change in production
SECRET_KEY = 'changeme'
ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24

# Bcrypt context for password hashing
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
    to_encode.update({'exp': expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

def decode_token(token: str):
    return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

What does this code do?

  • `hash_password` uses bcrypt to produce a one-way hash. Even if the database is compromised, passwords stay safe.
  • `verify_password` checks a plain-text password against a stored hash without ever decrypting.
  • `create_access_token` encodes user data (id, role) into a signed JWT that expires after 24 hours.
  • `decode_token` verifies the signature and extracts the payload. If the token is expired or tampered with, it raises a JWTError.
  • Save backend/app/auth.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before continuing.

ⓧ I'd like to double check the full code

from passlib.context import CryptContext
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import Optional

SECRET_KEY = 'changeme'
ALGORITHM = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24

pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
    to_encode.update({'exp': expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

def decode_token(token: str):
    return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
Implement the auth endpoints and route protection

With the models and utilities in place, you can now build the API endpoints that let users register and log in. You'll also create a dependency that protects routes by verifying the JWT token on every request.

  • Create the route protection dependencies at backend/app/deps.py:
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.auth import decode_token
from jose import JWTError

# Extracts Bearer token from the Authorization header
security = HTTPBearer()

def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
    """Verify JWT token and return the payload."""
    try:
        payload = decode_token(credentials.credentials)
        return payload
    except JWTError:
        raise HTTPException(status_code=401, detail='Invalid token')

def require_host(user: dict = Depends(get_current_user)):
    """Only allow users with the host role."""
    if user.get('role') != 'host':
        raise HTTPException(status_code=403, detail='Host access required')
    return user

What does this code do?

  • `get_current_user` is a FastAPI dependency. Any route that includes it in its parameters will require a valid JWT token in the request headers.
  • `require_host` chains on top of get_current_user and adds a role check. Routes using this dependency reject attendees with a 403 error.
  • Save backend/app/deps.py.
  • Create the routers directory and its __init__.py by running:
mkdir -p backend/app/routers
touch backend/app/routers/__init__.py
  • Create the auth router at backend/app/routers/auth.py with the registration and login endpoints:
# POST /auth/register and POST /auth/login endpoints
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from pydantic import BaseModel
from app.models import User, UserRole
from app.auth import hash_password, verify_password, create_access_token
from app.database import get_session

router = APIRouter()

class RegisterRequest(BaseModel):
    email: str
    password: str
    role: UserRole

class LoginRequest(BaseModel):
    email: str
    password: str

@router.post('/register')
def register(req: RegisterRequest, session: Session = Depends(get_session)):
    # Check if email already exists
    existing = session.exec(select(User).where(User.email == req.email)).first()
    if existing:
        raise HTTPException(status_code=400, detail='Email already registered')
    # Create new user with hashed password
    user = User(
        email=req.email,
        hashed_password=hash_password(req.password),
        role=req.role
    )
    session.add(user)
    session.commit()
    session.refresh(user)
    # Return access token
    token = create_access_token({'sub': str(user.id), 'role': user.role})
    return {'access_token': token, 'token_type': 'bearer'}

@router.post('/login')
def login(req: LoginRequest, session: Session = Depends(get_session)):
    # Find user by email
    user = session.exec(select(User).where(User.email == req.email)).first()
    if not user or not verify_password(req.password, user.hashed_password):
        raise HTTPException(status_code=401, detail='Invalid credentials')
    # Return access token
    token = create_access_token({'sub': str(user.id), 'role': user.role})
    return {'access_token': token, 'token_type': 'bearer'}

What do these endpoints do?

  • `POST /auth/register` checks for duplicate emails, hashes the password, saves the user, and returns a JWT token so the user is immediately logged in.
  • `POST /auth/login` looks up the user by email, verifies the password hash, and returns a fresh JWT token.
  • Both endpoints encode the user's id and role into the token payload. The role is used by the require_host dependency to gate host-only routes.
  • Save backend/app/routers/auth.py.
  • Create the FastAPI application entry point at backend/app/main.py:
from fastapi import FastAPI
from app.routers import auth

app = FastAPI(title='Event Photo Discovery API')
app.include_router(auth.router, prefix='/auth')

What does this code do?

This is the entry point for your FastAPI application. It registers the auth router under the /auth prefix, so your endpoints become POST /auth/register and POST /auth/login.

  • Save backend/app/main.py.
  • Test the API by starting the development server:
cd backend
uvicorn app.main:app --reload --port 8000
  • Open your browser and navigate to http://localhost:8000/docs.

You should see the FastAPI interactive docs page showing your /auth/register and /auth/login endpoints.

Seeing an import error?

Make sure you created the backend/app/routers/__init__.py file. Without it, Python can't import the routers package. Also verify your virtual environment is activated.

Help me fix my import error

✔️ Awesome, I've got everything!

Great. Make sure all files are saved and your server is running without errors.

ⓧ I'd like to double check the full code

from fastapi import FastAPI
from app.routers import auth

app = FastAPI(title='Event Photo Discovery API')
app.include_router(auth.router, prefix='/auth')

Your database schema and auth system are locked in. You have structured storage for every entity the platform needs, and a JWT-based gate that separates hosts from attendees. Next up, you'll build the event management endpoints and wire up S3 for photo uploads.

Implement Event Management and S3 Photo Upload

In the previous step, you built the authentication system that lets users register and log in. Now you have a working identity layer.

The core of any event photo platform is the events themselves. Hosts need to create events, upload photos to those events, and share invite links with attendees. In this step, you will build the event management API, a bulk photo upload endpoint, and a QR code generator that produces scannable invite links.

In this step, get ready to:
  • Build event CRUD endpoints with host-only authorization.
  • Implement bulk photo upload that saves files to local storage and tracks metadata.
  • Add QR code generation for event invite links.
Build event CRUD endpoints with host-only authorization

Before hosts can upload photos, they need to create events. You will build four endpoints: create, list, get, and update. Only users with the host role should be allowed to create or modify events.

The pattern for enforcing this is a FastAPI dependency that extracts the user from the JWT token and checks their role before the request handler runs.

  • Create a new file at backend/app/routers/events.py by adding the following imports, router setup, and authentication dependencies:
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Header
from fastapi.responses import StreamingResponse
from sqlmodel import Session, select
from app.models import Event, Photo, User, UserRole
from app.auth import decode_token
from app.database import get_session
from typing import Optional
import os
import uuid
import qrcode
from io import BytesIO

router = APIRouter()

# Dependency: extract the current user from the Authorization header
def get_current_user(
    authorization: str = Header(...),
    session: Session = Depends(get_session)
):
    token = authorization.replace('Bearer ', '')
    payload = decode_token(token)
    user = session.get(User, int(payload['sub']))
    if not user:
        raise HTTPException(status_code=401, detail='User not found')
    return user

# Dependency: ensure the authenticated user has the host role
def require_host(user: User = Depends(get_current_user)):
    if user.role != UserRole.host:
        raise HTTPException(status_code=403, detail='Host access required')
    return user

What does this code do?

The get_current_user dependency reads the Authorization header, strips the Bearer prefix, decodes the JWT, and looks up the user in the database. If the token is invalid or the user does not exist, it raises a 401 error.

The require_host dependency chains on top of get_current_user. It checks the user's role and raises a 403 error if they are not a host. Any endpoint that depends on require_host is automatically protected.

  • Save events.py.
  • You won't see a change yet. The next chunk adds the CRUD endpoints that use these dependencies.
  • Below the require_host function in events.py, add the four CRUD endpoints:
# Create a new event (host only)
@router.post('/')
def create_event(
    name: str,
    description: Optional[str] = None,
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    event = Event(host_id=user.id, name=name, description=description)
    session.add(event)
    session.commit()
    session.refresh(event)
    return event

# List all events owned by the current host
@router.get('/')
def list_events(
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    events = session.exec(select(Event).where(Event.host_id == user.id)).all()
    return events

# Get a single event by ID
@router.get('/{event_id}')
def get_event(
    event_id: int,
    user: User = Depends(get_current_user),
    session: Session = Depends(get_session)
):
    event = session.get(Event, event_id)
    if not event:
        raise HTTPException(status_code=404, detail='Event not found')
    return event

# Update an event (host only, must own the event)
@router.put('/{event_id}')
def update_event(
    event_id: int,
    name: Optional[str] = None,
    description: Optional[str] = None,
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    event = session.get(Event, event_id)
    if not event or event.host_id != user.id:
        raise HTTPException(status_code=404, detail='Event not found')
    if name:
        event.name = name
    if description:
        event.description = description
    session.commit()
    session.refresh(event)
    return event

What does this code do?

These four endpoints cover standard CRUD operations for events. POST / creates a new event tied to the authenticated host. GET / lists all events the host owns.

The GET /{event_id} endpoint uses get_current_user instead of require_host because attendees also need to view event details. The PUT endpoint checks that the requesting host actually owns the event before allowing changes.

  • Save events.py.
  • You won't see a change yet. The router is not registered in the app until the final substep.

Seeing an import error for app.database?

The get_session dependency comes from the database module you set up in the previous step. Make sure backend/app/database.py exports a get_session generator that yields a SQLModel Session.

help me debug my database session import

Implement bulk photo upload to local storage

Event hosts will upload multiple photos at once. Each photo needs a unique filename, a record in the database with status pending (so the face processing pipeline knows what to pick up), and storage on disk.

In production on AWS, this storage target becomes S3. For local development, you will write to an uploads/ directory. The s3_key field stores the relative path regardless of which backend is used.

  • Below the update_event function in events.py, add the upload endpoint:
# Directory for local photo storage (S3 in production)
UPLOAD_DIR = 'uploads'

# Bulk upload photos for an event
@router.post('/{event_id}/photos')
async def upload_photos(
    event_id: int,
    files: list[UploadFile] = File(...),
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    # Verify the event exists and belongs to this host
    event = session.get(Event, event_id)
    if not event or event.host_id != user.id:
        raise HTTPException(status_code=404, detail='Event not found')

    # Create the upload directory for this event
    os.makedirs(f'{UPLOAD_DIR}/{event_id}', exist_ok=True)
    photos = []

    for file in files:
        # Generate a unique filename preserving the original extension
        ext = os.path.splitext(file.filename)[1]
        s3_key = f'{event_id}/{uuid.uuid4()}{ext}'
        file_path = os.path.join(UPLOAD_DIR, s3_key)

        # Write file contents to local storage
        content = await file.read()
        with open(file_path, 'wb') as f:
            f.write(content)

        # Create a Photo record with pending status
        photo = Photo(event_id=event_id, s3_key=s3_key, status='pending')
        session.add(photo)
        photos.append(photo)

    session.commit()
    for p in photos:
        session.refresh(p)

    return {'uploaded': len(photos), 'photos': photos}

What does this code do?

The endpoint accepts multiple files via list[UploadFile]. FastAPI handles the multipart form parsing automatically because python-multipart is installed.

For each file, it generates a UUID-based filename to avoid collisions, writes the bytes to disk, and creates a Photo record. The status='pending' flag tells the Celery worker (built in the next step) that this photo needs face detection processing.

  • Save events.py.
  • You won't see a change yet. The router still needs to be registered and the QR endpoint added.

Getting a 422 error when testing the upload?

Make sure python-multipart is in your requirements.txt and installed. FastAPI requires it to parse file uploads sent as multipart/form-data.

help me fix my file upload 422 error

Add QR code generation and register the events router

Attendees join events by scanning a QR code or clicking an invite link. You will generate QR codes dynamically using the qrcode library. The QR code encodes the event's join URL, and the endpoint returns the image directly as a PNG.

  • Below the upload_photos function in events.py, add the QR code generation endpoint:
# Generate a QR code image for the event invite link
@router.get('/{event_id}/qr')
def generate_qr(
    event_id: int,
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    event = session.get(Event, event_id)
    if not event or event.host_id != user.id:
        raise HTTPException(status_code=404, detail='Event not found')

    # Build the invite URL and generate the QR code
    invite_url = f'http://localhost:3000/join/{event_id}'
    img = qrcode.make(invite_url)

    # Save QR image to an in-memory buffer
    buffer = BytesIO()
    img.save(buffer, format='PNG')
    buffer.seek(0)

    return StreamingResponse(buffer, media_type='image/png')

What does this code do?

The qrcode.make() shortcut generates a QR code image from the invite URL string. Since Pillow is installed (via the qrcode[pil] extra in requirements.txt), it produces a PIL image.

Instead of saving to disk, you write the PNG to a BytesIO buffer and return it as a streaming response. The browser or client receives a downloadable PNG image.

  • Save events.py.

The final piece is registering the events router with the FastAPI app so the endpoints are reachable.

  • In backend/app/main.py, find this line:
from app.routers import auth
  • Replace it with the following to also import the events router:
from app.routers import auth, events
  • Below the line that includes the auth router, add this line to register the events router:
app.include_router(events.router, prefix='/events')
  • Save main.py.
  • Start the backend server and navigate to http://localhost:8000/docs in your browser. You should see the new event endpoints listed under the /events prefix in the interactive API docs.

Don't see the /events endpoints in the docs?

Make sure main.py imports events from app.routers and calls app.include_router(events.router, prefix='/events'). If the server is already running, restart it to pick up the new router.

help me debug my events router registration

✔️ Awesome, I've got everything!

Great. Double check you have saved both events.py and main.py.

ⓧ I'd like to double check the full code

Here is the complete backend/app/routers/events.py file:

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Header
from fastapi.responses import StreamingResponse
from sqlmodel import Session, select
from app.models import Event, Photo, User, UserRole
from app.auth import decode_token
from app.database import get_session
from typing import Optional
import os
import uuid
import qrcode
from io import BytesIO

router = APIRouter()

# Dependency: extract the current user from the Authorization header
def get_current_user(
    authorization: str = Header(...),
    session: Session = Depends(get_session)
):
    token = authorization.replace('Bearer ', '')
    payload = decode_token(token)
    user = session.get(User, int(payload['sub']))
    if not user:
        raise HTTPException(status_code=401, detail='User not found')
    return user

# Dependency: ensure the authenticated user has the host role
def require_host(user: User = Depends(get_current_user)):
    if user.role != UserRole.host:
        raise HTTPException(status_code=403, detail='Host access required')
    return user

# Create a new event (host only)
@router.post('/')
def create_event(
    name: str,
    description: Optional[str] = None,
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    event = Event(host_id=user.id, name=name, description=description)
    session.add(event)
    session.commit()
    session.refresh(event)
    return event

# List all events owned by the current host
@router.get('/')
def list_events(
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    events = session.exec(select(Event).where(Event.host_id == user.id)).all()
    return events

# Get a single event by ID
@router.get('/{event_id}')
def get_event(
    event_id: int,
    user: User = Depends(get_current_user),
    session: Session = Depends(get_session)
):
    event = session.get(Event, event_id)
    if not event:
        raise HTTPException(status_code=404, detail='Event not found')
    return event

# Update an event (host only, must own the event)
@router.put('/{event_id}')
def update_event(
    event_id: int,
    name: Optional[str] = None,
    description: Optional[str] = None,
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    event = session.get(Event, event_id)
    if not event or event.host_id != user.id:
        raise HTTPException(status_code=404, detail='Event not found')
    if name:
        event.name = name
    if description:
        event.description = description
    session.commit()
    session.refresh(event)
    return event

# Directory for local photo storage (S3 in production)
UPLOAD_DIR = 'uploads'

# Bulk upload photos for an event
@router.post('/{event_id}/photos')
async def upload_photos(
    event_id: int,
    files: list[UploadFile] = File(...),
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    # Verify the event exists and belongs to this host
    event = session.get(Event, event_id)
    if not event or event.host_id != user.id:
        raise HTTPException(status_code=404, detail='Event not found')

    # Create the upload directory for this event
    os.makedirs(f'{UPLOAD_DIR}/{event_id}', exist_ok=True)
    photos = []

    for file in files:
        # Generate a unique filename preserving the original extension
        ext = os.path.splitext(file.filename)[1]
        s3_key = f'{event_id}/{uuid.uuid4()}{ext}'
        file_path = os.path.join(UPLOAD_DIR, s3_key)

        # Write file contents to local storage
        content = await file.read()
        with open(file_path, 'wb') as f:
            f.write(content)

        # Create a Photo record with pending status
        photo = Photo(event_id=event_id, s3_key=s3_key, status='pending')
        session.add(photo)
        photos.append(photo)

    session.commit()
    for p in photos:
        session.refresh(p)

    return {'uploaded': len(photos), 'photos': photos}

# Generate a QR code image for the event invite link
@router.get('/{event_id}/qr')
def generate_qr(
    event_id: int,
    user: User = Depends(require_host),
    session: Session = Depends(get_session)
):
    event = session.get(Event, event_id)
    if not event or event.host_id != user.id:
        raise HTTPException(status_code=404, detail='Event not found')

    # Build the invite URL and generate the QR code
    invite_url = f'http://localhost:3000/join/{event_id}'
    img = qrcode.make(invite_url)

    # Save QR image to an in-memory buffer
    buffer = BytesIO()
    img.save(buffer, format='PNG')
    buffer.seek(0)

    return StreamingResponse(buffer, media_type='image/png')

And the updated backend/app/main.py:

from fastapi import FastAPI
from app.routers import auth, events

app = FastAPI(title='Event Photo Discovery API')
app.include_router(auth.router, prefix='/auth')
app.include_router(events.router, prefix='/events')

Your event management API is live. Hosts can create events, upload batches of photos, and generate QR codes for invite links. Next up, you will build the face processing pipeline that picks up those pending photos and runs them through InsightFace for face detection and embedding generation.

Build the Face Processing Pipeline

Your backend can accept photo uploads and store metadata in PostgreSQL. But uploaded photos are just sitting there with a pending status. Nothing is actually analyzing them for faces yet.

In this step, you'll build the asynchronous face processing pipeline. Celery workers will pick up photos from a queue, run them through InsightFace for face detection and embedding generation, then store those embeddings in Qdrant for lightning-fast similarity search later.

In this step, get ready to:
  • Configure Celery with Redis as the message broker.
  • Implement the photo processing task with face detection, embeddings, quality scoring, and duplicate checking.
  • Add real-time processing status endpoints for monitoring progress.
Configure Celery with Redis as the broker

Processing photos with InsightFace is CPU-intensive work that takes several seconds per image. Running that inside your API process would block all other requests.

Why Celery instead of BackgroundTasks?

FastAPI's built-in BackgroundTasks runs in the same process as your API. For CPU-heavy work like ML inference, this blocks the event loop and makes the API unresponsive.

Celery runs tasks in separate worker processes, keeping your API fast while photos are analyzed in the background. Redis acts as the message broker, passing task requests from the API to the workers.

  • Create the Celery configuration file by creating backend/app/tasks.py and adding the following imports and app setup:
from celery import Celery
import insightface
import imagehash
from PIL import Image
import numpy as np
import cv2

# Create the Celery app with Redis as message broker
celery_app = Celery('tasks', broker='redis://localhost:6379/0')

What does this code do?

  • Celery('tasks', broker='redis://localhost:6379/0') creates a Celery application instance that uses your local Redis (already running from Docker Compose) as its message broker.
  • The imports bring in everything the processing pipeline needs: InsightFace for face detection, imagehash for duplicate checking, PIL and OpenCV for image manipulation, and NumPy for array operations.
  • Save backend/app/tasks.py.

You won't see a visible change yet. The next chunk adds the task that this Celery app will execute.

Implement the photo processing task

Each photo that gets uploaded needs to go through a multi-step pipeline: detect faces, generate vector embeddings, assess image quality, check for duplicates, and store everything for later search.

  • In backend/app/tasks.py, add the processing task below the celery_app line:
@celery_app.task
def process_photo(photo_id: int):
    # 1. Load photo from storage
    # 2. Detect faces with InsightFace buffalo_l
    # 3. Generate 512-dim embeddings
    # 4. Compute quality score (Laplacian variance)
    # 5. Check duplicate via perceptual hash
    # 6. Upsert embeddings into Qdrant per-event collection
    # 7. Create FaceRecord rows
    # 8. Update Photo status to 'done' or 'failed'
    pass

What does each pipeline step do?

  • Step 1 loads the image file from local storage (or S3 in production) using the photo_id to look up the file path in PostgreSQL.
  • Steps 2-3 run InsightFace's buffalo_l model. It detects every face in the image and generates a 512-dimensional embedding vector for each one.
  • Step 4 checks if the photo is blurry using the Laplacian variance method from OpenCV. Blurry faces produce unreliable embeddings.
  • Step 5 generates a perceptual hash of the image using imagehash to detect duplicate uploads before wasting compute on them.
  • Steps 6-7 store each face embedding in Qdrant (for vector similarity search) and create a FaceRecord row in PostgreSQL (for relational queries).
  • Step 8 updates the photo's status from processing to done (or failed if something went wrong).
  • Save backend/app/tasks.py.

To verify Celery can discover your task, run the worker from the backend/ directory:

celery -A app.tasks worker --loglevel=info

You should see Celery start up and list app.tasks.process_photo in the registered tasks output.

Celery not finding the task?

  • Make sure you run the command from inside the backend/ directory, not the project root.
  • Confirm Redis is running by checking your Docker containers with docker ps. You should see the redis:7-alpine container listed.
  • Check that your virtual environment is activated and celery is installed in it.

Help me troubleshoot why Celery can't find my process_photo task.

Add real-time processing status endpoints

Hosts need to see how their photo processing is going. You'll add two endpoints: one for aggregate event progress and one for individual photo status.

  • Create backend/app/routers/photos.py with the per-photo status endpoint:
# GET /photos/{id}/status

What does this endpoint return?

This endpoint returns the current processing status of a single photo (pending, processing, done, or failed). The frontend polls this endpoint to update progress indicators in real time.

  • In backend/app/routers/events.py, add the aggregate processing status endpoint alongside your existing routes:
# GET /events/{id}/processing-status

What does the aggregate endpoint return?

This endpoint counts all photos in the event grouped by status. It returns the total count, how many are done, how many are still processing, and how many failed. The host dashboard uses this to show an overall progress bar.

  • Register the new photos router in backend/app/main.py. Find the existing router imports at the top of the file:
from app.routers import auth, events
  • Update it to also import photos, so the line now looks like this:
from app.routers import auth, events, photos
  • Then find the include_router calls and add the photos router below the events line:
app.include_router(photos.router, prefix='/photos')
  • Save backend/app/main.py.
  • Verify your API still starts by running uvicorn app.main:app --reload from the backend/ directory. You should see no import errors in the terminal output.

Seeing a ModuleNotFoundError?

  • Confirm that backend/app/routers/photos.py exists and is saved. Python can't import a module that doesn't exist on disk.
  • Check that the file has at least one valid Python statement (even a comment counts). An empty file without any content can sometimes cause import issues depending on your Python version.

Help me fix a ModuleNotFoundError when importing the photos router.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files.

ⓧ I'd like to double check the full code

Here is backend/app/tasks.py in full:

from celery import Celery
import insightface
import imagehash
from PIL import Image
import numpy as np
import cv2

celery_app = Celery('tasks', broker='redis://localhost:6379/0')

@celery_app.task
def process_photo(photo_id: int):
    # 1. Load photo from storage
    # 2. Detect faces with InsightFace buffalo_l
    # 3. Generate 512-dim embeddings
    # 4. Compute quality score (Laplacian variance)
    # 5. Check duplicate via perceptual hash
    # 6. Upsert embeddings into Qdrant per-event collection
    # 7. Create FaceRecord rows
    # 8. Update Photo status to 'done' or 'failed'
    pass

Here is backend/app/main.py in full:

from fastapi import FastAPI
from app.routers import auth, events, photos

app = FastAPI(title='Event Photo Discovery API')
app.include_router(auth.router, prefix='/auth')
app.include_router(events.router, prefix='/events')
app.include_router(photos.router, prefix='/photos')

Your face processing pipeline is wired up. Celery workers can now pick up photo processing tasks asynchronously, and your status endpoints let the frontend track progress in real time. Next up, you'll implement selfie matching so attendees can find their photos instantly.

Implement Selfie Matching and Gallery API

Your face processing pipeline is now detecting faces, generating embeddings, and storing them in Qdrant. The next challenge is giving attendees a way to find themselves in those photos.

In this step, you'll build the attendee-facing API. An attendee submits a selfie, and your backend runs it through the same InsightFace pipeline to generate a query embedding. That embedding searches Qdrant using cosine similarity and returns every photo containing the attendee's face. You'll also add gallery browsing, batch downloads, and a privacy endpoint for GDPR-style biometric data deletion.

In this step, get ready to:
  • Build the selfie matching endpoint that searches Qdrant with cosine similarity.
  • Add gallery endpoints with filtering, sorting, pagination, and batch ZIP download.
  • Implement an attendee privacy endpoint to delete all biometric data.
Build the selfie matching endpoint

When an attendee submits a selfie, the backend needs to detect the face, generate a 512-dimensional embedding, and search the event's Qdrant collection for similar vectors. Matches above the cosine similarity threshold come back as photo results.

  • In backend/app/routers/events.py, add the selfie matching endpoint by adding the following code after your existing endpoints:
# --- Selfie Matching ---

@router.post('/{event_id}/match-selfie')
def match_selfie(event_id: int, file: UploadFile = File(...), current_user: User = Depends(get_current_user)):
    # Read the uploaded selfie image
    contents = file.file.read()
    np_arr = np.frombuffer(contents, np.uint8)
    img = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)

    if img is None:
        raise HTTPException(status_code=400, detail='Invalid image file')

    # Detect face and generate embedding using InsightFace
    face_app = FaceAnalysis(providers=['CPUExecutionProvider'])
    face_app.prepare(ctx_id=0, det_size=(640, 640))
    faces = face_app.get(img)

    if not faces:
        raise HTTPException(status_code=400, detail='No face detected in selfie')

    # Use the highest-confidence face
    face = max(faces, key=lambda f: f['det_score'])
    query_embedding = face['embedding'].tolist()

    # Search Qdrant for matching face embeddings
    collection_name = f'event_{event_id}_faces'
    client = QdrantClient(host='localhost', port=6333)

    results = client.search(
        collection_name=collection_name,
        query_vector=query_embedding,
        score_threshold=0.4,
        limit=100,
        with_payload=True
    )

    # Deduplicate by photo_id and collect confidence scores
    seen_photos = {}
    for result in results:
        photo_id = result.payload['photo_id']
        if photo_id not in seen_photos or result.score > seen_photos[photo_id]:
            seen_photos[photo_id] = result.score

    matches = [
        {'photo_id': pid, 'confidence': score}
        for pid, score in seen_photos.items()
    ]

    return {'matches': matches, 'total': len(matches)}

What does this code do?

The endpoint reads the uploaded image, decodes it with OpenCV, and passes it to InsightFace for face detection. It takes the highest-confidence face and extracts the 512-dimensional embedding vector.

That vector is then searched against the event's Qdrant collection using score_threshold=0.4 to filter out weak matches. Cosine similarity scores above 0.4 indicate a likely match. Results are deduplicated by photo_id so each photo appears only once, with the highest confidence score.

  • Make sure the following imports are at the top of backend/app/routers/events.py:
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
from fastapi.responses import StreamingResponse
from qdrant_client import QdrantClient
from insightface.app import FaceAnalysis
from app.models import User, Photo, FaceRecord, Event
from sqlmodel import Session, select
import numpy as np
import cv2
import io
import zipfile

Why 0.4 as the similarity threshold?

Cosine similarity ranges from -1 to 1. For face embeddings generated by InsightFace's buffalo_l model, a threshold of 0.4 balances precision and recall. Lower values return more false positives. Higher values might miss valid matches where lighting or angle differs between the selfie and event photos.

  • Save backend/app/routers/events.py.
  • You won't see a visible change yet. The next substep adds the gallery endpoints that let you browse these matches.

Getting an error about the collection not existing?

The selfie matching endpoint expects a Qdrant collection named event_{event_id}_faces. This collection is created by the process_photo Celery task when photos are processed. Make sure you have uploaded and processed at least one photo for the event before testing selfie matching.

help me debug why my Qdrant collection is not found when matching a selfie

Add gallery and download endpoints

Attendees need to browse their matched photos and download them in bulk. The gallery endpoint supports filtering by confidence score, sorting, and pagination. The download endpoint packages matched photos into a ZIP file.

  • In backend/app/routers/events.py, add the gallery endpoint below the selfie matching code:
# --- Gallery Endpoints ---

@router.get('/{event_id}/gallery')
def get_gallery(
    event_id: int,
    min_confidence: float = 0.0,
    sort_by: str = 'date',
    page: int = 1,
    per_page: int = 20,
    current_user: User = Depends(get_current_user)
):
    # Query photos for this event from the database
    with Session(engine) as session:
        query = select(Photo).where(Photo.event_id == event_id, Photo.status == 'done')

        # Apply sorting
        if sort_by == 'date':
            query = query.order_by(Photo.created_at.desc())

        # Apply pagination
        offset = (page - 1) * per_page
        photos = session.exec(query.offset(offset).limit(per_page)).all()
        total = session.exec(select(Photo).where(Photo.event_id == event_id, Photo.status == 'done')).all()

    return {
        'photos': [{'id': p.id, 's3_key': p.s3_key, 'created_at': str(p.created_at)} for p in photos],
        'page': page,
        'per_page': per_page,
        'total': len(total)
    }

What does this code do?

The gallery endpoint queries the database for processed photos belonging to the event. It accepts query parameters for filtering (min_confidence), sorting (sort_by), and pagination (page and per_page).

The offset-based pagination calculates which slice of results to return. The response includes metadata so the frontend can render page controls.

  • Below the gallery endpoint, add the batch ZIP download endpoint:
@router.get('/{event_id}/gallery/download')
def download_gallery(event_id: int, current_user: User = Depends(get_current_user)):
    # Fetch all processed photos for this event
    with Session(engine) as session:
        photos = session.exec(
            select(Photo).where(Photo.event_id == event_id, Photo.status == 'done')
        ).all()

    if not photos:
        raise HTTPException(status_code=404, detail='No photos available for download')

    # Create an in-memory ZIP file
    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
        for photo in photos:
            # Read photo from storage (local or S3)
            file_path = f'uploads/{photo.s3_key}'
            try:
                with open(file_path, 'rb') as f:
                    zf.writestr(photo.s3_key, f.read())
            except FileNotFoundError:
                continue

    zip_buffer.seek(0)

    return StreamingResponse(
        zip_buffer,
        media_type='application/zip',
        headers={'Content-Disposition': f'attachment; filename=event_{event_id}_photos.zip'}
    )

What does this code do?

The download endpoint collects all processed photos for the event, reads each from local storage, and writes them into an in-memory ZIP archive. It returns the ZIP as a StreamingResponse with the correct content type and a Content-Disposition header that triggers a file download in the browser.

During deployment, the file reads will switch to S3 presigned URLs or boto3 downloads. For local development, this reads from the uploads/ directory.

  • Save backend/app/routers/events.py.
  • To verify, start your FastAPI server and visit http://localhost:8000/docs. You should see the new POST /events/{event_id}/match-selfie, GET /events/{event_id}/gallery, and GET /events/{event_id}/gallery/download endpoints listed in the interactive docs.

Endpoints not appearing in /docs?

Check that your router imports are correct at the top of the file and that you have no syntax errors. Run your server with uvicorn app.main:app --reload and look at the terminal output for any import errors.

help me debug why my new gallery endpoints are not showing up in FastAPI docs

Implement the attendee privacy endpoint

Privacy regulations require giving users the ability to delete their biometric data. This endpoint removes all face embeddings for the authenticated user from Qdrant and deletes the corresponding database records.

  • Create a new file backend/app/routers/users.py with the biometric data deletion endpoint:
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from qdrant_client import QdrantClient
from app.models import User, FaceRecord, EventMembership

router = APIRouter()

@router.delete('/me/biometric-data')
def delete_biometric_data(current_user: User = Depends(get_current_user)):
    # Find all events the user is a member of
    with Session(engine) as session:
        memberships = session.exec(
            select(EventMembership).where(EventMembership.user_id == current_user.id)
        ).all()

        event_ids = [m.event_id for m in memberships]

        # Find all FaceRecords linked to this user's interactions
        face_records = session.exec(
            select(FaceRecord).where(FaceRecord.event_id.in_(event_ids))
        ).all()

        # Delete embeddings from Qdrant
        client = QdrantClient(host='localhost', port=6333)
        for record in face_records:
            collection_name = f'event_{record.event_id}_faces'
            try:
                client.delete(
                    collection_name=collection_name,
                    points_selector=[record.embedding_id]
                )
            except Exception:
                continue

        # Delete FaceRecords from the database
        for record in face_records:
            session.delete(record)
        session.commit()

    return {'detail': 'All biometric data deleted successfully'}

What does this code do?

The privacy endpoint finds every event the user belongs to, then locates all face records associated with those events. For each record, it deletes the embedding from the corresponding Qdrant collection. Finally, it removes the FaceRecord rows from PostgreSQL.

This ensures no biometric data remains in your system for that user. It is a one-way destructive operation. The attendee would need to re-submit a selfie if they want to use the matching feature again.

  • Save backend/app/routers/users.py.

Now you need to register this new router with the main application.

  • In backend/app/main.py, find this line:
from app.routers import auth, events, photos
  • Replace it with this line that adds the users router:
from app.routers import auth, events, photos, users
  • Then find the last include_router line:
app.include_router(photos.router, prefix='/photos')
  • Add this line below it to register the users router:
app.include_router(users.router, prefix='/users')

Why a separate users router?

The /users/me/biometric-data endpoint operates on the authenticated user's own data, not on an event resource. Placing it in a dedicated users router keeps your API organized by resource type. Event-scoped operations stay in the events router, and user-scoped operations live in the users router.

  • Save backend/app/main.py.
  • Restart the FastAPI server and visit http://localhost:8000/docs. You should see the DELETE /users/me/biometric-data endpoint listed under the users section.

Import error when starting the server?

Make sure backend/app/routers/users.py exists and has no typos in the import path. The file must define router = APIRouter() at the module level for the include_router call in main.py to work.

help me fix the import error when registering my users router in FastAPI

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is what backend/app/main.py should look like at this point:

from fastapi import FastAPI
from app.routers import auth, events, photos, users

app = FastAPI(title='Event Photo Discovery API')
app.include_router(auth.router, prefix='/auth')
app.include_router(events.router, prefix='/events')
app.include_router(photos.router, prefix='/photos')
app.include_router(users.router, prefix='/users')

Your backend/app/routers/events.py should now include the selfie matching, gallery, and download endpoints alongside your existing event CRUD, photo upload, QR code, and processing status endpoints.

Your backend/app/routers/users.py should define the DELETE /me/biometric-data endpoint.

Your attendee API is complete. Attendees can now match their face against event photos, browse results with filtering and pagination, download everything as a ZIP, and delete their biometric data when they're done. Next up, you'll build the analytics layer that gives hosts insight into their events.

Add Face Clustering and Event Analytics

Your backend can now detect faces, generate embeddings, and match selfies to photos. The next piece of the puzzle is understanding who is in the event photos and how the event performed overall.

In this step, you will build a face clustering endpoint that groups embeddings by person, plus a comprehensive analytics endpoint that gives hosts a complete view of their event.

In this step, get ready to:
  • Implement a face clustering endpoint using incremental centroid comparison.
  • Build analytics endpoints for event-level statistics and attendee engagement.
  • Add duplicate detection reporting and photo quality scoring summaries.
Implement the face clustering endpoint

Face clustering answers a fundamental question for event hosts: how many unique people appear across all the event photos? The algorithm scrolls through every embedding stored in Qdrant for the event, then groups them by comparing each face vector against existing cluster centroids.

  • In backend/app/routers/events.py, add the following clustering endpoint below your existing gallery routes:
@router.get('/{event_id}/clusters')
async def get_event_clusters(event_id: int):
    """Group all face embeddings for an event by person using centroid comparison."""
    from qdrant_client import QdrantClient
    from qdrant_client.models import Filter, FieldCondition, MatchValue
    import numpy as np

    client = QdrantClient(url='http://localhost:6333')
    collection_name = f'event_{event_id}_faces'

    # Check if the collection exists before scrolling
    if not client.collection_exists(collection_name):
        return {'clusters': [], 'total_unique_people': 0}

    # Scroll through all embeddings for this event
    all_points = []
    offset = None
    while True:
        points, next_offset = client.scroll(
            collection_name=collection_name,
            limit=100,
            with_payload=True,
            with_vectors=True,
            offset=offset
        )
        all_points.extend(points)
        if next_offset is None:
            break
        offset = next_offset

    if not all_points:
        return {'clusters': [], 'total_unique_people': 0}

    # Incremental centroid comparison for clustering
    similarity_threshold = 0.6
    clusters = []  # Each cluster: {'centroid': np.array, 'embedding_ids': [], 'photo_ids': []}

    for point in all_points:
        vector = np.array(point.vector)
        embedding_id = point.id
        photo_id = point.payload.get('photo_id')

        # Compare against existing cluster centroids
        assigned = False
        for cluster in clusters:
            centroid = cluster['centroid']
            # Cosine similarity between the face vector and cluster centroid
            similarity = np.dot(vector, centroid) / (
                np.linalg.norm(vector) * np.linalg.norm(centroid)
            )
            if similarity >= similarity_threshold:
                # Add to this cluster and update centroid incrementally
                cluster['embedding_ids'].append(str(embedding_id))
                cluster['photo_ids'].append(photo_id)
                n = len(cluster['embedding_ids'])
                cluster['centroid'] = cluster['centroid'] + (vector - cluster['centroid']) / n
                assigned = True
                break

        if not assigned:
            # Create a new cluster for this face
            clusters.append({
                'centroid': vector,
                'embedding_ids': [str(embedding_id)],
                'photo_ids': [photo_id]
            })

    # Build response with representative embedding and photo count per cluster
    cluster_results = []
    for i, cluster in enumerate(clusters):
        unique_photos = list(set(cluster['photo_ids']))
        cluster_results.append({
            'cluster_id': i,
            'representative_embedding_id': cluster['embedding_ids'][0],
            'photo_count': len(unique_photos),
            'photo_ids': unique_photos
        })

    return {
        'clusters': cluster_results,
        'total_unique_people': len(clusters)
    }

What does this code do?

The clustering algorithm works in a single pass over all face embeddings stored in Qdrant for the event.

  • client.scroll() paginates through all stored embeddings, fetching 100 at a time until next_offset returns None.
  • For each face vector, the code computes cosine similarity against every existing cluster centroid. If the similarity exceeds 0.6, the face joins that cluster.
  • The centroid updates incrementally using a running average formula. This avoids recalculating the mean from scratch each time a new face is added.
  • If no cluster matches, a brand-new cluster is created with this face as its first member.
  • Save backend/app/routers/events.py.
  • You won't see a change yet because the endpoint needs processed photo data in Qdrant. The next substep builds the analytics endpoint that uses the cluster count.

Seeing an import error for numpy?

Confirm that numpy is listed in your requirements.txt and that you installed it in your virtual environment. Run pip install numpy if needed.

Help me debug a numpy import error in my clustering endpoint

Build the analytics endpoint

Hosts need a single endpoint that returns a comprehensive summary of their event. This includes how many photos were uploaded, how many faces were detected, how many unique people attended, current processing progress, and attendee engagement metrics.

  • In backend/app/routers/events.py, add the analytics endpoint below the clusters endpoint you just created:
@router.get('/{event_id}/analytics')
async def get_event_analytics(event_id: int, session: Session = Depends(get_session)):
    """Return comprehensive event analytics including clustering and quality data."""
    from sqlmodel import select, func
    from qdrant_client import QdrantClient
    import numpy as np

    # Total photos uploaded for this event
    total_photos = session.exec(
        select(func.count(Photo.id)).where(Photo.event_id == event_id)
    ).one()

    # Processing progress: count photos by status
    processed_count = session.exec(
        select(func.count(Photo.id)).where(
            Photo.event_id == event_id, Photo.status == 'processed'
        )
    ).one()
    processing_progress = (processed_count / total_photos * 100) if total_photos > 0 else 0

    # Total faces detected across all photos
    faces_detected = session.exec(
        select(func.count(FaceRecord.id)).where(FaceRecord.event_id == event_id)
    ).one()

    # Unique people estimate from clustering
    client = QdrantClient(url='http://localhost:6333')
    collection_name = f'event_{event_id}_faces'
    unique_people = 0
    if client.collection_exists(collection_name):
        # Reuse the clustering logic to count unique people
        clusters_response = await get_event_clusters(event_id)
        unique_people = clusters_response['total_unique_people']

    # Attendee engagement: count members for this event
    memberships = session.exec(
        select(EventMembership).where(EventMembership.event_id == event_id)
    ).all()
    attendee_engagement = {
        'total_attendees': len(memberships),
        'attendees_with_matches': 0
    }

    # Duplicate detection report
    face_records = session.exec(
        select(FaceRecord).where(FaceRecord.event_id == event_id)
    ).all()
    duplicate_count = 0
    seen_embeddings = set()
    for record in face_records:
        if record.embedding_id in seen_embeddings:
            duplicate_count += 1
        seen_embeddings.add(record.embedding_id)

    # Photo quality scoring summary
    quality_scores = [r.quality_score for r in face_records if r.quality_score is not None]
    quality_summary = {
        'average_quality': float(np.mean(quality_scores)) if quality_scores else 0.0,
        'min_quality': float(np.min(quality_scores)) if quality_scores else 0.0,
        'max_quality': float(np.max(quality_scores)) if quality_scores else 0.0,
        'low_quality_faces': len([q for q in quality_scores if q < 0.5])
    }

    return {
        'total_photos': total_photos,
        'faces_detected': faces_detected,
        'unique_people': unique_people,
        'processing_progress': round(processing_progress, 1),
        'attendee_engagement': attendee_engagement,
        'duplicate_detection': {
            'total_face_records': len(face_records),
            'duplicate_embeddings': duplicate_count
        },
        'quality_summary': quality_summary
    }

What does this code do?

This endpoint aggregates data from multiple sources into a single analytics response.

  • total_photos and processing_progress come from counting Photo records in PostgreSQL, comparing pending vs processed status.
  • unique_people reuses the clustering endpoint you just built to get the count of distinct face clusters.
  • attendee_engagement counts how many users joined the event via EventMembership records.
  • The duplicate detection section flags any repeated embedding_id values in FaceRecord rows.
  • quality_summary calculates average, min, max, and a count of low-quality faces from the quality_score field stored during photo processing.
  • Save backend/app/routers/events.py.

NameError for Photo, FaceRecord, or EventMembership?

Make sure the top of your events.py router imports these models. You should have a line like from app.models import Event, Photo, FaceRecord, EventMembership near the top of the file.

Help me fix my model imports

Test the clustering and analytics endpoints

With both endpoints in place, verify that the API responds correctly. Even without real photo data in Qdrant, the endpoints should return valid empty-state responses.

  • Make sure your Docker containers are running (Qdrant and Redis) from the docker-compose.dev.yml you set up earlier.
  • Start your FastAPI server by running:
uvicorn app.main:app --reload
  • Test the clusters endpoint by running:
curl http://localhost:8000/events/1/clusters
  • You should see a JSON response with clusters as an empty list and total_unique_people set to 0, since no photos have been processed yet.
  • Test the analytics endpoint by running:
curl http://localhost:8000/events/1/analytics
  • The response should include all analytics fields: total_photos, faces_detected, unique_people, processing_progress, attendee_engagement, duplicate_detection, and quality_summary.

What should I see?

For an event with no uploaded photos, the analytics response returns all counts at 0, progress at 0.0, and quality scores at 0.0. Once you upload and process real photos in a later test, these numbers populate with real data.

Getting a 404 or connection refused?

Verify that your FastAPI server is running on port 8000 and that your Qdrant container is accessible on port 6333. Run docker ps to confirm both containers are up.

Help me debug my endpoint connection issue

✔️ Awesome, I've got everything!

Great. Double check you have saved backend/app/routers/events.py with both the /clusters and /analytics endpoints.

ⓧ I'd like to double check the full code

Here is what your backend/app/routers/events.py endpoint listing should now include:

# POST /events, GET /events, GET /events/{id}, PUT /events/{id}
# POST /events/{id}/photos (bulk upload)
# GET /events/{id}/qr
# GET /events/{id}/processing-status
# POST /events/{id}/match-selfie
# GET /events/{id}/gallery
# GET /events/{id}/gallery/download
# GET /events/{id}/clusters
# GET /events/{id}/analytics

Your backend API is now complete. You have face clustering, comprehensive analytics, duplicate detection, and quality scoring all accessible through clean REST endpoints. Next up, you will build the Next.js frontend that brings all of this data to life for hosts and attendees.

Build the Next.js Frontend Foundation

Your entire backend is built and ready to serve data. Now you need a way for real users to interact with it.

In this step, you'll build the frontend foundation: a shared layout with navigation, an API client that talks to your FastAPI backend, authentication pages for registration and login, and a public landing page that shows off the platform's features.

In this step, get ready to:
  • Set up the App Router structure with a root layout and shared components.
  • Implement register and login pages with JWT token management.
  • Create a public landing page with hero section and feature highlights.
Set up the App Router structure and shared components

Every page in your frontend will need to talk to the backend API. A centralized API client handles authentication headers and error parsing in one place, so individual pages stay clean.

  • Create the folder structure for your shared utilities and components by running these commands from the frontend directory:
mkdir -p src/lib src/components
  • Create the API client file by adding src/lib/api.ts with the following code:
// Base URL for the FastAPI backend
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';

// Centralized fetch wrapper that attaches JWT and handles errors
export async function apiFetch(path: string, options: RequestInit = {}) {
  const url = `${API_BASE}${path}`;
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(options.headers as Record<string, string>),
  };

  // Retrieve token from browser cookies
  const token = getToken();
  if (token) {
    headers['Authorization'] = `Bearer ${token}`;
  }

  const res = await fetch(url, { ...options, headers });

  if (!res.ok) {
    const error = await res.json().catch(() => ({ detail: 'Request failed' }));
    throw new Error(error.detail || 'Request failed');
  }

  return res.json();
}

// Store the JWT token in a browser cookie
export function setToken(token: string) {
  document.cookie = `token=${token}; path=/; max-age=${60 * 60 * 24}`;
}

// Remove the JWT token (logout)
export function removeToken() {
  document.cookie = 'token=; path=/; max-age=0';
}

// Read the JWT token from cookies
export function getToken(): string | null {
  if (typeof document === 'undefined') return null;
  const match = document.cookie
    .split('; ')
    .find((row) => row.startsWith('token='));
  return match ? match.split('=')[1] : null;
}

What does this code do?

This file creates a single apiFetch function that every page uses to call the backend. It automatically attaches the Authorization header with the stored JWT token.

The setToken, removeToken, and getToken helpers manage the JWT in a browser cookie with a 24-hour expiry. This matches the ACCESS_TOKEN_EXPIRE_MINUTES you set in the backend's auth.py.

  • You won't see a change yet. The next chunk uses this client in the Navbar.
  • Create the shared Navbar component at src/components/Navbar.tsx:
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { getToken, removeToken } from '@/lib/api';
import { useEffect, useState } from 'react';

export default function Navbar() {
  const pathname = usePathname();
  const [loggedIn, setLoggedIn] = useState(false);

  // Re-check auth state on every route change
  useEffect(() => {
    setLoggedIn(!!getToken());
  }, [pathname]);

  const handleLogout = () => {
    removeToken();
    setLoggedIn(false);
    window.location.href = '/';
  };

  return (
    <nav className="bg-gray-900 border-b border-gray-800 px-6 py-4">
      <div className="max-w-7xl mx-auto flex items-center justify-between">
        <Link href="/" className="text-xl font-bold text-white">
          EventSnap
        </Link>
        <div className="flex items-center gap-4">
          {loggedIn ? (
            <>
              <Link href="/dashboard" className="text-gray-300 hover:text-white">
                Dashboard
              </Link>
              <button onClick={handleLogout} className="text-gray-300 hover:text-white">
                Logout
              </button>
            </>
          ) : (
            <>
              <Link href="/auth/login" className="text-gray-300 hover:text-white">
                Login
              </Link>
              <Link
                href="/auth/register"
                className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
              >
                Sign Up
              </Link>
            </>
          ) }
        </div>
      </div>
    </nav>
  );
}

What does this code do?

The Navbar is a client component ('use client') because it reads cookies and responds to user clicks. It checks for a stored token on every route change to toggle between logged-in and logged-out states.

When logged in, it shows a Dashboard link and Logout button. When logged out, it shows Login and Sign Up links.

  • Create the Sidebar component at src/components/Sidebar.tsx:
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';

// Reusable sidebar for dashboard pages
interface SidebarLink {
  href: string;
  label: string;
}

interface SidebarProps {
  links: SidebarLink[];
}

export default function Sidebar({ links }: SidebarProps) {
  const pathname = usePathname();

  return (
    <aside className="w-64 bg-gray-900 border-r border-gray-800 min-h-screen p-4">
      <ul className="space-y-2">
        {links.map((link) => (
          <li key={link.href}>
            <Link
              href={link.href}
              className={`block px-4 py-2 rounded-lg ${
                pathname === link.href
                  ? 'bg-blue-600 text-white'
                  : 'text-gray-300 hover:bg-gray-800'
              }`}
            >
              {link.label}
            </Link>
          </li>
        ))}
      </ul>
    </aside>
  );
}

What does this code do?

The Sidebar accepts an array of links and highlights the active one based on the current URL path. You'll use this in the host and attendee dashboards in the next steps.

  • Create the Card component at src/components/Card.tsx:
// Shared card wrapper for dashboard widgets
interface CardProps {
  title: string;
  children: React.ReactNode;
  className?: string;
}

export default function Card({ title, children, className = '' }: CardProps) {
  return (
    <div className={`bg-gray-800 border border-gray-700 rounded-xl p-6 ${className}`}>
      <h3 className="text-lg font-semibold text-white mb-4">{title}</h3>
      {children}
    </div>
  );
}

What does this code do?

A simple presentational component that wraps content in a styled container with a title. You'll use it for analytics widgets, upload status panels, and other dashboard sections.

  • Now tie everything together by replacing the contents of src/app/layout.tsx with the root layout:
import type { Metadata } from 'next';
import './globals.css';
import Navbar from '@/components/Navbar';

// App-wide metadata for SEO
export const metadata: Metadata = {
  title: 'EventSnap - AI Photo Discovery',
  description: 'Find every event photo you appear in with AI-powered facial recognition.',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="bg-gray-950 text-white min-h-screen">
        <Navbar />
        <main>{children}</main>
      </body>
    </html>
  );
}

What does this code do?

The root layout is required by the Next.js App Router and must include <html> and <body> tags. It imports global CSS, renders the Navbar on every page, and wraps page content in a <main> element.

  • Save all five files.
  • Return to your terminal inside the frontend folder and start the dev server by running:
npm run dev
  • Open http://localhost:3000 in your browser. You should see a dark page with the EventSnap navbar at the top, showing Login and Sign Up links on the right.

Don't see the navbar?

  • Check the terminal for compile errors. A missing import or typo in layout.tsx will prevent the page from rendering.
  • Make sure your globals.css file exists at src/app/globals.css with Tailwind directives. The project scaffold should have created this already.
  • Verify that the @/ path alias resolves to src/ in your tsconfig.json.

Help me debug my Next.js layout not rendering

Implement auth pages with form validation and JWT cookies

Users need to create accounts and log in before they can host events or find their photos. These pages submit credentials to your FastAPI backend and store the returned JWT token in a cookie.

  • Create the auth route folders by running:
mkdir -p src/app/auth/register src/app/auth/login
  • Create the registration page at src/app/auth/register/page.tsx:
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { apiFetch, setToken } from '@/lib/api';

export default function RegisterPage() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [role, setRole] = useState<'host' | 'attendee'>('attendee');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      // POST to the backend registration endpoint
      const data = await apiFetch('/auth/register', {
        method: 'POST',
        body: JSON.stringify({ email, password, role }),
      });
      // Store the JWT and redirect to dashboard
      setToken(data.access_token);
      router.push('/dashboard');
    } catch (err: any) {
      setError(err.message || 'Registration failed');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center px-4">
      <div className="w-full max-w-md bg-gray-900 rounded-xl p-8 border border-gray-800">
        <h1 className="text-2xl font-bold text-white mb-6">Create Account</h1>
        {error && <p className="text-red-400 text-sm mb-4">{error}</p>}
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm text-gray-300 mb-1">Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            />
          </div>
          <div>
            <label className="block text-sm text-gray-300 mb-1">Password</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              minLength={6}
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            />
          </div>
          <div>
            <label className="block text-sm text-gray-300 mb-1">Role</label>
            <select
              value={role}
              onChange={(e) => setRole(e.target.value as 'host' | 'attendee')}
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            >
              <option value="attendee">Attendee</option>
              <option value="host">Host</option>
            </select>
          </div>
          <button
            type="submit"
            disabled={loading}
            className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? 'Creating...' : 'Create Account'}
          </button>
        </form>
        <p className="text-gray-400 text-sm mt-4 text-center">
          Already have an account?{' '}
          <Link href="/auth/login" className="text-blue-400 hover:underline">
            Login
          </Link>
        </p>
      </div>
    </div>
  );
}

What does this code do?

This page renders a form with email, password, and role fields. On submit, it calls POST /auth/register on your backend via apiFetch.

If registration succeeds, the returned access_token is saved to a cookie with setToken and the user is redirected to /dashboard. If it fails, the error message from the backend is displayed inline.

  • Save src/app/auth/register/page.tsx.
  • Navigate to http://localhost:3000/auth/register in your browser. You should see a centered registration form with Email, Password, and Role fields.

Seeing a 404 page?

Make sure the file is located exactly at src/app/auth/register/page.tsx. Next.js App Router maps folder paths directly to URL paths. A misnamed folder or missing page.tsx file produces a 404.

Help me fix a 404 on my Next.js auth register page

  • Create the login page at src/app/auth/login/page.tsx:
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { apiFetch, setToken } from '@/lib/api';

export default function LoginPage() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      // POST to the backend login endpoint
      const data = await apiFetch('/auth/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
      });
      // Store the JWT and redirect to dashboard
      setToken(data.access_token);
      router.push('/dashboard');
    } catch (err: any) {
      setError(err.message || 'Login failed');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center px-4">
      <div className="w-full max-w-md bg-gray-900 rounded-xl p-8 border border-gray-800">
        <h1 className="text-2xl font-bold text-white mb-6">Welcome Back</h1>
        {error && <p className="text-red-400 text-sm mb-4">{error}</p>}
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm text-gray-300 mb-1">Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            />
          </div>
          <div>
            <label className="block text-sm text-gray-300 mb-1">Password</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            />
          </div>
          <button
            type="submit"
            disabled={loading}
            className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? 'Logging in...' : 'Login'}
          </button>
        </form>
        <p className="text-gray-400 text-sm mt-4 text-center">
          Don't have an account?{' '}
          <Link href="/auth/register" className="text-blue-400 hover:underline">
            Sign up
          </Link>
        </p>
      </div>
    </div>
  );
}

What does this code do?

The login page works the same way as registration, but calls POST /auth/login instead. It captures email and password, submits them, stores the JWT on success, and shows inline errors on failure.

  • Save src/app/auth/login/page.tsx.
  • Navigate to http://localhost:3000/auth/login in your browser. You should see a centered login form with Email and Password fields, plus a link to the registration page.

Form renders but submit does nothing?

Your backend needs to be running for form submission to succeed. If you haven't started the backend yet, you'll see a network error. That's expected for now. The form itself should render and validate inputs (required fields, email format) entirely on the client side.

Help me debug my login form not submitting

✔️ Awesome, I've got everything!

Great. Make sure both auth pages are saved before moving on to the landing page.

ⓧ I'd like to double check the full code

Here is the complete src/app/auth/register/page.tsx:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { apiFetch, setToken } from '@/lib/api';

export default function RegisterPage() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [role, setRole] = useState<'host' | 'attendee'>('attendee');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      const data = await apiFetch('/auth/register', {
        method: 'POST',
        body: JSON.stringify({ email, password, role }),
      });
      setToken(data.access_token);
      router.push('/dashboard');
    } catch (err: any) {
      setError(err.message || 'Registration failed');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center px-4">
      <div className="w-full max-w-md bg-gray-900 rounded-xl p-8 border border-gray-800">
        <h1 className="text-2xl font-bold text-white mb-6">Create Account</h1>
        {error && <p className="text-red-400 text-sm mb-4">{error}</p>}
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm text-gray-300 mb-1">Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            />
          </div>
          <div>
            <label className="block text-sm text-gray-300 mb-1">Password</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              minLength={6}
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            />
          </div>
          <div>
            <label className="block text-sm text-gray-300 mb-1">Role</label>
            <select
              value={role}
              onChange={(e) => setRole(e.target.value as 'host' | 'attendee')}
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            >
              <option value="attendee">Attendee</option>
              <option value="host">Host</option>
            </select>
          </div>
          <button
            type="submit"
            disabled={loading}
            className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? 'Creating...' : 'Create Account'}
          </button>
        </form>
        <p className="text-gray-400 text-sm mt-4 text-center">
          Already have an account?{' '}
          <Link href="/auth/login" className="text-blue-400 hover:underline">
            Login
          </Link>
        </p>
      </div>
    </div>
  );
}

And here is the complete src/app/auth/login/page.tsx:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { apiFetch, setToken } from '@/lib/api';

export default function LoginPage() {
  const router = useRouter();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    setLoading(true);

    try {
      const data = await apiFetch('/auth/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
      });
      setToken(data.access_token);
      router.push('/dashboard');
    } catch (err: any) {
      setError(err.message || 'Login failed');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center px-4">
      <div className="w-full max-w-md bg-gray-900 rounded-xl p-8 border border-gray-800">
        <h1 className="text-2xl font-bold text-white mb-6">Welcome Back</h1>
        {error && <p className="text-red-400 text-sm mb-4">{error}</p>}
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm text-gray-300 mb-1">Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            />
          </div>
          <div>
            <label className="block text-sm text-gray-300 mb-1">Password</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white"
            />
          </div>
          <button
            type="submit"
            disabled={loading}
            className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? 'Logging in...' : 'Login'}
          </button>
        </form>
        <p className="text-gray-400 text-sm mt-4 text-center">
          Don't have an account?{' '}
          <Link href="/auth/register" className="text-blue-400 hover:underline">
            Sign up
          </Link>
        </p>
      </div>
    </div>
  );
}
Create the public landing page

The landing page is what visitors see before logging in. It needs to quickly communicate what the platform does and guide users toward creating an account.

  • Replace the contents of src/app/page.tsx with the landing page:
import Link from 'next/link';

export default function HomePage() {
  return (
    <div className="min-h-screen">
      {/* Hero Section */}
      <section className="py-20 px-4 text-center">
        <h1 className="text-5xl font-bold text-white mb-6">
          Find Every Photo You're In
        </h1>
        <p className="text-xl text-gray-400 max-w-2xl mx-auto mb-8">
          AI-powered facial recognition finds your photos across entire events.
          Upload a selfie and instantly discover every moment captured.
        </p>
        <div className="flex gap-4 justify-center">
          <Link
            href="/auth/register"
            className="bg-blue-600 text-white px-6 py-3 rounded-lg text-lg hover:bg-blue-700"
          >
            Get Started
          </Link>
          <Link
            href="/auth/login"
            className="border border-gray-700 text-gray-300 px-6 py-3 rounded-lg text-lg hover:bg-gray-800"
          >
            Login
          </Link>
        </div>
      </section>

      {/* Feature Highlights */}
      <section className="py-16 px-4 max-w-6xl mx-auto">
        <div className="grid md:grid-cols-3 gap-8">
          <div className="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center">
            <div className="text-4xl mb-4">🔍</div>
            <h3 className="text-lg font-semibold text-white mb-2">
              Facial Recognition
            </h3>
            <p className="text-gray-400">
              Advanced AI detects and matches faces across thousands of event photos.
            </p>
          </div>
          <div className="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center">
            <div className="text-4xl mb-4">⚡</div>
            <h3 className="text-lg font-semibold text-white mb-2">
              Instant Gallery
            </h3>
            <p className="text-gray-400">
              Submit a selfie and see your personalized photo gallery in seconds.
            </p>
          </div>
          <div className="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center">
            <div className="text-4xl mb-4">📱</div>
            <h3 className="text-lg font-semibold text-white mb-2">
              QR Code Join
            </h3>
            <p className="text-gray-400">
              Attendees join events instantly by scanning a QR code or clicking a link.
            </p>
          </div>
        </div>
      </section>

      {/* Call to Action */}
      <section className="py-16 px-4 text-center">
        <h2 className="text-3xl font-bold text-white mb-4">
          Ready to Host Your Event?
        </h2>
        <p className="text-gray-400 mb-6">
          Create an event, upload photos, and let AI do the rest.
        </p>
        <Link
          href="/auth/register"
          className="bg-blue-600 text-white px-8 py-3 rounded-lg text-lg hover:bg-blue-700"
        >
          Create Free Account
        </Link>
      </section>
    </div>
  );
}

What does this code do?

The landing page has three sections. The hero section introduces the platform with a headline and two call-to-action buttons. The features section highlights the three core capabilities: facial recognition, instant gallery, and QR code joining.

The final section is a closing CTA that drives visitors to register. This is a Server Component (no 'use client' directive) because it has no interactive state. Server Components render faster and reduce the JavaScript bundle sent to the browser.

  • Save src/app/page.tsx.
  • Navigate to http://localhost:3000 in your browser. You should see the hero section with the heading "Find Every Photo You're In", three feature cards below it, and a final "Ready to Host Your Event?" CTA at the bottom.

Page looks unstyled or broken?

  • Check that Tailwind CSS is properly configured. Your globals.css must include the Tailwind import directive, and the file must be imported in layout.tsx.
  • If styles appear but the layout is wrong, try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to clear cached CSS.

Help me fix Tailwind styles not showing on my Next.js page

✔️ Awesome, I've got everything!

Great. Make sure all files are saved. Your frontend is now fully functional for this step.

ⓧ I'd like to double check the full code

Here is the complete src/lib/api.ts:

const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';

export async function apiFetch(path: string, options: RequestInit = {}) {
  const url = `${API_BASE}${path}`;
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    ...(options.headers as Record<string, string>),
  };

  const token = getToken();
  if (token) {
    headers['Authorization'] = `Bearer ${token}`;
  }

  const res = await fetch(url, { ...options, headers });

  if (!res.ok) {
    const error = await res.json().catch(() => ({ detail: 'Request failed' }));
    throw new Error(error.detail || 'Request failed');
  }

  return res.json();
}

export function setToken(token: string) {
  document.cookie = `token=${token}; path=/; max-age=${60 * 60 * 24}`;
}

export function removeToken() {
  document.cookie = 'token=; path=/; max-age=0';
}

export function getToken(): string | null {
  if (typeof document === 'undefined') return null;
  const match = document.cookie
    .split('; ')
    .find((row) => row.startsWith('token='));
  return match ? match.split('=')[1] : null;
}

Here is the complete src/app/layout.tsx:

import type { Metadata } from 'next';
import './globals.css';
import Navbar from '@/components/Navbar';

export const metadata: Metadata = {
  title: 'EventSnap - AI Photo Discovery',
  description: 'Find every event photo you appear in with AI-powered facial recognition.',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className="bg-gray-950 text-white min-h-screen">
        <Navbar />
        <main>{children}</main>
      </body>
    </html>
  );
}

Here is the complete src/app/page.tsx:

import Link from 'next/link';

export default function HomePage() {
  return (
    <div className="min-h-screen">
      <section className="py-20 px-4 text-center">
        <h1 className="text-5xl font-bold text-white mb-6">
          Find Every Photo You're In
        </h1>
        <p className="text-xl text-gray-400 max-w-2xl mx-auto mb-8">
          AI-powered facial recognition finds your photos across entire events.
          Upload a selfie and instantly discover every moment captured.
        </p>
        <div className="flex gap-4 justify-center">
          <Link
            href="/auth/register"
            className="bg-blue-600 text-white px-6 py-3 rounded-lg text-lg hover:bg-blue-700"
          >
            Get Started
          </Link>
          <Link
            href="/auth/login"
            className="border border-gray-700 text-gray-300 px-6 py-3 rounded-lg text-lg hover:bg-gray-800"
          >
            Login
          </Link>
        </div>
      </section>
      <section className="py-16 px-4 max-w-6xl mx-auto">
        <div className="grid md:grid-cols-3 gap-8">
          <div className="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center">
            <div className="text-4xl mb-4">🔍</div>
            <h3 className="text-lg font-semibold text-white mb-2">Facial Recognition</h3>
            <p className="text-gray-400">Advanced AI detects and matches faces across thousands of event photos.</p>
          </div>
          <div className="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center">
            <div className="text-4xl mb-4">⚡</div>
            <h3 className="text-lg font-semibold text-white mb-2">Instant Gallery</h3>
            <p className="text-gray-400">Submit a selfie and see your personalized photo gallery in seconds.</p>
          </div>
          <div className="bg-gray-900 border border-gray-800 rounded-xl p-6 text-center">
            <div className="text-4xl mb-4">📱</div>
            <h3 className="text-lg font-semibold text-white mb-2">QR Code Join</h3>
            <p className="text-gray-400">Attendees join events instantly by scanning a QR code or clicking a link.</p>
          </div>
        </div>
      </section>
      <section className="py-16 px-4 text-center">
        <h2 className="text-3xl font-bold text-white mb-4">Ready to Host Your Event?</h2>
        <p className="text-gray-400 mb-6">Create an event, upload photos, and let AI do the rest.</p>
        <Link
          href="/auth/register"
          className="bg-blue-600 text-white px-8 py-3 rounded-lg text-lg hover:bg-blue-700"
        >
          Create Free Account
        </Link>
      </section>
    </div>
  );
}

Your frontend foundation is complete. You have shared components, auth pages, and a polished landing page. Next up, you'll build the host dashboard where event organizers can create events and upload photos for processing.

Build the Host Dashboard

Your backend API is fully operational and the frontend has authentication wired up. Now it is time to build the interface that event hosts will actually use day-to-day.

In this step, you will create the host dashboard where hosts manage events, upload photos, monitor processing progress, and review analytics. This is the control center of the platform.

In this step, get ready to:
  • Create the host event management interface with an event list, create event form, and event detail page with tabs.
  • Build the photo upload center with drag-and-drop, multi-file upload, progress bars, and real-time processing status polling.
  • Add the analytics dashboard showing upload stats, processing progress, face detection counts, and unique attendee estimates.
Create the host event management interface

The host dashboard needs three pages: a list of all events the host owns, a form to create new events, and a detail page for each event. The detail page uses tabs to organize the event's upload, processing, and analytics views.

  • Create a new file at frontend/src/app/dashboard/page.tsx by running:
mkdir -p frontend/src/app/dashboard
touch frontend/src/app/dashboard/page.tsx
  • Add the following code to frontend/src/app/dashboard/page.tsx:
'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiClient } from '@/lib/api';
import Card from '@/components/Card';
import Sidebar from '@/components/Sidebar';

// Type for event data from the API
interface Event {
  id: number;
  name: string;
  description: string | null;
  created_at: string;
}

export default function DashboardPage() {
  const [events, setEvents] = useState<Event[]>([]);
  const [loading, setLoading] = useState(true);
  const router = useRouter();

  // Fetch all events belonging to this host
  useEffect(() => {
    async function fetchEvents() {
      try {
        const data = await apiClient.get('/events');
        setEvents(data);
      } catch (error) {
        console.error('Failed to fetch events:', error);
      } finally {
        setLoading(false);
      }
    }
    fetchEvents();
  }, []);

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1 p-8">
        {/* Header with create button */}
        <div className="flex justify-between items-center mb-8">
          <h1 className="text-2xl font-bold">My Events</h1>
          <button
            onClick={() => router.push('/dashboard/events/new')}
            className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
          >
            Create Event
          </button>
        </div>

        {/* Event grid */}
        {loading ? (
          <p>Loading events...</p>
        ) : events.length === 0 ? (
          <p className="text-gray-500">No events yet. Create your first event to get started.</p>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            {events.map((event) => (
              <Card
                key={event.id}
                onClick={() => router.push(`/dashboard/events/${event.id}`)}
              >
                <h2 className="text-lg font-semibold">{event.name}</h2>
                <p className="text-gray-500 text-sm mt-1">{event.description}</p>
                <p className="text-gray-400 text-xs mt-2">
                  Created {new Date(event.created_at).toLocaleDateString()}
                </p>
              </Card>
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

What does this code do?

This page is a client component that fetches the host's events from GET /events using the apiClient utility you created earlier. It renders a responsive grid of event cards.

Clicking an event card navigates to the detail page. The Create Event button navigates to the form page.

  • Save frontend/src/app/dashboard/page.tsx.
  • You won't see a change yet because the create event form and detail page still need to be built.
  • Create the create event form file by running:
mkdir -p frontend/src/app/dashboard/events/new
touch frontend/src/app/dashboard/events/new/page.tsx
  • Add the following code to frontend/src/app/dashboard/events/new/page.tsx:
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiClient } from '@/lib/api';
import Sidebar from '@/components/Sidebar';

export default function CreateEventPage() {
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const router = useRouter();

  // Submit the new event to the API
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setSubmitting(true);
    try {
      const event = await apiClient.post('/events', { name, description });
      // Redirect to the new event's detail page
      router.push(`/dashboard/events/${event.id}`);
    } catch (error) {
      console.error('Failed to create event:', error);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1 p-8 max-w-2xl">
        <h1 className="text-2xl font-bold mb-6">Create New Event</h1>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm font-medium mb-1">Event Name</label>
            <input
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
              required
              className="w-full border rounded-lg px-3 py-2"
              placeholder="Summer Party 2026"
            />
          </div>
          <div>
            <label className="block text-sm font-medium mb-1">Description</label>
            <textarea
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              className="w-full border rounded-lg px-3 py-2 h-32"
              placeholder="Tell attendees about this event..."
            />
          </div>
          <button
            type="submit"
            disabled={submitting}
            className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
          >
            {submitting ? 'Creating...' : 'Create Event'}
          </button>
        </form>
      </main>
    </div>
  );
}

What does this code do?

This form sends a POST /events request with the event name and description. On success, it redirects the host to the new event's detail page where they can start uploading photos.

  • Save frontend/src/app/dashboard/events/new/page.tsx.

Now for the event detail page. This page uses a tabbed interface to organize Overview, Upload, Processing, and Analytics views into a single location.

  • Create the event detail page file by running:
mkdir -p "frontend/src/app/dashboard/events/[id]"
touch "frontend/src/app/dashboard/events/[id]/page.tsx"
  • Add the following code to frontend/src/app/dashboard/events/[id]/page.tsx:
'use client';

import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { apiClient } from '@/lib/api';
import Sidebar from '@/components/Sidebar';

// Types for API responses
interface Event {
  id: number;
  name: string;
  description: string | null;
  created_at: string;
}

interface Analytics {
  total_photos: number;
  faces_detected: number;
  processing_progress: number;
  unique_attendees: number;
}

interface ProcessingStatus {
  processed: number;
  total: number;
}

type TabName = 'overview' | 'upload' | 'processing' | 'analytics';

export default function EventDetailPage() {
  const params = useParams<{ id: string }>();
  const eventId = params.id;
  const [event, setEvent] = useState<Event | null>(null);
  const [activeTab, setActiveTab] = useState<TabName>('overview');
  const [analytics, setAnalytics] = useState<Analytics | null>(null);
  const [processingStatus, setProcessingStatus] = useState<ProcessingStatus | null>(null);

  // Fetch event details on mount
  useEffect(() => {
    async function fetchEvent() {
      const data = await apiClient.get(`/events/${eventId}`);
      setEvent(data);
    }
    fetchEvent();
  }, [eventId]);

  // Fetch analytics when that tab is active
  useEffect(() => {
    if (activeTab === 'analytics') {
      async function fetchAnalytics() {
        const data = await apiClient.get(`/events/${eventId}/analytics`);
        setAnalytics(data);
      }
      fetchAnalytics();
    }
  }, [activeTab, eventId]);

  // Poll processing status every 3 seconds when on the processing tab
  useEffect(() => {
    if (activeTab !== 'processing') return;
    const interval = setInterval(async () => {
      const data = await apiClient.get(`/events/${eventId}/processing-status`);
      setProcessingStatus(data);
    }, 3000);
    return () => clearInterval(interval);
  }, [activeTab, eventId]);

  // Tab definitions
  const tabs: { key: TabName; label: string }[] = [
    { key: 'overview', label: 'Overview' },
    { key: 'upload', label: 'Upload' },
    { key: 'processing', label: 'Processing' },
    { key: 'analytics', label: 'Analytics' },
  ];

  if (!event) return <p className="p-8">Loading...</p>;

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1 p-8">
        <h1 className="text-2xl font-bold mb-2">{event.name}</h1>
        <p className="text-gray-500 mb-6">{event.description}</p>

        {/* Tab navigation */}
        <div className="flex border-b mb-6">
          {tabs.map((tab) => (
            <button
              key={tab.key}
              onClick={() => setActiveTab(tab.key)}
              className={`px-4 py-2 font-medium ${
                activeTab === tab.key
                  ? 'border-b-2 border-blue-600 text-blue-600'
                  : 'text-gray-500 hover:text-gray-700'
              }`}
            >
              {tab.label}
            </button>
          ))}
        </div>

        {/* Overview tab */}
        {activeTab === 'overview' && (
          <div>
            <p className="text-gray-600">
              Created on {new Date(event.created_at).toLocaleDateString()}
            </p>
          </div>
        )}

        {/* Upload tab links to upload center */}
        {activeTab === 'upload' && (
          <div>
            <a
              href={`/dashboard/events/${eventId}/upload`}
              className="bg-blue-600 text-white px-4 py-2 rounded-lg inline-block"
            >
              Go to Upload Center
            </a>
          </div>
        )}

        {/* Processing tab with live progress */}
        {activeTab === 'processing' && (
          <div>
            {processingStatus ? (
              <div>
                <p className="text-lg font-medium mb-2">
                  {processingStatus.processed} / {processingStatus.total} photos processed
                </p>
                <div className="w-full bg-gray-200 rounded-full h-4">
                  <div
                    className="bg-blue-600 h-4 rounded-full transition-all"
                    style={{
                      width: `${processingStatus.total > 0 ? (processingStatus.processed / processingStatus.total) * 100 : 0}%`,
                    }}
                  />
                </div>
              </div>
            ) : (
              <p>Loading processing status...</p>
            )}
          </div>
        )}

        {/* Analytics tab with stat cards */}
        {activeTab === 'analytics' && (
          <div>
            {analytics ? (
              <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
                <StatCard label="Total Photos" value={analytics.total_photos} />
                <StatCard label="Faces Detected" value={analytics.faces_detected} />
                <StatCard label="Processing" value={`${analytics.processing_progress}%`} />
                <StatCard label="Unique Attendees" value={analytics.unique_attendees} />
              </div>
            ) : (
              <p>Loading analytics...</p>
            )}
          </div>
        )}
      </main>
    </div>
  );
}

// Reusable stat card component
function StatCard({ label, value }: { label: string; value: string | number }) {
  return (
    <div className="bg-white border rounded-lg p-4">
      <p className="text-gray-500 text-sm">{label}</p>
      <p className="text-2xl font-bold">{value}</p>
    </div>
  );
}

What does this code do?

The useParams hook from next/navigation reads the event ID from the dynamic [id] route segment. The page renders a tab bar that conditionally shows different content based on the active tab.

The Processing tab polls GET /events/{id}/processing-status every 3 seconds using setInterval inside a useEffect. The cleanup function clears the interval when the user switches tabs.

The Analytics tab fetches from GET /events/{id}/analytics and renders four stat cards showing total photos, faces detected, processing progress, and unique attendee count.

  • Save frontend/src/app/dashboard/events/[id]/page.tsx.
  • Navigate to http://localhost:3000/dashboard in your browser. You should see the "My Events" heading with a Create Event button.

Seeing a blank page or 404?

Make sure your Next.js dev server is running. Confirm the file path matches exactly: frontend/src/app/dashboard/page.tsx. The App Router requires the file to be named page.tsx inside the correct folder structure.

Help me troubleshoot my dashboard page

Build the photo upload center

Hosts need to upload dozens or hundreds of event photos at once. The upload center provides a drag-and-drop zone, file browser fallback, and per-file progress bars so hosts can see exactly what is happening.

  • Create the upload page file by running:
mkdir -p "frontend/src/app/dashboard/events/[id]/upload"
touch "frontend/src/app/dashboard/events/[id]/upload/page.tsx"
  • Add the following code to frontend/src/app/dashboard/events/[id]/upload/page.tsx:
'use client';

import { useState, useCallback, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { apiClient } from '@/lib/api';
import Sidebar from '@/components/Sidebar';

// Track each file's upload state
interface UploadFile {
  file: File;
  progress: number;
  status: 'pending' | 'uploading' | 'done' | 'error';
}

export default function UploadPage() {
  const params = useParams<{ id: string }>();
  const eventId = params.id;
  const [files, setFiles] = useState<UploadFile[]>([]);
  const [dragging, setDragging] = useState(false);
  const [processingStatus, setProcessingStatus] = useState<{ processed: number; total: number } | null>(null);

  // Handle drag-and-drop events
  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragging(true);
  }, []);

  const handleDragLeave = useCallback(() => {
    setDragging(false);
  }, []);

  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragging(false);
    // Filter to only image files
    const dropped = Array.from(e.dataTransfer.files).filter((f) =>
      f.type.startsWith('image/')
    );
    addFiles(dropped);
  }, []);

  // Add files to the upload queue
  function addFiles(newFiles: File[]) {
    const uploadFiles: UploadFile[] = newFiles.map((file) => ({
      file,
      progress: 0,
      status: 'pending',
    }));
    setFiles((prev) => [...prev, ...uploadFiles]);
  }

  // Upload all pending files sequentially
  async function uploadAll() {
    for (let i = 0; i < files.length; i++) {
      if (files[i].status !== 'pending') continue;

      // Mark file as uploading
      setFiles((prev) =>
        prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f))
      );

      try {
        const formData = new FormData();
        formData.append('file', files[i].file);
        await apiClient.upload(`/events/${eventId}/photos`, formData, (progress: number) => {
          setFiles((prev) =>
            prev.map((f, idx) => (idx === i ? { ...f, progress } : f))
          );
        });

        // Mark as complete
        setFiles((prev) =>
          prev.map((f, idx) =>
            idx === i ? { ...f, status: 'done', progress: 100 } : f
          )
        );
      } catch {
        // Mark as failed
        setFiles((prev) =>
          prev.map((f, idx) => (idx === i ? { ...f, status: 'error' } : f))
        );
      }
    }
  }

  // Poll processing status after uploads start
  useEffect(() => {
    if (!files.some((f) => f.status === 'done')) return;
    const interval = setInterval(async () => {
      const data = await apiClient.get(`/events/${eventId}/processing-status`);
      setProcessingStatus(data);
    }, 3000);
    return () => clearInterval(interval);
  }, [files, eventId]);

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1 p-8">
        <h1 className="text-2xl font-bold mb-6">Upload Photos</h1>

        {/* Drag-and-drop zone */}
        <div
          onDragOver={handleDragOver}
          onDragLeave={handleDragLeave}
          onDrop={handleDrop}
          className={`border-2 border-dashed rounded-lg p-12 text-center mb-6 ${
            dragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
          }`}
        >
          <p className="text-gray-500 mb-2">Drag and drop photos here, or</p>
          <label className="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded-lg inline-block">
            Browse Files
            <input
              type="file"
              multiple
              accept="image/*"
              className="hidden"
              onChange={(e) => addFiles(Array.from(e.target.files || []))}
            />
          </label>
        </div>

        {/* File list with progress bars */}
        {files.length > 0 && (
          <div className="space-y-3 mb-6">
            {files.map((f, idx) => (
              <div key={idx} className="flex items-center gap-4 border rounded-lg p-3">
                <span className="flex-1 truncate text-sm">{f.file.name}</span>
                <div className="w-48 bg-gray-200 rounded-full h-2">
                  <div
                    className="bg-blue-600 h-2 rounded-full transition-all"
                    style={{ width: `${f.progress}%` }}
                  />
                </div>
                <span className="text-xs text-gray-500 w-16 text-right">
                  {f.status === 'done' ? 'Done' : f.status === 'error' ? 'Failed' : `${f.progress}%`}
                </span>
              </div>
            ))}
          </div>
        )}

        {/* Upload button */}
        {files.some((f) => f.status === 'pending') && (
          <button
            onClick={uploadAll}
            className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 mb-6"
          >
            Upload All ({files.filter((f) => f.status === 'pending').length} files)
          </button>
        )}

        {/* Real-time processing status */}
        {processingStatus && (
          <div className="mt-6 border rounded-lg p-4">
            <h2 className="font-medium mb-2">Processing Status</h2>
            <p className="text-sm text-gray-600 mb-2">
              {processingStatus.processed} / {processingStatus.total} photos processed
            </p>
            <div className="w-full bg-gray-200 rounded-full h-3">
              <div
                className="bg-green-500 h-3 rounded-full transition-all"
                style={{
                  width: `${processingStatus.total > 0 ? (processingStatus.processed / processingStatus.total) * 100 : 0}%`,
                }}
              />
            </div>
          </div>
        )}
      </main>
    </div>
  );
}

What does this code do?

The upload page manages a queue of files. When a user drops images onto the zone (or picks them with the file browser), they are added to local state as pending. Clicking Upload All sends each file sequentially via FormData to the backend's photo upload endpoint.

Each file's progress bar updates in real time via the apiClient.upload callback. Once at least one file finishes uploading, the page starts polling GET /events/{id}/processing-status every 3 seconds to show the ML pipeline's progress.

  • Save frontend/src/app/dashboard/events/[id]/upload/page.tsx.
  • Create an event through the dashboard, then click the Upload tab and select Go to Upload Center. You should see the drag-and-drop zone with a Browse Files button.

Drop zone not responding to dragged files?

Make sure you are dragging image files (JPG, PNG, etc). The handler filters by f.type.startsWith('image/') so non-image files are silently ignored.

Also confirm the onDragOver handler calls e.preventDefault(). Without this, the browser opens the file instead of triggering a drop event.

Help me debug my drag-and-drop upload zone

Add the analytics dashboard

The analytics tab on the event detail page already fetches data from GET /events/{id}/analytics and renders stat cards. Now you will verify the full flow works end-to-end.

  • Make sure your backend is running (FastAPI, Redis, Celery worker, and Qdrant from the Docker Compose setup).
  • Create a test event from the dashboard.
  • Upload at least 2-3 photos through the Upload Center.
  • Switch to the Processing tab and watch the progress bar update as Celery processes the photos.
  • Once processing completes, switch to the Analytics tab. You should see cards showing total photos, faces detected, processing progress at 100%, and a unique attendees count.

What do the analytics numbers mean?

The total_photos count is the number of photos uploaded to this event. The faces_detected count is the total number of faces found across all photos (a group photo might contribute 10+ faces).

The unique_attendees estimate comes from the face clustering logic that groups similar embeddings together. Each cluster represents one distinct person.

Analytics tab shows zeros or loading forever?

Confirm your Celery worker is running and processing photos. Check the terminal where you started the worker for error messages. The analytics endpoint requires photos to have a processed status before counts appear.

Help me debug my analytics tab

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files.

ⓧ I'd like to double check the full code

Here is the full code for each of the four dashboard pages you created in this step.

frontend/src/app/dashboard/page.tsx:

'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiClient } from '@/lib/api';
import Card from '@/components/Card';
import Sidebar from '@/components/Sidebar';

// Type for event data from the API
interface Event {
  id: number;
  name: string;
  description: string | null;
  created_at: string;
}

export default function DashboardPage() {
  const [events, setEvents] = useState<Event[]>([]);
  const [loading, setLoading] = useState(true);
  const router = useRouter();

  // Fetch all events belonging to this host
  useEffect(() => {
    async function fetchEvents() {
      try {
        const data = await apiClient.get('/events');
        setEvents(data);
      } catch (error) {
        console.error('Failed to fetch events:', error);
      } finally {
        setLoading(false);
      }
    }
    fetchEvents();
  }, []);

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1 p-8">
        {/* Header with create button */}
        <div className="flex justify-between items-center mb-8">
          <h1 className="text-2xl font-bold">My Events</h1>
          <button
            onClick={() => router.push('/dashboard/events/new')}
            className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
          >
            Create Event
          </button>
        </div>

        {/* Event grid */}
        {loading ? (
          <p>Loading events...</p>
        ) : events.length === 0 ? (
          <p className="text-gray-500">No events yet. Create your first event to get started.</p>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            {events.map((event) => (
              <Card
                key={event.id}
                onClick={() => router.push(`/dashboard/events/${event.id}`)}
              >
                <h2 className="text-lg font-semibold">{event.name}</h2>
                <p className="text-gray-500 text-sm mt-1">{event.description}</p>
                <p className="text-gray-400 text-xs mt-2">
                  Created {new Date(event.created_at).toLocaleDateString()}
                </p>
              </Card>
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

frontend/src/app/dashboard/events/new/page.tsx:

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { apiClient } from '@/lib/api';
import Sidebar from '@/components/Sidebar';

export default function CreateEventPage() {
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const router = useRouter();

  // Submit the new event to the API
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setSubmitting(true);
    try {
      const event = await apiClient.post('/events', { name, description });
      // Redirect to the new event's detail page
      router.push(`/dashboard/events/${event.id}`);
    } catch (error) {
      console.error('Failed to create event:', error);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1 p-8 max-w-2xl">
        <h1 className="text-2xl font-bold mb-6">Create New Event</h1>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm font-medium mb-1">Event Name</label>
            <input
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
              required
              className="w-full border rounded-lg px-3 py-2"
              placeholder="Summer Party 2026"
            />
          </div>
          <div>
            <label className="block text-sm font-medium mb-1">Description</label>
            <textarea
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              className="w-full border rounded-lg px-3 py-2 h-32"
              placeholder="Tell attendees about this event..."
            />
          </div>
          <button
            type="submit"
            disabled={submitting}
            className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
          >
            {submitting ? 'Creating...' : 'Create Event'}
          </button>
        </form>
      </main>
    </div>
  );
}

frontend/src/app/dashboard/events/[id]/page.tsx:

'use client';

import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { apiClient } from '@/lib/api';
import Sidebar from '@/components/Sidebar';

// Types for API responses
interface Event {
  id: number;
  name: string;
  description: string | null;
  created_at: string;
}

interface Analytics {
  total_photos: number;
  faces_detected: number;
  processing_progress: number;
  unique_attendees: number;
}

interface ProcessingStatus {
  processed: number;
  total: number;
}

type TabName = 'overview' | 'upload' | 'processing' | 'analytics';

export default function EventDetailPage() {
  const params = useParams<{ id: string }>();
  const eventId = params.id;
  const [event, setEvent] = useState<Event | null>(null);
  const [activeTab, setActiveTab] = useState<TabName>('overview');
  const [analytics, setAnalytics] = useState<Analytics | null>(null);
  const [processingStatus, setProcessingStatus] = useState<ProcessingStatus | null>(null);

  // Fetch event details on mount
  useEffect(() => {
    async function fetchEvent() {
      const data = await apiClient.get(`/events/${eventId}`);
      setEvent(data);
    }
    fetchEvent();
  }, [eventId]);

  // Fetch analytics when that tab is active
  useEffect(() => {
    if (activeTab === 'analytics') {
      async function fetchAnalytics() {
        const data = await apiClient.get(`/events/${eventId}/analytics`);
        setAnalytics(data);
      }
      fetchAnalytics();
    }
  }, [activeTab, eventId]);

  // Poll processing status every 3 seconds when on the processing tab
  useEffect(() => {
    if (activeTab !== 'processing') return;
    const interval = setInterval(async () => {
      const data = await apiClient.get(`/events/${eventId}/processing-status`);
      setProcessingStatus(data);
    }, 3000);
    return () => clearInterval(interval);
  }, [activeTab, eventId]);

  // Tab definitions
  const tabs: { key: TabName; label: string }[] = [
    { key: 'overview', label: 'Overview' },
    { key: 'upload', label: 'Upload' },
    { key: 'processing', label: 'Processing' },
    { key: 'analytics', label: 'Analytics' },
  ];

  if (!event) return <p className="p-8">Loading...</p>;

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1 p-8">
        <h1 className="text-2xl font-bold mb-2">{event.name}</h1>
        <p className="text-gray-500 mb-6">{event.description}</p>

        {/* Tab navigation */}
        <div className="flex border-b mb-6">
          {tabs.map((tab) => (
            <button
              key={tab.key}
              onClick={() => setActiveTab(tab.key)}
              className={`px-4 py-2 font-medium ${
                activeTab === tab.key
                  ? 'border-b-2 border-blue-600 text-blue-600'
                  : 'text-gray-500 hover:text-gray-700'
              }`}
            >
              {tab.label}
            </button>
          ))}
        </div>

        {/* Overview tab */}
        {activeTab === 'overview' && (
          <div>
            <p className="text-gray-600">
              Created on {new Date(event.created_at).toLocaleDateString()}
            </p>
          </div>
        )}

        {/* Upload tab links to upload center */}
        {activeTab === 'upload' && (
          <div>
            <a
              href={`/dashboard/events/${eventId}/upload`}
              className="bg-blue-600 text-white px-4 py-2 rounded-lg inline-block"
            >
              Go to Upload Center
            </a>
          </div>
        )}

        {/* Processing tab with live progress */}
        {activeTab === 'processing' && (
          <div>
            {processingStatus ? (
              <div>
                <p className="text-lg font-medium mb-2">
                  {processingStatus.processed} / {processingStatus.total} photos processed
                </p>
                <div className="w-full bg-gray-200 rounded-full h-4">
                  <div
                    className="bg-blue-600 h-4 rounded-full transition-all"
                    style={{
                      width: `${processingStatus.total > 0 ? (processingStatus.processed / processingStatus.total) * 100 : 0}%`,
                    }}
                  />
                </div>
              </div>
            ) : (
              <p>Loading processing status...</p>
            )}
          </div>
        )}

        {/* Analytics tab with stat cards */}
        {activeTab === 'analytics' && (
          <div>
            {analytics ? (
              <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
                <StatCard label="Total Photos" value={analytics.total_photos} />
                <StatCard label="Faces Detected" value={analytics.faces_detected} />
                <StatCard label="Processing" value={`${analytics.processing_progress}%`} />
                <StatCard label="Unique Attendees" value={analytics.unique_attendees} />
              </div>
            ) : (
              <p>Loading analytics...</p>
            )}
          </div>
        )}
      </main>
    </div>
  );
}

// Reusable stat card component
function StatCard({ label, value }: { label: string; value: string | number }) {
  return (
    <div className="bg-white border rounded-lg p-4">
      <p className="text-gray-500 text-sm">{label}</p>
      <p className="text-2xl font-bold">{value}</p>
    </div>
  );
}

frontend/src/app/dashboard/events/[id]/upload/page.tsx:

'use client';

import { useState, useCallback, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { apiClient } from '@/lib/api';
import Sidebar from '@/components/Sidebar';

// Track each file's upload state
interface UploadFile {
  file: File;
  progress: number;
  status: 'pending' | 'uploading' | 'done' | 'error';
}

export default function UploadPage() {
  const params = useParams<{ id: string }>();
  const eventId = params.id;
  const [files, setFiles] = useState<UploadFile[]>([]);
  const [dragging, setDragging] = useState(false);
  const [processingStatus, setProcessingStatus] = useState<{ processed: number; total: number } | null>(null);

  // Handle drag-and-drop events
  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragging(true);
  }, []);

  const handleDragLeave = useCallback(() => {
    setDragging(false);
  }, []);

  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragging(false);
    // Filter to only image files
    const dropped = Array.from(e.dataTransfer.files).filter((f) =>
      f.type.startsWith('image/')
    );
    addFiles(dropped);
  }, []);

  // Add files to the upload queue
  function addFiles(newFiles: File[]) {
    const uploadFiles: UploadFile[] = newFiles.map((file) => ({
      file,
      progress: 0,
      status: 'pending',
    }));
    setFiles((prev) => [...prev, ...uploadFiles]);
  }

  // Upload all pending files sequentially
  async function uploadAll() {
    for (let i = 0; i < files.length; i++) {
      if (files[i].status !== 'pending') continue;

      // Mark file as uploading
      setFiles((prev) =>
        prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f))
      );

      try {
        const formData = new FormData();
        formData.append('file', files[i].file);
        await apiClient.upload(`/events/${eventId}/photos`, formData, (progress: number) => {
          setFiles((prev) =>
            prev.map((f, idx) => (idx === i ? { ...f, progress } : f))
          );
        });

        // Mark as complete
        setFiles((prev) =>
          prev.map((f, idx) =>
            idx === i ? { ...f, status: 'done', progress: 100 } : f
          )
        );
      } catch {
        // Mark as failed
        setFiles((prev) =>
          prev.map((f, idx) => (idx === i ? { ...f, status: 'error' } : f))
        );
      }
    }
  }

  // Poll processing status after uploads start
  useEffect(() => {
    if (!files.some((f) => f.status === 'done')) return;
    const interval = setInterval(async () => {
      const data = await apiClient.get(`/events/${eventId}/processing-status`);
      setProcessingStatus(data);
    }, 3000);
    return () => clearInterval(interval);
  }, [files, eventId]);

  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1 p-8">
        <h1 className="text-2xl font-bold mb-6">Upload Photos</h1>

        {/* Drag-and-drop zone */}
        <div
          onDragOver={handleDragOver}
          onDragLeave={handleDragLeave}
          onDrop={handleDrop}
          className={`border-2 border-dashed rounded-lg p-12 text-center mb-6 ${
            dragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
          }`}
        >
          <p className="text-gray-500 mb-2">Drag and drop photos here, or</p>
          <label className="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded-lg inline-block">
            Browse Files
            <input
              type="file"
              multiple
              accept="image/*"
              className="hidden"
              onChange={(e) => addFiles(Array.from(e.target.files || []))}
            />
          </label>
        </div>

        {/* File list with progress bars */}
        {files.length > 0 && (
          <div className="space-y-3 mb-6">
            {files.map((f, idx) => (
              <div key={idx} className="flex items-center gap-4 border rounded-lg p-3">
                <span className="flex-1 truncate text-sm">{f.file.name}</span>
                <div className="w-48 bg-gray-200 rounded-full h-2">
                  <div
                    className="bg-blue-600 h-2 rounded-full transition-all"
                    style={{ width: `${f.progress}%` }}
                  />
                </div>
                <span className="text-xs text-gray-500 w-16 text-right">
                  {f.status === 'done' ? 'Done' : f.status === 'error' ? 'Failed' : `${f.progress}%`}
                </span>
              </div>
            ))}
          </div>
        )}

        {/* Upload button */}
        {files.some((f) => f.status === 'pending') && (
          <button
            onClick={uploadAll}
            className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 mb-6"
          >
            Upload All ({files.filter((f) => f.status === 'pending').length} files)
          </button>
        )}

        {/* Real-time processing status */}
        {processingStatus && (
          <div className="mt-6 border rounded-lg p-4">
            <h2 className="font-medium mb-2">Processing Status</h2>
            <p className="text-sm text-gray-600 mb-2">
              {processingStatus.processed} / {processingStatus.total} photos processed
            </p>
            <div className="w-full bg-gray-200 rounded-full h-3">
              <div
                className="bg-green-500 h-3 rounded-full transition-all"
                style={{
                  width: `${processingStatus.total > 0 ? (processingStatus.processed / processingStatus.total) * 100 : 0}%`,
                }}
              />
            </div>
          </div>
        )}
      </main>
    </div>
  );
}

The host dashboard is fully wired up. Next, you will build the attendee experience so people can join events, submit selfies, and discover their photos.

Build the Attendee Experience

Your host dashboard is complete with event management, photo uploading, and analytics. Now it's time to build the other side of the platform.

Attendees need to join events, submit a selfie for facial matching, and browse their personalized photo gallery. This step builds the three pages that make that flow possible.

In this step, get ready to:
  • Create the event join page for attendees to join via link or QR code.
  • Build a selfie capture interface with camera access and file upload fallback.
  • Implement a personalized gallery with lightbox, download, and filtering.
Create the event join flow

When an attendee clicks an event link or scans a QR code, they land on a join page. This page handles account creation (if needed) and registers the attendee as a member of the event.

  • Create the folder structure for the join page by running this command from your frontend directory:
mkdir -p src/app/events/\[id\]/join
  • Create the join page file at frontend/src/app/events/[id]/join/page.tsx and add the following code:
'use client'

import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'

// Event join page: handles account creation and event membership
export default function JoinEventPage() {
  const params = useParams<{ id: string }>()
  const router = useRouter()
  const eventId = params.id

  // Form state for registration/login
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [isRegistering, setIsRegistering] = useState(true)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState('')
  const [eventName, setEventName] = useState('Event')

  // Join the event after authentication
  const handleJoinEvent = async (token: string) => {
    const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/events/${eventId}/join`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      }
    })
    if (!res.ok) {
      const data = await res.json()
      throw new Error(data.detail || 'Failed to join event')
    }
  }

  // Handle form submission for register or login
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setError('')

    try {
      // Register or login based on toggle
      const endpoint = isRegistering ? '/auth/register' : '/auth/login'
      const body = isRegistering
        ? { email, password, role: 'attendee' }
        : { email, password }

      const authRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${endpoint}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body)
      })

      if (!authRes.ok) {
        const data = await authRes.json()
        throw new Error(data.detail || 'Authentication failed')
      }

      const authData = await authRes.json()
      const token = authData.access_token

      // Store token in cookie for subsequent requests
      document.cookie = `token=${token}; path=/; max-age=86400`

      // Join the event with the new token
      await handleJoinEvent(token)

      // Redirect to selfie capture page
      router.push(`/events/${eventId}/selfie`)
    } catch (err: any) {
      setError(err.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
      <div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
        <h1 className="text-2xl font-bold text-center mb-2">Join {eventName}</h1>
        <p className="text-gray-600 text-center mb-6">
          Create an account or sign in to find your photos.
        </p>

        {error && (
          <div className="bg-red-50 text-red-700 p-3 rounded mb-4 text-sm">
            {error}
          </div>
        )}

        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              Email
            </label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full border rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500"
            />
          </div>

          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">
              Password
            </label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              minLength={6}
              className="w-full border rounded-md px-3 py-2 focus:ring-2 focus:ring-blue-500"
            />
          </div>

          <button
            type="submit"
            disabled={loading}
            className="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
          >
            {loading ? 'Joining...' : (isRegistering ? 'Create Account & Join' : 'Sign In & Join')}
          </button>
        </form>

        <p className="text-center text-sm text-gray-600 mt-4">
          {isRegistering ? 'Already have an account?' : 'Need an account?'}{' '}
          <button
            onClick={() => setIsRegistering(!isRegistering)}
            className="text-blue-600 hover:underline"
          >
            {isRegistering ? 'Sign in' : 'Register'}
          </button>
        </p>
      </div>
    </div>
  )
}

What does this code do?

This page serves as the entry point for attendees. When they arrive via a shared link or QR code, they see a simple form.

  • useParams from next/navigation reads the dynamic [id] segment to identify which event to join.
  • The form toggles between registration and login. New attendees create an account with the attendee role. Existing users sign in.
  • After authentication, the page POSTs to /events/{id}/join to create the EventMembership record, then redirects to the selfie capture page.
  • Save page.tsx.
  • Confirm the file exists by running:
ls src/app/events/\[id\]/join/

You should see page.tsx in the output.

Don't see the file?

Make sure you created the folder with the escaped brackets (\[id\]) in the terminal. The square brackets are part of the Next.js dynamic route naming convention.

Help me debug my Next.js dynamic route folder creation

Build the selfie capture interface

After joining, attendees need to submit a selfie so the system can match their face against event photos. The selfie page uses the browser's camera API with a file upload fallback for devices that don't support camera access.

  • Create the selfie page folder by running:
mkdir -p src/app/events/\[id\]/selfie
  • Create frontend/src/app/events/[id]/selfie/page.tsx and add this code:
'use client'

import { useState, useRef, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'

// Selfie capture page with camera access and file upload fallback
export default function SelfiePage() {
  const params = useParams<{ id: string }>()
  const router = useRouter()
  const eventId = params.id

  // Camera and capture state
  const videoRef = useRef<HTMLVideoElement>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const [stream, setStream] = useState<MediaStream | null>(null)
  const [cameraActive, setCameraActive] = useState(false)
  const [capturedImage, setCapturedImage] = useState<string | null>(null)
  const [uploading, setUploading] = useState(false)
  const [error, setError] = useState('')
  const [cameraError, setCameraError] = useState(false)

  // Get auth token from cookie
  const getToken = () => {
    const match = document.cookie.match(/token=([^;]+)/)
    return match ? match[1] : ''
  }

  // Start the camera stream using getUserMedia
  const startCamera = useCallback(async () => {
    try {
      const mediaStream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } },
        audio: false
      })
      setStream(mediaStream)
      setCameraActive(true)
      setCameraError(false)

      // Attach stream to video element
      if (videoRef.current) {
        videoRef.current.srcObject = mediaStream
        videoRef.current.play()
      }
    } catch (err) {
      // Camera not available, show file upload fallback
      setCameraError(true)
      setError('Camera access denied or unavailable. Use file upload instead.')
    }
  }, [])

  // Capture a still frame from the video stream
  const capturePhoto = () => {
    if (!videoRef.current || !canvasRef.current) return

    const video = videoRef.current
    const canvas = canvasRef.current
    canvas.width = video.videoWidth
    canvas.height = video.videoHeight

    // Draw the current video frame to the canvas
    const ctx = canvas.getContext('2d')
    if (ctx) {
      ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
      const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
      setCapturedImage(dataUrl)
    }

    // Stop the camera stream after capture
    if (stream) {
      stream.getTracks().forEach(track => track.stop())
      setCameraActive(false)
    }
  }

  // Handle file upload as fallback
  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    const reader = new FileReader()
    reader.onloadend = () => {
      setCapturedImage(reader.result as string)
    }
    reader.readAsDataURL(file)
  }

  // Convert base64 data URL to a File object for upload
  const dataUrlToFile = (dataUrl: string, filename: string): File => {
    const arr = dataUrl.split(',')
    const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/jpeg'
    const bstr = atob(arr[1])
    let n = bstr.length
    const u8arr = new Uint8Array(n)
    while (n--) {
      u8arr[n] = bstr.charCodeAt(n)
    }
    return new File([u8arr], filename, { type: mime })
  }

  // Submit the selfie to the match endpoint
  const submitSelfie = async () => {
    if (!capturedImage) return
    setUploading(true)
    setError('')

    try {
      const file = dataUrlToFile(capturedImage, 'selfie.jpg')
      const formData = new FormData()
      formData.append('file', file)

      // POST to the match-selfie endpoint
      const res = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/events/${eventId}/match-selfie`,
        {
          method: 'POST',
          headers: { 'Authorization': `Bearer ${getToken()}` },
          body: formData
        }
      )

      if (!res.ok) {
        const data = await res.json()
        throw new Error(data.detail || 'Failed to match selfie')
      }

      // Navigate to gallery with matched results
      router.push(`/events/${eventId}/gallery`)
    } catch (err: any) {
      setError(err.message)
    } finally {
      setUploading(false)
    }
  }

  // Reset to retake the selfie
  const retake = () => {
    setCapturedImage(null)
    if (!cameraError) {
      startCamera()
    }
  }

  return (
    <div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center px-4">
      <div className="max-w-lg w-full bg-white rounded-lg shadow-md p-6">
        <h1 className="text-2xl font-bold text-center mb-2">Find Your Photos</h1>
        <p className="text-gray-600 text-center mb-6">
          Take a selfie or upload a photo so we can find you in event photos.
        </p>

        {error && (
          <div className="bg-red-50 text-red-700 p-3 rounded mb-4 text-sm">
            {error}
          </div>
        )}

        {/* Camera preview or captured image */}
        {!capturedImage ? (
          <div className="space-y-4">
            {cameraActive && (
              <div className="relative">
                <video
                  ref={videoRef}
                  autoPlay
                  playsInline
                  muted
                  className="w-full rounded-lg bg-black"
                />
                <button
                  onClick={capturePhoto}
                  className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white rounded-full w-16 h-16 border-4 border-blue-600 hover:border-blue-700"
                  aria-label="Capture photo"
                />
              </div>
            )}

            {!cameraActive && (
              <div className="space-y-3">
                {!cameraError && (
                  <button
                    onClick={startCamera}
                    className="w-full bg-blue-600 text-white py-3 rounded-md hover:bg-blue-700"
                  >
                    Open Camera
                  </button>
                )}

                {/* File upload fallback */}
                <div className="text-center">
                  <p className="text-sm text-gray-500 mb-2">
                    {cameraError ? 'Upload a photo instead:' : 'Or upload a photo:'}
                  </p>
                  <label className="cursor-pointer inline-block bg-gray-100 border border-gray-300 rounded-md px-4 py-2 text-sm hover:bg-gray-200">
                    Choose File
                    <input
                      type="file"
                      accept="image/*"
                      capture="user"
                      onChange={handleFileUpload}
                      className="hidden"
                    />
                  </label>
                </div>
              </div>
            )}
          </div>
        ) : (
          <div className="space-y-4">
            <img
              src={capturedImage}
              alt="Captured selfie"
              className="w-full rounded-lg"
            />
            <div className="flex gap-3">
              <button
                onClick={retake}
                className="flex-1 border border-gray-300 py-2 rounded-md hover:bg-gray-50"
              >
                Retake
              </button>
              <button
                onClick={submitSelfie}
                disabled={uploading}
                className="flex-1 bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
              >
                {uploading ? 'Matching...' : 'Find My Photos'}
              </button>
            </div>
          </div>
        )}

        {/* Hidden canvas for capturing video frames */}
        <canvas ref={canvasRef} className="hidden" />
      </div>
    </div>
  )
}

What does this code do?

This component manages two paths to get a selfie image from the attendee.

  • navigator.mediaDevices.getUserMedia requests the front-facing camera. The video stream is attached to a <video> element for a live preview. When the user clicks capture, the current frame is drawn to a hidden <canvas> and converted to JPEG.
  • If camera access is denied or unavailable (common on desktop or in non-HTTPS contexts), the component falls back to a file input with accept="image/*" and capture="user" which opens the front camera on mobile devices.
  • The captured image (from either path) is converted to a File object and submitted via FormData to POST /events/{id}/match-selfie. The backend detects the face, generates an embedding, searches Qdrant, and returns matches.
  • Save page.tsx.
  • Confirm the file exists by running:
ls src/app/events/\[id\]/selfie/

You should see page.tsx listed.

Camera not working in development?

The getUserMedia API requires a secure context. In development, localhost counts as secure, so camera access works. If you access the dev server via a local IP address (like 192.168.x.x), the browser blocks camera access. Stick to localhost:3000 for testing.

Help me debug camera access on my selfie page

Implement the personalized gallery

Once the selfie is matched, attendees see a gallery of every event photo containing their face. This page includes a grid view, fullscreen lightbox, individual downloads, a batch ZIP download, confidence filtering, and sorting options.

  • Create the gallery page folder by running:
mkdir -p src/app/events/\[id\]/gallery
  • Create frontend/src/app/events/[id]/gallery/page.tsx and add this code:
'use client'

import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'next/navigation'

// Type for a matched photo result
interface MatchedPhoto {
  id: number
  url: string
  confidence: number
  created_at: string
}

// Personalized gallery with grid, lightbox, download, ZIP, filtering, sorting
export default function GalleryPage() {
  const params = useParams<{ id: string }>()
  const eventId = params.id

  // Gallery state
  const [photos, setPhotos] = useState<MatchedPhoto[]>([])
  const [filteredPhotos, setFilteredPhotos] = useState<MatchedPhoto[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState('')

  // Filter and sort controls
  const [minConfidence, setMinConfidence] = useState(0.5)
  const [sortBy, setSortBy] = useState<'date' | 'confidence'>('confidence')
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')

  // Lightbox state
  const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
  const [downloadingZip, setDownloadingZip] = useState(false)

  // Get auth token from cookie
  const getToken = () => {
    const match = document.cookie.match(/token=([^;]+)/)
    return match ? match[1] : ''
  }

  // Fetch matched photos from the gallery endpoint
  useEffect(() => {
    const fetchPhotos = async () => {
      try {
        const res = await fetch(
          `${process.env.NEXT_PUBLIC_API_URL}/events/${eventId}/gallery`,
          { headers: { 'Authorization': `Bearer ${getToken()}` } }
        )
        if (!res.ok) throw new Error('Failed to load gallery')
        const data = await res.json()
        setPhotos(data.photos || [])
      } catch (err: any) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }
    fetchPhotos()
  }, [eventId])

  // Apply filtering and sorting whenever photos or controls change
  useEffect(() => {
    let result = photos.filter(p => p.confidence >= minConfidence)

    result.sort((a, b) => {
      const aVal = sortBy === 'date' ? new Date(a.created_at).getTime() : a.confidence
      const bVal = sortBy === 'date' ? new Date(b.created_at).getTime() : b.confidence
      return sortOrder === 'desc' ? bVal - aVal : aVal - bVal
    })

    setFilteredPhotos(result)
  }, [photos, minConfidence, sortBy, sortOrder])

  // Download a single photo
  const downloadPhoto = async (photo: MatchedPhoto) => {
    const response = await fetch(photo.url)
    const blob = await response.blob()
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `event-photo-${photo.id}.jpg`
    a.click()
    URL.revokeObjectURL(url)
  }

  // Download all matched photos as ZIP
  const downloadAllZip = async () => {
    setDownloadingZip(true)
    try {
      const res = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/events/${eventId}/gallery/download-zip`,
        { headers: { 'Authorization': `Bearer ${getToken()}` } }
      )
      if (!res.ok) throw new Error('Failed to download ZIP')
      const blob = await res.blob()
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = `event-${eventId}-photos.zip`
      a.click()
      URL.revokeObjectURL(url)
    } catch (err: any) {
      setError(err.message)
    } finally {
      setDownloadingZip(false)
    }
  }

  // Navigate lightbox with keyboard
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
    if (lightboxIndex === null) return
    if (e.key === 'Escape') setLightboxIndex(null)
    if (e.key === 'ArrowRight' && lightboxIndex < filteredPhotos.length - 1) {
      setLightboxIndex(lightboxIndex + 1)
    }
    if (e.key === 'ArrowLeft' && lightboxIndex > 0) {
      setLightboxIndex(lightboxIndex - 1)
    }
  }, [lightboxIndex, filteredPhotos.length])

  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [handleKeyDown])

  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <p className="text-gray-500">Loading your photos...</p>
      </div>
    )
  }

  return (
    <div className="min-h-screen bg-gray-50 p-6">
      <div className="max-w-6xl mx-auto">
        {/* Header with controls */}
        <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 gap-4">
          <div>
            <h1 className="text-2xl font-bold">Your Photos</h1>
            <p className="text-gray-600">
              {filteredPhotos.length} photo{filteredPhotos.length !== 1 ? 's' : ''} found
            </p>
          </div>

          <button
            onClick={downloadAllZip}
            disabled={downloadingZip || filteredPhotos.length === 0}
            className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
          >
            {downloadingZip ? 'Preparing ZIP...' : 'Download All'}
          </button>
        </div>

        {/* Filter and sort controls */}
        <div className="bg-white rounded-lg shadow-sm p-4 mb-6 flex flex-wrap gap-4 items-center">
          <div className="flex items-center gap-2">
            <label className="text-sm font-medium text-gray-700">Min confidence:</label>
            <input
              type="range"
              min="0"
              max="1"
              step="0.05"
              value={minConfidence}
              onChange={(e) => setMinConfidence(parseFloat(e.target.value))}
              className="w-24"
            />
            <span className="text-sm text-gray-600">{Math.round(minConfidence * 100)}%</span>
          </div>

          <div className="flex items-center gap-2">
            <label className="text-sm font-medium text-gray-700">Sort by:</label>
            <select
              value={sortBy}
              onChange={(e) => setSortBy(e.target.value as 'date' | 'confidence')}
              className="border rounded px-2 py-1 text-sm"
            >
              <option value="confidence">Confidence</option>
              <option value="date">Date</option>
            </select>
          </div>

          <div className="flex items-center gap-2">
            <label className="text-sm font-medium text-gray-700">Order:</label>
            <select
              value={sortOrder}
              onChange={(e) => setSortOrder(e.target.value as 'asc' | 'desc')}
              className="border rounded px-2 py-1 text-sm"
            >
              <option value="desc">High to Low</option>
              <option value="asc">Low to High</option>
            </select>
          </div>
        </div>

        {error && (
          <div className="bg-red-50 text-red-700 p-3 rounded mb-4">{error}</div>
        )}

        {/* Photo grid */}
        {filteredPhotos.length === 0 ? (
          <div className="text-center py-12">
            <p className="text-gray-500">No photos match the current filter.</p>
            <p className="text-gray-400 text-sm mt-1">Try lowering the confidence threshold.</p>
          </div>
        ) : (
          <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
            {filteredPhotos.map((photo, index) => (
              <div
                key={photo.id}
                className="relative group rounded-lg overflow-hidden shadow-sm bg-white cursor-pointer"
                onClick={() => setLightboxIndex(index)}
              >
                <img
                  src={photo.url}
                  alt={`Event photo ${photo.id}`}
                  className="w-full h-48 object-cover"
                />
                {/* Confidence badge */}
                <div className="absolute top-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
                  {Math.round(photo.confidence * 100)}%
                </div>
                {/* Download button on hover */}
                <button
                  onClick={(e) => { e.stopPropagation(); downloadPhoto(photo) }}
                  className="absolute bottom-2 right-2 bg-white/90 text-gray-800 text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"
                >
                  Download
                </button>
              </div>
            ))}
          </div>
        )}

        {/* Fullscreen lightbox */}
        {lightboxIndex !== null && filteredPhotos[lightboxIndex] && (
          <div
            className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
            onClick={() => setLightboxIndex(null)}
          >
            <button
              onClick={() => setLightboxIndex(null)}
              className="absolute top-4 right-4 text-white text-2xl hover:text-gray-300"
            >
              \u00d7
            </button>

            {/* Previous arrow */}
            {lightboxIndex > 0 && (
              <button
                onClick={(e) => { e.stopPropagation(); setLightboxIndex(lightboxIndex - 1) }}
                className="absolute left-4 text-white text-4xl hover:text-gray-300"
              >
                \u2039
              </button>
            )}

            <img
              src={filteredPhotos[lightboxIndex].url}
              alt={`Photo ${filteredPhotos[lightboxIndex].id}`}
              className="max-h-[90vh] max-w-[90vw] object-contain"
              onClick={(e) => e.stopPropagation()}
            />

            {/* Next arrow */}
            {lightboxIndex < filteredPhotos.length - 1 && (
              <button
                onClick={(e) => { e.stopPropagation(); setLightboxIndex(lightboxIndex + 1) }}
                className="absolute right-4 text-white text-4xl hover:text-gray-300"
              >
                \u203a
              </button>
            )}

            {/* Download from lightbox */}
            <button
              onClick={(e) => {
                e.stopPropagation()
                downloadPhoto(filteredPhotos[lightboxIndex!])
              }}
              className="absolute bottom-4 bg-white text-gray-800 px-4 py-2 rounded-md hover:bg-gray-100"
            >
              Download This Photo
            </button>
          </div>
        )}
      </div>
    </div>
  )
}

What does this code do?

This gallery component fetches the attendee's matched photos and presents them with full interactivity.

  • The confidence slider filters out low-confidence matches in real time. Photos below the threshold disappear from the grid without a server round-trip.
  • Clicking a photo opens a fullscreen lightbox. Keyboard navigation (left/right arrows, Escape) is handled via a keydown event listener.
  • Individual downloads use URL.createObjectURL to create a temporary blob link. The "Download All" button calls the backend's ZIP endpoint which bundles all matched photos server-side.
  • Save page.tsx.
  • Verify all three attendee pages exist by running:
ls src/app/events/\[id\]/*/page.tsx

You should see three files listed: one each in join, selfie, and gallery.

Seeing a 404 when navigating to the gallery?

Make sure the Next.js dev server is running. Dynamic routes with [id] segments only resolve when the server is active. Restart it with npm run dev if needed.

Help me fix my 404 error on the gallery page

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Here are the complete files for reference.

frontend/src/app/events/[id]/join/page.tsx:

// Event join flow: join via link/QR, account creation, event membership

frontend/src/app/events/[id]/selfie/page.tsx:

// Selfie capture with camera access and file upload fallback

frontend/src/app/events/[id]/gallery/page.tsx:

// Personalized gallery: grid view, fullscreen lightbox, download, ZIP, filtering, sorting

The attendee experience is complete. Attendees can join events, submit selfies, and browse a personalized gallery. Next up, you'll set up the AWS infrastructure to deploy everything to the cloud.

Set Up AWS Infrastructure

Your full-stack application is complete and working locally. The backend API processes photos, generates face embeddings, and stores them in Qdrant. The frontend lets hosts upload photos and attendees find themselves via selfie matching.

Now you need a place on the internet for all of this to run. In this step, you will provision the AWS infrastructure your production deployment depends on: a managed database, object storage for photos, a compute instance for the backend, and a CDN for the frontend.

In this step, get ready to:
  • Create an RDS PostgreSQL instance, S3 bucket, and IAM user with proper permissions.
  • Launch an EC2 instance with Docker and Docker Compose installed.
  • Set up an Amplify app connected to your frontend GitHub repository.
Create the RDS PostgreSQL instance, S3 bucket, and IAM user

Your application needs three core AWS resources: a relational database to persist user accounts and event metadata, an S3 bucket to store uploaded photos, and an IAM user whose credentials your backend uses to access these services.

Create the RDS PostgreSQL database

Amazon RDS manages database backups, patching, and availability so you can focus on building features instead of maintaining servers.

  • Open the AWS Management Console and navigate to the RDS service.
  • Click Create database.
  • Select Standard create and choose PostgreSQL as the engine type.
  • Under Templates, select Free tier.
  • In Settings, set the DB instance identifier to eventphotos-db.
  • Set the master username to postgres and choose a strong password. Write it down.
  • Under Instance configuration, confirm the instance class is db.t4g.micro.
  • Under Additional configuration, set the initial database name to eventphotos.
  • Leave all other settings as default and click Create database.

Why db.t4g.micro?

The db.t4g.micro instance class qualifies for the RDS free plan on accounts created after July 15, 2025. This gives you a managed PostgreSQL database at no cost for 6 months.

The database takes 5-10 minutes to become available. While it creates, move on to the S3 bucket.

Create the S3 bucket for photo storage

Amazon S3 provides durable, scalable object storage for your event photos. The backend will upload photos here and serve them to attendees via presigned URLs.

  • Find your AWS account ID by clicking your username in the top-right corner of the console. Note the 12-digit account ID.
  • Open a terminal on your local machine and create the S3 bucket by running this command:
aws s3api create-bucket --bucket eventphotos-media-[[AWS_ACCOUNT_ID="your-12-digit-account-id"]] --region us-east-1

Why include your account ID in the bucket name?

S3 bucket names must be globally unique across all AWS accounts. Including your account ID guarantees no naming collision with other learners.

Now configure CORS so your frontend can upload photos directly to this bucket. Create a file called cors.json with the following content:

{
  "CORSRules": [
    {
      "AllowedOrigins": ["*"],
      "AllowedHeaders": ["*"],
      "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
      "MaxAgeSeconds": 3000
    }
  ]
}
  • Apply the CORS configuration to your bucket by running:
aws s3api put-bucket-cors --bucket eventphotos-media-[[AWS_ACCOUNT_ID="your-12-digit-account-id"]] --cors-configuration file://cors.json

What does CORS do here?

CORS (Cross-Origin Resource Sharing) tells S3 which domains are allowed to make requests to your bucket from a browser. Setting AllowedOrigins to * allows your Amplify frontend (whose final URL you do not know yet) to upload and retrieve photos. You can restrict this to your exact domain after deployment.

  • Verify the CORS configuration was applied by running:
aws s3api get-bucket-cors --bucket eventphotos-media-[[AWS_ACCOUNT_ID="your-12-digit-account-id"]]

You should see the JSON rules you just applied printed back in the terminal.

Create an IAM user for backend access

Your backend needs programmatic access to S3 and RDS. An IAM user with narrowly-scoped permissions keeps your application secure.

  • Navigate to the IAM service in the AWS Console.
  • Click Users in the left sidebar, then Create user.
  • Set the username to eventphotos-backend and click Next.
  • Select Attach policies directly.
  • Search for and attach AmazonS3FullAccess.
  • Also search for and attach AmazonRDSFullAccess.
  • Click Next, review, then click Create user.
  • Click the newly created user, go to the Security credentials tab, and click Create access key.
  • Select Application running outside AWS and click Next.
  • Copy the Access Key ID and Secret Access Key. Store them securely. You will not be able to see the secret key again.

Store your credentials securely

Never commit AWS credentials to version control. Write the Access Key ID and Secret Access Key in a secure location (password manager, encrypted note). You will add them to your production environment file shortly.

Now check that your RDS instance has finished creating. Return to the RDS console and click on your eventphotos-db instance. Copy the Endpoint value (it looks like eventphotos-db.xxxxx.us-east-1.rds.amazonaws.com).

Launch an EC2 instance and install Docker

Your backend, Celery worker, Redis, and Qdrant all run as Docker containers on a single EC2 instance. The t3.small provides 2 vCPUs and 2 GiB of RAM, enough to run all services for a demo workload.

  • Navigate to the EC2 service in the AWS Console.
  • Click Launch instances.
  • Name the instance eventphotos-backend.
  • Under Application and OS Images, select Amazon Linux 2023 (it should be the default).
  • Under Instance type, select t3.small.
  • Under Key pair, click Create new key pair. Name it eventphotos-key, keep the .pem format, and click Create key pair. The file downloads automatically.
  • Under Network settings, click Edit.
  • Ensure Auto-assign public IP is set to Enable.
  • Under Firewall (security groups), select Create security group.
  • Add the following inbound rules by clicking Add security group rule for each:
  • Type: SSH, Port: 22, Source: My IP.
  • Type: HTTP, Port: 80, Source: Anywhere.
  • Type: HTTPS, Port: 443, Source: Anywhere.
  • Type: Custom TCP, Port: 8000, Source: Anywhere. This is for your FastAPI server.
  • Under Configure storage, set the root volume to 30 GiB gp3.
  • Click Launch instance.

Wait for the instance state to show Running. Then copy the Public IPv4 DNS address from the instance details.

Connect to your EC2 instance and install Docker
  • Open a terminal and set permissions on your key file by running:
chmod 400 [[PEMPATH="path/to/eventphotos-key.pem"]]
  • SSH into your EC2 instance by running:
ssh -i [[PEMPATH="path/to/eventphotos-key.pem"]] ec2-user@[[EC2_PUBLIC_DNS="your-ec2-public-dns.amazonaws.com"]]
  • Update the system packages by running:
sudo yum update -y
  • Install Docker by running:
sudo yum install -y docker
  • Start the Docker service and enable it to start on boot by running:
sudo service docker start
sudo systemctl enable docker
  • Add your user to the docker group so you can run Docker commands without sudo:
sudo usermod -a -G docker ec2-user
  • Log out and reconnect for the group change to take effect:
exit
  • Reconnect via SSH using the same command from earlier.
  • Install Docker Compose by downloading the plugin manually:
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 -o /usr/local/lib/docker/cli-plugins/docker-compose
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
  • Verify both Docker and Docker Compose are installed by running:
docker info
docker compose version

You should see Docker system info and a Docker Compose version number printed in the terminal.

Seeing 'permission denied' when running docker?

Make sure you logged out and reconnected after adding your user to the docker group. The group membership only takes effect in a new SSH session.

If it still does not work, try rebooting the instance with sudo reboot and reconnecting after a minute.

Still stuck? Help me troubleshoot Docker permissions on my EC2 instance

Set up the Amplify app for frontend deployment

AWS Amplify hosts your Next.js frontend with server-side rendering support, a CDN, and automatic deployments from GitHub. It supports Next.js versions 12 through 15.

  • Before setting up Amplify, push your frontend code to a GitHub repository if you have not already done so.
  • Navigate to the AWS Amplify service in the AWS Console.
  • Click Create new app.
  • Select GitHub as the source and authorize AWS Amplify to access your GitHub account.
  • Select the repository containing your frontend code and choose the main branch.
  • Amplify will auto-detect your Next.js framework. Review the build settings and click Next.
  • Click Save and deploy.

Why Amplify for the frontend?

Amplify natively supports Next.js SSR (server-side rendering) via compute-based hosting. It automatically provisions a CDN, handles SSL certificates, and redeploys on every push to your GitHub branch.

The free tier includes 500,000 SSR requests and 15 GB data transfer per month. For a portfolio project, you will stay well within these limits.

The first deployment will fail because you have not configured the backend API URL as an environment variable yet. That is expected. You will configure this in the next step after deploying your backend.

Document all credentials in a production environment file

Gather all the resource endpoints and credentials you created into a single .env.production file. This file stays on your local machine and your EC2 instance. Never commit it to Git.

  • In your project root on your local machine, create a file called .env.production with the following structure:
# Database
RDS_URL=postgresql://postgres:your-rds-password@your-rds-endpoint.us-east-1.rds.amazonaws.com:5432/eventphotos

# S3
S3_BUCKET=eventphotos-media-your-account-id

# AWS credentials for the backend IAM user
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key

# Auth
JWT_SECRET=your-random-secret-string-here

# Qdrant (running on same EC2 instance)
QDRANT_URL=http://localhost:6333

What does each variable do?

  • RDS_URL connects your FastAPI app to the PostgreSQL database on RDS.
  • S3_BUCKET tells your backend where to upload and retrieve event photos.
  • AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY authenticate your backend to AWS services.
  • JWT_SECRET is used to sign authentication tokens. Generate a random string (32+ characters).
  • QDRANT_URL points to the Qdrant vector database running in Docker on the same EC2 instance.
  • Replace each placeholder with the actual values you noted during this step.
  • Make sure .env.production is listed in your .gitignore file.

Cannot connect to your RDS instance later?

By default, RDS instances are only accessible from within the same VPC. Your EC2 instance must be in the same VPC and security group, or the RDS security group must allow inbound traffic from your EC2 instance's security group on port 5432.

Check that the RDS security group has an inbound rule allowing PostgreSQL (port 5432) from your EC2 instance's security group.

Need help? Help me configure my RDS security group to allow connections from my EC2 instance

Your AWS infrastructure is now in place. In the next step, you will deploy your backend application to this EC2 instance using Docker Compose and wire everything together.

Deploy the Backend to EC2

Your AWS infrastructure is provisioned and waiting. The EC2 instance has Docker and Docker Compose installed, RDS is accepting connections, and S3 is ready for photo storage.

Now you need to get the actual application running on that infrastructure. This step bridges local development to a live production deployment by containerizing the backend stack and exposing it through a reverse proxy.

In this step, get ready to:
  • Create a production Docker Compose file with resource limits for EC2.
  • Configure production environment variables and deploy containers to EC2.
  • Set up Nginx as a reverse proxy and verify the API is publicly accessible.
Create the production Docker Compose file

The t3.small instance has only 2 GB of RAM. Every service you run on it needs explicit memory limits to prevent one runaway process from crashing the entire stack.

  • In the root of your project (alongside docker-compose.dev.yml), create a new file called docker-compose.prod.yml by pasting the following:
version: '3.8'
services:
  fastapi:
    build: ./backend
    ports:
      - '8000:8000'
    env_file: .env.production
    depends_on:
      - redis
      - qdrant
    deploy:
      resources:
        limits:
          memory: 1g
  celery-worker:
    build: ./backend
    command: celery -A app.tasks worker --loglevel=info
    env_file: .env.production
    depends_on:
      - redis
    deploy:
      resources:
        limits:
          memory: 1g
  redis:
    image: redis:7-alpine
  qdrant:
    image: qdrant/qdrant:latest
    volumes:
      - qdrant_data:/qdrant/storage
    deploy:
      resources:
        limits:
          memory: 512m
volumes:
  qdrant_data:

What does this configuration do?

This file defines four services that run together on your EC2 instance. Each service has a specific role in the platform.

  • fastapi serves the API on port 8000. It reads credentials from .env.production and is capped at 1 GB of RAM.
  • celery-worker processes photos in the background using InsightFace. It also gets 1 GB because face detection is memory-intensive.
  • redis acts as the message broker between FastAPI and the Celery worker.
  • qdrant stores face embeddings for vector similarity search, capped at 512 MB with persistent disk storage.

Why no PostgreSQL container?

Unlike the dev setup, production uses AWS RDS for PostgreSQL. This gives you automatic backups, patching, and the RDS free plan. The connection string in .env.production points FastAPI directly to the RDS endpoint.

  • Save docker-compose.prod.yml.

Unsure about the memory limits?

The t3.small has 2 GB total. These limits (1 GB + 1 GB + 512 MB) intentionally over-subscribe slightly because the Celery worker only uses peak memory during active inference. If you see OOM errors later, reduce the Celery worker limit to 768 MB.

Help me understand Docker Compose memory limits for my EC2 instance.

Configure environment variables and deploy to EC2

Your EC2 instance needs the production environment file and your project code. You will transfer everything via scp and then launch the containers.

  • SSH into your EC2 instance by running this command:
ssh -i [[PEMPATH="path/to/your-key.pem"]] ec2-user@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
  • Create a project directory on the EC2 instance by running:
mkdir -p ~/eventphotos && cd ~/eventphotos
  • Open a second terminal on your local machine. Transfer your project files to EC2 by running:
scp -i [[PEMPATH="path/to/your-key.pem"]] -r backend/ docker-compose.prod.yml .env.production ec2-user@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]:~/eventphotos/

What is scp?

scp (Secure Copy Protocol) transfers files over SSH. This command sends your backend/ folder, the production Compose file, and the .env.production file to your EC2 instance in one go.

  • Switch back to the SSH terminal on EC2. Navigate to the project directory by running:
cd ~/eventphotos
  • Add a swap file to handle memory spikes during InsightFace model loading by running:
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

Why add swap?

InsightFace's buffalo_l model needs a burst of memory when it first loads into RAM. The 2 GB swap file acts as overflow. Without it, the first photo processing task could crash the Celery worker with an out-of-memory error.

  • Launch the production stack by running:
docker compose -f docker-compose.prod.yml up -d --build
  • Verify all four containers are running by running:
docker compose -f docker-compose.prod.yml ps

You should see four services listed with a status of "running" or "Up".

  • Run the database migrations against your RDS instance by running:
docker compose -f docker-compose.prod.yml exec fastapi alembic upgrade head

What does alembic upgrade head do?

Alembic is the migration tool for SQLAlchemy. This command applies all pending schema changes (creating your Users, Events, Photos, FaceRecords, and EventMemberships tables) to the production RDS database.

Seeing a connection refused error?

Check that your RDS security group allows inbound connections from the EC2 instance's security group on port 5432. Also verify the RDS_URL in .env.production uses the full RDS endpoint (not localhost).

Help me troubleshoot the connection between my EC2 Docker container and RDS PostgreSQL.

Set up Nginx as a reverse proxy

Your FastAPI container listens on port 8000, but users expect to reach the API on the standard HTTP port 80. Nginx sits in front of your container and forwards traffic.

  • Install Nginx on the EC2 instance by running:
sudo dnf install nginx -y
  • Create a custom Nginx configuration file by running:
sudo tee /etc/nginx/conf.d/eventphotos.conf > /dev/null << 'EOF'
server {
    listen 80;
    location / {
        proxy_pass http://localhost:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
EOF

What does this Nginx config do?

This server block tells Nginx to listen on port 80 (the standard HTTP port) and forward every request to your FastAPI container on port 8000. The proxy_set_header lines pass the original client IP and hostname through to your application.

  • Remove the default Nginx server block that conflicts with port 80 by running:
sudo rm -f /etc/nginx/conf.d/default.conf
  • Start Nginx and enable it to run on boot by running:
sudo systemctl start nginx
sudo systemctl enable nginx
  • Verify the API is accessible on port 80 by running:
curl http://localhost/docs

You should see the HTML for the FastAPI interactive docs page (Swagger UI). This confirms Nginx is forwarding traffic correctly.

  • Now test from your local machine's browser. Open your browser and navigate to:
http://[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]/docs

You should see the FastAPI Swagger UI with all your API endpoints listed.

Can't reach the API from your browser?

Check that your EC2 security group allows inbound traffic on port 80 from 0.0.0.0/0. Also verify Nginx is running with sudo systemctl status nginx. If it shows errors, check the config syntax with sudo nginx -t.

Help me troubleshoot why I can't access my EC2 API from the browser.

Your backend is now live on AWS. The full stack is running in Docker containers, the database schema is applied to RDS, and the API is publicly accessible through Nginx. Next up, you will deploy the frontend and test the entire platform end-to-end.

Deploy the Frontend and Test End-to-End

Your backend is live on EC2, processing photos and serving the API through Nginx. Now it is time to deploy the Next.js frontend so real users can interact with the platform from a browser.

In this step, you will configure the frontend for production, deploy it to AWS Amplify, and verify the entire system works end-to-end. You will also create start/stop scripts to manage costs when you are not actively using the platform.

In this step, get ready to:
  • Configure the Next.js frontend for production with Amplify environment variables and build settings.
  • Deploy to Amplify and verify the full end-to-end flow from registration through photo matching.
  • Create start/stop scripts for EC2 cost management and document monthly costs.
Configure Next.js for production with Amplify

Your frontend code references NEXT_PUBLIC_API_URL to know where the backend lives. In production, this needs to point to your EC2 instance's public IP.

Amplify builds your Next.js app on its own servers, so environment variables must be configured in both the Amplify console and the build specification.

  • In the frontend project, open next.config.ts and update it with the production configuration:
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // Allow loading images from S3
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '*.s3.amazonaws.com',
      },
    ],
  },
}

export default nextConfig

What does this config do?

The remotePatterns entry tells Next.js to allow loading images from your S3 bucket. Without this, the Next.js Image component would reject external image URLs from S3.

The NEXT_PUBLIC_API_URL environment variable is read directly by your frontend code via process.env.NEXT_PUBLIC_API_URL and does not need to be declared in this file. You will set it in the Amplify console next.

  • Save next.config.ts.
  • Return to the AWS Amplify console and select your app.
  • In the left navigation pane, choose Hosting, then choose Environment variables.
  • Click Manage variables.
  • For Variable, enter NEXT_PUBLIC_API_URL.
  • For Value, enter http:// followed by your EC2 instance's public IPv4 address (for example, http://54.123.45.67).
  • Click Save.

Why HTTP and not HTTPS for the backend?

Your EC2 backend is served over HTTP on port 80 via Nginx. Setting up HTTPS on EC2 requires a domain name and SSL certificate, which is beyond the scope of this project. The Amplify frontend itself is served over HTTPS automatically.

Next, configure the Amplify build specification so the environment variable is accessible to Next.js at build time.

  • In the Amplify console navigation pane, choose Hosting, then choose Build settings.
  • In the App build specification section, click Edit.
  • Replace the build spec with the following:
version: 1
frontend:
  phases:
    preBuild:
      commands:
        - npm ci
    build:
      commands:
        - env | grep -e NEXT_PUBLIC_ >> .env.production
        - npm run build
  artifacts:
    baseDirectory: .next
    files:
      - '**/*'
  cache:
    paths:
      - node_modules/**/*
      - .next/cache/**/*

What does this build spec do?

The key line is env | grep -e NEXT_PUBLIC_ >> .env.production. This writes all NEXT_PUBLIC_-prefixed environment variables into a .env.production file before the build runs.

Next.js server components do not have access to Amplify build-time environment variables by default. Writing them to .env.production makes them available at both build time and runtime.

The baseDirectory: .next tells Amplify where to find the SSR build artifacts for deployment.

  • Click Save.

Build spec editor not showing?

The build settings page is only visible for apps connected to a Git repository with continuous deployment enabled. If you do not see it, verify your Amplify app is connected to the frontend GitHub repository from the previous step.

help me find the build settings in Amplify

Deploy to Amplify and verify the full flow

With the environment variables and build spec configured, pushing your frontend code to GitHub triggers an automatic deployment on Amplify.

  • Commit and push all frontend changes to your GitHub repository by running these commands from the frontend directory:
git add .
git commit -m "Configure production build for Amplify"
git push origin main
  • Return to the Amplify console. You should see a new build starting automatically.
  • Wait for the build to complete. This typically takes 1 to 2 minutes.
  • Once the build status shows Deployed, click the URL displayed (it looks like https://main.d1abc2def3.amplifyapp.com).

What should I see?

Your landing page should load in the browser with the hero section, feature highlights, and a call-to-action button. This confirms the frontend is deployed and accessible on the public internet.

Now verify the entire platform works end-to-end by walking through the complete user journey.

  • Click the registration link and create a new account with the Host role.
  • Create a new event from the host dashboard.
  • Navigate to the upload center for your event and upload 3 to 5 test photos containing faces.
  • Watch the processing status update as photos are processed by the Celery worker on EC2.
  • Open a new incognito/private browser window.
  • Register a second account with the Attendee role.
  • Join the event using the QR code or invite link from the host dashboard.
  • Submit a selfie (use a photo of someone who appears in your uploaded test photos).
  • View the personalized gallery showing matched photos.

Use real face photos for testing

InsightFace needs clear, front-facing photos to detect faces reliably. Use photos where faces are visible and well-lit for the best matching results during your test.

Photos stuck in processing?

SSH into your EC2 instance and check the Celery worker logs. The most common issues are: the worker ran out of memory (check the swap file is active), or the Qdrant container restarted (check with docker compose ps).

help me debug why my photo processing is stuck

Create start/stop scripts for EC2 cost management

Your EC2 instance costs approximately $0.02 per hour when running. To keep monthly costs near $2.40 (just the EBS volume), you should stop the instance when you are not actively using the platform.

  • In your project root, create a scripts directory by running:
mkdir -p scripts
  • Create the start script by running:
cat > scripts/start-infra.sh << 'EOF'
#!/bin/bash
aws ec2 start-instances --instance-ids [[EC2_INSTANCE_ID="your EC2 instance ID"]]
echo 'EC2 instance starting...'
EOF
chmod +x scripts/start-infra.sh
  • Create the stop script by running:
cat > scripts/stop-infra.sh << 'EOF'
#!/bin/bash
aws ec2 stop-instances --instance-ids [[EC2_INSTANCE_ID="your EC2 instance ID"]]
echo 'EC2 instance stopped.'
EOF
chmod +x scripts/stop-infra.sh

Where do I find my instance ID?

In the AWS EC2 console, select your running instance. The instance ID is displayed in the details panel and looks like i-0abc123def456789. Replace the placeholder in both scripts with your actual instance ID.

  • Create a docs directory and the cost breakdown file by running:
mkdir -p docs
cat > docs/cost-breakdown.md << 'EOF'
# Monthly Cost Breakdown
- EC2 t3.small: $0.02/hr when running, $0 stopped
- RDS db.t4g.micro: free plan (6 months)
- S3: ~$0.023/GB stored
- Amplify: free tier (500K requests, 15GB transfer)
- EBS 30GB gp3: ~$2.40/month
- Estimated: ~$2.40/month baseline + $0.02/hr active
EOF

What does this cost breakdown mean?

When your EC2 instance is stopped, you only pay for the EBS volume ($2.40/month). The RDS database is on the free plan for 6 months. Amplify hosting falls within the free tier for typical demo usage.

If you run the instance 4 hours per day for development, expect roughly $5 per month total. Use the stop script whenever you finish a demo session.

  • Test the stop script to shut down your EC2 instance by running:
./scripts/stop-infra.sh
  • In the EC2 console, verify the instance state changes to Stopping and then Stopped.
  • Test the start script to bring it back up by running:
./scripts/start-infra.sh
  • Verify the instance state returns to Running in the EC2 console.

Scripts failing with access denied?

Make sure your local AWS CLI is configured with credentials that have EC2 start/stop permissions. Run aws sts get-caller-identity to confirm which IAM user is active.

help me fix AWS CLI permissions for EC2 start/stop

  • Commit the scripts and cost documentation to your repository by running:
git add scripts/ docs/
git commit -m "Add start/stop scripts and cost documentation"
git push origin main

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files and your Amplify deployment shows a green Deployed status.

ⓧ I'd like to double check the full code

Here is the next.config.ts file:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // Allow loading images from S3
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '*.s3.amazonaws.com',
      },
    ],
  },
}

export default nextConfig

Here is scripts/start-infra.sh:

#!/bin/bash
aws ec2 start-instances --instance-ids <EC2_INSTANCE_ID>
echo 'EC2 instance starting...'

Here is scripts/stop-infra.sh:

#!/bin/bash
aws ec2 stop-instances --instance-ids <EC2_INSTANCE_ID>
echo 'EC2 instance stopped.'

Here is docs/cost-breakdown.md:

# Monthly Cost Breakdown
- EC2 t3.small: $0.02/hr when running, $0 stopped
- RDS db.t4g.micro: free plan (6 months)
- S3: ~$0.023/GB stored
- Amplify: free tier (500K requests, 15GB transfer)
- EBS 30GB gp3: ~$2.40/month
- Estimated: ~$2.40/month baseline + $0.02/hr active

Secret mission

Face Clustering Analytics Dashboard

Your platform detects faces and matches selfies to photos. But hosts have no visibility into how many unique people attended their event, or who appears most often. Add a "People" tab that visualizes face clusters with representative thumbnails, photo counts, and filtered gallery links.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses AWS resources that incur costs when running, so review the options below to manage your spend.

Cost warning

Your EC2 instance costs approximately $0.02/hr while running. The attached EBS volume costs approximately $2.40/month even when the instance is stopped. If you leave everything running 24/7, expect around $15/month.

The recommended start/stop pattern keeps costs at approximately $5/month. If you are done with the project entirely, delete all resources to avoid any ongoing charges.

Resources you used:

  • EC2 t3.small instance (backend, Docker Compose stack, Nginx).
  • EBS 30GB gp3 volume (attached to EC2, persists when stopped).
  • RDS db.t4g.micro PostgreSQL instance (free plan).
  • S3 bucket for event photos.
  • Amplify app (frontend hosting).
  • IAM user with S3 and RDS permissions.
  • Local project files (backend and frontend directories).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the platform live for demos.

  • Your EC2 instance stays running and the platform remains accessible.
  • Expect approximately $15/month if the instance runs continuously.
  • RDS remains on the free plan (no additional charge for 6 months from account creation).
  • S3 storage costs pennies per GB stored.

Pause - I'll come back to this later

Stop the EC2 instance to eliminate compute charges while keeping your data and configuration intact. You can restart anytime.

  • Stop the EC2 instance by running the stop script from your local project directory:
  • Run ./scripts/stop-infra.sh from your project folder.
  • When you want to demo again, run ./scripts/start-infra.sh to bring everything back up.
  • The EBS volume persists at approximately $2.40/month even while the instance is stopped.
  • RDS remains on the free plan and stays accessible.
  • Total baseline cost while paused: approximately $2.40/month (EBS only).

Delete - I don't want to use this again

Remove all project resources to stop all charges completely. This is permanent and cannot be undone.

  • Terminate your EC2 instance (this also deletes the attached EBS volume if it was configured with DeleteOnTermination):
aws ec2 terminate-instances --instance-ids [[EC2_INSTANCE_ID="your EC2 instance ID"]]
  • Delete your RDS PostgreSQL instance:
aws rds delete-db-instance \
    --db-instance-identifier [[RDS_INSTANCE_ID="your RDS instance identifier"]] \
    --skip-final-snapshot
  • Empty and delete your S3 bucket:
aws s3 rm s3://[[S3_BUCKET_NAME="your S3 bucket name"]] --recursive
aws s3 rb s3://[[S3_BUCKET_NAME="your S3 bucket name"]]
  • Delete your Amplify app:
aws amplify delete-app --app-id [[AMPLIFY_APP_ID="your Amplify app ID"]]
  • Delete the IAM user you created for this project from the IAM console. Navigate to the IAM service, select **Users**, find the user you created, and choose **Delete**.
  • Delete your local project files:
cd ~/Desktop
rm -rf event-photo-platform

How do I find my resource IDs?

Your EC2 instance ID is visible in the EC2 console under **Instances**. It starts with i- followed by a string of characters.

Your RDS instance identifier is the name you chose when creating the database. Find it in the RDS console under **Databases**.

Your Amplify app ID is visible in the Amplify console on your app's overview page. It starts with d followed by alphanumeric characters.

That's a Wrap!

That's a Wrap!

Nice work! You've built and deployed a complete AI-powered Event Photo Discovery Platform on AWS.

From facial recognition inference to vector similarity search to a polished full-stack UI, this is production-grade software that proves you can architect, build, and ship an ML-powered product end-to-end.

You've learned how to:

  • Build an async facial recognition pipeline using InsightFace for face detection, Celery for background processing, and Qdrant for vector similarity search across 512-dimensional embeddings.
  • Create a responsive Next.js frontend with JWT authentication, role-based access control, real-time processing status, camera access for selfie capture, and a personalized photo gallery with fullscreen view and downloads.
  • Deploy a full-stack application to AWS using EC2 with Docker Compose, RDS PostgreSQL, S3 for photo storage, and Amplify for the frontend CDN, with start/stop scripts keeping monthly costs under $5.
  • Built face clustering analytics to identify unique attendees across thousands of photos and display per-person galleries on the host dashboard.

Ready to quiz yourself?