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(); + }} + > + +