diff --git a/.env.example b/.env.example index c40fddd..147eddf 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,11 @@ NEXT_PUBLIC_CHATBOT_NAME=FEDI CERT_ORG= NEXT_PUBLIC_CERT_ORG= +# When the current-year batch registration restriction went live. Registrations +# for emails starting with the year's two digits (e.g. 26…@ in 2026) made before +# this timestamp do not receive attendance QR codes. +BATCH_REGISTRATION_RESTRICTION_ENABLED_AT=2026-08-21T00:00:00.000Z + # --- Server ----------------------------------------------------------------- # Port the Next.js server listens on, for both `npm run dev` and `npm start`. # diff --git a/app/api/form/attendance-events/route.ts b/app/api/form/attendance-events/route.ts new file mode 100644 index 0000000..9d67a1f --- /dev/null +++ b/app/api/form/attendance-events/route.ts @@ -0,0 +1,27 @@ +import { + getAttendanceStats, + listAttendanceEvents, +} from "@/lib/services/attendance"; +import { expressError, handle, json } from "@/lib/api/express"; +import { getCurrentUser, isAdmin } from "@/lib/auth/access"; + +/** + * GET /api/form/attendance-events + * + * Lightweight event list for the attendance scanner — id + info only, no + * sections JSON. Avoids loading every form's full payload on door duty. + */ +export async function GET() { + return handle(async () => { + const user = await getCurrentUser(); + if (!user) return expressError(401, "Token is required"); + if (!isAdmin(user)) return expressError(403, "Unauthorized"); + + const events = await listAttendanceEvents(); + return json({ + success: true, + message: "Events fetched successfully", + events, + }); + }); +} diff --git a/app/api/form/attendance-stats/[id]/route.ts b/app/api/form/attendance-stats/[id]/route.ts new file mode 100644 index 0000000..70df564 --- /dev/null +++ b/app/api/form/attendance-stats/[id]/route.ts @@ -0,0 +1,24 @@ +import { getAttendanceStats } from "@/lib/services/attendance"; +import { expressError, handle, json } from "@/lib/api/express"; +import { getCurrentUser, isAdmin } from "@/lib/auth/access"; + +/** + * GET /api/form/attendance-stats/:id + * + * Live present / registered counts while scanning at the door. + */ +export async function GET( + _request: Request, + ctx: RouteContext<"/api/form/attendance-stats/[id]">, +) { + return handle(async () => { + const user = await getCurrentUser(); + if (!user) return expressError(401, "Token is required"); + if (!isAdmin(user)) return expressError(403, "Unauthorized"); + + const { id } = await ctx.params; + const stats = await getAttendanceStats(id); + + return json({ success: true, ...stats }); + }); +} diff --git a/app/api/form/register/route.ts b/app/api/form/register/route.ts index e85c233..43b120e 100644 --- a/app/api/form/register/route.ts +++ b/app/api/form/register/route.ts @@ -9,6 +9,10 @@ import { sendMail } from "@/lib/email/mailer"; import { registrationEmail } from "@/lib/email/templates"; import { uploadImage } from "@/lib/services/upload"; import type { EventInfo } from "@/lib/types/event"; +import { + batchRegistrationErrorMessage, + isBatchRegistrationBlocked, +} from "@/lib/batch-restriction"; /** * POST /api/form/register @@ -121,6 +125,10 @@ export async function POST(request: Request) { ); } + if (isBatchRegistrationBlocked(user.email)) { + return expressError(400, batchRegistrationErrorMessage()); + } + if ( tracker?.regUserEmails.includes(user.email) || user.regForm.includes(formId) diff --git a/app/globals.css b/app/globals.css index 02dcbfb..f7506bb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -19,7 +19,6 @@ --fed-border-hover: rgba(255, 255, 255, 0.16); --fed-orange: #f97316; --fed-orange-dark: #ea580c; - --fed-orange-glow: rgba(249, 115, 22, 0.25); --fed-text: #ffffff; --fed-muted: #888888; --fed-subtle: #555555; @@ -83,7 +82,7 @@ main>div { background-color: transparent !important; } -/* ─── Background Patterns & Glows ───────────────────────────────── */ +/* ─── Background Patterns ───────────────────────────────────────── */ .bg-grid-pattern { background-color: #080808; background-image: @@ -92,28 +91,6 @@ main>div { background-size: 64px 64px; } -.bg-grid-glow { - position: relative; -} - -.bg-top-orange-glow { - position: relative; -} - -.bg-top-orange-glow::before { - content: ""; - position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%); - width: 100%; - max-width: 1200px; - height: 600px; - background: radial-gradient(ellipse at 50% 0%, rgba(249, 115, 22, 0.18) 0%, rgba(249, 115, 22, 0.05) 45%, transparent 75%); - pointer-events: none; - z-index: 0; -} - /* ─── Responsive Section & Container Layouts ────────────────────── */ .fed-container { width: 100%; @@ -158,33 +135,6 @@ main>div { } } -/* ─── Alternating Section Glow Effects ───────────────────────────── */ -.section-glow-left::before { - content: ""; - position: absolute; - top: 50%; - left: -10%; - transform: translateY(-50%); - width: 55%; - height: 85%; - background: radial-gradient(ellipse at left center, rgba(249, 115, 22, 0.14) 0%, transparent 70%); - pointer-events: none; - z-index: 0; -} - -.section-glow-right::before { - content: ""; - position: absolute; - top: 50%; - right: -10%; - transform: translateY(-50%); - width: 55%; - height: 85%; - background: radial-gradient(ellipse at right center, rgba(249, 115, 22, 0.14) 0%, transparent 70%); - pointer-events: none; - z-index: 0; -} - /* ─── Navbar Styles ──────────────────────────────────────────────── */ .fed-navbar-wrapper { position: fixed; @@ -392,7 +342,6 @@ main>div { .fed-btn-orange:hover { background: #ff6611; transform: none !important; - box-shadow: 0 0 16px rgba(255, 85, 0, 0.55) !important; } .fed-btn-orange--full { @@ -481,7 +430,6 @@ main>div { border: 1px solid rgba(249, 115, 22, 0.7) !important; color: #ffffff !important; font-weight: 700 !important; - box-shadow: inset 0 0 12px rgba(249, 115, 22, 0.3) !important; } @media (min-width: 768px) { @@ -539,17 +487,15 @@ main>div { padding: 0.65rem 1.5rem; border-radius: 9999px; text-decoration: none; - transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease; + transition: background 0.2s ease, transform 0.15s ease; white-space: nowrap; border: none; cursor: pointer; - box-shadow: 0 4px 20px var(--fed-orange-glow); } .fed-btn-primary:hover { background: var(--fed-orange-dark); transform: translateY(-1px); - box-shadow: 0 6px 24px rgba(249, 115, 22, 0.45); } .fed-btn-secondary { @@ -565,14 +511,13 @@ main>div { padding: 0.65rem 1.5rem; border-radius: 9999px; text-decoration: none; - transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease; + transition: background 0.2s ease, transform 0.15s ease; white-space: nowrap; cursor: pointer; } .fed-btn-secondary:hover { background: rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.22); transform: translateY(-1px); } @@ -598,22 +543,6 @@ main>div { background: var(--fed-orange); border-radius: 50%; flex-shrink: 0; - box-shadow: 0 0 8px var(--fed-orange); - animation: pulse-dot 2s ease-in-out infinite; -} - -@keyframes pulse-dot { - - 0%, - 100% { - opacity: 1; - transform: scale(1); - } - - 50% { - opacity: 0.4; - transform: scale(0.75); - } } /* ─── Typography & Headings ──────────────────────────────────────── */ @@ -645,11 +574,10 @@ main>div { background: var(--fed-surface); border: 1px solid var(--fed-border); border-radius: 24px; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; } .fed-card:hover { - border-color: var(--fed-border-hover); transform: translateY(-2px); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); } @@ -661,11 +589,10 @@ main>div { border-radius: 24px; padding: 2.5rem 1.75rem; text-align: center; - transition: border-color 0.2s ease, transform 0.2s ease; + transition: transform 0.2s ease; } .fed-stat-card:hover { - border-color: rgba(255, 255, 255, 0.16); transform: translateY(-2px); } @@ -698,10 +625,6 @@ main>div { transition: border-color 0.2s ease; } -.fed-img-placeholder:hover { - border-color: rgba(249, 115, 22, 0.6); -} - .fed-img-placeholder-icon { width: 50px; height: 50px; @@ -719,31 +642,16 @@ main>div { border: 1px solid var(--fed-border); border-radius: 24px; overflow: hidden; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; } .fed-event-card:hover { - border-color: rgba(249, 115, 22, 0.4); transform: translateY(-4px); box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55); } .fed-event-card--live { border-color: rgba(34, 197, 94, 0.4); - box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.2), 0 0 45px rgba(34, 197, 94, 0.14); - animation: live-pulse 3s ease-in-out infinite; -} - -@keyframes live-pulse { - - 0%, - 100% { - box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.2), 0 0 40px rgba(34, 197, 94, 0.12); - } - - 50% { - box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.45), 0 0 65px rgba(34, 197, 94, 0.22); - } } .fed-event-tag { @@ -798,7 +706,7 @@ main>div { } .status-dot--live { - animation: pulse-dot 1.5s ease-in-out infinite; + background: currentColor; } /* ─── Sponsor Cards & Carousel ───────────────────────────────────── */ @@ -847,12 +755,11 @@ main>div { gap: 1rem; min-width: 200px; width: 200px; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; flex-shrink: 0; } .fed-sponsor-card:hover { - border-color: rgba(249, 115, 22, 0.4); transform: translateY(-3px); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5); } @@ -867,12 +774,7 @@ main>div { align-items: center; justify-content: center; color: rgba(249, 115, 22, 0.5); - transition: border-color 0.2s ease, background 0.2s ease; -} - -.fed-sponsor-card:hover .fed-sponsor-img-placeholder { - border-color: rgba(249, 115, 22, 0.6); - background: rgba(249, 115, 22, 0.06); + transition: background 0.2s ease; } /* ─── Testimonial Carousel ───────────────────────────────────────── */ @@ -912,8 +814,6 @@ main>div { .testimonial-dot--active { background: var(--fed-orange); - transform: scale(1.35); - box-shadow: 0 0 10px var(--fed-orange-glow); } .testimonial-nav-btn { @@ -927,13 +827,12 @@ main>div { align-items: center; justify-content: center; cursor: pointer; - transition: background 0.2s ease, border-color 0.2s ease; + transition: background 0.2s ease; flex-shrink: 0; } .testimonial-nav-btn:hover { background: rgba(255, 255, 255, 0.14); - border-color: rgba(255, 255, 255, 0.25); } .fed-testimonial-card { @@ -941,19 +840,18 @@ main>div { border: 1px solid var(--fed-border); border-radius: 24px; padding: 2.75rem 2.25rem; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; width: 100%; } .fed-testimonial-card:hover { - border-color: rgba(255, 255, 255, 0.18); transform: translateY(-3px); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45); } /* ─── CTA Banner ─────────────────────────────────────────────────── */ .fed-cta-banner { - background: radial-gradient(ellipse at 50% 100%, rgba(249, 115, 22, 0.2) 0%, rgba(17, 17, 17, 0.96) 75%); + background: rgba(17, 17, 17, 0.96); border: 1px solid var(--fed-border); border-radius: 28px; padding: 5rem 2.5rem; @@ -1060,17 +958,15 @@ main>div { border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 30px; overflow: hidden; - transition: border-color 0.25s ease, box-shadow 0.25s ease; + transition: box-shadow 0.25s ease; } .event-featured-card:hover { - border-color: rgba(249, 115, 22, 0.45); box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); } .event-featured-card--live { border-color: rgba(34, 197, 94, 0.35); - box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.12), 0 0 60px rgba(34, 197, 94, 0.1); } .event-grid-card { @@ -1080,18 +976,16 @@ main>div { overflow: hidden; display: flex; flex-direction: column; - transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease, box-shadow 0.25s ease; } .event-grid-card:hover { - border-color: rgba(249, 115, 22, 0.35); transform: translateY(-4px); box-shadow: 0 20px 48px rgba(0, 0, 0, 0.55); } .event-grid-card--live { border-color: rgba(34, 197, 94, 0.35); - animation: live-pulse 3s ease-in-out infinite; } .event-img-area { @@ -1205,13 +1099,11 @@ main>div { background: rgba(249, 115, 22, 0.42); border-radius: 9999px; border: 1px solid rgba(255, 255, 255, 0.15); - box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.3), 0 0 8px rgba(249, 115, 22, 0.25); - transition: all 0.25s ease; + transition: background 0.25s ease; } ::-webkit-scrollbar-thumb:hover { background: rgba(249, 115, 22, 0.75); - box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.45), 0 0 14px rgba(249, 115, 22, 0.45); } /* ─── Animations ─────────────────────────────────────────────────── */ @@ -1265,12 +1157,11 @@ main>div { align-items: center; text-decoration: none; border-radius: 9999px; - transition: transform 0.25s ease, box-shadow 0.25s ease; + transition: transform 0.25s ease; } .fed-avatar-link:hover { transform: translateY(-1px); - box-shadow: 0 0 0 2px rgba(255, 85, 0, 0.55); } .fed-avatar { diff --git a/app/layout.tsx b/app/layout.tsx index 1f2ddc0..69ffe73 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import Script from "next/script"; import Providers from "@/src/context/Providers"; import Chatbot from "@/src/components/Chatbot/Chatbot"; +import ToastProvider from "@/src/components/ToastProvider/ToastProvider"; import { JsonLd } from "@/components/seo/JsonLd"; import { SITE } from "@/lib/site"; import { SITE_URL } from "@/lib/seo/metadata"; @@ -101,6 +102,7 @@ export default function RootLayout({ AuthContext to attach the signed-in user to a conversation. */} + {children} diff --git a/lib/auth/session.ts b/lib/auth/session.ts index d35d529..fe68e9e 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -1,6 +1,6 @@ import "server-only"; -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import { SignJWT, jwtVerify } from "jose"; import { getEnv } from "@/lib/env"; @@ -61,11 +61,24 @@ export async function verifySessionToken( export async function setSessionCookie(token: string): Promise { const cookieStore = await cookies(); + + // Match the request scheme: ngrok serves over HTTPS and many browsers refuse + // to persist a non-Secure cookie there, which breaks proxy.ts route guards + // even though localStorage still has the token. + let secure = process.env.NODE_ENV === "production"; + try { + const headerList = await headers(); + const proto = headerList.get("x-forwarded-proto")?.split(",")[0]?.trim(); + if (proto) secure = proto === "https"; + } catch { + // headers() is unavailable outside a request scope — keep the default. + } + if (process.env.SESSION_COOKIE_SECURE === "true") secure = true; + if (process.env.SESSION_COOKIE_SECURE === "false") secure = false; + cookieStore.set(SESSION_COOKIE, token, { httpOnly: true, - // The old backend hardcoded `secure: true`, which silently breaks session - // cookies on http://localhost during development. - secure: process.env.NODE_ENV === "production", + secure, sameSite: "lax", path: "/", maxAge: SESSION_TTL_SECONDS, diff --git a/lib/batch-restriction.ts b/lib/batch-restriction.ts new file mode 100644 index 0000000..1e23635 --- /dev/null +++ b/lib/batch-restriction.ts @@ -0,0 +1,90 @@ +/** + * Blocks the current academic year's intake batch from registering for events. + * + * KIIT student emails start with the two-digit intake year (e.g. `26…@` for the + * 2026 batch). The prefix follows the calendar year automatically, so in 2027 + * emails starting with `27` are restricted instead. + * + * Registrations that slipped through before this rule shipped must not receive + * attendance QR codes — see `isBatchAttendanceQrBlocked`. + */ + +const DEFAULT_RESTRICTION_ENABLED_AT = "2026-08-21T00:00:00.000Z"; + +/** When the restriction went live — registrations before this may exist but get no QR. */ +export function batchRestrictionEnabledAt(): Date { + const raw = + process.env.BATCH_REGISTRATION_RESTRICTION_ENABLED_AT ?? + DEFAULT_RESTRICTION_ENABLED_AT; + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) + ? new Date(DEFAULT_RESTRICTION_ENABLED_AT) + : parsed; +} + +/** Two-digit prefix for the current calendar year's batch (e.g. `"26"` in 2026). */ +export function currentBatchEmailPrefix(now: Date = new Date()): string { + return String(now.getFullYear()).slice(-2); +} + +export function emailLocalPart(email: string): string { + return email.split("@")[0]?.trim().toLowerCase() ?? ""; +} + +/** True when the mailbox local-part starts with this year's batch digits. */ +export function isCurrentBatchEmail( + email: string | null | undefined, + now: Date = new Date(), +): boolean { + if (!email) return false; + const local = emailLocalPart(email); + const prefix = currentBatchEmailPrefix(now); + return local.length >= prefix.length && local.startsWith(prefix); +} + +export function batchRegistrationErrorMessage(now: Date = new Date()): string { + const year = now.getFullYear(); + const prefix = currentBatchEmailPrefix(now); + return `Registration is not open for ${year} batch students (emails starting with ${prefix}). If you feel this is an error, contact fedkiit@gmail.com.`; +} + +export function batchAttendanceQrBlockedMessage(now: Date = new Date()): string { + const year = now.getFullYear(); + return `Attendance QR codes are not available for ${year} batch registrations made before the restriction was applied. Please contact fedkiit@gmail.com if you need help.`; +} + +export function isBatchRegistrationBlocked( + email: string | null | undefined, + now: Date = new Date(), +): boolean { + return isCurrentBatchEmail(email, now); +} + +type SubmissionLike = { date_time?: string }; + +/** Reads the ISO timestamp stored on a registration submission. */ +export function registrationDateFromSubmission(registration: { + value: unknown[]; +}): Date | null { + for (const entry of registration.value ?? []) { + const dt = (entry as SubmissionLike)?.date_time; + if (!dt) continue; + const parsed = new Date(dt); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + return null; +} + +/** + * Grandfathered current-batch registrations (made before the restriction) must + * not receive attendance QR codes. + */ +export function isBatchAttendanceQrBlocked( + email: string | null | undefined, + registeredAt: Date | null, + now: Date = new Date(), +): boolean { + if (!isCurrentBatchEmail(email, now)) return false; + if (!registeredAt) return true; + return registeredAt < batchRestrictionEnabledAt(); +} diff --git a/lib/dev-origins.ts b/lib/dev-origins.ts new file mode 100644 index 0000000..3a0f745 --- /dev/null +++ b/lib/dev-origins.ts @@ -0,0 +1,47 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Hostnames that may access the Next.js **dev** server when it was started on + * localhost but opened via a LAN IP, ngrok, or Cloudflare quick tunnel. + * + * Without this, Next blocks `/_next/*` and HMR for non-localhost hosts — the + * HTML loads but client JS and API-driven flows (login, forms) silently fail. + * + * Reads `TRUSTED_ORIGIN_HOSTS` from `.env.local` / `.env` so one list drives + * both this and server-side origin checks. + */ +export function devAllowedOrigins(): string[] { + const origins = new Set([ + "localhost", + "127.0.0.1", + // Wildcards supported by Next 16 — covers changing tunnel subdomains. + "*.ngrok-free.app", + "*.ngrok.app", + "*.trycloudflare.com", + ]); + + for (const file of [".env.local", ".env"]) { + const path = resolve(process.cwd(), file); + if (!existsSync(path)) continue; + + const text = readFileSync(path, "utf8"); + const match = text.match(/^TRUSTED_ORIGIN_HOSTS=(.+)$/m); + if (!match?.[1]) continue; + + for (const part of match[1].split(",")) { + const entry = part.trim(); + if (!entry) continue; + try { + const host = entry.includes("://") + ? new URL(entry).hostname + : entry.split(":")[0]; + if (host) origins.add(host); + } catch { + origins.add(entry.split(":")[0]!); + } + } + } + + return [...origins]; +} diff --git a/lib/services/attendance.ts b/lib/services/attendance.ts index a9b0be1..42b221f 100644 --- a/lib/services/attendance.ts +++ b/lib/services/attendance.ts @@ -5,6 +5,11 @@ import { SignJWT, jwtVerify } from "jose"; import { prisma } from "@/lib/db"; import { ApiError } from "@/lib/api/errors"; import { getEnv } from "@/lib/env"; +import { + batchAttendanceQrBlockedMessage, + isBatchAttendanceQrBlocked, + registrationDateFromSubmission, +} from "@/lib/batch-restriction"; import type { SafeUser } from "@/lib/auth/access"; import type { EventInfo } from "@/lib/types/event"; @@ -23,17 +28,30 @@ async function signAttendanceToken(attendanceId: string): Promise { .sign(secret()); } -/** - * Attendance and registration export. - * - * Ports controllers/registration/{markAttendance,getAttendanceCode, - * downloadRegistration,exportAttendance}.js. - * - * Spreadsheets are built as CSV rather than through ExcelJS. The original - * streamed a real .xlsx via `workbook.xlsx.writeBuffer()`; CSV opens correctly - * in Excel and Sheets, avoids adding a heavy native-ish dependency to the - * bundle, and sidesteps the SheetJS advisories entirely. - */ +type AttendanceInfo = { + user_name?: string; + user_email?: string; + user_id?: string; +}; + +function attendeeFromRecord( + record: { + teamName: string; + teamCode: string; + info: unknown; + userId: string; + }, + user?: { name: string | null; email: string | null; rollNumber: string | null } | null, +) { + const info = (record.info ?? {}) as AttendanceInfo; + return { + name: info.user_name || user?.name || "", + email: info.user_email || user?.email || "", + rollNumber: user?.rollNumber || "", + teamName: record.teamName, + teamCode: record.teamCode, + }; +} /** RFC 4180 escaping — quotes doubled, fields with delimiters quoted. */ function csvCell(value: unknown): string { @@ -50,55 +68,143 @@ export function toCsv(rows: Array>): string { for (const row of rows) { lines.push(headers.map((h) => csvCell(row[h])).join(",")); } - // Excel needs a BOM to read UTF-8 correctly. return "" + lines.join("\r\n"); } -/** - * The caller's attendance token for an event — encoded into their QR code. - * - * Returns a **signed JWT that expires in 20 minutes**, not the record id. That - * signature is the security control for the whole flow: it is why the Express - * route left its `checkAccess` commented out, and why a scanned code cannot be - * forged or replayed a day later. An earlier version of this port handed back - * the raw `attendance.id`, which was both unauthenticated and permanent — and - * did not match what `QRCodeModal` reads (`response.data.attendanceToken`). - */ -export async function getAttendanceCode( +const registrationSelect = { + teamName: true, + teamCode: true, + value: true, + userId: true, +} as const; + +function userIsOnRegistration( + registration: { userId: string; value: unknown[] }, + userId: string, +): boolean { + if (registration.userId === userId) return true; + return (registration.value as Array<{ user_id?: string }>).some( + (v) => v?.user_id === userId, + ); +} + +/** Participant count across all teams for a form. */ +async function countRegisteredParticipants(formId: string): Promise { + const registrations = await prisma.formRegistration.findMany({ + where: { formId }, + select: { userId: true, value: true }, + }); + + const userIds = new Set(); + for (const registration of registrations) { + userIds.add(registration.userId); + for (const entry of registration.value ?? []) { + const uid = (entry as { user_id?: string })?.user_id; + if (uid) userIds.add(uid); + } + } + return userIds.size; +} + +function submissionInfoForUser( + registration: { + userId: string; + value: unknown[]; + }, + user: SafeUser, +): AttendanceInfo | undefined { + const fromValue = (registration.value as AttendanceInfo[]).find( + (v) => v?.user_id === user.id, + ); + if (fromValue) return fromValue; + + if (registration.userId === user.id) { + const first = registration.value?.[0] as AttendanceInfo | undefined; + if (first) return { ...first, user_id: user.id }; + return { + user_id: user.id, + user_name: user.name ?? undefined, + user_email: user.email ?? undefined, + }; + } + + return { + user_id: user.id, + user_name: user.name ?? undefined, + user_email: user.email ?? undefined, + }; +} + +/** Finds the registration row for a user on a form (solo leader or team member). */ +async function findUserRegistration( formId: string, user: SafeUser, teamCode?: string | null, ) { - if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); - - // With a teamCode the original looked the team up directly; without one it - // scanned every registration for the form and picked the row containing this - // user. Both branches are preserved, including their distinct 404 messages. - let registration = null; if (teamCode && teamCode.trim() !== "") { - registration = await prisma.formRegistration.findFirst({ - where: { formId, teamCode }, + const registration = await prisma.formRegistration.findFirst({ + where: { formId, teamCode: teamCode.trim() }, + select: registrationSelect, }); if (!registration) { throw new ApiError(404, "Form registration not found."); } - } else { - const all = await prisma.formRegistration.findMany({ where: { formId } }); - registration = - all.find((reg) => - (reg.value as Array<{ user_id?: string }>).some( - (v) => v?.user_id === user.id, - ), - ) ?? null; - if (!registration) { - throw new ApiError(404, "Form registration not found for the user."); + if (!userIsOnRegistration(registration, user.id)) { + throw new ApiError(403, "You are not registered for this team."); } + return registration; } - const info = - (registration.value as Array<{ user_id?: string }>).find( + // Fast path: user registered solo or as team leader (userId on the row). + const owned = await prisma.formRegistration.findFirst({ + where: { formId, userId: user.id }, + select: registrationSelect, + }); + if (owned) return owned; + + // Team member: scan registrations for this form only (value[] holds members). + const teams = await prisma.formRegistration.findMany({ + where: { formId }, + select: registrationSelect, + }); + const memberOf = teams.find((reg) => + (reg.value as Array<{ user_id?: string }>).some( (v) => v?.user_id === user.id, - ) ?? undefined; + ), + ); + if (!memberOf) { + throw new ApiError(404, "Form registration not found for the user."); + } + return memberOf; +} + +/** + * The caller's attendance token for an event — encoded into their QR code. + * Returns a signed JWT (20 min), not the raw record id. + */ +export async function getAttendanceCode( + formId: string, + user: SafeUser, + teamCode?: string | null, +) { + if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); + + const form = await prisma.form.findUnique({ + where: { id: formId }, + select: { id: true }, + }); + if (!form) throw new ApiError(404, "Form not found"); + + const registration = await findUserRegistration(formId, user, teamCode); + + const registeredAt = registrationDateFromSubmission(registration); + if (isBatchAttendanceQrBlocked(user.email, registeredAt)) { + throw new ApiError(403, batchAttendanceQrBlockedMessage(), [ + { code: "BATCH_QR_BLOCKED" }, + ]); + } + + const info = submissionInfoForUser(registration, user); const attendanceData = { formId, @@ -108,38 +214,77 @@ export async function getAttendanceCode( info, }; - const record = await prisma.attendance.upsert({ - where: { - formId_userId_teamCode: { - formId, - userId: user.id, - teamCode: registration.teamCode, - }, - }, - create: attendanceData as never, - update: attendanceData as never, + const uniqueKey = { + formId, + userId: user.id, + teamCode: registration.teamCode, + }; + + const existing = await prisma.attendance.findUnique({ + where: { formId_userId_teamCode: uniqueKey }, + select: { id: true, isPresent: true }, }); + if (existing?.isPresent) { + throw new ApiError(400, "Attendance already marked.", [ + { code: "ALREADY_MARKED" }, + ]); + } + + const record = existing + ? existing + : await prisma.attendance.create({ + data: attendanceData as never, + select: { id: true, isPresent: true }, + }); + + if (existing && info) { + await prisma.attendance.update({ + where: { id: existing.id }, + data: { info }, + }); + } + const token = await signAttendanceToken(record.id); return { message: "Validation id generated successfully.", attendanceToken: token }; } +export type MarkAttendanceResult = { + message: string; + alreadyMarked: boolean; + attendance: { + id: string; + formId: string; + teamName: string; + teamCode: string; + isPresent: boolean; + markedAt: Date | null; + }; + attendee: { + name: string; + email: string; + rollNumber: string; + teamName: string; + teamCode: string; + }; +}; + /** * Marks a scanned attendance record present. - * - * Requires a club member — the original mounted this with its access check - * commented out, so any signed-in user could mark anyone present. + * Uses an atomic conditional update to prevent duplicate marks under concurrency. */ export async function markAttendance(input: { formId?: string; token?: string; -}) { +}): Promise { const token = input.token?.trim(); if (!token) throw new ApiError(400, "Attendance token is required."); - // The scanned value is the signed QR token, not a record id. Verifying it is - // what stops a stale or hand-crafted QR from marking anyone present. + if (!input.formId || !/^[a-f\d]{24}$/i.test(input.formId)) { + throw new ApiError(400, "Valid event ID is required."); + } + let attendanceId: string | undefined; try { const { payload } = await jwtVerify(token, secret(), { @@ -147,31 +292,133 @@ export async function markAttendance(input: { }); attendanceId = payload.attendanceToken as string | undefined; } catch { - throw new ApiError(401, "Invalid or expired QR."); + throw new ApiError(401, "Invalid or expired QR.", [{ code: "INVALID_QR" }]); } - if (!attendanceId) { - throw new ApiError(400, "Attendance ID is missing in the token."); + if (!attendanceId || !/^[a-f\d]{24}$/i.test(attendanceId)) { + throw new ApiError(401, "Invalid or expired QR.", [{ code: "INVALID_QR" }]); + } + + const markedAt = new Date(); + const updated = await prisma.attendance.updateMany({ + where: { + id: attendanceId, + formId: input.formId, + isPresent: false, + }, + data: { isPresent: true, markedAt }, + }); + + if (updated.count === 1) { + const record = await prisma.attendance.findUnique({ + where: { id: attendanceId }, + select: { + id: true, + formId: true, + teamName: true, + teamCode: true, + isPresent: true, + markedAt: true, + info: true, + userId: true, + }, + }); + if (!record) { + throw new ApiError(404, "Attendance record not found.", [{ code: "NOT_FOUND" }]); + } + + const user = await prisma.user.findUnique({ + where: { id: record.userId }, + select: { name: true, email: true, rollNumber: true }, + }); + + return { + message: "Attendance marked successfully.", + alreadyMarked: false, + attendance: { + id: record.id, + formId: record.formId, + teamName: record.teamName, + teamCode: record.teamCode, + isPresent: record.isPresent, + markedAt: record.markedAt, + }, + attendee: attendeeFromRecord(record, user), + }; } const record = await prisma.attendance.findUnique({ where: { id: attendanceId }, + select: { + id: true, + formId: true, + teamName: true, + teamCode: true, + isPresent: true, + markedAt: true, + info: true, + userId: true, + }, }); - if (!record) throw new ApiError(404, "Attendance record not found."); - // A QR for one event must not check someone in at another. + if (!record) { + throw new ApiError(404, "Attendance record not found.", [{ code: "NOT_FOUND" }]); + } if (record.formId !== input.formId) { - throw new ApiError(400, "QR does not belong to the specified form."); + throw new ApiError(400, "QR does not belong to this event.", [ + { code: "WRONG_EVENT" }, + ]); + } + if (record.isPresent) { + const user = await prisma.user.findUnique({ + where: { id: record.userId }, + select: { name: true, email: true, rollNumber: true }, + }); + return { + message: "Attendance already marked.", + alreadyMarked: true, + attendance: { + id: record.id, + formId: record.formId, + teamName: record.teamName, + teamCode: record.teamCode, + isPresent: record.isPresent, + markedAt: record.markedAt, + }, + attendee: attendeeFromRecord(record, user), + }; } - if (record.isPresent) throw new ApiError(400, "Attendance already marked."); + throw new ApiError(409, "Could not mark attendance. Please scan again.", [ + { code: "CONFLICT" }, + ]); +} - const updated = await prisma.attendance.update({ - where: { id: attendanceId }, - data: { isPresent: true, markedAt: new Date() }, +/** Lightweight event list for the attendance scanner page (no sections payload). */ +export async function listAttendanceEvents() { + const forms = await prisma.form.findMany({ + select: { id: true, info: true }, + orderBy: { id: "desc" }, }); + return forms; +} - return { message: "Attendance marked successfully.", attendance: updated }; +/** Present / total counts for an event — shown while scanning. */ +export async function getAttendanceStats(formId: string) { + if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); + + const form = await prisma.form.findUnique({ + where: { id: formId }, + select: { id: true }, + }); + if (!form) throw new ApiError(404, "Form not found"); + + const [registered, present] = await Promise.all([ + countRegisteredParticipants(formId), + prisma.attendance.count({ where: { formId, isPresent: true } }), + ]); + + return { registered, present }; } export type StoredField = { name?: string; type?: string; value?: unknown }; @@ -186,13 +433,6 @@ export type StoredSubmission = { const isHttpUrl = (v: unknown): v is string => typeof v === "string" && /^https?:\/\//i.test(v); -/** - * Pulls the payment answers out of a stored submission. - * - * Located by shape and by name pattern rather than by a fixed index, because - * admins can rename and reorder the sections a form is built from. Shared with - * the payments endpoint so the two cannot drift. - */ export function paymentFromSubmission(submission: StoredSubmission): { utr: string; screenshot: string | null; @@ -202,8 +442,6 @@ export function paymentFromSubmission(submission: StoredSubmission): { ); const utr = fields.find((f) => /utr|transaction/i.test(f?.name ?? "")); - // Matched on the stored URL rather than on `type`: a renamed field still - // uploads to the same place, and a media field left blank is null. const screenshot = fields.find( (f) => (f?.type === "image" || f?.type === "file") && isHttpUrl(f?.value), ); @@ -214,7 +452,6 @@ export function paymentFromSubmission(submission: StoredSubmission): { }; } -/** Flattens a registration's stored submission into spreadsheet columns. */ function flattenRegistration(row: { teamName: string; teamCode: string; @@ -255,7 +492,6 @@ function flattenRegistration(row: { return out; } -/** All registrations for a form, as spreadsheet rows. */ export async function exportRegistrations(formId: string) { if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); @@ -284,7 +520,6 @@ export async function exportRegistrations(formId: string) { }; } -/** Attendance rows for a form, as spreadsheet rows. */ export async function exportAttendance(formId: string) { if (!/^[a-f\d]{24}$/i.test(formId)) throw new ApiError(404, "Form not found"); @@ -294,18 +529,27 @@ export async function exportAttendance(formId: string) { }); if (!form) throw new ApiError(404, "Form not found"); - const records = await prisma.attendance.findMany({ where: { formId } }); + const records = await prisma.attendance.findMany({ + where: { formId }, + select: { + userId: true, + teamName: true, + teamCode: true, + isPresent: true, + isPaymentVerified: true, + markedAt: true, + }, + }); const userIds = [...new Set(records.map((r) => r.userId))]; - const users = await prisma.user.findMany({ - where: { id: { in: userIds } }, - select: { id: true, name: true, email: true, rollNumber: true }, - }); + const users = userIds.length + ? await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true, email: true, rollNumber: true }, + }) + : []; const byId = new Map(users.map((u) => [u.id, u])); - // Payment proof lives on the registration, not on the attendance record, so - // it has to be joined in. Without this the attendance sheet gave a desk - // volunteer no way to check a payment against what the participant uploaded. const registrations = await prisma.formRegistration.findMany({ where: { formId }, select: { userId: true, value: true }, diff --git a/lib/services/registration.ts b/lib/services/registration.ts index 53be086..c4cc8da 100644 --- a/lib/services/registration.ts +++ b/lib/services/registration.ts @@ -5,6 +5,10 @@ import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/db"; import { ApiError } from "@/lib/api/errors"; +import { + batchRegistrationErrorMessage, + isBatchRegistrationBlocked, +} from "@/lib/batch-restriction"; import type { SafeUser } from "@/lib/auth/access"; import { sendMail } from "@/lib/email/mailer"; import { registrationEmail } from "@/lib/email/templates"; @@ -89,6 +93,10 @@ export async function registerForEvent(input: { throw new ApiError(403, "This form is not open for registration."); } + if (isBatchRegistrationBlocked(user.email)) { + throw new ApiError(400, batchRegistrationErrorMessage()); + } + const alreadyRegistered = tracker?.regUserEmails.includes(user.email) || user.regForm.includes(formId); if (alreadyRegistered) { diff --git a/next.config.ts b/next.config.ts index 204cccd..c4ef161 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,12 @@ import type { NextConfig } from "next"; +import { devAllowedOrigins } from "./lib/dev-origins"; + const nextConfig: NextConfig = { + // Dev-only: allow LAN IP, ngrok, and Cloudflare tunnel hosts to load /_next/* + // bundles and HMR. Without this, opening http://10.x.x.x:5000 or a tunnel URL + // serves HTML but blocks client JS — login and all client API calls break. + allowedDevOrigins: devAllowedOrigins(), // Inject the shared SCSS variables into every stylesheet. The original // modules each did `@import ".../Global.scss"`, and two used Vite's absolute // "/src/..." form which Next cannot resolve. Injecting once is equivalent and diff --git a/package-lock.json b/package-lock.json index e9ef779..133e274 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2110,7 +2110,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -2123,14 +2123,14 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -2144,14 +2144,14 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -2163,7 +2163,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" @@ -2196,7 +2196,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -2585,7 +2585,6 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3798,7 +3797,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -3981,7 +3980,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -3997,7 +3996,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -4095,14 +4094,14 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -4302,7 +4301,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -4348,7 +4347,7 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/delayed-stream": { @@ -4373,7 +4372,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/detect-element-overflow": { @@ -4444,7 +4443,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4480,7 +4479,7 @@ "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -4505,7 +4504,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -5171,7 +5170,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/extend": { @@ -5184,7 +5183,7 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -5628,7 +5627,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -6529,7 +6528,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -8015,7 +8014,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/node-releases": { @@ -8032,7 +8031,7 @@ "version": "0.6.9", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz", "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -8050,7 +8049,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/object-assign": { @@ -8179,7 +8178,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/optionator": { @@ -8345,14 +8344,14 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -8378,7 +8377,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -8468,7 +8467,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -8534,7 +8533,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -8581,7 +8580,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -8991,7 +8990,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -9929,7 +9928,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -10156,7 +10155,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index ea9115c..060a54f 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "dev": "node scripts/with-env.mjs dev", + "tunnel": "node scripts/tunnel.mjs", + "expose": "node scripts/expose.mjs", "build": "node scripts/with-env.mjs build", "start": "node scripts/with-env.mjs start", "lint": "eslint", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fef3bb8..91a66b7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -203,6 +203,8 @@ model attendance { markedAt DateTime? @@unique([formId, userId, teamCode]) + @@index([formId]) + @@index([formId, isPresent]) @@map("attendance") } diff --git a/proxy.ts b/proxy.ts index 04dfd6b..8cf6ccb 100644 --- a/proxy.ts +++ b/proxy.ts @@ -46,8 +46,15 @@ const AUTH_ONLY = new Set([ "/otp", ]); +function bearerFromHeader(header: string | null): string | null { + if (!header) return null; + return header.startsWith("Bearer ") ? header.slice(7) : header; +} + async function hasValidSession(request: NextRequest): Promise { - const token = request.cookies.get("token")?.value; + const token = + request.cookies.get("token")?.value ?? + bearerFromHeader(request.headers.get("authorization")); if (!token) return false; const secret = process.env.JWT_SECRET; diff --git a/scripts/expose.mjs b/scripts/expose.mjs new file mode 100644 index 0000000..eec346e --- /dev/null +++ b/scripts/expose.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Expose the local dev server with ngrok. + * + * npm run expose + * + * Copy the https URL into `.env.local`: + * NEXT_PUBLIC_SITE_URL=https://….ngrok-free.app + * TRUSTED_ORIGIN_HOSTS=….ngrok-free.app + * Then restart `npm run dev`. + */ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); + +function readPort() { + const flag = process.argv.indexOf("--port"); + if (flag !== -1 && process.argv[flag + 1]) return process.argv[flag + 1]; + + for (const file of [".env.local", ".env"]) { + const path = resolve(root, file); + if (!existsSync(path)) continue; + const match = readFileSync(path, "utf8").match(/^PORT=(\d+)/m); + if (match) return match[1]; + } + return "3000"; +} + +const port = readPort(); +console.log(`[expose] ngrok http ${port}`); +console.log("[expose] Dashboard: http://127.0.0.1:4040"); +console.log("[expose] Same Wi‑Fi LAN: http://:" + port); +console.log( + "[expose] Add the new ngrok hostname to TRUSTED_ORIGIN_HOSTS in .env.local, then restart npm run dev", +); + +const child = spawn("ngrok", ["http", port], { + stdio: "inherit", + shell: process.platform === "win32", +}); + +child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/scripts/tunnel.mjs b/scripts/tunnel.mjs new file mode 100644 index 0000000..4ae47a3 --- /dev/null +++ b/scripts/tunnel.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * Starts a Cloudflare quick tunnel to the local Next.js dev server. + * + * Usage: + * node scripts/tunnel.mjs + * node scripts/tunnel.mjs --port 5000 + * + * After the tunnel URL appears, set in `.env.local`: + * NEXT_PUBLIC_SITE_URL=https://.trycloudflare.com + * TRUSTED_ORIGIN_HOSTS=.trycloudflare.com + * Then restart `npm run dev`. + */ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); + +function readPort() { + const flag = process.argv.indexOf("--port"); + if (flag !== -1 && process.argv[flag + 1]) { + return process.argv[flag + 1]; + } + + for (const file of [".env.local", ".env"]) { + const path = resolve(root, file); + if (!existsSync(path)) continue; + const match = readFileSync(path, "utf8").match(/^PORT=(\d+)/m); + if (match) return match[1]; + } + + return "3000"; +} + +const port = readPort(); +const target = `http://localhost:${port}`; + +console.log(`[tunnel] Starting Cloudflare quick tunnel -> ${target}`); +console.log("[tunnel] Copy the https://….trycloudflare.com URL into .env.local"); + +const child = spawn("cloudflared", ["tunnel", "--url", target], { + stdio: "inherit", + shell: process.platform === "win32", +}); + +child.on("exit", (code) => process.exit(code ?? 0)); diff --git a/scripts/with-env.mjs b/scripts/with-env.mjs index 3516712..66a0356 100644 --- a/scripts/with-env.mjs +++ b/scripts/with-env.mjs @@ -83,7 +83,13 @@ if (!ensurePrismaClient(env)) process.exit(1); const nextBin = resolve(root, "node_modules", "next", "dist", "bin", "next"); -const child = spawn(process.execPath, [nextBin, ...args], { +// Listen on all interfaces in dev so phones on the same Wi‑Fi can hit the LAN IP. +const nextArgs = [...args]; +if (args[0] === "dev" && !nextArgs.includes("-H")) { + nextArgs.push("-H", "0.0.0.0"); +} + +const child = spawn(process.execPath, [nextBin, ...nextArgs], { cwd: root, env, stdio: "inherit", diff --git a/src/authentication/Login/GoogleLogin.jsx b/src/authentication/Login/GoogleLogin.jsx index 7c2c009..5a5eda1 100644 --- a/src/authentication/Login/GoogleLogin.jsx +++ b/src/authentication/Login/GoogleLogin.jsx @@ -4,15 +4,15 @@ import { useContext, useState, useEffect } from "react"; import style from "../SignUp/style/Signup.module.scss"; import { useGoogleLogin } from "@react-oauth/google"; -import axios from "axios"; import AuthContext, { SESSION_TTL_MS } from "../../context/AuthContext"; import google from "../../assets/images/google.png"; import { Alert, MicroLoading } from "../../microInteraction"; import { api } from "../../services"; import { useRouter } from "next/navigation"; import postAuthRedirect from "../../utils/postAuthRedirect"; +import { isGoogleOAuthEnabled } from "../../utils/googleOAuth"; -export default function GoogleLogin() { +function GoogleLoginButton() { const [alert, setAlert] = useState(null); const [codeResponse, setCodeResponse] = useState(null); const [shouldNavigate, setShouldNavigate] = useState(false); @@ -22,7 +22,7 @@ export default function GoogleLogin() { const [isLoading, setIsLoading] = useState(false); const login = useGoogleLogin({ - onSuccess: (codeResponse) => setCodeResponse(codeResponse), + onSuccess: (response) => setCodeResponse(response), onError: (error) => console.error("Login failed:", error), }); @@ -40,7 +40,6 @@ export default function GoogleLogin() { } }, [alert]); - // `replace`, not `push`: App.jsx redirected with . useEffect(() => { if (shouldNavigate) { router.replace(navigatePath); @@ -57,8 +56,6 @@ export default function GoogleLogin() { }); if (response.status === 200 || response.status === 201) { - // User exists in the backend - // console.log(response); const user = response.data.user; setAlert({ @@ -89,18 +86,10 @@ export default function GoogleLogin() { user.regForm, user.blurhash, response.data.token, - SESSION_TTL_MS + SESSION_TTL_MS, ); - // App.jsx re-rendered /Login as once isLoggedIn - // flipped; nothing watches that flag under the App Router, so the - // navigation this component was already wired for is triggered here. setShouldNavigate(true); }, 800); - - } else { - - // console.log("Unexpected backend response status:", response.status); - // handleFallbackOrSignup(googleUserData); } } catch (error) { setAlert({ @@ -111,7 +100,6 @@ export default function GoogleLogin() { }); console.error("Backend API call failed:", error); - } } catch (error) { console.error("Login error:", error); @@ -129,6 +117,7 @@ export default function GoogleLogin() { return ( <> - + ); } + +export default function GoogleSignup({ setAlert }) { + if (!isGoogleOAuthEnabled()) { + return null; + } + + return ; +} diff --git a/src/authentication/SignUp/SignUP.jsx b/src/authentication/SignUp/SignUP.jsx index 2148755..b1ff8f8 100644 --- a/src/authentication/SignUp/SignUP.jsx +++ b/src/authentication/SignUp/SignUP.jsx @@ -7,6 +7,7 @@ import { useContext, useState } from "react"; import styles from "./style/Signup.module.scss"; import { Input, Button, Text } from "../../components"; import GoogleSignup from "./GoogleSignup"; +import { isGoogleOAuthEnabled } from "../../utils/googleOAuth"; import bcrypt from "bcryptjs"; import { useEffect } from "react"; @@ -286,8 +287,8 @@ const SignUp = () => { setShowModal(false); }; - const handleCheckBox = () => { - setTandC((prevState) => !prevState); + const handleCheckBox = (e) => { + setTandC(e.target.checked); }; // console.log(alert) @@ -315,18 +316,20 @@ const SignUp = () => { SignUp -
-
-

or

-
-
+ {isGoogleOAuthEnabled() ? ( +
+
+

or

+
+
+ ) : null}
@@ -474,7 +477,7 @@ const SignUp = () => { type="checkbox" style={{ height: "17px", width: "17px", cursor: "pointer" }} checked={isTandChecked} - onClick={handleCheckBox} + onChange={handleCheckBox} id="custom-checkbox" /> diff --git a/src/components/Carousel/styles/Carousel.module.scss b/src/components/Carousel/styles/Carousel.module.scss index 5f53189..3280158 100644 --- a/src/components/Carousel/styles/Carousel.module.scss +++ b/src/components/Carousel/styles/Carousel.module.scss @@ -102,9 +102,6 @@ cursor: pointer; } -.pagination_dot:hover { - transform: scale(1.2); -} .pagination_dot_active { background-color: steelblue; diff --git a/src/components/Chatbot/Chatbot.jsx b/src/components/Chatbot/Chatbot.jsx index 6f2170a..f081101 100644 --- a/src/components/Chatbot/Chatbot.jsx +++ b/src/components/Chatbot/Chatbot.jsx @@ -380,7 +380,6 @@ const Chatbot = () => { aria-label="Open Chat" > -
)} diff --git a/src/components/Chatbot/Chatbot.module.scss b/src/components/Chatbot/Chatbot.module.scss index 42aae49..3f15c66 100644 --- a/src/components/Chatbot/Chatbot.module.scss +++ b/src/components/Chatbot/Chatbot.module.scss @@ -118,37 +118,7 @@ $border-radius-medium: 16px; backdrop-filter: blur($glass-blur); &:hover { - transform: scale(1.1); animation: none; - box-shadow: 0 12px 32px rgba(244, 43, 3, 0.5); - } - - &:active { - transform: scale(0.95); - } -} - -.pulseRing { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 100%; - height: 100%; - border-radius: 50%; - border: 3px solid rgba(255, 190, 11, 0.6); - animation: pulseRing 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; -} - -@keyframes pulseRing { - 0% { - transform: translate(-50%, -50%) scale(1); - opacity: 1; - } - - 100% { - transform: translate(-50%, -50%) scale(1.5); - opacity: 0; } } @@ -292,7 +262,6 @@ $border-radius-medium: 16px; } &:active { - transform: scale(0.95); } } @@ -393,7 +362,7 @@ $border-radius-medium: 16px; cursor: default; &:hover { - transform: translateY(-2px) scale(1.01); + transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); } } @@ -403,21 +372,13 @@ $border-radius-medium: 16px; color: #1a1a1a; border-bottom-right-radius: 4px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - - &:hover { - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); - } } .botMessage { background: $primary-gradient; color: $light-text; border-bottom-left-radius: 4px; - box-shadow: 0 2px 8px rgba(244, 43, 3, 0.2); - - &:hover { - box-shadow: 0 8px 24px rgba(255, 140, 40, 0.35); - } + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); // Style bullet lists inside bot messages ul { @@ -532,9 +493,7 @@ $border-radius-medium: 16px; &:hover { background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 190, 11, 0.5); transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(255, 190, 11, 0.2); } &:active { @@ -583,8 +542,6 @@ $border-radius-medium: 16px; &:focus { outline: none; background: rgba(255, 255, 255, 0.12); - border-color: #ff5500; - box-shadow: 0 0 0 3px rgba(255, 85, 0, 0.25); } } @@ -607,7 +564,6 @@ $border-radius-medium: 16px; &:hover { background: rgba(255, 255, 255, 0.16); - border-color: rgba(255, 255, 255, 0.25); } &:active { @@ -617,8 +573,6 @@ $border-radius-medium: 16px; &.listening { background: linear-gradient(135deg, #ff5500 0%, #ff8a00 100%); border-color: transparent; - animation: pulse 1.5s ease-in-out infinite; - box-shadow: 0 0 20px rgba(255, 85, 0, 0.5); } } @@ -638,11 +592,10 @@ $border-radius-medium: 16px; color: #ffffff; box-sizing: border-box; transition: all 0.2s ease; - box-shadow: 0 4px 14px rgba(255, 85, 0, 0.35); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); &:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 6px 18px rgba(255, 85, 0, 0.5); } &:active:not(:disabled) { diff --git a/src/components/Core/Input.jsx b/src/components/Core/Input.jsx index 971a6e4..c2876cc 100644 --- a/src/components/Core/Input.jsx +++ b/src/components/Core/Input.jsx @@ -143,7 +143,7 @@ const Input = (props) => { containerStyle, style, placeholder, - value = "", + value, onChange, label, options, @@ -162,6 +162,13 @@ const Input = (props) => { const [showPassword, setshowPassword] = useState(false); const [previewFile, setpreviewFile] = useState(null); + // When callers pass only `onChange` (no `value`), keep the field uncontrolled. + // Defaulting `value` to "" used to force every input controlled at "" — typing + // never appeared because React kept resetting the DOM value. + const isControlled = Object.prototype.hasOwnProperty.call(props, "value"); + const controlledValue = isControlled ? (value ?? "") : undefined; + const valueProps = isControlled ? { value: controlledValue } : {}; + const filterPassedTime = (time) => { const currentDate = new Date(); const selectedDate = new Date(time); @@ -169,8 +176,6 @@ const Input = (props) => { return currentDate.getTime() < selectedDate.getTime(); }; - const safeValue = value ?? ""; - const getInputTypes = () => { switch (type) { case "text": @@ -181,7 +186,7 @@ const Input = (props) => { type={type} style={style || {}} placeholder={placeholder} - value={safeValue} + {...valueProps} onChange={onChange} {...rest} /> @@ -195,7 +200,7 @@ const Input = (props) => { type={type} style={style || {}} placeholder={placeholder} - value={safeValue} + {...valueProps} onChange={onChange} {...rest} /> @@ -209,7 +214,7 @@ const Input = (props) => { type={type} style={style || {}} placeholder={placeholder} - value={safeValue} + {...valueProps} onChange={onChange} {...rest} /> @@ -221,7 +226,11 @@ const Input = (props) => {