diff --git a/.env.example b/.env.example index 66c1eef..c40fddd 100644 --- a/.env.example +++ b/.env.example @@ -82,5 +82,29 @@ PORT=3000 # which would have published a sitemap full of localhost URLs. NEXT_PUBLIC_SITE_URL=https://www.fedkiit.com +# --- Tunables --------------------------------------------------------------- +# All optional. Every default below reproduces the previous hardcoded value, so +# leaving them unset changes nothing. + +# One-time passwords. NEXT_PUBLIC so OtpInput draws exactly this many boxes — +# one variable for both sides means the code length and the UI cannot desync. +NEXT_PUBLIC_OTP_LENGTH=4 +OTP_VALIDITY_MINUTES=15 + +# Extra hosts allowed to appear in an Origin header when building an emailed +# invite link. Comma-separated. The canonical site host, localhost and 127.0.0.1 +# are always trusted; this is for staging and preview deployments. +# e.g. TRUSTED_ORIGIN_HOSTS=staging.fedkiit.com,fed-frontend.vercel.app +TRUSTED_ORIGIN_HOSTS= + +# Addresses that may read form analytics regardless of role. Comma-separated. +# Previously the literal srex@fedkiit.com in the route. +FORM_ANALYTICS_ALLOWED_EMAILS=srex@fedkiit.com + +# Calendar month (1-12) the academic year rolls over in. Used to derive the year +# of study from a KIIT roll number, so a 2022 intake stays 4th Year until July +# 2026 rather than being promoted every 1 January. +ACADEMIC_YEAR_START_MONTH=7 + LOG_REQ=false DEBUG=false diff --git a/MIGRATION.md b/MIGRATION.md index 697b516..7344ade 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -343,6 +343,757 @@ answer 400 on an empty body, and `logout` is idempotent. `changePassword` is the reset step and is gated on a single-use OTP, rate limited, and returns the same message whether or not the account exists. +## Load time — the barrel files were the problem + +The landing page was shipping **2.3 MB of JavaScript**. The cause is visible in +any dev-server warning trace: + +``` +./src/sections/Profile/Admin/View/VerifyCertificate/VerifyCertificate.jsx +./src/sections/Profile/index.jsx +./src/sections/index.jsx <- re-exports Home *and* Profile +./src/views/Home/Home.jsx +./app/(main)/page.jsx +``` + +`Home.jsx` imported `{ Hero, About, Sponser, Feedback, Contact }` from the +`sections` barrel, which also re-exports `sections/Profile` — the entire admin +panel. Every one of those is a client component, so the bundler pulled the whole +graph into the landing page: certificate tooling, admin tables, the avatar +editor, event analytics. A visitor who only wanted the hero image downloaded the +admin panel. The `features` barrel did the same thing for `LiveEventPopup`. + +Under Vite this cost nothing noticeable, because the dev server serves ES modules +untouched and the SPA loaded one bundle for every route anyway. Under Next each +route gets its own bundle, so a barrel import silently undoes the code splitting. + +Fixed by importing the four components directly instead of through a barrel. The +barrels are untouched — other call sites still use them. + +| Page | Before | After | +|---|---|---| +| `/` | 2317 KB | **1082 KB** | +| `/Events` | 2063 KB | **1082 KB** | +| `/Team` | 2014 KB | **1082 KB** | +| `/Login` | 1248 KB | **920 KB** | + +Uncompressed. Over the wire the landing page is **327 KB** of JS and 55 KB of +HTML, and locally serves in TTFB 38 ms / DOMContentLoaded 135 ms / load 536 ms. + +**Dev-server slowness is separate and expected.** `next dev` compiles each route +on first request, so a cold page can take seconds while production serves the +same page in 5–30 ms. Measure `npm run build && npm start`, never `npm run dev`. + +## Invalid HTML nesting that only mattered under SSR — all of it + +Four components wrapped block-level content in a `

`: + +| Component | The nesting | +|---|---| +| `EventCard` | `

` → `div.price` → `

` | +| `EventModal` | `

` → `div.price` → `

` | +| `Hero` | `

` → `` → `

` | +| `Social` | `

` → `div.fed` → `

` | + +Client-rendered under Vite none of this mattered: React builds the DOM node by +node, and nothing reparents a tree that already exists. Server-rendered it is +real markup, so the parser closes the `

` at the first block child and the +content lands as a *sibling* — a different layout, which React then reports as a +hydration mismatch. + +Each wrapper is now a `

` carrying a class listed alongside the original +`p` selector, so the computed styles are unchanged. Verified in the browser: + +| | Was styled by | Now computes to | +|---|---|---| +| `EventCard .meta` | `.eventname p` | 14.4px / flex / center / 1.6px | +| `EventModal .meta` | `.eventname p` | 14.4px / flex / center / 1.6px / #fff | +| `Hero .tagline` | `.largeContent p` | 39.2px / 700 / #fff | +| `Social .content` | `.text p` | 40px / 600 / #fff / center | + +Hero keeps its `

` rather than downgrading it to a `` — the wrapper +changed instead, so the heading still counts as a heading. + +`Social`'s wrapper is worth a note: `styles.content` had **no rule in the +stylesheet**, so the className resolved to `undefined` and did nothing — the +element was styled purely by `.text p`. `.content` now exists and carries those +declarations. (`EventCardModal.price` is undefined in the same way; both are +inherited from the original and left as they are.) + +**`npm run audit:nesting` keeps it that way.** `scripts/audit-nesting.mjs` +walks a tag stack through every JSX file and reports any element the HTML parser +would reparent. It skips comments, string and regex literals — without that, a +comment mentioning `

` or a `.replace(/]*>/gi, '')` gets read as markup, +which produced three separate rounds of false positives while writing it. The +scanner was validated by running it against the pre-fix files: it reports all +four real cases and nothing for the two false-positive files. + +## Social embeds do not survive hydration + +Fixing `Social`'s nesting uncovered a second, unrelated mismatch on the same +page: `react-social-media-embed` mints a fresh UUID per render and writes it +into both `id` and `className`, so the server's markup can never match the +client's. The embed sizes compound it — they come from `useDimensions()`, which +reads `window` and therefore measures 0 on the server. + +`InstagramEmbed` and `LinkedInEmbed` are now loaded through `next/dynamic` with +`ssr: false`. Nothing is lost: the visible post is drawn by Instagram's and +LinkedIn's own scripts after mount, so the server-rendered markup was an +invisible placeholder either way. `/Social` renders the same four embeds at the +same 1674px page height, with the console clean. + +`{ ssr: false }` is written out at both call sites because `next/dynamic` is a +compile-time transform and rejects a shared options variable — +*"next/dynamic options must be an object literal."* + +## Smaller fixes from the same dev-server log + +- `darken($accent-color, 10%)` in `VerifyCertificate.module.scss` is deprecated + in Dart Sass. Replaced with `color.adjust($accent-color, $lightness: -10%)`, + its documented equivalent — confirmed by compiling both and diffing the output + (`rgb(80%, 43.2941176471%, 0%)` either way). The build no longer emits + deprecation warnings. +- `` carries `data-scroll-behavior="smooth"`, acknowledging the + `scroll-behavior: smooth` that `globals.scss` sets, so Next stops warning and + keeps the original's smooth scrolling. + +Not fixed, deliberately: the `` "height decreased" messages come from +`react-fit`, a transitive dependency of `react-date-picker`, when the calendar +popup is repositioned to fit the viewport. It uses the `warning` package, which +compiles to a no-op in production, so this is dev-only third-party noise. + +## Admin forms & events — re-verified against the Express controllers + +The calendar on the admin form page had **no styling at all**. The original +`index.scss` pulled in three vendor stylesheets; only one was carried over: + +```scss +@import "react-date-picker/dist/DatePicker.css"; // was missing +@import "react-calendar/dist/Calendar.css"; // was missing +@import "react-datepicker/dist/react-datepicker.css"; +``` + +`react-date-picker` draws its popup with `react-calendar`, so with `Calendar.css` +absent the picker opened as an unstyled column of numbers. Confirmed by grepping +the emitted CSS — **zero** `.react-calendar` rules shipped, now 50. In the +browser the popup measures 350×248 with a `#a0a096` border, which is +react-calendar's own default and therefore exactly what the original rendered. + +Re-reading every form/event controller alongside its port turned up more: + +**`getFormAnalytics` returned the wrong shape entirely.** `EventStats.jsx` reads +`response.data.form.formAnalytics`, `response.data.form.info` and +`response.data.yearCounts`; the port returned `{ success, message, data }`, so +`response.data.form` was `undefined` and the admin analytics panel threw. It now +returns `{ message, form, yearCounts }`, including the `yearCounts` histogram +built from registrants' `year` field, and the 404 for a form nobody has +registered to. Its access check is the controller's own allowlist +(ADMIN / PRESIDENT / VICEPRESIDENT / DIRECTOR_*) plus the `srex@fedkiit.com` +escape hatch, answering 401 "Access Denied" — not the `isMember` test the port +had invented. + +**The attendance flow did not work.** The QR code carries a *signed JWT*: + +| | Express | Port (before) | +|---|---|---| +| `attendanceCode` returns | `{ message, attendanceToken }`, a JWT expiring in 20 min | `{ success, data }` with the raw record id | +| `markAttendance` accepts | `{ formId, token }`, verifies the JWT | a bare ObjectId | + +`QRCodeModal` reads `response.data.attendanceToken` — absent, so no QR was +generated — and `AttendancePage` posts the scanned JWT, which the port rejected +as not being a 24-character id. Both sides now match the original, including the +`formId` binding that stops one event's QR checking someone in at another, and +the `?teamCode=` branch. Verified by minting a token with the original's +`jsonwebtoken` call and verifying it with the port's `jose` code, and the +reverse: + +``` +Express-minted QR verifies in the port : true +Port-minted QR verifies in Express : true + lifetime (minutes) : 20 +Tampered QR rejected : true +``` + +**Access levels corrected in both directions:** + +| Endpoint | Express | Port (before) | Now | +|---|---|---|---| +| `export-attendance/:id` | `checkAccess("ADMIN")` | any club member | ADMIN | +| `markAttendance` | signed-in (its `checkAccess` is commented out) | any club member | signed-in | +| `getFormAnalytics/:id` | controller allowlist | any club member | allowlist | + +`markAttendance` reads as the loosest of the three but is not: the door +volunteer signs in as a plain USER, so requiring member access locked the door +staff out, and the 20-minute signed QR is the actual control. + +**Image dimensions were wrong.** Both controllers resize through Cloudinary at +fixed sizes; the port used 1000×1000 and 500×500 instead: + +| | Express | Port (before) | Now | +|---|---|---|---| +| `addForm` FormImages | h 350.67 × w 196.37 | 1000 × 1000 | h 350.67 × w 196.37 | +| `addForm` QRMediaImages | h 400 × w 150 | 500 × 500 | h 400 × w 150 | +| `editForm` FormImages | h 350.67 × w 196.37 | 1000 × 1000 | h 350.67 × w 196.37 | +| `editForm` QRMediaImages | h 150 × w 400 | 500 × 500 | h 150 × w 400 | + +The two QR rows are transposed relative to each other because `addForm` passes +`(QrImageWidth, QrImageHeight)` and `editForm` passes `(QrImageHeight, QrImageWidth)` +into the same `(height, width)` parameters. Each call site is reproduced as +written rather than reconciled. Note also that Express's helper is +`uploadImage(path, folder, height, width)` while this project's is +`(file, folder, width, height)`, so the arguments read transposed in the source +while sending identical values. + +`addForm` also returns 200 with "Form created successfully", and `editForm` +"Form info and sections updated successfully", matching the originals. + +**One divergence kept on purpose.** `addForm` in Express computes +`isPublic: Boolean(isPublic) || false` over a multipart field, and +`Boolean("false")` is `true` — so every event created through the admin form was +public, registration-closed and past regardless of the toggles. `editForm` +already used `isPublic === "true"`. The port uses the `editForm` form in both, so +the three switches actually work. Restoring byte-parity here would re-break them. + +## OTP length — a hardening change that broke the screen + +The password-reset email carried a **6-digit** code while every OTP screen in the +app renders **4 boxes**, so the code could not be typed in at all. Both the +reset flow and signup share `components/OtpInput`, and both were affected. + +The UI was not at fault — it is 4 boxes in the original too, character for +character. The mismatch came from this port raising `OTP_LENGTH` from 4 to 6 as +a hardening measure, without a consumer that could accept six. + +Reverted to 4, matching `generateOtp(4, false, false, false)`. The reasoning +behind 6 does not survive contact with the rest of this codebase: 10,000 +combinations really are brute-forceable against the Express backend, which had +no throttling whatsoever, but `RATE_LIMITS.passwordReset` allows 6 attempts per +15 minutes and a code expires after 15 — so an attacker gets at most 6 guesses +out of 10,000 before the code they are hunting stops existing. The other OTP +hardening stays: codes are stored as a SHA-256 digest, compared in constant +time, single-use, and expiry is derived from `createdAt` rather than a +`setTimeout` that never fires on a serverless host. + +One caveat carried over from `lib/api/rate-limit.ts`: the limiter is +in-process, so a horizontally scaled deploy multiplies the effective limit by +the instance count. A shared store is the follow-up if the app is scaled out. + +Codes already issued before this change are 6 digits and cannot be entered; +they expire on their own within 15 minutes. + +## Vite bundles all CSS; Next splits it per route + +The disabled "Resend OTP" button on `/otp` rendered with a grey box around it. +Measured against the original running side by side: + +| | Original (Vite) | Port (before) | +|---|---|---| +| `background-color` | `rgba(0, 0, 0, 0)` | `rgba(19, 1, 1, 0.3)` | +| `border` | `0px none` | `2px outset rgba(195,195,195,0.3)` | + +That is the user-agent's disabled-button chrome showing through. The cause is +structural rather than a mistranslation. `TeamCard.module.scss` contains a +**top-level bare `button { }`** rule — Vite does not hash element selectors in +CSS Modules, and it bundles every module into one stylesheet for the SPA, so +that rule was live on every page of the original site. Next code-splits CSS per +route, so once ported it only loaded where `TeamCard` did: `/Team` and +`/profile/members`. Everywhere else, buttons lost their reset. + +The rule is now declared in `app/globals.scss`, which reproduces the original +cascade. At specificity 0-0-1 every component's own class rules still win, so +nothing else moves — verified by diffing all three buttons on `/Login` between +the two apps: identical background, font-size, margins and box sizes. + +**The same trap applies to ten other rules.** These are top-level `:global(...)` +selectors inside CSS Modules — app-wide under Vite, route-scoped here: + +``` +src/views/Event/styles/Event.module.scss:1 :global(*) +src/views/Event/styles/PastEvent.module.scss:1 :global(*) +src/views/TermsAndConditions/styles/T&C.module.scss:2 :global(*) +src/components/EventCard/styles/EventCard.module.scss:261 :global(a) +src/layouts/Blog/TabBar/styles/TabBar.module.scss:24 :global(ul) +src/sections/Home/Feedback/styles/Feedback.module.scss:15 :global(::-webkit-scrollbar) +src/sections/LiveEvents/Omega/Attend/styles/Attend.module.scss:77 :global(img) +src/components/Core/styles/Core.module.scss:103-104 :global(input[type="number"]::-webkit-*-spin-button) +src/authentication/Login/ForgotPassword/styles/forgotPassword.module.scss:1 :global(:root) +``` + +Only the `button` one is fixed here, because it had a visible, reported symptom +and a verified before/after. The `:global(*)` rules in particular would change +layout on every page and need reviewing one at a time rather than hoisting +wholesale. The `:root` entry is already harmless — it only defines `--primary`, +which `globals.scss` also defines. + +## The Chatbot was missing from every auth screen + +App.jsx renders `` above ``, so it appears on every route. +The port mounted it in the `(main)` layout, which hid it on `/Login`, `/SignUp`, +`/otp`, `/ForgotPassword` and `/completeProfile`. Moved to the root layout, +inside `Providers` since it reads `AuthContext`. Confirmed present in the +server-rendered HTML for all six routes checked, and the toggle now measures +`72x72` on `/Login` in both apps. + +## Request-body field names — audited + +`/otp` rejected a correct code with "Email, otp and password are required". +`OtpInput.jsx` posts `{ newPassword, confirmPassword, otp, email }`, matching +Express; this port's handler destructured `password`, so the check failed before +the code was ever looked at. Fixed, along with the rest of that controller's +contract, which had also been simplified away: + +- 400 `"Missing fields."` when any of the four is absent +- 409 `"Conflict : New Password and confirm Password did not match!!"` +- 404 `"User not found!"` — Express's `checkAccess` looks the account up by the + body's `email`, which is also why the endpoint works without a session +- 400 `"New password cannot be same as the old password ! Instead try login"` +- 200 `{ status: "OK", message: "Password has been changed successfully !!" }` + +This was the third contract break found by report rather than by testing, so +`npm run audit:contracts` now walks every `api.*()` call in the components, +resolves it to its Route Handler, and flags payload keys the handler never +reads. It found two more live ones: + +| Endpoint | UI sends | Handler read | Effect | +|---|---|---|---| +| `renameTeam` | `newTeamName` | `teamName` | every rename failed on an empty name | +| `sendJoinRequest` | `teamRegistrationId` | `teamCode` | every join request rejected | + +Both now match Express, including `sendJoinRequest` identifying the target by +registration row id — the value `searchTeams` already returns to the UI as +`teamRegistrationId` — rather than by team code, with the explicit +`team.formId !== formId` check that a global row id makes necessary. + +## Certificate endpoints are largely unimplemented + +The audit also showed the admin certificate tooling calling endpoints that do +not exist here. Express exposes **15** certificate routes; this port has 5, and +one of those (`addCertificateTemplate`) has no counterpart upstream at all. + +Five of the missing ones are called by +`CertificatesForm/tools/certificateTools.js` and so are reachable from the admin +panel today: `getEvent`, `getEventByFormId`, `createOrganisationEvent`, +`sendBatchMails`, `sendCertViaEmail`. The remainder — `getCertificateTest`, +`getOrganisationEvents`, `addAttendee`, `createEvent`, `getCertificate`, +`testNamePosition` — are unreferenced by the UI. + +This is a gap, not a regression: it was never built. It is called out here +rather than fixed in passing because the certificate flow already carries a +deliberate architectural deviation (images composite client-side instead of +through `canvas`/`puppeteer`), so completing it is a design decision rather than +a translation. + +## `useSearchParams` has a different shape in Next + +React Router returns `[params, setParams]`; Next returns the params object +itself. Components that kept the array destructuring crash: + +```jsx +const [searchParams] = useSearchParams(); // wrong under Next +const searchParams = useSearchParams(); // correct +``` + +`ReadonlyURLSearchParams` is iterable, so destructuring it as an array does not +throw — it quietly yields the first `[key, value]` entry, or `undefined` when the +URL has no query string. The next `.get()` then fails with "Cannot read +properties of undefined (reading 'get')". + +Fixed here in `EventForm.jsx` (the event registration form) and +`VerifyCertificate.jsx` (certificate verification). + +`EventForm` is the one that hid. It reads `searchParams.get("teamCode")` during +render, but `ProtectedRoute` redirects signed-out visitors first, so an anonymous +smoke test returns 200 and only a signed-in user ever reaches the crash. + +Swept the tree for the rest of the React Router surface (`useNavigate`, +`useLocation`, `Outlet`, `Navigate`, `to=`): only comments remain. + +## Team management — rebuilt against the Express controllers + +The five components (`TeamManagement`, `MemberCard`, `InviteSection`, +`TeamlessState`, `ConfirmDialog`) were already ported line for line. The backend +behind them was not: it had been written against a **different data model**, so +most of the page did not work. + +**The model.** A team is one `formRegistration` row. It holds every member's +address in `regTeamMemEmails` and every member's form answers in `value`, and its +`userId` is the leader. Leaving or being removed means lifting a person's entries +out of that row and giving them their own `UNAFFILIATED` row, so they stay +registered for the event and can join or start another team. The port had assumed +a row per member sharing a `teamCode`, with the earliest row treated as leader. + +That single wrong assumption produced most of the faults: + +| Endpoint | Fault | Effect | +|---|---|---| +| `teamDetails/:formId` | looked the registration up by `userId` | **only the leader could load the page** — every other member got "no registration found". Confirmed against live data: the ownership query returns `NULL` for a real member of team "ABC", the membership query finds the team | +| `teamDetails/:formId` | returned `registrationId, regTeamMemEmails, isLeader` | the UI reads `eventTitle`, `leaderEmail`, `maxTeamSize`, `minTeamSize`, `isRegistrationClosed`, `isEventPast` and `data.isTeamless`, none of which existed, and members lacked `college` and `year` | +| `teamDetails/:formId` | no `UNAFFILIATED` branch | `TeamlessState` — the whole create/join flow — could never render | +| `searchTeams/:formId` | returned a flat array of `{teamName, teamCode, size, maxSize, isFull}`, read `?q=` | the picker reads `data.data.teams` with `teamRegistrationId`, `teamSize`, `maxTeamSize`, `leaderName`, `spotsRemaining`, `hasPendingRequest`, and sends `?search=`. The list rendered empty and the search box filtered nothing | +| `leaveTeam` | keyed on `userId`; no leader/member distinction | a member could not leave; nobody's answers were carried across; the tracker's `regTeamNames` was never released | +| `removeTeamMember` | assumed row-per-member | removal did not work, and the removed member got no email | +| `renameTeam` | read `teamName` | UI sends `newTeamName` — every rename failed | +| `sendJoinRequest` | read `teamCode` | UI sends `teamRegistrationId` — every request rejected | + +Also restored: `leaveTeam` blocks a leader who still has members +("You must remove all team members before leaving…"), the closed-registration +guard compares the flags as the **strings** `"true"`/`"false"` the data actually +stores, `joinTeam` returns `eventId` (falling back to `formId` when +`relatedEvent` is absent or the literal string `"null"`), +`joinRequestUpdates` returns `pendingCount`, and the removed-member email is +sent, following `emailTemplates/removedMember.html`. + +Every success message now matches the original, because the components put +`response.data.message` straight into a toast — `Team "X" created successfully!`, +`Successfully joined team "X"!`, +`Successfully dissolved|left the team "X". You can now create or join another team.`, +`Successfully removed a@b.com from the team & informed through a@b.com`, +`Invitation sent to a@b.com`. + +**Verified against the live database**, read paths only: + +``` +teamDetails as leader -> team ABC, size 2, max/min 3/1, eventTitle, + leaderEmail, members with year + college +teamDetails as member -> identical payload (previously: nothing) +teamDetails teamless -> isTeamless true, eventTitle, max/min 5/3 +searchTeams -> data.teams[] with teamRegistrationId, leaderName, + spotsRemaining, hasPendingRequest +searchTeams?search=AB -> filters to "ABC" +inviteLink -> inviteLink, teamCode, teamName, shareText +joinRequestUpdates -> { updates: [], pendingCount: 0 } +``` + +The mutations (`createTeam`, `joinTeam`, `leaveTeam`, `removeTeamMember`, +`renameTeam`, `inviteTeamMember`, `sendJoinRequest`) are **not** exercised here: +this database holds real registrations, and running them would rewrite other +people's teams. They are matched to the controllers line by line and typecheck +clean, but they want a run-through on a scratch database before release. + +## The team page hit the same `useSearchParams` bug + +`TeamManagement.jsx` carried the React Router destructuring described above, so +opening any team page crashed with *"Cannot read properties of undefined +(reading 'get')"*. + +It also cleaned the URL after showing an email-redirect toast by mutating the +params and calling the setter. Next's object is read-only and has no setter, so +that is now a copy plus `router.replace(..., { scroll: false })` — same outcome: +the toast does not re-fire on refresh and no history entry is added. + +Verified in the browser against live data: the team page renders in full for a +non-leader member (team, code, 2/3 members with year and college, "You" marker, +Leave Team) and for the leader (invite panel, share link, per-member remove), +`?toast=joined&name=…` is consumed and stripped from the URL, and the console is +clean on both. + +## Invite links resolve to the deployed domain + +`inviteLink` is built from the request, not hardcoded, so a browser on +production produces a production URL. The localhost you see in development is +development's own origin. + +The origin is now checked against an allowlist before being used. `Origin` and +`Host` are set by the caller, and these URLs go into **email** — the team +invitation, and the accept/reject buttons sent to a team leader. Reflected +unchecked, anyone able to create a team could have FED KIIT send a message, from +its own address, containing a link to a domain of their choosing. The Express +controller did reflect them +(`req.headers.origin || process.env.FRONTEND_URL || "https://fedkiit.com"`); +this is the one place the port deliberately does not follow it. + +An origin is trusted when it matches `NEXT_PUBLIC_SITE_URL`, or is localhost so +a developer's copied link works on their machine. Anything else falls back to +`NEXT_PUBLIC_SITE_URL`. Measured: + +| Request | Link produced | +|---|---| +| `Origin: https://www.fedkiit.com` | `https://www.fedkiit.com/Events/…` | +| `Origin: http://localhost:3999` | `http://localhost:3999/Events/…` | +| `Origin: https://evil.example` | falls back to the real host — attacker domain dropped | +| `Host: evil.example` | `https://www.fedkiit.com/Events/…` | + +**`NEXT_PUBLIC_SITE_URL` must be set correctly in the production environment**; +it is what every fallback resolves to. + +## Team invite links now survive signing in + +Clicking an invite while signed out sent you to the login page and then, after +signing in, dropped you on the team-finding page instead of joining the team. + +The auto-join itself was never the problem — it was ported and works. The +destination was being thrown away during authentication, in four places: + +| Where | What it did | +|---|---| +| `Login.jsx` — the "Sign Up" link | overwrote `prevPage` with `/Login`, discarding the invite `ProtectedRoute` had just saved | +| `SignUP.jsx` | `router.push("/")` after signing up | +| `GoogleSignup.jsx` | cleared `prevPage`, then resolved to `/profile` | +| `CompleteProfile.jsx` | `router.push("/")` | + +So the very case an invite is for — someone without an account — lost it before +signing up. All four now resolve through `postAuthRedirect()`, which takes a +fallback so the signup screens still default to `/` as they always did. + +Measured through the real UI, with the login and join calls stubbed so nothing +was written: + +``` +open invite signed out -> /Login + prevPage = /Events//Form?teamCode=40-002-7161 +click "Sign Up" -> prevPage unchanged (previously: "/Login") +sign in -> POST /api/auth/login + POST /api/form/joinTeam {formId, teamCode:"40-002-7161"} + -> /Events//team +``` + +Both the email and WhatsApp links carry the same `?teamCode=`, so both behave +identically. + +**A new account still has to fill in the event's registration form.** Team +membership is a `formRegistration` row: there is nothing to move onto a team +until the person has registered for the event, and that form is where required +details and any payment are collected. The invite is carried through it — +`EventForm` passes the code to `PreviewForm`, which joins the team the moment +registration succeeds. Someone who is *already* registered skips all of that and +joins on the spot, which is the flow shown above. + +## A failed auto-join no longer fails silently + +`PreviewForm` joins the invited team as soon as registration succeeds. When that +join was rejected it did nothing but `console.error`, then fell through to +`router.push("/Events")` — the person landed on the events listing, registered +but teamless, with no indication that the invite had not been honoured. The same +silent fall-through is in `FED-Frontend/src/features/Modals/Profile/Admin/ +PreviewForm.jsx:191-194`, so this is inherited rather than a port defect, but it +is reachable in ordinary use: invite links get shared in group chats, and the +last person to act on one finds the team full. + +Two paths were silent, not one. Besides the `catch`, a `200` carrying +`success: false` also fell through untouched. Both now redirect to +`/Events//team?toast=join_failed&reason=`. + +That destination is deliberate. The person *is* registered at that point, just +unaffiliated, so `TeamManagement` renders `TeamlessState` — the team search. They +arrive on the screen that lets them fix the situation instead of the events +listing. + +The reason is forwarded rather than hard-coded because the same path catches +four different rejections from `joinTeam`: + +| API message | What happened | +| --- | --- | +| `This team is full` | filled up while they were registering | +| `Invalid team code` | team disbanded or the link is stale | +| `Registration is closed for this event` | deadline passed mid-flow | +| `You are already in a team` | duplicate submit | + +`TeamManagement` appends "You can join another team below." and strips `reason` +from the URL alongside `toast` and `name`, so the toast does not re-fire on +refresh. + +Not exercised against the database: `joinTeam` is a mutation and the configured +Atlas instance holds real registrations. The redirect and the toast are verified +by build and by reading the path; the full-team rejection itself wants a scratch +database. + +## Team mutations — exercised against the database, and four defects found + +Previously only the read paths were verified; the mutations were matched to the +controllers by eye and explicitly flagged as unrun. Running them end to end with +two real accounts on the "Team Test" event found four defects that reading had +missed. + +**1. `joinTeam` failed outright (500).** `formRegistration` carries +`@@unique([formId, teamCode])` — the model is **one row per team**, with the +whole roster in `regTeamMemEmails`, not one row per member. The port stamped the +team's code onto the joiner's own row, which collides with the team row on that +constraint. Every join died with a Prisma P2002 surfaced as a 500. Joining is a +*merge*: the joiner's email and their `value` entry move onto the team row and +their solo row is deleted. + +This is the mutation the entire invite-link flow depends on, so the flow could +not have worked in production. + +**2. `respondJoinRequest` failed the same way**, for the same reason, so a leader +accepting from their email got the generic error page and the request stayed +PENDING for ever. + +**3. `renameTeam` had no leader check at all.** Any member could rename the team. +The controller looks the row up by membership and compares its owner to the +caller; the port loaded the caller's own row and compared nothing. It was also +missing the registration-closed check, the "name unchanged" no-op, and the +tracker's name swap — so a rename left the old name reserved for ever. + +**4. Team codes were generated in the wrong shape.** The controller builds +`<2-letter event code>-<3-digit index>-<4 digits>` (`AR-003-8793`); the port +built a slug of the *team* name (`CLAUDETE-7130`). Every code already in the +database uses the first shape, and people share these by hand — a second shape +makes a valid code look fake. + +Two message mismatches went with them: `createTeam` used a **commented-out v1 +string** from `addRegistration.js` rather than the live one, and `renameTeam` +invented its own. + +One deliberate deviation was kept. The controller lets a teamless registrant +rename their own `UNAFFILIATED` placeholder, which produces a named "team" +carrying a `SOLO--` code and absent from the tracker — a row nothing +else expects. That is guarded here. + +### What was run + +Event "Team Test", two accounts belonging to the repo owner, restored to their +original state afterwards. No team containing anyone else was touched — every +event both accounts share has a third party in one of their teams, so account A +was registered for "Team Test" to get two free accounts in one event. + +``` +createTeam · duplicate name · searchTeams · joinTeam (valid, invalid code, +already-on-a-team) · renameTeam (leader, non-leader 403, unchanged no-op) · +inviteTeamMember · removeTeamMember (leader, non-leader) · leaveTeam (member, +leader-last) · sendJoinRequest (new, duplicate) · respondJoinRequest (accept, +replay) · teamDetails after every step +``` + +20/20 asserted steps pass; both accounts end `TEAMLESS` with no leftover PENDING +requests. + +`checkJoinRequestUpdates` filtering on `requesterEmail` is **correct**, not a +bug: it is the requester's view of their own requests. Leaders are notified by +email only, so a leader legitimately sees `pendingCount: 0`. + +### Renaming a team and replacing one are different things + +A pending request used to survive the dissolution of the team it pointed at, and +became live again the moment that leader created their next team. A leader could +disband "ABC", create "BCD", and the person who had asked to join ABC would be +pulled into BCD without ever agreeing to it. + +`teamRegistrationId` cannot tell the two apart — the registration row is reused, +so its id survives a rename *and* a disband. Verified rather than assumed: after +a disband and a fresh createTeam, the request's `teamRegistrationId` still equals +the new team's row id. + +**`teamCode` is the discriminator.** A rename leaves it untouched; disbanding +resets it to a `SOLO-…` code and the next `createTeam` mints a fresh one. So +`teamJoinRequest` now pins the code at the time of asking, and acceptance +compares it: + +| Leader does | Team code | Pending request | Invite link | +| --- | --- | --- | --- | +| renames ABC → BCD | unchanged | **still accepted** | **still works** | +| disbands ABC, creates BCD | new | **auto-expired** | **404** | + +The invite-link path already behaved correctly — those links carry +`?teamCode=`, so a rename keeps them valid and a disband invalidates them — but +it was confirmed against the database rather than taken on trust. + +`teamCode` on `teamJoinRequest` is optional: rows written before it existed do +not carry one, and a request that cannot be verified is treated as stale. There +were 45 requests in the database when this shipped and **none** of them pending, +so nothing was grandfathered. MongoDB needs no migration for an optional scalar +with no index — `prisma generate` is enough, no `db push` against production. + +Proven end to end on the live database, 9/9 assertions: rename keeps the code and +the request is accepted into the renamed team; disband-and-recreate changes the +code, the row id is demonstrably reused, the requester is *not* pulled in, and +the request lands in `AUTO_EXPIRED`; the invite link survives the rename and dies +on the disband. + +## Pulling a schema change does not break anyone + +The generated Prisma client lives in `node_modules/.prisma/client`, which is +gitignored, so it never travels with a commit. `@prisma/client` has its own +postinstall hook, but npm only fires that during an install — and a schema change +on its own does not touch `package.json`, so nobody has a reason to reinstall. + +That combination is the trap: pulling a branch that changed `schema.prisma` +leaves a client that no longer matches it, the app starts perfectly, and the +mismatch only surfaces as `Unknown argument …` on whichever request happens to +touch the new field. `npm run build` and `npm run typecheck` do fail loudly, but +`npm run dev` does not type-check up front, so the usual workflow hides it. + +`scripts/ensure-prisma.mjs` runs the check where it cannot be skipped — +`with-env.mjs` calls it, and `dev`, `build` and `start` all go through that. + +It regenerates **only when the schema has actually changed**, comparing a +SHA-256 of `schema.prisma` against a stamp written beside the generated client. +Generating unconditionally would add seconds to every dev start; hashing one file +is imperceptible, so the common case is free. The stamp lives inside the client +directory deliberately: deleting `node_modules` takes it along, so a wiped +install can never look up to date. It is written only after a successful +generate, so a failure retries rather than marking a client that was never +produced as current. + +`postinstall` runs the same script with `--optional`, which tolerates a missing +CLI: `prisma` is a devDependency, so `npm install --omit=dev` on a deploy host +legitimately has none, and failing there would break the install. Running a dev +server without one is not legitimate, so that path leaves the flag off and fails. + +`npm run prisma:generate` is the manual escape hatch. + +Verified: no stamp → generates; unchanged schema → silent, no `prisma generate` +spawned; changed schema → detected and regenerated, both standalone and through +a real `npm run dev`, which then served `/api/health` 200. `npm install` still +exits 0 with the new hook. + +## Google sign-in was rejecting every request + +Two defects stacked on top of each other, both introduced by this port. + +**The body field never matched.** `GoogleLogin.jsx` and `GoogleSignup.jsx` post +`{ access_token }`, as they always did against Express. The route read +`credential || token || tokenId`, matched none of them, and returned +400 *"Google credential is required"* — before Google was contacted at all. So +Google sign-in and sign-up failed for everyone, every time. + +**The token type was wrong underneath.** The route then handed the value to +`OAuth2Client.verifyIdToken`, which expects an ID token (a JWT). Neither +component ever produces one: both use `useGoogleLogin` without a `flow` option, +which is the implicit flow, and its response carries an opaque **access token**. +Verifying it locally is not possible — it is not a JWT and carries no claims. +Handing it back to Google is what establishes whose it is, which is exactly what +the controller does: + +```js +`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${access_token}` +``` + +Now matched, with one deliberate difference: the token goes in an +`Authorization: Bearer` header instead of the query string. Same endpoint, same +response, but a credential in a URL ends up in proxy and server logs. + +Also restored from the controller and previously missing: the display name is +built from `given_name` + `family_name` before falling back to `name`, and a +`hd === "kiit.ac.in"` account gets `college`, `rollNumber`, `year` and `school` +derived from the roll number. That derivation was checked against the +controller's inline version across six roll numbers spanning 1st year to +Passout — identical output on all six. + +Error mapping: Google answering 401/403 becomes a 401 (the caller's token is +bad, not a server fault), any other non-OK becomes 502, and a transport failure +becomes 503 — where an unhandled throw would otherwise have surfaced as a 500. + +Status codes now follow the controller: 201 when the account was just created, +200 otherwise, with the message `"LOGGED IN"` in both cases. + +Verified against the live Google endpoint: an empty body gives +400 *"Missing fields: access_token"*; an invalid `access_token` gives **401**, +proving the request now reaches Google instead of being turned away at the door. +A full popup sign-in needs a browser and was not automated. + +### Google Cloud console + +The implicit flow validates **Authorized JavaScript origins**, not Authorized +redirect URIs — those apply to the server-side auth-code flow, which no app on +this client id uses (checked across FED-Backend, FED-Frontend and this repo: no +`redirect_uri`, no auth-code flow, no OAuth callback route). + +So local development needs `http://localhost:3111` as a JavaScript **origin** +— scheme, host and port, no path. `https://fedkiit.com` is not needed alongside +`https://www.fedkiit.com`: the apex 308-redirects to `www`, so the browser is +never on the apex origin. + ## Known issues - **`/ForgotPassword` reloads instead of submitting.** Its `

` has no @@ -358,6 +1109,15 @@ same message whether or not the account exists. and `editDetails` return 403 — so the exposure is the admin screen itself, not the ability to use it. The data it lists comes from `fetchTeam`, which is public either way (see below). +- **`checkAccess("USER")` is stricter in Express than here.** It passes only when + `access === "USER"` (or ADMIN), so club executives could not register for an + event, create or join a team, or fetch their attendance code — they got a 403. + The ported routes require a signed-in user instead, which lets staff use those + features. Worth a decision rather than a silent change: matching Express + exactly would start returning 403 to every executive account. +- `GET /api/form/allJoinRequestUpdates` answers `{ updates: [] }` to anonymous + callers where Express returned 401. App.jsx polls it on load, so a 401 logged + an error on every signed-out page view. - `/api/user/fetchTeam` returns members' email addresses to anonymous callers. Preserved deliberately: trimming the projection changes the response bytes and the Team page's sort order. Worth fixing, but it is a behaviour change, not a diff --git a/app/(main)/layout.jsx b/app/(main)/layout.jsx index 8ff7059..a2b4a6a 100644 --- a/app/(main)/layout.jsx +++ b/app/(main)/layout.jsx @@ -4,14 +4,16 @@ import { usePathname } from "next/navigation"; import Navbar from "@/src/layouts/Navbar/Navbar"; import Footer from "@/src/layouts/Footer/Footer"; -import Chatbot from "@/src/components/Chatbot/Chatbot"; /** * Main site layout — the `MainLayout` component from App.jsx. * * Same structure: Navbar, a `.page` wrapper that gains `.omega-page` on the - * Omega route, then Footer. The global Chatbot sits alongside it, as it did at - * the top of `App()`. + * Omega route, then Footer. + * + * The Chatbot is *not* here. App.jsx renders it above ``, so it appears + * on every route including Login, SignUp and the OTP screens; mounting it in + * this layout hid it on all of those. It now lives in the root layout. * * Deliberately no Suspense boundary here. Wrapping `{children}` made every * prerendered page ship its content twice — once inside the streamed boundary @@ -25,7 +27,6 @@ export default function MainLayout({ children }) { return (
-
{children} diff --git a/app/api/auth/changePassword/route.ts b/app/api/auth/changePassword/route.ts index e963b23..8f72dde 100644 --- a/app/api/auth/changePassword/route.ts +++ b/app/api/auth/changePassword/route.ts @@ -1,36 +1,53 @@ import { prisma } from "@/lib/db"; import { body, expressError, handle, json } from "@/lib/api/express"; import { enforceRateLimit, RATE_LIMITS } from "@/lib/api/rate-limit"; -import { hashPassword } from "@/lib/auth/password"; +import { hashPassword, verifyPassword } from "@/lib/auth/password"; import { verifyOtp } from "@/lib/services/otp"; /** * POST /api/auth/changePassword * Port of controllers/auth/changePassword.js — completes the reset flow. + * + * Body is `{ newPassword, confirmPassword, otp, email }`, which is what + * `OtpInput.jsx` posts. An earlier version of this route read `password` + * instead of `newPassword`, so a correct code was rejected with + * "Email, otp and password are required" before it was ever checked. + * + * Public, as in Express: the route's `checkAccess('USER','MEMBER','ADMIN')` runs + * with no `verifyToken` ahead of it, so the middleware takes its `email` from + * the body and looks the account up itself. Knowing the address is the entry + * requirement; the OTP is what actually authorises the change. */ export async function POST(request: Request) { return handle(async () => { - const { email, otp, password } = await body<{ - email?: string; + const { newPassword, confirmPassword, otp, email } = await body<{ + newPassword?: string; + confirmPassword?: string; otp?: string; - password?: string; + email?: string; }>(request); - if (!email || !otp || !password) { - return expressError(400, "Email, otp and password are required"); + if (!newPassword || !confirmPassword || !otp || !email) { + return expressError(400, "Missing fields."); + } + + if (newPassword !== confirmPassword) { + return expressError( + 409, + "Conflict : New Password and confirm Password did not match!!", + ); } const address = email.trim().toLowerCase(); await enforceRateLimit({ ...RATE_LIMITS.passwordReset, subject: address }); + // `checkAccess` answered 404 here when the address was unknown. const user = await prisma.user.findUnique({ where: { email: address }, - select: { id: true }, + select: { id: true, password: true }, }); - - // Same message whether or not the account exists. - if (!user) return expressError(400, "That code is not correct"); + if (!user) return expressError(404, "User not found!"); await verifyOtp({ email: address, @@ -39,11 +56,21 @@ export async function POST(request: Request) { consume: true, }); + if (await verifyPassword(newPassword, user.password)) { + return expressError( + 400, + "New password cannot be same as the old password ! Instead try login", + ); + } + await prisma.user.update({ where: { id: user.id }, - data: { password: await hashPassword(password) }, + data: { password: await hashPassword(newPassword) }, }); - return json({ message: "Password changed successfully" }, 200); + return json({ + status: "OK", + message: "Password has been changed successfully !!", + }); }); } diff --git a/app/api/auth/googleAuth/route.ts b/app/api/auth/googleAuth/route.ts index 4847d9b..cc29e38 100644 --- a/app/api/auth/googleAuth/route.ts +++ b/app/api/auth/googleAuth/route.ts @@ -6,30 +6,46 @@ import { googleAuth } from "@/lib/services/auth"; * POST /api/auth/googleAuth * Port of controllers/auth/google/googleAuthentication.js. * - * Accepts either `credential` or `token` — GoogleLogin.jsx and GoogleSignup.jsx - * each send a different key. + * The body is `{ access_token }`. `GoogleLogin.jsx` and `GoogleSignup.jsx` both + * use `useGoogleLogin`'s implicit flow and post that key — as they did against + * Express. This route previously looked for `credential` / `token` / `tokenId` + * and matched none of them, so every Google sign-in was rejected with a 400 + * before Google was ever contacted. + * + * `token` and `credential` stay accepted as aliases so a caller that already + * adopted either keeps working. */ export async function POST(request: Request) { return handle(async () => { const payload = await body<{ + access_token?: string; credential?: string; token?: string; tokenId?: string; }>(request); - const credential = payload.credential || payload.token || payload.tokenId; - if (!credential) return expressError(400, "Google credential is required"); + const accessToken = + payload.access_token || payload.token || payload.credential || payload.tokenId; + if (!accessToken) { + return expressError(400, "Missing fields: access_token"); + } await enforceRateLimit(RATE_LIMITS.login); - const result = await googleAuth(credential); + const result = await googleAuth(accessToken); - return json({ - message: result.isNewUser ? "User created successfully" : "LOGGED IN", - user: result.user, - token: result.token, - isNewUser: result.isNewUser, - needsProfile: result.needsProfile, - }); + // 201 when the account was just created, 200 otherwise; the message is + // "LOGGED IN" either way, matching the controller. GoogleSignup.jsx keys its + // toast off the status, not the message. + return json( + { + message: "LOGGED IN", + user: result.user, + token: result.token, + isNewUser: result.isNewUser, + needsProfile: result.needsProfile, + }, + result.isNewUser ? 201 : 200, + ); }); } diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 5aeefed..47c63b4 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -7,6 +7,7 @@ import { createSessionToken, setSessionCookie } from "@/lib/auth/session"; import { consumeOtp, verifyOtp } from "@/lib/services/otp"; import { sendMail } from "@/lib/email/mailer"; import { welcomeEmail } from "@/lib/email/templates"; +import { normalizeYear } from "@/lib/academic"; /** * POST /api/auth/register @@ -59,7 +60,16 @@ export async function POST(request: Request) { rollNumber: data.rollNumber || null, school: data.school || null, college: data.college || null, - year: data.year || null, + // Exactly what the user selected — never inferred. Anyone can sign up + // with a personal address, so the roll number beside this field is not + // guaranteed to be a KIIT one, and guessing from it would quietly stamp + // a wrong year on the account. Lateral entry breaks the inference even + // when the roll number *is* real: a 2025 LE student sits in 2nd year + // with the 2024 batch. + // + // `normalizeYear` only folds spelling ("3rd Year" -> "3rd"); it never + // invents a value. + year: normalizeYear(data.year), contactNo: data.contactNo || null, whatsappNo: data.whatsappNo || null, img: data.img || null, diff --git a/app/api/blog/createBlog/route.ts b/app/api/blog/createBlog/route.ts index 13dd821..26c8e00 100644 --- a/app/api/blog/createBlog/route.ts +++ b/app/api/blog/createBlog/route.ts @@ -35,7 +35,7 @@ export async function POST(request: Request) { let image = text("image"); const file = form.get("image"); if (file instanceof File && file.size > 0) { - const result = await uploadImage(file, "BlogImages", 1200, 800); + const result = await uploadImage(file, "BlogImages"); image = result?.secure_url ?? image; } diff --git a/app/api/blog/updateBlog/[id]/route.ts b/app/api/blog/updateBlog/[id]/route.ts index bcfc8cb..106508b 100644 --- a/app/api/blog/updateBlog/[id]/route.ts +++ b/app/api/blog/updateBlog/[id]/route.ts @@ -60,7 +60,7 @@ export async function PUT( const file = form.get("image"); if (file instanceof File && file.size > 0) { - const result = await uploadImage(file, "BlogImages", 1200, 800); + const result = await uploadImage(file, "BlogImages"); if (result) data.image = result.secure_url; } else { const imageUrl = text("image"); diff --git a/app/api/form/addForm/route.ts b/app/api/form/addForm/route.ts index 6fbd2a9..c042917 100644 --- a/app/api/form/addForm/route.ts +++ b/app/api/form/addForm/route.ts @@ -14,10 +14,16 @@ import { uploadImage } from "@/lib/services/upload"; * fields, and assembles the same `info` blob the original wrote, so existing * documents and new ones stay structurally identical. */ -const FORM_IMAGE_W = 1000; -const FORM_IMAGE_H = 1000; -const QR_IMAGE_W = 500; -const QR_IMAGE_H = 500; +/** + * Upload dimensions come from `lib/config/images.ts`. + * + * They were literals here and in `editForm`, and the two had drifted: Express + * declares `QrImageWidth = 400, QrImageHeight = 150` in both controllers, but + * `addForm` passed them into `uploadimage(path, folder, height, width)` in the + * wrong order, so it uploaded QR media at 150x400 while `editForm` used + * 400x150. Centralising picks the declared intent, which is what `editForm` + * already did — so this call changes, deliberately, to match. + */ export async function POST(request: Request) { return handle(async () => { @@ -55,23 +61,13 @@ export async function POST(request: Request) { const eventImg = form.get("eventImg"); if (eventImg instanceof File && eventImg.size > 0) { - const result = await uploadImage( - eventImg, - "FormImages", - FORM_IMAGE_W, - FORM_IMAGE_H, - ); + const result = await uploadImage(eventImg, "FormImages"); info.eventImg = result?.secure_url ?? null; } const media = form.get("media"); if (media instanceof File && media.size > 0) { - const result = await uploadImage( - media, - "QRMediaImages", - QR_IMAGE_W, - QR_IMAGE_H, - ); + const result = await uploadImage(media, "QRMediaImages"); (info.receiverDetails as { media: string | null }).media = result?.secure_url ?? null; } @@ -96,9 +92,11 @@ export async function POST(request: Request) { // Drop the cached listing so the new event shows up immediately. revalidatePath("/Events"); - return json( - { success: true, message: "Form added successfully", form: created }, - 201, - ); + // 200 and this wording are what the Express controller returned. + return json({ + success: true, + message: "Form created successfully", + form: created, + }); }); } diff --git a/app/api/form/attendanceCode/[id]/route.ts b/app/api/form/attendanceCode/[id]/route.ts index f7e2659..04f3359 100644 --- a/app/api/form/attendanceCode/[id]/route.ts +++ b/app/api/form/attendanceCode/[id]/route.ts @@ -3,12 +3,16 @@ import { expressError, handle, json } from "@/lib/api/express"; import { getCurrentUser } from "@/lib/auth/access"; /** - * GET /api/form/attendanceCode/:id - * Port of controllers/registration/getAttendanceCode — the value the - * attendee's QR code encodes. + * GET /api/form/attendanceCode/:id?teamCode= + * Port of controllers/registration/getAttendanceCode. + * + * Responds `{ message, attendanceToken }` at the top level: QRCodeModal reads + * `response.data.attendanceToken` and encodes it straight into the QR image. + * `attendanceToken` is a signed JWT expiring in 20 minutes, not the attendance + * record's id. */ export async function GET( - _request: Request, + request: Request, ctx: RouteContext<"/api/form/attendanceCode/[id]">, ) { return handle(async () => { @@ -16,8 +20,8 @@ export async function GET( if (!user) return expressError(401, "Token is required"); const { id } = await ctx.params; - const data = await getAttendanceCode(id, user); + const teamCode = new URL(request.url).searchParams.get("teamCode"); - return json({ success: true, data }); + return json(await getAttendanceCode(id, user, teamCode)); }); } diff --git a/app/api/form/createTeam/route.ts b/app/api/form/createTeam/route.ts index 1361c9c..e5131c9 100644 --- a/app/api/form/createTeam/route.ts +++ b/app/api/form/createTeam/route.ts @@ -11,9 +11,22 @@ export async function POST(request: Request) { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - const b = await body>(request); - const data = await createTeam({ user, formId: b.formId ?? b._id, teamName: b.teamName ?? '' }); + const b = await body<{ formId?: string; teamName?: string }>(request); + if (!b.formId || !b.teamName) { + return expressError(400, "Form ID and team name are required"); + } - return json({ success: true, message: "Team created successfully", data }); + const data = await createTeam({ + user, + formId: b.formId, + teamName: b.teamName, + }); + + // TeamlessState.jsx shows this verbatim, so it names the team as before. + return json({ + success: true, + message: `Team "${data.teamName}" created successfully!`, + data, + }); }); } diff --git a/app/api/form/editForm/[id]/route.ts b/app/api/form/editForm/[id]/route.ts index a2859b4..36fc189 100644 --- a/app/api/form/editForm/[id]/route.ts +++ b/app/api/form/editForm/[id]/route.ts @@ -76,13 +76,13 @@ export async function PUT( const eventImg = form.get("eventImg"); if (eventImg instanceof File && eventImg.size > 0) { - const result = await uploadImage(eventImg, "FormImages", 1000, 1000); + const result = await uploadImage(eventImg, "FormImages"); if (result) info.eventImg = result.secure_url; } const media = form.get("media"); if (media instanceof File && media.size > 0) { - const result = await uploadImage(media, "QRMediaImages", 500, 500); + const result = await uploadImage(media, "QRMediaImages"); if (result) { info.receiverDetails = { ...(info.receiverDetails ?? {}), @@ -110,7 +110,7 @@ export async function PUT( return json({ success: true, - message: "Form updated successfully", + message: "Form info and sections updated successfully", form: updated, }); }); diff --git a/app/api/form/export-attendance/[id]/route.ts b/app/api/form/export-attendance/[id]/route.ts index 3ad6542..3e2d4dc 100644 --- a/app/api/form/export-attendance/[id]/route.ts +++ b/app/api/form/export-attendance/[id]/route.ts @@ -1,10 +1,14 @@ import { exportAttendance } from "@/lib/services/attendance"; import { expressError, handle } from "@/lib/api/express"; -import { getCurrentUser, isMember } from "@/lib/auth/access"; +import { getCurrentUser, isAdmin } from "@/lib/auth/access"; /** * GET /api/form/export-attendance/:id - * Port of controllers/registration/exportAttendance — club members only. + * Port of controllers/registration/exportAttendance. + * + * Admin only, matching the route's `checkAccess("ADMIN")`. This previously + * accepted any club member, which handed the full attendee list of any event to + * every executive rather than to admins alone. */ export async function GET( _request: Request, @@ -13,7 +17,7 @@ export async function GET( return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - if (!isMember(user)) return expressError(403, "Unauthorized"); + if (!isAdmin(user)) return expressError(403, "Unauthorized"); const { id } = await ctx.params; const { filename, csv } = await exportAttendance(id); diff --git a/app/api/form/getFormAnalytics/[id]/route.ts b/app/api/form/getFormAnalytics/[id]/route.ts index fa2f5be..f54c5e5 100644 --- a/app/api/form/getFormAnalytics/[id]/route.ts +++ b/app/api/form/getFormAnalytics/[id]/route.ts @@ -1,14 +1,18 @@ import { prisma } from "@/lib/db"; import { expressError, handle, json } from "@/lib/api/express"; -import { getCurrentUser, isMember } from "@/lib/auth/access"; +import { getCurrentUser } from "@/lib/auth/access"; +import { can } from "@/lib/auth/permissions"; +import { envList, getEnv } from "@/lib/env"; /** * GET /api/form/getFormAnalytics/:id * Port of controllers/forms/analytics.js. * - * Requires a club member: the original was mounted without any access check, so - * the full registrant email list of any event was readable by anyone signed in. + * Response is `{ message, form, yearCounts }` at the top level, because + * EventStats.jsx reads `response.data.form.formAnalytics`, + * `response.data.form.info` and `response.data.yearCounts` directly. */ + export async function GET( _request: Request, ctx: RouteContext<"/api/form/getFormAnalytics/[id]">, @@ -16,33 +20,65 @@ export async function GET( return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - if (!isMember(user)) return expressError(403, "Unauthorized"); + + // Role list lives in `lib/auth/permissions.ts`; the address allowlist that + // sits beside it is `FORM_ANALYTICS_ALLOWED_EMAILS` in the environment, + // both previously hardcoded here. + const allowedEmails = envList(getEnv().FORM_ANALYTICS_ALLOWED_EMAILS).map( + (entry) => entry.toLowerCase(), + ); + + // 401 rather than 403, matching `ApiError(status.UNAUTHORIZED, ...)`. + if ( + !can(user, "FORM_ANALYTICS_VIEW") && + !allowedEmails.includes(user.email.toLowerCase()) + ) { + return expressError(401, "Access Denied"); + } const { id } = await ctx.params; + // The original passed the id straight to Prisma; a non-ObjectId throws + // there and surfaces as a 500. Screened here so it reads as "not found". if (!/^[a-f\d]{24}$/i.test(id)) return expressError(404, "Form not found"); + // The original wrote `include: { formAnalytics: true, sections: false }`. + // `sections` is a scalar JSON column, not a relation, so listing it in + // `include` never excluded anything — scalars come back regardless unless a + // `select` says otherwise. Dropping it returns exactly the same row. const form = await prisma.form.findUnique({ where: { id }, - include: { formAnalytics: true, userReg: true }, + include: { formAnalytics: true }, }); if (!form) return expressError(404, "Form not found"); - const tracker = form.formAnalytics[0] ?? null; - - return json({ - success: true, - message: "Analytics fetched successfully", - data: { - formId: id, - info: form.info, - totalRegistrationCount: - tracker?.totalRegistrationCount ?? form.userReg.length, - totalClickCount: tracker?.totalClickCount ?? 0, - regUserEmails: tracker?.regUserEmails ?? [], - regTeamNames: tracker?.regTeamNames ?? [], - faildAttemptCount: tracker?.faildAttemptCount ?? 0, - registrations: form.userReg, - }, - }); + if (form.formAnalytics.length === 0) { + return expressError(404, "No users have registered to this form yet"); + } + + const formAnalytics = form.formAnalytics[0]; + + // Registrant counts bucketed by the first word of `year` ("2nd Year" -> "2nd"). + // A user with no year lands under the key "null", exactly as before: the + // original initialised `year` to null and used it as the object key. + let yearCounts: Record | undefined; + try { + const users = await prisma.user.findMany({ + where: { email: { in: formAnalytics.regUserEmails } }, + }); + + yearCounts = users.reduce>((acc, obj) => { + let year: string | null = null; + if (obj.year) year = obj.year.split(" ")[0]; + + acc[year as string] = (acc[year as string] || 0) + 1; + return acc; + }, {}); + } catch (error) { + // Matches the original: the failure is logged and the response still goes + // out, with `yearCounts` undefined. + console.error("Error fetching all the users form the array list", error); + } + + return json({ message: "success", form, yearCounts }); }); } diff --git a/app/api/form/inviteTeamMember/route.ts b/app/api/form/inviteTeamMember/route.ts index 7bd51f8..1ca2adc 100644 --- a/app/api/form/inviteTeamMember/route.ts +++ b/app/api/form/inviteTeamMember/route.ts @@ -17,13 +17,21 @@ export async function POST(request: Request) { await enforceRateLimit({ ...RATE_LIMITS.otpRequest, subject: user.id }); - const b = await body>(request); + const b = await body<{ formId?: string; inviteeEmail?: string }>(request); + if (!b.formId || !b.inviteeEmail) { + return expressError(400, "Form ID and invitee email are required"); + } + const data = await inviteTeamMember({ user, - formId: b.formId ?? b._id ?? "", - inviteeEmail: b.inviteeEmail ?? b.email ?? "", + formId: b.formId, + inviteeEmail: b.inviteeEmail, }); - return json({ success: true, message: "Invitation sent", data }); + return json({ + success: true, + message: `Invitation sent to ${b.inviteeEmail.trim().toLowerCase()}`, + data, + }); }); } diff --git a/app/api/form/joinTeam/route.ts b/app/api/form/joinTeam/route.ts index 187be42..f030193 100644 --- a/app/api/form/joinTeam/route.ts +++ b/app/api/form/joinTeam/route.ts @@ -11,9 +11,21 @@ export async function POST(request: Request) { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - const b = await body>(request); - const data = await joinTeam({ user, formId: b.formId ?? b._id, teamCode: b.teamCode ?? '' }); + const b = await body<{ formId?: string; teamCode?: string }>(request); + if (!b.formId || !b.teamCode) { + return expressError(400, "Form ID and team code are required"); + } - return json({ success: true, message: "Joined the team successfully", data }); + const data = await joinTeam({ + user, + formId: b.formId, + teamCode: b.teamCode, + }); + + return json({ + success: true, + message: `Successfully joined team "${data.teamName}"!`, + data, + }); }); } diff --git a/app/api/form/leaveTeam/route.ts b/app/api/form/leaveTeam/route.ts index 57aea02..55f3372 100644 --- a/app/api/form/leaveTeam/route.ts +++ b/app/api/form/leaveTeam/route.ts @@ -5,15 +5,24 @@ import { getCurrentUser } from "@/lib/auth/access"; /** * POST /api/form/leaveTeam * Port of controllers/registration/leaveTeam.js. + * + * The message is built from the outcome because TeamManagement.jsx puts + * `response.data.message` straight into the toast, and the original distinguishes + * a leader dissolving a team from a member leaving one. */ export async function POST(request: Request) { return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - const b = await body>(request); - const data = await leaveTeam({ user, formId: b.formId ?? b._id }); + const b = await body<{ formId?: string }>(request); + if (!b.formId) return expressError(400, "Form ID is required"); - return json({ success: true, message: "Left the team successfully", data }); + const { action, oldTeamName } = await leaveTeam({ user, formId: b.formId }); + + return json({ + success: true, + message: `Successfully ${action} the team "${oldTeamName}". You can now create or join another team.`, + }); }); } diff --git a/app/api/form/markAttendance/route.ts b/app/api/form/markAttendance/route.ts index 736b26f..73dcfe9 100644 --- a/app/api/form/markAttendance/route.ts +++ b/app/api/form/markAttendance/route.ts @@ -1,25 +1,28 @@ import { markAttendance } from "@/lib/services/attendance"; import { body, expressError, handle, json } from "@/lib/api/express"; -import { getCurrentUser, isMember } from "@/lib/auth/access"; +import { getCurrentUser } from "@/lib/auth/access"; /** * POST /api/form/markAttendance * Port of controllers/registration/markAttendance.js. * - * Requires a club member. The Express route had its `checkAccess` call - * commented out, so any signed-in user could mark any attendee present. + * Signed-in callers only, with no access-level check — matching the Express + * route, which has its `checkAccess` call commented out. That is deliberate on + * their side: the volunteer scanning at the door signs in as a plain USER, so + * requiring club-member access locks the door staff out. The real control is + * the signed, 20-minute QR token, which `markAttendance` verifies. + * + * Responds `{ message, attendance }` at the top level, which is the shape + * AttendancePage reads. */ export async function POST(request: Request) { return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - if (!isMember(user)) return expressError(403, "Unauthorized"); - const b = await body>(request); - const data = await markAttendance({ - attendanceId: b.attendanceId ?? b.token ?? b.id ?? "", - }); + const b = await body<{ formId?: string; token?: string }>(request); + const result = await markAttendance({ formId: b.formId, token: b.token }); - return json({ success: true, message: data.message, data }); + return json(result); }); } diff --git a/app/api/form/removeTeamMember/route.ts b/app/api/form/removeTeamMember/route.ts index 17b4f19..32f6975 100644 --- a/app/api/form/removeTeamMember/route.ts +++ b/app/api/form/removeTeamMember/route.ts @@ -5,15 +5,30 @@ import { getCurrentUser } from "@/lib/auth/access"; /** * POST /api/form/removeTeamMember * Port of controllers/registration/removeTeamMember.js. + * + * Body is `{ formId, memberEmail }`. The message names both the address as + * typed and the normalised one the notification went to, as the original did — + * TeamManagement.jsx shows it verbatim in a toast. */ export async function POST(request: Request) { return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - const b = await body>(request); - const data = await removeTeamMember({ user, formId: b.formId ?? b._id, email: b.email ?? b.memberEmail ?? '' }); + const b = await body<{ formId?: string; memberEmail?: string }>(request); + if (!b.formId || !b.memberEmail) { + return expressError(400, "Form ID and member email are required"); + } - return json({ success: true, message: "Member removed successfully", data }); + const { memberEmail, normalizedEmail } = await removeTeamMember({ + user, + formId: b.formId, + memberEmail: b.memberEmail, + }); + + return json({ + success: true, + message: `Successfully removed ${memberEmail} from the team & informed through ${normalizedEmail}`, + }); }); } diff --git a/app/api/form/renameTeam/route.ts b/app/api/form/renameTeam/route.ts index 43772cd..5fb4008 100644 --- a/app/api/form/renameTeam/route.ts +++ b/app/api/form/renameTeam/route.ts @@ -5,15 +5,35 @@ import { getCurrentUser } from "@/lib/auth/access"; /** * PATCH /api/form/renameTeam * Port of controllers/registration/renameTeam.js. + * + * Body is `{ formId, newTeamName }`. This route previously read `teamName`, + * which TeamManagement.jsx never sends, so every rename failed on an empty + * name. The success message echoes the new name because the UI surfaces it. */ export async function PATCH(request: Request) { return handle(async () => { const user = await getCurrentUser(); if (!user) return expressError(401, "Token is required"); - const b = await body>(request); - const data = await renameTeam({ user, formId: b.formId ?? b._id, teamName: b.teamName ?? '' }); + const b = await body<{ formId?: string; newTeamName?: string }>(request); + if (!b.formId || !b.newTeamName) { + return expressError(400, "Form ID and new team name are required"); + } - return json({ success: true, message: "Team renamed successfully", data }); + const data = await renameTeam({ + user, + formId: b.formId, + teamName: b.newTeamName, + }); + + // The controller answers 200 "Team name unchanged" when the submitted name + // matches the current one, rather than treating it as a duplicate. + return json({ + success: true, + message: data.unchanged + ? "Team name unchanged" + : `Team renamed to "${data.teamName}"`, + data: { teamName: data.teamName }, + }); }); } diff --git a/app/api/form/searchTeams/[formId]/route.ts b/app/api/form/searchTeams/[formId]/route.ts index 6db9f41..b35f224 100644 --- a/app/api/form/searchTeams/[formId]/route.ts +++ b/app/api/form/searchTeams/[formId]/route.ts @@ -3,8 +3,13 @@ import { expressError, handle, json } from "@/lib/api/express"; import { getCurrentUser } from "@/lib/auth/access"; /** - * GET /api/form/searchTeams/:formId?q= + * GET /api/form/searchTeams/:formId?search= * Port of controllers/registration/searchTeams.js — teams with room left. + * + * Two details the UI depends on: the query parameter is `search` (this route + * read `q`, which `TeamlessState.jsx` never sends, so typing in the box filtered + * nothing), and the list is nested as `data.teams` — the component reads + * `response.data.data.teams`. */ export async function GET( request: Request, @@ -15,10 +20,11 @@ export async function GET( if (!user) return expressError(401, "Token is required"); const { formId } = await ctx.params; - const query = new URL(request.url).searchParams.get("q") ?? ""; + if (!formId) return expressError(400, "Form ID is required"); - const teams = await searchTeams(formId, query); + const search = new URL(request.url).searchParams.get("search") ?? ""; + const teams = await searchTeams(formId, search, user.email); - return json({ success: true, message: "Teams fetched successfully", data: teams }); + return json({ success: true, data: { teams } }); }); } diff --git a/app/api/form/sendJoinRequest/route.ts b/app/api/form/sendJoinRequest/route.ts index 937e1e2..ed4a299 100644 --- a/app/api/form/sendJoinRequest/route.ts +++ b/app/api/form/sendJoinRequest/route.ts @@ -6,6 +6,10 @@ import { getCurrentUser } from "@/lib/auth/access"; /** * POST /api/form/sendJoinRequest * Port of controllers/registration/sendJoinRequest.js. + * + * Body is `{ formId, teamRegistrationId }` — the id `searchTeams` returns for + * each team. This route previously looked for `teamCode`, which the UI never + * sends, so every join request was rejected. */ export async function POST(request: Request) { return handle(async () => { @@ -14,13 +18,24 @@ export async function POST(request: Request) { await enforceRateLimit({ ...RATE_LIMITS.registration, subject: user.id }); - const b = await body>(request); + const b = await body<{ formId?: string; teamRegistrationId?: string }>( + request, + ); + if (!b.formId || !b.teamRegistrationId) { + return expressError(400, "Form ID and team registration ID are required"); + } + const data = await sendJoinRequest({ user, - formId: b.formId ?? b._id ?? "", - teamCode: b.teamCode ?? "", + formId: b.formId, + teamRegistrationId: b.teamRegistrationId, }); - return json({ success: true, message: "Join request sent", data }); + return json({ + success: true, + message: + "Join request sent to the team leader. They will receive an email with your request.", + data, + }); }); } diff --git a/app/api/form/teamDetails/[formId]/route.ts b/app/api/form/teamDetails/[formId]/route.ts index 1675ff1..b857ece 100644 --- a/app/api/form/teamDetails/[formId]/route.ts +++ b/app/api/form/teamDetails/[formId]/route.ts @@ -1,14 +1,21 @@ import { prisma } from "@/lib/db"; import { expressError, handle, json } from "@/lib/api/express"; import { getCurrentUser } from "@/lib/auth/access"; +import type { EventInfo } from "@/lib/types/event"; /** * GET /api/form/teamDetails/:formId * Port of controllers/registration/getTeamDetails.js. * - * Returns only the caller's own registration for the form — the original looked - * the record up by form alone in places, which exposed other teams' member - * lists. + * Drives the whole team management page, so the payload has to match field for + * field: `TeamManagement.jsx` reads `data.isTeamless`, `teamData.eventTitle`, + * `.leaderEmail`, `.maxTeamSize`, `.minTeamSize`, `.teamSize`, `.teamCode`, + * `.teamName`, `.members`, `.isRegistrationClosed` and `.isEventPast`, and + * `MemberCard.jsx` reads `member.name/email/img/college/year`. + * + * The registration is looked up by **membership**, not ownership. An earlier + * version matched on `userId`, which is only ever the team leader's id — so + * every other member of a team got "no registration found" and an empty page. */ export async function GET( _request: Request, @@ -19,48 +26,88 @@ export async function GET( if (!user) return expressError(401, "Token is required"); const { formId } = await ctx.params; + if (!formId) return expressError(400, "Form ID is required"); + // A malformed id makes Prisma throw; answer as "not found" instead. if (!/^[a-f\d]{24}$/i.test(formId)) { - return expressError(404, "Form not found"); + return expressError( + 404, + "No team registration found for this user in the specified form", + ); } - const registration = await prisma.formRegistration.findFirst({ - where: { formId, userId: user.id }, + const teamRegistration = await prisma.formRegistration.findFirst({ + where: { formId, regTeamMemEmails: { has: user.email } }, + include: { form: { select: { info: true } } }, }); - if (!registration) { + if (!teamRegistration) { + return expressError( + 404, + "No team registration found for this user in the specified form", + ); + } + + const info = (teamRegistration.form.info ?? {}) as EventInfo; + + if (info.participationType !== "Team") { + return expressError(400, "This is not a team event"); + } + + const maxTeamSize = Number.parseInt(String(info.maxTeamSize ?? ""), 10) || 1; + const minTeamSize = Number.parseInt(String(info.minTeamSize ?? ""), 10) || 1; + + // Registered for the event but not yet on a team — the UI swaps in + // TeamlessState, which needs the event's limits to offer team creation. + if (teamRegistration.teamName === "UNAFFILIATED") { return json({ success: true, - message: "No registration found", - data: null, + message: "User is registered but not yet on a team", + data: { + isTeamless: true, + eventTitle: info.eventTitle, + maxTeamSize, + minTeamSize, + isRegistrationClosed: info.isRegistrationClosed || false, + isEventPast: info.isEventPast || false, + formId: teamRegistration.formId, + }, }); } - // Everyone on the same team code, so the UI can list teammates. - const teammates = await prisma.formRegistration.findMany({ - where: { formId, teamCode: registration.teamCode }, - select: { regTeamMemEmails: true, teamSize: true }, + const members = await prisma.user.findMany({ + where: { email: { in: teamRegistration.regTeamMemEmails } }, + select: { + name: true, + email: true, + img: true, + rollNumber: true, + college: true, + year: true, + }, }); - const memberEmails = [ - ...new Set(teammates.flatMap((t) => t.regTeamMemEmails)), - ]; - - const members = await prisma.user.findMany({ - where: { email: { in: memberEmails } }, - select: { name: true, email: true, img: true }, + // `userId` on the registration is the leader; the UI compares this against + // the signed-in user to decide who may rename, invite and remove. + const leaderUser = await prisma.user.findUnique({ + where: { id: teamRegistration.userId }, + select: { email: true }, }); return json({ success: true, - message: "Team details fetched successfully", + message: "Team details retrieved successfully", data: { - registrationId: registration.id, - teamName: registration.teamName, - teamCode: registration.teamCode, - teamSize: registration.teamSize, - regTeamMemEmails: registration.regTeamMemEmails, + teamName: teamRegistration.teamName, + teamCode: teamRegistration.teamCode, + teamSize: teamRegistration.teamSize, + maxTeamSize, + minTeamSize, members, - isLeader: registration.userId === user.id, + eventTitle: info.eventTitle, + leaderEmail: leaderUser?.email || null, + isRegistrationClosed: info.isRegistrationClosed || false, + isEventPast: info.isEventPast || false, + formId: teamRegistration.formId, }, }); }); diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..43c99fe --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from "next/server"; + +import { prisma } from "@/lib/db"; + +/** + * GET|HEAD /api/health + * + * Liveness/readiness probe for uptime monitors and container orchestrators. + * There was no equivalent in the Express backend — monitors were pointed at + * `/api/form/getAllForms`, which reads the whole forms collection on every + * poll and returns 200 even when the database is unreachable but cached. + * + * 200 { status: "ok" } every dependency answered + * 503 { status: "unhealthy" } at least one did not + * + * A monitor only needs the status code; the body is for a human reading it + * after an alert fires. + */ + +// Never prerendered or cached: a health check answering from the build output +// would report "ok" for a process that is not actually serving. +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +/** + * A hung TCP connection to Atlas does not reject — it hangs until the driver's + * own (much longer) timeout. A probe that hangs reads as a timeout to the + * monitor rather than a clean 503, so the check is bounded here. + */ +const DB_TIMEOUT_MS = 3000; + +type CheckResult = { + status: "ok" | "error"; + latencyMs: number; + error?: string; +}; + +async function checkDatabase(): Promise { + const startedAt = Date.now(); + let timer: NodeJS.Timeout | undefined; + + try { + // `ping` is the cheapest command the MongoDB driver exposes: it touches no + // collection and reads no documents, so polling it every few seconds costs + // nothing. A `findFirst` against a real collection would also exercise the + // schema, but it puts load on a production database on every poll. + await Promise.race([ + prisma.$runCommandRaw({ ping: 1 }), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out after ${DB_TIMEOUT_MS}ms`)), + DB_TIMEOUT_MS, + ); + }), + ]); + + return { status: "ok", latencyMs: Date.now() - startedAt }; + } catch (error) { + // The message can carry the connection string, which holds the Atlas + // password. `/api/health` is unauthenticated, so only the error's class and + // a fixed message are exposed; the detail goes to the server log. + console.error("[health] database check failed:", error); + + const message = + error instanceof Error && error.message.startsWith("timed out") + ? error.message + : "unreachable"; + + return { status: "error", latencyMs: Date.now() - startedAt, error: message }; + } finally { + clearTimeout(timer); + } +} + +async function runChecks() { + const database = await checkDatabase(); + const healthy = database.status === "ok"; + + return { + healthy, + body: { + status: healthy ? ("ok" as const) : ("unhealthy" as const), + timestamp: new Date().toISOString(), + // Seconds this Node process has been up. On serverless this resets with + // every cold start and is expected to be small — it is a signal about the + // instance answering, not about the deployment's age. + uptime: Math.round(process.uptime()), + // Set by Vercel; absent elsewhere. A commit SHA is not a secret and makes + // "which build is actually live" answerable from the probe itself. + version: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null, + environment: process.env.NODE_ENV, + checks: { database }, + }, + }; +} + +const NO_STORE = { + "Cache-Control": "no-store, no-cache, must-revalidate", +} as const; + +export async function GET() { + const { healthy, body } = await runChecks(); + + return NextResponse.json(body, { + status: healthy ? 200 : 503, + headers: NO_STORE, + }); +} + +/** + * Uptime monitors commonly poll with HEAD to avoid transferring a body. Next + * does not derive HEAD from GET for route handlers, so without this a monitor + * configured for HEAD gets 405 and reports the site as down. + */ +export async function HEAD() { + const { healthy } = await runChecks(); + + return new Response(null, { + status: healthy ? 200 : 503, + headers: NO_STORE, + }); +} diff --git a/app/api/user/editDetails/route.ts b/app/api/user/editDetails/route.ts index a2e1844..3f02663 100644 --- a/app/api/user/editDetails/route.ts +++ b/app/api/user/editDetails/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { body, expressError, handle, json } from "@/lib/api/express"; import { getCurrentUser, isAdmin, toSafeUser } from "@/lib/auth/access"; +import { normalizeYear } from "@/lib/academic"; /** * PUT /api/user/editDetails @@ -54,7 +55,11 @@ export async function PUT(request: Request) { college: data.college ?? target.college, contactNo: data.contactNo ?? target.contactNo, whatsappNo: data.whatsappNo ?? target.whatsappNo, - year: data.year ?? target.year, + // Editable, and never recomputed behind the user's back: a + // lateral-entry student sets a year their roll number does not imply, + // and re-deriving on save would silently undo it every time. Only + // normalised, so the stored spelling stays consistent. + year: normalizeYear(data.year) ?? target.year, img: data.img ?? target.img, extra, // Access is never taken from the request body unless an admin sets it. diff --git a/app/api/user/editProfileImage/route.ts b/app/api/user/editProfileImage/route.ts index c2bdd40..b21e60a 100644 --- a/app/api/user/editProfileImage/route.ts +++ b/app/api/user/editProfileImage/route.ts @@ -4,9 +4,8 @@ import { enforceRateLimit, RATE_LIMITS } from "@/lib/api/rate-limit"; import { getCurrentUser } from "@/lib/auth/access"; import { uploadImage } from "@/lib/services/upload"; -/** Cloudinary caps and the square avatar the profile UI renders. */ +/** Upload size cap. The avatar's pixel bounds live in `lib/config/images.ts`. */ const MAX_BYTES = 5 * 1024 * 1024; -const AVATAR = 512; /** * POST /api/user/editProfileImage @@ -40,7 +39,7 @@ export async function POST(request: Request) { return expressError(415, "That file is not an image"); } - const result = await uploadImage(file, "ProfileImages", AVATAR, AVATAR); + const result = await uploadImage(file, "ProfileImages"); if (!result) { return expressError(502, "Could not upload the image. Please try again."); } diff --git a/app/globals.scss b/app/globals.scss index c4e5eac..04b15ee 100644 --- a/app/globals.scss +++ b/app/globals.scss @@ -16,6 +16,14 @@ // text silently fell back to a system sans-serif and every line box was ~2px // shorter than the original. +// All three vendor stylesheets the original index.scss pulled in. Only the +// react-datepicker one was carried over at first, which left the +// on the admin form with no styling at all: react-date-picker renders its popup +// with react-calendar, and with Calendar.css missing the calendar opened as an +// unstyled column of numbers. Verified by grepping the emitted CSS — zero +// `.react-calendar` rules shipped. +@import "react-date-picker/dist/DatePicker.css"; +@import "react-calendar/dist/Calendar.css"; @import "react-datepicker/dist/react-datepicker.css"; // The brand gradient. Lived in Global.scss on the original site; it moved here @@ -40,6 +48,32 @@ border: none !important; } +/** + * Lifted out of TeamCard.module.scss, where it sits as a top-level bare + * `button { }` rule. + * + * Vite emits every CSS Module into one stylesheet for the whole SPA, and it does + * not hash element selectors — so that rule was live on every page of the + * original site. Next code-splits CSS per route, so once ported it only loaded + * where TeamCard did (/Team, /profile/members). Everywhere else buttons fell + * back to the user-agent appearance: measured on /otp, the disabled "Resend OTP" + * button rendered with `background-color: rgba(19, 1, 1, 0.3)` and a + * `2px outset` border against the original's transparent / `0px none`. + * + * Declared here so the cascade matches the original app. It is only specificity + * 0-0-1, so every component's own class rules still win, exactly as before. + */ +button { + color: #ff8a00; + background-color: transparent; + border: none; + cursor: pointer; + margin-top: 10px; + font-size: 1.2em; + font-weight: 500; + margin-bottom: 15px; +} + html { scroll-behavior: smooth; } diff --git a/app/layout.tsx b/app/layout.tsx index a6a8af9..23caba4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next"; import Script from "next/script"; import Providers from "@/src/context/Providers"; +import Chatbot from "@/src/components/Chatbot/Chatbot"; import { JsonLd } from "@/components/seo/JsonLd"; import { SITE } from "@/lib/site"; import { SITE_URL } from "@/lib/seo/metadata"; @@ -59,7 +60,11 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - + // `data-scroll-behavior="smooth"` acknowledges the `scroll-behavior: smooth` + // that globals.scss sets on . Without it Next warns, because it has to + // decide whether a route change should animate the scroll to the top — with + // the attribute present it keeps the smooth scroll the original had. + {/* The exact font families and weights the original SCSS pulled in. @@ -80,7 +85,17 @@ export default function RootLayout({ - {children} + + {/* + App.jsx renders above , so it is present on + every route — the auth screens included. Mounting it in the (main) + layout instead hid it on /Login, /SignUp, /otp, /ForgotPassword and + /completeProfile. It sits inside Providers because it reads + AuthContext to attach the signed-in user to a conversation. + */} + + {children} + {/* Razorpay checkout, loaded in index.html on the original site. */}