A two-sided marketplace connecting college events with brand sponsors.
Colleges publish what they are running, what it costs and who attends. Brands publish what they want to back and how much they will spend. A match can start from either side, and both paths converge on the same structured sponsorship record — instead of a cold email thread that goes nowhere.
Stack: React 18 · Vite · Node.js · Express · MongoDB (Mongoose) · Socket.IO · JWT
This is the core of the product. Most marketplaces only work in one direction; Festigo runs both, because sometimes the college moves first and sometimes the brand does.
college publishes an event POST /api/events
brand applies with an amount POST /api/events/:id/interests
college reviews applicants GET /api/events/:id/interests
college accepts or declines PATCH /api/events/:id/interests/:brandId
└── on accept → Sponsorship { source: 'event-first' } + shared thread
brand publishes a listing POST /api/listings
college sends a PDF proposal POST /api/proposals (multipart)
brand reviews the decks GET /api/proposals/received
brand accepts or declines PATCH /api/proposals/:id/status
└── on accept → Sponsorship { source: 'brand-first' } + shared thread
Both write to one Sponsorship collection tagged with the source that
produced it, so a dashboard can show the whole pipeline regardless of which
direction each deal came from.
State transitions are enforced server-side — a pending application can only
become accepted or rejected, an accepted one can only become completed,
and a decided proposal cannot be decided twice.
Prerequisites: Node 18+ and a MongoDB instance (local mongod, or a free
MongoDB Atlas cluster).
cd backend
npm install
cp .env.example .envEdit backend/.env — at minimum set MONGODB_URI and a real JWT_SECRET:
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"The server refuses to boot without them, rather than silently falling back to a hardcoded default.
npm run seed # demo colleges, brands, events, listings and in-flight matches
npm start # http://localhost:5050Port 5050 rather than 5000 because macOS AirPlay Receiver occupies 5000.
cd .. # repo root
npm install
npm run dev # http://localhost:5173Leave VITE_API_URL empty in development. The Vite dev server proxies /api
and /socket.io to the backend, so the browser stays on a single origin and
there is no CORS preflight in the loop.
All seeded accounts use the password festigo123. The login screen has
one-click buttons for the two below.
| Role | Organisation | |
|---|---|---|
| College | vjti@festigo.demo |
VJTI Mumbai |
| College | srcc@festigo.demo |
Shri Ram College of Commerce |
| College | christ@festigo.demo |
Christ University |
| Brand | stackforge@festigo.demo |
StackForge |
| Brand | zephyr@festigo.demo |
Zephyr Energy |
To see both flows immediately: log in as StackForge, apply to a VJTI event, then log in as VJTI and accept it. Or log in as VJTI, send a PDF proposal to a StackForge listing, then accept it as StackForge.
Four suites, all running against a live server — real HTTP, real sockets, a real browser. No mocks.
# terminal 1
cd backend && npm run seed && npm start
# terminal 2 — API suites
cd backend && npm run verify
# terminal 3 — browser suite (needs the frontend running too)
npm run dev # in another terminal
npm run test:ui| Command | Checks | What it covers |
|---|---|---|
npm run smoke |
53 | Both flows end to end, auth, role guards, ownership, PDF upload/download, state transitions |
npm run contract |
45 | Every API path the frontend calls resolves — catches client/server drift |
npm run realtime |
9 | Socket handshake, participant-gated rooms, live delivery, typing, and that outsiders receive nothing |
npm run test:ui |
30 | Playwright walkthrough of both flows in Chromium, plus dark mode, mobile layout and console errors |
npm run verify (in backend/) runs the first three. npm run test:ui (at the
root) reseeds first, since the walkthrough consumes state, and drops
screenshots in .artifacts/.
Two bugs the browser suite caught that the HTTP suites could not:
- The rate limiter was mounted on the whole
/api/authrouter, includingGET /verify— which the client calls on every page load. A user refreshing a few dozen times would have been locked out. It is now scoped to the three endpoints that actually accept a password. - Messages could render twice. The sender is in their own conversation room, so their message arrives over the socket and in the POST response, in either order. Both paths now go through one append that dedupes by id.
The first version of this project trusted the client in several places. Those are the bugs worth calling out, because fixing them shaped the current design.
| Was | Now |
|---|---|
JWT signed with a hardcoded 'your-secret-key' |
Secret from the environment; the server refuses to boot without it |
/api/brands/:brandId/dashboard read the id from the URL |
Derived from the token — there is no id to swap |
| Conversation routes took participant ids from the body | Every route asserts the caller is one of the two participants |
/api/proposals/create had no auth middleware at all |
Authenticated, college-only, and the brand is resolved from the listing |
| Proposal PDFs served from a public static directory | Streamed through an authorized route, restricted to the two parties |
| Password hashes returned in login responses | select: false on the field plus a single publicProfile() serializer |
Also in place: helmet, rate limiting on the credential endpoints, bcrypt at
cost 12, identical error text for unknown-email and wrong-password (so the
endpoint cannot enumerate accounts), and a whitelist on profile updates so a
caller cannot patch their own role.
Every row in the first table has a corresponding test in npm run smoke.
PDF storage. Proposal decks go into GridFS rather than the local disk. Hosts like Render and Fly have an ephemeral filesystem, so disk-written uploads disappear on the next deploy or restart. Multer still handles the upload — it buffers in memory and the buffer is streamed into GridFS.
Authenticated file downloads. A plain <a href> or window.open cannot
attach an Authorization header, so the client fetches the PDF through axios
and hands the browser a blob URL (src/lib/files.js).
One thread per subject. Conversations are unique on
(college, brand, event) and (college, brand, listing) via partial indexes,
so both sides always land in the same thread instead of forking a new one per
action.
Realtime authorization. The Socket.IO handshake reuses the REST JWT. Joining a conversation room is gated on being a participant — otherwise any authenticated user could subscribe to anyone's thread.
Dark mode. Colours are declared as channel triplets (--brand-rgb: 99 102 241) so components can build translucent variants with
rgb(var(--brand-rgb) / 0.12) without a second hardcoded value. The theme
follows the OS by default, with a manual override that wins in both directions.
Config is committed for the common hosts: render.yaml (both services),
vercel.json and netlify.toml (frontend), and backend/Dockerfile.
-
Database — create a free MongoDB Atlas cluster, add a database user, and allow network access from anywhere (
0.0.0.0/0) since managed hosts do not publish fixed egress IPs on free plans. Copy the connection string. -
API — deploy
backend/as a Node web service. SetMONGODB_URI,JWT_SECRET,NODE_ENV=production, andCLIENT_ORIGINSto the frontend origin. Health check path is/health. -
Frontend — deploy the repo root as a static site. Build
npm run build, publishdist, and setVITE_API_URLto the API origin (no trailing slash). The SPA fallback rewrite is already in each host's config file. -
Close the loop — neither URL exists until its service is created, so set
CLIENT_ORIGINSandVITE_API_URLafter the first deploy and redeploy both. CORS rejects unlisted origins, so a mismatch here shows up as a failed request rather than a silent misconfiguration.
VITE_API_URL is read at build time, not runtime — changing it requires a
rebuild, not just a restart.
backend/
app.js Express app: middleware, CORS, routes, error handling
server.js bootstrap: Mongo connect, HTTP + Socket.IO, shutdown
config/env.js validates required env vars at boot
middleware/ auth (requireAuth, requireRole), error handling
models/ User (+College/Brand discriminators), Event, Listing,
Proposal, Sponsorship, Conversation, Notification
routes/ auth, events, listings, proposals, sponsorships,
brands, messages, files
services/ conversations, notify, storage (GridFS)
sockets/ authenticated Socket.IO server and room helpers
scripts/ seed, smoke, contract, realtime
src/
lib/ api client, socket, hooks, formatters, file download
context/ Auth, Realtime, Theme, Toast
components/ui/ design-system primitives
components/layout/ navbar, footer
pages/ landing, auth, browse, detail, dashboards, messages
styles/ tokens.css, global.css, ui.css