From 01631acc9b3f824a7f51730b9202a2cfeaf78eb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 01:41:44 +0000 Subject: [PATCH] Board: "Send to" for announcements - The announcement form's "admins only" box is now a "Send to" picker: Everyone, Leads only (Lead plus the top two chart rows), Hub admins, one or more teams, or picked people (searchable checklist). It shows the live count and names, and who has no email, before sending. - Emails go to exactly that group, one email per person. Each send records what it was addressed to: "Sent to: Leads only, 9 people". - On the hub, admins see everything. Members see Everyone posts, plus posts for a group that includes the name they picked on My page (now kept in a cookie so the server filters). Hub admins posts only reach admin sign-ins. - Old admins-only announcements become Hub admins. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QzNDQSmhjgKE63rwUnwcMq --- BOARD_SETUP.md | 22 ++-- app/api/board/announce-email/[id]/route.ts | 14 ++- app/api/board/announce-test/route.ts | 2 +- app/board/board.css | 26 +++++ components/board/AnnouncementForm.tsx | 117 ++++++++++++++++++--- components/board/ChangesTab.tsx | 1 + components/board/ChapterPage.tsx | 8 +- components/board/HomeTab.tsx | 25 ++++- components/board/MyPage.tsx | 35 +++--- components/board/util.ts | 10 ++ lib/board/announce.ts | 39 ++++--- lib/board/audience.ts | 77 ++++++++++++++ lib/board/data.ts | 24 +++-- lib/board/setup.ts | 7 ++ lib/board/tables.ts | 8 +- lib/board/types.ts | 9 +- scripts/board-schema.sql | 6 ++ 17 files changed, 356 insertions(+), 74 deletions(-) create mode 100644 lib/board/audience.ts diff --git a/BOARD_SETUP.md b/BOARD_SETUP.md index c721410..d923adc 100644 --- a/BOARD_SETUP.md +++ b/BOARD_SETUP.md @@ -87,12 +87,22 @@ 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 the chapter's sender (above) -with reply-to set to the chapter's organizer. They need `BREVO_API_KEY`. +Each announcement has a **Send to**: Everyone (the default), Leads only +(everyone marked Lead plus the top two rows of the chart), Hub admins +(members ticked **Hub admin** on the Board tab), one or more teams, or +picked people. The form lists who that is before anything is sent. + +"Email it" (on by default) sends one email per person through the Brevo +API, so no one sees anyone else's address. Emails come from the chapter's +sender (above) with reply-to set to the chapter's organizer. They need +`BREVO_API_KEY`. + +On the hub, admins see every announcement. Members see posts for Everyone, +plus posts for a group they're in once they choose their name on My page +(it's remembered in that browser). Hub admins posts only show to admin +sign-ins. Since everyone shares the member password, the name is +self-reported, so don't put anything in a Leads, team or people post that +a member shouldn't see; use Hub admins for that. ## Calendar invites diff --git a/app/api/board/announce-email/[id]/route.ts b/app/api/board/announce-email/[id]/route.ts index 64f658d..3a2ad3e 100644 --- a/app/api/board/announce-email/[id]/route.ts +++ b/app/api/board/announce-email/[id]/route.ts @@ -1,6 +1,7 @@ import { bad, guard, idFrom, notFound, readJson } from "@/lib/board/api"; import { db } from "@/lib/board/db"; import { actorOf, logEntry } from "@/lib/board/changelog"; +import { audienceLabel, parseAudience } from "@/lib/board/audience"; import { announceSetupProblem, announcementRecipients, recordSends, sendAnnouncement, sendSummaries } from "@/lib/board/announce"; // Emails an announcement to the chapter (admins only). kind: "post" (new), @@ -20,9 +21,11 @@ export async function POST(request: Request, ctx: { params: Promise<{ id: string 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}`; + const [a] = await db()`SELECT id, title, body, audience, 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 }; + const mail = { title: a.title, body: a.body }; + const audience = parseAudience(a.audience, a.admins_only); + let label = audienceLabel(audience); let people: { name: string; email: string }[]; let update = kind === "update"; @@ -31,12 +34,13 @@ export async function POST(request: Request, ctx: { params: Promise<{ id: string if (!last || !last.failed.length) return bad("Everyone already got this one."); people = last.failed; update = last.kind === "update"; + label = last.audience || label; } 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."); + people = (await announcementRecipients(user.chapter, audience)).to; + if (!people.length) return bad("No one it's addressed to has an email on file."); } const results = await sendAnnouncement(user.chapter, mail, people, update); - await recordSends(user.chapter, id, kind, results); + await recordSends(user.chapter, id, kind, label, 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)` : ""}`, []); diff --git a/app/api/board/announce-test/route.ts b/app/api/board/announce-test/route.ts index ca883c2..1f11717 100644 --- a/app/api/board/announce-test/route.ts +++ b/app/api/board/announce-test/route.ts @@ -15,6 +15,6 @@ export async function POST(request: Request) { 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); + const [r] = await sendAnnouncement(user.chapter, { title, body: text }, [{ 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/board/board.css b/app/board/board.css index c07f012..819d7c1 100644 --- a/app/board/board.css +++ b/app/board/board.css @@ -215,6 +215,32 @@ font-weight: 600; text-decoration: none; } +.board fieldset.send-to { + border: 0; + padding: 0; + margin: 0; + display: grid; + gap: 8px; + min-width: 0; +} +.board fieldset.send-to legend { + padding: 0; + margin-bottom: 4px; +} +.board fieldset.send-to p { + margin: 0; +} +.board .pick-people ul { + list-style: none; + margin: 8px 0 0; + padding: 4px 10px; + max-height: 260px; + overflow-y: auto; + border: 1px solid var(--rule); +} +.board .pick-people li { + padding: 4px 0; +} .board .other-hub { margin: 0 0 18px; } diff --git a/components/board/AnnouncementForm.tsx b/components/board/AnnouncementForm.tsx index eef3c35..6ceea17 100644 --- a/components/board/AnnouncementForm.tsx +++ b/components/board/AnnouncementForm.tsx @@ -3,12 +3,21 @@ import { useId, useState } from "react"; import { isEmail } from "@/lib/board/submit-rules"; import { TABS, type Announcement, type Tab } from "@/lib/board/types"; +import { EVERYONE, inAudience, type Audience } from "@/lib/board/audience"; 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. +type Kind = Audience["kind"]; +const KINDS: [Kind, string][] = [ + ["everyone", "Everyone"], + ["leads", "Leads only"], + ["hubAdmins", "Hub admins"], + ["teams", "A team"], + ["people", "Pick people"], +]; + +// Post or edit an announcement (admins only). "Send to" picks who sees it +// on the hub and, if emailed, who gets it: one email per person. export function AnnouncementForm({ existing, onClose }: { existing?: Announcement; onClose: () => void }) { const { data, today, refresh } = useBoard(); const uid = useId(); @@ -18,8 +27,12 @@ export function AnnouncementForm({ existing, onClose }: { existing?: Announcemen postedOn: existing?.postedOn ?? today, linkTab: existing?.linkTab ?? "", linkLabel: existing?.linkLabel ?? "", - adminsOnly: existing?.adminsOnly ?? false, }); + const start = existing?.audience ?? EVERYONE; + const [kind, setKind] = useState(start.kind); + const [teams, setTeams] = useState(start.kind === "teams" ? start.teams : []); + const [ids, setIds] = useState(start.kind === "people" ? start.ids : []); + const [search, setSearch] = useState(""); // On by default for a new announcement, off for an edit. const [email, setEmail] = useState(!existing); const [testTo, setTestTo] = useState(data.organizerEmail ?? ""); @@ -32,17 +45,25 @@ export function AnnouncementForm({ existing, onClose }: { existing?: Announcemen setF((x) => ({ ...x, [k]: v })); setConfirming(false); }; - const audience = data.members.filter((m) => !f.adminsOnly || m.hubAdmin); + const touch = () => setConfirming(false); + const target: Audience | null = + kind === "teams" ? (teams.length ? { kind, teams } : null) : kind === "people" ? (ids.length ? { kind, ids } : null) : { kind }; + const audience = target ? data.members.filter((m) => inAudience(m, target)) : []; const reach = audience.filter((m) => isEmail(m.email.trim())); const missing = audience.filter((m) => !isEmail(m.email.trim())); + const allTeams = ["Executive", ...data.settings.teams]; + const q = search.trim().toLowerCase(); + const found = data.members.filter((m) => !q || `${m.name} ${m.role} ${m.team}`.toLowerCase().includes(q)); + const toggle = (list: T[], v: T, on: boolean) => (on ? [...list, v] : list.filter((x) => x !== v)); const id = (k: string) => `${uid}-${k}`; async function save() { setBusy(true); setMsg(null); + const payload = { ...f, audience: target }; const { error, data: r } = existing - ? await apiCall(`/api/board/announcements/${existing.id}`, "PATCH", f) - : await apiCall("/api/board/announcements", "POST", f); + ? await apiCall(`/api/board/announcements/${existing.id}`, "PATCH", payload) + : await apiCall("/api/board/announcements", "POST", payload); if (error) { setBusy(false); setConfirming(false); @@ -96,6 +117,7 @@ export function AnnouncementForm({ existing, onClose }: { existing?: Announcemen onSubmit={(e) => { e.preventDefault(); if (!f.title.trim()) return setMsg({ kind: "err", text: "Add a title." }); + if (!target) return setMsg({ kind: "err", text: kind === "teams" ? "Choose at least one team." : "Choose at least one person." }); 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(); @@ -132,10 +154,70 @@ export function AnnouncementForm({ existing, onClose }: { existing?: Announcemen set("linkLabel", e.target.value)} /> )} - +
+ Send to +
+ {KINDS.map(([k, label]) => ( + + ))} +
+ {kind === "leads" &&

Everyone marked Lead on the Board tab, plus the top two rows of the chart.

} + {kind === "hubAdmins" &&

Members ticked Hub admin. On the hub, only admin sign-ins see it.

} + {kind === "teams" && ( +
+ {allTeams.map((t) => ( + + ))} +
+ )} + {kind === "people" && ( +
+ setSearch(e.target.value)} /> +
    + {found.map((m) => ( +
  • + +
  • + ))} + {found.length === 0 &&
  • No one matches.
  • } +
+
+ )} +
- {email && ( -

- {reach.length} {reach.length === 1 ? "person" : "people"} will get it, each in their own email. - {missing.length > 0 && ` No email on file for ${listNames(missing.map((m) => m.name))}, so they won't get it.`} + {target && ( +

+ {email + ? `Sending to ${reach.length} ${reach.length === 1 ? "person" : "people"}${reach.length ? `: ${listNames(reach.map((m) => m.name))}` : ""}. Each gets their own email.` + : `For ${audience.length} ${audience.length === 1 ? "person" : "people"}${audience.length ? `: ${listNames(audience.map((m) => m.name))}` : ""}.`} + {email && missing.length > 0 && ` No email on file for ${listNames(missing.map((m) => m.name))}, so they won't get it.`}

)} diff --git a/components/board/ChangesTab.tsx b/components/board/ChangesTab.tsx index bdddfbc..3bc8f50 100644 --- a/components/board/ChangesTab.tsx +++ b/components/board/ChangesTab.tsx @@ -20,6 +20,7 @@ const FIELD: Record = { link_tab: "link tab", link_label: "link text", admins_only: "admins only", + audience: "send to", is_current: "current week", show_status: "show status", }; diff --git a/components/board/ChapterPage.tsx b/components/board/ChapterPage.tsx index d9640dd..3ad5005 100644 --- a/components/board/ChapterPage.tsx +++ b/components/board/ChapterPage.tsx @@ -20,7 +20,7 @@ export async function ChapterPage({ chapter }: { chapter: ChapterId }) { } let data; try { - data = await loadBoard(user); + data = await loadBoard(user, viewerFrom(await headers(), chapter)); } catch (e) { console.error("[board] Could not load the board:", e); return ; @@ -28,6 +28,12 @@ export async function ChapterPage({ chapter }: { chapter: ChapterId }) { return ; } +// The member this browser picked on My page (see VIEWER_COOKIE in util.ts). +function viewerFrom(h: Headers, chapter: ChapterId): number | null { + const m = (h.get("cookie") ?? "").match(new RegExp(`(?:^|;\\s*)board_viewer_${chapter}=(\\d{1,9})`)); + return m ? Number(m[1]) : null; +} + function BoardNotice({ chapter, text }: { chapter: ChapterId; text: string }) { return (
diff --git a/components/board/HomeTab.tsx b/components/board/HomeTab.tsx index aaae7e2..8feae09 100644 --- a/components/board/HomeTab.tsx +++ b/components/board/HomeTab.tsx @@ -1,13 +1,14 @@ "use client"; import { useState } from "react"; -import type { Announcement, SendSummary, Tab } from "@/lib/board/types"; +import type { Announcement, Member, SendSummary, Tab } from "@/lib/board/types"; +import { audienceLabel } from "@/lib/board/audience"; import { useBoard } from "./BoardApp"; import { AnnouncementForm } from "./AnnouncementForm"; import { OtherHub } from "./OtherHub"; import { EditLink, KeyDatesEditor, PhotosEditor, QuickLinksEditor } from "./Editors"; import { roundTasks, inLeadership } from "./derive"; -import { apiCall, daysBetween, fmt, photoSrc } from "./util"; +import { apiCall, daysBetween, fmt, listNames, photoSrc } from "./util"; type Editing = "links" | "dates" | "photos" | "new" | number | null; @@ -95,6 +96,11 @@ export function HomeTab() {

)} + {!admin && data.viewerId === null && ( +

+ Choose your name on to also see posts sent to your team or to you. +

+ )} {editing === "new" && } {data.announcements.map((a) => editing === a.id ? ( @@ -154,8 +160,16 @@ export function HomeTab() { ); } +// "Leads only", "Data team", or the names for picked people. +function forLabel(a: Announcement, members: Member[]) { + if (a.audience.kind !== "people") return audienceLabel(a.audience); + const ids = a.audience.ids; + const names = members.filter((m) => ids.includes(m.id)).map((m) => m.name); + return names.length ? listNames(names) : audienceLabel(a.audience); +} + function AnnouncementItem({ a, admin, sent, onEdit }: { a: Announcement; admin: boolean; sent?: SendSummary; onEdit: () => void }) { - const { go, refresh } = useBoard(); + const { data, go, refresh } = useBoard(); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -171,7 +185,7 @@ function AnnouncementItem({ a, admin, sent, onEdit }: { a: Announcement; admin: return (

{a.title}

- {a.adminsOnly &&

Board admins only

} + {a.audience.kind !== "everyone" &&

For: {forLabel(a, data.members)}

} {(a.body || a.linkLabel) && (

{a.body} @@ -187,7 +201,8 @@ function AnnouncementItem({ a, admin, sent, onEdit }: { a: Announcement; admin: )} {admin && sent && (

- {sent.kind === "update" ? "Update emailed" : "Emailed"} to {sent.sent} {sent.sent === 1 ? "person" : "people"} on{" "} + {sent.kind === "update" ? "Update sent to" : "Sent to"}: {sent.audience ? `${sent.audience}, ` : ""} + {sent.sent} {sent.sent === 1 ? "person" : "people"}, on{" "} {new Date(sent.at).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "America/Los_Angeles" })}. {sent.failed.length > 0 && ( <> diff --git a/components/board/MyPage.tsx b/components/board/MyPage.tsx index 1dd80ad..80d1090 100644 --- a/components/board/MyPage.tsx +++ b/components/board/MyPage.tsx @@ -4,33 +4,38 @@ import { useEffect, useState } from "react"; import type { Member } from "@/lib/board/types"; import { useBoard } from "./BoardApp"; import { roundTasks } from "./derive"; -import { daysBetween, fmt } from "./util"; +import { daysBetween, fmt, setViewer } from "./util"; import { timeRange, upcomingDates } from "@/lib/board/schedule"; // Who "My page" is for. Everyone signs in with a shared password, so the -// viewer picks themselves and the choice is remembered in this browser. -// When per-person sign-in exists, return the signed-in member here and -// drop the picker; nothing else on the page needs to change. +// viewer picks themselves and the choice is remembered in this browser (a +// cookie, so Home also gets announcements addressed to them). When +// per-person sign-in exists, return the signed-in member here and drop the +// picker; nothing else on the page needs to change. function useViewer(members: Member[]) { - const { chapter } = useBoard(); - const key = `vin-board-viewer-${chapter.id}`; - const [id, setId] = useState(null); + const { chapter, data, refresh } = useBoard(); + const [id, setId] = useState(data.viewerId); + // A choice saved before the cookie existed: move it over once. useEffect(() => { + if (data.viewerId) return; try { + const key = `vin-board-viewer-${chapter.id}`; const saved = Number(localStorage.getItem(key)); - if (saved) setId(saved); + localStorage.removeItem(key); + if (saved && members.some((m) => m.id === saved)) { + setId(saved); + setViewer(saved); + refresh(); + } } catch { /* private mode: start unpicked */ } - }, [key]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const pick = (next: number | null) => { setId(next); - try { - if (next) localStorage.setItem(key, String(next)); - else localStorage.removeItem(key); - } catch { - /* ignore */ - } + setViewer(next); + refresh(); }; return { viewer: members.find((m) => m.id === id) ?? null, pick, canPick: true }; } diff --git a/components/board/util.ts b/components/board/util.ts index f692721..58cff17 100644 --- a/components/board/util.ts +++ b/components/board/util.ts @@ -40,6 +40,16 @@ export function setActor(name: string) { } } +// Who this browser picked on My page ("Viewing as"). A cookie, so the server +// can send the announcements addressed to that person. Self-reported, like +// the shared password it sits behind. +export function setViewer(id: number | null) { + const name = `board_viewer_${chapter}`; + document.cookie = id + ? `${name}=${id}; path=/board; max-age=31536000; samesite=lax` + : `${name}=; path=/board; max-age=0; samesite=lax`; +} + // Board API routes read the chapter from ?chapter=. export const chapterUrl = (path: string) => `${path}${path.includes("?") ? "&" : "?"}chapter=${chapter}`; diff --git a/lib/board/announce.ts b/lib/board/announce.ts index e3d219e..1de64b2 100644 --- a/lib/board/announce.ts +++ b/lib/board/announce.ts @@ -7,12 +7,13 @@ import { db } from "./db"; import { hubUrl } from "./invites"; import { isEmail } from "./submit-rules"; import type { SendSummary } from "./types"; +import { inAudience, type Audience } from "./audience"; // Announcement emails: one email per person through the Brevo API, so no // one sees anyone else's address. From the chapter's sender (or // BOARD_INVITE_FROM), reply-to the chapter's organizer. -export type AnnouncementMail = { title: string; body: string; adminsOnly: boolean }; +export type AnnouncementMail = { title: string; body: string }; type Person = { name: string; email: string }; const esc = (s: string) => @@ -30,11 +31,14 @@ export function announceSetupProblem(chapter: ChapterId): string | null { return missing.length ? `Announcement emails aren't set up yet. Rana: add ${missing.join(" and ")} in Railway (see BOARD_SETUP.md).` : null; } -// Everyone an announcement emails: all members with an email on file, or -// only the Hub admins for an admins-only one. Plus who has no email. -export async function announcementRecipients(chapter: ChapterId, adminsOnly: boolean) { - const rows = await db()`SELECT name, email FROM board_members - WHERE chapter = ${chapter} ${adminsOnly ? db()`AND is_hub_admin` : db()``} ORDER BY sort, id`; +// Everyone an announcement emails: the members it's addressed to who have +// an email on file. Plus who in that group has no email. +export async function announcementRecipients(chapter: ChapterId, audience: Audience) { + const all = await db()`SELECT id, name, email, team, also_team, tier, is_lead, is_hub_admin FROM board_members + WHERE chapter = ${chapter} ORDER BY sort, id`; + const rows = all.filter((r) => + inAudience({ id: r.id, team: r.team, alsoTeam: r.also_team, tier: r.tier, isLead: r.is_lead, hubAdmin: r.is_hub_admin }, audience), + ); return { to: rows.filter((r) => isEmail(r.email.trim())).map((r) => ({ name: r.name as string, email: r.email.trim() as string })), missing: rows.filter((r) => !isEmail(r.email.trim())).map((r) => r.name as string), @@ -87,24 +91,31 @@ export async function sendAnnouncement(chapter: ChapterId, a: AnnouncementMail, } // Records one send (post, update or retry) for the admin view. -export async function recordSends(chapter: ChapterId, announcementId: number, kind: string, results: { name: string; email: string; ok: boolean; error: string }[]) { +// `audience` is the label of who it was addressed to ("Leads only"). +export async function recordSends( + chapter: ChapterId, + announcementId: number, + kind: string, + audience: string, + results: { name: string; email: string; ok: boolean; error: string }[], +) { const sql = db(); const batch = randomUUID(); for (const r of results) { - await sql`INSERT INTO board_announcement_sends (chapter, announcement_id, batch, kind, email, name, ok, error) - VALUES (${chapter}, ${announcementId}, ${batch}, ${kind}, ${r.email}, ${r.name}, ${r.ok}, ${r.error})`; + await sql`INSERT INTO board_announcement_sends (chapter, announcement_id, batch, kind, audience, email, name, ok, error) + VALUES (${chapter}, ${announcementId}, ${batch}, ${kind}, ${audience}, ${r.email}, ${r.name}, ${r.ok}, ${r.error})`; } } // For each announcement, its latest send and who still hasn't got it. export async function sendSummaries(chapter: ChapterId): Promise { - const rows = await db()`SELECT announcement_id, batch, kind, email, name, ok, at FROM board_announcement_sends + const rows = await db()`SELECT announcement_id, batch, kind, audience, email, name, ok, at FROM board_announcement_sends WHERE chapter = ${chapter} ORDER BY at`; - const by = new Map }>(); + const by = new Map }>(); for (const r of rows) { - const s = by.get(r.announcement_id) ?? { announcementId: r.announcement_id, at: "", kind: "", sent: 0, failed: [], batches: new Map() }; - const b = s.batches.get(r.batch) ?? { ok: [], failed: [], at: new Date(r.at).toISOString(), kind: r.kind }; + const s = by.get(r.announcement_id) ?? { announcementId: r.announcement_id, at: "", kind: "", audience: "", sent: 0, failed: [], batches: new Map() }; + const b = s.batches.get(r.batch) ?? { ok: [], failed: [], at: new Date(r.at).toISOString(), kind: r.kind, audience: r.audience }; if (r.ok) b.ok.push(r.email); else b.failed.push({ name: r.name, email: r.email }); s.batches.set(r.batch, b); @@ -118,6 +129,6 @@ export async function sendSummaries(chapter: ChapterId): Promise const reached = new Set(recent.flatMap((b) => b.ok)); const failed = new Map(); for (const b of recent) for (const f of b.failed) if (!reached.has(f.email)) failed.set(f.email, f); - return { announcementId: s.announcementId, at: recent[0].at, kind: recent[0].kind, sent: reached.size, failed: [...failed.values()] }; + return { announcementId: s.announcementId, at: recent[0].at, kind: recent[0].kind, audience: recent[0].audience, sent: reached.size, failed: [...failed.values()] }; }); } diff --git a/lib/board/audience.ts b/lib/board/audience.ts new file mode 100644 index 0000000..3ecf119 --- /dev/null +++ b/lib/board/audience.ts @@ -0,0 +1,77 @@ +// Who an announcement is for ("Send to" on the announcement form). The same +// rules pick who it's emailed to and which members see it on the hub. +// Shared by the server and the browser. + +export type Audience = + | { kind: "everyone" } + | { kind: "leads" } + | { kind: "hubAdmins" } + | { kind: "teams"; teams: string[] } + | { kind: "people"; ids: number[] }; + +export const EVERYONE: Audience = { kind: "everyone" }; + +type Person = { id: number; team: string; alsoTeam: string | null; tier: number | null; isLead: boolean; hubAdmin: boolean }; + +// Leads are everyone marked Lead plus the top two rows of the chart. +export function inAudience(m: Person, a: Audience): boolean { + switch (a.kind) { + case "everyone": + return true; + case "leads": + return m.isLead || m.tier === 0 || m.tier === 1; + case "hubAdmins": + return m.hubAdmin; + case "teams": + return a.teams.includes(m.team) || (!!m.alsoTeam && a.teams.includes(m.alsoTeam)); + case "people": + return a.ids.includes(m.id); + } +} + +// Checks an audience sent by the form. Returns null if it isn't one. +export function checkAudience(v: unknown): Audience | null { + if (!v || typeof v !== "object") return null; + const o = v as Record; + if (o.kind === "everyone" || o.kind === "leads" || o.kind === "hubAdmins") return { kind: o.kind }; + if (o.kind === "teams") { + const teams = Array.isArray(o.teams) ? [...new Set(o.teams.filter((t): t is string => typeof t === "string" && t.length <= 100))] : []; + return teams.length && teams.length <= 50 ? { kind: "teams", teams } : null; + } + if (o.kind === "people") { + const ids = Array.isArray(o.ids) ? [...new Set(o.ids.filter((n): n is number => Number.isInteger(n) && n > 0))] : []; + return ids.length && ids.length <= 300 ? { kind: "people", ids } : null; + } + return null; +} + +// Reads the stored value. Announcements from before "Send to" only had +// "admins only", which is now Hub admins. +export function parseAudience(raw: unknown, adminsOnly = false): Audience { + let a: Audience | null = null; + try { + a = checkAudience(typeof raw === "string" ? JSON.parse(raw) : raw); + } catch { + a = null; + } + if (adminsOnly && (!a || a.kind === "everyone")) return { kind: "hubAdmins" }; + return a ?? EVERYONE; +} + +const and = (xs: string[]) => (xs.length <= 1 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs.at(-1)}`); + +// "Everyone", "Leads only", "Hub admins", "Data and Design teams", "Picked people". +export function audienceLabel(a: Audience): string { + switch (a.kind) { + case "everyone": + return "Everyone"; + case "leads": + return "Leads only"; + case "hubAdmins": + return "Hub admins"; + case "teams": + return `${and(a.teams)} ${a.teams.length === 1 ? "team" : "teams"}`; + case "people": + return "Picked people"; + } +} diff --git a/lib/board/data.ts b/lib/board/data.ts index 511836e..827515c 100644 --- a/lib/board/data.ts +++ b/lib/board/data.ts @@ -8,11 +8,15 @@ import { listLog } from "./changelog"; import { sendSummaries } from "./announce"; import type { BoardUser } from "./session"; import { UNASSIGNED } from "./static"; -import type { BoardData } from "./types"; +import type { BoardData, Member } from "./types"; +import { inAudience, parseAudience } from "./audience"; // Everything one chapter's hub shows, for the signed-in user. Members never -// receive tasks with no owner yet or admins-only announcements. -export async function loadBoard(user: BoardUser): Promise { +// receive tasks with no owner yet. Admins get every announcement; everyone +// else gets those for Everyone, plus ones addressed to the person this +// browser picked on My page (`viewerId`). Hub admins posts are only sent to +// admin sign-ins, since anyone with the member password can pick any name. +export async function loadBoard(user: BoardUser, viewerId: number | null = null): Promise { await ensureSetup(); const sql = db(); const c = user.chapter; @@ -37,13 +41,18 @@ export async function loadBoard(user: BoardUser): Promise { sql`SELECT id, date, time, title, location, kind, lead, status, cost, note FROM board_socials WHERE chapter = ${c} ORDER BY date, id`, sql`SELECT id, title, body, posted_on AS "postedOn", link_tab AS "linkTab", link_label AS "linkLabel", - admins_only AS "adminsOnly" - FROM board_announcements WHERE chapter = ${c} ${admin ? sql`` : sql`AND NOT admins_only`} + audience, admins_only + FROM board_announcements WHERE chapter = ${c} ORDER BY sort, posted_on DESC, id DESC`, sql`SELECT id, date, title, summary, absent, recording_url AS "recordingUrl" FROM board_meetings WHERE chapter = ${c} ORDER BY date DESC, id DESC`, ]); const plain = (rows: unknown) => JSON.parse(JSON.stringify(rows)) as T; + const people = plain(members); + const viewer = people.find((m) => m.id === viewerId) ?? null; + const posts = announcements + .map(({ audience, admins_only, ...a }) => ({ ...a, audience: parseAudience(audience, admins_only) })) + .filter((a) => admin || a.audience.kind === "everyone" || (a.audience.kind !== "hubAdmins" && !!viewer && inAudience(viewer, a.audience))); return { settings, changelog, @@ -51,12 +60,13 @@ export async function loadBoard(user: BoardUser): Promise { rounds: plain(rounds), scheduled, organizerEmail: admin ? CHAPTER_SERVER[c].organizer.email : null, + viewerId: viewer?.id ?? null, recipients: recipientNames(c), - members: plain(members), + members: people, assignments: plain(assignments), submissions: plain(submissions), socials: plain(socials), - announcements: plain(announcements), + announcements: plain(posts), meetings: plain(meetings), }; } diff --git a/lib/board/setup.ts b/lib/board/setup.ts index 3e8ebc0..508413f 100644 --- a/lib/board/setup.ts +++ b/lib/board/setup.ts @@ -69,6 +69,13 @@ async function setup() { await seedSettings(sql); await markHubAdmins(sql); await renameEvanceline(sql); + await adminsOnlyToHubAdmins(sql); +} + +// Announcements marked "admins only" before "Send to" existed are for Hub +// admins. Safe to re-run: a restored old version gets converted too. +async function adminsOnlyToHubAdmins(sql: ReturnType) { + await sql`UPDATE board_announcements SET audience = '{"kind":"hubAdmins"}', admins_only = false WHERE admins_only`; } // UCLA's Design Lead is Evanceline Tang; the starting data called her diff --git a/lib/board/tables.ts b/lib/board/tables.ts index a15bddd..44f68c7 100644 --- a/lib/board/tables.ts +++ b/lib/board/tables.ts @@ -2,6 +2,7 @@ import "server-only"; import type postgres from "postgres"; import { db } from "./db"; import { TABS } from "./types"; +import { EVERYONE, checkAudience } from "./audience"; // Editable /board tables: which JSON fields map to which columns, and how // each value is checked. Used by the create/update/delete API routes. @@ -34,6 +35,11 @@ const int = (min: number, max: number): Check => (v) => : { ok: false, error: "Check the numbers." }; const optionalTier: Check = (v) => v === null || v === "" ? { ok: true, value: null } : int(0, 1)(typeof v === "string" ? Number(v) : v); +// Stored as JSON text; see lib/board/audience.ts. +const audience: Check = (v) => { + const a = checkAudience(v); + return a ? { ok: true, value: JSON.stringify(a) } : { ok: false, error: "Choose who it's for." }; +}; const bool: Check = (v) => (typeof v === "boolean" ? { ok: true, value: v } : { ok: false, error: "Check the form." }); type Field = { column: string; check: Check; onCreate?: unknown }; @@ -76,7 +82,7 @@ export const TABLES = { postedOn: { column: "posted_on", check: date("a date") }, linkTab: { column: "link_tab", check: oneOf(["", ...TABS], "a tab"), onCreate: "" }, linkLabel: { column: "link_label", check: text(100), onCreate: "" }, - adminsOnly: { column: "admins_only", check: bool, onCreate: false }, + audience: { column: "audience", check: audience, onCreate: JSON.stringify(EVERYONE) }, }, }, rounds: { diff --git a/lib/board/types.ts b/lib/board/types.ts index bedce92..2013f7f 100644 --- a/lib/board/types.ts +++ b/lib/board/types.ts @@ -1,5 +1,6 @@ // Shapes shared by the /board server code and its client components. +import type { Audience } from "./audience"; import type { ScheduledMeeting } from "./schedule"; import type { Settings } from "./settings"; @@ -72,7 +73,7 @@ export type Announcement = { postedOn: string; linkTab: string; linkLabel: string; - adminsOnly: boolean; + audience: Audience; }; export type Meeting = { @@ -99,7 +100,8 @@ export type LogEntry = { }; // An announcement's latest email: how many it reached and who it didn't. -export type SendSummary = { announcementId: number; at: string; kind: string; sent: number; failed: { name: string; email: string }[] }; +// `audience` is what the send was addressed to ("Leads only"). +export type SendSummary = { announcementId: number; at: string; kind: string; audience: string; sent: number; failed: { name: string; email: string }[] }; export type BoardData = { settings: Settings; @@ -111,6 +113,9 @@ export type BoardData = { scheduled: ScheduledMeeting[]; // Admins only: prefills "Send me a test invite". organizerEmail: string | null; + // Who this browser picked on My page ("Viewing as"), or null. Members see + // announcements addressed to that person. + viewerId: number | null; // First names of who a submission goes to: everyone, and the extra // people for submitters on `techTeams`. recipients: { base: string[]; tech: string[]; techTeams: string[] }; diff --git a/scripts/board-schema.sql b/scripts/board-schema.sql index 632fb9e..2da3b79 100644 --- a/scripts/board-schema.sql +++ b/scripts/board-schema.sql @@ -171,3 +171,9 @@ CREATE TABLE IF NOT EXISTS board_announcement_sends ( ALTER TABLE board_members ADD COLUMN IF NOT EXISTS is_hub_admin boolean NOT NULL DEFAULT false; -- Lets rounds be edited and restored like every other row. ALTER TABLE board_rounds ADD COLUMN IF NOT EXISTS id serial; + +-- Who an announcement is for ("Send to"): JSON, see lib/board/audience.ts. +-- Replaces admins_only, which setup turns into {"kind":"hubAdmins"}. +ALTER TABLE board_announcements ADD COLUMN IF NOT EXISTS audience text NOT NULL DEFAULT '{"kind":"everyone"}'; +-- What a send was addressed to, as shown on the hub ("Leads only"). +ALTER TABLE board_announcement_sends ADD COLUMN IF NOT EXISTS audience text NOT NULL DEFAULT '';