Skip to content
Open
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
35 changes: 21 additions & 14 deletions apps/app-portal/src/app/api/v1/post-acceptance/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down
56 changes: 36 additions & 20 deletions apps/app-portal/src/app/api/v1/show-decision/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
41 changes: 13 additions & 28 deletions apps/app-portal/src/app/api/v1/status/route.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
19 changes: 4 additions & 15 deletions apps/app-portal/src/components/dashboard/RsvpForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof rsvpSchema>;

Expand Down Expand Up @@ -51,7 +40,7 @@ export default function RsvpForm({
} = useForm<RsvpFormValues>({
resolver: zodResolver(rsvpSchema),
defaultValues: {
attending: "yes",
attending: "confirmed",
dietaryRestrictions: "",
tshirtSize: "m",
accessibilityNeeds: "",
Expand Down Expand Up @@ -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")}
>
<option value="yes">Yes, I’m coming</option>
<option value="no">No, I can’t make it</option>
<option value="confirmed">Yes, I’m coming</option>
<option value="unconfirmed">No, I can’t make it</option>
</select>
{errors.attending && (
<p className="text-sm text-rose-600">
Expand Down
53 changes: 2 additions & 51 deletions apps/app-portal/src/lib/status/fetchPortalStatus.ts
Original file line number Diff line number Diff line change
@@ -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<PortalStatusResponse> {
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();
}
90 changes: 0 additions & 90 deletions apps/app-portal/src/lib/status/mock-singletons.ts

This file was deleted.

11 changes: 11 additions & 0 deletions apps/app-portal/src/lib/status/rsvp.ts
Original file line number Diff line number Diff line change
@@ -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<typeof rsvpSchema>;
Loading