Skip to content
Merged

Beta #17

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
# env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*
# ...but the template carries no secrets and documents the contract.
!.env.example
Expand All @@ -41,3 +46,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

/app/generated/prisma
6 changes: 4 additions & 2 deletions app/(main)/Events/[eventId]/Form/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import { Suspense } from "react";

import Event from "@/src/views/Event/Event";
import EventForm from "@/src/views/Event/EventForm";
import ProtectedRoute from "@/src/components/ProtectedRoute";
import { Loading } from "@/src/microInteraction";

/**
* /Events/:eventId/Form — the registration form, behind the auth guard.
*
* The event listing is no longer rendered underneath. It was only there to sit
* behind the overlay; now that the form is a page it would mean fetching and
* painting every event just to hide it under an opaque card.
*
* Suspense is required: both EventForm and ProtectedRoute read
* `useSearchParams()`. It is declared per-page rather than in the layout, since
* a layout-level boundary made every prerendered page emit its markup twice.
Expand All @@ -18,7 +21,6 @@ export default function Page() {
return (
<Suspense fallback={<Loading />}>
<ProtectedRoute>
<Event />
<EventForm />
</ProtectedRoute>
</Suspense>
Expand Down
15 changes: 5 additions & 10 deletions app/(main)/Events/[eventId]/page.jsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
"use client";

import Event from "@/src/views/Event/Event";
import EventModal from "@/src/features/Modals/Event/EventModal/EventModal";
import EventDetail from "@/src/views/Event/EventDetail";

/**
* /Events/:eventId
*
* App.jsx rendered `[<Event />, <EventModal onClosePath="/Events" />]` — the
* listing stays mounted underneath and the modal opens over it.
* Was `[<Event />, <EventModal onClosePath="/Events" />]` — the listing stayed
* mounted and a fixed overlay opened on top of it. It is a page of its own now,
* so the listing is no longer fetched and painted underneath just to be covered.
*/
export default function Page() {
return (
<>
<Event />
<EventModal onClosePath="/Events" />
</>
);
return <EventDetail />;
}
25 changes: 22 additions & 3 deletions app/(main)/layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import { usePathname } from "next/navigation";

import Navbar from "@/src/layouts/Navbar/Navbar";
// The revamped navbar. Its links point at the canonical capitalised routes
// (/Events, /Team, /Blog), so it drives the existing pages rather than
// replacing them. The previous SCSS navbar is still in the tree, unused.
import Navbar from "@/app/components/Navbar";
import Footer from "@/src/layouts/Footer/Footer";

/**
Expand All @@ -23,12 +26,28 @@ import Footer from "@/src/layouts/Footer/Footer";
*/
export default function MainLayout({ children }) {
const pathname = usePathname();
const isOmegaPage = pathname?.toLowerCase() === "/omega";
const path = pathname?.toLowerCase() ?? "";
const isOmegaPage = path === "/omega";

// The navbar is fixed, so every page has to reserve space for it or its first
// element renders behind the pill. `.page` used to carry `margin-top: 88px`
// for exactly this, but globals.css zeroes it with `!important` across body,
// .page and main — which is what put the headings on /Team, /Alumni and
// /profile underneath the navbar.
//
// Home and Omega opt out: both open with a full-bleed hero that is meant to
// run up behind a transparent navbar, and an offset there would leave a band
// of empty page above it.
const isFullBleed = path === "/" || isOmegaPage;

return (
<div>
<Navbar />
<div className={`page ${isOmegaPage ? "omega-page" : ""}`}>
<div
className={`page ${isOmegaPage ? "omega-page" : ""} ${
isFullBleed ? "" : "page--nav-offset"
}`}
>
{children}
</div>
<Footer />
Expand Down
23 changes: 21 additions & 2 deletions app/(main)/profile/attendance/page.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
// Route entry — renders the component ported from
// FED-Frontend/src/pages/AttendancePage/AttendancePage.jsx
"use client";
//
// Gated to ADMIN on the server. The sidebar only ever *showed* this link to
// admins, but that is presentation: `proxy.ts` guards /profile by checking for
// a valid session, not a role, so before this check any signed-in participant
// who typed the path got a working scanner. Combined with the fact that a
// participant can generate their own QR (that is the whole point of
// QRCodeModal), it meant anyone could mark themselves present.
//
// The QR-issuing endpoint stays open to any signed-in user — participants must
// be able to produce their own code. Only scanning is restricted.

import { redirect } from "next/navigation";

import { getCurrentUser, isAdmin } from "@/lib/auth/access";
import AttendancePage from "@/src/views/AttendancePage/AttendancePage";

export default function Page() {
export default async function Page() {
const user = await getCurrentUser();

// Matches what proxy.ts does for an anonymous request to a protected route,
// so an expired session lands on the login page rather than a bare redirect.
if (!user) redirect("/Login?next=/profile/attendance");
if (!isAdmin(user)) redirect("/profile");

return <AttendancePage />;
}
27 changes: 27 additions & 0 deletions app/(main)/profile/events/Analytics/[eventId]/page.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Route entry for the per-event admin view — registration counts, year
// breakdown, the CSV export and the payment proofs.
//
// This route did not exist. `EventCard` has shipped an analytics button since
// the revamp that pushes to `/profile/events/Analytics/<id>`, and `EventStats`
// was written and exported from the modals barrel, but nothing ever mounted it
// — so the button 404'd and the component was dead code.
//
// Gated to ADMIN on the server, matching /profile/attendance: `proxy.ts` only
// checks for a valid session, not a role, so without this any signed-in
// participant who typed the path would see every registrant's email and
// payment screenshot.

import { redirect } from "next/navigation";

import { getCurrentUser, isAdmin } from "@/lib/auth/access";
import EventStats from "@/src/features/Modals/Event/EventStats/EventStats";

export default async function Page({ params }) {
const { eventId } = await params;
const user = await getCurrentUser();

if (!user) redirect(`/Login?next=/profile/events/Analytics/${eventId}`);
if (!isAdmin(user)) redirect("/profile");

return <EventStats onClosePath="/profile/events" />;
}
8 changes: 6 additions & 2 deletions app/(main)/profile/layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";

import ProfileLayout from "@/src/layouts/Profile/ProfileLayout/ProfileLayout";
import Sidebar from "@/src/layouts/Profile/Sidebar/Sidebar";
import AuthContext from "@/src/context/AuthContext";
import AuthContext, { clearServerSession } from "@/src/context/AuthContext";
import { api } from "@/src/services";
import { Loading } from "@/src/microInteraction";
import style from "@/src/views/Profile/styles/Profile.module.scss";
Expand All @@ -32,7 +32,11 @@ export default function ProfileShell({
if (authCtx.isLoading) return;

if (!authCtx.isLoggedIn) {
router.replace("/Login");
// Clear the cookie before redirecting. proxy.ts gates /Login on the
// cookie alone, so leaving a live one here means it bounces us straight
// back to /profile and we land in this branch again — an invisible loop
// whose only symptom is that the Login button never opens the form.
clearServerSession().finally(() => router.replace("/Login"));
return;
}

Expand Down
11 changes: 10 additions & 1 deletion app/api/form/addForm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,16 @@ export async function POST(request: Request) {
isPublic: text("isPublic") === "true",
isRegistrationClosed: text("isRegistrationClosed") === "true",
isEventPast: text("isEventPast") === "true",
receiverDetails: { upi: text("upi") ?? null, media: null as string | null },
receiverDetails: {
upi: text("upi") ?? null,
media: null as string | null,
// Anything other than "Link" is QR — that is the historical behaviour
// and the safe default if the field is missing or malformed.
mode: text("paymentMode") === "Link" ? "Link" : "QR",
link: text("paymentLink") || null,
buttonText: text("paymentButtonText") || null,
message: text("paymentMessage") || null,
},
};

const eventImg = form.get("eventImg");
Expand Down
24 changes: 24 additions & 0 deletions app/api/form/editForm/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,30 @@ export async function PUT(
info.receiverDetails = { ...(info.receiverDetails ?? {}), upi };
}

// Merged one key at a time, like `upi` above: a partial edit must not blank
// out settings the request did not carry.
const paymentMode = text("paymentMode");
if (paymentMode !== undefined) {
info.receiverDetails = {
...(info.receiverDetails ?? {}),
mode: paymentMode === "Link" ? "Link" : "QR",
};
}

for (const [key, field] of [
["paymentLink", "link"],
["paymentButtonText", "buttonText"],
["paymentMessage", "message"],
] as const) {
const value = text(key);
if (value !== undefined) {
info.receiverDetails = {
...(info.receiverDetails ?? {}),
[field]: value || null,
};
}
}

const eventImg = form.get("eventImg");
if (eventImg instanceof File && eventImg.size > 0) {
const result = await uploadImage(eventImg, "FormImages");
Expand Down
20 changes: 14 additions & 6 deletions app/api/form/markAttendance/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { markAttendance } from "@/lib/services/attendance";
import { body, expressError, handle, json } from "@/lib/api/express";
import { getCurrentUser } from "@/lib/auth/access";
import { getCurrentUser, isAdmin } from "@/lib/auth/access";

/**
* POST /api/form/markAttendance
* Port of controllers/registration/markAttendance.js.
*
* 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.
* ADMIN only. This deliberately diverges from the Express route, which has its
* `checkAccess` commented out entirely and so accepts unauthenticated calls.
*
* The QR token alone is not an access control: a participant can mint their own
* through /api/form/attendanceCode — that endpoint exists so they can display
* their code — and could then post it straight back here to mark themselves
* present. Restricting who may *scan* is what closes that, and it has to live
* here rather than only on the page, because the page is just a UI over this
* call.
*
* Issuing a code stays open to any signed-in user; only redeeming one is
* restricted.
*
* Responds `{ message, attendance }` at the top level, which is the shape
* AttendancePage reads.
Expand All @@ -19,6 +26,7 @@ export async function POST(request: Request) {
return handle(async () => {
const user = await getCurrentUser();
if (!user) return expressError(401, "Token is required");
if (!isAdmin(user)) return expressError(403, "Unauthorized");

const b = await body<{ formId?: string; token?: string }>(request);
const result = await markAttendance({ formId: b.formId, token: b.token });
Expand Down
80 changes: 80 additions & 0 deletions app/api/form/payments/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { prisma } from "@/lib/db";
import { expressError, handle, json } from "@/lib/api/express";
import { getCurrentUser, isAdmin } from "@/lib/auth/access";
import { paymentFromSubmission } from "@/lib/services/attendance";
import type { StoredSubmission } from "@/lib/services/attendance";
import type { EventInfo } from "@/lib/types/event";

/**
* GET /api/form/payments/:id — admin only.
*
* Payment proof for one event's registrations: who paid, what they declared as
* their UTR, and the screenshot they uploaded.
*
* There is no Express counterpart. The original had no way to see an uploaded
* screenshot at all — the upload path was commented out in `addRegistration.js`
* and the register route discarded file parts — so verifying a payment meant
* taking the typed UTR on faith.
*
* The answers live inside a free-form `sections` blob that admins can rename
* and reorder, so fields are located by shape and by name pattern rather than
* by a fixed index.
*/




export async function GET(
_request: Request,
ctx: RouteContext<"/api/form/payments/[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;
if (!/^[a-f\d]{24}$/i.test(id)) return expressError(404, "Form not found");

const form = await prisma.form.findUnique({
where: { id },
select: { info: true },
});
if (!form) return expressError(404, "Form not found");

const info = (form.info ?? {}) as EventInfo;

const registrations = await prisma.formRegistration.findMany({
where: { formId: id },
select: { id: true, teamName: true, teamCode: true, value: true },
});

const payments = registrations.flatMap((registration) =>
(registration.value ?? []).map((entry) => {
const submission = entry as StoredSubmission;
const payment = paymentFromSubmission(submission);

return {
registrationId: registration.id,
teamName: registration.teamName,
teamCode: registration.teamCode,
userName: submission.user_name ?? "",
userEmail: submission.user_email ?? "",
registeredAt: submission.date_time ?? "",
amount: submission.amount ?? String(info.eventAmount ?? "0"),
utr: payment.utr,
screenshot: payment.screenshot,
};
}),
);

return json({
success: true,
eventTitle: info.eventTitle ?? "",
eventType: info.eventType ?? "Free",
eventAmount: String(info.eventAmount ?? "0"),
count: payments.length,
payments,
});
});
}
Loading
Loading