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
79 changes: 72 additions & 7 deletions apps/app-portal/scripts/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,30 @@ const ROWS: Row[] = [
],
];

function toDoc(row: Row) {
// Maps the seed table's free-text `year` to the real `year_of_study`
// question's enum option values (src/lib/application/questions.ts).
const YEAR_OF_STUDY_MAP: Record<string, string> = {
Junior: "third",
Senior: "fourth",
Graduate: "graduate",
};

const HACKATHON_OPTIONS = ["0", "1-2", "3-5", "6+"];
const INTEREST_OPTIONS = ["web", "mobile", "ai", "hardware", "design", "other"];
const TSHIRT_OPTIONS = ["xs", "s", "m", "l", "xl"];

// A couple of entries deliberately contain a comma/quote so the CSV export's
// escaping logic has real data to exercise during manual verification.
const DIETARY_RESTRICTIONS = [
"",
"Vegetarian",
"Vegetarian, nut allergy",
`Allergic to "shellfish"`,
"Halal",
"Gluten-free",
];

function toDoc(row: Row, index: number) {
const [
email,
firstName,
Expand All @@ -360,6 +383,51 @@ function toDoc(row: Row) {
rsvpStatus,
appSubmissionTime,
] = row;

// Keyed by the real application question ids (questions.ts), not
// ad hoc names — otherwise seed data silently diverges from what the
// real form (and the CSV export/detail page built on top of it) expects.
const applicationResponses: Record<string, string | string[]> = {
legal_name: `${firstName} ${lastName}`,
email,
university: school,
year_of_study: YEAR_OF_STUDY_MAP[year] ?? "graduate",
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
interests:
index % 2 === 0
? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
: [
INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
],
why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
};
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
if (applicationStatus === "submitted" && index % 4 === 0) {
// Placeholder uploadId — no real upload pipeline exists yet (separate,
// in-flight uploads ticket); this just gives the detail page's resume
// row something to render during manual verification.
applicationResponses.resume = `seed-upload-${index}`;
}

// Only applicants who actually reached the RSVP step have post-acceptance
// data — "unconfirmed" rows leave this unset, matching reality.
const postAcceptanceResponses =
rsvpStatus === "confirmed" || rsvpStatus === "not-attending"
? {
attending: rsvpStatus === "confirmed" ? "yes" : "no",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
index % 7 === 0 ? "Arriving a day early for setup." : "",
}
: undefined;

return {
email,
applicationStatus,
Expand All @@ -368,11 +436,8 @@ function toDoc(row: Row) {
isAdmin: false,
...(appSubmissionTime ? { appSubmissionTime } : {}),
lastSavedAt: appSubmissionTime ?? DRAFT_SAVED_AT,
applicationResponses: {
name: `${firstName} ${lastName}`,
school,
yearOfEducation: year,
},
applicationResponses,
...(postAcceptanceResponses ? { postAcceptanceResponses } : {}),
};
}

Expand All @@ -390,7 +455,7 @@ async function main() {
const col = db.collection(COLLECTION);

await col.deleteMany({});
const docs = ROWS.map(toDoc);
const docs = ROWS.map((row, index) => toDoc(row, index));
await col.insertMany(docs);

console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
Expand Down
73 changes: 30 additions & 43 deletions apps/app-portal/src/app/api/v1/applicants/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import { NextRequest, NextResponse } from "next/server";

import { getApplicant, updateApplicant } from "@/lib/applicants/service";
import type { ApplicantUpdate } from "@/lib/applicants/types";
import { requireAdmin } from "@/lib/auth/guards";
import {
DECISION_STATUSES,
RSVP_STATUSES,
type DecisionStatus,
type RsvpStatus,
} from "@/lib/types/user";

function isDecisionStatus(v: unknown): v is DecisionStatus {
return DECISION_STATUSES.includes(v as DecisionStatus);
}

function isRsvpStatus(v: unknown): v is RsvpStatus {
return RSVP_STATUSES.includes(v as RsvpStatus);
}
InvalidApplicantUpdateError,
getApplicant,
updateApplicant,
} from "@/lib/applicants/service";

export async function GET(
_req: NextRequest,
{ params }: { params: { id: string } },
) {
try {
await requireAdmin();
} catch {
// Stopgap 403 mapping until the (separate, in-flight) auth ticket lands
// typed errors distinguishing unauthenticated (401) from non-admin (403).
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const applicant = await getApplicant(params.id);
if (!applicant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
Expand All @@ -32,42 +30,31 @@ export async function POST(
req: NextRequest,
{ params }: { params: { id: string } },
) {
let admin;
try {
admin = await requireAdmin();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const { decisionStatus, rsvpStatus } = (body ?? {}) as Record<
string,
unknown
>;
const update: ApplicantUpdate = {};

if (decisionStatus !== undefined) {
if (!isDecisionStatus(decisionStatus)) {
return NextResponse.json(
{ error: "Invalid decisionStatus" },
{ status: 400 },
);
const updatedBy = admin.email ?? "unknown";
try {
const updated = await updateApplicant(params.id, body, updatedBy);
if (!updated) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
update.decisionStatus = decisionStatus;
}

if (rsvpStatus !== undefined) {
if (!isRsvpStatus(rsvpStatus)) {
return NextResponse.json(
{ error: "Invalid rsvpStatus" },
{ status: 400 },
);
return NextResponse.json(updated);
} catch (err) {
if (err instanceof InvalidApplicantUpdateError) {
return NextResponse.json({ error: err.message }, { status: 400 });
}
update.rsvpStatus = rsvpStatus;
}

const updated = await updateApplicant(params.id, update);
if (!updated) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
throw err;
}
return NextResponse.json(updated);
}
10 changes: 9 additions & 1 deletion apps/app-portal/src/app/api/v1/applicants/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { NextRequest, NextResponse } from "next/server";

import { requireAdmin } from "@/lib/auth/guards";
import { parseApplicantQuery } from "@/lib/applicants/params";
import { listApplicants } from "@/lib/applicants/service";

export async function GET(req: NextRequest) {
// TODO: gate with requireAdmin() once Ticket 1 ships its helpers
try {
await requireAdmin();
} catch {
// Stopgap 403 mapping until the (separate, in-flight) auth ticket lands
// typed errors distinguishing unauthenticated (401) from non-admin (403).
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const parsed = parseApplicantQuery(new URL(req.url).searchParams);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
Expand Down
47 changes: 46 additions & 1 deletion apps/app-portal/src/app/api/v1/export/applications/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
import { NextResponse } from "next/server";

import { requireAdmin } from "@/lib/auth/guards";
import { getApplicantCursor } from "@/lib/applicants/service";
import { toCsv, responseField, type CsvColumn } from "@/lib/applicants/csv";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";
import type { ApplicantDoc } from "@/lib/applicants/types";

export const dynamic = "force-dynamic"; // per-request stream — never statically rendered or cached

const QUESTION_COLUMNS: CsvColumn<ApplicantDoc>[] =
APPLICATION_SECTIONS.flatMap((section) =>
section.questions.map((q) => ({
header: q.label,
value: (doc: ApplicantDoc) =>
responseField(doc.applicationResponses, q.id),
})),
);

const COLUMNS: CsvColumn<ApplicantDoc>[] = [
{ header: "Email", value: (d) => d.email },
{
header: "Name",
value: (d) => responseField(d.applicationResponses, "legal_name"),
},
{ header: "Status", value: (d) => d.applicationStatus },
{ header: "Decision", value: (d) => d.decisionStatus ?? "" },
{ header: "RSVP", value: (d) => d.rsvpStatus },
...QUESTION_COLUMNS,
];

export async function GET() {
return new NextResponse("Not implemented", { status: 501 });
try {
await requireAdmin();
} catch {
// Stopgap 403 mapping until the (separate, in-flight) auth ticket lands
// typed errors distinguishing unauthenticated (401) from non-admin (403).
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const cursor = await getApplicantCursor();
const stream = toCsv(cursor, COLUMNS);
const date = new Date().toISOString().slice(0, 10);
return new NextResponse(stream, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="applications-${date}.csv"`,
},
});
}
58 changes: 57 additions & 1 deletion apps/app-portal/src/app/api/v1/export/post-acceptance/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,61 @@
import { NextResponse } from "next/server";

import { requireAdmin } from "@/lib/auth/guards";
import { getApplicantCursor } from "@/lib/applicants/service";
import { toCsv, responseField, type CsvColumn } from "@/lib/applicants/csv";
import type { ApplicantDoc } from "@/lib/applicants/types";

export const dynamic = "force-dynamic"; // per-request stream — never statically rendered or cached

const COLUMNS: CsvColumn<ApplicantDoc>[] = [
{ header: "Email", value: (d) => d.email },
{
header: "Name",
value: (d) => responseField(d.applicationResponses, "legal_name"),
},
{ header: "Status", value: (d) => d.applicationStatus },
{ header: "Decision", value: (d) => d.decisionStatus ?? "" },
{ header: "RSVP", value: (d) => d.rsvpStatus },
{
header: "Attending",
value: (d) => responseField(d.postAcceptanceResponses, "attending"),
},
{
header: "Dietary Restrictions",
value: (d) =>
responseField(d.postAcceptanceResponses, "dietaryRestrictions"),
},
{
header: "T-Shirt Size",
value: (d) => responseField(d.postAcceptanceResponses, "tshirtSize"),
},
{
header: "Accessibility Needs",
value: (d) =>
responseField(d.postAcceptanceResponses, "accessibilityNeeds"),
},
{
header: "Additional Notes",
value: (d) => responseField(d.postAcceptanceResponses, "additionalNotes"),
},
];

export async function GET() {
return new NextResponse("Not implemented", { status: 501 });
try {
await requireAdmin();
} catch {
// Stopgap 403 mapping until the (separate, in-flight) auth ticket lands
// typed errors distinguishing unauthenticated (401) from non-admin (403).
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

const cursor = await getApplicantCursor();
const stream = toCsv(cursor, COLUMNS);
const date = new Date().toISOString().slice(0, 10);
return new NextResponse(stream, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="post-acceptance-${date}.csv"`,
},
});
}
Loading