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
25 changes: 23 additions & 2 deletions apps/app-portal/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,26 @@ BEEHIIV_API_KEY=
GOOGLE_CLOUD_PROJECT_ID=
GOOGLE_CLOUD_STORAGE_RESUME_BUCKET=
GOOGLE_CLOUD_STORAGE_RESUME_BUCKET_TEST=
GOOGLE_CLOUD_PRIVATE_KEY=
GOOGLE_CLOUD_EMAIL=
GOOGLE_CLOUD_PRIVATE_KEY=
GOOGLE_CLOUD_EMAIL=

# --- MongoDB (see src/lib/db.ts) ---
# Dev and prod share one Atlas cluster; collections get a `_test` suffix outside
# production (see resolveCollectionName in src/lib/db.ts), so this is safe to point
# at the same cluster used in production.
MONGO_PROD_CONNECTION_STRING=
MONGO_SERVER_DBNAME=

# --- NextAuth (see src/lib/auth/config.ts) ---
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET=
# Base URL of this app. Required in production — used to build absolute URLs in
# outgoing emails (see src/lib/auth/email-transport.ts) and by the auth middleware.
NEXTAUTH_URL=http://localhost:3000

# --- Outgoing email (magic-link sign-in, see src/lib/auth/email-transport.ts) ---
EMAIL_SERVER_HOST=
EMAIL_SERVER_PORT=
EMAIL_SERVER_USER=
EMAIL_SERVER_PASSWORD=
EMAIL_FROM=
6 changes: 4 additions & 2 deletions apps/app-portal/scripts/setup-indexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export async function ensureApplicantIndexes(col: Collection): Promise<void> {

export async function ensureUploadsCollection(): Promise<void> {
const db = await getDb();
const existing = await db.listCollections({ name: UPLOADS_COLLECTION }).toArray();
const existing = await db
.listCollections({ name: UPLOADS_COLLECTION })
.toArray();

if (existing.length === 0) {
await db.createCollection(UPLOADS_COLLECTION);
Expand All @@ -60,7 +62,7 @@ export async function ensureUploadIndexes(col: Collection): Promise<void> {
async function main() {
const db = await getDb();
const col = db.collection(APPLICANT_COLLECTION);

await ensureApplicantIndexes(col);
await ensureUploadsCollection();
await ensureUploadIndexes(db.collection(UPLOADS_COLLECTION));
Expand Down
28 changes: 28 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from "react";

import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";

export default function AdminLoading(): JSX.Element {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-8 w-56" />
<Skeleton className="mt-2 h-4 w-40" />
</div>

<div className="grid grid-cols-3 gap-6">
{["a", "b", "c"].map((key) => (
<Card key={key}>
<CardContent className="p-8">
<Skeleton className="h-6 w-32" />
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-1 h-4 w-3/4" />
<Skeleton className="mt-4 h-8 w-20 rounded" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
1 change: 0 additions & 1 deletion apps/app-portal/src/app/(admin)/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export default function AdminPage() {

<Link
href={t.link}
style={{ backgroundColor: "#1890ff" }}
className="mt-4 inline-block rounded border bg-blue-400 px-3 py-1 text-white"
>
Open
Expand Down
30 changes: 30 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/settings/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from "react";

import { Skeleton } from "@/components/ui/skeleton";

export default function SettingsLoading(): JSX.Element {
return (
<div className="flex flex-col gap-8">
<Skeleton className="h-8 w-64" />

<section>
<Skeleton className="mb-4 h-6 w-24" />
<div className="flex flex-col gap-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</section>

<section>
<Skeleton className="mb-4 h-6 w-24" />
<Skeleton className="h-16 w-full" />
</section>

<section>
<Skeleton className="mb-4 h-6 w-48" />
<Skeleton className="h-48 w-full" />
</section>
</div>
);
}
36 changes: 16 additions & 20 deletions apps/app-portal/src/app/(admin)/admin/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,29 @@
import React from "react";
import { headers } from "next/headers";
import ShowDecisionToggle from "@/components/admin/ShowDecisionToggle";
import DateControls from "@/components/admin/DateControls";
import FormConfigEditor from "@/components/admin/FormConfigEditor";
import { getSingleton } from "@/lib/admin/singleton-service";
import { SingletonKey } from "@/lib/types/singleton";

async function fetchJson(url: string, cookie: string) {
const res = await fetch(url, {
cache: "no-store",
headers: { cookie },
});

if (!res.ok) {
return { value: null };
}

return res.json();
}
export const dynamic = "force-dynamic";

export default async function Page() {
const cookie = headers().get("cookie") ?? "";

const [openData, closeData, confirmData, showDecisionData] =
// Read singletons directly (same pattern as admin/stats and admin/applicants) instead of
// self-fetching our own API routes over HTTP — that previously relied on a hardcoded
// http://localhost:3000 origin, which breaks in every deployed environment.
const [openValue, closeValue, confirmValue, showDecisionValue] =
await Promise.all([
fetchJson("http://localhost:3000/api/v1/dates/registration-open", cookie),
fetchJson("http://localhost:3000/api/v1/dates/registration-closed", cookie),
fetchJson("http://localhost:3000/api/v1/dates/confirm-by", cookie),
fetchJson("http://localhost:3000/api/v1/show-decision", cookie),
getSingleton(SingletonKey.RegistrationOpen),
getSingleton(SingletonKey.RegistrationClosed),
getSingleton(SingletonKey.ConfirmBy),
getSingleton(SingletonKey.ShowDecision),
]);

const openData = { value: openValue ?? undefined };
const closeData = { value: closeValue ?? undefined };
const confirmData = { value: confirmValue ?? undefined };
const showDecisionData = { value: showDecisionValue ?? false };

return (
<div>
<h1 className="text-3xl font-bold">Configure Portal Settings</h1>
Expand Down
16 changes: 16 additions & 0 deletions apps/app-portal/src/app/(applicant)/application/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from "react";

export default function Loading(): JSX.Element {
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<div className="mb-10 space-y-3">
<div className="h-8 w-2/3 animate-pulse rounded-md bg-heather/20" />
<div className="h-5 w-full animate-pulse rounded-md bg-heather/20" />
</div>
<div className="space-y-4">
<div className="h-8 animate-pulse rounded-md bg-heather/20" />
<div className="h-64 animate-pulse rounded-lg bg-heather/20" />
</div>
</div>
);
}
6 changes: 5 additions & 1 deletion apps/app-portal/src/app/(applicant)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ export default async function DashboardPage(): Promise<JSX.Element> {
showDecision: new Date().toISOString(),
confirmBy: new Date().toISOString(),
};
let completionPercent = 0;

try {
const res = await fetchPortalStatus();
branch = res.branch;
status = res.status;
decisionDates = res.decisionDates;
completionPercent = res.completionPercent;
} catch (err) {
// If fetch fails, render a simple error view instead of crashing the page.
return (
Expand Down Expand Up @@ -53,7 +55,9 @@ export default async function DashboardPage(): Promise<JSX.Element> {
case "pre-registration":
return <PreRegistrationView decisionDates={resolvedDates} />;
case "in-progress":
return <InProgressView status={status} />;
return (
<InProgressView status={status} completionPercent={completionPercent} />
);
case "submitted":
return <SubmittedView decisionDates={resolvedDates} status={status} />;
case "admitted":
Expand Down
10 changes: 8 additions & 2 deletions apps/app-portal/src/app/(landing)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { redirect } from "next/navigation";
import icon from "@/app/icon.ico";
import TiledBackground from "@/components/ui/tiled-background";
import { getSession } from "@/lib/auth/session";

export default async function Page(): Promise<JSX.Element> {
const session = await getSession();
if (session?.user) {
redirect("/dashboard");
}

//TODO: update to redirect authed users to /dashboard
export default function Page(): JSX.Element {
return (
<div className="relative w-[100vw] h-[100vh] overflow-hidden">
<TiledBackground />
Expand Down
39 changes: 32 additions & 7 deletions apps/app-portal/src/app/api/joinMailingList/route.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,56 @@
import { NextResponse, NextRequest } from "next/server";
import { z } from "zod";

const PUBLICATION = "pub_e065c094-6f4b-4e8d-91d2-e39de7201fd4";

const joinMailingListSchema = z.object({
email: z.string().email(),
reactivate_existing: z.boolean().optional(),
});

export async function POST(req: NextRequest) {
const body = await req.json();
const airtableUrl = `https://api.beehiiv.com/v2/publications/${PUBLICATION}/subscriptions`;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const parsed = joinMailingListSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "A valid email is required" },
{ status: 400 },
);
}

const beehiivUrl = `https://api.beehiiv.com/v2/publications/${PUBLICATION}/subscriptions`;
try {
const response = await fetch(`${airtableUrl}`, {
const response = await fetch(beehiivUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BEEHIIV_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
body: JSON.stringify(parsed.data),
});

if (!response.ok) {
throw new Error("API request failed");
throw new Error(
`Beehiiv API request failed with status ${response.status}`,
);
}

return NextResponse.json({
success: "Successfully subscribed to mailing list",
});
} catch (err) {
// Log the real error server-side, but don't leak internal details to the client.
// eslint-disable-next-line no-console -- intentional server-side error log
console.error("joinMailingList: Beehiiv request failed:", err);
return NextResponse.json(
{ error: `Request to post email to beehiiv failed ${err}` },
{ status: 500 },
{ error: "Could not subscribe to the mailing list. Please try again." },
{ status: 502 },
);
}
}
27 changes: 9 additions & 18 deletions apps/app-portal/src/app/api/v1/admin/form-config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,16 @@ import {
updateFormConfig,
} from "@/lib/admin/form-config-service";

// type QuestionType = "text" | "textarea";

// type Question = {
// id: string;
// label: string;
// type: QuestionType;
// };

// type Section = {
// id: string;
// title: string;
// questions: Question[];
// };

// // type FormConfig = {
// // sections: Section[];
// // };

export async function GET() {
try {
await requireAdmin();
} catch (error) {
if (error instanceof Error && error.message === "Forbidden") {
return NextResponse.json({ error: error.message }, { status: 403 });
}
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const config = await getFormConfig();

return NextResponse.json(config);
Expand Down
4 changes: 4 additions & 0 deletions apps/app-portal/src/app/api/v1/applicants/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";

import { requireAdmin } from "@/lib/auth/guards";
import {
InvalidApplicantStateError,
InvalidApplicantUpdateError,
getApplicant,
updateApplicant,
Expand Down Expand Up @@ -55,6 +56,9 @@ export async function POST(
if (err instanceof InvalidApplicantUpdateError) {
return NextResponse.json({ error: err.message }, { status: 400 });
}
if (err instanceof InvalidApplicantStateError) {
return NextResponse.json({ error: err.message }, { status: 409 });
}
throw err;
}
}
45 changes: 4 additions & 41 deletions apps/app-portal/src/app/api/v1/dates/confirm-by/route.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,6 @@
import { NextResponse } from "next/server";
import { createDateSingletonHandlers } from "@/lib/admin/date-route-handlers";
import { SingletonKey } from "@/lib/types/singleton";
import { requireAdmin } from "@/lib/auth/guards";
import {
getSingleton,
setSingleton,
validateDateSingleton,
} from "@/lib/admin/singleton-service";

export async function GET() {
const value = await getSingleton(SingletonKey.ConfirmBy);

return NextResponse.json({
value,
});
}

export async function POST(req: Request) {
const admin = await requireAdmin();

if (!admin.email) {
return NextResponse.json(
{ error: "Admin email is required." },
{ status: 400 },
);
}

const body = await req.json();
const { value } = body;

const result = validateDateSingleton(value);

if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 400 });
}

await setSingleton(SingletonKey.ConfirmBy, result.value, admin.email);

return NextResponse.json({
ok: true,
value: result.value,
});
}
export const { GET, POST } = createDateSingletonHandlers(
SingletonKey.ConfirmBy,
);
Loading