diff --git a/.env.example b/.env.example index 26ff070..8c15266 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/BOARD_SETUP.md b/BOARD_SETUP.md index c721410..9215330 100644 --- a/BOARD_SETUP.md +++ b/BOARD_SETUP.md @@ -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. diff --git a/app/api/board/schedule/[id]/route.ts b/app/api/board/schedule/[id]/route.ts index e8c6ac2..f3ef936 100644 --- a/app/api/board/schedule/[id]/route.ts +++ b/app/api/board/schedule/[id]/route.ts @@ -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; @@ -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, @@ -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; @@ -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 }); } diff --git a/app/api/board/schedule/route.ts b/app/api/board/schedule/route.ts index af6fe96..75afd95 100644 --- a/app/api/board/schedule/route.ts +++ b/app/api/board/schedule/route.ts @@ -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; @@ -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); @@ -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 }); } diff --git a/components/board/MeetingsTab.tsx b/components/board/MeetingsTab.tsx index f3fc737..68b782a 100644 --- a/components/board/MeetingsTab.tsx +++ b/components/board/MeetingsTab.tsx @@ -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[] => [ @@ -28,7 +28,9 @@ export function MeetingsTab() { // A past date of a scheduled meeting being logged: "|". const [logging, setLogging] = useState(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 @@ -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); @@ -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 (

{m.title}

- Next: {shortDate(dates[0])} · {timeRange(m.startTime, m.endTime)} + {dates.length ? ( + <> + Next: {shortDate(dates[0])} + + ) : ( + <> + Was: {shortDate(m.date)} + + )}{" "} + · {timeRange(m.startTime, m.endTime)}

{m.weekly &&

{repeatText(m)}

} {m.location && ( @@ -201,16 +222,32 @@ function Upcoming({ m, admin, onEdit }: { m: ScheduledMeeting; admin: boolean; o )}

)} -

- Invited: {m.audience} ({m.invitees.length} {m.invitees.length === 1 ? "invite" : "invites"} sent) -

+ {notSent && admin ? ( + <> +

+ Invites not sent. No one has received an invite for this meeting yet. +

+

+ To invite: {m.audience} +

+ + ) : ( +

+ Invited: {m.audience} ({m.invitees.length} {m.invitees.length === 1 ? "invite" : "invites"} sent) +

+ )} {m.notes &&

{m.notes}

} {admin && (
+ {notSent && dates.length > 0 && ( + + )} - {m.weekly && dates.length > 1 && ( + {!notSent && m.weekly && dates.length > 1 && (