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)} />
)}
-
+
- {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.
+