From a7623de8fd2f7246d74c153550ff411e44877129 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 20:50:16 +0000 Subject: [PATCH] Calendar invites for board meetings (both chapters) Admins schedule meetings on the Meetings tab: title, date, start and end, location or Zoom link, notes, weekly with an end date, and who to invite (whole board, one team, or picked members). The form shows who has no email on file, asks "Send invites to N people?" and can send a test invite to an address prefilled with the chapter's organizer. Invites go through Brevo's SMTP relay with nodemailer so the .ics arrives as text/calendar; method=REQUEST (or CANCEL) and Gmail, Apple Mail and Outlook show Yes / Maybe / No. Each meeting keeps one UID; edits resend with SEQUENCE+1 (removed invitees get a cancellation, added ones the invite), and cancelling one date of a series sends CANCEL with RECURRENCE-ID. The .ics has a VTIMEZONE for America/Los_Angeles, an RRULE for weekly meetings and each invitee as an ATTENDEE with RSVP=TRUE. Upcoming meetings show on the Meetings tab and on My page; past dates with nothing logged offer the log form, prefilled. New variables: BREVO_SMTP_LOGIN, BREVO_SMTP_KEY, BOARD_INVITE_FROM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QzNDQSmhjgKE63rwUnwcMq --- .env.example | 6 + BOARD_SETUP.md | 23 +++ app/api/board/schedule/[id]/route.ts | 108 +++++++++++++ app/api/board/schedule/route.ts | 50 ++++++ app/board/board.css | 30 ++++ components/board/MeetingsTab.tsx | 144 +++++++++++++++++- components/board/MyPage.tsx | 35 +++-- components/board/ScheduleForm.tsx | 218 +++++++++++++++++++++++++++ lib/board/chapter-server.ts | 4 + lib/board/data.ts | 8 +- lib/board/ics.ts | 134 ++++++++++++++++ lib/board/invites.ts | 151 +++++++++++++++++++ lib/board/schedule-server.ts | 76 ++++++++++ lib/board/schedule.ts | 75 +++++++++ lib/board/types.ts | 6 + package.json | 2 + pnpm-lock.yaml | 19 +++ scripts/board-schema.sql | 21 +++ 18 files changed, 1098 insertions(+), 12 deletions(-) create mode 100644 app/api/board/schedule/[id]/route.ts create mode 100644 app/api/board/schedule/route.ts create mode 100644 components/board/ScheduleForm.tsx create mode 100644 lib/board/ics.ts create mode 100644 lib/board/invites.ts create mode 100644 lib/board/schedule-server.ts create mode 100644 lib/board/schedule.ts diff --git a/.env.example b/.env.example index 457d54e..8b0dc32 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,9 @@ 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). The from +# address must be on a domain verified in Brevo (getvindicated.org). +BREVO_SMTP_LOGIN= +BREVO_SMTP_KEY= +BOARD_INVITE_FROM=board@getvindicated.org diff --git a/BOARD_SETUP.md b/BOARD_SETUP.md index f1c1a8b..818909a 100644 --- a/BOARD_SETUP.md +++ b/BOARD_SETUP.md @@ -49,6 +49,29 @@ for one). The recipient lists are in `lib/board/chapter-server.ts`. +## 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. It needs three 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`: the address invites come from, e.g. + `board@getvindicated.org`. Its domain, **getvindicated.org**, must be + verified in Brevo (**Senders, Domains & Dedicated IPs → Domains**, then add + the DNS records Brevo shows). + +Use "Send me a test invite" in the form to check one before sending to the +board. + ## One-time setup in Railway 1. **Database.** In the Railway project, add a **Postgres** service. In the diff --git a/app/api/board/schedule/[id]/route.ts b/app/api/board/schedule/[id]/route.ts new file mode 100644 index 0000000..0c4910f --- /dev/null +++ b/app/api/board/schedule/[id]/route.ts @@ -0,0 +1,108 @@ +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 { occurrences } from "@/lib/board/schedule"; + +type Ctx = { params: Promise<{ id: string }> }; + +async function load(request: Request, ctx: Ctx) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + const id = idFrom((await ctx.params).id); + const meeting = id ? await getScheduled(user.chapter, id) : null; + if (!meeting) return notFound(); + const problem = inviteSetupProblem(); + if (problem) return notReady(problem); + return { user, meeting }; +} + +// Edits a scheduled meeting and resends it: the same UID with SEQUENCE+1, +// so calendars update the event they have. People taken off the invite +// get a cancellation; people added get the invite. +export async function PATCH(request: Request, ctx: Ctx) { + const x = await load(request, ctx); + if (x instanceof Response) return x; + const { user, meeting: old } = x; + const body = await readJson(request); + if (!body) return bad("Check the form."); + const parsed = parseMeeting(body); + if ("error" in parsed) return bad(parsed.error); + const { invitees, skipped, audience } = await resolveInvitees(user.chapter, parsed.invite); + if (invitees.length === 0) return bad("No one you picked has an email on file, so there's no one to invite."); + + const m = parsed.meeting; + const sql = db(); + // Cancelled dates only make sense on the same weekday after an edit. + const cancelled = old.cancelledDates.filter((d) => occurrences({ ...m, cancelledDates: [] }).includes(d)); + await sql`UPDATE board_scheduled SET ${sql({ + sequence: old.sequence + 1, + title: m.title, + date: m.date, + start_time: m.startTime, + end_time: m.endTime, + location: m.location, + notes: m.notes, + weekly: m.weekly, + until: m.until, + invitees: sql.json(invitees), + audience, + cancelled_dates: cancelled, + })} WHERE chapter = ${user.chapter} AND id = ${old.id}`; + const updated = (await getScheduled(user.chapter, old.id))!; + + const had = new Set(old.invitees.map((i) => i.email.toLowerCase())); + const has = new Set(invitees.map((i) => i.email.toLowerCase())); + const kept = invitees.filter((i) => had.has(i.email.toLowerCase())); + 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 }); + return Response.json({ + ok: true, + sent: a.sent.length + b.sent.length, + cancelled: c.sent.length, + failed: [...a.failed, ...b.failed, ...c.failed], + skipped, + }); +} + +// 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. +export async function DELETE(request: Request, ctx: Ctx) { + const x = await load(request, ctx); + if (x instanceof Response) return x; + const { user, meeting: m } = x; + const date = new URL(request.url).searchParams.get("date"); + const sql = db(); + const sequence = m.sequence + 1; + + if (date) { + if (!isDate(date) || !occurrences(m).includes(date)) return bad("That date isn't part of this meeting."); + const cancelledDates = [...m.cancelledDates, date].sort(); + if (!m.weekly || occurrences({ ...m, cancelledDates }).length === 0) { + // 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, + onlyDate: date, + }); + 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 }); + 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 new file mode 100644 index 0000000..abca3a4 --- /dev/null +++ b/app/api/board/schedule/route.ts @@ -0,0 +1,50 @@ +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 { 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. +export async function POST(request: Request) { + const user = await guard(request, ["admin"]); + if (user instanceof Response) return user; + const problem = inviteSetupProblem(); + if (problem) return notReady(problem); + const body = await readJson(request); + if (!body) return bad("Check the form."); + const parsed = parseMeeting(body); + if ("error" in parsed) return bad(parsed.error); + const { meeting, invite } = parsed; + + 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 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); + } + + const { invitees, skipped, audience } = await resolveInvitees(user.chapter, invite); + if (invitees.length === 0) return bad("No one you picked has an email on file, so there's no one to invite."); + const uid = `${user.chapter}-${randomUUID()}@getvindicated.org`; + const sql = db(); + const [row] = await sql`INSERT INTO board_scheduled ${sql({ + chapter: user.chapter, + uid, + title: meeting.title, + date: meeting.date, + start_time: meeting.startTime, + end_time: meeting.endTime, + location: meeting.location, + notes: meeting.notes, + weekly: meeting.weekly, + until: meeting.until, + invitees: sql.json(invitees), + audience, + })} RETURNING id`; + const saved = (await getScheduled(user.chapter, row.id))!; + const r = await sendInvites({ chapter: user.chapter, meeting: saved, kind: "new", recipients: invitees }); + return Response.json({ ok: true, id: row.id, sent: r.sent.length, failed: r.failed, skipped }, { status: 201 }); +} diff --git a/app/board/board.css b/app/board/board.css index c3840eb..a56b37a 100644 --- a/app/board/board.css +++ b/app/board/board.css @@ -162,6 +162,8 @@ .board input[type="url"], .board input[type="tel"], .board input[type="number"], +.board input[type="time"], +.board input:not([type]), .board select, .board textarea { font: inherit; @@ -811,3 +813,31 @@ border: 1px dashed var(--deep); padding: 14px 16px; } + +/* Schedule a meeting */ +.board fieldset { + border: 0; + padding: 0; + margin: 0; +} +.board legend { + font-weight: 700; + padding: 0; + margin-bottom: 6px; +} +.board .pick-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 4px 14px; +} +.board .test-invite { + align-items: end; +} +.board .admin-row.confirm { + align-items: center; +} +@media (max-width: 480px) { + .board .test-invite { + grid-template-columns: 1fr; + } +} diff --git a/components/board/MeetingsTab.tsx b/components/board/MeetingsTab.tsx index 8193cc8..5090a82 100644 --- a/components/board/MeetingsTab.tsx +++ b/components/board/MeetingsTab.tsx @@ -3,7 +3,9 @@ import { useState } from "react"; import { useBoard } from "./BoardApp"; import { RowEditor, type FieldDef } from "./RowEditor"; -import { fmt } from "./util"; +import { ScheduleForm } from "./ScheduleForm"; +import { api, fmt } from "./util"; +import { pastDates, repeatText, shortDate, timeRange, upcomingDates, type ScheduledMeeting } from "@/lib/board/schedule"; const meetingFields = (attendance: boolean): FieldDef[] => [ { key: "date", label: "Date", type: "date", pair: "d" }, @@ -19,11 +21,74 @@ export function MeetingsTab() { const MEETING_FIELDS = meetingFields(attendance); const admin = user.role === "admin"; const [editing, setEditing] = useState(null); + const [scheduling, setScheduling] = useState(null); + // 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); + // 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 + ? data.scheduled + .flatMap((m) => pastDates(m, today).map((d) => ({ m, d }))) + .filter(({ d }) => !logged.has(d)) + .sort((a, b) => (a.d < b.d ? 1 : -1)) + .slice(0, 4) + : []; return (

{heading}

{note &&

{note}

} + +

Upcoming

+ {admin && scheduling === null && ( +

+ +

+ )} + {scheduling === "new" && setScheduling(null)} />} + {upcoming.map((m) => + scheduling === m.id ? ( + setScheduling(null)} /> + ) : ( + setScheduling(m.id)} /> + ), + )} + {upcoming.length === 0 && scheduling === null &&

No meetings scheduled yet.

} + + {toLog.length > 0 && ( + <> +

Waiting to be logged

+ {toLog.map(({ m, d }) => + logging === `${m.id}|${d}` ? ( + setLogging(null)} + /> + ) : ( +
+

+ {m.title} · {fmt(d, { month: "short", day: "numeric", year: "numeric" })} +

+
+ +
+
+ ), + )} + + )} + +

Past meetings

{admin && editing === null && (

); } + +// One scheduled meeting, with its next date and admin controls. +function Upcoming({ m, admin, onEdit }: { m: ScheduledMeeting; admin: boolean; onEdit: () => void }) { + const { today, refresh } = useBoard(); + const dates = upcomingDates(m, today); + const [date, setDate] = useState(dates[0]); + const [confirm, setConfirm] = useState<"one" | "all" | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + async function cancel(which: "one" | "all") { + if (confirm !== which) return setConfirm(which); + setBusy(true); + setError(""); + const err = await api(`/api/board/schedule/${m.id}${which === "one" ? `?date=${date}` : ""}`, "DELETE"); + setBusy(false); + setConfirm(null); + if (err) setError(err); + else refresh(); + } + + return ( +
+

{m.title}

+

+ Next: {shortDate(dates[0])} · {timeRange(m.startTime, m.endTime)} +

+ {m.weekly &&

{repeatText(m)}

} + {m.location && ( +

+ Where:{" "} + {/^https?:\/\//.test(m.location) ? ( + + {m.location} + + ) : ( + m.location + )} +

+ )} +

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

+ {m.notes &&

{m.notes}

} + {admin && ( +
+ + {m.weekly && dates.length > 1 && ( + + )} + +
+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/board/MyPage.tsx b/components/board/MyPage.tsx index f9fd726..4c51fb2 100644 --- a/components/board/MyPage.tsx +++ b/components/board/MyPage.tsx @@ -5,6 +5,7 @@ import type { Member } from "@/lib/board/types"; import { useBoard } from "./BoardApp"; import { roundTasks } from "./derive"; import { daysBetween, fmt } 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. @@ -89,6 +90,26 @@ export function MyPage() { const dueText = left === null ? "" : left > 1 ? `Due ${dueDay} · ${left} days left` : left === 1 ? `Due tomorrow, ${dueDay}` : left === 0 ? "Due today" : "Past due"; const teammates = data.members.filter((x) => x.id !== me.id && (x.team === me.team || x.alsoTeam === me.team)); const upcoming = data.socials.filter((s) => s.date >= today).slice(0, 3); + // Scheduled meetings this person is invited to, by their next date. + const myEmail = me.email.trim().toLowerCase(); + const meetings = data.scheduled + .filter((m) => myEmail && m.invitees.some((i) => i.email.toLowerCase() === myEmail)) + .map((m) => ({ m, next: upcomingDates(m, today)[0] })) + .filter((x) => x.next) + .sort((a, b) => (a.next < b.next ? -1 : 1)); + // Meetings and events together, by date. + const comingUp = [ + ...meetings.map(({ m, next }) => ({ + key: `m${m.id}`, + date: next, + text: `${m.title} · ${timeRange(m.startTime, m.endTime)}${m.weekly ? ", weekly" : ""}${m.location ? ` · ${m.location}` : ""}`, + })), + ...upcoming.map((e) => ({ + key: `s${e.id}`, + date: e.date, + text: [e.title, e.location, e.time].filter(Boolean).join(" · "), + })), + ].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); const timeline = chapter.projects.timeline; const week = timeline.weeks.find((w) => daysBetween(w.start, today) >= 0 && daysBetween(today, w.end) >= -timeline.graceDays); @@ -179,20 +200,16 @@ export function MyPage() {

Coming up

    - {chapter.myPage.meetingLine && ( + {meetings.length === 0 && chapter.myPage.meetingLine && (
  • Sun {chapter.myPage.meetingLine}
  • )} - {upcoming.map((s) => ( -
  • - {fmt(s.date, { month: "short", day: "numeric" })} - - {s.title} - {s.location && ` · ${s.location}`} - {s.time && ` · ${s.time}`} - + {comingUp.map((c) => ( +
  • + {fmt(c.date, { month: "short", day: "numeric" })} + {c.text}
  • ))}
diff --git a/components/board/ScheduleForm.tsx b/components/board/ScheduleForm.tsx new file mode 100644 index 0000000..2df27db --- /dev/null +++ b/components/board/ScheduleForm.tsx @@ -0,0 +1,218 @@ +"use client"; + +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"; + +type Kind = "all" | "team" | "people"; + +// Schedule a meeting with calendar invites, or edit one (admins only). +// Separate from logging a past meeting. +export function ScheduleForm({ existing, onClose }: { existing?: ScheduledMeeting; onClose: () => void }) { + const { chapter, data, today, refresh } = useBoard(); + const uid = useId(); + const members = data.members; + const teams = ["Executive", ...chapter.teams]; + + // Prefill who's invited from what was saved. + const initialKind: Kind = !existing ? "all" : existing.audience === "Whole board" ? "all" : existing.audience.endsWith(" team") ? "team" : "people"; + const invitedEmails = new Set((existing?.invitees ?? []).map((i) => i.email.toLowerCase())); + + const [f, setF] = useState({ + title: existing?.title ?? `${chapter.id === "ucla" ? "UCLA" : "Berkeley"} board meeting`, + date: existing?.date ?? today, + startTime: existing?.startTime ?? "18:00", + endTime: existing?.endTime ?? "18:30", + location: existing?.location ?? "", + notes: existing?.notes ?? "", + weekly: existing?.weekly ?? false, + until: existing?.until ?? "", + }); + const [kind, setKind] = useState(initialKind); + const [team, setTeam] = useState(initialKind === "team" ? existing!.audience.replace(/ team$/, "") : teams[1] ?? teams[0]); + const [people, setPeople] = useState>( + () => new Set(initialKind === "people" ? members.filter((m) => invitedEmails.has(m.email.trim().toLowerCase())).map((m) => m.id) : []), + ); + const [testTo, setTestTo] = useState(data.organizerEmail ?? ""); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + + const set = (k: keyof typeof f, v: string | boolean) => { + setF((x) => ({ ...x, [k]: v })); + setConfirming(false); + }; + const picked = members.filter((m) => + kind === "all" ? true : kind === "team" ? m.team === team || m.alsoTeam === team : people.has(m.id), + ); + const reachable = picked.filter((m) => isEmail(m.email.trim())); + const noEmail = picked.filter((m) => !isEmail(m.email.trim())); + const invite = kind === "all" ? { kind } : kind === "team" ? { kind, team } : { kind, ids: [...people] }; + const body = { ...f, until: f.weekly ? f.until : null, invite }; + + async function send(test: boolean) { + 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); + setBusy(false); + setConfirming(false); + if (error) 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); + const cancelled = Number(r?.cancelled ?? 0); + setMsg({ + kind: failed.length ? "err" : "ok", + text: + `${existing ? "Updated invites" : "Invites"} sent to ${sent} ${sent === 1 ? "person" : "people"}` + + (cancelled ? `; ${cancelled} taken off the invite got a cancellation` : "") + + "." + + (failed.length ? ` These didn't go through: ${failed.join(", ")}.` : ""), + }); + refresh(); + if (!failed.length) setTimeout(onClose, 1500); + } + + function submit(e: FormEvent) { + e.preventDefault(); + if (reachable.length === 0) return setMsg({ kind: "err", text: "No one you picked has an email on file." }); + setConfirming(true); + } + + const id = (k: string) => `${uid}-${k}`; + return ( +
+
+

{existing ? `Edit ${existing.title}` : "Schedule a meeting"}

+

Everyone invited gets an email with Yes, Maybe and No buttons that add it to their calendar.

+ +
+ +
+ + +
+
+ +