diff --git a/apps/app-portal/jest.config.ts b/apps/app-portal/jest.config.ts
new file mode 100644
index 00000000..75254737
--- /dev/null
+++ b/apps/app-portal/jest.config.ts
@@ -0,0 +1,11 @@
+import type { Config } from "jest";
+
+const config: Config = {
+ preset: "ts-jest",
+ testEnvironment: "node",
+ moduleNameMapper: {
+ "^@/(.*)$": "/src/$1",
+ },
+};
+
+export default config;
\ No newline at end of file
diff --git a/apps/app-portal/src/app/(applicant)/rsvp/page.tsx b/apps/app-portal/src/app/(applicant)/rsvp/page.tsx
index d5ea5388..4bb55331 100644
--- a/apps/app-portal/src/app/(applicant)/rsvp/page.tsx
+++ b/apps/app-portal/src/app/(applicant)/rsvp/page.tsx
@@ -14,7 +14,7 @@ export default async function RsvpPage(): Promise {
return (
);
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 1021b11b..3e38c0de 100644
--- a/apps/app-portal/src/app/api/v1/status/route.ts
+++ b/apps/app-portal/src/app/api/v1/status/route.ts
@@ -1,12 +1,23 @@
-import { NextResponse } from "next/server";
+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";
-export async function GET() {
- const status = await getApplicantStatus("mock-user");
+// 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;
- const branch = returnDashboardBranch(status, decisionDates, showDecision);
+
+ const branch = returnDashboardBranch({
+ user: status,
+ dates: {
+ registrationOpen: decisionDates.registrationOpen,
+ confirmBy: decisionDates.confirmBy,
+ },
+ showDecision,
+ now: new Date(),
+ });
return NextResponse.json({
branch,
@@ -21,4 +32,4 @@ export async function GET() {
export async function POST() {
return NextResponse.json({ error: "error 501" }, { status: 501 });
-}
+}
\ No newline at end of file
diff --git a/apps/app-portal/src/components/dashboard/AdmittedView.tsx b/apps/app-portal/src/components/dashboard/AdmittedView.tsx
index c749dc61..dd687269 100644
--- a/apps/app-portal/src/components/dashboard/AdmittedView.tsx
+++ b/apps/app-portal/src/components/dashboard/AdmittedView.tsx
@@ -55,10 +55,10 @@ export default function AdmittedView({
RSVP status
- {status.rsvpStatus === "submitted" ? "Confirmed" : "Needs RSVP"}
+ {status.rsvpStatus === "confirmed" ? "Confirmed" : "Needs RSVP"}
- {status.rsvpStatus === "submitted"
+ {status.rsvpStatus === "confirmed"
? "Thanks for confirming your attendance."
: "Please complete the RSVP form before the deadline."}
diff --git a/apps/app-portal/src/components/dashboard/InProgressView.tsx b/apps/app-portal/src/components/dashboard/InProgressView.tsx
index db178c2e..fac55393 100644
--- a/apps/app-portal/src/components/dashboard/InProgressView.tsx
+++ b/apps/app-portal/src/components/dashboard/InProgressView.tsx
@@ -16,7 +16,7 @@ type InProgressViewProps = {
export default function InProgressView({
status,
}: InProgressViewProps): JSX.Element {
- const progressPercent = status.applicationStatus === "in-progress" ? 60 : 100;
+ const progressPercent = status.applicationStatus === "incomplete" ? 60 : 100;
return (
- {status.rsvpStatus === "submitted"
+ {status.rsvpStatus === "confirmed"
? "You've already completed post-acceptance RSVP steps."
: "Your application is ready for the next review stage."}
diff --git a/apps/app-portal/src/lib/status/fetchPortalStatus.ts b/apps/app-portal/src/lib/status/fetchPortalStatus.ts
index 8130c362..83421ae7 100644
--- a/apps/app-portal/src/lib/status/fetchPortalStatus.ts
+++ b/apps/app-portal/src/lib/status/fetchPortalStatus.ts
@@ -10,8 +10,6 @@ function getBaseUrl(): string {
const forwardedHost = headerList.get("x-forwarded-host");
const host = forwardedHost ?? headerList.get("host");
- // If headers are not available (e.g. some dev environments), fall back
- // to localhost so server-side fetches still work during local dev.
if (!host) {
return `http://localhost:3000`;
}
@@ -31,11 +29,18 @@ export async function fetchPortalStatus(): Promise {
return (await response.json()) as PortalStatusResponse;
} catch {
- // Fallback to local in-memory service for dev environments where
- // a network fetch to the same server may fail.
const status = await getApplicantStatus("mock-user");
const showDecision = new Date() >= decisionDates.showDecision;
- const branch = returnDashboardBranch(status, decisionDates, showDecision);
+
+ const branch = returnDashboardBranch({
+ user: status,
+ dates: {
+ registrationOpen: decisionDates.registrationOpen,
+ confirmBy: decisionDates.confirmBy,
+ },
+ showDecision,
+ now: new Date(),
+ });
return {
branch,
@@ -47,4 +52,4 @@ export async function fetchPortalStatus(): Promise {
},
};
}
-}
+}
\ No newline at end of file
diff --git a/apps/app-portal/src/lib/status/machine.test.ts b/apps/app-portal/src/lib/status/machine.test.ts
new file mode 100644
index 00000000..36df9001
--- /dev/null
+++ b/apps/app-portal/src/lib/status/machine.test.ts
@@ -0,0 +1,86 @@
+import { returnDashboardBranch } from "./machine";
+import type { ApplicantStatus, MachineInput } from "./types";
+
+const PAST = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+const FUTURE = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+
+function makeInput(overrides: {
+ user?: Partial;
+ dates?: { registrationOpen: Date; confirmBy: Date };
+ showDecision?: boolean;
+ now?: Date;
+} = {}): MachineInput {
+ return {
+ user: {
+ userId: "test-user",
+ applicationStatus: "not-started",
+ rsvpStatus: "unconfirmed",
+ ...overrides.user,
+ },
+ dates: overrides.dates ?? {
+ registrationOpen: PAST,
+ confirmBy: FUTURE,
+ },
+ showDecision: overrides.showDecision ?? true,
+ now: overrides.now ?? new Date(),
+ };
+}
+describe("returnDashboardBranch", () => {
+ test("pre-registration: now < registrationOpen regardless of other fields", () => {
+ const input = makeInput({
+ user: { applicationStatus: "submitted", decisionStatus: "admitted" },
+ dates: { registrationOpen: FUTURE, confirmBy: FUTURE },
+ });
+ expect(returnDashboardBranch(input)).toBe("pre-registration");
+ });
+
+ test("in-progress: applicationStatus incomplete and now >= registrationOpen", () => {
+ const input = makeInput({ user: { applicationStatus: "incomplete" } });
+ expect(returnDashboardBranch(input)).toBe("in-progress");
+ });
+
+ test("not-started after open returns in-progress", () => {
+ const input = makeInput({ user: { applicationStatus: "not-started" } });
+ expect(returnDashboardBranch(input)).toBe("in-progress");
+ });
+
+ test("submitted with decisions hidden returns submitted", () => {
+ const input = makeInput({
+ user: { applicationStatus: "submitted", decisionStatus: "admitted" },
+ showDecision: false,
+ });
+ expect(returnDashboardBranch(input)).toBe("submitted");
+ });
+
+ test("submitted with decisionStatus pending returns submitted", () => {
+ const input = makeInput({
+ user: { applicationStatus: "submitted", decisionStatus: "pending" },
+ showDecision: true,
+ });
+ expect(returnDashboardBranch(input)).toBe("submitted");
+ });
+
+ test("admitted: submitted + admitted + showDecision true", () => {
+ const input = makeInput({
+ user: { applicationStatus: "submitted", decisionStatus: "admitted" },
+ showDecision: true,
+ });
+ expect(returnDashboardBranch(input)).toBe("admitted");
+ });
+
+ test("waitlisted: submitted + waitlisted + showDecision true", () => {
+ const input = makeInput({
+ user: { applicationStatus: "submitted", decisionStatus: "waitlisted" },
+ showDecision: true,
+ });
+ expect(returnDashboardBranch(input)).toBe("waitlisted");
+ });
+
+ test("declined: submitted + declined + showDecision true", () => {
+ const input = makeInput({
+ user: { applicationStatus: "submitted", decisionStatus: "declined" },
+ showDecision: true,
+ });
+ expect(returnDashboardBranch(input)).toBe("declined");
+ });
+});
\ No newline at end of file
diff --git a/apps/app-portal/src/lib/status/machine.ts b/apps/app-portal/src/lib/status/machine.ts
index 77dae8c0..28e54e0c 100644
--- a/apps/app-portal/src/lib/status/machine.ts
+++ b/apps/app-portal/src/lib/status/machine.ts
@@ -1,20 +1,18 @@
-import type { ApplicantStatus, DashboardBranch, DecisionDates } from "./types";
+import type { DashboardBranch, MachineInput } from "./types";
-export function returnDashboardBranch(
- user: ApplicantStatus,
- dates: DecisionDates,
- showDecision: boolean,
-): DashboardBranch {
- const now = new Date();
+export function returnDashboardBranch(input: MachineInput): DashboardBranch {
+ const { user, dates, showDecision, now } = input;
if (now < dates.registrationOpen) {
return "pre-registration";
}
- if (!showDecision) {
- return user.applicationStatus === "in-progress"
- ? "in-progress"
- : "submitted";
+ if (user.applicationStatus !== "submitted") {
+ return "in-progress";
+ }
+
+ if (!showDecision || !user.decisionStatus || user.decisionStatus === "pending") {
+ return "submitted";
}
switch (user.decisionStatus) {
@@ -25,8 +23,6 @@ export function returnDashboardBranch(
case "declined":
return "declined";
default:
- return user.applicationStatus === "in-progress"
- ? "in-progress"
- : "submitted";
+ return "submitted";
}
-}
+}
\ No newline at end of file
diff --git a/apps/app-portal/src/lib/status/mock-singletons.ts b/apps/app-portal/src/lib/status/mock-singletons.ts
index a26c9e9e..b77c0ea0 100644
--- a/apps/app-portal/src/lib/status/mock-singletons.ts
+++ b/apps/app-portal/src/lib/status/mock-singletons.ts
@@ -77,7 +77,7 @@ export const mockApplicantStatus: ApplicantStatus = {
userId: "mock-user",
applicationStatus: "submitted",
decisionStatus: "admitted",
- rsvpStatus: "not-submitted",
+ rsvpStatus: "unconfirmed",
};
export const decisionDates: DecisionDates = {
diff --git a/apps/app-portal/src/lib/status/service.ts b/apps/app-portal/src/lib/status/service.ts
index 38952fba..3cad24d5 100644
--- a/apps/app-portal/src/lib/status/service.ts
+++ b/apps/app-portal/src/lib/status/service.ts
@@ -1,11 +1,59 @@
-import { mockApplicantStatus } from "./mock-singletons";
-import type { ApplicantStatus, RsvpPayload, RsvpStatus } from "./types";
+import { getDb } from "@/lib/db";
+import { decisionDates, mockApplicantStatus } from "./mock-singletons";
+import { returnDashboardBranch } from "./machine";
+import type {
+ ApplicantStatus,
+ PortalStatusResponse,
+ RsvpPayload,
+ RsvpStatus,
+} from "./types";
-export async function getApplicantStatus(
- userId: string,
-): Promise {
- void userId;
- return { ...mockApplicantStatus };
+export async function getApplicantStatus(userId: string): Promise {
+ const db = await getDb();
+ const doc = await db.collection("applicant_data").findOne({ userId });
+
+ if (!doc) {
+ return {
+ userId,
+ applicationStatus: "not-started",
+ decisionStatus: undefined,
+ rsvpStatus: "unconfirmed",
+ };
+ }
+
+ return {
+ userId,
+ applicationStatus: doc.applicationStatus ?? "not-started",
+ decisionStatus: doc.decisionStatus,
+ rsvpStatus: doc.rsvpStatus ?? "unconfirmed",
+ };
+}
+
+export async function getPortalStatus(userId: string): Promise {
+ const user = await getApplicantStatus(userId);
+
+ // TODO: replace with getSingleton() calls once Ticket 5 lands real singleton service
+ const showDecision = new Date() >= decisionDates.showDecision;
+
+ const branch = returnDashboardBranch({
+ user,
+ dates: {
+ registrationOpen: decisionDates.registrationOpen,
+ confirmBy: decisionDates.confirmBy,
+ },
+ showDecision,
+ now: new Date(),
+ });
+
+ return {
+ branch,
+ status: user,
+ decisionDates: {
+ registrationOpen: decisionDates.registrationOpen.toISOString(),
+ confirmBy: decisionDates.confirmBy.toISOString(),
+ showDecision: decisionDates.showDecision.toISOString(),
+ },
+ };
}
export async function saveRsvp(
@@ -14,6 +62,6 @@ export async function saveRsvp(
): Promise {
void userId;
void payload;
- mockApplicantStatus.rsvpStatus = "submitted";
- return "submitted";
-}
+ mockApplicantStatus.rsvpStatus = "confirmed";
+ return "confirmed";
+}
\ No newline at end of file
diff --git a/apps/app-portal/src/lib/status/types.ts b/apps/app-portal/src/lib/status/types.ts
index 97a1a644..185fd0ba 100644
--- a/apps/app-portal/src/lib/status/types.ts
+++ b/apps/app-portal/src/lib/status/types.ts
@@ -1,3 +1,11 @@
+import type {
+ ApplicationStatus,
+ DecisionStatus,
+ RsvpStatus,
+} from "@/lib/types/user";
+
+export type { ApplicationStatus, DecisionStatus, RsvpStatus };
+
export type DashboardBranch =
| "pre-registration"
| "in-progress"
@@ -9,15 +17,19 @@ export type DashboardBranch =
export type ApplicantStatus = {
userId: string;
applicationStatus: ApplicationStatus;
- decisionStatus: DecisionStatus;
+ decisionStatus?: DecisionStatus;
rsvpStatus: RsvpStatus;
};
-export type ApplicationStatus = "in-progress" | "submitted";
-
-export type DecisionStatus = "pending" | "admitted" | "waitlisted" | "declined";
-
-export type RsvpStatus = "not-submitted" | "submitted";
+export type MachineInput = {
+ user: ApplicantStatus;
+ dates: {
+ registrationOpen: Date;
+ confirmBy: Date;
+ };
+ showDecision: boolean;
+ now: Date;
+};
export type RsvpPayload = {
attending: "yes" | "no";
@@ -43,4 +55,4 @@ export type PortalStatusResponse = {
branch: DashboardBranch;
status: ApplicantStatus;
decisionDates: SerializedDecisionDates;
-};
+};
\ No newline at end of file
diff --git a/apps/app-portal/tsconfig.json b/apps/app-portal/tsconfig.json
index 24b8cfcd..c8c03b48 100644
--- a/apps/app-portal/tsconfig.json
+++ b/apps/app-portal/tsconfig.json
@@ -5,7 +5,8 @@
"paths": {
"@/*": ["./src/*"],
"@util/*": ["../../packages/util/src/*"]
- }
+ },
+ "types": ["jest"]
},
"include": [
"next-env.d.ts",
@@ -15,4 +16,4 @@
"../../packages/ui/src/Placeholder.tsx"
],
"exclude": ["node_modules"]
-}
+}
\ No newline at end of file