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
6 changes: 2 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ BOARD_UCLA_PASSWORD=
# Signs the board hub login cookies (both chapters). Any random string of 32+ characters,
# e.g. the output of: openssl rand -base64 32
BOARD_SESSION_SECRET=
# Calendar invites from the board hubs' Meetings tab go through Brevo's SMTP
# relay (Brevo → SMTP & API → SMTP: the login and an SMTP key). Each chapter's
# Calendar invites from the board hubs' Meetings tab go through the Brevo API
# (BREVO_API_KEY), like the other board email. Each chapter's
# from address is in lib/board/chapter-server.ts; BOARD_INVITE_FROM is the
# fallback for a chapter without one.
BREVO_SMTP_LOGIN=
BREVO_SMTP_KEY=
BOARD_INVITE_FROM=board@getvindicated.org
32 changes: 16 additions & 16 deletions BOARD_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,22 +97,22 @@ with reply-to set to the chapter's organizer. They need `BREVO_API_KEY`.
## Calendar invites

Admins schedule meetings on the Meetings tab ("Schedule a meeting"). Each
person invited gets an email with a calendar invite (invite.ics); Gmail,
Apple Mail and Outlook show Yes / Maybe / No, and the reply goes to the
chapter's organizer (Fiona for UCLA, Rana for Berkeley, set in
`lib/board/chapter-server.ts`). Editing a meeting updates the event already
on people's calendars; cancelling a date or the whole meeting removes it.

These go through Brevo's SMTP relay rather than the Brevo API, because only
SMTP can mark the invite as a calendar request. They come from the
chapter's sender (above) and need two Railway variables:

- `BREVO_SMTP_LOGIN` and `BREVO_SMTP_KEY`: in Brevo, **SMTP & API → SMTP**
(the login shown there, and an SMTP key you generate).

`BOARD_INVITE_FROM` is only needed for a chapter with no sender of its own;
its domain, **getvindicated.org**, must be verified in Brevo (**Senders,
Domains & Dedicated IPs → Domains**).
person invited gets an email with the event attached as `invite.ics`.
Replies go to the chapter's organizer (Fiona for UCLA, Rana for Berkeley,
set in `lib/board/chapter-server.ts`). Editing a meeting sends the same
event with a higher sequence number, so calendars update the copy they
have; cancelling a date or the whole meeting sends a cancellation for it.

Invites go through the Brevo API like the other board email (Railway blocks
outbound SMTP), from the chapter's sender (above). They need
`BREVO_API_KEY`; `BOARD_INVITE_FROM` only for a chapter with no sender of
its own. The API can't mark the attachment as a calendar request the way
SMTP could, so some mail apps show `invite.ics` as an attachment to open
instead of Yes / Maybe / No buttons. Opening it adds the event.

Every email call gives up after 15 seconds. If no invite goes through, the
meeting stays on the Meetings tab marked **Invites not sent**, with
**Resend invites** and **Delete** (deleting it sends nothing).

Use "Send me a test invite" in the form to check one before sending to the
board.
Expand Down
40 changes: 31 additions & 9 deletions app/api/board/schedule/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { bad, guard, idFrom, isDate, notFound, readJson } from "@/lib/board/api";
import { db } from "@/lib/board/db";
import { inviteSetupProblem, sendInvites } from "@/lib/board/invites";
import { getScheduled, notReady, parseMeeting, resolveInvitees } from "@/lib/board/schedule-server";
import { NOT_SENT, countSent, getScheduled, notReady, parseMeeting, resolveInvitees } from "@/lib/board/schedule-server";
import { occurrences } from "@/lib/board/schedule";

type Ctx = { params: Promise<{ id: string }> };

const NOT_CANCELLED = "The cancellations didn't go through. Try again.";

async function load(request: Request, ctx: Ctx) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
Expand Down Expand Up @@ -57,9 +59,13 @@ export async function PATCH(request: Request, ctx: Ctx) {
const added = invitees.filter((i) => !had.has(i.email.toLowerCase()));
const removed = old.invitees.filter((i) => !has.has(i.email.toLowerCase()));

const a = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "update", recipients: kept, attendees: invitees });
const b = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "new", recipients: added, attendees: invitees });
const c = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "cancel", recipients: removed });
// If no invite ever went through, everyone gets a first invite instead.
const neverSent = old.invitesSent === 0;
const a = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "update", recipients: neverSent ? [] : kept, attendees: invitees });
const b = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "new", recipients: neverSent ? invitees : added, attendees: invitees });
const c = await sendInvites({ chapter: user.chapter, meeting: updated, kind: "cancel", recipients: neverSent ? [] : removed });
await countSent(user.chapter, old.id, a.sent.length + b.sent.length);
if (a.sent.length + b.sent.length === 0) return bad(NOT_SENT, 502);
return Response.json({
ok: true,
sent: a.sent.length + b.sent.length,
Expand All @@ -69,9 +75,23 @@ export async function PATCH(request: Request, ctx: Ctx) {
});
}

// Resends the invite to everyone on it, unchanged (same UID and SEQUENCE).
// For a meeting whose invites never went through.
export async function POST(request: Request, ctx: Ctx) {
const x = await load(request, ctx);
if (x instanceof Response) return x;
const { user, meeting: m } = x;
const r = await sendInvites({ chapter: user.chapter, meeting: m, kind: "new", recipients: m.invitees });
await countSent(user.chapter, m.id, r.sent.length);
if (r.sent.length === 0) return bad(NOT_SENT, 502);
return Response.json({ ok: true, sent: r.sent.length, failed: r.failed });
}

// Cancels a meeting. With ?date=YYYY-MM-DD, cancels just that date of a
// weekly series (METHOD:CANCEL with RECURRENCE-ID); otherwise the whole
// meeting, which is then deleted.
// meeting, which is then deleted. Cancellations go out first, and nothing
// changes unless at least one gets through, so a failed cancel can be tried
// again. A meeting whose invites never went out is changed without email.
export async function DELETE(request: Request, ctx: Ctx) {
const x = await load(request, ctx);
if (x instanceof Response) return x;
Expand All @@ -87,21 +107,23 @@ export async function DELETE(request: Request, ctx: Ctx) {
// Nothing would be left: cancel the whole meeting instead.
return cancelAll();
}
await sql`UPDATE board_scheduled SET sequence = ${sequence}, cancelled_dates = ${cancelledDates}
WHERE chapter = ${user.chapter} AND id = ${m.id}`;
const r = await sendInvites({
chapter: user.chapter,
meeting: { ...m, sequence, cancelledDates },
kind: "cancel-one",
recipients: m.invitees,
recipients: m.invitesSent ? m.invitees : [],
onlyDate: date,
});
if (m.invitesSent && m.invitees.length && r.sent.length === 0) return bad(NOT_CANCELLED, 502);
await sql`UPDATE board_scheduled SET sequence = ${sequence}, cancelled_dates = ${cancelledDates}
WHERE chapter = ${user.chapter} AND id = ${m.id}`;
return Response.json({ ok: true, cancelled: r.sent.length, failed: r.failed });
}
return cancelAll();

async function cancelAll() {
const r = await sendInvites({ chapter: user.chapter, meeting: { ...m, sequence }, kind: "cancel", recipients: m.invitees });
const r = await sendInvites({ chapter: user.chapter, meeting: { ...m, sequence }, kind: "cancel", recipients: m.invitesSent ? m.invitees : [] });
if (m.invitesSent && m.invitees.length && r.sent.length === 0) return bad(NOT_CANCELLED, 502);
await sql`DELETE FROM board_scheduled WHERE chapter = ${user.chapter} AND id = ${m.id}`;
return Response.json({ ok: true, cancelled: r.sent.length, failed: r.failed });
}
Expand Down
12 changes: 8 additions & 4 deletions app/api/board/schedule/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { randomUUID } from "node:crypto";
import { bad, guard, readJson } from "@/lib/board/api";
import { db } from "@/lib/board/db";
import { inviteSetupProblem, sendInvites } from "@/lib/board/invites";
import { getScheduled, notReady, parseMeeting, resolveInvitees } from "@/lib/board/schedule-server";
import { NOT_SENT, countSent, getScheduled, notReady, parseMeeting, resolveInvitees } from "@/lib/board/schedule-server";
import { isEmail } from "@/lib/board/submit-rules";

// Schedules a meeting and emails the invites (admins only). With `test`
// set to an address, sends one invite there and saves nothing.
// set to an address, sends one invite there and saves nothing. If no invite
// goes through, the meeting stays saved and the Meetings tab offers
// "Resend invites".
export async function POST(request: Request) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
Expand All @@ -21,9 +23,9 @@ export async function POST(request: Request) {
if (body.test !== undefined) {
const to = typeof body.test === "string" ? body.test.trim() : "";
if (!isEmail(to)) return bad("Add the email to send the test to.");
const test = { ...meeting, id: 0, uid: `test-${randomUUID()}@getvindicated.org`, sequence: 0, invitees: [], audience: "", cancelledDates: [] };
const test = { ...meeting, id: 0, uid: `test-${randomUUID()}@getvindicated.org`, sequence: 0, invitees: [], audience: "", cancelledDates: [], invitesSent: 0 };
const r = await sendInvites({ chapter: user.chapter, meeting: test, kind: "new", recipients: [{ name: to, email: to }] });
return r.sent.length ? Response.json({ ok: true, test: to }) : bad("The test invite didn't go through. Check the address and try again.", 502);
return r.sent.length ? Response.json({ ok: true, test: to }) : bad("The test invite didn't go through. Try again.", 502);
}

const { invitees, skipped, audience } = await resolveInvitees(user.chapter, invite);
Expand All @@ -46,5 +48,7 @@ export async function POST(request: Request) {
})} RETURNING id`;
const saved = (await getScheduled(user.chapter, row.id))!;
const r = await sendInvites({ chapter: user.chapter, meeting: saved, kind: "new", recipients: invitees });
await countSent(user.chapter, row.id, r.sent.length);
if (r.sent.length === 0) return bad(NOT_SENT, 502);
return Response.json({ ok: true, id: row.id, sent: r.sent.length, failed: r.failed, skipped }, { status: 201 });
}
63 changes: 54 additions & 9 deletions components/board/MeetingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useBoard } from "./BoardApp";
import { RowEditor, type FieldDef } from "./RowEditor";
import { ScheduleForm } from "./ScheduleForm";
import { EditLink, TextSetting } from "./Editors";
import { api, fmt } from "./util";
import { INVITE_TIMEOUT, api, apiCall, fmt } from "./util";
import { pastDates, repeatText, shortDate, timeRange, upcomingDates, type ScheduledMeeting } from "@/lib/board/schedule";

const meetingFields = (attendance: boolean): FieldDef[] => [
Expand All @@ -28,7 +28,9 @@ export function MeetingsTab() {
// A past date of a scheduled meeting being logged: "<id>|<date>".
const [logging, setLogging] = useState<string | null>(null);

const upcoming = data.scheduled.filter((m) => upcomingDates(m, today).length > 0);
// Admins also see meetings whose invites never went out, even past ones,
// so they can resend or delete them.
const upcoming = data.scheduled.filter((m) => upcomingDates(m, today).length > 0 || (admin && m.invitesSent === 0));
// Past dates of scheduled meetings with nothing logged yet (most recent first).
const logged = new Set(data.meetings.map((m) => m.date));
const toLog = admin
Expand Down Expand Up @@ -166,6 +168,7 @@ export function MeetingsTab() {
function Upcoming({ m, admin, onEdit }: { m: ScheduledMeeting; admin: boolean; onEdit: () => void }) {
const { today, refresh } = useBoard();
const dates = upcomingDates(m, today);
const notSent = m.invitesSent === 0;
const [date, setDate] = useState(dates[0]);
const [confirm, setConfirm] = useState<"one" | "all" | null>(null);
const [busy, setBusy] = useState(false);
Expand All @@ -182,11 +185,29 @@ function Upcoming({ m, admin, onEdit }: { m: ScheduledMeeting; admin: boolean; o
else refresh();
}

async function resend() {
setBusy(true);
setError("");
const { error: err } = await apiCall(`/api/board/schedule/${m.id}`, "POST", undefined, INVITE_TIMEOUT);
setBusy(false);
if (err) setError(err);
else refresh();
}

return (
<div className="item">
<h3>{m.title}</h3>
<p>
<strong>Next:</strong> {shortDate(dates[0])} · {timeRange(m.startTime, m.endTime)}
{dates.length ? (
<>
<strong>Next:</strong> {shortDate(dates[0])}
</>
) : (
<>
<strong>Was:</strong> {shortDate(m.date)}
</>
)}{" "}
· {timeRange(m.startTime, m.endTime)}
</p>
{m.weekly && <p>{repeatText(m)}</p>}
{m.location && (
Expand All @@ -201,16 +222,32 @@ function Upcoming({ m, admin, onEdit }: { m: ScheduledMeeting; admin: boolean; o
)}
</p>
)}
<p>
<strong>Invited:</strong> {m.audience} ({m.invitees.length} {m.invitees.length === 1 ? "invite" : "invites"} sent)
</p>
{notSent && admin ? (
<>
<p className="msg err">
<strong>Invites not sent.</strong> No one has received an invite for this meeting yet.
</p>
<p>
<strong>To invite:</strong> {m.audience}
</p>
</>
) : (
<p>
<strong>Invited:</strong> {m.audience} ({m.invitees.length} {m.invitees.length === 1 ? "invite" : "invites"} sent)
</p>
)}
{m.notes && <p>{m.notes}</p>}
{admin && (
<div className="admin-row">
{notSent && dates.length > 0 && (
<button type="button" className="btn" disabled={busy} onClick={resend}>
{busy ? "Sending…" : "Resend invites"}
</button>
)}
<button type="button" className="linkbtn" onClick={onEdit}>
Edit and resend
{notSent ? "Edit and send" : "Edit and resend"}
</button>
{m.weekly && dates.length > 1 && (
{!notSent && m.weekly && dates.length > 1 && (
<label className="inline">
Cancel one date
<select value={date} onChange={(e) => (setDate(e.target.value), setConfirm(null))}>
Expand All @@ -226,7 +263,15 @@ function Upcoming({ m, admin, onEdit }: { m: ScheduledMeeting; admin: boolean; o
</label>
)}
<button type="button" className="linkbtn" disabled={busy} onClick={() => cancel("all")}>
{confirm === "all" ? "Click again to cancel the whole meeting" : m.weekly ? "Cancel the whole series" : "Cancel the meeting"}
{notSent
? confirm === "all"
? "Click again to delete (no email is sent)"
: "Delete"
: confirm === "all"
? "Click again to cancel the whole meeting"
: m.weekly
? "Cancel the whole series"
: "Cancel the meeting"}
</button>
</div>
)}
Expand Down
16 changes: 13 additions & 3 deletions components/board/ScheduleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useId, useState, type FormEvent } from "react";
import type { ScheduledMeeting } from "@/lib/board/schedule";
import { isEmail } from "@/lib/board/submit-rules";
import { useBoard } from "./BoardApp";
import { apiCall, listNames } from "./util";
import { INVITE_TIMEOUT, apiCall, listNames } from "./util";

type Kind = "all" | "team" | "people";

Expand Down Expand Up @@ -56,10 +56,20 @@ export function ScheduleForm({ existing, onClose }: { existing?: ScheduledMeetin
setBusy(true);
setMsg(null);
const path = existing ? `/api/board/schedule/${existing.id}` : "/api/board/schedule";
const { error, data: r } = await apiCall(test ? "/api/board/schedule" : path, test || !existing ? "POST" : "PATCH", test ? { ...body, test: testTo } : body);
const { error, data: r } = await apiCall(
test ? "/api/board/schedule" : path,
test || !existing ? "POST" : "PATCH",
test ? { ...body, test: testTo } : body,
INVITE_TIMEOUT,
);
setBusy(false);
setConfirming(false);
if (error) return setMsg({ kind: "err", text: error });
if (error) {
// A meeting can be saved even when its invites failed; show it so it
// can be resent.
if (!test) refresh();
return setMsg({ kind: "err", text: error });
}
if (test) return setMsg({ kind: "ok", text: `Test invite sent to ${testTo}.` });
const failed = (r?.failed as string[] | undefined) ?? [];
const sent = Number(r?.sent ?? 0);
Expand Down
19 changes: 16 additions & 3 deletions components/board/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ export const chapterUrl = (path: string) => `${path}${path.includes("?") ? "&" :

// Calls a board API route for this page's chapter. Returns an error
// message, or null on success; `data` is the parsed reply.
export async function apiCall(path: string, method: string, body?: unknown): Promise<{ error: string | null; data: Record<string, unknown> | null }> {
// `timeout` stops waiting after that many ms and returns its message, so a
// stuck request never leaves a button spinning.
export async function apiCall(
path: string,
method: string,
body?: unknown,
timeout?: { ms: number; error: string },
): Promise<{ error: string | null; data: Record<string, unknown> | null }> {
try {
const actor = getActor();
const res = await fetch(chapterUrl(path), {
Expand All @@ -55,15 +62,21 @@ export async function apiCall(path: string, method: string, body?: unknown): Pro
...(actor ? { "x-board-actor": encodeURIComponent(actor) } : {}),
},
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body),
signal: timeout ? AbortSignal.timeout(timeout.ms) : undefined,
});
const j = await res.json().catch(() => null);
if (res.ok) return { error: null, data: j };
return { error: (j && typeof j.error === "string" && j.error) || "Something went wrong. Try again.", data: null };
} catch {
return { error: (j && typeof j.error === "string" && j.error) || timeout?.error || "Something went wrong. Try again.", data: null };
} catch (e) {
if (timeout && e instanceof DOMException && e.name === "TimeoutError") return { error: timeout.error, data: null };
return { error: "Couldn't reach the server. Check your connection and try again.", data: null };
}
}

// Sending invites: the server gives up on a stuck email after 15 seconds,
// so this only fires if something else goes wrong.
export const INVITE_TIMEOUT = { ms: 90_000, error: "The invites didn't go through. Try again." };

export async function api(path: string, method: string, body?: unknown): Promise<string | null> {
return (await apiCall(path, method, body)).error;
}
Expand Down
9 changes: 7 additions & 2 deletions lib/board/announce.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { sendEmail } from "@/lib/brevo";
import { EmailTimeoutError, sendEmail } from "@/lib/brevo";
import { chapterConfig, type ChapterId } from "./chapters";
import { CHAPTER_SERVER, chapterSender } from "./chapter-server";
import { db } from "./db";
Expand Down Expand Up @@ -61,7 +61,7 @@ export async function sendAnnouncement(chapter: ChapterId, a: AnnouncementMail,
const title = chapterConfig(chapter).title;
const sender = chapterSender(chapter, title) ?? { name: title, email: "board@getvindicated.org" };
const results: (Person & { ok: boolean; error: string })[] = [];
for (const p of people) {
for (const [i, p] of people.entries()) {
const m = message(chapter, a, update, p.name.split(/\s+/)[0] || p.name);
const mail = {
sender,
Expand All @@ -81,6 +81,11 @@ export async function sendAnnouncement(chapter: ChapterId, a: AnnouncementMail,
const error = e instanceof Error ? e.message.slice(0, 300) : "Send failed";
console.error(`[board] Announcement to ${p.email} failed:`, error);
results.push({ ...p, ok: false, error });
// Brevo isn't answering: don't wait 15 seconds for each of the rest.
if (e instanceof EmailTimeoutError) {
results.push(...people.slice(i + 1).map((x) => ({ ...x, ok: false, error })));
break;
}
}
}
return results;
Expand Down
Loading