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"
/>
+ {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) => (
(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?