From 593cce01624a2d2b3077904522ab8b1ebbb79c1a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:34:56 +0000 Subject: [PATCH] Multi-chapter board hub: UCLA at /board/ucla, UCLA submission emails - Every board table gets a chapter column; existing rows are Berkeley's. All reads and writes are scoped to the signed-in user's chapter. - Sign-in per chapter: own cookie, own passwords (BOARD_UCLA_ADMIN_PASSWORD, BOARD_UCLA_PASSWORD). Neither chapter's passwords open the other hub. Existing Berkeley sessions stay signed in. - Chapter config (lib/board/chapters.ts) drives teams, labels, Projects, quick links, key dates and the rule; server-only config holds password variables and submission recipients. - UCLA hub seeded from the prototype: Oct 11 deliverables due Oct 18 with no proof, the Oct 4 week archived without status, events, announcements (Finances admins only), meetings with attendance, and six board photos. - Weeks: admins change the due date, set each person's status (Not submitted, Submitted, Missing, Excused) and start a new week, which carries over anything not submitted or excused. - UCLA submissions go to Fiona and Will, Cc Rana, plus Rizwaan for App Dev and Data; reply-to is the submitter, asked for when none is on file. - My page for both chapters, with a Viewing as picker kept in the browser. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QzNDQSmhjgKE63rwUnwcMq --- .env.example | 8 +- .gitignore | 2 +- BOARD_SETUP.md | 51 +- app/api/board/[table]/[id]/route.ts | 13 +- app/api/board/[table]/route.ts | 8 +- app/api/board/import-members/route.ts | 21 +- app/api/board/login/route.ts | 16 +- app/api/board/logout/route.ts | 6 +- app/api/board/person-status/route.ts | 23 + app/api/board/submit/route.ts | 54 +- app/api/board/week/route.ts | 61 ++ app/board/board.css | 97 +++ app/board/page.tsx | 37 +- app/board/ucla/page.tsx | 8 + components/board/AssignmentsTab.tsx | 345 +++++---- components/board/BoardApp.tsx | 27 +- components/board/BoardLogin.tsx | 11 +- components/board/BoardTab.tsx | 25 +- components/board/ChapterPage.tsx | 39 ++ components/board/HomeTab.tsx | 41 +- components/board/ImportMembers.tsx | 16 +- components/board/MeetingsTab.tsx | 23 +- components/board/MyPage.tsx | 206 ++++++ components/board/ProjectsTab.tsx | 61 +- components/board/SocialsTab.tsx | 28 +- components/board/SubmitForm.tsx | 39 +- components/board/derive.ts | 25 +- components/board/util.ts | 30 +- docs/board-ucla-preview.html | 971 ++++++++++++++++++++++++++ docs/vindicated-invite-preview.html | 136 ++++ lib/board/api.ts | 16 +- lib/board/chapter-server.ts | 71 ++ lib/board/chapters.ts | 235 +++++++ lib/board/data.ts | 34 +- lib/board/editable.ts | 16 +- lib/board/roles.ts | 27 +- lib/board/session.ts | 70 +- lib/board/setup.ts | 86 ++- lib/board/starter-ucla.json | 267 +++++++ lib/board/static.ts | 13 +- lib/board/submit-rules.ts | 3 +- lib/board/tables.ts | 24 +- lib/board/types.ts | 19 +- public/board/ucla/board-1.webp | Bin 0 -> 22104 bytes public/board/ucla/board-2.webp | Bin 0 -> 24146 bytes public/board/ucla/board-3.webp | Bin 0 -> 19204 bytes public/board/ucla/board-4.webp | Bin 0 -> 12396 bytes public/board/ucla/board-5.webp | Bin 0 -> 16750 bytes public/board/ucla/board-6.webp | Bin 0 -> 27124 bytes scripts/board-schema.sql | 26 +- 50 files changed, 2884 insertions(+), 451 deletions(-) create mode 100644 app/api/board/person-status/route.ts create mode 100644 app/api/board/week/route.ts create mode 100644 app/board/ucla/page.tsx create mode 100644 components/board/ChapterPage.tsx create mode 100644 components/board/MyPage.tsx create mode 100644 docs/board-ucla-preview.html create mode 100644 docs/vindicated-invite-preview.html create mode 100644 lib/board/chapter-server.ts create mode 100644 lib/board/chapters.ts create mode 100644 lib/board/starter-ucla.json create mode 100644 public/board/ucla/board-1.webp create mode 100644 public/board/ucla/board-2.webp create mode 100644 public/board/ucla/board-3.webp create mode 100644 public/board/ucla/board-4.webp create mode 100644 public/board/ucla/board-5.webp create mode 100644 public/board/ucla/board-6.webp diff --git a/.env.example b/.env.example index 7ba3cd7..457d54e 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,12 @@ DATABASE_URL= BOARD_PASSWORD= BOARD_ADMIN_PASSWORD= BOARD_SOCIALS_PASSWORD= -# Signs the /board login cookie. Any random string of 32+ characters, +# Passwords for /board/ucla (UCLA's hub). Fiona and Will get +# BOARD_UCLA_ADMIN_PASSWORD (admin view); everyone else on the UCLA board +# gets BOARD_UCLA_PASSWORD. Use values different from Berkeley's; neither +# chapter's passwords open the other's hub. +BOARD_UCLA_ADMIN_PASSWORD= +BOARD_UCLA_PASSWORD= +# Signs the board hub login cookies (both chapters). Any random string of 32+ characters, # e.g. the output of: openssl rand -base64 32 BOARD_SESSION_SECRET= diff --git a/.gitignore b/.gitignore index 89840fb..069010f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,4 @@ yarn-error.log* next-env.d.ts # /board member list with phone numbers. Never commit. -scripts/board-members.local.json +scripts/*.local.json diff --git a/BOARD_SETUP.md b/BOARD_SETUP.md index 53dfb39..f1c1a8b 100644 --- a/BOARD_SETUP.md +++ b/BOARD_SETUP.md @@ -1,11 +1,19 @@ -# Setting up /board +# Setting up the board hubs -`/board` is the UC Berkeley chapter's board hub. It isn't linked from the -site and tells search engines not to index it. To see anything, people -need a password. +Each chapter has its own board hub: UC Berkeley at `/board`, UCLA at +`/board/ucla`. They run the same code and share one database, where every +row belongs to a chapter. Neither hub is linked from the site, and both +tell search engines not to index them. To see anything, people need their +chapter's password; a Berkeley password never opens UCLA's hub, or the +reverse. + +Per-chapter settings (teams, labels, Projects content, who submissions are +emailed to) are in `lib/board/chapters.ts` and `lib/board/chapter-server.ts`. ## Passwords +### Berkeley (`/board`) + | Railway variable | Who gets it | What it unlocks | | ------------------------ | -------------------- | --------------------------------------- | | `BOARD_PASSWORD` | everyone on the board | view everything, submit assignments | @@ -18,7 +26,28 @@ Use different passwords for each role. People stay signed in on a device for 30 days. To remove someone's access, change the password they used; that signs out everyone who used it, and you share the new one with the rest. -Ten wrong tries from the same connection lock the form for 15 minutes. +### UCLA (`/board/ucla`) + +| Railway variable | Who gets it | What it unlocks | +| --------------------------- | ----------------------- | ------------------------------------- | +| `BOARD_UCLA_PASSWORD` | everyone on the UCLA board | view everything, submit deliverables | +| `BOARD_UCLA_ADMIN_PASSWORD` | Fiona and Will | edit everything on the UCLA hub | + +Use passwords that differ from Berkeley's. + +Ten wrong tries from the same connection lock a hub's form for 15 minutes. + +## Submissions + +The Assignments tab's form emails each submission through Brevo, with +reply-to set to the submitter's email (members with none on file are asked +for one). + +- Berkeley: to Rana. +- UCLA: to Fiona and Will, Cc Rana. Submissions from the App Dev and Data + teams also go to Rizwaan. + +The recipient lists are in `lib/board/chapter-server.ts`. ## One-time setup in Railway @@ -27,15 +56,17 @@ Ten wrong tries from the same connection lock the form for 15 minutes. Postgres service's `DATABASE_URL` (Railway offers it as a reference). 2. **Passwords and secret.** In the same Variables screen, add: - `BOARD_PASSWORD`, `BOARD_SOCIALS_PASSWORD`, `BOARD_ADMIN_PASSWORD` + - `BOARD_UCLA_PASSWORD`, `BOARD_UCLA_ADMIN_PASSWORD` - `BOARD_SESSION_SECRET`: a random string of at least 32 characters. `openssl rand -base64 32` makes one. 3. Redeploy, open `https://www.getvindicated.org/board`, and sign in with the admin password. The board makes its own tables and fills in the starting assignments, socials, announcements and meetings the first time it loads. -4. **Members.** Open the **Board** tab and choose the - `board-members.local.json` file. It has phone numbers, so it is never - committed; Rana has the copy. +4. **Members.** On each hub, sign in with the admin password, open the + **Board** tab and choose the member file: `board-members.local.json` + for Berkeley, `board-members-ucla.local.json` for UCLA. They have + contact details, so they are never committed. Until the secret and at least one password are set, the page shows the password box and nothing gets through. @@ -49,9 +80,11 @@ DATABASE_URL=postgres://localhost:5432/board BOARD_PASSWORD=member-test BOARD_SOCIALS_PASSWORD=socials-test BOARD_ADMIN_PASSWORD=admin-test +BOARD_UCLA_PASSWORD= +BOARD_UCLA_ADMIN_PASSWORD= BOARD_SESSION_SECRET=any-random-string-at-least-32-characters-long ``` Then `pnpm dev`, and open -http://localhost:3000/board. Without `BREVO_API_KEY`, `pnpm dev` prints +http://localhost:3000/board or http://localhost:3000/board/ucla. Without `BREVO_API_KEY`, `pnpm dev` prints submission emails to the terminal instead of sending them. diff --git a/app/api/board/[table]/[id]/route.ts b/app/api/board/[table]/[id]/route.ts index daeee46..2c384a0 100644 --- a/app/api/board/[table]/[id]/route.ts +++ b/app/api/board/[table]/[id]/route.ts @@ -4,12 +4,13 @@ import { deleteRow, updateRow } from "@/lib/board/tables"; async function target(request: Request, ctx: { params: Promise<{ table: string; id: string }> }) { const { table, id: rawId } = await ctx.params; - const t = editable(table); const id = idFrom(rawId); - if (!t || !id) return notFound(); - const user = await guard(request, t.roles); + if (!id) return notFound(); + const user = await guard(request); if (user instanceof Response) return user; - return { t, id }; + const t = editable(table, user.chapter); + if (!t || !t.roles.includes(user.role)) return notFound(); + return { t, id, chapter: user.chapter }; } export async function PATCH(request: Request, ctx: { params: Promise<{ table: string; id: string }> }) { @@ -17,13 +18,13 @@ export async function PATCH(request: Request, ctx: { params: Promise<{ table: st if (x instanceof Response) return x; const body = await readJson(request); if (!body) return bad("Check the form."); - const r = await updateRow(x.t.def, x.id, body, derived(x.t.name, body)); + const r = await updateRow(x.t.def, x.chapter, x.id, body, derived(x.t.name, body)); return r.ok ? Response.json({ id: r.id }) : bad(r.error, r.status); } export async function DELETE(request: Request, ctx: { params: Promise<{ table: string; id: string }> }) { const x = await target(request, ctx); if (x instanceof Response) return x; - const r = await deleteRow(x.t.def, x.id); + const r = await deleteRow(x.t.def, x.chapter, x.id); return r.ok ? Response.json({ id: r.id }) : bad(r.error, r.status); } diff --git a/app/api/board/[table]/route.ts b/app/api/board/[table]/route.ts index b35421c..c3ed0e2 100644 --- a/app/api/board/[table]/route.ts +++ b/app/api/board/[table]/route.ts @@ -4,13 +4,13 @@ import { createRow } from "@/lib/board/tables"; export async function POST(request: Request, ctx: { params: Promise<{ table: string }> }) { const { table } = await ctx.params; - const t = editable(table); - if (!t) return notFound(); - const user = await guard(request, t.roles); + const user = await guard(request); if (user instanceof Response) return user; + const t = editable(table, user.chapter); + if (!t || !t.roles.includes(user.role)) return notFound(); const body = await readJson(request); if (!body) return bad("Check the form."); - const r = await createRow(t.def, body, derived(t.name, body)); + const r = await createRow(t.def, body, { ...derived(t.name, body), chapter: user.chapter }); return r.ok ? Response.json({ id: r.id }, { status: 201 }) : bad(r.error, r.status); } diff --git a/app/api/board/import-members/route.ts b/app/api/board/import-members/route.ts index 4478b30..bd7466d 100644 --- a/app/api/board/import-members/route.ts +++ b/app/api/board/import-members/route.ts @@ -1,7 +1,6 @@ import { bad, guard, readJson } from "@/lib/board/api"; import { db } from "@/lib/board/db"; - -const TEAMS = ["Executive", "Data", "Software", "Research & Legal", "Outreach"]; +import { chapterConfig, type ChapterId } from "@/lib/board/chapters"; type Row = { name: string; @@ -23,13 +22,13 @@ export async function POST(request: Request) { console.warn(`[board] Member import refused (${user.status}).`); return user; } - const res = await importMembers(request); - if (!res.ok) console.warn(`[board] Member import rejected (${res.status}).`); + const res = await importMembers(request, user.chapter); + if (!res.ok) console.warn(`[board] Member import (${user.chapter}) rejected (${res.status}).`); return res; } -async function importMembers(request: Request): Promise { - +async function importMembers(request: Request, chapter: ChapterId): Promise { + const TEAMS = ["Executive", ...chapterConfig(chapter).teams]; const body = await readJson(request); const list = body?.members; if (!Array.isArray(list) || list.length === 0) return bad("That file doesn't have a member list in it."); @@ -58,23 +57,23 @@ async function importMembers(request: Request): Promise { } const sql = db(); - const [{ n }] = await sql`SELECT count(*)::int AS n FROM board_members`; + const [{ n }] = await sql`SELECT count(*)::int AS n FROM board_members WHERE chapter = ${chapter}`; if (n > 0 && body?.replace !== true) { return bad("Members are already loaded. Replacing them would undo any edits made on the Board tab.", 409); } try { await sql.begin(async (tx) => { - await tx`DELETE FROM board_members`; + await tx`DELETE FROM board_members WHERE chapter = ${chapter}`; for (const [i, r] of rows.entries()) { - await tx`INSERT INTO board_members (name, first_name, role, team, also_team, tier, is_lead, email, phone, sort) - VALUES (${r.name}, ${r.name.split(/\s+/)[0]}, ${r.role}, ${r.team}, ${r.alsoTeam}, ${r.tier}, ${r.lead}, ${r.email}, ${r.phone}, ${i})`; + await tx`INSERT INTO board_members (chapter, name, first_name, role, team, also_team, tier, is_lead, email, phone, sort) + VALUES (${chapter}, ${r.name}, ${r.name.split(/\s+/)[0]}, ${r.role}, ${r.team}, ${r.alsoTeam}, ${r.tier}, ${r.lead}, ${r.email}, ${r.phone}, ${i})`; } }); } catch (e) { console.error("[board] Member import failed:", e); return bad("The members couldn't be saved. Try again; if it keeps happening, tell Claude.", 500); } - console.log(`[board] Imported ${rows.length} members.`); + console.log(`[board] Imported ${rows.length} ${chapter} members.`); return Response.json({ ok: true, count: rows.length }); } diff --git a/app/api/board/login/route.ts b/app/api/board/login/route.ts index 41486f6..6570726 100644 --- a/app/api/board/login/route.ts +++ b/app/api/board/login/route.ts @@ -1,4 +1,5 @@ import { createSession, roleForPassword, sessionCookie, setupProblem, SESSION_DAYS } from "@/lib/board/session"; +import { chapterOf } from "@/lib/board/api"; // Wrong-password limit per IP: 10 tries per 15 minutes. Kept in memory, // which is enough for one Railway instance. @@ -14,9 +15,14 @@ function clientIp(request: Request) { ); } +// Signs in to one chapter's hub (?chapter=ucla; Berkeley by default). Only +// that chapter's passwords are checked. export async function POST(request: Request) { + const chapter = chapterOf(request); + if (!chapter) return Response.json({ error: "Not found" }, { status: 404 }); + // Missing Railway variables shouldn't look like a wrong password. - const problem = setupProblem(); + const problem = setupProblem(chapter); if (problem) { console.error(`[board] Sign-in is off: ${problem} See BOARD_SETUP.md.`); return Response.json( @@ -25,7 +31,7 @@ export async function POST(request: Request) { ); } - const ip = clientIp(request); + const ip = `${chapter}:${clientIp(request)}`; const now = Date.now(); const rec = fails.get(ip); if (rec && now - rec.since > WINDOW_MS) fails.delete(ip); @@ -41,8 +47,8 @@ export async function POST(request: Request) { /* treated as a wrong password */ } - const role = password ? roleForPassword(password) : null; - const token = role ? createSession(role) : null; + const role = password ? roleForPassword(chapter, password) : null; + const token = role ? createSession(chapter, role) : null; if (!role || !token) { const r = fails.get(ip) ?? { n: 0, since: now }; fails.set(ip, { n: r.n + 1, since: r.since }); @@ -52,6 +58,6 @@ export async function POST(request: Request) { fails.delete(ip); return Response.json( { ok: true }, - { headers: { "set-cookie": sessionCookie(token, SESSION_DAYS * 86400) } }, + { headers: { "set-cookie": sessionCookie(chapter, token, SESSION_DAYS * 86400) } }, ); } diff --git a/app/api/board/logout/route.ts b/app/api/board/logout/route.ts index 4447bad..ca8b1c0 100644 --- a/app/api/board/logout/route.ts +++ b/app/api/board/logout/route.ts @@ -1,5 +1,7 @@ +import { chapterOf } from "@/lib/board/api"; import { sessionCookie } from "@/lib/board/session"; -export async function POST() { - return Response.json({ ok: true }, { headers: { "set-cookie": sessionCookie("", 0) } }); +export async function POST(request: Request) { + const chapter = chapterOf(request) ?? "berkeley"; + return Response.json({ ok: true }, { headers: { "set-cookie": sessionCookie(chapter, "", 0) } }); } diff --git a/app/api/board/person-status/route.ts b/app/api/board/person-status/route.ts new file mode 100644 index 0000000..9af07b5 --- /dev/null +++ b/app/api/board/person-status/route.ts @@ -0,0 +1,23 @@ +import { bad, guard, readJson } from "@/lib/board/api"; +import { chapterConfig } from "@/lib/board/chapters"; +import { db } from "@/lib/board/db"; + +const STATUSES = ["pending", "submitted", "missing", "excused"]; + +// Weekly deliverables (UCLA) have one status per person: setting it marks +// all of that person's tasks in the current week. Admins only. +export async function POST(request: Request) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + if (!chapterConfig(user.chapter).weekly) return bad("This chapter sets status per task.", 404); + const body = await readJson(request); + const person = typeof body?.person === "string" ? body.person.trim() : ""; + const status = typeof body?.status === "string" ? body.status : ""; + if (!person || !STATUSES.includes(status)) return bad("Choose a person and a status."); + const sql = db(); + const rows = await sql`UPDATE board_assignments SET status = ${status} + WHERE chapter = ${user.chapter} AND person = ${person} + AND round = (SELECT round FROM board_rounds WHERE chapter = ${user.chapter} AND is_current) + RETURNING id`; + return rows.length ? Response.json({ ok: true, count: rows.length }) : bad("That person has no tasks this week.", 404); +} diff --git a/app/api/board/submit/route.ts b/app/api/board/submit/route.ts index 1c831fc..3f57443 100644 --- a/app/api/board/submit/route.ts +++ b/app/api/board/submit/route.ts @@ -1,8 +1,9 @@ import { sendEmail } from "@/lib/brevo"; import { bad, guard } from "@/lib/board/api"; import { db } from "@/lib/board/db"; -import { ROLE_LABEL, SUBMISSIONS_INBOX } from "@/lib/board/roles"; -import { MAX_ATTACHMENT_BYTES, NEED_PROOF, OTHER_TASK, TOO_BIG, hasLink } from "@/lib/board/submit-rules"; +import { chapterConfig } from "@/lib/board/chapters"; +import { CHAPTER_SERVER, submissionRecipients } from "@/lib/board/chapter-server"; +import { MAX_ATTACHMENT_BYTES, OTHER_TASK, TOO_BIG, hasLink, isEmail } from "@/lib/board/submit-rules"; const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); @@ -16,6 +17,7 @@ async function deliver(mail: Mail) { console.log("[board] Email not sent (no BREVO_API_KEY in dev):", { subject: mail.subject, to: mail.to, + cc: mail.cc, replyTo: mail.replyTo, attachments: mail.attachment?.map((a) => `${a.name} (${Math.round((a.content.length * 3) / 4)} bytes)`), }); @@ -27,6 +29,8 @@ async function deliver(mail: Mail) { export async function POST(request: Request) { const user = await guard(request); if (user instanceof Response) return user; + const chapter = user.chapter; + const config = chapterConfig(chapter); let form: FormData; try { @@ -38,17 +42,27 @@ export async function POST(request: Request) { const assignment = String(form.get("assignment") ?? "").trim(); const text = String(form.get("text") ?? "").trim(); const files = form.getAll("files").filter((f): f is File => f instanceof File && f.size > 0); + const givenEmail = String(form.get("email") ?? "").trim(); if (!person) return bad("Choose your name before submitting."); if (!assignment) return bad("Choose the assignment."); if (text.length > 20000) return bad("The notes are too long. Put the full write-up in a doc and paste the link."); - if (files.length === 0 && !hasLink(text)) return bad(NEED_PROOF); + // Berkeley needs proof (a file or a link); UCLA needs an update or a file. + if (config.proofRequired ? files.length === 0 && !hasLink(text) : files.length === 0 && !text) { + return bad(config.submit.needProof); + } const total = files.reduce((n, f) => n + f.size, 0); if (total > MAX_ATTACHMENT_BYTES) return bad(TOO_BIG, 413); const sql = db(); - const [member] = await sql`SELECT first_name, name, email FROM board_members WHERE first_name = ${person} LIMIT 1`; + const [member] = await sql`SELECT first_name, name, email, team FROM board_members + WHERE chapter = ${chapter} AND first_name = ${person} LIMIT 1`; if (!member) return bad("Choose your name from the list."); + // Everyone shares a password, so reply-to is the email on file for the + // name they picked. With none on file the form asks for one, used only + // for this email. + const replyEmail = isEmail(member.email) ? member.email : isEmail(givenEmail) ? givenEmail : null; + if (!replyEmail) return bad("Add your email so they can reply to you."); let assignmentId: number | null = null; let taskLabel = OTHER_TASK; @@ -56,7 +70,8 @@ export async function POST(request: Request) { if (assignment !== "other") { const id = Number(assignment); const [row] = Number.isInteger(id) - ? await sql`SELECT id, task, proof_required FROM board_assignments WHERE id = ${id} AND person = ${person}` + ? await sql`SELECT id, task, proof_required FROM board_assignments + WHERE chapter = ${chapter} AND id = ${id} AND person = ${person}` : []; if (!row) return bad("Choose one of your assignments."); assignmentId = row.id; @@ -72,19 +87,21 @@ export async function POST(request: Request) { ); const shortTask = taskLabel.length > 90 ? `${taskLabel.slice(0, 88)}…` : taskLabel; + const { to, cc } = submissionRecipients(chapter, member.team); + const strip = (p: { email: string; name: string }) => ({ email: p.email, name: p.name }); try { await deliver({ sender: { email: "getvindicated@outlook.com", name: "VINdicated" }, - to: [SUBMISSIONS_INBOX], - // Everyone shares a password, so reply-to is the email on file for - // the name they picked. - ...(/^[^\s@]+@[^\s@]+$/.test(member.email) ? { replyTo: { email: member.email, name: member.name } } : {}), - subject: `[VINdicated Board] ${person}: ${shortTask}`, + to: to.map(strip), + ...(cc.length ? { cc: cc.map(strip) } : {}), + replyTo: { email: replyEmail, name: member.name }, + subject: `${CHAPTER_SERVER[chapter].submissions.subjectPrefix} ${person}: ${shortTask}`, attachment, htmlContent: `

Board submission

Name: ${esc(person)}

-

Signed in with: the ${esc(ROLE_LABEL[user.role])} password

+

Team: ${esc(member.team)}

+

Signed in with: the ${esc(config.roleLabels[user.role] ?? user.role)} password

Assignment: ${esc(taskLabel)}

${proof ? `

Proof needed: ${esc(proof)}

` : ""}

Files: ${attachment.length ? attachment.map((a) => esc(a.name)).join(", ") : "none"}

@@ -93,16 +110,21 @@ export async function POST(request: Request) { `, }); } catch { - return bad("The email didn't go through. Try again, or send your proof to Rana directly.", 502); + return bad(`The email didn't go through. Try again, or send it to ${config.contact} directly.`, 502); } await sql.begin(async (tx) => { - await tx`INSERT INTO board_submissions (person, assignment_id, task_label, submitter_email, body, filenames) - VALUES (${person}, ${assignmentId}, ${taskLabel}, ${member.email}, ${text}, ${attachment.map((a) => a.name)})`; - if (assignmentId) { + await tx`INSERT INTO board_submissions (chapter, person, assignment_id, task_label, submitter_email, body, filenames) + VALUES (${chapter}, ${person}, ${assignmentId}, ${taskLabel}, ${replyEmail}, ${text}, ${attachment.map((a) => a.name)})`; + if (config.weekly) { + // UCLA keeps one status per person: a submission marks their week. + await tx`UPDATE board_assignments SET status = 'submitted' + WHERE chapter = ${chapter} AND person = ${person} AND status = 'pending' + AND round = (SELECT round FROM board_rounds WHERE chapter = ${chapter} AND is_current)`; + } else if (assignmentId) { await tx`UPDATE board_assignments SET status = 'submitted' WHERE id = ${assignmentId} AND status = 'pending'`; } }); - return Response.json({ ok: true, files: attachment.length }, { status: 201 }); + return Response.json({ ok: true, files: attachment.length, to: to.map((p) => p.short) }, { status: 201 }); } diff --git a/app/api/board/week/route.ts b/app/api/board/week/route.ts new file mode 100644 index 0000000..e0719c8 --- /dev/null +++ b/app/api/board/week/route.ts @@ -0,0 +1,61 @@ +import { bad, guard, isDate, readJson } from "@/lib/board/api"; +import { chapterConfig } from "@/lib/board/chapters"; +import { db } from "@/lib/board/db"; + +// Weekly deliverables (UCLA): admins change the current week's due date, +// or start a new week. + +async function weeklyAdmin(request: Request) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + if (!chapterConfig(user.chapter).weekly) return bad("This chapter doesn't use weeks.", 404); + return user; +} + +export async function PATCH(request: Request) { + const user = await weeklyAdmin(request); + if (user instanceof Response) return user; + const body = await readJson(request); + const due = body?.dueDate; + if (!isDate(due)) return bad("Choose a due date."); + const sql = db(); + await sql.begin(async (tx) => { + const [cur] = await tx`UPDATE board_rounds SET due_date = ${due} + WHERE chapter = ${user.chapter} AND is_current RETURNING round`; + if (cur) { + await tx`UPDATE board_assignments SET due_date = ${due} + WHERE chapter = ${user.chapter} AND round = ${cur.round}`; + } + }); + return Response.json({ ok: true }); +} + +// Archives the current week and starts the next one, due a week after the +// old due date. Tasks of anyone not marked Submitted or Excused carry over. +export async function POST(request: Request) { + const user = await weeklyAdmin(request); + if (user instanceof Response) return user; + const sql = db(); + const c = user.chapter; + const next = await sql.begin(async (tx) => { + const [cur] = await tx`SELECT round, due_date FROM board_rounds WHERE chapter = ${c} AND is_current FOR UPDATE`; + if (!cur) return null; + const [{ max }] = await tx`SELECT max(round)::int AS max FROM board_rounds WHERE chapter = ${c}`; + const round = max + 1; + const [{ due }] = await tx`SELECT to_char(${cur.due_date}::date + 7, 'YYYY-MM-DD') AS due`; + await tx`UPDATE board_rounds SET is_current = false WHERE chapter = ${c}`; + await tx`INSERT INTO board_rounds (chapter, round, due_date, is_current, note) + VALUES (${c}, ${round}, ${due}, true, '')`; + await tx`INSERT INTO board_assignments (chapter, round, person, person_role, task, proof_required, due_date, status, sort) + SELECT chapter, ${round}, person, person_role, task, proof_required, ${due}, 'pending', sort + FROM board_assignments + WHERE chapter = ${c} AND round = ${cur.round} + AND person NOT IN ( + SELECT person FROM board_assignments + WHERE chapter = ${c} AND round = ${cur.round} AND status IN ('submitted', 'done', 'excused') + ) + ORDER BY sort, id`; + return { round, due }; + }); + return next ? Response.json({ ok: true, ...next }, { status: 201 }) : bad("There's no current week to archive."); +} diff --git a/app/board/board.css b/app/board/board.css index 8619906..c3840eb 100644 --- a/app/board/board.css +++ b/app/board/board.css @@ -714,3 +714,100 @@ margin: 0 auto; max-width: 360px; } + +/* UCLA: one row of board cutouts at the top of Home. */ +.board .faces { + display: flex; + flex-wrap: nowrap; + gap: 6px 4px; + align-items: flex-end; + justify-content: center; + padding: 18px 0 0; + border-bottom: 1px solid var(--rule); +} +/* Shrink together on narrow screens so the row never wraps or scrolls. */ +.board .faces img { + height: auto; + width: auto; + max-height: 130px; + min-width: 0; + flex: 0 1 auto; + object-fit: contain; +} +@media (max-width: 640px) { + .board .faces img { + max-height: 80px; + } +} + +.board .st-excused { + color: var(--deep); + font-weight: 700; +} + +/* Admin controls grouped in one box (UCLA's week tools). */ +.board .admin-tools { + border: 1px solid var(--accent); + padding: 14px 16px; + margin: 0 0 16px; + display: grid; + gap: 10px; +} +.board .admin-tools h3 { + margin: 0; +} +.board label.inline { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.board label.inline input, +.board label.inline select { + width: auto; + max-width: 100%; +} + +/* My page */ +.board .me-head { + display: grid; + gap: 2px; + margin: 18px 0 6px; +} +.board .me-head .nm { + font-size: clamp(26px, 5vw, 34px); + font-weight: 300; + letter-spacing: 0.02em; +} +.board .me-head .rl { + font-size: 18px; + color: var(--accent); +} +.board .me-facts { + list-style: none; + padding: 0; + margin: 10px 0 0; + display: grid; + gap: 4px; +} +.board .me-facts li { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; +} +.board .me-facts li > span:first-child { + width: 110px; + font-size: 15px; + letter-spacing: 0.06em; + text-transform: uppercase; + font-weight: 700; + color: var(--deep); +} +.board .me-facts li > span:last-child { + min-width: 0; + overflow-wrap: anywhere; +} +.board .empty { + border: 1px dashed var(--deep); + padding: 14px 16px; +} diff --git a/app/board/page.tsx b/app/board/page.tsx index 16822d0..fb06780 100644 --- a/app/board/page.tsx +++ b/app/board/page.tsx @@ -1,39 +1,8 @@ import type { Metadata } from "next"; -import { headers } from "next/headers"; -import { boardUser } from "@/lib/board/session"; -import { loadBoard } from "@/lib/board/data"; -import { BoardApp } from "@/components/board/BoardApp"; -import { BoardLogin } from "@/components/board/BoardLogin"; +import { ChapterPage } from "@/components/board/ChapterPage"; export const metadata: Metadata = { title: "VINdicated at Berkeley" }; -// Today's date in Berkeley, so the server and browser agree on "today". -function todayInBerkeley() { - return new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles" }).format(new Date()); -} - -export default async function BoardPage() { - const user = boardUser(await headers()); - if (!user) return ; - if (!process.env.DATABASE_URL) { - return ; - } - let data; - try { - data = await loadBoard(user.role); - } catch (e) { - console.error("[board] Could not load the board:", e); - return ; - } - return ; -} - -function BoardNotice({ text }: { text: string }) { - return ( -
-

VINDICATED

-

UC Berkeley chapter board

-

{text}

-
- ); +export default function BerkeleyBoardPage() { + return ; } diff --git a/app/board/ucla/page.tsx b/app/board/ucla/page.tsx new file mode 100644 index 0000000..ba80139 --- /dev/null +++ b/app/board/ucla/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from "next"; +import { ChapterPage } from "@/components/board/ChapterPage"; + +export const metadata: Metadata = { title: "VINdicated at UCLA" }; + +export default function UclaBoardPage() { + return ; +} diff --git a/components/board/AssignmentsTab.tsx b/components/board/AssignmentsTab.tsx index ad4d4d0..319ddc0 100644 --- a/components/board/AssignmentsTab.tsx +++ b/components/board/AssignmentsTab.tsx @@ -1,12 +1,12 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import type { Assignment, AssignmentStatus } from "@/lib/board/types"; -import { ARCHIVE_ROUND, CURRENT_ROUND, RULE, UNASSIGNED } from "@/lib/board/static"; +import type { Assignment, AssignmentStatus, Round } from "@/lib/board/types"; +import { UNASSIGNED } from "@/lib/board/static"; import { useBoard } from "./BoardApp"; import { RowEditor, type FieldDef } from "./RowEditor"; import { SubmitForm } from "./SubmitForm"; -import { currentRound, unassignedTasks } from "./derive"; +import { roundTasks, unassignedTasks, type PersonTasks } from "./derive"; import { api, daysBetween, fmt } from "./util"; const STATUS_WORD: Record = { @@ -14,23 +14,38 @@ const STATUS_WORD: Record = { submitted: "Submitted", done: "Done", missing: "Missing", + excused: "Excused", +}; +// UCLA: one status per person, set by an admin. +const WEEKLY_STATUSES: AssignmentStatus[] = ["pending", "submitted", "missing", "excused"]; +const STATUS_CLASS: Record = { + pending: "st-prog", + submitted: "st-done", + done: "st-done", + missing: "st-missing", + excused: "st-excused", }; const TICKS_KEY = "vin-board-ticks"; const submittedOn = (iso: string) => new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "America/Los_Angeles" }); +type Filter = "All" | AssignmentStatus; + export function AssignmentsTab() { - const { data, user, today, focus, setFocus, refresh } = useBoard(); + const { chapter, round, data, user, today, focus, setFocus, refresh } = useBoard(); const admin = user.role === "admin"; - const people = currentRound(data); - const unassigned = admin ? unassignedTasks(data) : []; - const [filter, setFilter] = useState<"All" | "Not submitted" | "Submitted">("All"); + const weekly = chapter.weekly; + const people = roundTasks(data, round?.round ?? null); + const unassigned = admin ? unassignedTasks(data, round?.round ?? null) : []; + const [filter, setFilter] = useState("All"); const [open, setOpen] = useState>(new Set()); const [ticks, setTicks] = useState>({}); const [editing, setEditing] = useState(null); + const [newFor, setNewFor] = useState(""); const [submitFor, setSubmitFor] = useState(""); const [adminError, setAdminError] = useState(""); + const [confirmWeek, setConfirmWeek] = useState(false); const formRef = useRef(null); // Personal checklist ticks stay in this browser only. @@ -51,7 +66,7 @@ export function AssignmentsTab() { } }; - // Opened from the Board tab's "View" link. + // Opened from the Board tab's "View" link or My page. useEffect(() => { if (focus?.tab !== "assignments") return; setFilter("All"); @@ -60,70 +75,174 @@ export function AssignmentsTab() { requestAnimationFrame(() => document.getElementById(`as-${focus.key}`)?.scrollIntoView({ block: "center" })); }, [focus, setFocus]); + // Berkeley groups people as submitted or not; UCLA by each person's status. + const statusOf = (p: PersonTasks): AssignmentStatus => (weekly ? p.status : p.submitted ? "submitted" : "pending"); + const filters: Filter[] = weekly ? ["All", ...WEEKLY_STATUSES] : ["All", "pending", "submitted"]; const done = people.filter((p) => p.submitted).length; - const left = daysBetween(today, CURRENT_ROUND.due); - const shown = people.filter((p) => filter === "All" || (filter === "Submitted") === p.submitted); - const archive = data.assignments.filter((a) => a.round === ARCHIVE_ROUND.round); - const archiveDone = archive.filter((a) => a.status !== "missing" && a.status !== "pending").length; + const left = round ? daysBetween(today, round.dueDate) : 0; + const shown = people.filter((p) => filter === "All" || statusOf(p) === filter); + const archived = data.rounds.filter((r) => !r.isCurrent); const memberOptions = useMemo( () => [...data.members.map((m) => ({ value: m.firstName, label: m.name })), { value: UNASSIGNED, label: "Unassigned (admins only)" }], [data.members], ); + const statusOptions = (weekly ? WEEKLY_STATUSES : (["pending", "submitted", "done", "missing"] as AssignmentStatus[])).map((s) => ({ + value: s, + label: STATUS_WORD[s], + })); const taskFields: FieldDef[] = [ { key: "person", label: "Person", type: "select", options: memberOptions, pair: "p" }, { key: "personRole", label: "Role shown", pair: "p" }, { key: "task", label: "Task", type: "textarea" }, - { key: "proof", label: "Proof needed" }, + ...(chapter.proofRequired ? [{ key: "proof", label: "Proof needed" } as FieldDef] : []), { key: "dueDate", label: "Due", type: "date", pair: "d" }, - { key: "status", label: "Status", type: "select", options: (Object.keys(STATUS_WORD) as AssignmentStatus[]).map((s) => ({ value: s, label: STATUS_WORD[s] })), pair: "d" }, + ...(weekly ? [] : [{ key: "status", label: "Status", type: "select", options: statusOptions, pair: "d" } as FieldDef]), ]; const editingTask = typeof editing === "number" ? data.assignments.find((a) => a.id === editing) : null; + const taskInitial = (a: Assignment) => ({ + person: a.person, + personRole: a.personRole, + task: a.task, + ...(chapter.proofRequired ? { proof: a.proof } : {}), + dueDate: a.dueDate ?? "", + ...(weekly ? {} : { status: a.status }), + }); - async function mark(a: Assignment, status: AssignmentStatus) { + async function run(path: string, method: string, body?: unknown) { setAdminError(""); - const err = await api(`/api/board/assignments/${a.id}`, "PATCH", { status }); + const err = await api(path, method, body); if (err) setAdminError(err); else refresh(); + return !err; } + const mark = (a: Assignment, status: AssignmentStatus) => run(`/api/board/assignments/${a.id}`, "PATCH", { status }); const startSubmit = (person: string) => { setSubmitFor(person); formRef.current?.scrollIntoView({ behavior: "smooth" }); }; + const addFor = (person: string, role: string) => { + setNewFor(person ? `${person}\u0000${role}` : ""); + setEditing("new"); + }; + const [newPerson, newRole] = newFor ? newFor.split("\u0000") : [memberOptions[0]?.value ?? "", ""]; + + const taskItem = (a: Assignment, showTick: boolean) => ( +
  • + {showTick && tick(a.id, e.target.checked)} />} +
    + + {a.proof && ( +
    + Proof: {a.proof} +
    + )} + {admin && ( +
    + {!weekly && a.person !== UNASSIGNED && ( + <> + + + {a.status !== "pending" && ( + + )} + + )} + +
    + )} + {editingTask?.id === a.id && ( + setEditing(null)} /> + )} +
    +
  • + ); return (
    -

    {CURRENT_ROUND.label}

    -

    - The rule: {RULE} -

    +

    {round ? chapter.roundLabel(round, true) : "Deliverables"}

    + {chapter.rule && ( +

    + The rule: {chapter.rule} +

    + )} + {!chapter.rule && round?.note &&

    {round.note}

    }

    - {done} of {people.length} people submitted · {left > 0 ? `${left} days left` : left === 0 ? "due tonight" : "deadline passed"} + {done} of {people.length} people submitted + {round && <> · {left > 1 ? `${left} days left` : left === 1 ? "1 day left" : left === 0 ? (weekly ? "due today" : "due tonight") : "deadline passed"}}

    - {(["All", "Not submitted", "Submitted"] as const).map((s) => ( + {filters.map((s) => ( ))} {admin && ( - )}
    - {adminError &&

    {adminError}

    } - {editing === "new" && ( + + {admin && weekly && round && ( +
    +

    Admin

    +
    + + +
    +

    + Open a person below to edit their tasks or set their status. Starting a new week moves this week into the archive and carries over anything + not submitted or excused. +

    +
    + )} + {adminError && ( +

    + {adminError} +

    + )} + {editing === "new" && round && ( setEditing(null)} /> )} @@ -139,33 +258,7 @@ export function AssignmentsTab() {

    Edit a task and choose a person to give it to them.

    -
      - {unassigned.map((a) => ( -
    • -
      - {a.task} -
      - Proof: {a.proof} -
      -
      - -
      - {editingTask?.id === a.id && ( - setEditing(null)} - /> - )} -
      -
    • - ))} -
    +
      {unassigned.map((a) => taskItem(a, false))}
    )} @@ -173,6 +266,7 @@ export function AssignmentsTab() {
    {shown.map((p) => { const subs = data.submissions.filter((s) => s.person === p.person && (!s.assignmentId || p.items.some((i) => i.id === s.assignmentId))); + const st = statusOf(p); return (
    · {p.role} {p.due ? fmt(p.due, { month: "short", day: "numeric", year: "numeric" }) : ""} - {p.submitted ? "Submitted" : "Not submitted"} + {STATUS_WORD[st]}
    -
      - {p.items.map((a) => ( -
    • - tick(a.id, e.target.checked)} /> -
      - -
      - Proof: {a.proof} -
      - {admin && ( -
      - - - {a.status !== "pending" && ( - - )} - -
      - )} - {editingTask?.id === a.id && ( - setEditing(null)} - /> - )} -
      -
    • - ))} -
    +
      {p.items.map((a) => taskItem(a, true))}
    {subs.length ? ( subs.map((s) => (
    - Submitted {submittedOn(s.createdAt)}: {s.filenames.length ? s.filenames.join(", ") : "link in notes"} + Submitted {submittedOn(s.createdAt)}: {s.filenames.length ? s.filenames.join(", ") : "update in notes"} {!s.assignmentId && ({s.taskLabel})}
    )) ) : (
    No submissions yet.
    )} - +
    + + {admin && ( + + )} + {admin && weekly && ( + + )} +
    ); @@ -261,34 +331,49 @@ export function AssignmentsTab() { {shown.length === 0 &&

    No one in this group.

    }
    -
    - - {ARCHIVE_ROUND.label} - - - {archiveDone} of {archive.length} done - - -
    - - - {archive.map((a) => ( - - - - - - ))} - -
    - {a.person} - {a.task}{a.statusNote || STATUS_WORD[a.status]}
    -
    -
    + {archived.map((r) => ( + + ))}

    Submit your work

    -

    This form emails your submission and any files to Rana. Attach proof or paste a link to it in the notes.

    +

    {chapter.submit.note}

    ); } + +// A past round or week: who had what, and (unless the week was never +// marked) how it went. +function Archive({ r, label }: { r: Round; label: string }) { + const { data } = useBoard(); + const people = roundTasks(data, r.round); + const done = people.filter((p) => p.submitted || p.status === "excused").length; + return ( +
    + + {label} + + {r.showStatus ? `${done} of ${people.length} done` : `${people.length} people`} + +
    + + + {people.map((p) => { + const note = p.items.find((a) => a.statusNote)?.statusNote; + const st: AssignmentStatus = p.submitted ? "submitted" : p.status; + return ( + + + + {r.showStatus && } + + ); + })} + +
    + {p.person} + {p.items.map((a) => a.task).join("; ")}{note || STATUS_WORD[st]}
    +
    +
    + ); +} diff --git a/components/board/BoardApp.tsx b/components/board/BoardApp.tsx index 2f29623..68f4238 100644 --- a/components/board/BoardApp.tsx +++ b/components/board/BoardApp.tsx @@ -3,9 +3,10 @@ import { createContext, useCallback, useContext, useEffect, useState, type MouseEvent } from "react"; import { useRouter } from "next/navigation"; import type { BoardUser } from "@/lib/board/session"; -import { ROLE_LABEL } from "@/lib/board/roles"; -import { TABS, type BoardData, type Tab } from "@/lib/board/types"; -import { TERM } from "@/lib/board/static"; +import { chapterConfig, type ChapterConfig, type ChapterId } from "@/lib/board/chapters"; +import { TABS, type BoardData, type Round, type Tab } from "@/lib/board/types"; +import { chapterUrl, setApiChapter } from "./util"; +import { MyPage } from "./MyPage"; import { HomeTab } from "./HomeTab"; import { BoardTab } from "./BoardTab"; import { ProjectsTab } from "./ProjectsTab"; @@ -14,6 +15,7 @@ import { SocialsTab } from "./SocialsTab"; import { MeetingsTab } from "./MeetingsTab"; const LABELS: Record = { + me: "My page", home: "Home", board: "Board", projects: "Projects", @@ -23,6 +25,9 @@ const LABELS: Record = { }; type Ctx = { + chapter: ChapterConfig; + // The round or week on the Assignments tab now. + round: Round | null; data: BoardData; user: BoardUser; today: string; @@ -45,12 +50,15 @@ const fromHash = (): Tab => { return (TABS as readonly string[]).includes(h) ? (h as Tab) : "home"; }; -export function BoardApp({ data, user, today }: { data: BoardData; user: BoardUser; today: string }) { +export function BoardApp({ chapter: id, data, user, today }: { chapter: ChapterId; data: BoardData; user: BoardUser; today: string }) { + setApiChapter(id); + const chapter = chapterConfig(id); + const round = data.rounds.find((r) => r.isCurrent) ?? null; const router = useRouter(); const [tab, setTab] = useState("home"); const [focus, setFocus] = useState(null); - // Each tab is linkable: /board#socials. + // Each tab is linkable: /board#socials, /board/ucla#me. useEffect(() => { const sync = () => setTab(fromHash()); sync(); @@ -72,10 +80,10 @@ export function BoardApp({ data, user, today }: { data: BoardData; user: BoardUs go(t); }; - const ctx: Ctx = { data, user, today, go, refresh, focus, setFocus }; + const ctx: Ctx = { chapter, round, data, user, today, go, refresh, focus, setFocus }; async function signOut() { - await fetch("/api/board/logout", { method: "POST" }); + await fetch(chapterUrl("/api/board/logout"), { method: "POST" }); router.refresh(); } @@ -90,14 +98,15 @@ export function BoardApp({ data, user, today }: { data: BoardData; user: BoardUs ))}

    VINDICATED

    -

    {TERM}

    +

    {chapter.term}

    - Signed in as {ROLE_LABEL[user.role]} ·{" "} + Signed in as {chapter.roleLabels[user.role] ?? user.role} ·{" "}

    + {tab === "me" && } {tab === "home" && } {tab === "board" && } {tab === "projects" && } diff --git a/components/board/BoardLogin.tsx b/components/board/BoardLogin.tsx index 78b7e19..02163d3 100644 --- a/components/board/BoardLogin.tsx +++ b/components/board/BoardLogin.tsx @@ -2,9 +2,12 @@ import { useState, type FormEvent } from "react"; import { useRouter } from "next/navigation"; -import { api } from "./util"; +import { chapterConfig, type ChapterId } from "@/lib/board/chapters"; +import { api, setApiChapter } from "./util"; -export function BoardLogin() { +export function BoardLogin({ chapter }: { chapter: ChapterId }) { + const config = chapterConfig(chapter); + setApiChapter(chapter); const router = useRouter(); const [password, setPassword] = useState(""); const [error, setError] = useState(""); @@ -24,7 +27,7 @@ export function BoardLogin() { return (

    VINDICATED

    -

    UC Berkeley chapter board

    +

    {config.loginSub}

    ); diff --git a/components/board/BoardTab.tsx b/components/board/BoardTab.tsx index 0d4c985..e04e4ad 100644 --- a/components/board/BoardTab.tsx +++ b/components/board/BoardTab.tsx @@ -2,21 +2,17 @@ import { useEffect, useState } from "react"; import type { Member } from "@/lib/board/types"; -import { TEAM_ORDER } from "@/lib/board/static"; import { useBoard } from "./BoardApp"; import { RowEditor, type FieldDef } from "./RowEditor"; import { ImportMembers } from "./ImportMembers"; -import { currentRound } from "./derive"; +import { roundTasks } from "./derive"; import { fmt } from "./util"; -const ALL_TEAMS = ["All", "Executive", ...TEAM_ORDER]; -const TEAM_OPTIONS = ["Executive", ...TEAM_ORDER].map((t) => ({ value: t, label: t })); - -const MEMBER_FIELDS: FieldDef[] = [ +const memberFields = (teams: readonly string[]): FieldDef[] => [ { key: "name", label: "Full name" }, { key: "role", label: "Role", placeholder: "Data Engineer" }, - { key: "team", label: "Team", type: "select", options: TEAM_OPTIONS, pair: "t" }, - { key: "alsoTeam", label: "Also leads (optional)", type: "select", options: [{ value: "", label: "None" }, ...TEAM_ORDER.map((t) => ({ value: t, label: t }))], pair: "t" }, + { key: "team", label: "Team", type: "select", options: ["Executive", ...teams].map((t) => ({ value: t, label: t })), pair: "t" }, + { key: "alsoTeam", label: "Also leads (optional)", type: "select", options: [{ value: "", label: "None" }, ...teams.map((t) => ({ value: t, label: t }))], pair: "t" }, { key: "tier", label: "Top of chart", type: "select", options: [{ value: "", label: "No" }, { value: "0", label: "President row" }, { value: "1", label: "Executive row" }] }, { key: "isLead", label: "Team lead", type: "checkbox" }, { key: "email", label: "Email", type: "email", pair: "c" }, @@ -26,7 +22,10 @@ const MEMBER_FIELDS: FieldDef[] = [ const inTeam = (m: Member, t: string) => t === "All" || m.team === t || m.alsoTeam === t; export function BoardTab() { - const { data, user, go, setFocus, refresh } = useBoard(); + const { chapter, round, data, user, go, setFocus, refresh } = useBoard(); + const TEAM_ORDER = chapter.teams; + const ALL_TEAMS = ["All", "Executive", ...TEAM_ORDER]; + const MEMBER_FIELDS = memberFields(TEAM_ORDER); const admin = user.role === "admin"; const people = data.members; const [team, setTeam] = useState("All"); @@ -49,7 +48,7 @@ export function BoardTab() { const visible = (m: Member) => inTeam(m, team) && matches(m); const filtering = team !== "All" || !!q; - const tasks = currentRound(data); + const tasks = roundTasks(data, round?.round ?? null); const person = open !== null ? people.find((m) => m.id === open) ?? null : null; const editingMember = typeof editing === "number" ? people.find((m) => m.id === editing) : null; @@ -88,7 +87,7 @@ export function BoardTab() { }} /> ) : ( -

    The member list hasn't been loaded yet. Ask Rana.

    +

    The member list hasn't been loaded yet. Ask {chapter.contact}.

    )} ); @@ -123,7 +122,7 @@ export function BoardTab() { {editing === "new" && ( - setEditing(null)} /> + setEditing(null)} /> )} {!list ? ( @@ -229,7 +228,7 @@ function PersonCard({ onTasks, }: { m: Member; - tasks?: ReturnType[number]; + tasks?: ReturnType[number]; admin: boolean; copy: (el: HTMLButtonElement, v: string) => void; onEdit: () => void; diff --git a/components/board/ChapterPage.tsx b/components/board/ChapterPage.tsx new file mode 100644 index 0000000..d9640dd --- /dev/null +++ b/components/board/ChapterPage.tsx @@ -0,0 +1,39 @@ +import { headers } from "next/headers"; +import { boardUser } from "@/lib/board/session"; +import { loadBoard } from "@/lib/board/data"; +import type { ChapterId } from "@/lib/board/chapters"; +import { BoardApp } from "./BoardApp"; +import { BoardLogin } from "./BoardLogin"; + +// Today's date in California, so the server and browser agree on "today". +function todayInCalifornia() { + return new Intl.DateTimeFormat("en-CA", { timeZone: "America/Los_Angeles" }).format(new Date()); +} + +// One chapter's board hub: the sign-in box, or the hub for whoever is +// signed in to this chapter. +export async function ChapterPage({ chapter }: { chapter: ChapterId }) { + const user = boardUser(await headers(), chapter); + if (!user) return ; + if (!process.env.DATABASE_URL) { + return ; + } + let data; + try { + data = await loadBoard(user); + } catch (e) { + console.error("[board] Could not load the board:", e); + return ; + } + return ; +} + +function BoardNotice({ chapter, text }: { chapter: ChapterId; text: string }) { + return ( +
    +

    VINDICATED

    +

    {chapter === "ucla" ? "UCLA chapter board hub" : "UC Berkeley chapter board"}

    +

    {text}

    +
    + ); +} diff --git a/components/board/HomeTab.tsx b/components/board/HomeTab.tsx index 435f11c..6c4bb42 100644 --- a/components/board/HomeTab.tsx +++ b/components/board/HomeTab.tsx @@ -1,11 +1,10 @@ "use client"; import { useState } from "react"; -import { KEY_DATES, QUICK_LINKS } from "@/lib/board/static"; import { TABS, type Announcement, type Tab } from "@/lib/board/types"; import { useBoard } from "./BoardApp"; import { RowEditor, type FieldDef } from "./RowEditor"; -import { currentRound, inLeadership } from "./derive"; +import { roundTasks, inLeadership } from "./derive"; import { daysBetween, fmt } from "./util"; const LINK_TABS = [{ value: "", label: "No link" }, ...TABS.map((t) => ({ value: t, label: t[0].toUpperCase() + t.slice(1) }))]; @@ -16,43 +15,54 @@ const ANNOUNCEMENT_FIELDS: FieldDef[] = [ { key: "postedOn", label: "Date", type: "date", pair: "a" }, { key: "linkTab", label: "Link to a tab", type: "select", options: LINK_TABS, pair: "a" }, { key: "linkLabel", label: "Link text", placeholder: "See your tasks" }, + { key: "adminsOnly", label: "Board admins only", type: "checkbox" }, ]; export function HomeTab() { - const { data, user, today, go } = useBoard(); + const { chapter, round, data, user, today, go } = useBoard(); const admin = user.role === "admin"; const [editing, setEditing] = useState(null); - const people = currentRound(data); + const people = roundTasks(data, round?.round ?? null); const done = people.filter((p) => p.submitted).length; const glance: [string, string, Tab][] = [ - [String(data.members.length), "members on the Berkeley board", "board"], - [`${done}/${people.length}`, "round 2 submissions in", "assignments"], - [String(data.socials.filter((s) => s.kind !== "org").length), "socials on the calendar", "socials"], + [String(data.members.length), chapter.glance.members, "board"], + [`${done}/${people.length}`, chapter.glance.submissions, "assignments"], + [String(data.socials.filter((s) => s.kind !== "org").length), chapter.glance.socials, "socials"], [String(data.members.filter(inLeadership).length), "people in leadership", "board"], ]; - const dates = [...KEY_DATES].sort((a, b) => (a[0] < b[0] ? -1 : 1)); + const dates = [...chapter.keyDates].sort((a, b) => (a[0] < b[0] ? -1 : 1)); const editingRow = typeof editing === "number" ? data.announcements.find((a) => a.id === editing) : null; return (
    + {chapter.photos.length > 0 && ( +
    + {chapter.photos.map((src) => ( + // eslint-disable-next-line @next/next/no-img-element + Board member + ))} +
    + )}

    Logistics & Quick Links

      - {QUICK_LINKS.map((l) => ( -
    • - {l.label}:{" "} + {chapter.quickLinks.map((l) => ( +
    • + {l.label && `${l.label}: `} {l.tab ? ( ) : l.href ? ( {l.text} - ) : ( + ) : l.href === null ? ( <> {l.text} (link to add) + ) : ( + l.text )}
    • ))} @@ -71,7 +81,7 @@ export function HomeTab() { title="Add an announcement" table="announcements" fields={ANNOUNCEMENT_FIELDS} - initial={{ title: "", body: "", postedOn: today, linkTab: "", linkLabel: "" }} + initial={{ title: "", body: "", postedOn: today, linkTab: "", linkLabel: "", adminsOnly: false }} onClose={() => setEditing(null)} /> )} @@ -83,7 +93,7 @@ export function HomeTab() { table="announcements" id={a.id} fields={ANNOUNCEMENT_FIELDS} - initial={{ title: a.title, body: a.body, postedOn: a.postedOn, linkTab: a.linkTab, linkLabel: a.linkLabel }} + initial={{ title: a.title, body: a.body, postedOn: a.postedOn, linkTab: a.linkTab, linkLabel: a.linkLabel, adminsOnly: a.adminsOnly }} onClose={() => setEditing(null)} /> ) : ( @@ -135,6 +145,7 @@ function AnnouncementItem({ a, admin, onEdit }: { a: Announcement; admin: boolea return (

      {a.title}

      + {a.adminsOnly &&

      Board admins only

      } {(a.body || a.linkLabel) && (

      {a.body} @@ -151,7 +162,7 @@ function AnnouncementItem({ a, admin, onEdit }: { a: Announcement; admin: boolea {admin && (

      )} diff --git a/components/board/ImportMembers.tsx b/components/board/ImportMembers.tsx index 3161078..8bab1a7 100644 --- a/components/board/ImportMembers.tsx +++ b/components/board/ImportMembers.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, type ChangeEvent } from "react"; +import { useBoard } from "./BoardApp"; +import { chapterUrl } from "./util"; type Status = { kind: "ok" | "err" | "busy"; text: string }; @@ -20,7 +22,7 @@ function readText(file: File): Promise { async function send(members: unknown[]): Promise { let res: Response; try { - res = await fetch("/api/board/import-members", { + res = await fetch(chapterUrl("/api/board/import-members"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ members }), @@ -44,6 +46,8 @@ export function ImportMembers({ }: { onLoaded: (count: number) => void; }) { + const { chapter } = useBoard(); + const fileName = chapter.id === "berkeley" ? "board-members.local.json" : `board-members-${chapter.id}.local.json`; const [status, setStatus] = useState(null); async function onFile(e: ChangeEvent) { @@ -62,13 +66,13 @@ export function ImportMembers({ } catch { return setStatus({ kind: "err", - text: `${file.name} isn't the member list. Choose board-members.local.json.`, + text: `${file.name} isn't the member list. Choose ${fileName}.`, }); } if (!Array.isArray(members) || members.length === 0) { return setStatus({ kind: "err", - text: `${file.name} doesn't have a member list in it. Choose board-members.local.json.`, + text: `${file.name} doesn't have a member list in it. Choose ${fileName}.`, }); } setStatus({ kind: "busy", text: `Loading ${members.length} members…` }); @@ -91,9 +95,9 @@ export function ImportMembers({ return (

      - The member list isn't loaded yet. Choose the file - that Claude sent you, board-members.local.json. It has - everyone's names, roles, emails and phone numbers. + The member list isn't loaded yet. Choose the member + list file, {fileName}. It has everyone's names, + roles, emails and phone numbers.

      )} diff --git a/components/board/MyPage.tsx b/components/board/MyPage.tsx new file mode 100644 index 0000000..f9fd726 --- /dev/null +++ b/components/board/MyPage.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Member } from "@/lib/board/types"; +import { useBoard } from "./BoardApp"; +import { roundTasks } from "./derive"; +import { daysBetween, fmt } from "./util"; + +// Who "My page" is for. Everyone signs in with a shared password, so the +// viewer picks themselves and the choice is remembered in this browser. +// When per-person sign-in exists, return the signed-in member here and +// drop the picker; nothing else on the page needs to change. +function useViewer(members: Member[]) { + const { chapter } = useBoard(); + const key = `vin-board-viewer-${chapter.id}`; + const [id, setId] = useState(null); + useEffect(() => { + try { + const saved = Number(localStorage.getItem(key)); + if (saved) setId(saved); + } catch { + /* private mode: start unpicked */ + } + }, [key]); + const pick = (next: number | null) => { + setId(next); + try { + if (next) localStorage.setItem(key, String(next)); + else localStorage.removeItem(key); + } catch { + /* ignore */ + } + }; + return { viewer: members.find((m) => m.id === id) ?? null, pick, canPick: true }; +} + +// The person someone reports to: team members to their team lead, leads +// and the executive row to the president. +function leadOf(m: Member, members: Member[]): Member | null { + const president = members.find((x) => x.tier === 0) ?? null; + if (m.tier === 0) return null; + if (m.tier === 1 || m.isLead) return president; + return members.find((x) => x !== m && x.isLead && (x.team === m.team || x.alsoTeam === m.team)) ?? president; +} + +export function MyPage() { + const { chapter, round, data, today, go, setFocus } = useBoard(); + const { viewer: me, pick, canPick } = useViewer(data.members); + + if (data.members.length === 0) { + return ( +
      +

      My page

      +

      The member list hasn't been loaded yet. Ask {chapter.contact}.

      +
      + ); + } + + const picker = canPick && ( +
      + +
      + ); + + if (!me) { + return ( +
      +

      My page

      + {picker} +

      Choose your name to see your tasks, your team and what's coming up. This browser remembers it.

      +
      + ); + } + + const lead = leadOf(me, data.members); + const mine = roundTasks(data, round?.round ?? null).find((p) => p.person === me.firstName); + const left = round ? daysBetween(today, round.dueDate) : null; + const dueDay = round ? fmt(round.dueDate, { weekday: "short", month: "short", day: "numeric" }) : ""; + const dueText = left === null ? "" : left > 1 ? `Due ${dueDay} · ${left} days left` : left === 1 ? `Due tomorrow, ${dueDay}` : left === 0 ? "Due today" : "Past due"; + const teammates = data.members.filter((x) => x.id !== me.id && (x.team === me.team || x.alsoTeam === me.team)); + const upcoming = data.socials.filter((s) => s.date >= today).slice(0, 3); + + const timeline = chapter.projects.timeline; + const week = timeline.weeks.find((w) => daysBetween(w.start, today) >= 0 && daysBetween(today, w.end) >= -timeline.graceDays); + const track = chapter.myPage.teamTrack[me.team]; + const trackItem = week && track ? week.items.find(([t]) => t === track) : undefined; + const teamNote = chapter.myPage.teamNote[me.team]; + + return ( +
      + {picker} +
      + {me.name} + {me.role} +
      +
        +
      • + Team + + {me.team} + {me.alsoTeam && ` · also ${me.alsoTeam}`} + +
      • + {lead && ( +
      • + Your lead + + {lead.name} + {lead.email && ` · ${lead.email}`} + +
      • + )} +
      • + Your email + {me.email || "Not on file"} +
      • +
      + +

      Your tasks · this week

      + {mine ? ( + <> +

      + {dueText}. +

      +
        + {mine.items.map((a) => ( +
      • + {a.task} +
      • + ))} +
      + + + ) : ( +

      + No tasks assigned to you this round. + {lead && lead.id !== me.id && ` ${lead.firstName} assigns tasks for ${me.team}, so check in with them if you want something to work on.`} +

      + )} + +

      What your team is working on

      + {week && trackItem ? ( +

      + + Week {week.n} ({week.label}): + {" "} + {trackItem[1]}. +

      + ) : teamNote ? ( +

      {teamNote}

      + ) : null} +

      + +

      +

      + Your team: {teammates.map((x) => x.firstName).join(", ") || "Just you for now"} +

      + +

      Coming up

      +
        + {chapter.myPage.meetingLine && ( +
      • + Sun + {chapter.myPage.meetingLine} +
      • + )} + {upcoming.map((s) => ( +
      • + {fmt(s.date, { month: "short", day: "numeric" })} + + {s.title} + {s.location && ` · ${s.location}`} + {s.time && ` · ${s.time}`} + +
      • + ))} +
      +

      + +

      +
      + ); +} diff --git a/components/board/ProjectsTab.tsx b/components/board/ProjectsTab.tsx index 27afab4..bc98928 100644 --- a/components/board/ProjectsTab.tsx +++ b/components/board/ProjectsTab.tsx @@ -1,28 +1,34 @@ "use client"; import { useState } from "react"; -import { PIR, PROJECTS, WEEKS, type Track } from "@/lib/board/static"; +import type { Para } from "@/lib/board/chapters"; import { useBoard } from "./BoardApp"; import { daysBetween } from "./util"; -const TRACKS: ("All" | Track)[] = ["All", "Data", "Backend", "App"]; +const P = ({ p }: { p: Para }) => ( +

      + {p.b && {p.b} } + {p.t} +

      +); export function ProjectsTab() { - const { today } = useBoard(); - const [track, setTrack] = useState<"All" | Track>("All"); + const { chapter, today } = useBoard(); + const { groups, sections, timeline } = chapter.projects; + const [track, setTrack] = useState("All"); return (

      All Projects

      - {PROJECTS.map((g) => ( + {groups.map((g) => (

      {g.group}

        - {g.items.map(([name, chapter]) => ( + {g.items.map(([name, where]) => (
      • {name} - {chapter && · {chapter}} + {where && · {where}}
      • ))}
      @@ -30,37 +36,34 @@ export function ProjectsTab() { ))}
      -

      Project Price Is Right

      -
      -
      -

      {PIR.intro}

      -

      - Modes: {PIR.modes} -

      -

      - Screens: {PIR.screens} -

      -
      -
      -

      - How it fits together: {PIR.howItFits} -

      -

      {PIR.credit}

      + {sections.map((s) => ( +
      +

      {s.title}

      +
      + {s.cols.map((col, i) => ( +
      + {col.map((p, j) => ( +

      + ))} + {i === s.cols.length - 1 && s.credit &&

      {s.credit}

      } +
      + ))} +
      -
      + ))} -

      6-week calendar

      +

      {timeline.title}

      - {TRACKS.map((t) => ( + {["All", ...timeline.tracks].map((t) => ( ))}
      - {WEEKS.map((w) => { - // A week stays "this week" through its weekend. - const now = daysBetween(w.start, today) >= 0 && daysBetween(today, w.end) >= -2; + {timeline.weeks.map((w) => { + // A week can stay "this week" through its weekend (Berkeley's end on Fridays). + const now = daysBetween(w.start, today) >= 0 && daysBetween(today, w.end) >= -timeline.graceDays; const items = w.items.filter((i) => track === "All" || i[0] === track); return (
      diff --git a/components/board/SocialsTab.tsx b/components/board/SocialsTab.tsx index ddff1f1..c8b7c8f 100644 --- a/components/board/SocialsTab.tsx +++ b/components/board/SocialsTab.tsx @@ -2,21 +2,19 @@ import { useState } from "react"; import type { Social } from "@/lib/board/types"; -import { SOCIAL_KINDS, SOCIAL_MONTHS, SOCIAL_STATUSES } from "@/lib/board/static"; +import { SOCIAL_MONTHS, SOCIAL_STATUSES } from "@/lib/board/static"; +import type { ChapterConfig } from "@/lib/board/chapters"; import { canEditSocials } from "@/lib/board/roles"; import { useBoard } from "./BoardApp"; import { RowEditor, type FieldDef } from "./RowEditor"; import { D, fmt, isoDate } from "./util"; -const FILTERS = [{ key: "All", label: "All" }, ...SOCIAL_KINDS.map((k) => ({ key: k.key as string, label: k.label }))]; -const TAG: Record = { b: "Berkeley", o: "Online", org: "Org date" }; - -const SOCIAL_FIELDS: FieldDef[] = [ +const socialFields = (kinds: ChapterConfig["socials"]["kinds"]): FieldDef[] => [ { key: "title", label: "Name" }, { key: "date", label: "Date", type: "date", pair: "d" }, { key: "time", label: "Time", placeholder: "2:00–4:00pm", pair: "d" }, { key: "location", label: "Location", placeholder: "Place name and address" }, - { key: "kind", label: "Type", type: "select", options: SOCIAL_KINDS.map((k) => ({ value: k.key, label: k.label })), pair: "k" }, + { key: "kind", label: "Type", type: "select", options: kinds.map((k) => ({ value: k.key, label: k.label })), pair: "k" }, { key: "status", label: "Status", type: "select", options: SOCIAL_STATUSES.map((s) => ({ value: s, label: s })), pair: "k" }, { key: "lead", label: "Lead" }, { key: "cost", label: "Cost" }, @@ -24,7 +22,11 @@ const SOCIAL_FIELDS: FieldDef[] = [ ]; export function SocialsTab() { - const { data, user, today } = useBoard(); + const { chapter, data, user, today } = useBoard(); + const cfg = chapter.socials; + const FILTERS = [{ key: "All", label: "All" }, ...cfg.kinds.map((k) => ({ key: k.key as string, label: k.label }))]; + const TAG = cfg.tags; + const SOCIAL_FIELDS = socialFields(cfg.kinds); const editor = canEditSocials(user.role); const socials = data.socials; const [filter, setFilter] = useState("All"); @@ -63,7 +65,7 @@ export function SocialsTab() { return (
      -

      Fall 2026 Socials

      +

      {cfg.heading}

      {FILTERS.map((f) => (

      )} @@ -139,10 +141,10 @@ export function SocialsTab() { {editing === "new" && ( setEditing(null)} onSaved={(v) => jumpTo(v.date)} /> @@ -213,7 +215,7 @@ export function SocialsTab() {
      )} -

      All socials

      +

      {cfg.list}

        {socials.filter(show).map((s) => { const past = s.date < today; @@ -233,7 +235,7 @@ export function SocialsTab() { ); })}
      -

      Status runs Idea → Planning → Confirmed → Completed.

      +

      {cfg.note}

      ); } diff --git a/components/board/SubmitForm.tsx b/components/board/SubmitForm.tsx index c9ee322..b2e0f77 100644 --- a/components/board/SubmitForm.tsx +++ b/components/board/SubmitForm.tsx @@ -1,22 +1,26 @@ "use client"; import { useRef, useState, type FormEvent } from "react"; -import { CURRENT_ROUND } from "@/lib/board/static"; -import { MAX_ATTACHMENT_BYTES, NEED_PROOF, OTHER_TASK, TOO_BIG, hasLink } from "@/lib/board/submit-rules"; +import { MAX_ATTACHMENT_BYTES, OTHER_TASK, TOO_BIG, hasLink, isEmail } from "@/lib/board/submit-rules"; import { useBoard } from "./BoardApp"; -import { api } from "./util"; +import { apiCall, listNames } from "./util"; const short = (s: string) => (s.length > 90 ? `${s.slice(0, 88)}…` : s); export function SubmitForm({ person, onPerson }: { person: string; onPerson: (p: string) => void }) { - const { data, refresh } = useBoard(); + const { chapter, round, data, refresh } = useBoard(); const formRef = useRef(null); const [assignment, setAssignment] = useState(""); const [status, setStatus] = useState<{ kind: "ok" | "err"; text: string } | null>(null); const [busy, setBusy] = useState(false); - const tasks = data.assignments.filter((a) => a.round === CURRENT_ROUND.round && a.person === person); + const tasks = data.assignments.filter((a) => a.round === round?.round && a.person === person); const value = tasks.some((t) => String(t.id) === assignment) || assignment === "other" ? assignment : tasks[0] ? String(tasks[0].id) : "other"; + const member = data.members.find((m) => m.firstName === person); + // Reply-to is the email on file; without one, ask for it. + const needsEmail = !!member && !isEmail(member.email); + const { base, tech, techTeams } = data.recipients; + const goesTo = listNames(member && techTeams.includes(member.team) ? [...base, ...tech] : base); async function submit(e: FormEvent) { e.preventDefault(); @@ -24,19 +28,24 @@ export function SubmitForm({ person, onPerson }: { person: string; onPerson: (p: const text = String(form.get("text") ?? "").trim(); const files = form.getAll("files").filter((f): f is File => f instanceof File && f.size > 0); if (!person) return setStatus({ kind: "err", text: "Choose your name before submitting." }); - if (!files.length && !hasLink(text)) return setStatus({ kind: "err", text: NEED_PROOF }); + if (needsEmail && !isEmail(String(form.get("email") ?? "").trim())) { + return setStatus({ kind: "err", text: "Add your email so they can reply to you." }); + } + const missing = chapter.proofRequired ? !files.length && !hasLink(text) : !files.length && !text; + if (missing) return setStatus({ kind: "err", text: chapter.submit.needProof }); if (files.reduce((n, f) => n + f.size, 0) > MAX_ATTACHMENT_BYTES) return setStatus({ kind: "err", text: TOO_BIG }); form.set("person", person); form.set("assignment", value); setBusy(true); setStatus(null); - const err = await api("/api/board/submit", "POST", form); + const { error, data: reply } = await apiCall("/api/board/submit", "POST", form); setBusy(false); - if (err) return setStatus({ kind: "err", text: err }); + if (error) return setStatus({ kind: "err", text: error }); + const to = Array.isArray(reply?.to) ? listNames(reply.to as string[]) : goesTo; setStatus({ kind: "ok", - text: `Sent to Rana${files.length ? ` with ${files.length} file${files.length > 1 ? "s" : ""}` : ""}. It now shows under your name.`, + text: `Sent to ${to}${files.length ? ` with ${files.length} file${files.length > 1 ? "s" : ""}` : ""}. It now shows under your name.`, }); formRef.current?.reset(); refresh(); @@ -52,6 +61,7 @@ export function SubmitForm({ person, onPerson }: { person: string; onPerson: (p: onChange={(e) => { onPerson(e.target.value); setAssignment(""); + setStatus(null); }} > @@ -62,6 +72,12 @@ export function SubmitForm({ person, onPerson }: { person: string; onPerson: (p: ))} + {needsEmail && ( + + )}
      + + +
      +
      +
      +
      +

      Key Dates

      +
        + +

        At a Glance

        +
          +
          +
          +
        + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/vindicated-invite-preview.html b/docs/vindicated-invite-preview.html new file mode 100644 index 0000000..a50fd1c --- /dev/null +++ b/docs/vindicated-invite-preview.html @@ -0,0 +1,136 @@ + + + + + +VINdicated Invite Email + + + +
        +
        + + + + +
        +
        +

        +
        + + + diff --git a/lib/board/api.ts b/lib/board/api.ts index 0e2cba6..080e00b 100644 --- a/lib/board/api.ts +++ b/lib/board/api.ts @@ -1,18 +1,28 @@ import "server-only"; import { boardUser, type BoardUser } from "./session"; import type { BoardRole } from "./roles"; +import { isChapterId, type ChapterId } from "./chapters"; import { ensureSetup } from "./setup"; export const notFound = () => new Response("Not found", { status: 404 }); export const bad = (message: string, status = 400) => Response.json({ error: message }, { status }); -// Returns the signed-in user, or a 404 Response when there's no valid -// session or the user lacks one of the allowed roles. +// The chapter a board API request is for: ?chapter=ucla, else Berkeley. +export function chapterOf(request: Request): ChapterId | null { + const c = new URL(request.url).searchParams.get("chapter") ?? "berkeley"; + return isChapterId(c) ? c : null; +} + +// Returns the signed-in user of the request's chapter, or a 404 Response +// when there's no valid session for that chapter or the user lacks one of +// the allowed roles. Every read and write is then scoped to user.chapter. export async function guard( request: Request, allowed?: BoardRole[], ): Promise { - const user = boardUser(request.headers); + const chapter = chapterOf(request); + if (!chapter) return notFound(); + const user = boardUser(request.headers, chapter); if (!user) return notFound(); if (allowed && !allowed.includes(user.role)) return notFound(); if (!process.env.DATABASE_URL) return bad("The board's database isn't connected yet.", 503); diff --git a/lib/board/chapter-server.ts b/lib/board/chapter-server.ts new file mode 100644 index 0000000..82a830a --- /dev/null +++ b/lib/board/chapter-server.ts @@ -0,0 +1,71 @@ +import "server-only"; +import type { ChapterId } from "./chapters"; +import type { BoardRole } from "./roles"; + +// `short` is how the submit form names them: "Sent to Fiona and Will". +type Person = { email: string; name: string; short: string }; + +// Server-only chapter settings: which Railway variables hold each role's +// password, and where deliverable submissions are emailed. Change the +// recipients here; the submit route and form read them from this file. +export type ChapterServer = { + cookie: string; + // Checked in this order, so a password shared by two roles gets the higher one. + passwords: [BoardRole, string][]; + submissions: { + subjectPrefix: string; + to: Person[]; + cc: Person[]; + // Submitters on these teams also go to `techTo`. + techTeams: string[]; + techTo: Person[]; + }; +}; + +export const CHAPTER_SERVER: Record = { + berkeley: { + cookie: "board_session", + passwords: [ + ["admin", "BOARD_ADMIN_PASSWORD"], + ["socials", "BOARD_SOCIALS_PASSWORD"], + ["member", "BOARD_PASSWORD"], + ], + submissions: { + subjectPrefix: "[VINdicated Board]", + to: [{ email: "ranadarwich05@gmail.com", name: "Rana Darwich", short: "Rana" }], + cc: [], + techTeams: [], + techTo: [], + }, + }, + ucla: { + cookie: "board_session_ucla", + passwords: [ + ["admin", "BOARD_UCLA_ADMIN_PASSWORD"], + ["member", "BOARD_UCLA_PASSWORD"], + ], + submissions: { + subjectPrefix: "[VINdicated UCLA]", + to: [ + { email: "fionarhine@ucla.edu", name: "Fiona Wangsawidjaja", short: "Fiona" }, + { email: "prawira.j.william@gmail.com", name: "William Prawira", short: "Will" }, + ], + cc: [{ email: "ranadarwich05@gmail.com", name: "Rana Darwich", short: "Rana" }], + techTeams: ["App Dev", "Data"], + techTo: [{ email: "rizwaanbana@g.ucla.edu", name: "Rizwaan Bana", short: "Rizwaan" }], + }, + }, +}; + +// Who a submission from someone on `team` goes to. +export function submissionRecipients(chapter: ChapterId, team: string) { + const s = CHAPTER_SERVER[chapter].submissions; + const tech = s.techTeams.includes(team); + return { to: tech ? [...s.to, ...s.techTo] : s.to, cc: s.cc }; +} + +// Short names for the submit form, without email addresses. +export function recipientNames(chapter: ChapterId) { + const s = CHAPTER_SERVER[chapter].submissions; + return { base: s.to.map((p) => p.short), tech: s.techTo.map((p) => p.short), techTeams: s.techTeams }; +} diff --git a/lib/board/chapters.ts b/lib/board/chapters.ts new file mode 100644 index 0000000..685f680 --- /dev/null +++ b/lib/board/chapters.ts @@ -0,0 +1,235 @@ +// Per-chapter settings for the board hub. Berkeley lives at /board and UCLA +// at /board/ucla; both run the same pages and components, which read their +// differences from here. Nothing secret goes in this file: it's shipped to +// the browser. Passwords and email recipients are in chapter-server.ts. + +import { KEY_DATES, PIR, PROJECTS, QUICK_LINKS, RULE, TEAM_ORDER, TERM, WEEKS } from "./static"; +import type { Round, Tab } from "./types"; + +export const CHAPTER_IDS = ["berkeley", "ucla"] as const; +export type ChapterId = (typeof CHAPTER_IDS)[number]; +export const isChapterId = (v: unknown): v is ChapterId => typeof v === "string" && (CHAPTER_IDS as readonly string[]).includes(v); + +// A paragraph with an optional bold lead-in ("Data:" …). +export type Para = { b?: string; t: string }; +export type TimelineWeek = { n: number; start: string; end: string; label: string; goal: string; items: [track: string, text: string][] }; + +export type ChapterConfig = { + id: ChapterId; + path: string; + title: string; + term: string; + loginSub: string; + // Who members are told to ask when something isn't set up. + contact: string; + roleLabels: Record; + teams: readonly string[]; + rule: string | null; + proofRequired: boolean; + // UCLA keeps one status per person per week and starts new weeks itself. + weekly: boolean; + roundLabel: (r: Round, current: boolean) => string; + glance: { members: string; submissions: string; socials: string }; + quickLinks: { label?: string; text: string; href?: string | null; tab?: Tab }[]; + keyDates: [date: string, text: string][]; + photos: string[]; + socials: { + heading: string; + kinds: { key: "b" | "o"; label: string }[]; + tags: Record; + add: string; + list: string; + note: string; + defaultLead: string; + }; + projects: { + groups: { group: string; items: [name: string, chapter?: string][] }[]; + sections: { title: string; cols: Para[][]; credit?: string }[]; + timeline: { title: string; tracks: string[]; weeks: TimelineWeek[]; graceDays: number }; + }; + meetings: { heading: string; note: string | null; attendance: boolean }; + submit: { note: string; placeholder: string; filesLabel: string; needProof: string }; + myPage: { meetingLine: string | null; teamTrack: Record; teamNote: Record }; +}; + +const shortDay = (iso: string, o: Intl.DateTimeFormatOptions) => { + const [y, m, d] = iso.split("-").map(Number); + return new Date(y, m - 1, d).toLocaleDateString("en-US", o); +}; + +const BERKELEY: ChapterConfig = { + id: "berkeley", + path: "/board", + title: "VINdicated at Berkeley", + term: TERM, + loginSub: "UC Berkeley chapter board", + contact: "Rana", + roleLabels: { admin: "admin", socials: "socials editor", member: "board member" }, + teams: TEAM_ORDER, + rule: RULE, + proofRequired: true, + weekly: false, + roundLabel: (r, current) => + current + ? `Round ${r.round} · Due ${shortDay(r.dueDate, { weekday: "short", month: "short", day: "numeric" })}, 11:59pm` + : `Round ${r.round} archive · due ${shortDay(r.dueDate, { month: "short", day: "numeric" })}`, + glance: { members: "members on the Berkeley board", submissions: "round 2 submissions in", socials: "socials on the calendar" }, + quickLinks: QUICK_LINKS, + keyDates: KEY_DATES, + photos: [], + socials: { + heading: "Fall 2026 Socials", + kinds: [ + { key: "b", label: "Berkeley" }, + { key: "o", label: "Online" }, + ], + tags: { b: "Berkeley", o: "Online", org: "Org date" }, + add: "Add a social", + list: "All socials", + note: "Status runs Idea → Planning → Confirmed → Completed.", + defaultLead: "TBD", + }, + projects: { + groups: PROJECTS, + sections: [ + { + title: "Project Price Is Right", + cols: [ + [{ t: PIR.intro }, { b: "Modes:", t: PIR.modes }, { b: "Screens:", t: PIR.screens }], + [{ b: "How it fits together:", t: PIR.howItFits }], + ], + credit: PIR.credit, + }, + ], + timeline: { title: "6-week calendar", tracks: ["Data", "Backend", "App"], weeks: WEEKS, graceDays: 2 }, + }, + meetings: { heading: "Meeting Summaries & Recordings", note: null, attendance: false }, + submit: { + note: "This form emails your submission and any files to Rana. Attach proof or paste a link to it in the notes.", + placeholder: "Paste your write-up, call notes, links, or anything Rana should read.", + filesLabel: "Proof (required unless you paste a link above): screenshots, docs, call logs. Up to 14 MB in total.", + needProof: "Add proof: attach a file or paste a link to your post, doc or PR.", + }, + myPage: { + meetingLine: null, + teamTrack: { Data: "Data", Software: "App" }, + teamNote: {}, + }, +}; + +const UCLA_WEEKS: TimelineWeek[] = [ + { n: 1, label: "Sep 20 – 26", start: "2026-09-20", end: "2026-09-26", goal: "Projects locked, recruiting open", items: [["Data", "Scope locked, IIHS API application submitted, recruiting open"], ["Research", "Finalize the three projects and start finding faculty mentors"], ["Outreach", "Bruinwalk tabling: Guess the OTD Price game"], ["High school", "Research how to start a chapter at FVHS and find an advisor"]] }, + { n: 2, label: "Sep 27 – Oct 3", start: "2026-09-27", end: "2026-10-03", goal: "Teams in place", items: [["Data", "Team of 4 in place; NHTSA data loaded and its gaps documented"], ["Research", "Recruit and onboard members; intro to qualitative research and ethics"], ["Outreach", "IG Question of the Week; design \"too good to be true\" listings"], ["High school", "Start the chapter process and recruit through connections"]] }, + { n: 3, label: "Oct 4 – 10", start: "2026-10-04", end: "2026-10-10", goal: "First real work", items: [["Data", "A canonical vehicle list and a pipeline every member can run"], ["Research", "Preliminary research: what's known, the debates, the gaps"], ["Outreach", "Board intro video; tape fake listings around campus"], ["High school", "Finalize materials and pick a first student activity"]] }, + { n: 4, label: "Oct 11 – 17", start: "2026-10-11", end: "2026-10-17", goal: "Methods and first match", items: [["Data", "First working match between NHTSA and IIHS; output shape locked"], ["Research", "Set the method, source criteria and research plan"], ["Outreach", "Bruinwalk tabling: sticky-note whiteboard"], ["High school", "Launch first resources and a short student survey"]] }, + { n: 5, label: "Oct 18 – 24", start: "2026-10-18", end: "2026-10-24", goal: "Collect", items: [["Data", "Go / no-go on IIHS access; one table with both programs' ratings"], ["Research", "Collect sources: literature, ads, complaints, legal documents"], ["Outreach", "Midterm break: event recap on IG, plan interviews"], ["High school", "First student-facing table or activity"]] }, + { n: 6, label: "Oct 25 – 31", start: "2026-10-25", end: "2026-10-31", goal: "First findings", items: [["Data", "First findings: how often the programs disagree, and where"], ["Research", "Analyze materials for themes and patterns"], ["Outreach", "Quick on-campus interviews for IG; plan the dorm event"], ["High school", "Review results; ask the board to fact-check materials"]] }, + { n: 7, label: "Nov 1 – 7", start: "2026-11-01", end: "2026-11-07", goal: "Broader findings", items: [["Data", "One finding no existing car site has"], ["Research", "Move from observations to broader findings"], ["Outreach", "Dorm event: car-buying basics and DIY air freshener sachets"], ["High school", "Reach out to local driving schools and partners"]] }, + { n: 8, label: "Nov 8 – 14", start: "2026-11-08", end: "2026-11-14", goal: "MVP", items: [["Data", "MVP running end to end, rough but nothing faked"], ["Research", "Turn findings into practical takeaways"], ["Outreach", "Edit interviews"], ["High school", "Develop one outside partnership"]] }, + { n: 9, label: "Nov 15 – 21", start: "2026-11-15", end: "2026-11-21", goal: "Write it up", items: [["Data", "MVP complete, frozen, documented and reproducible"], ["Research", "Write and revise the final product; peer review"], ["Outreach", "Edit interviews"], ["High school", "Plan a bigger second-semester event"]] }, + { n: 10, label: "Nov 22 – 28", start: "2026-11-22", end: "2026-11-28", goal: "Demo and wrap-up", items: [["Data", "Demo and writeup"], ["Research", "Limitations and future research"], ["Outreach", "Post interview reels"], ["High school", "Compile results from the first 10 weeks"]] }, +]; + +const UCLA: ChapterConfig = { + id: "ucla", + path: "/board/ucla", + title: "VINdicated at UCLA", + term: "UCLA chapter · Board hub, Fall 2026", + loginSub: "UCLA chapter board hub", + contact: "Fiona", + roleLabels: { admin: "admin", member: "member" }, + teams: ["Data", "Research", "App Dev", "Design", "Technical Writing", "High School"], + rule: null, + proofRequired: false, + weekly: true, + roundLabel: (r, current) => + current + ? `Deliverables · Due ${shortDay(r.dueDate, { weekday: "short", month: "short", day: "numeric" })}` + : `Week due ${shortDay(r.dueDate, { weekday: "short", month: "short", day: "numeric" })}`, + glance: { members: "people on the UCLA chart", submissions: "deliverable sets submitted", socials: "events on the calendar" }, + quickLinks: [ + { text: "Member Drive 2025–26", href: null }, + { text: "2025–2026 Curriculum", href: null }, + { text: "Google Drive for photos", href: null }, + { text: "Outreach spreadsheets", href: null }, + { label: "Slack", text: "vindicatedgroup", href: "https://join.slack.com/t/vindicatedgroup/shared_invite/zt-4bavmrqhi-XJb9OIt4aRq6SpGaNyoKtg" }, + { label: "Board meetings", text: "Sundays, 6–6:30pm" }, + ], + keyDates: [ + ["2026-10-18", "Oct 11 deliverables due"], + ["2026-10-21", "Skribbl night with Berkeley, 9pm"], + ["2026-10-25", "Data: first findings (week 6)"], + ["2026-11-08", "Data: MVP running end to end (week 8)"], + ["2026-11-22", "Crosscheck demo and research writeups (week 10)"], + ], + photos: [1, 2, 3, 4, 5, 6].map((n) => `/board/ucla/board-${n}.webp`), + socials: { + heading: "Fall 2026 Events", + kinds: [ + { key: "b", label: "On campus" }, + { key: "o", label: "Online" }, + ], + tags: { b: "On campus", o: "Online", org: "Date" }, + add: "Add an event", + list: "All events", + note: "Status runs Idea → Planning → Confirmed → Completed. Days marked TBD come from the outreach timeline, which plans by week.", + defaultLead: "", + }, + projects: { + groups: [ + { group: "Data", items: [["Project Crosscheck"]] }, + { group: "Research", items: [["Language, Trust & Access"], ["The Hidden Price Gap"], ["After the Accident: Know Your Rights"]] }, + { group: "App Dev", items: [["DMV test practice"], ["Interactive quizzes"], ["Crash list and safety scores by model"]] }, + { group: "Outreach", items: [["Bruinwalk tabling"], ["Scam-awareness listings"], ["Dorm event"], ["IG Question of the Week"]] }, + { group: "High School", items: [["First-Car Survival campaign, Fountain Valley HS"]] }, + ], + sections: [ + { + title: "Project Crosscheck", + cols: [ + [ + { t: "A public, side-by-side table of NHTSA and IIHS safety ratings for model years 2023–2026, built into the VINdicated site." }, + { b: "Question for v1:", t: "how often do NHTSA and IIHS ratings disagree? Which one better predicts real-world outcomes is saved for v2." }, + ], + [ + { b: "Data:", t: "NHTSA ratings are public. IIHS needs an approved API key; the application is in." }, + { b: "Rules:", t: "no combined safety score, no scraping iihs.org, and the public table is generated by the pipeline, never edited by hand." }, + ], + ], + }, + { + title: "Research projects", + cols: [ + [ + { b: "Language, Trust & Access:", t: "how multilingual consumers experience communication, trust and access to information at dealerships." }, + { b: "The Hidden Price Gap:", t: "what makes car pricing hard to understand: dealer add-ons, fees, \"starting at\" prices and monthly payment ads." }, + ], + [ + { b: "After the Accident: Know Your Rights:", t: "a plain-language California guide for when something goes wrong: accidents, dealer disputes, fraud, repair and financing problems." }, + ], + ], + }, + ], + timeline: { title: "Fall quarter timeline", tracks: ["Data", "Research", "Outreach", "High school"], weeks: UCLA_WEEKS, graceDays: 0 }, + }, + meetings: { heading: "Board Meetings", note: "Sundays, 6–6:30pm.", attendance: true }, + submit: { + note: "This form emails your update and any files to the board. Attach files or paste links in the notes.", + placeholder: "Paste your update, links, or anything Fiona should read.", + filesLabel: "Files: docs, screenshots, designs. Up to 14 MB in total.", + needProof: "Add an update or attach a file before submitting.", + }, + myPage: { + meetingLine: "Board meeting, Sundays 6–6:30pm", + teamTrack: { Data: "Data", Research: "Research", "High School": "High school" }, + teamNote: { + "App Dev": "App ideas: DMV test practice, interactive quizzes, and a crash list with safety scores by model.", + Design: "Start or continue your plan, and update Fiona on progress.", + "Technical Writing": "Start or continue your plan, and update Fiona on progress.", + Executive: "Board operations: Sunday board meetings, deliverables, collabs and the signatory workshop.", + }, + }, +}; + +export const CHAPTERS: Record = { berkeley: BERKELEY, ucla: UCLA }; +export const chapterConfig = (id: ChapterId) => CHAPTERS[id]; diff --git a/lib/board/data.ts b/lib/board/data.ts index 6561b96..4ea62db 100644 --- a/lib/board/data.ts +++ b/lib/board/data.ts @@ -1,34 +1,44 @@ import "server-only"; import { db } from "./db"; import { ensureSetup } from "./setup"; -import type { BoardRole } from "./roles"; +import { recipientNames } from "./chapter-server"; +import type { BoardUser } from "./session"; import { UNASSIGNED } from "./static"; import type { BoardData } from "./types"; -// Tasks with no owner yet go to admins only; everyone else never gets them. -export async function loadBoard(role: BoardRole): Promise { +// Everything one chapter's hub shows, for the signed-in user. Members never +// receive tasks with no owner yet or admins-only announcements. +export async function loadBoard(user: BoardUser): Promise { await ensureSetup(); const sql = db(); - const [members, assignments, submissions, socials, announcements, meetings] = await Promise.all([ + const c = user.chapter; + const admin = user.role === "admin"; + const [rounds, members, assignments, submissions, socials, announcements, meetings] = await Promise.all([ + sql`SELECT round, due_date AS "dueDate", is_current AS "isCurrent", show_status AS "showStatus", note + FROM board_rounds WHERE chapter = ${c} ORDER BY round DESC`, sql`SELECT id, name, first_name AS "firstName", role, team, also_team AS "alsoTeam", tier, is_lead AS "isLead", email, phone - FROM board_members ORDER BY sort, id`, + FROM board_members WHERE chapter = ${c} ORDER BY sort, id`, sql`SELECT id, round, person, person_role AS "personRole", task, proof_required AS proof, due_date AS "dueDate", status, status_note AS "statusNote" - FROM board_assignments ${role === "admin" ? sql`` : sql`WHERE person <> ${UNASSIGNED}`} + FROM board_assignments WHERE chapter = ${c} ${admin ? sql`` : sql`AND person <> ${UNASSIGNED}`} ORDER BY round DESC, sort, id`, sql`SELECT id, person, assignment_id AS "assignmentId", task_label AS "taskLabel", filenames, created_at AS "createdAt" - FROM board_submissions ORDER BY created_at`, + FROM board_submissions WHERE chapter = ${c} ORDER BY created_at`, sql`SELECT id, date, time, title, location, kind, lead, status, cost, note - FROM board_socials ORDER BY date, id`, - sql`SELECT id, title, body, posted_on AS "postedOn", link_tab AS "linkTab", link_label AS "linkLabel" - FROM board_announcements ORDER BY sort, posted_on DESC, id DESC`, - sql`SELECT id, date, title, summary, recording_url AS "recordingUrl" - FROM board_meetings ORDER BY date DESC, id DESC`, + FROM board_socials WHERE chapter = ${c} ORDER BY date, id`, + sql`SELECT id, title, body, posted_on AS "postedOn", link_tab AS "linkTab", link_label AS "linkLabel", + admins_only AS "adminsOnly" + FROM board_announcements WHERE chapter = ${c} ${admin ? sql`` : sql`AND NOT admins_only`} + ORDER BY sort, posted_on DESC, id DESC`, + sql`SELECT id, date, title, summary, absent, recording_url AS "recordingUrl" + FROM board_meetings WHERE chapter = ${c} ORDER BY date DESC, id DESC`, ]); const plain = (rows: unknown) => JSON.parse(JSON.stringify(rows)) as T; return { + rounds: plain(rounds), + recipients: recipientNames(c), members: plain(members), assignments: plain(assignments), submissions: plain(submissions), diff --git a/lib/board/editable.ts b/lib/board/editable.ts index 0d86fec..97174df 100644 --- a/lib/board/editable.ts +++ b/lib/board/editable.ts @@ -1,8 +1,10 @@ import "server-only"; -import { TABLES, type TableDef } from "./tables"; +import { TABLES, oneOf, type TableDef } from "./tables"; import type { BoardRole } from "./roles"; +import { chapterConfig, type ChapterId } from "./chapters"; -// Which roles may change each table through /api/board/[table]. +// Which roles may change each table through /api/board/[table]. UCLA has no +// socials role, so only its admins edit events. const EDITORS: Record = { socials: ["admin", "socials"], assignments: ["admin"], @@ -11,12 +13,16 @@ const EDITORS: Record = { members: ["admin"], }; -export function editable(name: string): { def: TableDef; roles: BoardRole[]; name: keyof typeof TABLES } | null { +export function editable(name: string, chapter: ChapterId): { def: TableDef; roles: BoardRole[]; name: keyof typeof TABLES } | null { if (!Object.hasOwn(TABLES, name)) return null; const key = name as keyof typeof TABLES; - return { def: TABLES[key], roles: EDITORS[key], name: key }; + let def: TableDef = TABLES[key]; + if (key === "members") { + const teams = ["Executive", ...chapterConfig(chapter).teams]; + def = { ...def, fields: { ...def.fields, team: { column: "team", check: oneOf(teams, "a team") } } }; + } + return { def, roles: EDITORS[key], name: key }; } - // Members store a first name for matching assignments and submissions. export function derived(name: string, body: Record): Record { if (name === "members" && typeof body.name === "string" && body.name.trim()) { diff --git a/lib/board/roles.ts b/lib/board/roles.ts index 4ea66cd..4530e28 100644 --- a/lib/board/roles.ts +++ b/lib/board/roles.ts @@ -1,26 +1,11 @@ -// Who can do what on /board. People sign in with one of three passwords, -// all set as environment variables in Railway: -// BOARD_PASSWORD everyone on the board: view and submit -// BOARD_SOCIALS_PASSWORD socials editor (no one yet; set it to add one): also add/edit/delete socials -// BOARD_ADMIN_PASSWORD admin (Rana): edit everything - -// Where assignment submissions are emailed. -export const SUBMISSIONS_INBOX = { email: "ranadarwich05@gmail.com", name: "Rana Darwich" }; +// Who can do what on the board hub. People sign in with a shared password +// for their chapter; which Railway variable holds which role's password is +// set per chapter in chapter-server.ts: +// Berkeley BOARD_ADMIN_PASSWORD (Rana), BOARD_SOCIALS_PASSWORD (socials +// editor; no one yet, leave unset), BOARD_PASSWORD (everyone) +// UCLA BOARD_UCLA_ADMIN_PASSWORD (Fiona and Will), BOARD_UCLA_PASSWORD export type BoardRole = "admin" | "socials" | "member"; -export const ROLE_LABEL: Record = { - admin: "admin", - socials: "socials editor", - member: "board member", -}; - -// Checked in this order, so a password shared by two roles gets the higher one. -export const ROLE_PASSWORD_ENV: [BoardRole, string][] = [ - ["admin", "BOARD_ADMIN_PASSWORD"], - ["socials", "BOARD_SOCIALS_PASSWORD"], - ["member", "BOARD_PASSWORD"], -]; - export const canEditSocials = (role: BoardRole) => role === "admin" || role === "socials"; export const isAdmin = (role: BoardRole) => role === "admin"; diff --git a/lib/board/session.ts b/lib/board/session.ts index a0307b0..f8c937a 100644 --- a/lib/board/session.ts +++ b/lib/board/session.ts @@ -1,27 +1,31 @@ import "server-only"; import { createHash, createHmac, timingSafeEqual } from "node:crypto"; -import { ROLE_PASSWORD_ENV, type BoardRole } from "./roles"; +import type { ChapterId } from "./chapters"; +import { CHAPTER_SERVER } from "./chapter-server"; +import type { BoardRole } from "./roles"; -// Password sign-in for /board. A correct password sets a signed cookie -// that holds the role and an expiry. The cookie also carries a short +// Password sign-in for the board hub. A correct password sets a signed +// cookie that holds the chapter, the role and an expiry. Each chapter has +// its own cookie and only checks its own passwords, so a Berkeley password +// never opens UCLA or the reverse. The cookie also carries a short // fingerprint of the password it came from, so changing a password in // Railway signs out everyone who used the old one. -export const SESSION_COOKIE = "board_session"; export const SESSION_DAYS = 30; -export type BoardUser = { role: BoardRole }; +export type BoardUser = { role: BoardRole; chapter: ChapterId }; const sha = (s: string) => createHash("sha256").update(s).digest(); -const fingerprint = (password: string) => sha(`board:${password}`).toString("base64url").slice(0, 12); +const fingerprint = (chapter: ChapterId, password: string) => + sha(`board:${chapter}:${password}`).toString("base64url").slice(0, 12); function secret(): string | null { const s = process.env.BOARD_SESSION_SECRET?.trim(); return s && s.length >= 32 ? s : null; } -function passwordFor(role: BoardRole): string | null { - const env = ROLE_PASSWORD_ENV.find(([r]) => r === role)?.[1]; +function passwordFor(chapter: ChapterId, role: BoardRole): string | null { + const env = CHAPTER_SERVER[chapter].passwords.find(([r]) => r === role)?.[1]; const p = env ? process.env[env]?.trim() : ""; return p ? p : null; } @@ -32,62 +36,66 @@ const sign = (payload: string, key: string) => createHmac("sha256", key).update( // of a password matched. const same = (a: string, b: string) => timingSafeEqual(sha(a), sha(b)); -// Why sign-in can't work yet, for the server log, or null when it can. -export function setupProblem(): string | null { +// Why sign-in can't work yet for a chapter, for the server log, or null. +export function setupProblem(chapter: ChapterId): string | null { if (!secret()) return "BOARD_SESSION_SECRET is missing or shorter than 32 characters."; - if (!ROLE_PASSWORD_ENV.some(([role]) => passwordFor(role))) { - return "No board password is set (BOARD_PASSWORD, BOARD_SOCIALS_PASSWORD or BOARD_ADMIN_PASSWORD)."; + const vars = CHAPTER_SERVER[chapter].passwords; + if (!vars.some(([role]) => passwordFor(chapter, role))) { + return `No ${chapter} board password is set (${vars.map(([, v]) => v).join(", ")}).`; } return null; } -// Returns the role a password unlocks, or null. -export function roleForPassword(attempt: string): BoardRole | null { +// Returns the role a password unlocks for this chapter, or null. +export function roleForPassword(chapter: ChapterId, attempt: string): BoardRole | null { let found: BoardRole | null = null; - for (const [role] of ROLE_PASSWORD_ENV) { - const p = passwordFor(role); + for (const [role] of CHAPTER_SERVER[chapter].passwords) { + const p = passwordFor(chapter, role); // Keep comparing after a match so every attempt takes the same time. if (p && same(attempt, p) && !found) found = role; } return found; } -export function createSession(role: BoardRole): string | null { +export function createSession(chapter: ChapterId, role: BoardRole): string | null { const key = secret(); - const password = passwordFor(role); + const password = passwordFor(chapter, role); if (!key || !password) return null; const exp = Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400; - const payload = Buffer.from(JSON.stringify({ r: role, e: exp, f: fingerprint(password) })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ c: chapter, r: role, e: exp, f: fingerprint(chapter, password) })).toString("base64url"); return `${payload}.${sign(payload, key)}`; } -function readCookie(headers: Headers): string | null { - const m = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(headers.get("cookie") ?? ""); +function readCookie(headers: Headers, name: string): string | null { + const m = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(headers.get("cookie") ?? ""); return m ? m[1] : null; } -// The signed-in board user for this request, or null. -export function boardUser(headers: Headers): BoardUser | null { +// The signed-in user of this chapter for this request, or null. +export function boardUser(headers: Headers, chapter: ChapterId): BoardUser | null { const key = secret(); - const token = readCookie(headers); + const token = readCookie(headers, CHAPTER_SERVER[chapter].cookie); if (!key || !token) return null; const [payload, sig] = token.split("."); if (!payload || !sig) return null; const expected = sign(payload, key); if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null; try { - const { r, e, f } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); - if (r !== "admin" && r !== "socials" && r !== "member") return null; + const { c, r, e, f } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + // Sessions from before chapters existed have no `c` and were Berkeley's. + if ((c ?? "berkeley") !== chapter) return null; + if (!CHAPTER_SERVER[chapter].passwords.some(([role]) => role === r)) return null; if (typeof e !== "number" || e < Date.now() / 1000) return null; - const password = passwordFor(r); - if (!password || f !== fingerprint(password)) return null; - return { role: r }; + const password = passwordFor(chapter, r); + if (!password) return null; + const ok = f === fingerprint(chapter, password) || (c === undefined && f === sha(`board:${password}`).toString("base64url").slice(0, 12)); + return ok ? { role: r, chapter } : null; } catch { return null; } } -export function sessionCookie(value: string, maxAgeSeconds: number) { +export function sessionCookie(chapter: ChapterId, value: string, maxAgeSeconds: number) { const secure = process.env.NODE_ENV === "production" ? "; Secure" : ""; - return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`; + return `${CHAPTER_SERVER[chapter].cookie}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}${secure}`; } diff --git a/lib/board/setup.ts b/lib/board/setup.ts index 2c06601..0c324c1 100644 --- a/lib/board/setup.ts +++ b/lib/board/setup.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { db } from "./db"; import starter from "./starter-data.json"; +import ucla from "./starter-ucla.json"; // Makes the board database ready on first use, so nobody has to run // commands: creates the tables (scripts/board-schema.sql, safe to re-run) @@ -25,8 +26,8 @@ async function setup() { const schema = readFileSync(join(process.cwd(), "scripts", "board-schema.sql"), "utf8"); await sql.unsafe(schema); - const empty = async (table: string) => { - const [{ n }] = await sql`SELECT count(*)::int AS n FROM ${sql(table)}`; + const empty = async (table: string, chapter = "berkeley") => { + const [{ n }] = await sql`SELECT count(*)::int AS n FROM ${sql(table)} WHERE chapter = ${chapter}`; return n === 0; }; @@ -56,6 +57,13 @@ async function setup() { for (const m of starter.meetings) await sql`INSERT INTO board_meetings ${sql(m)}`; } await octoberUpdates(sql); + await allowExcused(sql); + const [{ n: berkeleyRounds }] = await sql`SELECT count(*)::int AS n FROM board_rounds WHERE chapter = 'berkeley'`; + if (berkeleyRounds === 0) { + await sql`INSERT INTO board_rounds (chapter, round, due_date, is_current) VALUES + ('berkeley', 1, '2026-09-26', false), ('berkeley', 2, ${starter.dueRound2}, true)`; + } + await seedUcla(sql, empty); } const OLD_IAN = "Start the /board page on getvindicated.org from this preview"; @@ -65,16 +73,16 @@ const OLD_HALIMA = ["Follow up with Cal TV and set a collaboration date", "Messa // database still has the old ones, swap them for the new ones once. async function retaskRound2(sql: ReturnType) { const [old] = await sql`SELECT id FROM board_assignments - WHERE round = 2 AND person = 'Ian' AND task = ${OLD_IAN}`; + WHERE chapter = 'berkeley' AND round = 2 AND person = 'Ian' AND task = ${OLD_IAN}`; if (!old) return; const tasks = (name: string) => (starter.round2 as [string, string, [string, string][]][]).find(([p]) => p === name)![2]; await sql.begin(async (tx) => { const ian = tasks("Ian"); await tx`UPDATE board_assignments SET task = ${ian[1][0]}, proof_required = ${ian[1][1]} WHERE id = ${old.id}`; - await tx`UPDATE board_assignments SET person_role = 'Software Lead' WHERE round = 2 AND person = 'Ian'`; + await tx`UPDATE board_assignments SET person_role = 'Software Lead' WHERE chapter = 'berkeley' AND round = 2 AND person = 'Ian'`; - const halima = await tx`SELECT id, task FROM board_assignments WHERE round = 2 AND person = 'Halima' ORDER BY sort, id`; + const halima = await tx`SELECT id, task FROM board_assignments WHERE chapter = 'berkeley' AND round = 2 AND person = 'Halima' ORDER BY sort, id`; const stale = halima.filter((r) => OLD_HALIMA.some((t) => r.task.startsWith(t))); if (stale.length) { const [task, proof] = tasks("Halima")[0]; @@ -101,18 +109,18 @@ async function octoberUpdates(sql: ReturnType) { // Pierce await tx`DELETE FROM board_members - WHERE lower(email) = 'pierce.kosobayashi@berkeley.edu' OR name ILIKE '%Kosobayashi%'`; - await tx`DELETE FROM board_submissions WHERE person = 'Pierce'`; - await tx`DELETE FROM board_assignments WHERE person = 'Pierce'`; + WHERE chapter = 'berkeley' AND (lower(email) = 'pierce.kosobayashi@berkeley.edu' OR name ILIKE '%Kosobayashi%')`; + await tx`DELETE FROM board_submissions WHERE chapter = 'berkeley' AND person = 'Pierce'`; + await tx`DELETE FROM board_assignments WHERE chapter = 'berkeley' AND person = 'Pierce'`; // His round 2 tasks: Daryen's court booking, then the unowned three. const add = async (person: string, task: string, proof: string, role: string, sort: number) => { - const [exists] = await tx`SELECT 1 FROM board_assignments WHERE round = 2 AND person = ${person} AND task = ${task}`; + const [exists] = await tx`SELECT 1 FROM board_assignments WHERE chapter = 'berkeley' AND round = 2 AND person = ${person} AND task = ${task}`; if (exists) return; await tx`INSERT INTO board_assignments (round, person, person_role, task, proof_required, due_date, sort) VALUES (2, ${person}, ${role}, ${task}, ${proof}, ${starter.dueRound2}, ${sort})`; }; - const [{ max }] = await tx`SELECT coalesce(max(sort), 0) AS max FROM board_assignments WHERE round = 2 AND person = 'Daryen'`; + const [{ max }] = await tx`SELECT coalesce(max(sort), 0) AS max FROM board_assignments WHERE chapter = 'berkeley' AND round = 2 AND person = 'Daryen'`; const [, daryenRole, daryen] = tasksOf("Daryen"); const court = daryen[daryen.length - 1]; await add("Daryen", court[0], court[1], daryenRole, max + 1); @@ -121,18 +129,66 @@ async function octoberUpdates(sql: ReturnType) { // Roles on the chart await tx`UPDATE board_members SET role = 'Outreach Lead · Social Media', team = 'Outreach', is_lead = true, tier = NULL - WHERE name ILIKE 'Chloe%Lin'`; + WHERE chapter = 'berkeley' AND name ILIKE 'Chloe%Lin'`; await tx`UPDATE board_members SET role = 'Research Analyst', team = 'Research & Legal', is_lead = false, tier = NULL - WHERE name ILIKE 'Rami%Dakak'`; + WHERE chapter = 'berkeley' AND name ILIKE 'Rami%Dakak'`; await tx`UPDATE board_members SET role = 'President · Research Lead', team = 'Executive', tier = 0, also_team = 'Research & Legal' - WHERE name ILIKE 'Rana%Darwich'`; + WHERE chapter = 'berkeley' AND name ILIKE 'Rana%Darwich'`; await tx`UPDATE board_members SET role = 'Software Lead', team = 'Software', is_lead = true, tier = NULL - WHERE name ILIKE 'Ian%Houssaye'`; + WHERE chapter = 'berkeley' AND name ILIKE 'Ian%Houssaye'`; // Socials: exactly the starter list, and no more UCSC x UCB type. - await tx`DELETE FROM board_socials`; + await tx`DELETE FROM board_socials WHERE chapter = 'berkeley'`; for (const s of starter.socials) await tx`INSERT INTO board_socials ${tx(s)}`; await tx`ALTER TABLE board_socials DROP CONSTRAINT IF EXISTS board_socials_kind_check`; await tx`ALTER TABLE board_socials ADD CONSTRAINT board_socials_kind_check CHECK (kind IN ('b', 'o', 'org'))`; }); } + +// Adds the "excused" status (UCLA) to a database made before it existed. +async function allowExcused(sql: ReturnType) { + await sql.begin(async (tx) => { + const [first] = await tx`INSERT INTO board_changes (name) VALUES ('2026-10-13-excused-status') + ON CONFLICT DO NOTHING RETURNING name`; + if (!first) return; + await tx`ALTER TABLE board_assignments DROP CONSTRAINT IF EXISTS board_assignments_status_check`; + await tx`ALTER TABLE board_assignments ADD CONSTRAINT board_assignments_status_check + CHECK (status IN ('pending', 'submitted', 'done', 'missing', 'excused'))`; + }); +} + +// UCLA's starting data from docs/board-ucla-preview.html: the deliverables +// Fiona set on Oct 11 (due Oct 18), the Oct 4 week as an archive with no +// status, events, announcements and meetings. Members are imported on the +// Board tab, like Berkeley's. +async function seedUcla(sql: ReturnType, empty: (table: string, chapter?: string) => Promise) { + const [{ n }] = await sql`SELECT count(*)::int AS n FROM board_rounds WHERE chapter = 'ucla'`; + if (n === 0) { + for (const r of ucla.rounds) await sql`INSERT INTO board_rounds ${sql({ ...r, chapter: "ucla" })}`; + } + if (await empty("board_assignments", "ucla")) { + const due = (round: number) => ucla.rounds.find((r) => r.round === round)!.due_date; + let sort = 0; + for (const [person, role, tasks] of ucla.current as [string, string, string[]][]) { + for (const task of tasks) { + await sql`INSERT INTO board_assignments (chapter, round, person, person_role, task, due_date, sort) + VALUES ('ucla', 2, ${person}, ${role}, ${task}, ${due(2)}, ${sort++})`; + } + } + for (const [person, role, task] of ucla.archive as [string, string, string][]) { + await sql`INSERT INTO board_assignments (chapter, round, person, person_role, task, due_date, sort) + VALUES ('ucla', 1, ${person}, ${role}, ${task}, ${due(1)}, ${sort++})`; + } + } + if (await empty("board_socials", "ucla")) { + for (const e of ucla.events) await sql`INSERT INTO board_socials ${sql({ ...e, chapter: "ucla" })}`; + } + if (await empty("board_announcements", "ucla")) { + for (const [i, a] of ucla.announcements.entries()) { + await sql`INSERT INTO board_announcements ${sql({ ...a, sort: i, chapter: "ucla" })}`; + } + } + if (await empty("board_meetings", "ucla")) { + for (const m of ucla.meetings) await sql`INSERT INTO board_meetings ${sql({ ...m, chapter: "ucla" })}`; + } +} diff --git a/lib/board/starter-ucla.json b/lib/board/starter-ucla.json new file mode 100644 index 0000000..3907379 --- /dev/null +++ b/lib/board/starter-ucla.json @@ -0,0 +1,267 @@ +{ + "rounds": [ + { + "round": 1, + "due_date": "2026-10-11", + "is_current": false, + "show_status": false, + "note": "" + }, + { + "round": 2, + "due_date": "2026-10-18", + "is_current": true, + "show_status": true, + "note": "Set at the Oct 11 board meeting. Update Fiona on your progress, and submit anything you made below." + } + ], + "current": [ + [ + "Fiona", + "President", + [ + "Make sure everyone is doing their work and manage members", + "Sign up for the SOLE signatory workshop (sole.ucla.edu/dayosole)" + ] + ], + [ + "Will", + "Internal Vice President", + [ + "Send the when2meet for weekly in-person general meetings; everyone fills it out by Wednesday night, then tell Fiona when everyone is available", + "Add Nikhil to all board chats, weekly board meetings and the Drive", + "Sign up for the SOLE signatory workshop (sole.ucla.edu/dayosole)" + ] + ], + [ + "Ayat", + "External Vice President", + [ + "Update and manage Instagram, and mass follow to grow followers", + "Reach out to other clubs about collaborating or featuring us on their page", + "Ask consulting and data clubs (DataRes consulting, NSDC, Creative Labs UCLA) whether we can be one of their projects", + "Update Fiona on collab progress and cc her on every email", + "Sign up for the SOLE signatory workshop (sole.ucla.edu/dayosole)" + ] + ], + [ + "Rizwaan", + "Software Lead", + [ + "Urgent: set a meeting with Nikhil to onboard him, covering what he needs to do and the project specifics" + ] + ], + [ + "Ray", + "Data Lead", + [ + "Start week two of your plan", + "Update Fiona on week 1 progress", + "Keep building your team through people you know who'll want to work with you" + ] + ], + [ + "Fernando", + "Research Lead", + [ + "Start week two of your plan", + "Update Fiona on week 1 progress", + "Keep building your team through people you know who'll want to work with you" + ] + ], + [ + "Celine", + "Design Lead", + [ + "Start week one of your plan", + "Update Fiona on week 1 progress", + "Keep building your team through people you know who'll want to work with you" + ] + ], + [ + "Brisa", + "Technical Writing Lead", + [ + "Start week two of your plan", + "Update Fiona on week 1 progress", + "Keep building your team through people you know who'll want to work with you" + ] + ], + [ + "Rouqaya", + "High School Program", + [ + "Update Fiona on progress at the high school" + ] + ] + ], + "archive": [ + [ + "Fiona", + "President", + "Manage applications and the club application, signatory workshop" + ], + [ + "Will", + "Internal Vice President", + "Manage applications and the club application, signatory workshop" + ], + [ + "Ayat", + "External Vice President", + "Instagram, club collabs, signatory workshop" + ], + [ + "Rizwaan", + "Software Lead", + "Onboard Nikhil" + ], + [ + "Ray", + "Data Lead", + "Start week one, pick your team" + ], + [ + "Fernando", + "Research Lead", + "Start week one, pick your team" + ], + [ + "Celine", + "Design Lead", + "Start week one, pick your team" + ], + [ + "Brisa", + "Technical Writing Lead", + "Start week one, pick your team" + ] + ], + "events": [ + { + "date": "2026-10-12", + "time": "Day TBD (week 4)", + "title": "Bruinwalk tabling", + "location": "Bruin Walk", + "kind": "b", + "lead": "Ayat", + "status": "Planning", + "cost": "Sticky notes and a whiteboard", + "note": "Interactive whiteboard where students share car experiences, questions and concerns on sticky notes." + }, + { + "date": "2026-10-21", + "time": "9:00–10:00pm", + "title": "Skribbl night with Berkeley", + "location": "Discord", + "kind": "o", + "lead": "TBD", + "status": "Planning", + "cost": "Free", + "note": "Joint online social with the Berkeley chapter." + }, + { + "date": "2026-10-26", + "time": "All week (week 6)", + "title": "On-campus interviews for IG", + "location": "Around campus", + "kind": "b", + "lead": "TBD", + "status": "Idea", + "cost": "Free", + "note": "Quick interviews for Instagram content. Plan questions, locations and volunteers during midterm week." + }, + { + "date": "2026-11-02", + "time": "Day TBD (week 7)", + "title": "Dorm event", + "location": "The Hill, TBD", + "kind": "b", + "lead": "TBD", + "status": "Idea", + "cost": "Supplies for air freshener sachets", + "note": "Car-buying basics plus DIY air freshener sachets. Work with RAs to reach residents." + }, + { + "date": "2026-11-11", + "time": "", + "title": "Veterans Day", + "location": "", + "kind": "org", + "lead": "", + "status": "", + "cost": "", + "note": "Holiday." + }, + { + "date": "2026-11-22", + "time": "", + "title": "Crosscheck demo and writeup (week 10)", + "location": "", + "kind": "org", + "lead": "Ray", + "status": "", + "cost": "", + "note": "Data team's end-of-quarter demo." + }, + { + "date": "2026-11-26", + "time": "", + "title": "Thanksgiving", + "location": "", + "kind": "org", + "lead": "", + "status": "", + "cost": "", + "note": "Holiday through Nov 27." + } + ], + "announcements": [ + { + "title": "Deliverables from the Oct 11 meeting", + "body": "Due Sunday, Oct 18. Update Fiona on your progress.", + "posted_on": "2026-10-11", + "link_tab": "assignments", + "link_label": "Open", + "admins_only": false + }, + { + "title": "Signatory workshop", + "body": "Fiona, Will and Ayat: sign up for the SOLE signatory workshop at sole.ucla.edu/dayosole.", + "posted_on": "2026-10-11", + "link_tab": "", + "link_label": "", + "admins_only": false + }, + { + "title": "This week: Bruinwalk tabling", + "body": "Interactive whiteboard where students share car experiences, questions and concerns on sticky notes.", + "posted_on": "2026-10-11", + "link_tab": "socials", + "link_label": "Open", + "admins_only": false + }, + { + "title": "Finances", + "body": "Balance $82.55 after $140 in T-shirt sales. Fiona's $57.45 loan for shirts and stickers has been paid back.", + "posted_on": "2026-10-11", + "link_tab": "", + "link_label": "", + "admins_only": true + } + ], + "meetings": [ + { + "date": "2026-10-11", + "absent": "Celine, Rizwaan, Fernando, Ayat" + }, + { + "date": "2026-10-04", + "absent": "Celine and Ray (both excused)" + }, + { + "date": "2026-09-27", + "absent": "Rizwaan; Will and Brisa (excused)" + } + ] +} diff --git a/lib/board/static.ts b/lib/board/static.ts index dc044ba..ab42ad9 100644 --- a/lib/board/static.ts +++ b/lib/board/static.ts @@ -1,5 +1,6 @@ -// /board content that changes rarely and lives in code rather than the -// database. Copied from docs/board-preview.html. +// Berkeley /board content that changes rarely and lives in code rather +// than the database (copied from docs/board-preview.html). chapters.ts +// assembles it with UCLA's. import type { Tab } from "./types"; @@ -21,10 +22,6 @@ export const KEY_DATES: [date: string, text: string][] = [ ["2026-11-06", "Price Is Right: final demo"], ]; -// The round shown on the Assignments tab, and its deadline. -export const CURRENT_ROUND = { round: 2, label: "Round 2 · Due Mon, Oct 12, 11:59pm", due: "2026-10-12" }; -export const ARCHIVE_ROUND = { round: 1, label: "Round 1 archive · due Sep 26" }; - export const RULE = "every task lists the proof it needs. \"I reached out\" is not proof. If any of your deliverables is missing after the deadline, you are removed from the board. No extensions unless you ask Rana before the deadline."; @@ -60,10 +57,6 @@ export const WEEKS: { n: number; start: string; end: string; label: string; goal // Socials calendar: months people can page through (0-based month index). export const SOCIAL_MONTHS = { year: 2026, first: 9, last: 11 }; export const SOCIAL_STATUSES = ["Idea", "Planning", "Confirmed", "Completed"] as const; -export const SOCIAL_KINDS: { key: "b" | "o"; label: string }[] = [ - { key: "b", label: "Berkeley" }, - { key: "o", label: "Online" }, -]; // Round tasks with no owner yet. Only admins see them. export const UNASSIGNED = "Unassigned"; diff --git a/lib/board/submit-rules.ts b/lib/board/submit-rules.ts index 214921d..6e6852d 100644 --- a/lib/board/submit-rules.ts +++ b/lib/board/submit-rules.ts @@ -7,8 +7,9 @@ export const MAX_ATTACHMENT_BYTES = 14 * 1024 * 1024; export const TOO_BIG = "Those files are too large to email (14 MB total). Upload them to Google Drive and paste the share link in the notes instead."; -export const NEED_PROOF = "Add proof: attach a file or paste a link to your post, doc or PR."; export const hasLink = (text: string) => /https?:\/\/\S+/.test(text); export const OTHER_TASK = "Other / general update"; + +export const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); diff --git a/lib/board/tables.ts b/lib/board/tables.ts index 4032da6..e9686f1 100644 --- a/lib/board/tables.ts +++ b/lib/board/tables.ts @@ -37,6 +37,7 @@ const bool: Check = (v) => (typeof v === "boolean" ? { ok: true, value: v } : { type Field = { column: string; check: Check; onCreate?: unknown }; export type TableDef = { table: string; fields: Record }; +export { oneOf }; export const TABLES = { socials: { @@ -62,7 +63,7 @@ export const TABLES = { task: { column: "task", check: required("the task") }, proof: { column: "proof_required", check: text(500), onCreate: "" }, dueDate: { column: "due_date", check: optionalDate, onCreate: null }, - status: { column: "status", check: oneOf(["pending", "submitted", "done", "missing"], "a status"), onCreate: "pending" }, + status: { column: "status", check: oneOf(["pending", "submitted", "done", "missing", "excused"], "a status"), onCreate: "pending" }, statusNote: { column: "status_note", check: text(300), onCreate: "" }, }, }, @@ -74,6 +75,7 @@ export const TABLES = { postedOn: { column: "posted_on", check: date("a date") }, linkTab: { column: "link_tab", check: oneOf(["", ...TABS], "a tab"), onCreate: "" }, linkLabel: { column: "link_label", check: text(100), onCreate: "" }, + adminsOnly: { column: "admins_only", check: bool, onCreate: false }, }, }, meetings: { @@ -82,6 +84,7 @@ export const TABLES = { date: { column: "date", check: date("the meeting date") }, title: { column: "title", check: required("a title", 200), onCreate: "Board meeting" }, summary: { column: "summary", check: text(4000), onCreate: "" }, + absent: { column: "absent", check: text(1000), onCreate: "" }, recordingUrl: { column: "recording_url", check: url, onCreate: "" }, }, }, @@ -90,7 +93,8 @@ export const TABLES = { fields: { name: { column: "name", check: required("a name", 200) }, role: { column: "role", check: text(200), onCreate: "" }, - team: { column: "team", check: oneOf(["Executive", "Data", "Software", "Research & Legal", "Outreach"], "a team") }, + // Checked against the chapter's teams in editable.ts. + team: { column: "team", check: oneOf(["Executive"], "a team") }, alsoTeam: { column: "also_team", check: (v) => (v === "" || v === null ? { ok: true, value: null } : text(100)(v)), onCreate: null }, tier: { column: "tier", check: optionalTier, onCreate: null }, isLead: { column: "is_lead", check: bool, onCreate: false }, @@ -134,18 +138,26 @@ export async function createRow(def: TableDef, body: Record, ex return { ok: true, id: r.id as number }; } -export async function updateRow(def: TableDef, id: number, body: Record, extra: Record = {}): Promise { +// Every row belongs to a chapter; updates and deletes only touch rows of +// the signed-in user's chapter. +export async function updateRow( + def: TableDef, + chapter: string, + id: number, + body: Record, + extra: Record = {}, +): Promise { const c = collect(def, body, false); if ("error" in c) return { ok: false, error: c.error, status: 400 }; const row = { ...c.row, ...extra }; if (Object.keys(row).length === 0) return { ok: false, error: "Nothing to change.", status: 400 }; const sql = db(); - const rows = await sql`UPDATE ${sql(def.table)} SET ${sql(row)} WHERE id = ${id} RETURNING id`; + const rows = await sql`UPDATE ${sql(def.table)} SET ${sql(row)} WHERE id = ${id} AND chapter = ${chapter} RETURNING id`; return rows.length ? { ok: true, id } : { ok: false, error: "Not found", status: 404 }; } -export async function deleteRow(def: TableDef, id: number): Promise { +export async function deleteRow(def: TableDef, chapter: string, id: number): Promise { const sql = db(); - const rows = await sql`DELETE FROM ${sql(def.table)} WHERE id = ${id} RETURNING id`; + const rows = await sql`DELETE FROM ${sql(def.table)} WHERE id = ${id} AND chapter = ${chapter} RETURNING id`; return rows.length ? { ok: true, id } : { ok: false, error: "Not found", status: 404 }; } diff --git a/lib/board/types.ts b/lib/board/types.ts index 43720d3..3b0fae3 100644 --- a/lib/board/types.ts +++ b/lib/board/types.ts @@ -13,7 +13,16 @@ export type Member = { phone: string; }; -export type AssignmentStatus = "pending" | "submitted" | "done" | "missing"; +export type AssignmentStatus = "pending" | "submitted" | "done" | "missing" | "excused"; + +// A round (Berkeley) or week (UCLA) of deliverables. +export type Round = { + round: number; + dueDate: string; + isCurrent: boolean; + showStatus: boolean; + note: string; +}; export type Assignment = { id: number; @@ -58,6 +67,7 @@ export type Announcement = { postedOn: string; linkTab: string; linkLabel: string; + adminsOnly: boolean; }; export type Meeting = { @@ -65,10 +75,15 @@ export type Meeting = { date: string; title: string; summary: string; + absent: string; recordingUrl: string; }; export type BoardData = { + rounds: Round[]; + // First names of who a submission goes to: everyone, and the extra + // people for submitters on `techTeams`. + recipients: { base: string[]; tech: string[]; techTeams: string[] }; members: Member[]; assignments: Assignment[]; submissions: Submission[]; @@ -77,5 +92,5 @@ export type BoardData = { meetings: Meeting[]; }; -export const TABS = ["home", "board", "projects", "assignments", "socials", "meetings"] as const; +export const TABS = ["me", "home", "board", "projects", "assignments", "socials", "meetings"] as const; export type Tab = (typeof TABS)[number]; diff --git a/public/board/ucla/board-1.webp b/public/board/ucla/board-1.webp new file mode 100644 index 0000000000000000000000000000000000000000..e2eb692b98d6c60ae0ef7c16309825bb2359b413 GIT binary patch literal 22104 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 Taq3$#3ywk$0UL~?3xEIsty}3m literal 0 HcmV?d00001 diff --git a/public/board/ucla/board-2.webp b/public/board/ucla/board-2.webp new file mode 100644 index 0000000000000000000000000000000000000000..bedeb12f9c5cc116ceb0ed4207b8bf790411bce6 GIT binary patch literal 24146 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

        ednJ3d3bVjQ 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 literal 0 HcmV?d00001 diff --git a/public/board/ucla/board-3.webp b/public/board/ucla/board-3.webp new file mode 100644 index 0000000000000000000000000000000000000000..f827a85e70aa1892226f05b70d88e169f636a07b GIT binary patch literal 19204 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%0Av^)u|y-_8IUOk>9!Mbq3m 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 VfeiLGXn*(Oy literal 0 HcmV?d00001 diff --git a/public/board/ucla/board-6.webp b/public/board/ucla/board-6.webp new file mode 100644 index 0000000000000000000000000000000000000000..12dcbe7e4ee4fa42152217a11f6fbc34f5316ef0 GIT binary patch literal 27124 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 literal 0 HcmV?d00001 diff --git a/scripts/board-schema.sql b/scripts/board-schema.sql index d9b629f..e13e82c 100644 --- a/scripts/board-schema.sql +++ b/scripts/board-schema.sql @@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS board_assignments ( proof_required text NOT NULL DEFAULT '', due_date date, status text NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'submitted', 'done', 'missing')), + CHECK (status IN ('pending', 'submitted', 'done', 'missing', 'excused')), status_note text NOT NULL DEFAULT '', sort integer NOT NULL DEFAULT 0 ); @@ -75,3 +75,27 @@ CREATE TABLE IF NOT EXISTS board_changes ( name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now() ); + +-- Chapters: every row belongs to one. Rows from before UCLA joined are +-- Berkeley's. +ALTER TABLE board_members ADD COLUMN IF NOT EXISTS chapter text NOT NULL DEFAULT 'berkeley'; +ALTER TABLE board_assignments ADD COLUMN IF NOT EXISTS chapter text NOT NULL DEFAULT 'berkeley'; +ALTER TABLE board_submissions ADD COLUMN IF NOT EXISTS chapter text NOT NULL DEFAULT 'berkeley'; +ALTER TABLE board_socials ADD COLUMN IF NOT EXISTS chapter text NOT NULL DEFAULT 'berkeley'; +ALTER TABLE board_announcements ADD COLUMN IF NOT EXISTS chapter text NOT NULL DEFAULT 'berkeley'; +ALTER TABLE board_meetings ADD COLUMN IF NOT EXISTS chapter text NOT NULL DEFAULT 'berkeley'; + +ALTER TABLE board_announcements ADD COLUMN IF NOT EXISTS admins_only boolean NOT NULL DEFAULT false; +ALTER TABLE board_meetings ADD COLUMN IF NOT EXISTS absent text NOT NULL DEFAULT ''; + +-- Rounds (Berkeley) or weeks (UCLA) of deliverables. One is current; the +-- rest show in the archive. +CREATE TABLE IF NOT EXISTS board_rounds ( + chapter text NOT NULL, + round smallint NOT NULL, + due_date date NOT NULL, + is_current boolean NOT NULL DEFAULT false, + show_status boolean NOT NULL DEFAULT true, + note text NOT NULL DEFAULT '', + PRIMARY KEY (chapter, round) +);