Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions BOARD_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,35 @@ for one).

The recipient lists are in `lib/board/chapter-server.ts`.

## What admins can edit

Signed in with a chapter's admin password, everything on that chapter's hub
has an Edit link: the subtitle, members and teams, the photo row, quick
links, key dates, the Projects tab (projects, descriptions and every week of
the timeline), assignments (including past weeks), announcements, events,
meetings, the board meeting time, and Berkeley's rule. Changes save to the
database; nothing needs a code change. The starting content comes from
`lib/board/chapters.ts`.

One exception: the UCLA submission routing matches the team names "App Dev"
and "Data" in `lib/board/chapter-server.ts`. If either team is renamed on
the hub, update that list too or Rizwaan stops getting those submissions.

The **Changes** tab (admins only) lists every change, newest first, with
what changed and a **Restore previous version** button. Everyone shares
the admin password, so admins can type their name once per browser; the
log shows it as self-reported.

## Announcement emails

Posting an announcement can email it to the chapter ("Email this to
everyone", on by default): one email per person through the Brevo API, so
no one sees anyone else's address. Admins-only announcements go to the
members ticked **Hub admin** on the Board tab (at first: Fiona and Will for
UCLA, Rana for Berkeley). Emails come from `BOARD_INVITE_FROM` with reply-to
set to the chapter's organizer, so they need `BREVO_API_KEY` and
`BOARD_INVITE_FROM` (its domain verified in Brevo, as for invites).

## Calendar invites

Admins schedule meetings on the Meetings tab ("Schedule a meeting"). Each
Expand Down
32 changes: 27 additions & 5 deletions app/api/board/[table]/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,52 @@
import { bad, guard, idFrom, notFound, readJson } from "@/lib/board/api";
import { derived, editable } from "@/lib/board/editable";
import { describe, derived, editable } from "@/lib/board/editable";
import { deleteRow, updateRow } from "@/lib/board/tables";
import { db } from "@/lib/board/db";
import { getTeams } from "@/lib/board/settings-server";
import { actorOf, logEntry, snapshot } from "@/lib/board/changelog";

async function target(request: Request, ctx: { params: Promise<{ table: string; id: string }> }) {
const { table, id: rawId } = await ctx.params;
const id = idFrom(rawId);
if (!id) return notFound();
const user = await guard(request);
if (user instanceof Response) return user;
const t = editable(table, user.chapter);
const t = editable(table, await getTeams(user.chapter));
if (!t || !t.roles.includes(user.role)) return notFound();
return { t, id, chapter: user.chapter };
return { t, id, user };
}

// Edits a row of the signed-in chapter and logs it with the old values.
export async function PATCH(request: Request, ctx: { params: Promise<{ table: string; id: string }> }) {
const x = await target(request, ctx);
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.chapter, x.id, body, derived(x.t.name, body));
const { t, id, user } = x;
const r = await db().begin(async (tx) => {
const before = await snapshot(tx, t.def.table, user.chapter, id);
const res = await updateRow(t.def, user.chapter, id, body, derived(t.name, body), tx);
if (res.ok) {
const after = await snapshot(tx, t.def.table, user.chapter, id);
await logEntry(tx, user, actorOf(request), "update", `Edited ${describe(t.name, after)}`, [{ table: t.def.table, id, before, after }]);
}
return res;
});
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.chapter, x.id);
const { t, id, user } = x;
if (t.name === "rounds") return bad("Weeks can't be deleted.", 400);
const r = await db().begin(async (tx) => {
const before = await snapshot(tx, t.def.table, user.chapter, id);
const res = await deleteRow(t.def, user.chapter, id, tx);
if (res.ok) {
await logEntry(tx, user, actorOf(request), "delete", `Removed ${describe(t.name, before)}`, [{ table: t.def.table, id, before, after: null }]);
}
return res;
});
return r.ok ? Response.json({ id: r.id }) : bad(r.error, r.status);
}
19 changes: 15 additions & 4 deletions app/api/board/[table]/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import { bad, guard, notFound, readJson } from "@/lib/board/api";
import { derived, editable } from "@/lib/board/editable";
import { describe, derived, editable } from "@/lib/board/editable";
import { createRow } from "@/lib/board/tables";
import { db } from "@/lib/board/db";
import { getTeams } from "@/lib/board/settings-server";
import { actorOf, logEntry, snapshot } from "@/lib/board/changelog";

// Adds a row to an editable table, for the signed-in chapter, and logs it.
export async function POST(request: Request, ctx: { params: Promise<{ table: string }> }) {
const { table } = await ctx.params;
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 t = editable(table, await getTeams(user.chapter));
if (!t || !t.roles.includes(user.role) || t.name === "rounds") 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), chapter: user.chapter });
const r = await db().begin(async (tx) => {
const res = await createRow(t.def, body, { ...derived(t.name, body), chapter: user.chapter }, tx);
if (res.ok) {
const after = await snapshot(tx, t.def.table, user.chapter, res.id);
await logEntry(tx, user, actorOf(request), "create", `Added ${describe(t.name, after)}`, [{ table: t.def.table, id: res.id, before: null, after }]);
}
return res;
});
return r.ok ? Response.json({ id: r.id }, { status: 201 }) : bad(r.error, r.status);
}
44 changes: 44 additions & 0 deletions app/api/board/announce-email/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { bad, guard, idFrom, notFound, readJson } from "@/lib/board/api";
import { db } from "@/lib/board/db";
import { actorOf, logEntry } from "@/lib/board/changelog";
import { announceSetupProblem, announcementRecipients, recordSends, sendAnnouncement, sendSummaries } from "@/lib/board/announce";

// Emails an announcement to the chapter (admins only). kind: "post" (new),
// "update" (edited, subject starts "Updated:"), or "retry" (only the people
// the last send didn't reach).
export async function POST(request: Request, ctx: { params: Promise<{ id: string }> }) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
const id = idFrom((await ctx.params).id);
if (!id) return notFound();
const problem = announceSetupProblem();
if (problem) {
console.error(`[board] Announcement emails are off: ${problem}`);
return bad("Announcement emails aren't set up yet. Rana: add BREVO_API_KEY and BOARD_INVITE_FROM in Railway (see BOARD_SETUP.md).", 503);
}
const body = await readJson(request);
const kind = body?.kind;
if (kind !== "post" && kind !== "update" && kind !== "retry") return bad("Check the form.");

const [a] = await db()`SELECT id, title, body, admins_only FROM board_announcements WHERE id = ${id} AND chapter = ${user.chapter}`;
if (!a) return notFound();
const mail = { title: a.title, body: a.body, adminsOnly: a.admins_only };

let people: { name: string; email: string }[];
let update = kind === "update";
if (kind === "retry") {
const last = (await sendSummaries(user.chapter)).find((s) => s.announcementId === id);
if (!last || !last.failed.length) return bad("Everyone already got this one.");
people = last.failed;
update = last.kind === "update";
} else {
people = (await announcementRecipients(user.chapter, mail.adminsOnly)).to;
if (!people.length) return bad(mail.adminsOnly ? "No Hub admin has an email on file." : "No one has an email on file.");
}
const results = await sendAnnouncement(user.chapter, mail, people, update);
await recordSends(user.chapter, id, kind, results);
const sent = results.filter((r) => r.ok).length;
const failed = results.filter((r) => !r.ok).map((r) => r.email);
await logEntry(db(), user, actorOf(request), "note", `Emailed "${a.title}" to ${sent} ${sent === 1 ? "person" : "people"}${failed.length ? ` (${failed.length} failed)` : ""}`, []);
return Response.json({ ok: true, sent, failed });
}
19 changes: 19 additions & 0 deletions app/api/board/announce-test/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { bad, guard, readJson } from "@/lib/board/api";
import { announceSetupProblem, sendAnnouncement } from "@/lib/board/announce";
import { isEmail } from "@/lib/board/submit-rules";

// "Send me a test": emails an announcement draft to one address the admin
// typed in. Nothing is saved.
export async function POST(request: Request) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
if (announceSetupProblem()) return bad("Announcement emails aren't set up yet. Rana: add BREVO_API_KEY and BOARD_INVITE_FROM in Railway (see BOARD_SETUP.md).", 503);
const body = await readJson(request);
const to = typeof body?.to === "string" ? body.to.trim() : "";
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 300) : "";
const text = typeof body?.body === "string" ? body.body.trim().slice(0, 4000) : "";
if (!isEmail(to)) return bad("Add the email to send the test to.");
if (!title) return bad("Add a title first.");
const [r] = await sendAnnouncement(user.chapter, { title, body: text, adminsOnly: false }, [{ name: to, email: to }], body?.update === true);
return r.ok ? Response.json({ ok: true }) : bad("The test didn't go through. Check the address and try again.", 502);
}
12 changes: 12 additions & 0 deletions app/api/board/changes/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { bad, guard, idFrom, notFound } from "@/lib/board/api";
import { actorOf, restoreEntry } from "@/lib/board/changelog";

// "Restore previous version" for one change log entry (admins only).
export async function POST(request: Request, ctx: { params: Promise<{ id: string }> }) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
const id = idFrom((await ctx.params).id);
if (!id) return notFound();
const r = await restoreEntry(user, actorOf(request), id);
return "error" in r ? bad(r.error) : Response.json({ ok: true, id: r.id });
}
30 changes: 24 additions & 6 deletions app/api/board/import-members/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { bad, guard, readJson } from "@/lib/board/api";
import { db } from "@/lib/board/db";
import { chapterConfig, type ChapterId } from "@/lib/board/chapters";
import type { ChapterId } from "@/lib/board/chapters";
import { CHAPTER_SERVER } from "@/lib/board/chapter-server";
import { getTeams } from "@/lib/board/settings-server";
import { actorOf, logEntry, snapshotWhere, type Change } from "@/lib/board/changelog";
import type { BoardUser } from "@/lib/board/session";

type Row = {
name: string;
Expand All @@ -22,13 +26,14 @@ export async function POST(request: Request) {
console.warn(`[board] Member import refused (${user.status}).`);
return user;
}
const res = await importMembers(request, user.chapter);
const res = await importMembers(request, user);
if (!res.ok) console.warn(`[board] Member import (${user.chapter}) rejected (${res.status}).`);
return res;
}

async function importMembers(request: Request, chapter: ChapterId): Promise<Response> {
const TEAMS = ["Executive", ...chapterConfig(chapter).teams];
async function importMembers(request: Request, user: BoardUser): Promise<Response> {
const chapter: ChapterId = user.chapter;
const TEAMS = ["Executive", ...(await getTeams(chapter))];
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.");
Expand Down Expand Up @@ -64,11 +69,24 @@ async function importMembers(request: Request, chapter: ChapterId): Promise<Resp

try {
await sql.begin(async (tx) => {
// Hub admins stay ticked across a re-import; the starting ones are ticked.
const old = await tx`SELECT id, name, is_hub_admin FROM board_members WHERE chapter = ${chapter}`;
const before = await snapshotWhere(tx, "board_members", chapter, old.map((r) => r.id));
const admins = new Set([...CHAPTER_SERVER[chapter].hubAdmins, ...old.filter((r) => r.is_hub_admin).map((r) => r.name)]);
await tx`DELETE FROM board_members WHERE chapter = ${chapter}`;
const ids: number[] = [];
for (const [i, r] of rows.entries()) {
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})`;
const [row] = await tx`INSERT INTO board_members (chapter, name, first_name, role, team, also_team, tier, is_lead, email, phone, sort, is_hub_admin)
VALUES (${chapter}, ${r.name}, ${r.name.split(/\s+/)[0]}, ${r.role}, ${r.team}, ${r.alsoTeam}, ${r.tier}, ${r.lead}, ${r.email}, ${r.phone}, ${i}, ${admins.has(r.name)})
RETURNING id`;
ids.push(row.id);
}
const after = await snapshotWhere(tx, "board_members", chapter, ids);
const changes: Change[] = [
...[...before.values()].map((b) => ({ table: "board_members", id: b.id as number, before: b, after: null })),
...[...after.values()].map((a) => ({ table: "board_members", id: a.id as number, before: null, after: a })),
];
await logEntry(tx, user, actorOf(request), "bulk", `Imported ${rows.length} members${old.length ? `, replacing ${old.length}` : ""}`, changes);
});
} catch (e) {
console.error("[board] Member import failed:", e);
Expand Down
17 changes: 12 additions & 5 deletions app/api/board/person-status/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { bad, guard, readJson } from "@/lib/board/api";
import { chapterConfig } from "@/lib/board/chapters";
import { db } from "@/lib/board/db";
import { actorOf, logRows, snapshotWhere } from "@/lib/board/changelog";

const STATUSES = ["pending", "submitted", "missing", "excused"];

Expand All @@ -14,10 +15,16 @@ export async function POST(request: 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`;
const rows = await db().begin(async (tx) => {
const ids = (await tx`SELECT id FROM board_assignments
WHERE chapter = ${user.chapter} AND person = ${person}
AND round = (SELECT round FROM board_rounds WHERE chapter = ${user.chapter} AND is_current)`).map((r) => r.id as number);
const before = await snapshotWhere(tx, "board_assignments", user.chapter, ids);
if (ids.length) await tx`UPDATE board_assignments SET status = ${status} WHERE id IN ${tx(ids)}`;
await logRows(tx, user, actorOf(request), `Set ${person}'s status to ${status === "pending" ? "not submitted" : status}`, [
{ table: "board_assignments", rows: before, ids },
]);
return ids;
});
return rows.length ? Response.json({ ok: true, count: rows.length }) : bad("That person has no tasks this week.", 404);
}
15 changes: 15 additions & 0 deletions app/api/board/photos/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { guard, idFrom, notFound } from "@/lib/board/api";
import { db } from "@/lib/board/db";

// An uploaded photo, for anyone signed in to its chapter.
export async function GET(request: Request, ctx: { params: Promise<{ id: string }> }) {
const user = await guard(request);
if (user instanceof Response) return user;
const id = idFrom((await ctx.params).id);
if (!id) return notFound();
const [row] = await db()`SELECT content_type, data FROM board_photos WHERE id = ${id} AND chapter = ${user.chapter}`;
if (!row) return notFound();
return new Response(new Uint8Array(row.data as Buffer), {
headers: { "content-type": row.content_type, "cache-control": "private, max-age=86400" },
});
}
25 changes: 25 additions & 0 deletions app/api/board/photos/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { bad, guard } from "@/lib/board/api";
import { db } from "@/lib/board/db";

const TYPES = ["image/webp", "image/png", "image/jpeg"];
const MAX = 2 * 1024 * 1024;

// Uploads a photo for the Home tab's photo row (admins only). Returns its
// address; saving the photo row itself is a separate, logged change.
export async function POST(request: Request) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
let file: File | null = null;
try {
const f = (await request.formData()).get("file");
file = f instanceof File ? f : null;
} catch {
/* handled below */
}
if (!file || !file.size) return bad("Choose a photo.");
if (!TYPES.includes(file.type)) return bad("Use a JPG, PNG or WebP photo.");
if (file.size > MAX) return bad("That photo is over 2 MB. Make it smaller and try again.");
const data = Buffer.from(await file.arrayBuffer());
const [row] = await db()`INSERT INTO board_photos (chapter, content_type, data) VALUES (${user.chapter}, ${file.type}, ${data}) RETURNING id`;
return Response.json({ src: `/api/board/photos/${row.id}` }, { status: 201 });
}
27 changes: 27 additions & 0 deletions app/api/board/settings/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { bad, guard, readJson } from "@/lib/board/api";
import { chapterConfig } from "@/lib/board/chapters";
import { db } from "@/lib/board/db";
import { actorOf, logEntry, snapshot } from "@/lib/board/changelog";
import { SETTING_KEYS, SETTING_LABELS, checkSetting, type SettingKey } from "@/lib/board/settings";
import { saveSetting, settingId } from "@/lib/board/settings-server";

// Saves one piece of chapter content (admins only) and logs it. Teams have
// their own route, because renaming or removing one moves members.
export async function PUT(request: Request) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
const body = await readJson(request);
const key = body?.key as SettingKey;
if (!SETTING_KEYS.includes(key) || key === "teams") return bad("Check the form.");
if (key === "rule" && chapterConfig(user.chapter).rule === null) return bad("This chapter has no rule text.");
const checked = checkSetting(key, body?.value);
if (!checked.ok) return bad(checked.error);
await db().begin(async (tx) => {
const oldId = await settingId(tx, user.chapter, key);
const before = oldId ? await snapshot(tx, "board_settings", user.chapter, oldId) : null;
const id = await saveSetting(tx, user.chapter, key, checked.value);
const after = await snapshot(tx, "board_settings", user.chapter, id);
await logEntry(tx, user, actorOf(request), "update", `Edited ${SETTING_LABELS[key].toLowerCase()}`, [{ table: "board_settings", id, before, after }]);
});
return Response.json({ ok: true });
}
Loading