(Built by Ryan)
frontend:
- React + Vite + Tailwind
backend:
- Serverless functions in
backend/(deployed on Vercel), MongoDB Atlas for storage, Groq for AI moderation of submissions, and one SMTP account for all outbound email. There is no server process to keep running — each endpoint is its own function, cold-started on demand.
scheduled jobs:
- Two daily GitHub Actions, both independent of the API and of each other — "Sync Calendar Events" scrapes Purdue research events into Google Calendar, and "Keep Database Awake" stops Atlas pausing the free cluster. See Scheduled jobs.
frontend/ the React app (its own package.json)
src/config.js public constants (must match backend/lib/constants.js)
api/ the serverless endpoints - each file IS one HTTP route
auth/ signup, verify-email, resend-code, login, me,
request-password-reset, reset-password
projects/ index.js (GET /api/projects), mine.js, [id].js (DELETE)
submit.js POST /api/submit
backend/ everything server-side that isn't an endpoint
lib/ db, auth, mailer, rate limiting, http helpers, env, constants
prompts/ the AI moderation system prompt
scrapers/ Purdue event sources -> Google Calendar
scripts/ entry points for the npm scripts and both GitHub Actions:
scrape.js, keepDatabaseAwake.js, ensureIndexes.js
package.json the server-side dependencies
package.json npm workspace root: scripts only, no dependencies
A file's path under api/ is its URL — api/auth/login.js serves POST /api/auth/login.
This is a Vercel requirement, not a choice: it only turns files under a root-level api/
directory into functions, and that path is not configurable. Everything those handlers lean on
lives in backend/, so adding an endpoint means adding one file under api/ at the path you
want the URL to be.
frontend/ and backend/ are separate npm workspaces, so their dependencies stay separate and
a single npm install at the root sets up both. The api/ handlers import from
backend/lib/, so they run on the backend workspace's dependencies.
Everything deploys as one Vercel project, so the frontend calls the API same-origin at
/api/* — no base URL to configure and no CORS to maintain.
Prerequisites: Node 22+, and a MongoDB you can reach (see step 3). Run everything from the repo root unless a step says otherwise.
npm installOne install at the root covers both workspaces — there is no separate install inside
frontend/ or backend/.
cp .env.example .envOne .env at the repo root serves the whole repo — there is no second one inside
backend/. Fill in every value: the code has no fallback defaults, so a missing variable
raises a clear error naming it rather than quietly running on a guessed value.
.env holds only secrets and per-deployment connection details. Public settings that don't
vary between environments — the @purdue.edu signup domain, the 3-project cap, the Google
Calendar id and its timezone — are plain constants in
backend/lib/constants.js and
frontend/src/config.js. Those two files declare the same four values
for their respective halves, so if you change one, change the other.
The frontend reads nothing from .env. No environment variable is ever compiled into the
browser bundle, so a secret cannot leak into shipped JavaScript — and npm run build works
without a .env at all. Only the api/ functions and backend/ scripts read it.
Which variables matter for which task:
| Doing this | Needs |
|---|---|
| Frontend only | nothing |
| Anything touching the API | MONGODB_URI, MONGODB_DB, JWT_SECRET, JWT_TTL |
| Signup / password reset | ...plus SMTP_* |
| Submitting a project | ...plus GROQ_* and ADMIN_EMAIL |
npm run scrape |
GOOGLE_CLIENT_EMAIL, GOOGLE_PRIVATE_KEY |
Either use your Atlas cluster, or run one locally:
mkdir -p ~/usb-mongo
mongod --dbpath ~/usb-mongo --port 27017
# then in .env: MONGODB_URI=mongodb://127.0.0.1:27017Then create the indexes — this is required, not optional. The unique index on
users.email is what actually prevents duplicate accounts, and the TTL index is what expires
rate-limit windows:
npm run ensure-indexesIt's idempotent, so re-run it any time.
Two terminals:
npx vercel dev # terminal 1 — API on http://localhost:3000cd frontend && npm run dev # terminal 2 — UI on http://localhost:5173Open http://localhost:5173. Vite proxies /api/* to port 3000, which mirrors how the two
are served same-origin in production — so relative /api/... paths behave identically in dev
and prod.
First run of
vercel devprompts you to link a Vercel project and writes a.vercel/folder (gitignored). It needs a Vercel account. If you only want to work on the frontend, you can skip it — the UI runs on its own, and API calls will just fail.
| Command | Does |
|---|---|
npm run ensure-indexes |
Create/verify the MongoDB indexes (idempotent) |
npm run scrape |
Run the calendar sync by hand |
npm run keepalive |
Ping the deployed API so Atlas stays awake (needs SITE_URL) |
npm run build |
Production build of the frontend |
npm run api |
Shorthand for vercel dev |
The script is called
api, notdev, on purpose.vercel devauto-detects adevscript and runs it as the Development Command — so naming itdevmakesvercel devinvoke itself and fail withmust not recursively invoke itself.
| Symptom | Cause |
|---|---|
X is not set. Every setting is read from the .env... |
That variable is missing from .env. There are no defaults by design — add it. If you added it after starting vercel dev, restart it. |
| Every login returns 401 after a code change | JWT_SECRET changed; tokens signed with the old one are dead. Log in again. |
| Signup 500s with an SMTP error | SMTP_* is wrong or unreachable. Gmail needs an App Password, not your account password. |
429 on login/signup while testing |
The rate limiter is doing its job. Windows live in the rate_limits collection; delete the docs to reset. |
| Frontend loads but every API call 404s | Terminal 1 isn't running, or it's not on port 3000. |
| UI and API disagree about the project cap | PROJECT_LIMIT drifted between backend/lib/constants.js and frontend/src/config.js. |
- Import the repo into Vercel. Leave the Root Directory at the repo root —
vercel.jsonbuilds the frontend workspace and picks upapi/automatically. Do not point the Root Directory atfrontend/orbackend/; Vercel needs to see both, plus the rootapi/. - Under Settings → Environment Variables (Production and Preview), add the 13 the API
needs:
MONGODB_URI,MONGODB_DB,JWT_SECRET,JWT_TTL,GROQ_API_KEY,GROQ_MODEL,GROQ_BASE_URL, the fiveSMTP_*, andADMIN_EMAIL.GOOGLE_*andSITE_URLare not used by the deployed API — they belong to the GitHub Actions instead (see the table under Scheduled jobs). - In Atlas, allow network access from
0.0.0.0/0. Serverless functions have no static egress IP, so IP allowlisting isn't available; use a DB user scoped toreadWriteonusb_boardwith a long random password instead — that credential is what protects the database. - Run
npm run ensure-indexesonce against production. The unique index onusers.emailis what prevents duplicate accounts, so this is required, not optional.
Free-tier notes: Vercel Hobby covers this comfortably (1M function invocations, 100 GB bandwidth/month) and is non-commercial only, which a student org site satisfies. Atlas M0 is free at 512 MB. Gmail SMTP sends ~500 recipients/day on a free account with an App Password.
Two workflows run daily at 08:00 UTC. They're kept separate on purpose — they do unrelated things, so each gets its own run history and its own failure email. Both can also be triggered by hand from the Actions tab (Run workflow).
Each workflow is a thin runner: it installs dependencies and calls an npm script, exactly like
running it by hand. All the logic lives in backend/scripts/, so both jobs can be tested
locally without pushing anything.
Sync Calendar Events — scrape-calendar.yml
Runs npm run scrape → backend/scripts/scrape.js. Scrapes Purdue
research events into the Google Calendar the site embeds. Needs two repository secrets:
GOOGLE_CLIENT_EMAILGOOGLE_PRIVATE_KEY(paste the full multi-line PEM — see the note below)
The calendar id isn't a secret — it's public in the embed URL — so it lives in backend/lib/constants.js instead.
Keep Database Awake — keep-database-awake.yml
Runs npm run keepalive → backend/scripts/keepDatabaseAwake.js, which
makes one request to /api/projects — an endpoint that reads MongoDB. Needs one repository
variable (not a secret; the URL is public):
SITE_URL— e.g.https://usb-research-resources.vercel.app
This is not an optimization — it prevents an outage. Atlas pauses a free cluster after 30 days without a connection, and a paused cluster refuses every connection until a human clicks Resume in the dashboard; it does not wake itself when traffic arrives. Left alone over a long break, the database would pause and every API route would return 500 while the static site kept loading normally. A daily touch makes 30 days of inactivity unreachable.
It goes through the public HTTP endpoint rather than connecting with the driver so that no database credential has to live in this public repo's Actions secrets, and it doubles as an uptime check. It verifies the response really is the JSON array the handler returns — a 200 with an HTML body would mean the request never reached Mongo.
Config is read from process.env either way, so the same code works locally and in CI. The
only difference is where the values come from: a .env file locally, injected values in
Actions and on Vercel. Nothing needs to be set in more than one place.
| Variable | Local .env |
Vercel | GitHub Actions |
|---|---|---|---|
MONGODB_URI, MONGODB_DB |
✓ | ✓ | — |
JWT_SECRET, JWT_TTL |
✓ | ✓ | — |
GROQ_API_KEY, GROQ_MODEL, GROQ_BASE_URL |
✓ | ✓ | — |
SMTP_* (5), ADMIN_EMAIL |
✓ | ✓ | — |
GOOGLE_CLIENT_EMAIL, GOOGLE_PRIVATE_KEY |
✓ | — | secret |
SITE_URL |
optional | — | variable |
GOOGLE_PRIVATE_KEY takes two shapes and both work. In .env it's one quoted line with
literal \n escapes; as a GitHub secret you paste the real multi-line PEM. The scraper
normalizes either form, so don't try to escape newlines when pasting into GitHub.
- Scheduled runs only fire from the default branch. Both files must be on
main. - GitHub auto-disables scheduled workflows on public repos after 60 days with no new commits. Runs, issues and releases don't reset that timer — only commits do. Worst case over a long quiet break: the workflows are disabled at day 60 and Atlas pauses at day 90. GitHub emails a warning first. If the repo goes quiet for a whole summer, either push a commit occasionally or make the repo private (the auto-disable is public-repos-only).
One SMTP account sends all four messages: the signup verification code, the password reset
code, the "your project was posted" confirmation to the submitter, and the new-submission
notice to ADMIN_EMAIL.
This used to be split across two providers — SMTP for the user-facing mail and EmailJS for the admin notice. EmailJS is built for sending mail from a browser when you have no server, which was true of this site before it had a backend. It isn't now, and it came with a tighter quota (200/month free, against Gmail's ~500/day) while the message body and the recipient address both lived in the EmailJS dashboard instead of this repo. Both are now in api/submit.js.
There is no endpoint that grants admin. Set isAdmin: true on the user document in Mongo by
hand. Privileges are read from the database on every request, so it takes effect immediately —
the user does not need to log out and back in.
- Similar to PTP, allow people to make a post in a new section about a research project they're working on where people can join a new project or already existing research group. This is different from PTP in the sense that PTP project are usually fullstack apps, where this is catered more towards ML/interdisciplinary CS work. Should be a pretty easy addition in the future. (Test out how well PTP goes and iterate form there). This could also culminate in a research roundtable similar to the one ML@Purdue has.