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
+
+ {
+ if (!confirmWeek) return setConfirmWeek(true);
+ setConfirmWeek(false);
+ await run("/api/board/week", "POST");
+ }}
+ >
+ {confirmWeek ? "Click again to start a new week" : "Start a new week"}
+
+
+
+ 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.
+
{a.body}
@@ -151,7 +162,7 @@ function AnnouncementItem({ a, admin, onEdit }: { a: Announcement; admin: boolea
{admin && (
- Edit
+ Edit or remove
)}
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.