From 39a339e0711b31568584f96ab11ad8b5bfb99dae Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sun, 30 Aug 2026 14:11:34 -0400 Subject: [PATCH 1/3] ok --- README.md | 6 + docs/README.md | 3 +- docs/club-project.md | 135 ++++++++ docs/sites/mainweb.md | 2 +- packages/api/src/routers/initiative.ts | 26 +- packages/auth/src/email.ts | 4 +- packages/db/package.json | 1 + packages/db/scripts/seed-club-projects.ts | 292 ++++++++++++++++++ packages/db/src/index.ts | 1 + packages/db/src/schemas/club-projects.ts | 61 ++++ packages/db/src/schemas/index.ts | 1 + .../app/(portal)/admin/initiatives/page.tsx | 10 +- sites/mainweb/app/(portal)/dashboard/page.tsx | 6 +- .../mainweb/app/(portal)/initiatives/page.tsx | 21 +- sites/mainweb/app/(portal)/lead/[id]/page.tsx | 16 +- sites/mainweb/app/(portal)/lead/page.tsx | 23 +- sites/mainweb/app/HomePageClient.tsx | 252 +++++++-------- sites/mainweb/app/page.tsx | 9 +- .../app/projects/ProjectsPageClient.tsx | 233 ++++++++++++++ sites/mainweb/app/projects/page.tsx | 208 +------------ .../components/portal/PortalSidebar.tsx | 12 +- sites/mainweb/lib/club-projects.server.ts | 37 +++ sites/mainweb/lib/club-projects.test.ts | 146 +++++++++ sites/mainweb/lib/club-projects.ts | 60 ++++ 24 files changed, 1167 insertions(+), 398 deletions(-) create mode 100644 docs/club-project.md create mode 100644 packages/db/scripts/seed-club-projects.ts create mode 100644 packages/db/src/schemas/club-projects.ts create mode 100644 sites/mainweb/app/projects/ProjectsPageClient.tsx create mode 100644 sites/mainweb/lib/club-projects.server.ts create mode 100644 sites/mainweb/lib/club-projects.test.ts create mode 100644 sites/mainweb/lib/club-projects.ts diff --git a/README.md b/README.md index 78acdc68..23021574 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Two Next.js sites share one Postgres database and four internal packages. Club m **Documentation:** start at [`docs/README.md`](./docs/README.md). +## Club project + +The public website and member portal for Data Science at Georgia Tech, live at [datasciencegt.org](https://datasciencegt.org). This is production club infrastructure (not a greenfield student app). + +Member-facing overview — what it is, what members use, current status, and how to help: [`docs/club-project.md`](./docs/club-project.md). Local setup and PR workflow stay in [`docs/getting-started.md`](./docs/getting-started.md) and [`docs/contributing.md`](./docs/contributing.md). + ## Workspace layout | Path | Workspace | Role | diff --git a/docs/README.md b/docs/README.md index 1544941c..96cec8c3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,10 +2,11 @@ This folder is the reference for **query**, the Data Science at Georgia Tech (DSGT) monorepo for club operations and digital infrastructure. -Start here, then jump to the page that matches the work you are doing. +Members looking for a club-language overview should start at [Club project](./club-project.md). For local setup and review rules, jump to the page that matches the work you are doing. | Document | What it covers | | --- | --- | +| [Club project](./club-project.md) | What the live site is, who uses it, current status, how to help | | [Getting started](./getting-started.md) | Prerequisites, local Postgres, env vars, first `pnpm dev` | | [Architecture](./architecture.md) | How the two sites and four packages fit together | | [Contributing](./contributing.md) | Branches, scripts, tests, and review expectations | diff --git a/docs/club-project.md b/docs/club-project.md new file mode 100644 index 00000000..2a6e5819 --- /dev/null +++ b/docs/club-project.md @@ -0,0 +1,135 @@ +# Club project: DS@GT website + +This is the member-facing overview of **query**, the live public website and member portal for [Data Science at Georgia Tech](https://datasciencegt.org) (DS@GT / DSGT). + +It is not a setup manual. Local install is [Getting started](./getting-started.md). Pull requests and review rules are [Contributing](./contributing.md). Architecture, packages, and operations stay in the rest of [`docs/`](./README.md). + +Aamogh Sawant ([@aamoghS](https://github.com/aamoghS)), club President, owns and ships this repo. There is no separate website lead. + +## What this is + +The public website visitors see, and the signed-in portal members use to join the club, pay dues, check in at events, follow bootcamp, apply to initiatives, and handle Hacklytics interest and registration. + +The public pages and the portal are one Next.js app (`sites/mainweb`). Signing in does not take you to a different hostname. + +## Live URLs + +| Surface | URL | +| -------------------- | ------------------------------------------------------------------------------------------------------------ | +| Public site + portal | [https://datasciencegt.org](https://datasciencegt.org) | +| Sign in | [https://datasciencegt.org/login](https://datasciencegt.org/login) | +| Member home | [https://datasciencegt.org/dashboard](https://datasciencegt.org/dashboard) | + +`member.datasciencegt.org` does **not** resolve. Do not send people there, and do not put it in copy or onboarding. + +Locally, the same app is [http://localhost:3001](http://localhost:3001). See [Getting started](./getting-started.md). + +## What members use it for + +After sign-in (Google, GitHub if configured, or email code): + +| Need | Where | +| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Join / pay dues | Portal membership — Stripe **$25** annual membership, **$10** bootcamp add-on (on top of membership, not instead of it) | +| Club events and check-in | Portal events / club pass | +| Bootcamp (term-gated add-on) | `/club/bootcamp` and the public `/bootcamp` page | +| Initiatives (club projects you apply to) | `/initiatives` | +| Staff tools | `/admin` (appointed roles only; there is no public admin signup) | +| Hacklytics interest and registration | Portal `/hacklytics` (the marketing site links here after login) | + +Hacklytics participation is open to non-members. A paid membership is not required to register for the hackathon. + +Public pages (no login): + +| Path | Page | +| ----------- | ------------------ | +| `/` | Home | +| `/team` | Executive board | +| `/events` | Public events | +| `/projects` | Projects | +| `/history` | Club history | +| `/bootcamp` | Bootcamp marketing | + +Route-level detail: [Main website](./sites/mainweb.md). Club vs hackathon vocabulary: [Glossary](./glossary.md). + +## Club projects (the roster on the site) + +`/` and `/projects` read the roster from the `club_project` table, not from a hardcoded array. Editing a card is a row edit, which is why the old list sat five years stale. + +| Column | What it does | +| --------------- | ----------------------------------------------------------------------------------------- | +| `status` | `active`, `revived`, `needs_lead`, or `past`. Only `past` drops out of the current roster | +| `lead_name` | Free text, so a lead can be named before they ever sign in | +| `initiative_id` | The portal initiative members apply to, when there is one | +| `join_url` | External destination for projects that recruit elsewhere (ARC) | +| `is_published` | Pull a card off the site without deleting it | + +Applying happens in the portal. A card with an `initiative_id` links to `/initiatives`, where a signed-in member can request to join; the initiative's leader accepts or declines from `/lead`. A card with no initiative falls back to `join_url`, then to the shared interest form. + +To reset the roster to the checked-in Fall 2026 list: + +```bash +pnpm --filter @query/db db:seed:club-projects +``` + +The seed upserts on `slug` and never deletes, so re-running it republishes the roster without duplicating cards. Removing a project from the site is `is_published = false`, not a deleted row. + +## Current status (Fall 2026) + +This is **live production infrastructure**, not a greenfield student app and not a class project waiting for a first deploy. + +- Serving real members at [datasciencegt.org](https://datasciencegt.org) +- Hosted on Firebase App Hosting / Cloud Run +- GCP project: `dsgt-website` +- Database: Neon (Postgres) +- Last `main` activity: late August 2026 + +Treat production as production. A broken PR can take down dues, login, or event check-in. + +## How the repo is laid out + +High level only. Details live in the linked docs. + +| Path | What it is | +| ---------------------- | ---------------------------------------------------- | +| `sites/mainweb` | Public club site **and** the authenticated portal | +| `sites/hacklytics2027` | Hacklytics 2027 marketing site (static; no database) | +| `packages/api` | tRPC, pricing, server logic | +| `packages/auth` | Sign-in (NextAuth) | +| `packages/db` | Schema and membership rules | +| `packages/ui` | Shared React components | + +Club operations (membership, club events, bootcamp, initiatives) and hackathon editions share one database but are modeled as separate domains. Do not hang club tables off a hackathon row. + +Setup, env, and first-admin bootstrap: [Getting started](./getting-started.md). How the pieces connect: [Architecture](./architecture.md). Index of the rest: [Documentation](./README.md). + +## Older repos (do not revive) + +These are predecessors. The live product is **this** repo (`DataScience-GT/query`). Do not open feature work there, do not migrate traffic back, and do not treat them as the current stack. + +| Repo | What it was | +| ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| [DataScience-GT/datascience-gt.github.io](https://github.com/DataScience-GT/datascience-gt.github.io) | Earlier website / portal repo | +| [DataScience-GT/dsgt-member-portal](https://github.com/DataScience-GT/dsgt-member-portal) | Earlier member portal (membership, Stripe, events) | + +## How to help + +Safe first work — useful, visible, and hard to take production down with: + +1. **Public content accuracy** — `/team`, `/projects`, `/events` (and related copy) matching the current board and programs +2. **Onboarding and docs** — this folder, especially anything that helps a new contributor run the app without guessing +3. **Small UI bugs** — layout, dead links, copy, accessibility on pages you can exercise locally +4. **Tests** — fill gaps in existing Vitest suites; see [Testing](./operations/testing.md) + +Label anything that touches **payments**, **auth**, or **production deploy** as **needs-exec-review**. Do not merge that class of change on a student PR alone. That includes Stripe amounts and webhooks, NextAuth / OAuth / email-code login, secrets, `apphosting.yaml`, Firebase Hosting, and anything that writes production schema. + +Club events and working time are after **6:30 PM ET**. Questions: [hello@datasciencegt.org](mailto:hello@datasciencegt.org) or Aamogh. + +## How to join / contribute + +1. Read [Contributing](./contributing.md) and [Getting started](./getting-started.md). +2. Branch from `dev` (that is the integration branch). `main` is production. +3. Open the pull request against **this** repo (`DataScience-GT/query`). Feature branches are reviewed into `dev`; `dev` is what ships to `main`. +4. Never commit secrets (`.env`, Stripe keys, OAuth client secrets, SMTP passwords, production `DATABASE_URL`). If a secret was pasted into a PR, say so immediately — do not “fix” it by committing a deletion and moving on. + +Code owners: `@aamoghS`. diff --git a/docs/sites/mainweb.md b/docs/sites/mainweb.md index 636ca0ee..99b13e85 100644 --- a/docs/sites/mainweb.md +++ b/docs/sites/mainweb.md @@ -14,7 +14,7 @@ This is the club’s public site **and** the authenticated portal. There is no s | `/` | Home (`HomePageClient`) | | `/team` | Team | | `/events` | Public events | -| `/projects` | Projects | +| `/projects` | Club projects — current roster and past archive, read from `club_project` | | `/history` | Club history | | `/bootcamp` | Bootcamp marketing | | `/docs` | In-app docs UI | diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts index b8e5846a..a80001dd 100644 --- a/packages/api/src/routers/initiative.ts +++ b/packages/api/src/routers/initiative.ts @@ -13,7 +13,7 @@ import { createTRPCRouter, protectedProcedure } from "../trpc"; import { isAdmin, isSuperAdmin, isProjectLeader } from "../middleware/procedures"; import { clearProjectLeaderCaches } from "../middleware/cache"; -const notFound = (message = "Initiative not found") => +const notFound = (message = "Project not found") => new TRPCError({ code: "NOT_FOUND", message }); /** Postgres unique_violation. Drizzle wraps driver errors, so walk `.cause`. */ @@ -80,7 +80,7 @@ async function requireActiveMember(db: Reader, userId: string) { if (!active) { throw new TRPCError({ code: "FORBIDDEN", - message: "An active membership is required to join an initiative.", + message: "An active membership is required to join a project.", }); } } @@ -234,7 +234,7 @@ export const initiativeRouter = createTRPCRouter({ if (!ctx.isPlatformAdmin) { throw new TRPCError({ code: "FORBIDDEN", - message: "Only an admin can create an initiative for someone else.", + message: "Only an admin can create a project for someone else.", }); } @@ -258,7 +258,7 @@ export const initiativeRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: - "You are not a project leader. Name the leader this initiative belongs to.", + "You are not a project leader. Name the leader this project belongs to.", }); } @@ -279,7 +279,7 @@ export const initiativeRouter = createTRPCRouter({ if (!created) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Could not create that initiative.", + message: "Could not create that project.", }); } return created; @@ -338,7 +338,7 @@ export const initiativeRouter = createTRPCRouter({ if (initiative.archivedAt !== null) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Restore this initiative before changing its status.", + message: "Restore this project before changing its status.", }); } @@ -434,7 +434,7 @@ export const initiativeRouter = createTRPCRouter({ if (taken >= initiative.maxMembers) { throw new TRPCError({ code: "BAD_REQUEST", - message: "This initiative is full.", + message: "This project is full.", }); } } @@ -650,14 +650,14 @@ export const initiativeRouter = createTRPCRouter({ if (initiative.status !== "open") { throw new TRPCError({ code: "BAD_REQUEST", - message: "This initiative is not taking applications.", + message: "This project is not taking applications.", }); } if (initiative.leaderUserId === userId) { throw new TRPCError({ code: "BAD_REQUEST", - message: "You already lead this initiative.", + message: "You already lead this project.", }); } @@ -675,9 +675,9 @@ export const initiativeRouter = createTRPCRouter({ code: "CONFLICT", message: existing.status === "pending" - ? "You have already applied to this initiative." + ? "You have already applied to this project." : existing.status === "accepted" - ? "You are already on this initiative." + ? "You are already on this project." : "The leader has already decided on your application.", }); } @@ -687,7 +687,7 @@ export const initiativeRouter = createTRPCRouter({ if (taken >= initiative.maxMembers) { throw new TRPCError({ code: "BAD_REQUEST", - message: "This initiative is full.", + message: "This project is full.", }); } } @@ -721,7 +721,7 @@ export const initiativeRouter = createTRPCRouter({ if (isUniqueViolation(error)) { throw new TRPCError({ code: "CONFLICT", - message: "You have already applied to this initiative.", + message: "You have already applied to this project.", }); } throw error; diff --git a/packages/auth/src/email.ts b/packages/auth/src/email.ts index 038070cb..fbef7f38 100644 --- a/packages/auth/src/email.ts +++ b/packages/auth/src/email.ts @@ -258,7 +258,7 @@ export async function sendInitiativeDecisionEmail({ ? [`Your proposal for ${initiativeTitle} was not taken forward this time.`] : [ `Your application to ${initiativeTitle} was not accepted this time.`, - "Other initiatives are open, and applying again later is welcome.", + "Other projects are open, and applying again later is welcome.", ]; if (note) paragraphs.push(note); @@ -268,7 +268,7 @@ export async function sendInitiativeDecisionEmail({ subject, heading: accepted ? "Good news" : "An update", paragraphs, - ctaLabel: accepted && kind === "proposal" ? "Open your initiative" : "See initiatives", + ctaLabel: accepted && kind === "proposal" ? "Open your project" : "See projects", ctaUrl: accepted && kind === "proposal" ? `${host}/lead` : `${host}/initiatives`, }); diff --git a/packages/db/package.json b/packages/db/package.json index 5c3c1eac..c7b222a6 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -14,6 +14,7 @@ "migrate:generate": "drizzle-kit generate", "studio": "drizzle-kit studio", "db:seed": "tsx scripts/seed.ts", + "db:seed:club-projects": "tsx scripts/seed-club-projects.ts", "export:clickhouse": "tsx scripts/export-clickhouse.mts", "lint": "eslint . --max-warnings 0", "typecheck": "tsc --noEmit" diff --git a/packages/db/scripts/seed-club-projects.ts b/packages/db/scripts/seed-club-projects.ts new file mode 100644 index 00000000..96c77987 --- /dev/null +++ b/packages/db/scripts/seed-club-projects.ts @@ -0,0 +1,292 @@ +/** + * Seeds the public club-project roster and the portal initiatives behind it. + * + * pnpm --filter @query/db db:seed:club-projects + * + * Idempotent and never deletes: club_project upserts on slug, initiatives match + * on title. Initiatives are owned by OWNER_EMAIL until the real leads have + * accounts; the public card still names the actual lead. + */ +import * as dotenv from "dotenv"; +import path from "path"; + +dotenv.config({ path: path.resolve(__dirname, "../../../.env") }); + +// `../src` is imported inside main(): its client reads DATABASE_URL at module +// load, and a static import would be hoisted above the dotenv call. +import type { ClubProjectStatus } from "../src/schemas/club-projects"; + +const OWNER_EMAIL = + process.env.CLUB_PROJECT_OWNER_EMAIL ?? "aamoghsawantt@gmail.com"; + +type Row = { + slug: string; + name: string; + status: ClubProjectStatus; + leadName: string | null; + summary: string; + tech: string[]; + repoUrl?: string; + joinUrl?: string; + capacityNote?: string; + term?: string; + sortOrder: number; + apply?: { commitment: string; maxMembers: number | null }; +}; + +const FALL_2026 = "Fall 2026"; + +const ROSTER: Row[] = [ + { + slug: "roboinvesting", + name: "Roboinvesting", + status: "active", + leadName: "Andrew Hlavacek", + summary: + "Systematic trading research: strategy backtests, technical indicators, and a dashboard over the results.", + tech: ["Python", "Pandas", "Backtesting", "Dashboards"], + repoUrl: "https://github.com/DataScience-GT/RoboinvestingDashboard", + capacityNote: "Recruiting 4-6 members", + term: FALL_2026, + sortOrder: 10, + apply: { commitment: "A few hours a week", maxMembers: 6 }, + }, + { + slug: "arc", + name: "ARC", + status: "active", + leadName: "Murilo Gustineli", + summary: + "The club's applied research group: Kaggle competitions and CLEF/TREC research tracks, run as a seminar.", + tech: ["Machine Learning", "PyTorch", "Competitions", "Research"], + joinUrl: "https://dsgt-arc.org/join", + term: FALL_2026, + sortOrder: 20, + }, + { + slug: "dsgt-website", + name: "DS@GT Website", + status: "active", + leadName: "Aamogh Sawant", + summary: + "The site you are on, plus the member portal behind it: dues, events, check-in, and bootcamp.", + tech: ["TypeScript", "Next.js", "tRPC", "Drizzle", "Postgres"], + repoUrl: "https://github.com/DataScience-GT/query", + capacityNote: "Capped at 6 members", + term: FALL_2026, + sortOrder: 30, + apply: { commitment: "A few hours a week", maxMembers: 6 }, + }, + { + slug: "deep-learning-playground", + name: "Deep Learning Playground", + status: "revived", + leadName: null, + summary: + "Restarting this term. A web app where people new to deep learning can load a dataset and try PyTorch modules without writing code. Needs a lead.", + tech: ["PyTorch", "Django", "TypeScript", "Docker"], + repoUrl: "https://github.com/DataScience-GT/Deep-Learning-Playground", + term: FALL_2026, + sortOrder: 40, + apply: { commitment: "Flexible while it restarts", maxMembers: null }, + }, + { + slug: "sports-analytics", + name: "Sports Analytics", + status: "revived", + leadName: null, + summary: + "Restarting this term around the AthleticsScrapers collection work, with open-ended sports modelling once the data is flowing. Needs a lead.", + tech: ["Python", "Web Scraping", "Statistical Modeling"], + repoUrl: "https://github.com/DataScience-GT/DSGT-AthleticsScrapers", + term: FALL_2026, + sortOrder: 50, + apply: { commitment: "Flexible while it restarts", maxMembers: null }, + }, + { + slug: "atlcrime", + name: "AtlCrime", + status: "needs_lead", + leadName: null, + summary: + "A heatmap of safety across Atlanta built from public crime data. Listed so somebody can pick it up: there is no lead today.", + tech: ["Python", "Geospatial", "Data Visualization"], + repoUrl: "https://github.com/DataScience-GT/AtlCrime", + term: FALL_2026, + sortOrder: 60, + apply: { commitment: "Flexible", maxMembers: null }, + }, + + // Past: history only, no application. + { + slug: "deep-learning-playground-2021", + name: "Deep Learning Playground (2021-2023)", + status: "past", + leadName: "Noah Iversen", + summary: + "The original build: visualising backpropagation and architecture tweaks in the browser, in real time.", + tech: ["AWS", "Docker", "PyTorch", "TypeScript", "Next.js", "Django"], + repoUrl: "https://github.com/DataScience-GT/Deep-Learning-Playground", + sortOrder: 110, + }, + { + slug: "ai-driven-investment-platform", + name: "AI-Driven Investment Platform", + status: "past", + leadName: "Aryan Hazra", + summary: + "NLP that helped investors reach goals conversationally, adapting to client information rather than static robo-investing inputs.", + tech: ["NLP", "Machine Learning", "Python", "Data Analytics"], + sortOrder: 120, + }, + { + slug: "furnichanter", + name: "Furnichanter", + status: "past", + leadName: "Jane Ivanova", + summary: + "Computer vision for interior design: search furniture by image, generate custom 3D models from text.", + tech: ["Deep Learning", "3D Modeling", "Python", "Computer Vision"], + sortOrder: 130, + }, + { + slug: "kaggle-clef", + name: "Kaggle CLEF", + status: "past", + leadName: "Anthony Miyaguchi", + summary: + "A seminar-styled introduction to data science competitions, building ML systems for the CLEF 2025 tracks.", + tech: ["Python", "Machine Learning", "Data Science"], + sortOrder: 140, + }, + { + slug: "sports-analysis-2024", + name: "Sports Analysis (2024)", + status: "past", + leadName: "Casper Guo", + summary: + "Open-ended sports research: NFL performance projections, optimal NBA rosters, and betting-odds differences.", + tech: ["Python", "Machine Learning", "Statistical Modeling"], + repoUrl: "https://github.com/DataScience-GT/FA24-Sports-Analysis", + sortOrder: 150, + }, +]; + +type Schema = typeof import("../src"); +type Database = NonNullable; + +let S: Schema; + +async function resolveOwner(database: Database) { + const [owner] = await database + .select({ id: S.users.id }) + .from(S.users) + .where(S.eq(S.users.email, OWNER_EMAIL)) + .limit(1); + + if (!owner) { + throw new Error( + `No user with email ${OWNER_EMAIL}. Set CLUB_PROJECT_OWNER_EMAIL to an account that exists.`, + ); + } + + const [existingRole] = await database + .select({ id: S.projectLeaders.id }) + .from(S.projectLeaders) + .where(S.eq(S.projectLeaders.userId, owner.id)) + .limit(1); + + if (!existingRole) { + await database.insert(S.projectLeaders).values({ userId: owner.id }); + } + + return owner.id; +} + +async function upsertInitiative(database: Database, row: Row, ownerId: string) { + if (!row.apply) return null; + + const values = { + leaderUserId: ownerId, + title: row.name, + summary: row.summary.slice(0, 300), + description: row.summary, + commitment: row.apply.commitment, + maxMembers: row.apply.maxMembers, + status: "open" as const, + updatedAt: new Date(), + }; + + const [existing] = await database + .select({ id: S.initiatives.id }) + .from(S.initiatives) + .where(S.eq(S.initiatives.title, row.name)) + .limit(1); + + if (existing) { + await database + .update(S.initiatives) + .set(values) + .where(S.eq(S.initiatives.id, existing.id)); + return existing.id; + } + + const [created] = await database + .insert(S.initiatives) + .values(values) + .returning({ id: S.initiatives.id }); + + return created.id; +} + +async function main() { + S = await import("../src"); + const db = S.db; + + if (!db) { + console.error("DATABASE_URL is not set"); + process.exit(1); + } + + const ownerId = await resolveOwner(db); + + for (const row of ROSTER) { + const initiativeId = await upsertInitiative(db, row, ownerId); + + const values = { + slug: row.slug, + name: row.name, + status: row.status, + leadName: row.leadName, + summary: row.summary, + tech: row.tech, + repoUrl: row.repoUrl ?? null, + joinUrl: row.joinUrl ?? null, + capacityNote: row.capacityNote ?? null, + term: row.term ?? null, + initiativeId, + sortOrder: row.sortOrder, + isPublished: true, + }; + + await db + .insert(S.clubProjects) + .values(values) + .onConflictDoUpdate({ + target: S.clubProjects.slug, + set: { ...values, updatedAt: new Date() }, + }); + + console.log( + ` ${row.status.padEnd(10)} ${row.slug}${initiativeId ? " (applications open)" : ""}`, + ); + } + + console.log(`\nSeeded ${ROSTER.length} club projects.`); + process.exit(0); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index d70bdd89..07b8a044 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,5 +11,6 @@ export { hackathonProjects, } from "./schemas/hackathons"; export { events, eventCheckIns } from "./schemas/events"; +export { clubProjects, clubProjectStatuses } from "./schemas/club-projects"; export { auditLogs, securitySeverityEnum } from "./schemas/security"; export { systemSettings } from "./schemas/settings"; diff --git a/packages/db/src/schemas/club-projects.ts b/packages/db/src/schemas/club-projects.ts new file mode 100644 index 00000000..c1fcb02f --- /dev/null +++ b/packages/db/src/schemas/club-projects.ts @@ -0,0 +1,61 @@ +import { + pgTable, + text, + timestamp, + uuid, + boolean, + integer, + index, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { initiatives } from "./initiatives"; + +// The public roster on `/` and `/projects`. Separate from `initiative`, which +// is the portal object people apply to: a roster card can have no lead and no +// seats, and past projects stay listed forever. + +export const clubProjectStatuses = [ + "active", + "revived", + "needs_lead", + "past", +] as const; +export type ClubProjectStatus = (typeof clubProjectStatuses)[number]; + +export const clubProjects = pgTable( + "club_project", + { + id: uuid("id").defaultRandom().primaryKey(), + slug: text("slug").notNull().unique(), + name: text("name").notNull(), + status: text("status", { enum: clubProjectStatuses }) + .notNull() + .default("active"), + // Free text, not a user reference: leads are named on a public page before + // they ever sign in, and past leads have graduated. + leadName: text("lead_name"), + summary: text("summary").notNull(), + tech: text("tech") + .array() + .notNull() + .default(sql`'{}'::text[]`), + repoUrl: text("repo_url"), + joinUrl: text("join_url"), + capacityNote: text("capacity_note"), + term: text("term"), + /** The portal initiative members apply to, when one exists. */ + initiativeId: uuid("initiative_id").references(() => initiatives.id, { + onDelete: "set null", + }), + sortOrder: integer("sort_order").notNull().default(0), + isPublished: boolean("is_published").notNull().default(true), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("club_project_status_idx").on(table.status), + index("club_project_published_idx").on(table.isPublished), + ], +); + +export type ClubProject = typeof clubProjects.$inferSelect; diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts index daba0e93..f8a88c0d 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -6,6 +6,7 @@ export * from "./admins"; export * from "./events"; export * from "./judge"; export * from "./initiatives"; +export * from "./club-projects"; export * from "./stripe"; export * from "./security"; export * from "./settings"; diff --git a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx index 313c04ce..556a4657 100644 --- a/sites/mainweb/app/(portal)/admin/initiatives/page.tsx +++ b/sites/mainweb/app/(portal)/admin/initiatives/page.tsx @@ -97,7 +97,7 @@ function ProposalRow({ maxLength={1000} value={note} onChange={(event) => setNote(event.target.value)} - placeholder="Too close to an existing initiative, needs a clearer scope, …" + placeholder="Too close to an existing project, needs a clearer scope, …" className="mt-2 w-full min-h-11 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none" /> ); @@ -75,7 +75,7 @@ function ProposeSection({ canPropose }: { canPropose: boolean }) { propose.mutate(toInput(draft)); }} > -

Propose an initiative

+

Propose a project

@@ -143,7 +143,7 @@ function ProposalRow({ proposal }: { proposal: MyProposal }) {

Approved — finish writing it and open it from{" "} - My Initiatives + My Projects .

@@ -180,7 +180,7 @@ function OpenRow({ const [writing, setWriting] = useState(false); const [pitch, setPitch] = useState(""); - // Both lists move together: applying takes an initiative out of one and puts + // Both lists move together: applying takes a project out of one and puts // it into the other, so refreshing one alone renders it twice. const refresh = async () => { await Promise.all([ @@ -239,7 +239,10 @@ function OpenRow({ {!canApply && !initiative.isFull && (

An active membership is required to join.{" "} - + Become a member

@@ -316,7 +319,7 @@ function ApplicationRow({ application }: { application: MyApplication }) { }, }); - // Nothing to leave once the leader has said no, and an archived initiative is + // Nothing to leave once the leader has said no, and an archived project is // over — the control would change nothing either way. const canWithdraw = application.myStatus !== "rejected" && application.archivedAt === null; @@ -424,7 +427,7 @@ export default function InitiativesPage() {
-

Initiatives

+

Projects

Projects the club runs year-round. Leaders post what they are building @@ -485,7 +488,7 @@ export default function InitiativesPage() {

{applications.length > 0 ? "Nothing else open right now." - : "No initiatives are taking applications."} + : "No projects are taking applications."}

Leaders open these up through the year. Check back, or ask at a diff --git a/sites/mainweb/app/(portal)/lead/[id]/page.tsx b/sites/mainweb/app/(portal)/lead/[id]/page.tsx index ef81abb1..83f462d5 100644 --- a/sites/mainweb/app/(portal)/lead/[id]/page.tsx +++ b/sites/mainweb/app/(portal)/lead/[id]/page.tsx @@ -180,7 +180,7 @@ export default function LeadInitiativePage({ href="/lead" className="mt-4 inline-block text-sm font-semibold text-white underline" > - Back to your initiatives + Back to your projects

@@ -210,7 +210,7 @@ export default function LeadInitiativePage({ href="/lead" className="mb-6 inline-flex items-center gap-1 text-sm font-semibold text-white/60 transition hover:text-white" > - Back to your initiatives + Back to your projects
@@ -234,7 +234,7 @@ export default function LeadInitiativePage({ {state === "draft" && (

This is still a draft, so members cannot see it or apply. Open it from - your initiatives list. + your projects list.

)} @@ -243,13 +243,17 @@ export default function LeadInitiativePage({

Nobody has applied yet.

{state === "open" - ? "It is open, so it is showing on the members' initiatives page." - : "Open it from your initiatives list and it will start showing to members."} + ? "It is open, so it is showing on the members' projects page." + : "Open it from your projects list and it will start showing to members."}

) : ( <> - +

- {initiative ? "Edit initiative" : "New initiative"} + {initiative ? "Edit project" : "New project"}

@@ -113,12 +113,19 @@ function InitiativeRow({ initiative }: { initiative: LeadInitiative }) { ]); }; - const setStatus = trpc.initiative.setStatus.useMutation({ onSuccess: refresh }); - const archive = trpc.initiative.setArchived.useMutation({ onSuccess: refresh }); + const setStatus = trpc.initiative.setStatus.useMutation({ + onSuccess: refresh, + }); + const archive = trpc.initiative.setArchived.useMutation({ + onSuccess: refresh, + }); if (editing) { return ( - setEditing(false)} /> + setEditing(false)} + /> ); } @@ -264,7 +271,7 @@ export default function LeadPage() { href="/initiatives" className="mt-4 inline-block text-sm font-semibold text-white underline" > - Browse initiatives instead + Browse projects instead
@@ -280,7 +287,7 @@ export default function LeadPage() {
-

My initiatives

+

My projects

{waiting > 0 @@ -295,7 +302,7 @@ export default function LeadPage() { onClick={() => setCreating(true)} className="rounded-full bg-white px-4 py-2 text-sm font-semibold text-black transition hover:bg-white/90" > - New initiative + New project )} @@ -314,7 +321,7 @@ export default function LeadPage() {

) : ( -

No initiatives yet.

+

No projects yet.

A new one starts as a draft, so you can write it up now and open it to members when you are ready. diff --git a/sites/mainweb/app/HomePageClient.tsx b/sites/mainweb/app/HomePageClient.tsx index 182c0a53..a1e02e7f 100644 --- a/sites/mainweb/app/HomePageClient.tsx +++ b/sites/mainweb/app/HomePageClient.tsx @@ -1,9 +1,19 @@ "use client"; import { useState, useEffect, useMemo, useCallback } from "react"; -import Image from "next/image"; +import Image, { type StaticImageData } from "next/image"; import Link from "next/link"; +import { + STATUS_CLASSES, + STATUS_LABELS, + groupClubProjects, + isExternalJoin, + joinHref, + joinLabel, + type ClubProjectCard, +} from "@/lib/club-projects"; + import Navbar from "@/components/Navbar"; import Hero from "@/components/Hero"; import Section from "@/components/Section"; @@ -38,7 +48,14 @@ type PieTooltipItem = { dataset: { data: number[] }; }; -const HomePageClient = () => { +const PROJECT_LOGOS: Record = { + arc, + roboinvesting: stock, + "sports-analytics": gtaa, + "dsgt-website": trading, +}; + +const HomePageClient = ({ projects }: { projects: ClubProjectCard[] }) => { const [windowWidth, setWindowWidth] = useState(1024); const [chartsReady, setChartsReady] = useState(false); @@ -97,6 +114,8 @@ const HomePageClient = () => { [windowWidth], ); + const { current: currentProjects } = groupClubProjects(projects); + return (

{ Projects.

- Protocol: Member_Initiatives + Protocol: Member_Projects

-
- -
-
- ARC -
-
-

- ARC Research -

-
- - Active - -
-

- ML competition group focusing on Kaggle and TREC research - tracks. -

- + The project roster is being updated —{" "} + - View Club → - -
+ see all club projects + + . +

+ ) : ( +
+ {currentProjects.map((project) => { + const logo = PROJECT_LOGOS[project.slug]; + const href = joinHref(project); + const label = joinLabel(project); - -
-
- Robo -
-
-

- Roboinvesting -

-
- - Active - -
-

- ML-driven trading simulations analyzing technical indicators. -

- - Contact Team → - -
+ return ( + + {logo && ( +
+
+ {project.name} +
+
+ )} +

+ {project.name} +

+
+ + {STATUS_LABELS[project.status]} + +
+

+ {project.summary} +

+

+ {project.leadName + ? `Lead // ${project.leadName}` + : "Lead // Open"} +

+ {isExternalJoin(project) ? ( + + {label} → + + ) : ( + + {label} → + + )} +
+ ); + })} - -
-
- AI -
-
-

- AI Trading Agent -

-
- - Active - -
-

- Conversational AI tools for real-time portfolio management. -

- - Contact Team → - -
- - -
- Sports -
-

- Sports Analytics -

-
- - Closed +
+

+ All Projects. +

+

+ Every project running this term, what each one needs, and + the archive of what members built before. +

+
+ + Browse Projects → -
-

- NFL projections and NBA roster optimization using advanced - stats. -

-
- - -
-

- Past Archive. -

-

- Explore five years of machine learning projects built by DSGT - members. -

-
- - Access Database → - - -
+ +
+ )} diff --git a/sites/mainweb/app/page.tsx b/sites/mainweb/app/page.tsx index bcecce40..c19f6b1f 100644 --- a/sites/mainweb/app/page.tsx +++ b/sites/mainweb/app/page.tsx @@ -1,5 +1,10 @@ import HomePageClient from "./HomePageClient"; +import { loadClubProjects } from "@/lib/club-projects.server"; -export default function HomePage() { - return ; +export const revalidate = 300; + +export default async function HomePage() { + const projects = await loadClubProjects(); + + return ; } diff --git a/sites/mainweb/app/projects/ProjectsPageClient.tsx b/sites/mainweb/app/projects/ProjectsPageClient.tsx new file mode 100644 index 00000000..0dfc9c94 --- /dev/null +++ b/sites/mainweb/app/projects/ProjectsPageClient.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import Navbar from "@/components/Navbar"; +import Footer from "@/components/Footer"; +import { + INTEREST_FORM_URL, + STATUS_CLASSES, + STATUS_LABELS, + groupClubProjects, + isExternalJoin, + joinHref, + joinLabel, + type ClubProjectCard, +} from "@/lib/club-projects"; + +function ProjectCard({ project }: { project: ClubProjectCard }) { + const href = joinHref(project); + const external = isExternalJoin(project); + const muted = project.status === "past"; + + return ( +
+
+ +
+

+ {project.name} +

+ + {STATUS_LABELS[project.status]} + +
+ +

+ {project.leadName ? `Lead // ${project.leadName}` : "Lead // Open"} + {project.capacityNote ? ` // ${project.capacityNote}` : ""} +

+ +

+ {project.summary} +

+ + {project.tech.length > 0 && ( +
+ {project.tech.map((tech) => ( + + {tech} + + ))} +
+ )} + +
+ {!muted && + (external ? ( + + {joinLabel(project)} → + + ) : ( + + {joinLabel(project)} → + + ))} + {project.repoUrl && ( + + Repo → + + )} +
+
+ ); +} + +export default function ProjectsPageClient({ + projects, +}: { + projects: ClubProjectCard[]; +}) { + const [windowWidth, setWindowWidth] = useState( + typeof window !== "undefined" ? window.innerWidth : 1024, + ); + + useEffect(() => { + const handleResize = () => setWindowWidth(window.innerWidth); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const { current, past } = groupClubProjects(projects); + const term = current.find((project) => project.term)?.term ?? "This term"; + + return ( +
+ +
+ + +
+

+ Club
+ + Projects. + +

+

+ What DSGT members are building right now. Apply through the portal, + or take one of the projects that still needs a lead. +

+ + One interest form for every project → + +
+ +
+
+

+ {term} +

+
+
+ + {current.length === 0 ? ( +

+ The roster is being updated. Check back shortly, or email{" "} + + hello@datasciencegt.org + + . +

+ ) : ( +
+ {current.map((project) => ( + + ))} +
+ )} +
+ + {past.length > 0 && ( +
+
+

+ Past +

+
+
+

+ Finished or retired. Kept here as an archive, not as something you + can join today. +

+
+ {past.map((project) => ( + + ))} +
+
+ )} + +
+

+ Want in? +

+
+ + Apply_In_Portal{" "} + + → + + + + Join_Bootcamp{" "} + + → + + +
+
+
+
+
+ ); +} diff --git a/sites/mainweb/app/projects/page.tsx b/sites/mainweb/app/projects/page.tsx index f2d47991..4e2f8282 100644 --- a/sites/mainweb/app/projects/page.tsx +++ b/sites/mainweb/app/projects/page.tsx @@ -1,206 +1,10 @@ -"use client"; +import { loadClubProjects } from "@/lib/club-projects.server"; +import ProjectsPageClient from "./ProjectsPageClient"; -import { useState, useEffect } from "react"; -import Link from "next/link"; -import Navbar from "@/components/Navbar"; -import Footer from "@/components/Footer"; +export const revalidate = 300; -interface Project { - name: string; - lead: string; - description: string; - tech: string[]; - category: "Deep Learning" | "Finance" | "Sports" | "General DS"; -} - -const projects: Project[] = [ - { - name: "Deep Learning Playground", - lead: "Noah Iversen", - category: "Deep Learning", - description: - "An interactive web application designed to demystify neural network training. At its core, the project allows users to visualize backpropagation and architecture tweaks in real-time.", - tech: ["AWS", "Docker", "PyTorch", "TypeScript", "NextJs", "Django"], - }, - { - name: "AI-Driven Investment Platform", - lead: "Aryan Hazra", - category: "Finance", - description: - "Using NLP to conversationally help investors reach goals. It adapts strategies based on client information rather than static robo-investing inputs.", - tech: ["NLP", "Machine Learning", "Python", "Data Analytics"], - }, - { - name: "Furnichanter", - lead: "Jane Ivanova", - category: "Deep Learning", - description: - "Seamlessly combining computer vision with interior design. Users can search for furniture via images and generate custom 3D models using text descriptions.", - tech: ["Deep Learning", "3D Modeling", "Python", "Computer Vision"], - }, - { - name: "Kaggle CLEF", - lead: "Anthony Miyaguchi", - category: "General DS", - description: - "A seminar-styled introduction to data science competitions. Members build ML systems for real-world problems like the CLEF 2025 competition.", - tech: [ - "Python", - "Machine Learning", - "Data Science", - "Algorithmic Development", - ], - }, - { - name: "Sports Analysis Project", - lead: "Casper Guo", - category: "Sports", - description: - "Open-ended sports research. Projects include projecting NFL performance, building 'perfect' NBA rosters, and exploiting betting odds differences.", - tech: [ - "Python", - "Machine Learning", - "Data Science", - "Statistical Modeling", - ], - }, -]; - -export default function ProjectsPage() { - const [windowWidth, setWindowWidth] = useState( - typeof window !== "undefined" ? window.innerWidth : 1024, - ); - const categories: Project["category"][] = [ - "Deep Learning", - "Finance", - "Sports", - "General DS", - ]; - - useEffect(() => { - const handleResize = () => setWindowWidth(window.innerWidth); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); - - return ( -
- -
- {/* BREADCRUMB NAVIGATION */} - - - {/* HERO SECTION */} -
-

- Project
- - Archive. - -

-

- A technical directory of past engineering ventures led by DSGT - members. Organized by domain expertise and technical stack. -

-
- - {/* ORGANIZED CATEGORIES */} -
- {categories.map((cat) => ( -
-
-

- {cat} -

-
-
- -
- {projects - .filter((p) => p.category === cat) - .map((project, i) => ( -
- {/* Subtle background glow on hover */} -
- -
-

- {project.name} -

- - Lead // {project.lead.split(" ").pop()?.toUpperCase()} - -
- -

- {project.description} -

- -
- {project.tech.map((t, index) => ( - - {t} - - ))} -
-
- ))} -
-
- ))} -
+export default async function ProjectsPage() { + const projects = await loadClubProjects(); - {/* NAVIGATION FOOTER */} -
-

- Back to the present? -

-
- - - ← - {" "} - Return_Home - - - Join_Bootcamp{" "} - - → - - -
-
-
-
-
- ); + return ; } diff --git a/sites/mainweb/components/portal/PortalSidebar.tsx b/sites/mainweb/components/portal/PortalSidebar.tsx index d9355497..3af7becc 100644 --- a/sites/mainweb/components/portal/PortalSidebar.tsx +++ b/sites/mainweb/components/portal/PortalSidebar.tsx @@ -105,7 +105,7 @@ export default function PortalSidebar({ { name: "Bootcamp", // Not /bootcamp — the public curriculum page owns that path. Shown to - // everyone, like Initiatives, so the add-on can be seen before paying. + // everyone, like Projects, so the add-on can be seen before paying. href: "/club/bootcamp", icon: GraduationCap, show: !portalContext?.isAdmin, @@ -119,7 +119,7 @@ export default function PortalSidebar({ show: portalContext?.isJudge && !portalContext?.isAdmin, }, { - name: "Initiatives", + name: "Projects", href: "/initiatives", icon: Rocket, // Somebody deciding whether to pay should be able to see what membership @@ -127,7 +127,7 @@ export default function PortalSidebar({ show: !portalContext?.isAdmin, }, { - name: "My Initiatives", + name: "My Projects", href: "/lead", icon: Rocket, show: portalContext?.isProjectLeader && !portalContext?.isAdmin, @@ -148,10 +148,10 @@ export default function PortalSidebar({ // page is reachable by typed URL alone, which means judging never starts. { name: "Judging Setup", href: "/admin/setup", icon: Upload }, { name: "Projects", href: "/admin/projects", icon: FolderGit2 }, - { name: "Initiatives", href: "/admin/initiatives", icon: Rocket }, - // /lead is the only screen that decides an initiative application, and + { name: "Projects", href: "/admin/initiatives", icon: Rocket }, + // /lead is the only screen that decides a project application, and // isProjectLeader admits admins so an absent leader cannot strand theirs. - { name: "Initiative Applications", href: "/lead", icon: Rocket }, + { name: "Project Applications", href: "/lead", icon: Rocket }, { name: "Bootcamp", href: "/admin/bootcamp", icon: GraduationCap }, { name: "Attendees", href: "/admin/attendees", icon: Users }, { name: "Memberships", href: "/admin/members", icon: CreditCard }, diff --git a/sites/mainweb/lib/club-projects.server.ts b/sites/mainweb/lib/club-projects.server.ts new file mode 100644 index 00000000..cc656456 --- /dev/null +++ b/sites/mainweb/lib/club-projects.server.ts @@ -0,0 +1,37 @@ +import { db, clubProjects } from "@query/db"; +import { eq } from "drizzle-orm"; +import type { ClubProjectCard } from "./club-projects"; + +// Read on the server, not through tRPC: the tRPC provider is mounted only +// inside the (portal) route group and this audience is signed out. +export async function loadClubProjects(): Promise { + if (!db) return []; + + try { + return await db.query.clubProjects.findMany({ + where: eq(clubProjects.isPublished, true), + orderBy: (project, { asc }) => [ + asc(project.sortOrder), + asc(project.name), + ], + columns: { + id: true, + slug: true, + name: true, + status: true, + leadName: true, + summary: true, + tech: true, + repoUrl: true, + joinUrl: true, + capacityNote: true, + term: true, + initiativeId: true, + sortOrder: true, + }, + }); + } catch (error) { + console.error("Failed to load club projects", error); + return []; + } +} diff --git a/sites/mainweb/lib/club-projects.test.ts b/sites/mainweb/lib/club-projects.test.ts new file mode 100644 index 00000000..5349bd4f --- /dev/null +++ b/sites/mainweb/lib/club-projects.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { + INTEREST_FORM_URL, + STATUS_CLASSES, + STATUS_LABELS, + byDisplayOrder, + groupClubProjects, + isExternalJoin, + joinHref, + joinLabel, + type ClubProjectCard, +} from "./club-projects"; + +const card = (overrides: Partial): ClubProjectCard => ({ + id: "id", + slug: "slug", + name: "Project", + status: "active", + leadName: null, + summary: "Summary", + tech: [], + repoUrl: null, + joinUrl: null, + capacityNote: null, + term: "Fall 2026", + initiativeId: null, + sortOrder: 0, + ...overrides, +}); + +describe("groupClubProjects", () => { + it("keeps past projects out of the current roster", () => { + const { current, past } = groupClubProjects([ + card({ id: "a", name: "Active", status: "active" }), + card({ id: "b", name: "Old", status: "past" }), + card({ id: "c", name: "Reviving", status: "revived" }), + card({ id: "d", name: "Unclaimed", status: "needs_lead" }), + ]); + + expect(current.map((p) => p.id)).toEqual(["a", "c", "d"]); + expect(past.map((p) => p.id)).toEqual(["b"]); + }); + + it("orders by sortOrder, then name", () => { + const projects = [ + card({ id: "z", name: "Zebra", sortOrder: 10 }), + card({ id: "a", name: "Apple", sortOrder: 10 }), + card({ id: "f", name: "First", sortOrder: 5 }), + ]; + + expect(groupClubProjects(projects).current.map((p) => p.id)).toEqual([ + "f", + "a", + "z", + ]); + }); + + it("sorts deterministically when called twice on the same data", () => { + const projects = [ + card({ id: "b", name: "B", sortOrder: 1 }), + card({ id: "a", name: "A", sortOrder: 1 }), + ]; + + const first = groupClubProjects([...projects]).current.map((p) => p.id); + const second = groupClubProjects([...projects]).current.map((p) => p.id); + expect(first).toEqual(second); + }); + + it("returns empty groups for an empty roster", () => { + expect(groupClubProjects([])).toEqual({ current: [], past: [] }); + }); +}); + +describe("byDisplayOrder", () => { + it("breaks sortOrder ties on name", () => { + expect( + byDisplayOrder( + card({ name: "A", sortOrder: 1 }), + card({ name: "B", sortOrder: 1 }), + ), + ).toBeLessThan(0); + }); +}); + +describe("joinHref", () => { + it("sends a project with an initiative into the portal", () => { + expect(joinHref(card({ initiativeId: "abc" }))).toBe("/initiatives"); + }); + + it("prefers the portal over an external link when both exist", () => { + expect( + joinHref(card({ initiativeId: "abc", joinUrl: "https://example.org" })), + ).toBe("/initiatives"); + }); + + it("uses the project's own link when there is no initiative", () => { + expect(joinHref(card({ joinUrl: "https://dsgt-arc.org/join" }))).toBe( + "https://dsgt-arc.org/join", + ); + }); + + it("never leaves a card without somewhere to go", () => { + expect(joinHref(card({}))).toBe(INTEREST_FORM_URL); + }); +}); + +describe("isExternalJoin", () => { + it("is true only for off-site destinations", () => { + expect(isExternalJoin(card({ initiativeId: "abc" }))).toBe(false); + expect(isExternalJoin(card({ joinUrl: "https://dsgt-arc.org/join" }))).toBe( + true, + ); + expect(isExternalJoin(card({}))).toBe(true); + }); +}); + +describe("joinLabel", () => { + it("asks for a lead when the project has none", () => { + expect(joinLabel(card({ status: "needs_lead" }))).toBe("Lead this project"); + }); + + it("says apply when there is an initiative to apply to", () => { + expect(joinLabel(card({ initiativeId: "abc" }))).toBe("Apply to join"); + }); + + it("falls back to interest for an external project", () => { + expect(joinLabel(card({ joinUrl: "https://dsgt-arc.org/join" }))).toBe( + "Express interest", + ); + }); +}); + +describe("status presentation", () => { + it("labels and colours every status", () => { + for (const status of Object.keys(STATUS_LABELS)) { + expect(STATUS_LABELS[status as keyof typeof STATUS_LABELS]).toBeTruthy(); + expect( + STATUS_CLASSES[status as keyof typeof STATUS_CLASSES], + ).toBeTruthy(); + } + }); + + it("does not present past projects in the active colour", () => { + expect(STATUS_CLASSES.past).not.toBe(STATUS_CLASSES.active); + }); +}); diff --git a/sites/mainweb/lib/club-projects.ts b/sites/mainweb/lib/club-projects.ts new file mode 100644 index 00000000..895d4e22 --- /dev/null +++ b/sites/mainweb/lib/club-projects.ts @@ -0,0 +1,60 @@ +import type { ClubProjectStatus } from "@query/db"; + +export interface ClubProjectCard { + id: string; + slug: string; + name: string; + status: ClubProjectStatus; + leadName: string | null; + summary: string; + tech: string[]; + repoUrl: string | null; + joinUrl: string | null; + capacityNote: string | null; + term: string | null; + initiativeId: string | null; + sortOrder: number; +} + +export const INTEREST_FORM_URL = "https://forms.gle/Lgoia8m3sAP9XgpB9"; + +export const STATUS_LABELS: Record = { + active: "Active", + revived: "Reviving", + needs_lead: "Needs a lead", + past: "Past", +}; + +export const STATUS_CLASSES: Record = { + active: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", + revived: "bg-[#00A8A8]/10 text-[#00A8A8] border-[#00A8A8]/20", + needs_lead: "bg-amber-500/10 text-amber-400 border-amber-500/20", + past: "bg-white/5 text-gray-500 border-white/10", +}; + +export const isCurrent = (project: { status: ClubProjectStatus }) => + project.status !== "past"; + +export const byDisplayOrder = (a: ClubProjectCard, b: ClubProjectCard) => + a.sortOrder - b.sortOrder || a.name.localeCompare(b.name); + +export const groupClubProjects = (projects: ClubProjectCard[]) => ({ + current: projects.filter(isCurrent).sort(byDisplayOrder), + past: projects.filter((p) => !isCurrent(p)).sort(byDisplayOrder), +}); + +// Applying happens in the portal. Without an initiative, fall back to the +// project's own link, then the interest form. +export const joinHref = (project: ClubProjectCard) => + project.initiativeId + ? "/initiatives" + : (project.joinUrl ?? INTEREST_FORM_URL); + +export const joinLabel = (project: ClubProjectCard) => { + if (project.status === "needs_lead") return "Lead this project"; + if (project.initiativeId) return "Apply to join"; + return "Express interest"; +}; + +export const isExternalJoin = (project: ClubProjectCard) => + joinHref(project).startsWith("http"); From 372dc0aa8125734f982e661feec96d429fae6a44 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Mon, 31 Aug 2026 02:26:45 -0400 Subject: [PATCH 2/3] ok --- docs/club-project.md | 8 +- docs/glossary.md | 6 +- .../.internal-tests/initiative-edge.test.ts | 13 ++- packages/api/src/routers/initiative.ts | 98 ++++++++++++++++- packages/db/src/schemas/initiatives.ts | 7 ++ .../app/(portal)/club/bootcamp/page.tsx | 22 +++- .../mainweb/app/(portal)/initiatives/page.tsx | 59 +++++++++- sites/mainweb/app/(portal)/lead/[id]/page.tsx | 35 ++++++ sites/mainweb/app/bootcamp/page.tsx | 72 +++++++----- .../components/portal/PortalSidebar.tsx | 103 ------------------ sites/mainweb/lib/bootcamp-schedule.ts | 72 ++---------- sites/mainweb/lib/portal-nav.test.ts | 23 ++-- sites/mainweb/lib/portal-nav.ts | 8 +- 13 files changed, 289 insertions(+), 237 deletions(-) diff --git a/docs/club-project.md b/docs/club-project.md index 2a6e5819..1d0e5e12 100644 --- a/docs/club-project.md +++ b/docs/club-project.md @@ -8,7 +8,7 @@ Aamogh Sawant ([@aamoghS](https://github.com/aamoghS)), club President, owns and ## What this is -The public website visitors see, and the signed-in portal members use to join the club, pay dues, check in at events, follow bootcamp, apply to initiatives, and handle Hacklytics interest and registration. +The public website visitors see, and the signed-in portal members use to join the club, pay dues, check in at events, follow bootcamp, apply to club projects, and handle Hacklytics interest and registration. The public pages and the portal are one Next.js app (`sites/mainweb`). Signing in does not take you to a different hostname. @@ -33,7 +33,7 @@ After sign-in (Google, GitHub if configured, or email code): | Join / pay dues | Portal membership — Stripe **$25** annual membership, **$10** bootcamp add-on (on top of membership, not instead of it) | | Club events and check-in | Portal events / club pass | | Bootcamp (term-gated add-on) | `/club/bootcamp` and the public `/bootcamp` page | -| Initiatives (club projects you apply to) | `/initiatives` | +| Club projects (pitch + optional resume) | `/initiatives` | | Staff tools | `/admin` (appointed roles only; there is no public admin signup) | | Hacklytics interest and registration | Portal `/hacklytics` (the marketing site links here after login) | @@ -64,7 +64,7 @@ Route-level detail: [Main website](./sites/mainweb.md). Club vs hackathon vocabu | `join_url` | External destination for projects that recruit elsewhere (ARC) | | `is_published` | Pull a card off the site without deleting it | -Applying happens in the portal. A card with an `initiative_id` links to `/initiatives`, where a signed-in member can request to join; the initiative's leader accepts or declines from `/lead`. A card with no initiative falls back to `join_url`, then to the shared interest form. +Applying happens in the portal. A card with an `initiative_id` links to `/initiatives`, where a signed-in member says why they want to join and may attach a PDF resume; the leader reads both and accepts or declines from `/lead`. A card with no initiative falls back to `join_url`, then to the shared interest form. To reset the roster to the checked-in Fall 2026 list: @@ -99,7 +99,7 @@ High level only. Details live in the linked docs. | `packages/db` | Schema and membership rules | | `packages/ui` | Shared React components | -Club operations (membership, club events, bootcamp, initiatives) and hackathon editions share one database but are modeled as separate domains. Do not hang club tables off a hackathon row. +Club operations (membership, club events, bootcamp, club projects) and hackathon editions share one database but are modeled as separate domains. Do not hang club tables off a hackathon row. Setup, env, and first-admin bootstrap: [Getting started](./getting-started.md). How the pieces connect: [Architecture](./architecture.md). Index of the rest: [Documentation](./README.md). diff --git a/docs/glossary.md b/docs/glossary.md index 23be0649..3795f227 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -3,7 +3,7 @@ | Term | Meaning in this repo | | --- | --- | | **query** | This monorepo (`package.json` name). Not a search engine. | -| **Club** | Year-round DSGT operations: membership, club events, bootcamp, initiatives. Not keyed by hackathon. | +| **Club** | Year-round DSGT operations: membership, club events, bootcamp, club projects. Not keyed by hackathon. | | **Hackathon / edition** | One `hackathon` row (e.g. Hacklytics 2027) and everything that cascades from it. | | **Hacklytics** | DSGT’s annual data-science hackathon. Marketing site is `sites/hacklytics2027`; operations are the portal. | | **Portal** | Authenticated product UI inside `sites/mainweb` route group `(portal)`. | @@ -11,8 +11,8 @@ | **Pass** | `member.pass_code` — rotatable QR for club check-in. Independent of membership dates. | | **Volunteer** | Weakest `admin.role`. Can scan badges (`isScanner`). Cannot pass `isAdmin`. | | **Staff** | Active admin whose role is not `volunteer`. | -| **Project leader** | `project_leader` row. Runs club **initiatives**. Not a staff role. | -| **Initiative** | Club project members apply to join. Never judged. Distinct from a hackathon **project**. | +| **Project leader** | `project_leader` row. Runs **club projects**. Not a staff role. | +| **Club project** | `initiative` row. Members apply to join with a pitch and an optional resume. Never judged. Distinct from a hackathon **project**, which is a judged submission. The UI says "club project"; the table is still `initiative`. | | **Hackathon project** | Team/solo submission (`hackathon_project`). Promoted into `judging_project` for scoring. | | **Interest** | “Tell me when registration opens” (`hackathon_interest`). Requires a signed-in user. | | **Current edition** | In-progress hackathon if one exists; otherwise the newest edition that is not `draft` or `announced`. | diff --git a/packages/api/src/.internal-tests/initiative-edge.test.ts b/packages/api/src/.internal-tests/initiative-edge.test.ts index 871aff0c..e0824842 100644 --- a/packages/api/src/.internal-tests/initiative-edge.test.ts +++ b/packages/api/src/.internal-tests/initiative-edge.test.ts @@ -387,7 +387,7 @@ describe("Club initiatives", () => { }); await expect( - callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }), ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); @@ -401,7 +401,7 @@ describe("Club initiatives", () => { }); await expect( - callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }), ).rejects.toMatchObject({ code: "NOT_FOUND" }); }, ); @@ -413,7 +413,7 @@ describe("Club initiatives", () => { }); await expect( - callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }), ).rejects.toMatchObject({ code: "NOT_FOUND" }); }); @@ -421,7 +421,7 @@ describe("Club initiatives", () => { lookups({ initiative: openInitiative(), member: activeMember }); await expect( - callerFor(LEADER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + callerFor(LEADER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }), ).rejects.toMatchObject({ code: "BAD_REQUEST" }); }); @@ -433,7 +433,7 @@ describe("Club initiatives", () => { onSelect = (t) => (t === initiativeApplications ? [{ taken: 3 }] : []); await expect( - callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }), ).rejects.toMatchObject({ code: "BAD_REQUEST" }); }); @@ -445,7 +445,7 @@ describe("Club initiatives", () => { }); await expect( - callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE }), + callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, pitch: "Why me" }), ).rejects.toMatchObject({ code: "CONFLICT" }); }); @@ -460,6 +460,7 @@ describe("Club initiatives", () => { const res = await callerFor(MEMBER).initiative.requestToJoin({ initiativeId: INITIATIVE, + pitch: "Why me", }); expect(res.status).toBe("pending"); diff --git a/packages/api/src/routers/initiative.ts b/packages/api/src/routers/initiative.ts index a80001dd..6043d302 100644 --- a/packages/api/src/routers/initiative.ts +++ b/packages/api/src/routers/initiative.ts @@ -9,8 +9,12 @@ import { users, } from "@query/db"; import type { DrizzleDB, Initiative } from "@query/db"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { isAdmin, isSuperAdmin, isProjectLeader } from "../middleware/procedures"; +import { createTRPCRouter, protectedProcedure, uploadProcedure } from "../trpc"; +import { + isAdmin, + isSuperAdmin, + isProjectLeader, +} from "../middleware/procedures"; import { clearProjectLeaderCaches } from "../middleware/cache"; const notFound = (message = "Project not found") => @@ -36,6 +40,32 @@ type Reader = DrizzleDB | Tx; // names no cap; an explicit null still means uncapped. const DEFAULT_TEAM_SIZE = 4; +// A resume is a PDF, small enough to sit in a row. `uploadProcedure` already +// caps the whole payload at 2 MB. +const resumeInput = z.object({ + fileName: z.string().trim().min(1).max(200), + dataUrl: z + .string() + .regex( + /^data:application\/pdf;base64,[A-Za-z0-9+/]+={0,2}$/, + "Your resume must be a PDF.", + ) + .max(2 * 1024 * 1024), +}); + +/** The declared type is not the file's; check the signature before storing. */ +function assertPdf(dataUrl: string) { + const base64 = dataUrl.split(",")[1] ?? ""; + const header = Buffer.from(base64.slice(0, 8), "base64").toString("latin1"); + if (!header.startsWith("%PDF-")) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "That file is not a PDF.", + }); + } + return dataUrl; +} + const initiativeInput = z.object({ title: z.string().trim().min(1).max(200), summary: z.string().trim().max(300).optional(), @@ -165,7 +195,10 @@ export const initiativeRouter = createTRPCRouter({ initiativeApplications.status, ); - const byInitiative = new Map(); + const byInitiative = new Map< + string, + { pending: number; accepted: number } + >(); for (const tally of tallies) { const entry = byInitiative.get(tally.initiativeId) ?? { pending: 0, @@ -202,6 +235,7 @@ export const initiativeRouter = createTRPCRouter({ image: users.image, status: initiativeApplications.status, pitch: initiativeApplications.pitch, + resumeFileName: initiativeApplications.resumeFileName, appliedAt: initiativeApplications.appliedAt, decidedAt: initiativeApplications.decidedAt, }) @@ -384,6 +418,42 @@ export const initiativeRouter = createTRPCRouter({ // Reversible both ways: a rejection can be taken back, an acceptance revoked // and the seat returned. The one refused transition is deciding on somebody // who withdrew. + // Separate from getById so a queue of thirty applicants does not ship thirty + // PDFs to render a list. + applicantResume: isProjectLeader + .input( + z.object({ + initiativeId: z.string().uuid(), + userId: z.string().min(1), + }), + ) + .query(async ({ ctx, input }) => { + const db = ctx.db as DrizzleDB; + + const initiative = await db.query.initiatives.findFirst({ + where: eq(initiatives.id, input.initiativeId), + }); + if (!initiative || !canManage(ctx, initiative)) throw notFound(); + + const [row] = await db + .select({ + fileName: initiativeApplications.resumeFileName, + dataUrl: initiativeApplications.resumeData, + }) + .from(initiativeApplications) + .where( + and( + eq(initiativeApplications.initiativeId, input.initiativeId), + eq(initiativeApplications.userId, input.userId), + ), + ) + .limit(1); + + if (!row?.dataUrl) throw notFound("No resume on this application."); + + return { fileName: row.fileName ?? "resume.pdf", dataUrl: row.dataUrl }; + }), + decide: isProjectLeader .input( z.object({ @@ -610,17 +680,31 @@ export const initiativeRouter = createTRPCRouter({ // Not `apply`: tRPC refuses a procedure named after anything on // Function.prototype and throws at router construction, taking the whole API // route down rather than just this procedure. - requestToJoin: protectedProcedure + requestToJoin: uploadProcedure .input( z.object({ initiativeId: z.string().uuid(), - pitch: z.string().trim().max(1000).optional(), + pitch: z + .string() + .trim() + .min(1, "Tell the leader why you want to join.") + .max(1000), + resume: resumeInput.optional(), }), ) .mutation(async ({ ctx, input }) => { const db = ctx.db as DrizzleDB; const userId = ctx.userId; - const pitch = input.pitch?.length ? input.pitch : null; + const pitch = input.pitch; + // Absent means "leave whatever is on file", so re-applying does not wipe + // a resume the applicant already sent. + const resume = input.resume + ? { + resumeFileName: input.resume.fileName, + resumeData: assertPdf(input.resume.dataUrl), + resumeUploadedAt: new Date(), + } + : {}; return db.transaction(async (tx) => { // Lock BEFORE reading, so every guard sees the row as it is now — otherwise a @@ -700,6 +784,7 @@ export const initiativeRouter = createTRPCRouter({ .set({ status: "pending", pitch, + ...resume, appliedAt: new Date(), decidedAt: null, decidedById: null, @@ -713,6 +798,7 @@ export const initiativeRouter = createTRPCRouter({ initiativeId: initiative.id, userId, pitch, + ...resume, status: "pending", }); } catch (error) { diff --git a/packages/db/src/schemas/initiatives.ts b/packages/db/src/schemas/initiatives.ts index 8ba32acf..0aa91f94 100644 --- a/packages/db/src/schemas/initiatives.ts +++ b/packages/db/src/schemas/initiatives.ts @@ -127,7 +127,14 @@ export const initiativeApplications = pgTable( status: text("status", { enum: applicationStatuses }) .notNull() .default("pending"), + /** Why they want to join, in their words. */ pitch: text("pitch"), + // The resume as a data URL. No object storage exists here — profile images + // already live in the database the same way — so it is capped at 2 MB of + // PDF and never selected by the queue query, only by applicantResume. + resumeFileName: text("resume_file_name"), + resumeData: text("resume_data"), + resumeUploadedAt: timestamp("resume_uploaded_at"), // Re-stamped on re-apply, so the leader's queue is ordered by when the hand // actually went up. appliedAt: timestamp("applied_at").defaultNow().notNull(), diff --git a/sites/mainweb/app/(portal)/club/bootcamp/page.tsx b/sites/mainweb/app/(portal)/club/bootcamp/page.tsx index fc982c6c..71e3ac59 100644 --- a/sites/mainweb/app/(portal)/club/bootcamp/page.tsx +++ b/sites/mainweb/app/(portal)/club/bootcamp/page.tsx @@ -16,6 +16,7 @@ import { BOOTCAMP_CURRICULUM, BOOTCAMP_MEETING_TIME, BOOTCAMP_ROOM, + BOOTCAMP_START_DATE, BOOTCAMP_WORKSPACE_URL, } from "@/lib/bootcamp-schedule"; import { @@ -64,6 +65,12 @@ function WorkInProgress({ term }: { term: string }) { up here once they are set, and you will hear from us before the first meeting.

+ {BOOTCAMP_START_DATE && ( +

+ + First session {BOOTCAMP_START_DATE} +

+ )}

{BOOTCAMP_ROOM ?? "Room to be announced"} @@ -103,8 +110,7 @@ function NotEnrolled({ term }: { term: string }) {

Twelve weeks of Python and data science, taught in person, with the - notebooks to keep. It runs for one semester, so joining covers this - term + notebooks to keep. It runs for one semester, so joining covers this term {isMember ? "" : ` — ${formatCents(BOOTCAMP_ADDON_CENTS)} on top of a membership (${formatCents(MEMBERSHIP_CENTS)} a year or ${formatCents(SEMESTER_MEMBERSHIP_CENTS)} a semester)`} @@ -174,7 +180,8 @@ export default function BootcampPortalPage() {

- {data ? termLabel(data.term) : ""} · Data Science at Georgia Tech + {data ? termLabel(data.term) : ""} · Data Science at Georgia + Tech

@@ -209,9 +216,16 @@ export default function BootcampPortalPage() {

- The twelve weeks + Syllabus

+ {weeks.length === 0 && ( +

+ Updating soon — the week-by-week syllabus is being written and + will appear here before the first session. +

+ )} +
    {weeks.map((entry) => (
  1. (null); + const [resumeError, setResumeError] = useState(null); + const join = trpc.initiative.requestToJoin.useMutation({ onSuccess: async () => { setWriting(false); setPitch(""); + setResume(null); await refresh(); }, }); + // 2 MB is the server's whole-payload cap; catching it here saves a round + // trip and says which file was too big. + const readResume = (file: File | undefined) => { + setResumeError(null); + if (!file) return setResume(null); + if (file.type !== "application/pdf") { + return setResumeError("Your resume must be a PDF."); + } + if (file.size > 1.4 * 1024 * 1024) { + return setResumeError("That PDF is over 1.4 MB. Please compress it."); + } + const reader = new FileReader(); + reader.onload = () => + setResume({ fileName: file.name, dataUrl: String(reader.result) }); + reader.onerror = () => setResumeError("Could not read that file."); + reader.readAsDataURL(file); + }; + return (
    @@ -255,7 +280,8 @@ function OpenRow({ event.preventDefault(); join.mutate({ initiativeId: initiative.id, - pitch: pitch.trim() || undefined, + pitch: pitch.trim(), + resume: resume ?? undefined, }); }} > @@ -263,22 +289,47 @@ function OpenRow({ htmlFor={`pitch-${initiative.id}`} className="text-xs font-semibold uppercase tracking-wide text-white/50" > - Anything the leader should know + Why do you want to join?