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: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions BOARD_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions app/api/board/schedule/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
50 changes: 50 additions & 0 deletions app/api/board/schedule/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
30 changes: 30 additions & 0 deletions app/board/board.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Loading