Skip to content

About

Full-stack event registration & check-in platform. React + FastAPI + TiDB. QR-based check-in, async badge PDF generation stored in Cloudflare R2, JWT auth, real-time status polling.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

32 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Solstice Events

Where Moments Become Memories

A production-grade, full-stack event registration and check-in platform. Customers browse events, register, receive QR-code confirmation emails, and download personalised PDF badges after check-in. Admins publish and manage events, view live attendee lists, and scan QR codes at the door β€” with badge generation running asynchronously through a background worker pipeline.


🌐 Live Deployment

Service URL
Frontend https://solstice-events.onrender.com
Backend API https://solstice-events-api.onrender.com
API Docs (Swagger) https://solstice-events-api.onrender.com/docs

Note β€” Render free tier spin-down: The backend sleeps after 15 minutes of inactivity. The first request after sleep takes ~30 seconds to wake up. Subsequent requests are instant.


πŸ”‘ Demo Credentials

Role Email Password Access
Admin admin@solstice.dev admin123 Full event management, QR scanner, attendee lists
Customer demo@solstice.dev demo123 Browse events, register, view QR, download badge

✨ Features

Customer

  • Browse published events with countdown pills and location details
  • Create an account or sign in β€” JWT-based auth, role-aware redirects
  • Register for any event β€” name + profession saved to your badge
  • Receive a confirmation email with event details
  • View your QR code on the dashboard β€” present it at the door
  • Live status polling β€” dashboard auto-updates Registered β†’ Pending β†’ Checked In βœ“ without page refresh
  • Download or print your personalised PDF badge once checked in
  • Unregister from an event (only while status is registered)

Admin

  • Create, edit, publish/unpublish, and delete events
  • Delete is blocked if any attendees have registered (data safety guard)
  • View per-event attendee table with live status pills and badge download links
  • QR scanner page β€” camera-based scanning (with permission prompt) or manual paste fallback
  • Real-time check-in: scan β†’ Pending… spinner β†’ Checked In βœ“ + badge link β€” all without refresh
  • Duplicate scan protection: re-scanning shows an amber "Already checked in" banner

πŸ—οΈ Tech Stack

Layer Technology
Backend Python 3.11 Β· FastAPI Β· Uvicorn
Database TiDB Cloud (MySQL-compatible, SSL) Β· SQLAlchemy async (aiomysql)
Frontend React 18 Β· Vite Β· Tailwind CSS v4
Auth JWT (python-jose) β€” roles: customer / admin
Email Resend SDK Β· verified domain test.kigumotvc.ac.ke
QR Codes qrcode[pil] β€” server-side PNG, in-memory generation Β· stored in Cloudinary
PDF Badges reportlab β€” A6 card-style PDF, generated once at check-in Β· stored in Cloudflare R2
File Storage Cloudinary CDN (QR PNGs) Β· Cloudflare R2 (badge PDFs, S3-compatible)
Hosting Render β€” FastAPI web service + React static site

πŸ—‚οΈ Project Structure

Solstice_Events/
β”œβ”€β”€ render.yaml                   ← Render infrastructure-as-code (both services)
β”œβ”€β”€ README.md
β”‚
β”œβ”€β”€ backend/
β”‚   β”œβ”€β”€ requirements.txt
β”‚   β”œβ”€β”€ .env.example              ← all env vars documented
β”‚   β”œβ”€β”€ seed.py                   ← idempotent demo data seeder
β”‚   β”œβ”€β”€ reset_attendees.py        ← maintenance: reset all attendees to 'registered'
β”‚   └── app/
β”‚       β”œβ”€β”€ main.py               ← FastAPI app, CORS, static mount, R2 health-check, worker startup
β”‚       β”œβ”€β”€ config.py             ← all settings read from environment variables
β”‚       β”œβ”€β”€ database.py           ← async engine (aiomysql) + sync engine (pymysql for worker)
β”‚       β”œβ”€β”€ models.py             ← User, Event, Attendee, BadgeJob ORM models
β”‚       β”œβ”€β”€ schemas.py            ← Pydantic v2 request/response schemas
β”‚       β”œβ”€β”€ auth/
β”‚       β”‚   β”œβ”€β”€ router.py         ← POST /auth/register Β· POST /auth/login
β”‚       β”‚   └── utils.py          ← JWT encode/decode, password hashing, require_role()
β”‚       β”œβ”€β”€ routes/
β”‚       β”‚   β”œβ”€β”€ events.py         ← CRUD /events + /admin/events
β”‚       β”‚   β”œβ”€β”€ attendees.py      ← register, my, status poll, badge 302β†’R2, unregister
β”‚       β”‚   β”œβ”€β”€ checkin.py        ← POST /checkin β€” enqueues badge job, duplicate-scan guard
β”‚       β”‚   └── webhooks.py       ← POST /webhooks/badge-complete β€” HMAC-SHA256 validated
β”‚       └── services/
β”‚           β”œβ”€β”€ cloudinary_storage.py  ← upload_image() β†’ Cloudinary CDN (QR PNGs only)
β”‚           β”œβ”€β”€ r2_storage.py     ← upload_pdf() β†’ Cloudflare R2 (badge PDFs, boto3 S3-compat)
β”‚           β”œβ”€β”€ qr.py             ← generate PNG in memory β†’ upload to Cloudinary β†’ CDN URL
β”‚           β”œβ”€β”€ badge.py          ← generate PDF to tempfile β†’ upload to R2 β†’ public URL
β”‚           β”œβ”€β”€ email.py          ← Resend HTML confirmation email
β”‚           └── worker.py         ← daemon thread: polls queued jobs β†’ generates badge β†’ writes R2 URL to DB
β”‚
└── frontend/
    β”œβ”€β”€ index.html
    β”œβ”€β”€ vite.config.js
    β”œβ”€β”€ .env.example              ← VITE_API_URL
    └── src/
        β”œβ”€β”€ App.jsx               ← BrowserRouter, RequireAuth guards, Toaster
        β”œβ”€β”€ main.jsx
        β”œβ”€β”€ index.css             ← Tailwind v4 + animate-fade-in keyframe
        β”œβ”€β”€ context/
        β”‚   └── AuthContext.jsx   ← token in localStorage, login/logout, decodeToken()
        β”œβ”€β”€ hooks/
        β”‚   β”œβ”€β”€ useAuth.js        ← re-export from AuthContext
        β”‚   └── usePolling.js     ← setInterval while condition=true, auto-clears on unmount
        β”œβ”€β”€ api/
        β”‚   β”œβ”€β”€ index.js          ← axios + Bearer interceptor + 401 redirect
        β”‚   β”œβ”€β”€ auth.js           ← login Β· register
        β”‚   β”œβ”€β”€ events.js         ← getEvents Β· getAdminEvents Β· create Β· update Β· delete
        β”‚   └── attendees.js      ← register Β· my Β· status Β· resolveBadgeUrl Β· checkIn Β· unregister
        β”œβ”€β”€ components/
        β”‚   β”œβ”€β”€ Navbar.jsx        ← logo, role-aware nav links, logout
        β”‚   β”œβ”€β”€ EventCard.jsx     ← image, countdown pill, date, location, Register button
        β”‚   β”œβ”€β”€ StatusPill.jsx    ← registered=gray Β· pending=amber Β· checked_in=green
        β”‚   └── Spinner.jsx       ← animated SVG spinner (sm / md / lg)
        └── pages/
            β”œβ”€β”€ Landing.jsx       ← hero section + event carousel + feature highlights
            β”œβ”€β”€ Events.jsx        ← published events grid
            β”œβ”€β”€ Login.jsx         ← email + password
            β”œβ”€β”€ SignUp.jsx        ← email + password Γ— 2
            β”œβ”€β”€ Register.jsx      ← event-specific registration (auth-gated)
            β”œβ”€β”€ Dashboard.jsx     ← registrations, QR toggle, badge download/print, live polling
            └── admin/
                β”œβ”€β”€ AdminDashboard.jsx  ← event list, publish toggle, delete with confirm modal
                β”œβ”€β”€ EventForm.jsx       ← create/edit with image preview + publish checkbox
                β”œβ”€β”€ AttendeeList.jsx    ← per-event table with stats + badge download
                └── ScanPage.jsx        ← camera scanner + manual fallback + live polling

πŸ”„ How It Works β€” End-to-End Flow

1. Customer registers for an event

Customer fills form β†’ POST /attendees/register
  β†’ Attendee row created (status: registered)
  β†’ QR PNG generated in memory β†’ uploaded to Cloudinary
  β†’ Confirmation email sent via Resend
  β†’ Returns attendee record with qr_code_id

2. Admin scans the QR code at the door

Admin scans QR (camera or manual paste) β†’ POST /checkin
  β†’ Duplicate check: if status=pending/checked_in β†’ already_checked_in: true
  β†’ Otherwise: attendee.status = "pending"
  β†’ BadgeJob(status="queued") inserted
  β†’ Returns immediately (non-blocking)

3. Async badge generation pipeline

BadgeWorker daemon thread (polls every 2 s)
  β†’ Finds queued BadgeJob
  β†’ Marks job status = "processing"
  β†’ Sleeps 3–5 s (simulates badge printer)
  β†’ Generates A6 PDF badge via reportlab (QR embedded in-memory)
  β†’ Uploads PDF bytes to Cloudflare R2 β†’ gets permanent public URL
  β†’ Deletes local temp file
  β†’ Updates attendee directly via DB session (one-time write):
      attendee.status        = "checked_in"
      attendee.badge_pdf_url = R2 public URL
  β†’ Marks job status = "completed"

4. Frontend reflects status in real time

Customer dashboard / Admin scan page
  β†’ usePolling(getAttendeeStatus, 3000ms, while status==="pending")
  β†’ Status changes to "checked_in" β†’ polling stops
  β†’ Toast: "Checked In βœ“ β€” badge ready"
  β†’ "Download Badge" / "Print Badge" buttons appear
  β†’ resolveBadgeUrl() reads badge_pdf_url from status response
  β†’ _isR2Url() confirms it's a Cloudflare R2 URL
  β†’ Browser opens PDF directly from R2 CDN β€” zero backend hop

☁️ Infrastructure & Storage

Render (hosting)

Service Type Plan
solstice-events-api Web Service (Python) Free
solstice-events Static Site Free

Both services auto-deploy on every push to main.

TiDB Cloud (database)

MySQL-compatible serverless database. Four tables:

  • users β€” UUID pk, email, hashed_password, role
  • events β€” UUID pk, title, description, date, location, image_url, is_published
  • attendees β€” UUID pk, user_id FK, event_id FK, name, profession, qr_code_id, status, badge_pdf_url
  • badge_jobs β€” UUID pk, attendee_id FK, status (queued/processing/completed/failed), timestamps

Cloudinary (QR code storage)

QR code PNGs are uploaded at registration time and survive Render restarts:

  • QR codes β†’ solstice/qrcodes/{qr_code_id} (PNG, image resource type)

Cloudflare R2 (badge PDF storage)

Badge PDFs are generated once at check-in and stored permanently:

  • Badges β†’ badges/{attendee_id}.pdf in the solstice-badges R2 bucket
  • Generated exactly once per attendee β€” no regeneration on download
  • The R2 public URL is stored in attendee.badge_pdf_url and served directly by the browser

The GET /attendees/{id}/badge endpoint issues a 302 redirect to the R2 public URL. The frontend reads badge_pdf_url directly from the status poll and opens R2 without even hitting the redirect.

Resend (email)

Sending domain: test.kigumotvc.ac.ke (verified)
From address: noreply@test.kigumotvc.ac.ke


πŸš€ Local Development Setup

Prerequisites

  • Python 3.11+
  • Node.js 18+
  • TiDB Cloud cluster (free tier)
  • Cloudinary account (free tier β€” for QR code storage)
  • Cloudflare account with an R2 bucket (free tier β€” for badge PDF storage)
  • Resend account + verified domain (optional β€” emails skip gracefully if key not set)

1 β€” Backend

git clone https://github.com/FranNMK/Solstice_Events.git
cd Solstice_Events/backend

python -m venv venv
.\venv\Scripts\Activate.ps1      # Windows PowerShell
# source venv/bin/activate        # macOS / Linux

pip install -r requirements.txt

copy .env.example .env           # Windows
# cp .env.example .env            # macOS / Linux

Edit backend/.env:

TIDB_URL=mysql+aiomysql://user:password@host:4000/solstice_events
JWT_SECRET=any-long-random-string
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
RESEND_API_KEY=re_xxxxxxxxxxxx
RESEND_FROM=noreply@yourdomain.com
RESEND_TEST_TO=
WEBHOOK_SECRET=another-random-string
BADGE_BASE_URL=http://localhost:8000
ALLOWED_ORIGINS=http://localhost:5173

# Cloudinary β€” QR code storage
CLOUDINARY_URL=cloudinary://api_key:api_secret@cloud_name

# Cloudflare R2 β€” badge PDF storage
R2_ACCOUNT_ID=your_cloudflare_account_id
R2_ACCESS_KEY_ID=your_r2_access_key_id
R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
R2_BUCKET_NAME=solstice-badges
R2_PUBLIC_URL_BASE=https://pub-xxxxxxxxxxxx.r2.dev

Local fallbacks: if CLOUDINARY_URL is unset, QR PNGs save to app/static/qrcodes/. If R2 vars are unset, badge PDFs save to app/static/badges/. Neither is persistent on Render.

python seed.py          # create tables + seed demo users and events
uvicorn app.main:app --reload --port 8000

API β†’ http://localhost:8000 Β· Swagger β†’ http://localhost:8000/docs


2 β€” Frontend

cd ../frontend
npm install
copy .env.example .env    # contains: VITE_API_URL=http://localhost:8000
npm run dev

Frontend β†’ http://localhost:5173


πŸ§ͺ Testing the Full Flow Locally

  1. Open http://localhost:5173 β€” landing page with seeded events
  2. Sign up as a new customer (or use demo@solstice.dev / demo123)
  3. Browse events β†’ click Register β†’ fill in name and profession
  4. Check your inbox β€” confirmation email from Resend
  5. Go to /dashboard β†’ click View QR Code
  6. Open a new tab β†’ log in as admin@solstice.dev / admin123
  7. Go to /admin/scan β†’ click Start Camera or paste the QR code ID manually
  8. Scan / submit β€” both tabs show Pending… with a spinner
  9. Wait ~3–5 seconds β€” status auto-updates to Checked In βœ“ on both tabs
  10. Click Download Badge β†’ PDF opens directly from Cloudflare R2
  11. Scan the same QR again β†’ amber "Already checked in" banner appears

πŸ“‘ API Reference

Method Endpoint Auth Description
POST /auth/register β€” Create account, returns JWT
POST /auth/login β€” Login, returns JWT
GET /events β€” List published events
GET /events/{id} β€” Single event detail
GET /admin/events Admin All events incl. drafts
POST /admin/events Admin Create event
PUT /admin/events/{id} Admin Update / publish event
DELETE /admin/events/{id} Admin Delete (blocked if attendees exist)
GET /admin/events/{id}/attendees Admin Attendee list for event
POST /attendees/register Customer Register for event
GET /attendees/my Customer My registrations with event details
GET /attendees/{id}/status Auth Status poll β€” returns status + badge_pdf_url
GET /attendees/{id}/badge Auth 302 redirect to R2 public PDF URL
DELETE /attendees/{id} Customer Unregister (blocked if pending/checked_in)
POST /checkin Admin Scan QR β†’ enqueue badge job
POST /webhooks/badge-complete HMAC Worker callback β†’ set checked_in

πŸ› οΈ Available Scripts

Command Directory Description
uvicorn app.main:app --reload --port 8000 backend/ Start backend dev server
python seed.py backend/ Seed TiDB with demo users + 3 events (idempotent)
python reset_attendees.py backend/ Reset all attendees to registered, clear badge jobs
DRY_RUN=1 python reset_attendees.py backend/ Inspect DB state without making any changes
npm run dev frontend/ Start Vite dev server
npm run build frontend/ Production build β†’ dist/

πŸ” Environment Variables Reference

Backend (backend/.env)

Variable Required Description
TIDB_URL βœ… SQLAlchemy async connection string for TiDB Cloud
JWT_SECRET βœ… Secret for signing JWTs β€” use a long random string
JWT_ALGORITHM βœ… HS256
ACCESS_TOKEN_EXPIRE_MINUTES βœ… Token lifetime in minutes (default 60)
RESEND_API_KEY ⚠️ Resend API key β€” emails skipped gracefully if omitted
RESEND_FROM ⚠️ Sender address β€” must match a verified Resend domain
RESEND_TEST_TO β€” Override: redirect all emails here (free-tier workaround)
WEBHOOK_SECRET βœ… HMAC-SHA256 secret for the webhook endpoint
BADGE_BASE_URL βœ… Backend public URL (used by worker for self-referencing)
ALLOWED_ORIGINS βœ… Comma-separated CORS origins (frontend URL)
CLOUDINARY_URL ⚠️ cloudinary://key:secret@cloud_name β€” QR PNGs stored locally if omitted
R2_ACCOUNT_ID ⚠️ Cloudflare account ID β€” badge PDFs stored locally if all R2 vars omitted
R2_ACCESS_KEY_ID ⚠️ R2 API token Access Key ID
R2_SECRET_ACCESS_KEY ⚠️ R2 API token Secret Access Key
R2_BUCKET_NAME ⚠️ R2 bucket name (e.g. solstice-badges)
R2_PUBLIC_URL_BASE ⚠️ Public URL base (e.g. https://pub-xxx.r2.dev)

Frontend (frontend/.env)

Variable Required Description
VITE_API_URL βœ… Full backend URL, e.g. http://localhost:8000 or the Render URL

About

Full-stack event registration & check-in platform. React + FastAPI + TiDB. QR-based check-in, async badge PDF generation stored in Cloudflare R2, JWT auth, real-time status polling.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages