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.

+ +
+ +
+ + +
+
+ +