From a0dd81ee5c8fb8b0fb82ec57b4081611db4a7040 Mon Sep 17 00:00:00 2001 From: Shreeya Adhikari Date: Wed, 29 Jul 2026 18:34:01 -0400 Subject: [PATCH] feat: wire status and RSVP to real singletons --- .../src/app/api/v1/post-acceptance/route.ts | 35 +++--- .../src/app/api/v1/show-decision/route.ts | 56 +++++---- .../app-portal/src/app/api/v1/status/route.ts | 41 +++---- .../src/components/dashboard/RsvpForm.tsx | 19 +-- .../src/lib/status/fetchPortalStatus.ts | 53 +-------- .../src/lib/status/mock-singletons.ts | 90 -------------- apps/app-portal/src/lib/status/rsvp.ts | 11 ++ apps/app-portal/src/lib/status/service.ts | 110 +++++++++++++++--- 8 files changed, 178 insertions(+), 237 deletions(-) delete mode 100644 apps/app-portal/src/lib/status/mock-singletons.ts create mode 100644 apps/app-portal/src/lib/status/rsvp.ts diff --git a/apps/app-portal/src/app/api/v1/post-acceptance/route.ts b/apps/app-portal/src/app/api/v1/post-acceptance/route.ts index 0cfc1db3..8fbae780 100644 --- a/apps/app-portal/src/app/api/v1/post-acceptance/route.ts +++ b/apps/app-portal/src/app/api/v1/post-acceptance/route.ts @@ -1,24 +1,31 @@ import { NextResponse } from "next/server"; -import { z } from "zod"; -import { saveRsvp } from "../../../../lib/status/service"; - -const rsvpSchema = z.object({ - attending: z.enum(["yes", "no"]), - dietaryRestrictions: z.string().max(240), - tshirtSize: z.enum(["xs", "s", "m", "l", "xl"]), - accessibilityNeeds: z.string().max(240), - additionalNotes: z.string().max(400), -}); +import { requireUser } from "../../../../lib/auth/guards"; +import { StatusError, saveRsvp } from "../../../../lib/status/service"; +import { ZodError } from "zod"; export async function POST(request: Request) { try { + const user = await requireUser(); const body = await request.json(); - const parsed = rsvpSchema.parse(body); + await saveRsvp((user as { id?: string }).id ?? "", body); + + return NextResponse.json({ ok: true }); + } catch (error) { + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: error.message }, { status: 401 }); + } + + if (error instanceof ZodError) { + return NextResponse.json( + { error: "Unable to save RSVP right now" }, + { status: 400 }, + ); + } - await saveRsvp("mock-user", parsed); + if (error instanceof StatusError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } - return NextResponse.json({ success: true }); - } catch { return NextResponse.json( { error: "Unable to save RSVP right now" }, { status: 400 }, diff --git a/apps/app-portal/src/app/api/v1/show-decision/route.ts b/apps/app-portal/src/app/api/v1/show-decision/route.ts index 769e7e1b..c373d327 100644 --- a/apps/app-portal/src/app/api/v1/show-decision/route.ts +++ b/apps/app-portal/src/app/api/v1/show-decision/route.ts @@ -1,27 +1,43 @@ import { NextResponse } from "next/server"; -import { SingletonKey } from "@/lib/admin/singleton-keys"; - -type SingletonResponse = { - key: SingletonKey; - value: string; -}; +import { requireAdmin } from "@/lib/auth/guards"; +import { getSingleton, setSingleton } from "@/lib/admin/singleton-service"; +import { SingletonKey } from "@/lib/types/singleton"; export async function GET() { - const data: SingletonResponse = { - key: "show-decision", - value: "2026-06-15T23:59:00Z", - }; + await requireAdmin(); + const value = await getSingleton(SingletonKey.ShowDecision); - return NextResponse.json(data); + return NextResponse.json({ + key: SingletonKey.ShowDecision, + value: value ?? false, + }); } -export async function POST() { - return NextResponse.json( - { - message: "Not implemented", - }, - { - status: 501, - }, - ); +export async function POST(req: Request) { + try { + const user = await requireAdmin(); + const body = await req.json(); + + if (typeof body.enabled !== "boolean") { + return NextResponse.json({ error: "enabled must be a boolean" }, { status: 400 }); + } + + await setSingleton( + SingletonKey.ShowDecision, + body.enabled, + (user as { id?: string; email?: string }).email ?? (user as { id?: string }).id ?? "unknown", + ); + + return NextResponse.json({ ok: true, value: body.enabled }); + } catch (error) { + if (error instanceof Error && error.message === "Forbidden") { + return NextResponse.json({ error: error.message }, { status: 403 }); + } + + if (error instanceof Error && error.message === "Unauthorized") { + return NextResponse.json({ error: error.message }, { status: 401 }); + } + + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } } diff --git a/apps/app-portal/src/app/api/v1/status/route.ts b/apps/app-portal/src/app/api/v1/status/route.ts index 0e306333..84f80948 100644 --- a/apps/app-portal/src/app/api/v1/status/route.ts +++ b/apps/app-portal/src/app/api/v1/status/route.ts @@ -1,33 +1,18 @@ -import { NextRequest, NextResponse } from "next/server"; -import { decisionDates } from "../../../../lib/status/mock-singletons"; -import { returnDashboardBranch } from "../../../../lib/status/machine"; -import { getApplicantStatus } from "../../../../lib/status/service"; +import { NextResponse } from "next/server"; +import { requireUser } from "../../../../lib/auth/guards"; +import { getPortalStatus, StatusError } from "../../../../lib/status/service"; -// TODO: gate with requireUser() once Ticket 1 ships its helpers -export async function GET(req: NextRequest) { - const userId = req.nextUrl.searchParams.get("userId") ?? "mock-user"; - const status = await getApplicantStatus(userId); - const showDecision = new Date() >= decisionDates.showDecision; +export async function GET() { + try { + await requireUser(); + return NextResponse.json(await getPortalStatus()); + } catch (error) { + if (error instanceof StatusError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } - const branch = returnDashboardBranch({ - user: status, - dates: { - registrationOpen: decisionDates.registrationOpen, - confirmBy: decisionDates.confirmBy, - }, - showDecision, - now: new Date(), - }); - - return NextResponse.json({ - branch, - status, - decisionDates: { - registrationOpen: decisionDates.registrationOpen.toISOString(), - showDecision: decisionDates.showDecision.toISOString(), - confirmBy: decisionDates.confirmBy.toISOString(), - }, - }); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } } export async function POST() { diff --git a/apps/app-portal/src/components/dashboard/RsvpForm.tsx b/apps/app-portal/src/components/dashboard/RsvpForm.tsx index 073de68a..f779a79d 100644 --- a/apps/app-portal/src/components/dashboard/RsvpForm.tsx +++ b/apps/app-portal/src/components/dashboard/RsvpForm.tsx @@ -5,18 +5,7 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; - -const rsvpSchema = z.object({ - attending: z.enum(["yes", "no"] as const, { - error: "Please choose whether you’re attending.", - }), - dietaryRestrictions: z.string().max(240).optional().or(z.literal("")), - tshirtSize: z.enum(["xs", "s", "m", "l", "xl"] as const, { - error: "Please choose a t-shirt size.", - }), - accessibilityNeeds: z.string().max(240).optional().or(z.literal("")), - additionalNotes: z.string().max(400).optional().or(z.literal("")), -}); +import { rsvpSchema } from "../../lib/status/rsvp"; type RsvpFormValues = z.infer; @@ -51,7 +40,7 @@ export default function RsvpForm({ } = useForm({ resolver: zodResolver(rsvpSchema), defaultValues: { - attending: "yes", + attending: "confirmed", dietaryRestrictions: "", tshirtSize: "m", accessibilityNeeds: "", @@ -133,8 +122,8 @@ export default function RsvpForm({ className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-slate-900 outline-none transition focus:border-slate-950" {...register("attending")} > - - + + {errors.attending && (

diff --git a/apps/app-portal/src/lib/status/fetchPortalStatus.ts b/apps/app-portal/src/lib/status/fetchPortalStatus.ts index 9570a510..f6644043 100644 --- a/apps/app-portal/src/lib/status/fetchPortalStatus.ts +++ b/apps/app-portal/src/lib/status/fetchPortalStatus.ts @@ -1,55 +1,6 @@ -import { headers } from "next/headers"; import type { PortalStatusResponse } from "./types"; -import { getApplicantStatus } from "./service"; -import { decisionDates } from "./mock-singletons"; -import { returnDashboardBranch } from "./machine"; - -function getBaseUrl(): string { - const headerList = headers(); - const forwardedProto = headerList.get("x-forwarded-proto"); - const forwardedHost = headerList.get("x-forwarded-host"); - const host = forwardedHost ?? headerList.get("host"); - - if (!host) { - return `http://localhost:3000`; - } - - return `${forwardedProto ?? "http"}://${host}`; -} +import { getPortalStatus } from "./service"; export async function fetchPortalStatus(): Promise { - try { - const response = await fetch(`${getBaseUrl()}/api/v1/status`, { - cache: "no-store", - }); - - if (!response.ok) { - throw new Error("Failed to load portal status"); - } - - return (await response.json()) as PortalStatusResponse; - } catch { - const status = await getApplicantStatus("mock-user"); - const showDecision = new Date() >= decisionDates.showDecision; - - const branch = returnDashboardBranch({ - user: status, - dates: { - registrationOpen: decisionDates.registrationOpen, - confirmBy: decisionDates.confirmBy, - }, - showDecision, - now: new Date(), - }); - - return { - branch, - status, - decisionDates: { - registrationOpen: decisionDates.registrationOpen.toISOString(), - showDecision: decisionDates.showDecision.toISOString(), - confirmBy: decisionDates.confirmBy.toISOString(), - }, - }; - } + return getPortalStatus(); } diff --git a/apps/app-portal/src/lib/status/mock-singletons.ts b/apps/app-portal/src/lib/status/mock-singletons.ts deleted file mode 100644 index b77c0ea0..00000000 --- a/apps/app-portal/src/lib/status/mock-singletons.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { ApplicantStatus, DecisionDates } from "./types"; - -// Pre registration -// export const mockApplicantStatus: ApplicantStatus = { -// userId: "mock-user", -// applicationStatus: "in-progress", -// decisionStatus: "pending", -// rsvpStatus: "not-submitted", -// }; - -// export const decisionDates = { -// registrationOpen: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // opens in 7 days -// showDecision: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), -// confirmBy: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000), -// }; - -// In progress -// export const mockApplicantStatus = { -// userId: "mock-user", -// applicationStatus: "in-progress", -// decisionStatus: "pending", -// rsvpStatus: "not-submitted", -// }; - -// export const decisionDates = { -// registrationOpen: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), -// showDecision: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), -// confirmBy: new Date(Date.now() + 21 * 24 * 60 * 60 * 1000), -// }; - -// Submitted -// export const mockApplicantStatus = { -// userId: "mock-user", -// applicationStatus: "submitted", -// decisionStatus: "pending", -// rsvpStatus: "not-submitted", -// }; - -// export const decisionDates = { -// registrationOpen: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000), -// showDecision: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), -// confirmBy: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), -// }; - -//Admiited - -// export const mockApplicantStatus = { -// userId: "mock-user", -// applicationStatus: "submitted", -// decisionStatus: "admitted", -// rsvpStatus: "not-submitted", -// }; - -// export const decisionDates = { -// registrationOpen: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000), -// showDecision: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), -// confirmBy: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // RSVP open -// }; - -// Waitlisted -// export const mockApplicantStatus = { -// userId: "mock-user", -// applicationStatus: "submitted", -// decisionStatus: "waitlisted", -// rsvpStatus: "not-submitted", -// }; - -// export const decisionDates = { -// registrationOpen: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000), -// showDecision: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), -// confirmBy: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), -// }; - -//Declined -// Admitted (mock) -export const mockApplicantStatus: ApplicantStatus = { - userId: "mock-user", - applicationStatus: "submitted", - decisionStatus: "admitted", - rsvpStatus: "unconfirmed", -}; - -export const decisionDates: DecisionDates = { - // registration opened in the past - registrationOpen: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000), - // showDecision happened recently - showDecision: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), - // confirmBy is in the future so RSVP is open - confirmBy: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), -}; diff --git a/apps/app-portal/src/lib/status/rsvp.ts b/apps/app-portal/src/lib/status/rsvp.ts new file mode 100644 index 00000000..8b50afdf --- /dev/null +++ b/apps/app-portal/src/lib/status/rsvp.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const rsvpSchema = z.object({ + attending: z.enum(["confirmed", "unconfirmed"]), + dietaryRestrictions: z.string().trim().max(240), + tshirtSize: z.enum(["xs", "s", "m", "l", "xl"]), + accessibilityNeeds: z.string().trim().max(240), + additionalNotes: z.string().trim().max(400), +}); + +export type RsvpSubmission = z.infer; \ No newline at end of file diff --git a/apps/app-portal/src/lib/status/service.ts b/apps/app-portal/src/lib/status/service.ts index f14e1e01..76e335f6 100644 --- a/apps/app-portal/src/lib/status/service.ts +++ b/apps/app-portal/src/lib/status/service.ts @@ -1,13 +1,46 @@ import { getDb } from "@/lib/db"; -import { decisionDates, mockApplicantStatus } from "./mock-singletons"; +import { requireUser } from "@/lib/auth/guards"; +import { getSingleton } from "@/lib/admin/singleton-service"; +import { SingletonKey } from "@/lib/types/singleton"; import { returnDashboardBranch } from "./machine"; +import { rsvpSchema } from "./rsvp"; import type { ApplicantStatus, PortalStatusResponse, - RsvpPayload, RsvpStatus, } from "./types"; +const DEFAULT_FUTURE_DATE = new Date("9999-12-31T23:59:59.999Z"); + +export class StatusError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "StatusError"; + this.status = status; + } +} + +async function loadPortalSettings() { + const [registrationOpenValue, confirmByValue, showDecisionValue] = + await Promise.all([ + getSingleton(SingletonKey.RegistrationOpen), + getSingleton(SingletonKey.ConfirmBy), + getSingleton(SingletonKey.ShowDecision), + ]); + + return { + registrationOpen: + registrationOpenValue !== null + ? new Date(registrationOpenValue) + : DEFAULT_FUTURE_DATE, + confirmBy: + confirmByValue !== null ? new Date(confirmByValue) : DEFAULT_FUTURE_DATE, + showDecision: showDecisionValue === true, + }; +} + export async function getApplicantStatus( userId: string, ): Promise { @@ -31,21 +64,24 @@ export async function getApplicantStatus( }; } -export async function getPortalStatus( - userId: string, -): Promise { - const user = await getApplicantStatus(userId); +export async function getPortalStatus(): Promise { + const sessionUser = await requireUser(); + const userId = (sessionUser as { id?: string }).id; - // TODO: replace with getSingleton() calls once Ticket 5 lands real singleton service - const showDecision = new Date() >= decisionDates.showDecision; + if (!userId) { + throw new StatusError("Unauthorized", 401); + } + + const user = await getApplicantStatus(userId); + const settings = await loadPortalSettings(); const branch = returnDashboardBranch({ user, dates: { - registrationOpen: decisionDates.registrationOpen, - confirmBy: decisionDates.confirmBy, + registrationOpen: settings.registrationOpen, + confirmBy: settings.confirmBy, }, - showDecision, + showDecision: settings.showDecision, now: new Date(), }); @@ -53,19 +89,55 @@ export async function getPortalStatus( branch, status: user, decisionDates: { - registrationOpen: decisionDates.registrationOpen.toISOString(), - confirmBy: decisionDates.confirmBy.toISOString(), - showDecision: decisionDates.showDecision.toISOString(), + registrationOpen: settings.registrationOpen.toISOString(), + confirmBy: settings.confirmBy.toISOString(), + showDecision: settings.showDecision + ? new Date().toISOString() + : DEFAULT_FUTURE_DATE.toISOString(), }, }; } export async function saveRsvp( userId: string, - payload: RsvpPayload, + payload: unknown, ): Promise { - void userId; - void payload; - mockApplicantStatus.rsvpStatus = "confirmed"; - return "confirmed"; + const parsedPayload = rsvpSchema.parse(payload); + const db = await getDb(); + const collection = db.collection("applicant_data"); + const applicant = await collection.findOne({ userId }); + + if (!applicant || applicant.decisionStatus !== "admitted") { + throw new StatusError("Only admitted users can RSVP.", 403); + } + + const confirmByValue = await getSingleton(SingletonKey.ConfirmBy); + const confirmBy = + confirmByValue !== null ? new Date(confirmByValue) : DEFAULT_FUTURE_DATE; + + if (Date.now() > confirmBy.getTime()) { + throw new StatusError("The confirm-by deadline has passed.", 410); + } + + const rsvpStatus: RsvpStatus = + parsedPayload.attending === "confirmed" ? "confirmed" : "not-attending"; + + await collection.updateOne( + { userId }, + { + $set: { + rsvpStatus, + postAcceptanceResponses: { + attending: parsedPayload.attending, + dietaryRestrictions: parsedPayload.dietaryRestrictions, + tshirtSize: parsedPayload.tshirtSize, + accessibilityNeeds: parsedPayload.accessibilityNeeds, + additionalNotes: parsedPayload.additionalNotes, + }, + rsvpSubmissionTime: new Date().toISOString(), + }, + }, + ); + + return rsvpStatus; }