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 0000000..e2eb692 Binary files /dev/null and b/public/board/ucla/board-1.webp differ diff --git a/public/board/ucla/board-2.webp b/public/board/ucla/board-2.webp new file mode 100644 index 0000000..bedeb12 Binary files /dev/null and b/public/board/ucla/board-2.webp differ diff --git a/public/board/ucla/board-3.webp b/public/board/ucla/board-3.webp new file mode 100644 index 0000000..f827a85 Binary files /dev/null and b/public/board/ucla/board-3.webp differ diff --git a/public/board/ucla/board-4.webp b/public/board/ucla/board-4.webp new file mode 100644 index 0000000..5ccdbf0 Binary files /dev/null and b/public/board/ucla/board-4.webp differ diff --git a/public/board/ucla/board-5.webp b/public/board/ucla/board-5.webp new file mode 100644 index 0000000..7c93036 Binary files /dev/null and b/public/board/ucla/board-5.webp differ diff --git a/public/board/ucla/board-6.webp b/public/board/ucla/board-6.webp new file mode 100644 index 0000000..12dcbe7 Binary files /dev/null and b/public/board/ucla/board-6.webp differ 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) +);