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
11 changes: 11 additions & 0 deletions apps/app-portal/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Config } from "jest";

const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
};

export default config;
2 changes: 1 addition & 1 deletion apps/app-portal/src/app/(applicant)/rsvp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default async function RsvpPage(): Promise<JSX.Element> {

return (
<RsvpExperience
alreadySubmitted={status.rsvpStatus === "submitted"}
alreadySubmitted={status.rsvpStatus === "confirmed"}
confirmBy={confirmBy.toISOString()}
/>
);
Expand Down
21 changes: 16 additions & 5 deletions apps/app-portal/src/app/api/v1/status/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,4 +32,4 @@ export async function GET() {

export async function POST() {
return NextResponse.json({ error: "error 501" }, { status: 501 });
}
}
4 changes: 2 additions & 2 deletions apps/app-portal/src/components/dashboard/AdmittedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ export default function AdmittedView({
RSVP status
</p>
<p className="mt-2 text-2xl font-semibold text-slate-950">
{status.rsvpStatus === "submitted" ? "Confirmed" : "Needs RSVP"}
{status.rsvpStatus === "confirmed" ? "Confirmed" : "Needs RSVP"}
</p>
<p className="mt-2 text-sm leading-6 text-slate-600">
{status.rsvpStatus === "submitted"
{status.rsvpStatus === "confirmed"
? "Thanks for confirming your attendance."
: "Please complete the RSVP form before the deadline."}
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<PortalShell
Expand Down
2 changes: 1 addition & 1 deletion apps/app-portal/src/components/dashboard/SubmittedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default function SubmittedView({
Review date: XXX
</p>
<p className="mt-2 text-sm leading-6 text-slate-600">
{status.rsvpStatus === "submitted"
{status.rsvpStatus === "confirmed"
? "You&apos;ve already completed post-acceptance RSVP steps."
: "Your application is ready for the next review stage."}
</p>
Expand Down
17 changes: 11 additions & 6 deletions apps/app-portal/src/lib/status/fetchPortalStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
Expand All @@ -31,11 +29,18 @@ export async function fetchPortalStatus(): Promise<PortalStatusResponse> {

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,
Expand All @@ -47,4 +52,4 @@ export async function fetchPortalStatus(): Promise<PortalStatusResponse> {
},
};
}
}
}
86 changes: 86 additions & 0 deletions apps/app-portal/src/lib/status/machine.test.ts
Original file line number Diff line number Diff line change
@@ -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<ApplicantStatus>;
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");
});
});
26 changes: 11 additions & 15 deletions apps/app-portal/src/lib/status/machine.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -25,8 +23,6 @@ export function returnDashboardBranch(
case "declined":
return "declined";
default:
return user.applicationStatus === "in-progress"
? "in-progress"
: "submitted";
return "submitted";
}
}
}
2 changes: 1 addition & 1 deletion apps/app-portal/src/lib/status/mock-singletons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const mockApplicantStatus: ApplicantStatus = {
userId: "mock-user",
applicationStatus: "submitted",
decisionStatus: "admitted",
rsvpStatus: "not-submitted",
rsvpStatus: "unconfirmed",
};

export const decisionDates: DecisionDates = {
Expand Down
68 changes: 58 additions & 10 deletions apps/app-portal/src/lib/status/service.ts
Original file line number Diff line number Diff line change
@@ -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<ApplicantStatus> {
void userId;
return { ...mockApplicantStatus };
export async function getApplicantStatus(userId: string): Promise<ApplicantStatus> {
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<PortalStatusResponse> {
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(
Expand All @@ -14,6 +62,6 @@ export async function saveRsvp(
): Promise<RsvpStatus> {
void userId;
void payload;
mockApplicantStatus.rsvpStatus = "submitted";
return "submitted";
}
mockApplicantStatus.rsvpStatus = "confirmed";
return "confirmed";
}
26 changes: 19 additions & 7 deletions apps/app-portal/src/lib/status/types.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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";
Expand All @@ -43,4 +55,4 @@ export type PortalStatusResponse = {
branch: DashboardBranch;
status: ApplicantStatus;
decisionDates: SerializedDecisionDates;
};
};
Loading