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.
| 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.
| Role | 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 |
- 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)
- 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
| 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 |
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 |
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
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
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)
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"
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
| Service | Type | Plan |
|---|---|---|
solstice-events-api |
Web Service (Python) | Free |
solstice-events |
Static Site | Free |
Both services auto-deploy on every push to main.
MySQL-compatible serverless database. Four tables:
usersβ UUID pk, email, hashed_password, roleeventsβ UUID pk, title, description, date, location, image_url, is_publishedattendeesβ UUID pk, user_id FK, event_id FK, name, profession, qr_code_id, status, badge_pdf_urlbadge_jobsβ UUID pk, attendee_id FK, status (queued/processing/completed/failed), timestamps
QR code PNGs are uploaded at registration time and survive Render restarts:
- QR codes β
solstice/qrcodes/{qr_code_id}(PNG,imageresource type)
Badge PDFs are generated once at check-in and stored permanently:
- Badges β
badges/{attendee_id}.pdfin thesolstice-badgesR2 bucket - Generated exactly once per attendee β no regeneration on download
- The R2 public URL is stored in
attendee.badge_pdf_urland 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.
Sending domain: test.kigumotvc.ac.ke (verified)
From address: noreply@test.kigumotvc.ac.ke
- 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)
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 / LinuxEdit 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.devLocal fallbacks: if
CLOUDINARY_URLis unset, QR PNGs save toapp/static/qrcodes/. If R2 vars are unset, badge PDFs save toapp/static/badges/. Neither is persistent on Render.
python seed.py # create tables + seed demo users and events
uvicorn app.main:app --reload --port 8000API β http://localhost:8000 Β· Swagger β http://localhost:8000/docs
cd ../frontend
npm install
copy .env.example .env # contains: VITE_API_URL=http://localhost:8000
npm run devFrontend β http://localhost:5173
- Open http://localhost:5173 β landing page with seeded events
- Sign up as a new customer (or use
demo@solstice.dev/demo123) - Browse events β click Register β fill in name and profession
- Check your inbox β confirmation email from Resend
- Go to
/dashboardβ click View QR Code - Open a new tab β log in as
admin@solstice.dev/admin123 - Go to
/admin/scanβ click Start Camera or paste the QR code ID manually - Scan / submit β both tabs show Pendingβ¦ with a spinner
- Wait ~3β5 seconds β status auto-updates to Checked In β on both tabs
- Click Download Badge β PDF opens directly from Cloudflare R2
- Scan the same QR again β amber "Already checked in" banner appears
| 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 |
| 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/ |
| 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) |
| Variable | Required | Description |
|---|---|---|
VITE_API_URL |
β | Full backend URL, e.g. http://localhost:8000 or the Render URL |