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 numberDiff line numberDiff 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/auth

# --- 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=
198 changes: 174 additions & 24 deletions apps/app-portal/scripts/seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
*
* Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root):
* yarn seed
* yarn seed --dry-run — validate and print, write nothing, connect to nothing
*
* Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via
* `node --env-file=.env`) — this always points at the shared Atlas cluster.
Expand All@@ -19,6 +20,7 @@
* app enums in src/lib/types/user.ts.
*/
import { getDb, resolveCollectionName } from "@/lib/db";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";

const TEST_COLLECTION_NAME = "applicant_data_test";
const COLLECTION = resolveCollectionName("applicant_data");
Expand DownExpand Up@@ -348,17 +350,87 @@ const ROWS: 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",
// Unmapped schools fall through to the question's "other" option, with the raw
// name in `school_other`.
const SCHOOL_MAP: Record<string, string> = {
"Northeastern University": "northeastern_university",
MIT: "mit",
Harvard: "harvard_university",
"Boston University": "boston_university",
};

// The seed table's free-text `year` spans two real questions.
const EDUCATION_MAP: Record<string, { level: string; year: string }> = {
Junior: { level: "undergraduate", year: "3rd_year" },
Senior: { level: "undergraduate", year: "4th_year" },
Graduate: { level: "graduate", year: "1st_year" },
};

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"];
const CS_CLASS_OPTIONS = ["0", "1-2", "3-5", "6+"];
const WORKSHOP_OPTIONS = [
"mobile",
"web",
"design",
"backend",
"frontend",
"data_science",
"cybersecurity",
"ai_ml",
"product_management",
"entrepreneurship",
];
const IDENTITIES = [
{ pronouns: "she/her", gender: "female" },
{ pronouns: "he/him", gender: "male" },
{ pronouns: "they/them", gender: "non_binary" },
{ pronouns: "she/they", gender: "genderqueer" },
{ pronouns: "he/him", gender: "prefer_not_to_say" },
{ pronouns: "they/them", gender: "unlisted" },
];
const RACE_OPTIONS = [
"indigenous_american_or_alaska_native",
"asian",
"black_or_african_american",
"hispanic_or_latinx",
"native_hawaiian_or_pacific_islander",
"white",
"unlisted",
"prefer_not_to_say",
];
const LGBTQ_OPTIONS = ["yes", "no", "unsure", "prefer_not_to_say"];
const REFERRAL_OPTIONS = [
"facebook",
"instagram",
"linkedin",
"twitter",
"tiktok",
"hbp_email_newsletter",
"word_of_mouth",
"hbp_outreach_events",
"school_communications",
"other_organization",
"other",
];
const HOMETOWNS = [
"Boston, MA",
"Providence, RI",
"Portland, ME",
"Hartford, CT",
"Nashua, NH",
];
const MAJORS = [
"Computer Science",
"Computer Science and Design",
"Data Science",
"Electrical Engineering",
"Mathematics",
];

// The application's own `tshirt_size` question allows 2XL; the RSVP payload schema
// (src/lib/status/rsvp.ts) stops at XL. Kept separate so both match their writer.
const TSHIRT_SIZES = ["xs", "s", "m", "l", "xl", "2xl"];
const RSVP_TSHIRT_SIZES = ["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.
Expand All@@ -384,43 +456,74 @@ function toDoc(row: Row, index: number) {
appSubmissionTime,
] = row;

const schoolValue = SCHOOL_MAP[school] ?? "other";
const education = EDUCATION_MAP[year] ?? EDUCATION_MAP.Graduate;
const identity = IDENTITIES[index % IDENTITIES.length];

// 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",
first_name: firstName,
last_name: lastName,
hometown: HOMETOWNS[index % HOMETOWNS.length],
pronouns: identity.pronouns,
gender: identity.gender,
race:
index % 3 === 0
? [RACE_OPTIONS[index % RACE_OPTIONS.length]]
: [
RACE_OPTIONS[index % RACE_OPTIONS.length],
RACE_OPTIONS[(index + 3) % RACE_OPTIONS.length],
],
lgbtq: LGBTQ_OPTIONS[index % LGBTQ_OPTIONS.length],
school: schoolValue,
education_level: education.level,
education_year: education.year,
major: MAJORS[index % MAJORS.length],
tshirt_size: TSHIRT_SIZES[index % TSHIRT_SIZES.length],
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
interests:
cs_classes: CS_CLASS_OPTIONS[(index + 1) % CS_CLASS_OPTIONS.length],
workshop_interests:
index % 2 === 0
? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
? [WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length]]
: [
INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length],
WORKSHOP_OPTIONS[(index + 2) % WORKSHOP_OPTIONS.length],
],
why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
goals_long_answer: `${firstName} wants to ship a project end to end and find people to keep building with afterwards.`,
passion_long_answer: `${firstName} could talk for hours about why good developer tooling changes what teams are willing to attempt.`,
hackathon_reflection: `${firstName} has been to a few hackathons and wants more time for workshops and less time fighting deploys.`,
premade_team: "no",
referral_source: [REFERRAL_OPTIONS[index % REFERRAL_OPTIONS.length]],
};
if (schoolValue === "other") {
applicationResponses.school_other = school;
}
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
if (index % 4 === 0) {
applicationResponses.premade_team = "yes";
applicationResponses.team_captain_info = `${firstName} ${lastName}, ${email}`;
}
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.
// Placeholder ids with no matching row in the uploads collection.
applicationResponses.resume = `seed-upload-${index}`;
applicationResponses.vaccination_card = `seed-vax-${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",
// saveRsvp writes the parsed payload verbatim, so `attending` holds the
// rsvpSchema enum value ("confirmed"/"unconfirmed"), not a yes/no string.
attending: rsvpStatus === "confirmed" ? "confirmed" : "unconfirmed",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
tshirtSize: RSVP_TSHIRT_SIZES[index % RSVP_TSHIRT_SIZES.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
Expand All@@ -441,8 +544,38 @@ function toDoc(row: Row, index: number) {
};
}

function validate(docs: ReturnType<typeof toDoc>[]): string[] {
const questions = new Map(
APPLICATION_SECTIONS.flatMap((section) =>
section.questions.map((q) => [q.id, q] as const),
),
);
const errors: string[] = [];

for (const doc of docs) {
for (const [id, value] of Object.entries(doc.applicationResponses)) {
const question = questions.get(id);
if (!question) {
errors.push(`${doc.email}: no question with id "${id}"`);
continue;
}
if (!question.options) continue;
const allowed = new Set(question.options.map((o) => o.value));
for (const v of Array.isArray(value) ? value : [value]) {
if (!allowed.has(v)) {
errors.push(`${doc.email}: "${v}" is not an option of "${id}"`);
}
}
}
}

return errors;
}

async function main() {
if (COLLECTION !== TEST_COLLECTION_NAME) {
const dryRun = process.argv.includes("--dry-run");

if (!dryRun && COLLECTION !== TEST_COLLECTION_NAME) {
console.error(
`Refusing to seed: resolved collection is "${COLLECTION}", not ` +
`"${TEST_COLLECTION_NAME}". This script is destructive and only ` +
Expand All@@ -451,11 +584,28 @@ async function main() {
process.exit(1);
}

const docs = ROWS.map((row, index) => toDoc(row, index));

const errors = validate(docs);
if (errors.length > 0) {
console.error("Seed data does not match the questions in questions.ts:");
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}

if (dryRun) {
console.log(
`Dry run: ${docs.length} applicants validated against ` +
`${APPLICATION_SECTIONS.length} sections. Target would be "${COLLECTION}".`,
);
console.log(JSON.stringify(docs[0], null, 2));
process.exit(0);
}

const db = await getDb();
const col = db.collection(COLLECTION);

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

console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
Expand Down
6 changes: 4 additions & 2 deletions apps/app-portal/scripts/setup-indexes.ts
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@ export default async function ApplicantDetailPage({
<RsvpEditor
applicantId={applicant.id}
value={applicant.rsvpStatus}
decisionStatus={applicant.decisionStatus}
/>
</CardContent>
</Card>
Expand Down
28 changes: 28 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/loading.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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>
);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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/auth

# --- 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=
198 changes: 174 additions & 24 deletions apps/app-portal/scripts/seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
*
* Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root):
* yarn seed
* yarn seed --dry-run — validate and print, write nothing, connect to nothing
*
* Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via
* `node --env-file=.env`) — this always points at the shared Atlas cluster.
Expand All@@ -19,6 +20,7 @@
* app enums in src/lib/types/user.ts.
*/
import { getDb, resolveCollectionName } from "@/lib/db";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";

const TEST_COLLECTION_NAME = "applicant_data_test";
const COLLECTION = resolveCollectionName("applicant_data");
Expand DownExpand Up@@ -348,17 +350,87 @@ const ROWS: 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",
// Unmapped schools fall through to the question's "other" option, with the raw
// name in `school_other`.
const SCHOOL_MAP: Record<string, string> = {
"Northeastern University": "northeastern_university",
MIT: "mit",
Harvard: "harvard_university",
"Boston University": "boston_university",
};

// The seed table's free-text `year` spans two real questions.
const EDUCATION_MAP: Record<string, { level: string; year: string }> = {
Junior: { level: "undergraduate", year: "3rd_year" },
Senior: { level: "undergraduate", year: "4th_year" },
Graduate: { level: "graduate", year: "1st_year" },
};

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"];
const CS_CLASS_OPTIONS = ["0", "1-2", "3-5", "6+"];
const WORKSHOP_OPTIONS = [
"mobile",
"web",
"design",
"backend",
"frontend",
"data_science",
"cybersecurity",
"ai_ml",
"product_management",
"entrepreneurship",
];
const IDENTITIES = [
{ pronouns: "she/her", gender: "female" },
{ pronouns: "he/him", gender: "male" },
{ pronouns: "they/them", gender: "non_binary" },
{ pronouns: "she/they", gender: "genderqueer" },
{ pronouns: "he/him", gender: "prefer_not_to_say" },
{ pronouns: "they/them", gender: "unlisted" },
];
const RACE_OPTIONS = [
"indigenous_american_or_alaska_native",
"asian",
"black_or_african_american",
"hispanic_or_latinx",
"native_hawaiian_or_pacific_islander",
"white",
"unlisted",
"prefer_not_to_say",
];
const LGBTQ_OPTIONS = ["yes", "no", "unsure", "prefer_not_to_say"];
const REFERRAL_OPTIONS = [
"facebook",
"instagram",
"linkedin",
"twitter",
"tiktok",
"hbp_email_newsletter",
"word_of_mouth",
"hbp_outreach_events",
"school_communications",
"other_organization",
"other",
];
const HOMETOWNS = [
"Boston, MA",
"Providence, RI",
"Portland, ME",
"Hartford, CT",
"Nashua, NH",
];
const MAJORS = [
"Computer Science",
"Computer Science and Design",
"Data Science",
"Electrical Engineering",
"Mathematics",
];

// The application's own `tshirt_size` question allows 2XL; the RSVP payload schema
// (src/lib/status/rsvp.ts) stops at XL. Kept separate so both match their writer.
const TSHIRT_SIZES = ["xs", "s", "m", "l", "xl", "2xl"];
const RSVP_TSHIRT_SIZES = ["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.
Expand All@@ -384,43 +456,74 @@ function toDoc(row: Row, index: number) {
appSubmissionTime,
] = row;

const schoolValue = SCHOOL_MAP[school] ?? "other";
const education = EDUCATION_MAP[year] ?? EDUCATION_MAP.Graduate;
const identity = IDENTITIES[index % IDENTITIES.length];

// 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",
first_name: firstName,
last_name: lastName,
hometown: HOMETOWNS[index % HOMETOWNS.length],
pronouns: identity.pronouns,
gender: identity.gender,
race:
index % 3 === 0
? [RACE_OPTIONS[index % RACE_OPTIONS.length]]
: [
RACE_OPTIONS[index % RACE_OPTIONS.length],
RACE_OPTIONS[(index + 3) % RACE_OPTIONS.length],
],
lgbtq: LGBTQ_OPTIONS[index % LGBTQ_OPTIONS.length],
school: schoolValue,
education_level: education.level,
education_year: education.year,
major: MAJORS[index % MAJORS.length],
tshirt_size: TSHIRT_SIZES[index % TSHIRT_SIZES.length],
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
interests:
cs_classes: CS_CLASS_OPTIONS[(index + 1) % CS_CLASS_OPTIONS.length],
workshop_interests:
index % 2 === 0
? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
? [WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length]]
: [
INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length],
WORKSHOP_OPTIONS[(index + 2) % WORKSHOP_OPTIONS.length],
],
why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
goals_long_answer: `${firstName} wants to ship a project end to end and find people to keep building with afterwards.`,
passion_long_answer: `${firstName} could talk for hours about why good developer tooling changes what teams are willing to attempt.`,
hackathon_reflection: `${firstName} has been to a few hackathons and wants more time for workshops and less time fighting deploys.`,
premade_team: "no",
referral_source: [REFERRAL_OPTIONS[index % REFERRAL_OPTIONS.length]],
};
if (schoolValue === "other") {
applicationResponses.school_other = school;
}
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
if (index % 4 === 0) {
applicationResponses.premade_team = "yes";
applicationResponses.team_captain_info = `${firstName} ${lastName}, ${email}`;
}
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.
// Placeholder ids with no matching row in the uploads collection.
applicationResponses.resume = `seed-upload-${index}`;
applicationResponses.vaccination_card = `seed-vax-${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",
// saveRsvp writes the parsed payload verbatim, so `attending` holds the
// rsvpSchema enum value ("confirmed"/"unconfirmed"), not a yes/no string.
attending: rsvpStatus === "confirmed" ? "confirmed" : "unconfirmed",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
tshirtSize: RSVP_TSHIRT_SIZES[index % RSVP_TSHIRT_SIZES.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
Expand All@@ -441,8 +544,38 @@ function toDoc(row: Row, index: number) {
};
}

function validate(docs: ReturnType<typeof toDoc>[]): string[] {
const questions = new Map(
APPLICATION_SECTIONS.flatMap((section) =>
section.questions.map((q) => [q.id, q] as const),
),
);
const errors: string[] = [];

for (const doc of docs) {
for (const [id, value] of Object.entries(doc.applicationResponses)) {
const question = questions.get(id);
if (!question) {
errors.push(`${doc.email}: no question with id "${id}"`);
continue;
}
if (!question.options) continue;
const allowed = new Set(question.options.map((o) => o.value));
for (const v of Array.isArray(value) ? value : [value]) {
if (!allowed.has(v)) {
errors.push(`${doc.email}: "${v}" is not an option of "${id}"`);
}
}
}
}

return errors;
}

async function main() {
if (COLLECTION !== TEST_COLLECTION_NAME) {
const dryRun = process.argv.includes("--dry-run");

if (!dryRun && COLLECTION !== TEST_COLLECTION_NAME) {
console.error(
`Refusing to seed: resolved collection is "${COLLECTION}", not ` +
`"${TEST_COLLECTION_NAME}". This script is destructive and only ` +
Expand All@@ -451,11 +584,28 @@ async function main() {
process.exit(1);
}

const docs = ROWS.map((row, index) => toDoc(row, index));

const errors = validate(docs);
if (errors.length > 0) {
console.error("Seed data does not match the questions in questions.ts:");
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}

if (dryRun) {
console.log(
`Dry run: ${docs.length} applicants validated against ` +
`${APPLICATION_SECTIONS.length} sections. Target would be "${COLLECTION}".`,
);
console.log(JSON.stringify(docs[0], null, 2));
process.exit(0);
}

const db = await getDb();
const col = db.collection(COLLECTION);

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

console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
Expand Down
6 changes: 4 additions & 2 deletions apps/app-portal/scripts/setup-indexes.ts
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@ export default async function ApplicantDetailPage({
<RsvpEditor
applicantId={applicant.id}
value={applicant.rsvpStatus}
decisionStatus={applicant.decisionStatus}
/>
</CardContent>
</Card>
Expand Down
28 changes: 28 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/loading.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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>
);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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/auth

# --- 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=
198 changes: 174 additions & 24 deletions apps/app-portal/scripts/seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
*
* Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root):
* yarn seed
* yarn seed --dry-run — validate and print, write nothing, connect to nothing
*
* Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via
* `node --env-file=.env`) — this always points at the shared Atlas cluster.
Expand All@@ -19,6 +20,7 @@
* app enums in src/lib/types/user.ts.
*/
import { getDb, resolveCollectionName } from "@/lib/db";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";

const TEST_COLLECTION_NAME = "applicant_data_test";
const COLLECTION = resolveCollectionName("applicant_data");
Expand DownExpand Up@@ -348,17 +350,87 @@ const ROWS: 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",
// Unmapped schools fall through to the question's "other" option, with the raw
// name in `school_other`.
const SCHOOL_MAP: Record<string, string> = {
"Northeastern University": "northeastern_university",
MIT: "mit",
Harvard: "harvard_university",
"Boston University": "boston_university",
};

// The seed table's free-text `year` spans two real questions.
const EDUCATION_MAP: Record<string, { level: string; year: string }> = {
Junior: { level: "undergraduate", year: "3rd_year" },
Senior: { level: "undergraduate", year: "4th_year" },
Graduate: { level: "graduate", year: "1st_year" },
};

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"];
const CS_CLASS_OPTIONS = ["0", "1-2", "3-5", "6+"];
const WORKSHOP_OPTIONS = [
"mobile",
"web",
"design",
"backend",
"frontend",
"data_science",
"cybersecurity",
"ai_ml",
"product_management",
"entrepreneurship",
];
const IDENTITIES = [
{ pronouns: "she/her", gender: "female" },
{ pronouns: "he/him", gender: "male" },
{ pronouns: "they/them", gender: "non_binary" },
{ pronouns: "she/they", gender: "genderqueer" },
{ pronouns: "he/him", gender: "prefer_not_to_say" },
{ pronouns: "they/them", gender: "unlisted" },
];
const RACE_OPTIONS = [
"indigenous_american_or_alaska_native",
"asian",
"black_or_african_american",
"hispanic_or_latinx",
"native_hawaiian_or_pacific_islander",
"white",
"unlisted",
"prefer_not_to_say",
];
const LGBTQ_OPTIONS = ["yes", "no", "unsure", "prefer_not_to_say"];
const REFERRAL_OPTIONS = [
"facebook",
"instagram",
"linkedin",
"twitter",
"tiktok",
"hbp_email_newsletter",
"word_of_mouth",
"hbp_outreach_events",
"school_communications",
"other_organization",
"other",
];
const HOMETOWNS = [
"Boston, MA",
"Providence, RI",
"Portland, ME",
"Hartford, CT",
"Nashua, NH",
];
const MAJORS = [
"Computer Science",
"Computer Science and Design",
"Data Science",
"Electrical Engineering",
"Mathematics",
];

// The application's own `tshirt_size` question allows 2XL; the RSVP payload schema
// (src/lib/status/rsvp.ts) stops at XL. Kept separate so both match their writer.
const TSHIRT_SIZES = ["xs", "s", "m", "l", "xl", "2xl"];
const RSVP_TSHIRT_SIZES = ["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.
Expand All@@ -384,43 +456,74 @@ function toDoc(row: Row, index: number) {
appSubmissionTime,
] = row;

const schoolValue = SCHOOL_MAP[school] ?? "other";
const education = EDUCATION_MAP[year] ?? EDUCATION_MAP.Graduate;
const identity = IDENTITIES[index % IDENTITIES.length];

// 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",
first_name: firstName,
last_name: lastName,
hometown: HOMETOWNS[index % HOMETOWNS.length],
pronouns: identity.pronouns,
gender: identity.gender,
race:
index % 3 === 0
? [RACE_OPTIONS[index % RACE_OPTIONS.length]]
: [
RACE_OPTIONS[index % RACE_OPTIONS.length],
RACE_OPTIONS[(index + 3) % RACE_OPTIONS.length],
],
lgbtq: LGBTQ_OPTIONS[index % LGBTQ_OPTIONS.length],
school: schoolValue,
education_level: education.level,
education_year: education.year,
major: MAJORS[index % MAJORS.length],
tshirt_size: TSHIRT_SIZES[index % TSHIRT_SIZES.length],
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
interests:
cs_classes: CS_CLASS_OPTIONS[(index + 1) % CS_CLASS_OPTIONS.length],
workshop_interests:
index % 2 === 0
? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
? [WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length]]
: [
INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length],
WORKSHOP_OPTIONS[(index + 2) % WORKSHOP_OPTIONS.length],
],
why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
goals_long_answer: `${firstName} wants to ship a project end to end and find people to keep building with afterwards.`,
passion_long_answer: `${firstName} could talk for hours about why good developer tooling changes what teams are willing to attempt.`,
hackathon_reflection: `${firstName} has been to a few hackathons and wants more time for workshops and less time fighting deploys.`,
premade_team: "no",
referral_source: [REFERRAL_OPTIONS[index % REFERRAL_OPTIONS.length]],
};
if (schoolValue === "other") {
applicationResponses.school_other = school;
}
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
if (index % 4 === 0) {
applicationResponses.premade_team = "yes";
applicationResponses.team_captain_info = `${firstName} ${lastName}, ${email}`;
}
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.
// Placeholder ids with no matching row in the uploads collection.
applicationResponses.resume = `seed-upload-${index}`;
applicationResponses.vaccination_card = `seed-vax-${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",
// saveRsvp writes the parsed payload verbatim, so `attending` holds the
// rsvpSchema enum value ("confirmed"/"unconfirmed"), not a yes/no string.
attending: rsvpStatus === "confirmed" ? "confirmed" : "unconfirmed",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
tshirtSize: RSVP_TSHIRT_SIZES[index % RSVP_TSHIRT_SIZES.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
Expand All@@ -441,8 +544,38 @@ function toDoc(row: Row, index: number) {
};
}

function validate(docs: ReturnType<typeof toDoc>[]): string[] {
const questions = new Map(
APPLICATION_SECTIONS.flatMap((section) =>
section.questions.map((q) => [q.id, q] as const),
),
);
const errors: string[] = [];

for (const doc of docs) {
for (const [id, value] of Object.entries(doc.applicationResponses)) {
const question = questions.get(id);
if (!question) {
errors.push(`${doc.email}: no question with id "${id}"`);
continue;
}
if (!question.options) continue;
const allowed = new Set(question.options.map((o) => o.value));
for (const v of Array.isArray(value) ? value : [value]) {
if (!allowed.has(v)) {
errors.push(`${doc.email}: "${v}" is not an option of "${id}"`);
}
}
}
}

return errors;
}

async function main() {
if (COLLECTION !== TEST_COLLECTION_NAME) {
const dryRun = process.argv.includes("--dry-run");

if (!dryRun && COLLECTION !== TEST_COLLECTION_NAME) {
console.error(
`Refusing to seed: resolved collection is "${COLLECTION}", not ` +
`"${TEST_COLLECTION_NAME}". This script is destructive and only ` +
Expand All@@ -451,11 +584,28 @@ async function main() {
process.exit(1);
}

const docs = ROWS.map((row, index) => toDoc(row, index));

const errors = validate(docs);
if (errors.length > 0) {
console.error("Seed data does not match the questions in questions.ts:");
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}

if (dryRun) {
console.log(
`Dry run: ${docs.length} applicants validated against ` +
`${APPLICATION_SECTIONS.length} sections. Target would be "${COLLECTION}".`,
);
console.log(JSON.stringify(docs[0], null, 2));
process.exit(0);
}

const db = await getDb();
const col = db.collection(COLLECTION);

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

console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
Expand Down
6 changes: 4 additions & 2 deletions apps/app-portal/scripts/setup-indexes.ts
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@ export default async function ApplicantDetailPage({
<RsvpEditor
applicantId={applicant.id}
value={applicant.rsvpStatus}
decisionStatus={applicant.decisionStatus}
/>
</CardContent>
</Card>
Expand Down
28 changes: 28 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/loading.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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>
);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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/auth

# --- 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=
198 changes: 174 additions & 24 deletions apps/app-portal/scripts/seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
*
* Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root):
* yarn seed
* yarn seed --dry-run — validate and print, write nothing, connect to nothing
*
* Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via
* `node --env-file=.env`) — this always points at the shared Atlas cluster.
Expand All@@ -19,6 +20,7 @@
* app enums in src/lib/types/user.ts.
*/
import { getDb, resolveCollectionName } from "@/lib/db";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";

const TEST_COLLECTION_NAME = "applicant_data_test";
const COLLECTION = resolveCollectionName("applicant_data");
Expand DownExpand Up@@ -348,17 +350,87 @@ const ROWS: 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",
// Unmapped schools fall through to the question's "other" option, with the raw
// name in `school_other`.
const SCHOOL_MAP: Record<string, string> = {
"Northeastern University": "northeastern_university",
MIT: "mit",
Harvard: "harvard_university",
"Boston University": "boston_university",
};

// The seed table's free-text `year` spans two real questions.
const EDUCATION_MAP: Record<string, { level: string; year: string }> = {
Junior: { level: "undergraduate", year: "3rd_year" },
Senior: { level: "undergraduate", year: "4th_year" },
Graduate: { level: "graduate", year: "1st_year" },
};

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"];
const CS_CLASS_OPTIONS = ["0", "1-2", "3-5", "6+"];
const WORKSHOP_OPTIONS = [
"mobile",
"web",
"design",
"backend",
"frontend",
"data_science",
"cybersecurity",
"ai_ml",
"product_management",
"entrepreneurship",
];
const IDENTITIES = [
{ pronouns: "she/her", gender: "female" },
{ pronouns: "he/him", gender: "male" },
{ pronouns: "they/them", gender: "non_binary" },
{ pronouns: "she/they", gender: "genderqueer" },
{ pronouns: "he/him", gender: "prefer_not_to_say" },
{ pronouns: "they/them", gender: "unlisted" },
];
const RACE_OPTIONS = [
"indigenous_american_or_alaska_native",
"asian",
"black_or_african_american",
"hispanic_or_latinx",
"native_hawaiian_or_pacific_islander",
"white",
"unlisted",
"prefer_not_to_say",
];
const LGBTQ_OPTIONS = ["yes", "no", "unsure", "prefer_not_to_say"];
const REFERRAL_OPTIONS = [
"facebook",
"instagram",
"linkedin",
"twitter",
"tiktok",
"hbp_email_newsletter",
"word_of_mouth",
"hbp_outreach_events",
"school_communications",
"other_organization",
"other",
];
const HOMETOWNS = [
"Boston, MA",
"Providence, RI",
"Portland, ME",
"Hartford, CT",
"Nashua, NH",
];
const MAJORS = [
"Computer Science",
"Computer Science and Design",
"Data Science",
"Electrical Engineering",
"Mathematics",
];

// The application's own `tshirt_size` question allows 2XL; the RSVP payload schema
// (src/lib/status/rsvp.ts) stops at XL. Kept separate so both match their writer.
const TSHIRT_SIZES = ["xs", "s", "m", "l", "xl", "2xl"];
const RSVP_TSHIRT_SIZES = ["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.
Expand All@@ -384,43 +456,74 @@ function toDoc(row: Row, index: number) {
appSubmissionTime,
] = row;

const schoolValue = SCHOOL_MAP[school] ?? "other";
const education = EDUCATION_MAP[year] ?? EDUCATION_MAP.Graduate;
const identity = IDENTITIES[index % IDENTITIES.length];

// 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",
first_name: firstName,
last_name: lastName,
hometown: HOMETOWNS[index % HOMETOWNS.length],
pronouns: identity.pronouns,
gender: identity.gender,
race:
index % 3 === 0
? [RACE_OPTIONS[index % RACE_OPTIONS.length]]
: [
RACE_OPTIONS[index % RACE_OPTIONS.length],
RACE_OPTIONS[(index + 3) % RACE_OPTIONS.length],
],
lgbtq: LGBTQ_OPTIONS[index % LGBTQ_OPTIONS.length],
school: schoolValue,
education_level: education.level,
education_year: education.year,
major: MAJORS[index % MAJORS.length],
tshirt_size: TSHIRT_SIZES[index % TSHIRT_SIZES.length],
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
interests:
cs_classes: CS_CLASS_OPTIONS[(index + 1) % CS_CLASS_OPTIONS.length],
workshop_interests:
index % 2 === 0
? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
? [WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length]]
: [
INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length],
WORKSHOP_OPTIONS[(index + 2) % WORKSHOP_OPTIONS.length],
],
why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
goals_long_answer: `${firstName} wants to ship a project end to end and find people to keep building with afterwards.`,
passion_long_answer: `${firstName} could talk for hours about why good developer tooling changes what teams are willing to attempt.`,
hackathon_reflection: `${firstName} has been to a few hackathons and wants more time for workshops and less time fighting deploys.`,
premade_team: "no",
referral_source: [REFERRAL_OPTIONS[index % REFERRAL_OPTIONS.length]],
};
if (schoolValue === "other") {
applicationResponses.school_other = school;
}
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
if (index % 4 === 0) {
applicationResponses.premade_team = "yes";
applicationResponses.team_captain_info = `${firstName} ${lastName}, ${email}`;
}
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.
// Placeholder ids with no matching row in the uploads collection.
applicationResponses.resume = `seed-upload-${index}`;
applicationResponses.vaccination_card = `seed-vax-${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",
// saveRsvp writes the parsed payload verbatim, so `attending` holds the
// rsvpSchema enum value ("confirmed"/"unconfirmed"), not a yes/no string.
attending: rsvpStatus === "confirmed" ? "confirmed" : "unconfirmed",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
tshirtSize: RSVP_TSHIRT_SIZES[index % RSVP_TSHIRT_SIZES.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
Expand All@@ -441,8 +544,38 @@ function toDoc(row: Row, index: number) {
};
}

function validate(docs: ReturnType<typeof toDoc>[]): string[] {
const questions = new Map(
APPLICATION_SECTIONS.flatMap((section) =>
section.questions.map((q) => [q.id, q] as const),
),
);
const errors: string[] = [];

for (const doc of docs) {
for (const [id, value] of Object.entries(doc.applicationResponses)) {
const question = questions.get(id);
if (!question) {
errors.push(`${doc.email}: no question with id "${id}"`);
continue;
}
if (!question.options) continue;
const allowed = new Set(question.options.map((o) => o.value));
for (const v of Array.isArray(value) ? value : [value]) {
if (!allowed.has(v)) {
errors.push(`${doc.email}: "${v}" is not an option of "${id}"`);
}
}
}
}

return errors;
}

async function main() {
if (COLLECTION !== TEST_COLLECTION_NAME) {
const dryRun = process.argv.includes("--dry-run");

if (!dryRun && COLLECTION !== TEST_COLLECTION_NAME) {
console.error(
`Refusing to seed: resolved collection is "${COLLECTION}", not ` +
`"${TEST_COLLECTION_NAME}". This script is destructive and only ` +
Expand All@@ -451,11 +584,28 @@ async function main() {
process.exit(1);
}

const docs = ROWS.map((row, index) => toDoc(row, index));

const errors = validate(docs);
if (errors.length > 0) {
console.error("Seed data does not match the questions in questions.ts:");
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}

if (dryRun) {
console.log(
`Dry run: ${docs.length} applicants validated against ` +
`${APPLICATION_SECTIONS.length} sections. Target would be "${COLLECTION}".`,
);
console.log(JSON.stringify(docs[0], null, 2));
process.exit(0);
}

const db = await getDb();
const col = db.collection(COLLECTION);

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

console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
Expand Down
6 changes: 4 additions & 2 deletions apps/app-portal/scripts/setup-indexes.ts
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@ export default async function ApplicantDetailPage({
<RsvpEditor
applicantId={applicant.id}
value={applicant.rsvpStatus}
decisionStatus={applicant.decisionStatus}
/>
</CardContent>
</Card>
Expand Down
28 changes: 28 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/loading.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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>
);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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/auth

# --- 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=
198 changes: 174 additions & 24 deletions apps/app-portal/scripts/seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
*
* Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root):
* yarn seed
* yarn seed --dry-run — validate and print, write nothing, connect to nothing
*
* Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via
* `node --env-file=.env`) — this always points at the shared Atlas cluster.
Expand All@@ -19,6 +20,7 @@
* app enums in src/lib/types/user.ts.
*/
import { getDb, resolveCollectionName } from "@/lib/db";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";

const TEST_COLLECTION_NAME = "applicant_data_test";
const COLLECTION = resolveCollectionName("applicant_data");
Expand DownExpand Up@@ -348,17 +350,87 @@ const ROWS: 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",
// Unmapped schools fall through to the question's "other" option, with the raw
// name in `school_other`.
const SCHOOL_MAP: Record<string, string> = {
"Northeastern University": "northeastern_university",
MIT: "mit",
Harvard: "harvard_university",
"Boston University": "boston_university",
};

// The seed table's free-text `year` spans two real questions.
const EDUCATION_MAP: Record<string, { level: string; year: string }> = {
Junior: { level: "undergraduate", year: "3rd_year" },
Senior: { level: "undergraduate", year: "4th_year" },
Graduate: { level: "graduate", year: "1st_year" },
};

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"];
const CS_CLASS_OPTIONS = ["0", "1-2", "3-5", "6+"];
const WORKSHOP_OPTIONS = [
"mobile",
"web",
"design",
"backend",
"frontend",
"data_science",
"cybersecurity",
"ai_ml",
"product_management",
"entrepreneurship",
];
const IDENTITIES = [
{ pronouns: "she/her", gender: "female" },
{ pronouns: "he/him", gender: "male" },
{ pronouns: "they/them", gender: "non_binary" },
{ pronouns: "she/they", gender: "genderqueer" },
{ pronouns: "he/him", gender: "prefer_not_to_say" },
{ pronouns: "they/them", gender: "unlisted" },
];
const RACE_OPTIONS = [
"indigenous_american_or_alaska_native",
"asian",
"black_or_african_american",
"hispanic_or_latinx",
"native_hawaiian_or_pacific_islander",
"white",
"unlisted",
"prefer_not_to_say",
];
const LGBTQ_OPTIONS = ["yes", "no", "unsure", "prefer_not_to_say"];
const REFERRAL_OPTIONS = [
"facebook",
"instagram",
"linkedin",
"twitter",
"tiktok",
"hbp_email_newsletter",
"word_of_mouth",
"hbp_outreach_events",
"school_communications",
"other_organization",
"other",
];
const HOMETOWNS = [
"Boston, MA",
"Providence, RI",
"Portland, ME",
"Hartford, CT",
"Nashua, NH",
];
const MAJORS = [
"Computer Science",
"Computer Science and Design",
"Data Science",
"Electrical Engineering",
"Mathematics",
];

// The application's own `tshirt_size` question allows 2XL; the RSVP payload schema
// (src/lib/status/rsvp.ts) stops at XL. Kept separate so both match their writer.
const TSHIRT_SIZES = ["xs", "s", "m", "l", "xl", "2xl"];
const RSVP_TSHIRT_SIZES = ["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.
Expand All@@ -384,43 +456,74 @@ function toDoc(row: Row, index: number) {
appSubmissionTime,
] = row;

const schoolValue = SCHOOL_MAP[school] ?? "other";
const education = EDUCATION_MAP[year] ?? EDUCATION_MAP.Graduate;
const identity = IDENTITIES[index % IDENTITIES.length];

// 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",
first_name: firstName,
last_name: lastName,
hometown: HOMETOWNS[index % HOMETOWNS.length],
pronouns: identity.pronouns,
gender: identity.gender,
race:
index % 3 === 0
? [RACE_OPTIONS[index % RACE_OPTIONS.length]]
: [
RACE_OPTIONS[index % RACE_OPTIONS.length],
RACE_OPTIONS[(index + 3) % RACE_OPTIONS.length],
],
lgbtq: LGBTQ_OPTIONS[index % LGBTQ_OPTIONS.length],
school: schoolValue,
education_level: education.level,
education_year: education.year,
major: MAJORS[index % MAJORS.length],
tshirt_size: TSHIRT_SIZES[index % TSHIRT_SIZES.length],
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
interests:
cs_classes: CS_CLASS_OPTIONS[(index + 1) % CS_CLASS_OPTIONS.length],
workshop_interests:
index % 2 === 0
? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
? [WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length]]
: [
INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length],
WORKSHOP_OPTIONS[(index + 2) % WORKSHOP_OPTIONS.length],
],
why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
goals_long_answer: `${firstName} wants to ship a project end to end and find people to keep building with afterwards.`,
passion_long_answer: `${firstName} could talk for hours about why good developer tooling changes what teams are willing to attempt.`,
hackathon_reflection: `${firstName} has been to a few hackathons and wants more time for workshops and less time fighting deploys.`,
premade_team: "no",
referral_source: [REFERRAL_OPTIONS[index % REFERRAL_OPTIONS.length]],
};
if (schoolValue === "other") {
applicationResponses.school_other = school;
}
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
if (index % 4 === 0) {
applicationResponses.premade_team = "yes";
applicationResponses.team_captain_info = `${firstName} ${lastName}, ${email}`;
}
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.
// Placeholder ids with no matching row in the uploads collection.
applicationResponses.resume = `seed-upload-${index}`;
applicationResponses.vaccination_card = `seed-vax-${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",
// saveRsvp writes the parsed payload verbatim, so `attending` holds the
// rsvpSchema enum value ("confirmed"/"unconfirmed"), not a yes/no string.
attending: rsvpStatus === "confirmed" ? "confirmed" : "unconfirmed",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
tshirtSize: RSVP_TSHIRT_SIZES[index % RSVP_TSHIRT_SIZES.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
Expand All@@ -441,8 +544,38 @@ function toDoc(row: Row, index: number) {
};
}

function validate(docs: ReturnType<typeof toDoc>[]): string[] {
const questions = new Map(
APPLICATION_SECTIONS.flatMap((section) =>
section.questions.map((q) => [q.id, q] as const),
),
);
const errors: string[] = [];

for (const doc of docs) {
for (const [id, value] of Object.entries(doc.applicationResponses)) {
const question = questions.get(id);
if (!question) {
errors.push(`${doc.email}: no question with id "${id}"`);
continue;
}
if (!question.options) continue;
const allowed = new Set(question.options.map((o) => o.value));
for (const v of Array.isArray(value) ? value : [value]) {
if (!allowed.has(v)) {
errors.push(`${doc.email}: "${v}" is not an option of "${id}"`);
}
}
}
}

return errors;
}

async function main() {
if (COLLECTION !== TEST_COLLECTION_NAME) {
const dryRun = process.argv.includes("--dry-run");

if (!dryRun && COLLECTION !== TEST_COLLECTION_NAME) {
console.error(
`Refusing to seed: resolved collection is "${COLLECTION}", not ` +
`"${TEST_COLLECTION_NAME}". This script is destructive and only ` +
Expand All@@ -451,11 +584,28 @@ async function main() {
process.exit(1);
}

const docs = ROWS.map((row, index) => toDoc(row, index));

const errors = validate(docs);
if (errors.length > 0) {
console.error("Seed data does not match the questions in questions.ts:");
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}

if (dryRun) {
console.log(
`Dry run: ${docs.length} applicants validated against ` +
`${APPLICATION_SECTIONS.length} sections. Target would be "${COLLECTION}".`,
);
console.log(JSON.stringify(docs[0], null, 2));
process.exit(0);
}

const db = await getDb();
const col = db.collection(COLLECTION);

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

console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
Expand Down
6 changes: 4 additions & 2 deletions apps/app-portal/scripts/setup-indexes.ts
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@ export default async function ApplicantDetailPage({
<RsvpEditor
applicantId={applicant.id}
value={applicant.rsvpStatus}
decisionStatus={applicant.decisionStatus}
/>
</CardContent>
</Card>
Expand Down
28 changes: 28 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/loading.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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>
);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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/auth

# --- 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=
198 changes: 174 additions & 24 deletions apps/app-portal/scripts/seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
*
* Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root):
* yarn seed
* yarn seed --dry-run — validate and print, write nothing, connect to nothing
*
* Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via
* `node --env-file=.env`) — this always points at the shared Atlas cluster.
Expand All@@ -19,6 +20,7 @@
* app enums in src/lib/types/user.ts.
*/
import { getDb, resolveCollectionName } from "@/lib/db";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";

const TEST_COLLECTION_NAME = "applicant_data_test";
const COLLECTION = resolveCollectionName("applicant_data");
Expand DownExpand Up@@ -348,17 +350,87 @@ const ROWS: 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",
// Unmapped schools fall through to the question's "other" option, with the raw
// name in `school_other`.
const SCHOOL_MAP: Record<string, string> = {
"Northeastern University": "northeastern_university",
MIT: "mit",
Harvard: "harvard_university",
"Boston University": "boston_university",
};

// The seed table's free-text `year` spans two real questions.
const EDUCATION_MAP: Record<string, { level: string; year: string }> = {
Junior: { level: "undergraduate", year: "3rd_year" },
Senior: { level: "undergraduate", year: "4th_year" },
Graduate: { level: "graduate", year: "1st_year" },
};

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"];
const CS_CLASS_OPTIONS = ["0", "1-2", "3-5", "6+"];
const WORKSHOP_OPTIONS = [
"mobile",
"web",
"design",
"backend",
"frontend",
"data_science",
"cybersecurity",
"ai_ml",
"product_management",
"entrepreneurship",
];
const IDENTITIES = [
{ pronouns: "she/her", gender: "female" },
{ pronouns: "he/him", gender: "male" },
{ pronouns: "they/them", gender: "non_binary" },
{ pronouns: "she/they", gender: "genderqueer" },
{ pronouns: "he/him", gender: "prefer_not_to_say" },
{ pronouns: "they/them", gender: "unlisted" },
];
const RACE_OPTIONS = [
"indigenous_american_or_alaska_native",
"asian",
"black_or_african_american",
"hispanic_or_latinx",
"native_hawaiian_or_pacific_islander",
"white",
"unlisted",
"prefer_not_to_say",
];
const LGBTQ_OPTIONS = ["yes", "no", "unsure", "prefer_not_to_say"];
const REFERRAL_OPTIONS = [
"facebook",
"instagram",
"linkedin",
"twitter",
"tiktok",
"hbp_email_newsletter",
"word_of_mouth",
"hbp_outreach_events",
"school_communications",
"other_organization",
"other",
];
const HOMETOWNS = [
"Boston, MA",
"Providence, RI",
"Portland, ME",
"Hartford, CT",
"Nashua, NH",
];
const MAJORS = [
"Computer Science",
"Computer Science and Design",
"Data Science",
"Electrical Engineering",
"Mathematics",
];

// The application's own `tshirt_size` question allows 2XL; the RSVP payload schema
// (src/lib/status/rsvp.ts) stops at XL. Kept separate so both match their writer.
const TSHIRT_SIZES = ["xs", "s", "m", "l", "xl", "2xl"];
const RSVP_TSHIRT_SIZES = ["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.
Expand All@@ -384,43 +456,74 @@ function toDoc(row: Row, index: number) {
appSubmissionTime,
] = row;

const schoolValue = SCHOOL_MAP[school] ?? "other";
const education = EDUCATION_MAP[year] ?? EDUCATION_MAP.Graduate;
const identity = IDENTITIES[index % IDENTITIES.length];

// 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",
first_name: firstName,
last_name: lastName,
hometown: HOMETOWNS[index % HOMETOWNS.length],
pronouns: identity.pronouns,
gender: identity.gender,
race:
index % 3 === 0
? [RACE_OPTIONS[index % RACE_OPTIONS.length]]
: [
RACE_OPTIONS[index % RACE_OPTIONS.length],
RACE_OPTIONS[(index + 3) % RACE_OPTIONS.length],
],
lgbtq: LGBTQ_OPTIONS[index % LGBTQ_OPTIONS.length],
school: schoolValue,
education_level: education.level,
education_year: education.year,
major: MAJORS[index % MAJORS.length],
tshirt_size: TSHIRT_SIZES[index % TSHIRT_SIZES.length],
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
interests:
cs_classes: CS_CLASS_OPTIONS[(index + 1) % CS_CLASS_OPTIONS.length],
workshop_interests:
index % 2 === 0
? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
? [WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length]]
: [
INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length],
WORKSHOP_OPTIONS[(index + 2) % WORKSHOP_OPTIONS.length],
],
why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
goals_long_answer: `${firstName} wants to ship a project end to end and find people to keep building with afterwards.`,
passion_long_answer: `${firstName} could talk for hours about why good developer tooling changes what teams are willing to attempt.`,
hackathon_reflection: `${firstName} has been to a few hackathons and wants more time for workshops and less time fighting deploys.`,
premade_team: "no",
referral_source: [REFERRAL_OPTIONS[index % REFERRAL_OPTIONS.length]],
};
if (schoolValue === "other") {
applicationResponses.school_other = school;
}
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
if (index % 4 === 0) {
applicationResponses.premade_team = "yes";
applicationResponses.team_captain_info = `${firstName} ${lastName}, ${email}`;
}
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.
// Placeholder ids with no matching row in the uploads collection.
applicationResponses.resume = `seed-upload-${index}`;
applicationResponses.vaccination_card = `seed-vax-${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",
// saveRsvp writes the parsed payload verbatim, so `attending` holds the
// rsvpSchema enum value ("confirmed"/"unconfirmed"), not a yes/no string.
attending: rsvpStatus === "confirmed" ? "confirmed" : "unconfirmed",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
tshirtSize: RSVP_TSHIRT_SIZES[index % RSVP_TSHIRT_SIZES.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
Expand All@@ -441,8 +544,38 @@ function toDoc(row: Row, index: number) {
};
}

function validate(docs: ReturnType<typeof toDoc>[]): string[] {
const questions = new Map(
APPLICATION_SECTIONS.flatMap((section) =>
section.questions.map((q) => [q.id, q] as const),
),
);
const errors: string[] = [];

for (const doc of docs) {
for (const [id, value] of Object.entries(doc.applicationResponses)) {
const question = questions.get(id);
if (!question) {
errors.push(`${doc.email}: no question with id "${id}"`);
continue;
}
if (!question.options) continue;
const allowed = new Set(question.options.map((o) => o.value));
for (const v of Array.isArray(value) ? value : [value]) {
if (!allowed.has(v)) {
errors.push(`${doc.email}: "${v}" is not an option of "${id}"`);
}
}
}
}

return errors;
}

async function main() {
if (COLLECTION !== TEST_COLLECTION_NAME) {
const dryRun = process.argv.includes("--dry-run");

if (!dryRun && COLLECTION !== TEST_COLLECTION_NAME) {
console.error(
`Refusing to seed: resolved collection is "${COLLECTION}", not ` +
`"${TEST_COLLECTION_NAME}". This script is destructive and only ` +
Expand All@@ -451,11 +584,28 @@ async function main() {
process.exit(1);
}

const docs = ROWS.map((row, index) => toDoc(row, index));

const errors = validate(docs);
if (errors.length > 0) {
console.error("Seed data does not match the questions in questions.ts:");
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}

if (dryRun) {
console.log(
`Dry run: ${docs.length} applicants validated against ` +
`${APPLICATION_SECTIONS.length} sections. Target would be "${COLLECTION}".`,
);
console.log(JSON.stringify(docs[0], null, 2));
process.exit(0);
}

const db = await getDb();
const col = db.collection(COLLECTION);

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

console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
Expand Down
6 changes: 4 additions & 2 deletions apps/app-portal/scripts/setup-indexes.ts
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@ export default async function ApplicantDetailPage({
<RsvpEditor
applicantId={applicant.id}
value={applicant.rsvpStatus}
decisionStatus={applicant.decisionStatus}
/>
</CardContent>
</Card>
Expand Down
28 changes: 28 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/loading.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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>
);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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/auth

# --- 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=
198 changes: 174 additions & 24 deletions apps/app-portal/scripts/seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
*
* Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root):
* yarn seed
* yarn seed --dry-run — validate and print, write nothing, connect to nothing
*
* Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via
* `node --env-file=.env`) — this always points at the shared Atlas cluster.
Expand All@@ -19,6 +20,7 @@
* app enums in src/lib/types/user.ts.
*/
import { getDb, resolveCollectionName } from "@/lib/db";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";

const TEST_COLLECTION_NAME = "applicant_data_test";
const COLLECTION = resolveCollectionName("applicant_data");
Expand DownExpand Up@@ -348,17 +350,87 @@ const ROWS: 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",
// Unmapped schools fall through to the question's "other" option, with the raw
// name in `school_other`.
const SCHOOL_MAP: Record<string, string> = {
"Northeastern University": "northeastern_university",
MIT: "mit",
Harvard: "harvard_university",
"Boston University": "boston_university",
};

// The seed table's free-text `year` spans two real questions.
const EDUCATION_MAP: Record<string, { level: string; year: string }> = {
Junior: { level: "undergraduate", year: "3rd_year" },
Senior: { level: "undergraduate", year: "4th_year" },
Graduate: { level: "graduate", year: "1st_year" },
};

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"];
const CS_CLASS_OPTIONS = ["0", "1-2", "3-5", "6+"];
const WORKSHOP_OPTIONS = [
"mobile",
"web",
"design",
"backend",
"frontend",
"data_science",
"cybersecurity",
"ai_ml",
"product_management",
"entrepreneurship",
];
const IDENTITIES = [
{ pronouns: "she/her", gender: "female" },
{ pronouns: "he/him", gender: "male" },
{ pronouns: "they/them", gender: "non_binary" },
{ pronouns: "she/they", gender: "genderqueer" },
{ pronouns: "he/him", gender: "prefer_not_to_say" },
{ pronouns: "they/them", gender: "unlisted" },
];
const RACE_OPTIONS = [
"indigenous_american_or_alaska_native",
"asian",
"black_or_african_american",
"hispanic_or_latinx",
"native_hawaiian_or_pacific_islander",
"white",
"unlisted",
"prefer_not_to_say",
];
const LGBTQ_OPTIONS = ["yes", "no", "unsure", "prefer_not_to_say"];
const REFERRAL_OPTIONS = [
"facebook",
"instagram",
"linkedin",
"twitter",
"tiktok",
"hbp_email_newsletter",
"word_of_mouth",
"hbp_outreach_events",
"school_communications",
"other_organization",
"other",
];
const HOMETOWNS = [
"Boston, MA",
"Providence, RI",
"Portland, ME",
"Hartford, CT",
"Nashua, NH",
];
const MAJORS = [
"Computer Science",
"Computer Science and Design",
"Data Science",
"Electrical Engineering",
"Mathematics",
];

// The application's own `tshirt_size` question allows 2XL; the RSVP payload schema
// (src/lib/status/rsvp.ts) stops at XL. Kept separate so both match their writer.
const TSHIRT_SIZES = ["xs", "s", "m", "l", "xl", "2xl"];
const RSVP_TSHIRT_SIZES = ["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.
Expand All@@ -384,43 +456,74 @@ function toDoc(row: Row, index: number) {
appSubmissionTime,
] = row;

const schoolValue = SCHOOL_MAP[school] ?? "other";
const education = EDUCATION_MAP[year] ?? EDUCATION_MAP.Graduate;
const identity = IDENTITIES[index % IDENTITIES.length];

// 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",
first_name: firstName,
last_name: lastName,
hometown: HOMETOWNS[index % HOMETOWNS.length],
pronouns: identity.pronouns,
gender: identity.gender,
race:
index % 3 === 0
? [RACE_OPTIONS[index % RACE_OPTIONS.length]]
: [
RACE_OPTIONS[index % RACE_OPTIONS.length],
RACE_OPTIONS[(index + 3) % RACE_OPTIONS.length],
],
lgbtq: LGBTQ_OPTIONS[index % LGBTQ_OPTIONS.length],
school: schoolValue,
education_level: education.level,
education_year: education.year,
major: MAJORS[index % MAJORS.length],
tshirt_size: TSHIRT_SIZES[index % TSHIRT_SIZES.length],
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
interests:
cs_classes: CS_CLASS_OPTIONS[(index + 1) % CS_CLASS_OPTIONS.length],
workshop_interests:
index % 2 === 0
? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
? [WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length]]
: [
INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length],
WORKSHOP_OPTIONS[(index + 2) % WORKSHOP_OPTIONS.length],
],
why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
goals_long_answer: `${firstName} wants to ship a project end to end and find people to keep building with afterwards.`,
passion_long_answer: `${firstName} could talk for hours about why good developer tooling changes what teams are willing to attempt.`,
hackathon_reflection: `${firstName} has been to a few hackathons and wants more time for workshops and less time fighting deploys.`,
premade_team: "no",
referral_source: [REFERRAL_OPTIONS[index % REFERRAL_OPTIONS.length]],
};
if (schoolValue === "other") {
applicationResponses.school_other = school;
}
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
if (index % 4 === 0) {
applicationResponses.premade_team = "yes";
applicationResponses.team_captain_info = `${firstName} ${lastName}, ${email}`;
}
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.
// Placeholder ids with no matching row in the uploads collection.
applicationResponses.resume = `seed-upload-${index}`;
applicationResponses.vaccination_card = `seed-vax-${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",
// saveRsvp writes the parsed payload verbatim, so `attending` holds the
// rsvpSchema enum value ("confirmed"/"unconfirmed"), not a yes/no string.
attending: rsvpStatus === "confirmed" ? "confirmed" : "unconfirmed",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
tshirtSize: RSVP_TSHIRT_SIZES[index % RSVP_TSHIRT_SIZES.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
Expand All@@ -441,8 +544,38 @@ function toDoc(row: Row, index: number) {
};
}

function validate(docs: ReturnType<typeof toDoc>[]): string[] {
const questions = new Map(
APPLICATION_SECTIONS.flatMap((section) =>
section.questions.map((q) => [q.id, q] as const),
),
);
const errors: string[] = [];

for (const doc of docs) {
for (const [id, value] of Object.entries(doc.applicationResponses)) {
const question = questions.get(id);
if (!question) {
errors.push(`${doc.email}: no question with id "${id}"`);
continue;
}
if (!question.options) continue;
const allowed = new Set(question.options.map((o) => o.value));
for (const v of Array.isArray(value) ? value : [value]) {
if (!allowed.has(v)) {
errors.push(`${doc.email}: "${v}" is not an option of "${id}"`);
}
}
}
}

return errors;
}

async function main() {
if (COLLECTION !== TEST_COLLECTION_NAME) {
const dryRun = process.argv.includes("--dry-run");

if (!dryRun && COLLECTION !== TEST_COLLECTION_NAME) {
console.error(
`Refusing to seed: resolved collection is "${COLLECTION}", not ` +
`"${TEST_COLLECTION_NAME}". This script is destructive and only ` +
Expand All@@ -451,11 +584,28 @@ async function main() {
process.exit(1);
}

const docs = ROWS.map((row, index) => toDoc(row, index));

const errors = validate(docs);
if (errors.length > 0) {
console.error("Seed data does not match the questions in questions.ts:");
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}

if (dryRun) {
console.log(
`Dry run: ${docs.length} applicants validated against ` +
`${APPLICATION_SECTIONS.length} sections. Target would be "${COLLECTION}".`,
);
console.log(JSON.stringify(docs[0], null, 2));
process.exit(0);
}

const db = await getDb();
const col = db.collection(COLLECTION);

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

console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
Expand Down
6 changes: 4 additions & 2 deletions apps/app-portal/scripts/setup-indexes.ts
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@ export default async function ApplicantDetailPage({
<RsvpEditor
applicantId={applicant.id}
value={applicant.rsvpStatus}
decisionStatus={applicant.decisionStatus}
/>
</CardContent>
</Card>
Expand Down
28 changes: 28 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/loading.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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>
);
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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/auth

# --- 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=
198 changes: 174 additions & 24 deletions apps/app-portal/scripts/seed.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
*
* Usage (from apps/app-portal, or `yarn workspace app-portal seed` from root):
* yarn seed
* yarn seed --dry-run — validate and print, write nothing, connect to nothing
*
* Reads MONGO_PROD_CONNECTION_STRING from .env (loaded via
* `node --env-file=.env`) — this always points at the shared Atlas cluster.
Expand All@@ -19,6 +20,7 @@
* app enums in src/lib/types/user.ts.
*/
import { getDb, resolveCollectionName } from "@/lib/db";
import { APPLICATION_SECTIONS } from "@/lib/application/questions";

const TEST_COLLECTION_NAME = "applicant_data_test";
const COLLECTION = resolveCollectionName("applicant_data");
Expand DownExpand Up@@ -348,17 +350,87 @@ const ROWS: 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",
// Unmapped schools fall through to the question's "other" option, with the raw
// name in `school_other`.
const SCHOOL_MAP: Record<string, string> = {
"Northeastern University": "northeastern_university",
MIT: "mit",
Harvard: "harvard_university",
"Boston University": "boston_university",
};

// The seed table's free-text `year` spans two real questions.
const EDUCATION_MAP: Record<string, { level: string; year: string }> = {
Junior: { level: "undergraduate", year: "3rd_year" },
Senior: { level: "undergraduate", year: "4th_year" },
Graduate: { level: "graduate", year: "1st_year" },
};

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"];
const CS_CLASS_OPTIONS = ["0", "1-2", "3-5", "6+"];
const WORKSHOP_OPTIONS = [
"mobile",
"web",
"design",
"backend",
"frontend",
"data_science",
"cybersecurity",
"ai_ml",
"product_management",
"entrepreneurship",
];
const IDENTITIES = [
{ pronouns: "she/her", gender: "female" },
{ pronouns: "he/him", gender: "male" },
{ pronouns: "they/them", gender: "non_binary" },
{ pronouns: "she/they", gender: "genderqueer" },
{ pronouns: "he/him", gender: "prefer_not_to_say" },
{ pronouns: "they/them", gender: "unlisted" },
];
const RACE_OPTIONS = [
"indigenous_american_or_alaska_native",
"asian",
"black_or_african_american",
"hispanic_or_latinx",
"native_hawaiian_or_pacific_islander",
"white",
"unlisted",
"prefer_not_to_say",
];
const LGBTQ_OPTIONS = ["yes", "no", "unsure", "prefer_not_to_say"];
const REFERRAL_OPTIONS = [
"facebook",
"instagram",
"linkedin",
"twitter",
"tiktok",
"hbp_email_newsletter",
"word_of_mouth",
"hbp_outreach_events",
"school_communications",
"other_organization",
"other",
];
const HOMETOWNS = [
"Boston, MA",
"Providence, RI",
"Portland, ME",
"Hartford, CT",
"Nashua, NH",
];
const MAJORS = [
"Computer Science",
"Computer Science and Design",
"Data Science",
"Electrical Engineering",
"Mathematics",
];

// The application's own `tshirt_size` question allows 2XL; the RSVP payload schema
// (src/lib/status/rsvp.ts) stops at XL. Kept separate so both match their writer.
const TSHIRT_SIZES = ["xs", "s", "m", "l", "xl", "2xl"];
const RSVP_TSHIRT_SIZES = ["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.
Expand All@@ -384,43 +456,74 @@ function toDoc(row: Row, index: number) {
appSubmissionTime,
] = row;

const schoolValue = SCHOOL_MAP[school] ?? "other";
const education = EDUCATION_MAP[year] ?? EDUCATION_MAP.Graduate;
const identity = IDENTITIES[index % IDENTITIES.length];

// 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",
first_name: firstName,
last_name: lastName,
hometown: HOMETOWNS[index % HOMETOWNS.length],
pronouns: identity.pronouns,
gender: identity.gender,
race:
index % 3 === 0
? [RACE_OPTIONS[index % RACE_OPTIONS.length]]
: [
RACE_OPTIONS[index % RACE_OPTIONS.length],
RACE_OPTIONS[(index + 3) % RACE_OPTIONS.length],
],
lgbtq: LGBTQ_OPTIONS[index % LGBTQ_OPTIONS.length],
school: schoolValue,
education_level: education.level,
education_year: education.year,
major: MAJORS[index % MAJORS.length],
tshirt_size: TSHIRT_SIZES[index % TSHIRT_SIZES.length],
hackathon_experience: HACKATHON_OPTIONS[index % HACKATHON_OPTIONS.length],
interests:
cs_classes: CS_CLASS_OPTIONS[(index + 1) % CS_CLASS_OPTIONS.length],
workshop_interests:
index % 2 === 0
? [INTEREST_OPTIONS[index % INTEREST_OPTIONS.length]]
? [WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length]]
: [
INTEREST_OPTIONS[index % INTEREST_OPTIONS.length],
INTEREST_OPTIONS[(index + 2) % INTEREST_OPTIONS.length],
WORKSHOP_OPTIONS[index % WORKSHOP_OPTIONS.length],
WORKSHOP_OPTIONS[(index + 2) % WORKSHOP_OPTIONS.length],
],
why_attend: `${firstName} is excited to build something new at HackBeanpot.`,
goals_long_answer: `${firstName} wants to ship a project end to end and find people to keep building with afterwards.`,
passion_long_answer: `${firstName} could talk for hours about why good developer tooling changes what teams are willing to attempt.`,
hackathon_reflection: `${firstName} has been to a few hackathons and wants more time for workshops and less time fighting deploys.`,
premade_team: "no",
referral_source: [REFERRAL_OPTIONS[index % REFERRAL_OPTIONS.length]],
};
if (schoolValue === "other") {
applicationResponses.school_other = school;
}
if (index % 5 === 0) {
applicationResponses.preferred_name = firstName;
}
if (index % 4 === 0) {
applicationResponses.premade_team = "yes";
applicationResponses.team_captain_info = `${firstName} ${lastName}, ${email}`;
}
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.
// Placeholder ids with no matching row in the uploads collection.
applicationResponses.resume = `seed-upload-${index}`;
applicationResponses.vaccination_card = `seed-vax-${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",
// saveRsvp writes the parsed payload verbatim, so `attending` holds the
// rsvpSchema enum value ("confirmed"/"unconfirmed"), not a yes/no string.
attending: rsvpStatus === "confirmed" ? "confirmed" : "unconfirmed",
dietaryRestrictions:
DIETARY_RESTRICTIONS[index % DIETARY_RESTRICTIONS.length],
tshirtSize: TSHIRT_OPTIONS[index % TSHIRT_OPTIONS.length],
tshirtSize: RSVP_TSHIRT_SIZES[index % RSVP_TSHIRT_SIZES.length],
accessibilityNeeds:
index % 6 === 0 ? "Wheelchair accessible seating" : "",
additionalNotes:
Expand All@@ -441,8 +544,38 @@ function toDoc(row: Row, index: number) {
};
}

function validate(docs: ReturnType<typeof toDoc>[]): string[] {
const questions = new Map(
APPLICATION_SECTIONS.flatMap((section) =>
section.questions.map((q) => [q.id, q] as const),
),
);
const errors: string[] = [];

for (const doc of docs) {
for (const [id, value] of Object.entries(doc.applicationResponses)) {
const question = questions.get(id);
if (!question) {
errors.push(`${doc.email}: no question with id "${id}"`);
continue;
}
if (!question.options) continue;
const allowed = new Set(question.options.map((o) => o.value));
for (const v of Array.isArray(value) ? value : [value]) {
if (!allowed.has(v)) {
errors.push(`${doc.email}: "${v}" is not an option of "${id}"`);
}
}
}
}

return errors;
}

async function main() {
if (COLLECTION !== TEST_COLLECTION_NAME) {
const dryRun = process.argv.includes("--dry-run");

if (!dryRun && COLLECTION !== TEST_COLLECTION_NAME) {
console.error(
`Refusing to seed: resolved collection is "${COLLECTION}", not ` +
`"${TEST_COLLECTION_NAME}". This script is destructive and only ` +
Expand All@@ -451,11 +584,28 @@ async function main() {
process.exit(1);
}

const docs = ROWS.map((row, index) => toDoc(row, index));

const errors = validate(docs);
if (errors.length > 0) {
console.error("Seed data does not match the questions in questions.ts:");
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}

if (dryRun) {
console.log(
`Dry run: ${docs.length} applicants validated against ` +
`${APPLICATION_SECTIONS.length} sections. Target would be "${COLLECTION}".`,
);
console.log(JSON.stringify(docs[0], null, 2));
process.exit(0);
}

const db = await getDb();
const col = db.collection(COLLECTION);

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

console.log(`Seeded ${docs.length} applicants into ${COLLECTION}.`);
Expand Down
6 changes: 4 additions & 2 deletions apps/app-portal/scripts/setup-indexes.ts
Original file line numberDiff line numberDiff 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
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,7 @@ export default async function ApplicantDetailPage({
<RsvpEditor
applicantId={applicant.id}
value={applicant.rsvpStatus}
decisionStatus={applicant.decisionStatus}
/>
</CardContent>
</Card>
Expand Down
28 changes: 28 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/loading.tsx
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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>
);
}
Loading