From 01af8254cded31b08c336dc52f0cac1bab2ebe2c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 21:57:39 +0000 Subject: [PATCH] Board: admins edit everything, change log with restore, announcement emails - Admins (per chapter) can edit members, teams, Projects content and timeline, quick links, key dates, photo row, subtitle, meeting time, rule (Berkeley), assignments including past weeks, announcements, meetings and events. Settings live in board_settings, seeded from the chapter defaults. - Every edit is checked against that chapter's admin session and logged in board_changelog; the admin-only Changes tab shows diffs and restores the previous version. Optional self-reported name per browser. - Hub admin checkbox on members (Fiona and Will for UCLA, Rana for Berkeley at first); changes to it are logged. - Announcements can be emailed through Brevo, one email per person, with a confirm step, a test send, "Updated:" emails on edit, recorded sends and a Retry for failures. Admins-only announcements go to Hub admins only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QzNDQSmhjgKE63rwUnwcMq --- BOARD_SETUP.md | 29 ++ app/api/board/[table]/[id]/route.ts | 32 +- app/api/board/[table]/route.ts | 19 +- app/api/board/announce-email/[id]/route.ts | 44 ++ app/api/board/announce-test/route.ts | 19 + app/api/board/changes/[id]/route.ts | 12 + app/api/board/import-members/route.ts | 30 +- app/api/board/person-status/route.ts | 17 +- app/api/board/photos/[id]/route.ts | 15 + app/api/board/photos/route.ts | 25 + app/api/board/settings/route.ts | 27 ++ app/api/board/teams/route.ts | 69 +++ app/api/board/week/route.ts | 34 +- components/board/AnnouncementForm.tsx | 204 +++++++++ components/board/AssignmentsTab.tsx | 91 +++- components/board/BoardApp.tsx | 21 +- components/board/BoardTab.tsx | 20 +- components/board/ChangesTab.tsx | 147 ++++++ components/board/Editors.tsx | 508 +++++++++++++++++++++ components/board/HomeTab.tsx | 210 +++++---- components/board/MeetingsTab.tsx | 18 +- components/board/MyPage.tsx | 8 +- components/board/ProjectsTab.tsx | 38 +- components/board/RowEditor.tsx | 4 +- components/board/ScheduleForm.tsx | 2 +- components/board/util.ts | 31 +- lib/board/announce.ts | 118 +++++ lib/board/changelog.ts | 159 +++++++ lib/board/chapter-server.ts | 5 + lib/board/data.ts | 15 +- lib/board/editable.ts | 24 +- lib/board/settings-server.ts | 44 ++ lib/board/settings.ts | 160 +++++++ lib/board/setup.ts | 16 + lib/board/tables.ts | 25 +- lib/board/types.ts | 26 +- scripts/board-schema.sql | 51 +++ 37 files changed, 2167 insertions(+), 150 deletions(-) create mode 100644 app/api/board/announce-email/[id]/route.ts create mode 100644 app/api/board/announce-test/route.ts create mode 100644 app/api/board/changes/[id]/route.ts create mode 100644 app/api/board/photos/[id]/route.ts create mode 100644 app/api/board/photos/route.ts create mode 100644 app/api/board/settings/route.ts create mode 100644 app/api/board/teams/route.ts create mode 100644 components/board/AnnouncementForm.tsx create mode 100644 components/board/ChangesTab.tsx create mode 100644 components/board/Editors.tsx create mode 100644 lib/board/announce.ts create mode 100644 lib/board/changelog.ts create mode 100644 lib/board/settings-server.ts create mode 100644 lib/board/settings.ts diff --git a/BOARD_SETUP.md b/BOARD_SETUP.md index 818909a..74ed63e 100644 --- a/BOARD_SETUP.md +++ b/BOARD_SETUP.md @@ -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 diff --git a/app/api/board/[table]/[id]/route.ts b/app/api/board/[table]/[id]/route.ts index 2c384a0..270cfff 100644 --- a/app/api/board/[table]/[id]/route.ts +++ b/app/api/board/[table]/[id]/route.ts @@ -1,6 +1,9 @@ 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; @@ -8,23 +11,42 @@ async function target(request: Request, ctx: { params: Promise<{ table: string; 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); } diff --git a/app/api/board/[table]/route.ts b/app/api/board/[table]/route.ts index c3ed0e2..ff2ed27 100644 --- a/app/api/board/[table]/route.ts +++ b/app/api/board/[table]/route.ts @@ -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); } diff --git a/app/api/board/announce-email/[id]/route.ts b/app/api/board/announce-email/[id]/route.ts new file mode 100644 index 0000000..fe496d2 --- /dev/null +++ b/app/api/board/announce-email/[id]/route.ts @@ -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 }); +} diff --git a/app/api/board/announce-test/route.ts b/app/api/board/announce-test/route.ts new file mode 100644 index 0000000..4b80eb0 --- /dev/null +++ b/app/api/board/announce-test/route.ts @@ -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); +} diff --git a/app/api/board/changes/[id]/route.ts b/app/api/board/changes/[id]/route.ts new file mode 100644 index 0000000..eb370a4 --- /dev/null +++ b/app/api/board/changes/[id]/route.ts @@ -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 }); +} diff --git a/app/api/board/import-members/route.ts b/app/api/board/import-members/route.ts index bd7466d..cc841d3 100644 --- a/app/api/board/import-members/route.ts +++ b/app/api/board/import-members/route.ts @@ -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; @@ -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 { - const TEAMS = ["Executive", ...chapterConfig(chapter).teams]; +async function importMembers(request: Request, user: BoardUser): Promise { + 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."); @@ -64,11 +69,24 @@ async function importMembers(request: Request, chapter: ChapterId): Promise { + // 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); diff --git a/app/api/board/person-status/route.ts b/app/api/board/person-status/route.ts index 9af07b5..b6598c0 100644 --- a/app/api/board/person-status/route.ts +++ b/app/api/board/person-status/route.ts @@ -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"]; @@ -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); } diff --git a/app/api/board/photos/[id]/route.ts b/app/api/board/photos/[id]/route.ts new file mode 100644 index 0000000..461f6d5 --- /dev/null +++ b/app/api/board/photos/[id]/route.ts @@ -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" }, + }); +} diff --git a/app/api/board/photos/route.ts b/app/api/board/photos/route.ts new file mode 100644 index 0000000..6be23f7 --- /dev/null +++ b/app/api/board/photos/route.ts @@ -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 }); +} diff --git a/app/api/board/settings/route.ts b/app/api/board/settings/route.ts new file mode 100644 index 0000000..74616f2 --- /dev/null +++ b/app/api/board/settings/route.ts @@ -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 }); +} diff --git a/app/api/board/teams/route.ts b/app/api/board/teams/route.ts new file mode 100644 index 0000000..111b384 --- /dev/null +++ b/app/api/board/teams/route.ts @@ -0,0 +1,69 @@ +import { bad, guard, readJson } from "@/lib/board/api"; +import { db } from "@/lib/board/db"; +import { actorOf, logRows, snapshotWhere } from "@/lib/board/changelog"; +import { checkSetting } from "@/lib/board/settings"; +import { getTeams, saveSetting, settingId } from "@/lib/board/settings-server"; + +// Adds, renames or removes a team (admins only). Renaming moves its members +// with it; removing a team that has members needs `moveTo`, the team they +// go to. One change log entry covers the team list and every member moved. +export async function POST(request: Request) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + const body = await readJson(request); + const action = body?.action; + const team = typeof body?.team === "string" ? body.team.trim() : ""; + const name = typeof body?.name === "string" ? body.name.trim() : ""; + const moveTo = typeof body?.moveTo === "string" ? body.moveTo.trim() : ""; + const c = user.chapter; + + const result = await db().begin(async (tx) => { + const teams = await getTeams(c, tx); + let next: string[]; + let summary: string; + if (action === "add") { + next = [...teams, name]; + summary = `Added the ${name} team`; + } else if (action === "rename") { + if (!teams.includes(team)) return "That team doesn't exist."; + next = teams.map((t) => (t === team ? name : t)); + summary = `Renamed the ${team} team to ${name}`; + } else if (action === "remove") { + if (!teams.includes(team)) return "That team doesn't exist."; + next = teams.filter((t) => t !== team); + summary = `Removed the ${team} team`; + } else return "Check the form."; + const checked = checkSetting("teams", next); + if (!checked.ok) return checked.error; + + // Members on (or also leading) the team. + const affected = await tx`SELECT id, team, also_team FROM board_members + WHERE chapter = ${c} AND (team = ${team} OR also_team = ${team})`; + const onTeam = affected.filter((m) => m.team === team); + if (action === "remove" && onTeam.length) { + if (!moveTo || moveTo === team || !["Executive", ...next].includes(moveTo)) { + return `${onTeam.length} ${onTeam.length === 1 ? "person is" : "people are"} on ${team}. Choose the team to move them to first.`; + } + summary += `, moving ${onTeam.length} to ${moveTo}`; + } + const memberIds = action === "add" ? [] : affected.map((m) => m.id as number); + const members = await snapshotWhere(tx, "board_members", c, memberIds); + const settingRow = await settingId(tx, c, "teams"); + const settingBefore = settingRow ? await snapshotWhere(tx, "board_settings", c, [settingRow]) : new Map(); + + const id = await saveSetting(tx, c, "teams", checked.value); + if (action === "rename") { + await tx`UPDATE board_members SET team = ${name} WHERE chapter = ${c} AND team = ${team}`; + await tx`UPDATE board_members SET also_team = ${name} WHERE chapter = ${c} AND also_team = ${team}`; + } else if (action === "remove") { + if (onTeam.length) await tx`UPDATE board_members SET team = ${moveTo} WHERE chapter = ${c} AND team = ${team}`; + await tx`UPDATE board_members SET also_team = NULL WHERE chapter = ${c} AND also_team = ${team}`; + } + await logRows(tx, user, actorOf(request), summary, [ + { table: "board_settings", rows: settingBefore, ids: [id] }, + { table: "board_members", rows: members, ids: memberIds }, + ]); + return null; + }); + return result ? bad(result) : Response.json({ ok: true }); +} diff --git a/app/api/board/week/route.ts b/app/api/board/week/route.ts index e0719c8..b2fbe62 100644 --- a/app/api/board/week/route.ts +++ b/app/api/board/week/route.ts @@ -1,6 +1,7 @@ import { bad, guard, isDate, 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"; // Weekly deliverables (UCLA): admins change the current week's due date, // or start a new week. @@ -20,12 +21,17 @@ export async function PATCH(request: Request) { 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}`; - } + const [cur] = await tx`SELECT id, round FROM board_rounds WHERE chapter = ${user.chapter} AND is_current`; + if (!cur) return; + const taskIds = (await tx`SELECT id FROM board_assignments WHERE chapter = ${user.chapter} AND round = ${cur.round}`).map((r) => r.id as number); + const rounds = await snapshotWhere(tx, "board_rounds", user.chapter, [cur.id]); + const tasks = await snapshotWhere(tx, "board_assignments", user.chapter, taskIds); + await tx`UPDATE board_rounds SET due_date = ${due} WHERE id = ${cur.id}`; + await tx`UPDATE board_assignments SET due_date = ${due} WHERE chapter = ${user.chapter} AND round = ${cur.round}`; + await logRows(tx, user, actorOf(request), `Changed this week's due date to ${due}`, [ + { table: "board_rounds", rows: rounds, ids: [cur.id] }, + { table: "board_assignments", rows: tasks, ids: taskIds }, + ]); }); return Response.json({ ok: true }); } @@ -38,15 +44,16 @@ export async function POST(request: Request) { 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`; + const [cur] = await tx`SELECT id, round, due_date FROM board_rounds WHERE chapter = ${c} AND is_current FOR UPDATE`; if (!cur) return null; + const oldRounds = await snapshotWhere(tx, "board_rounds", c, [cur.id]); 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) + const [added] = await tx`INSERT INTO board_rounds (chapter, round, due_date, is_current, note) + VALUES (${c}, ${round}, ${due}, true, '') RETURNING id`; + const carried = 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} @@ -54,7 +61,12 @@ export async function POST(request: Request) { SELECT person FROM board_assignments WHERE chapter = ${c} AND round = ${cur.round} AND status IN ('submitted', 'done', 'excused') ) - ORDER BY sort, id`; + ORDER BY sort, id + RETURNING id`; + await logRows(tx, user, actorOf(request), `Started a new week, due ${due}, carrying over ${carried.length} tasks`, [ + { table: "board_rounds", rows: oldRounds, ids: [cur.id, added.id] }, + { table: "board_assignments", rows: new Map(), ids: carried.map((r) => r.id as number) }, + ]); return { round, due }; }); return next ? Response.json({ ok: true, ...next }, { status: 201 }) : bad("There's no current week to archive."); diff --git a/components/board/AnnouncementForm.tsx b/components/board/AnnouncementForm.tsx new file mode 100644 index 0000000..eef3c35 --- /dev/null +++ b/components/board/AnnouncementForm.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useId, useState } from "react"; +import { isEmail } from "@/lib/board/submit-rules"; +import { TABS, type Announcement, type Tab } from "@/lib/board/types"; +import { useBoard } from "./BoardApp"; +import { api, apiCall, listNames } from "./util"; + +// Post or edit an announcement (admins only), optionally emailing it to the +// chapter: everyone with an email on file, or only the Hub admins for an +// admins-only one. One email per person. +export function AnnouncementForm({ existing, onClose }: { existing?: Announcement; onClose: () => void }) { + const { data, today, refresh } = useBoard(); + const uid = useId(); + const [f, setF] = useState({ + title: existing?.title ?? "", + body: existing?.body ?? "", + postedOn: existing?.postedOn ?? today, + linkTab: existing?.linkTab ?? "", + linkLabel: existing?.linkLabel ?? "", + adminsOnly: existing?.adminsOnly ?? false, + }); + // On by default for a new announcement, off for an edit. + const [email, setEmail] = useState(!existing); + const [testTo, setTestTo] = useState(data.organizerEmail ?? ""); + const [confirming, setConfirming] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + + const set = (k: K, v: (typeof f)[K]) => { + setF((x) => ({ ...x, [k]: v })); + setConfirming(false); + }; + const audience = data.members.filter((m) => !f.adminsOnly || m.hubAdmin); + const reach = audience.filter((m) => isEmail(m.email.trim())); + const missing = audience.filter((m) => !isEmail(m.email.trim())); + const id = (k: string) => `${uid}-${k}`; + + async function save() { + setBusy(true); + setMsg(null); + const { error, data: r } = existing + ? await apiCall(`/api/board/announcements/${existing.id}`, "PATCH", f) + : await apiCall("/api/board/announcements", "POST", f); + if (error) { + setBusy(false); + setConfirming(false); + return setMsg({ kind: "err", text: error }); + } + const annId = existing?.id ?? (r?.id as number); + if (email) { + const sent = await apiCall(`/api/board/announce-email/${annId}`, "POST", { kind: existing ? "update" : "post" }); + setBusy(false); + setConfirming(false); + refresh(); + if (sent.error) return setMsg({ kind: "err", text: `Saved, but the email didn't go out: ${sent.error}` }); + const failed = (sent.data?.failed as string[]) ?? []; + setMsg({ + kind: failed.length ? "err" : "ok", + text: `Saved and emailed to ${sent.data?.sent} ${sent.data?.sent === 1 ? "person" : "people"}.${failed.length ? ` It didn't reach ${failed.join(", ")}; use Retry on the announcement.` : ""}`, + }); + if (!failed.length) setTimeout(onClose, 1200); + return; + } + setBusy(false); + refresh(); + onClose(); + } + + async function test() { + setBusy(true); + setMsg(null); + const err = await api("/api/board/announce-test", "POST", { to: testTo, title: f.title, body: f.body, update: !!existing }); + setBusy(false); + setMsg(err ? { kind: "err", text: err } : { kind: "ok", text: `Test sent to ${testTo}.` }); + } + + async function remove() { + if (!existing) return; + if (!confirmDelete) return setConfirmDelete(true); + setBusy(true); + const err = await api(`/api/board/announcements/${existing.id}`, "DELETE"); + setBusy(false); + if (err) return setMsg({ kind: "err", text: err }); + refresh(); + onClose(); + } + + return ( +
+

{existing ? `Edit ${existing.title}` : "Post an announcement"}

+
{ + e.preventDefault(); + if (!f.title.trim()) return setMsg({ kind: "err", text: "Add a title." }); + if (email && reach.length === 0) return setMsg({ kind: "err", text: "No one it would go to has an email on file." }); + if (email && !confirming) return setConfirming(true); + save(); + }} + > + +