The official REST API powering βοΈβπ₯ NEXUS β aiming to be the central nervous system for all student clubs at the Army Institute of Technology, Pune. Built with β€οΈ by the FE Members of GDG AIT.
| Service | URL |
|---|---|
| π₯οΈ Frontend (NEXUS) | sync-ait.vercel.app |
| βοΈ Backend API Root | / β SYNC AIT BACKEND API |
sync-backend-api is a production-grade Node.js + Express REST API that drives the NEXUS platform. It handles everything from secure authentication flows (local + Google OAuth) to dynamic form management and task creation β all persisted in a MongoDB database via Mongoose.
| Layer | Technology |
|---|---|
| Runtime | Node.js (ESModules) |
| Framework | Express v5 |
| Database | MongoDB + Mongoose |
| Auth | JWT (httpOnly Cookies) + Google OAuth 2.0 |
| Nodemailer + Resend | |
| Security | bcrypt, CORS with credentials |
| Dev Tool | Nodemon |
- Node.js
v18+ - A running MongoDB instance (local or Atlas)
- Google OAuth credentials (for Google login)
- SMTP credentials for email (Nodemailer / Resend)
git clone https://github.com/MyTricks-code/sync-backend-api.git
cd sync-backend-apinpm installCreate a .env file in the project root:
# Server
PORT=8000
ORIGIN=http://localhost:5173
# MongoDB
MONGO_URI=your_mongodb_connection_string
# JWT
JWT_SECRET=your_jwt_secret
# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
# Email (Nodemailer)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=[email protected]
SMTP_PASS=your_email_password
# Resend (alternative email provider)
RESEND_API_KEY=your_resend_api_keynpm run devThe server will start on http://localhost:8000.
All routes are prefixed with /api.
Note β Email/Password auth is intentionally disabled. Applicants must sign in and register via Google OAuth only. The commented-out routes and controller functions are preserved in
routes/authRoutes.jsandcontrollers/userController.jsand can be re-enabled by uncommenting them there.
| Method | Endpoint | Auth Required | Status | Description |
|---|---|---|---|---|
POST |
/register |
β | π« Disabled | |
POST |
/login |
β | π« Disabled | |
POST |
/logout |
β | β Active | Logout (clears cookie) |
POST |
/verify-otp |
β | π« Disabled | |
POST |
/verify-account |
β | π« Disabled | |
POST |
/forget-password |
β | π« Disabled | |
POST |
/verify-forget-otp |
β | π« Disabled | |
POST |
/update-user-info |
β | β Active | Update profile information |
POST |
/google-auth |
β | β Active | Sign in / Sign up via Google |
GET |
/get-user-info |
β | β Active | Fetch authenticated user's profile |
| Method | Endpoint | Auth Required | Description |
|---|---|---|---|
POST |
/create-form |
β | Create a new form |
GET |
/get-user-forms |
β | Fetch all forms created by user |
PUT |
/edit-form |
β | Edit an existing form |
DELETE |
/delete-form |
β | Delete a form |
GET |
/get-public-forms |
β | Get all public forms |
GET |
/get-form/:formId |
β | Get a specific form by ID |
| Method | Endpoint | Auth Required | Description |
|---|---|---|---|
Managed by responseController |
β | Form response submission & retrieval |
| Method | Endpoint | Auth Required | Description |
|---|---|---|---|
POST |
/create-task |
β | Create a new task |
sync-backend/
βββ config/
β βββ mongoDB.js # MongoDB connection setup
β βββ nodeMailer.js # Nodemailer transporter config
βββ controllers/
β βββ userController.js # Auth & user management logic
β βββ googleAuth.js # Google OAuth handler
β βββ formController.js # Form CRUD operations
β βββ responseController.js# Form response handling
β βββ taskController.js # Task creation logic
βββ helpers/ # Utility functions (email senders, etc.)
βββ middlewares/
β βββ userAuth.js # JWT authentication middleware
βββ models/
β βββ userModel.js # User schema (local + Google auth)
β βββ formsModel.js # Form schema
β βββ responseModel.js # Response schema
β βββ taskModel.js # Task schema
βββ routes/
β βββ authRoutes.js
β βββ formRoutes.js
β βββ responseRoutes.js
β βββ taskRoutes.js
βββ index.js # App entry point
βββ package.json
βββββββββββββββ Register/Login ββββββββββββββββ
β Client β βββββββββββββββββββββββΊ β Express API β
β (NEXUS FE) β ββββββββββββββββββββββ β β
βββββββββββββββ httpOnly JWT Cookie ββββββββ¬ββββββββ
β
ββββββββββββββββββββββββββββΌβββββββββββ
βΌ βΌ βΌ
Email OTP Google OAuth 2.0 MongoDB
Verification (google-auth-lib)
Tokens are stored as httpOnly cookies β never exposed to JavaScript β for maximum security.
The backend automatically pulls new posts from every AIT club's Instagram, figures out which ones announce an upcoming event, and emails those events to every registered user. It's a single job β runScrapeJob() in jobs/scrape.job.js β but it moves through several stages, uses Gemini as an intelligent filter, and has fallbacks at every step so a slow API or one bad post doesn't sink the whole run.
| Trigger | Path | Behavior |
|---|---|---|
| Scheduled cron | POST /api/events/scrape |
Called by cron-job.org. Skips silently if a scrape ran less than a week ago. |
| Manual force | POST /api/events/scrape/trigger |
Bypasses the weekly guard. Refetches the last 8 days of posts in the background. |
| Boot trigger | (automatic) | Fires once when Mongo connects on server start. |
The weekly guard reads the newest ScrapeLog document. If a scrape finished under 7 days ago and force isn't set, the job exits with a "skipped" message and never touches Apify or Gemini.
Top to bottom, one stage at a time:
- Guard check β Skip if last scrape was less than 7 days ago (unless
force). - Scrape from Apify β Fires the
apify/instagram-post-scraperActor with all ~28 club handles in one call. Up to 10 posts per handle, filtered to posts newer thansinceDate(defaults to 8 days back), 120s timeout, 1024 MB memory. Runs synchronously β the job waits for the dataset before continuing. - Normalise + filter β Trim each raw post down to only what we need (
instagramId,caption,postUrl,clubHandle,postedAt,imageUrl,likesCount). Anything with a caption shorter than 10 characters is dropped as noise. - Save new posts β Insert each into the
postscollection, keyed oninstagramId. Posts already in the DB are skipped. Genuinely new posts are collected intonewPosts[]. - Pick what to classify
- Normal run: send only the newly-saved posts to Gemini.
- Forced run: send every normalised post that doesn't yet have an
Eventrow (so posts scraped before but never successfully classified get another shot). - If the list is empty, Gemini is skipped entirely.
- Classify + save events β Send the list through
classifyPostBatch()(see next section). Each returned event is upserted into theeventscollection byinstagramId. The post's caption becomes the eventdescription, and itsclubHandleis resolved to anOrganizationdocument viafindOrg(). Events that are truly new (upserted, not modified) are collected for emailing. - Send emails β For each new event, ask Gemini to write a subject line + HTML body + plain-text version, then blast it to every user.
Promise.allSettledcollects the results so a single failed send doesn't block the rest. - Write scrape log β Persist a
ScrapeLogwith counts + duration. Logs auto-expire after 1 week via a Mongo TTL index.
classifyPostBatch() in helpers/classifyService.js is where most of the complexity lives. It has to stay under Gemini's rate limits, gracefully hop between models when daily quotas run out, and never let one bad chunk take down a whole batch.
Startup safety. If GEMINI_API_KEY is missing from the environment, the module refuses to load. This is deliberate β without an API key the SDK silently falls back to Google's default credentials, which then fails with a confusing "Could not load the default credentials" error at request time.
Chunking. Posts are split into groups of 8. Each group is one Gemini API call. Small chunks mean a small blast radius when something fails.
Bounded concurrency (2 in-flight). At most 2 chunks talk to Gemini at the same time. The two workers start 1.5s apart so the very first calls don't hit the API in the exact same millisecond.
Adaptive spacing. Between chunks, a worker sleeps 4s Γ delayMultiplier. The multiplier starts at 1 and moves on its own:
- shrinks 10% after each success (
Γ 0.9, floor1) β go faster when Gemini is happy - grows 50% after each failure (
Γ 1.5, cap4) β back off when it's angry
Model chain. For every chunk, three models are tried in order, falling through on quota or repeated failure:
gemini-2.5-flashβ primary, best qualitygemini-2.5-flash-liteβ faster, lighter, separate quota poolgemini-2.0-flash-liteβ older, yet another quota pool
Per-model retries (up to 3 attempts). Retryable errors:
- 429 rate limit β reads the server's
Retry-Infohint (e.g. "wait 5s") and tries again. - 503 overload β exponential backoff: 5s β 10s β 20s, capped at 30s.
- Timeout (45s) β every request is wrapped in a timeout; a hang is treated like a 503.
Anything else (400, 401, malformed request) throws immediately and the chunk is abandoned with [].
Daily-quota is special. A 429 with a perDay quota violation won't clear by waiting. When that happens:
- The offending model is added to an
exhaustedModelsset scoped to this batch. - The current chunk falls through to the next model in the chain.
- Every later chunk in the batch skips that model too β no point in re-hitting a dead model just to receive the same rejection.
Total-exhaustion short-circuit. If a chunk runs out of models and the exhaustion set already covers the whole chain, the batch flips dailyQuotaHit = true. Every remaining chunk sees the flag on entry and returns [] immediately β no wasted API calls once we're sure the day is done.
Parsing the response. Gemini is asked for raw JSON via responseMimeType: "application/json". On top of that:
- Markdown fences (
```json ... ```) are stripped defensively. - If the model leaks prose around the JSON, the parser falls back to the substring from the first
[to the last]. - On a parse failure, the API call is retried up to the retry limit before the chunk is abandoned.
- If the response body comes back empty, the model's
finishReason(e.g.SAFETY,MAX_TOKENS,RECITATION) is logged so you can tell why nothing came back.
Validation + dedupe. Every classified item is:
- Date-coerced to
YYYY-MM-DD(accepts a few common formats). - Rejected if
instagramIdis missing, the date is malformed, or the date doesn't resolve to a real calendar day. - Deduplicated by
instagramIdwithin the chunk, and once more across chunks when the batch finishes.
What the caller sees. Even if half the chunks fail, the caller still gets whatever succeeded. A bad chunk returns [] β errors are logged but never propagated up.
generateEventEmail() also calls Gemini (this time gemini-2.0-flash) to write a per-event subject, HTML body, and plain-text version. If Gemini fails, throws, or returns malformed JSON, a hardcoded HTML template ships instead so recipients still get a usable email. Individual per-user delivery failures are captured by Promise.allSettled and don't block the rest of the recipient list.
π Explore the full NEXUS ecosystem: github.com/Jitesh-Yadav01/NEXUS
Β© 2025β26 GDG AIT Pune Frontend Team Β· Built for AIT Pune's student community π