diff --git a/BOARD_SETUP.md b/BOARD_SETUP.md
index 9215330..75f5b3b 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.
+