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)} /> )} - +
+ 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 ccac69f..e6d918b 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 b7d4a89..8049eec 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), @@ -92,24 +96,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); @@ -123,6 +134,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 8355f5c..734909b 100644 --- a/scripts/board-schema.sql +++ b/scripts/board-schema.sql @@ -172,6 +172,12 @@ ALTER TABLE board_members ADD COLUMN IF NOT EXISTS is_hub_admin boolean NOT NULL -- 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 ''; + -- How many invites to a scheduled meeting have gone through. 0 means none -- ever did (e.g. saved while sending was broken): the Meetings tab offers -- "Resend invites".