Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions BOARD_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 9 additions & 5 deletions app/api/board/announce-email/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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";
Expand All @@ -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)` : ""}`, []);
Expand Down
2 changes: 1 addition & 1 deletion app/api/board/announce-test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
26 changes: 26 additions & 0 deletions app/board/board.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
117 changes: 100 additions & 17 deletions components/board/AnnouncementForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<Kind>(start.kind);
const [teams, setTeams] = useState<string[]>(start.kind === "teams" ? start.teams : []);
const [ids, setIds] = useState<number[]>(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 ?? "");
Expand All @@ -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 = <T,>(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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -132,10 +154,70 @@ export function AnnouncementForm({ existing, onClose }: { existing?: Announcemen
<input id={id("label")} value={f.linkLabel} placeholder="Open" onChange={(e) => set("linkLabel", e.target.value)} />
</label>
)}
<label className="check" htmlFor={id("admins")}>
<input id={id("admins")} type="checkbox" checked={f.adminsOnly} onChange={(e) => set("adminsOnly", e.target.checked)} />
Board admins only
</label>
<fieldset className="send-to">
<legend>Send to</legend>
<div className="admin-row">
{KINDS.map(([k, label]) => (
<label key={k} className="check" htmlFor={id(`to-${k}`)}>
<input
id={id(`to-${k}`)}
type="radio"
name={id("to")}
checked={kind === k}
onChange={() => {
setKind(k);
touch();
}}
/>
{label}
</label>
))}
</div>
{kind === "leads" && <p>Everyone marked Lead on the Board tab, plus the top two rows of the chart.</p>}
{kind === "hubAdmins" && <p>Members ticked Hub admin. On the hub, only admin sign-ins see it.</p>}
{kind === "teams" && (
<div className="admin-row">
{allTeams.map((t) => (
<label key={t} className="check" htmlFor={id(`team-${t}`)}>
<input
id={id(`team-${t}`)}
type="checkbox"
checked={teams.includes(t)}
onChange={(e) => {
setTeams(toggle(teams, t, e.target.checked));
touch();
}}
/>
{t}
</label>
))}
</div>
)}
{kind === "people" && (
<div className="pick-people">
<input type="search" aria-label="Search members" placeholder="Search by name, role or team" value={search} onChange={(e) => setSearch(e.target.value)} />
<ul>
{found.map((m) => (
<li key={m.id}>
<label className="check" htmlFor={id(`p-${m.id}`)}>
<input
id={id(`p-${m.id}`)}
type="checkbox"
checked={ids.includes(m.id)}
onChange={(e) => {
setIds(toggle(ids, m.id, e.target.checked));
touch();
}}
/>
{m.name} · {m.role}
</label>
</li>
))}
{found.length === 0 && <li>No one matches.</li>}
</ul>
</div>
)}
</fieldset>
<label className="check" htmlFor={id("email")}>
<input
id={id("email")}
Expand All @@ -146,13 +228,14 @@ export function AnnouncementForm({ existing, onClose }: { existing?: Announcemen
setConfirming(false);
}}
/>
{existing ? "Email the update" : "Email this to everyone"}
{f.adminsOnly ? " (Hub admins only)" : ""}
{existing ? "Email the update" : "Email it"}
</label>
{email && (
<p>
{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 && (
<p className="reach" role="status">
{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.`}
</p>
)}

Expand Down
1 change: 1 addition & 0 deletions components/board/ChangesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const FIELD: Record<string, string> = {
link_tab: "link tab",
link_label: "link text",
admins_only: "admins only",
audience: "send to",
is_current: "current week",
show_status: "show status",
};
Expand Down
8 changes: 7 additions & 1 deletion components/board/ChapterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,20 @@ 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 <BoardNotice chapter={chapter} text="The board couldn't reach its database. Try again in a minute; if it keeps happening, tell Rana." />;
}
return <BoardApp chapter={chapter} data={data} user={user} today={todayInCalifornia()} />;
}

// 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 (
<main className="wrap">
Expand Down
25 changes: 20 additions & 5 deletions components/board/HomeTab.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -95,6 +96,11 @@ export function HomeTab() {
</button>
</p>
)}
{!admin && data.viewerId === null && (
<p>
Choose your name on <button className="linkbtn" onClick={() => go("me")}>My page</button> to also see posts sent to your team or to you.
</p>
)}
{editing === "new" && <AnnouncementForm onClose={close} />}
{data.announcements.map((a) =>
editing === a.id ? (
Expand Down Expand Up @@ -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("");

Expand All @@ -171,7 +185,7 @@ function AnnouncementItem({ a, admin, sent, onEdit }: { a: Announcement; admin:
return (
<div className="item">
<h3>{a.title}</h3>
{a.adminsOnly && <p className="deep">Board admins only</p>}
{a.audience.kind !== "everyone" && <p className="deep">For: {forLabel(a, data.members)}</p>}
{(a.body || a.linkLabel) && (
<p>
{a.body}
Expand All @@ -187,7 +201,8 @@ function AnnouncementItem({ a, admin, sent, onEdit }: { a: Announcement; admin:
)}
{admin && sent && (
<p className="deep">
{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 && (
<>
Expand Down
Loading