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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@ BOARD_SESSION_SECRET=
BREVO_SMTP_LOGIN=
BREVO_SMTP_KEY=
BOARD_INVITE_FROM=board@getvindicated.org

# Public Events page (/events). Leave unset until launch: the page is a 404
# except for board admins, and there's no nav link, home strip or sitemap
# entry. Set to exactly "true" to launch. See BOARD_SETUP.md.
EVENTS_PUBLIC=
46 changes: 46 additions & 0 deletions BOARD_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,52 @@ Use "Send me a test invite" in the form to check one before sending to the
board. Send it to an address other than the organizer's: Gmail doesn't
show RSVP buttons on an invite sent to its own organizer.

## Public events (/events)

Admins add workshops and meetups on each hub's **Events** tab (admins only).
They show on the public Events page, where anyone can RSVP. Each event has:
- a title, tag, date and time (Pacific)
- online or in person, with a meeting link or an address
- a host and a description
- a flyer, uploaded like the hub photos
- an optional "Show RSVP count"

An event belongs to the hub it's made on. Its emails come from that
chapter's sender (above), with reply-to set to the chapter's organizer.

- **RSVPs:** each RSVP gets a confirmation email with the details, the
meeting link and a calendar file. RSVPing again with the same email
doesn't add a second RSVP; it just resends the confirmation.
- **The meeting link is private.** It's never on the page or in anything
the page loads. It only goes out in confirmations and reminders, so add it
before people RSVP, or send a reminder once it's in.
- **RSVP list:** each event's RSVPs (name, email, school, question, whether
they want future emails, time) are on the Events tab, with **Copy
emails**, **Download CSV** and **Send reminder to everyone**. The
reminder asks first, showing how many people it goes to. Nothing is sent
on a schedule; reminders only go out when an admin presses the button.
- **Change log:** adding, editing and deleting events goes in the Changes
tab, like everything else. Sending a reminder is noted there too.

### Launching the page

Until launch the page is private:
- `/events` shows the normal "page not found" to the public, and the RSVP
form refuses.
- An admin signed in to either hub can open the page to test it (RSVPs
included), with a "Private preview" banner.
- There's no "Events" link in the header, no next-event strip on the home
page, no sitemap entry, and the page tells search engines not to index it.

To **launch**, add the Railway variable `EVENTS_PUBLIC` set to exactly
`true` (website service → **Variables**) and deploy. Railway offers to
redeploy when you save a variable; the header and home page only pick up
the change on that new deploy. This adds "Events" to the header (between
Team and Learning Resources) and the next-event strip to the home page.

To **hide it again**, delete `EVENTS_PUBLIC` (or set it to anything but
`true`) and redeploy. Events and RSVPs stay saved on the hub.

## One-time setup in Railway

1. **Database.** In the Railway project, add a **Postgres** service. In the
Expand Down
50 changes: 50 additions & 0 deletions app/[locale]/events/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { PageHero } from "@/components/sections/shared/PageHero";
import { EventsList, type ShownEvent } from "@/components/sections/events/EventsList";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { getRouteMetadata } from "@/lib/i18n/metadata";
import { isValidLocale, type Locale } from "@/lib/i18n/config";
import { eventsAdmin, eventsPublic, listPublicEvents } from "@/lib/events/server";
import { eventLabels } from "@/lib/events/format";

// Reads the board session cookie (admins can preview before launch), so it
// renders on every request.
export const dynamic = "force-dynamic";

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const meta = await getRouteMetadata(locale, "events", "/events");
// Kept out of search engines until launch (see BOARD_SETUP.md).
return eventsPublic() ? meta : { ...meta, robots: { index: false, follow: false } };
}

export default async function EventsPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isValidLocale(locale)) notFound();
const isPublic = eventsPublic();
if (!isPublic && !eventsAdmin(await headers())) notFound();

const d = (await getDictionary(locale as Locale)).eventsPage;
const events: ShownEvent[] = (await listPublicEvents().catch((e) => {
console.error("[events] Couldn't load events:", e instanceof Error ? e.message : e);
return [];
})).map((e) => ({ ...e, ...eventLabels(e, locale) }));

return (
<div className="rd rd-page">
{!isPublic && (
<div className="ev-preview" role="status">
{d.preview}
</div>
)}
<PageHero title={d.hero.title} body={d.hero.body} />
<section className="pt0">
<div className="wrap">
<EventsList dict={d} events={events} />
</div>
</section>
</div>
);
}
3 changes: 2 additions & 1 deletion app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SiteFooter } from "@/components/layout/SiteFooter";
import { locales, isValidLocale, isRtl, type Locale } from "@/lib/i18n/config";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { getRootMetadata } from "@/lib/i18n/metadata";
import { eventsPublic } from "@/lib/events/flag";
// import { CarCursor } from "@/components/ui/CarCursor";

const lora = Lora({
Expand Down Expand Up @@ -80,7 +81,7 @@ export default async function RootLayout({
/>
{/* <Starfield /> */}
{/* <CarCursor /> */}
<SiteHeader locale={locale as Locale} dict={dict} />
<SiteHeader locale={locale as Locale} dict={{ nav: dict.nav, ui: dict.ui }} showEvents={eventsPublic()} />
<main>{children}</main>
<SiteFooter locale={locale as Locale} dict={dict} />
</body>
Expand Down
3 changes: 3 additions & 0 deletions app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { CampusChapters } from "@/components/sections/home/CampusChapters";
import { FollowLinks } from "@/components/sections/home/FollowLinks";
import { Pillars } from "@/components/sections/home/Pillars";
import { FreeResources } from "@/components/sections/home/FreeResources";
import { NextEventStrip } from "@/components/sections/home/NextEventStrip";
import { eventsPublic } from "@/lib/events/flag";
import { getDictionary } from "@/lib/i18n/get-dictionary";
import { SHARE_IMAGE } from "@/lib/i18n/metadata";
import {
Expand Down Expand Up @@ -64,6 +66,7 @@ export default async function HomePage({
return (
<div className="rd rd-page">
<HomeHero dict={home.hero} ppiHref={ppi} storyHref={story} />
{eventsPublic() && <NextEventStrip dict={dict.eventsPage.strip} locale={locale} eventsHref={href("/events")} />}
<PpiExplainer
dict={home.ppi}
knowledge={home.publicKnowledge}
Expand Down
24 changes: 24 additions & 0 deletions app/api/board/events/[id]/csv/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { guard, idFrom, notFound } from "@/lib/board/api";
import { db } from "@/lib/board/db";
import { toRsvp } from "@/lib/events/server";
import { rsvpCsv } from "@/lib/events/admin";

// "Download CSV" of an event's RSVPs (admins).
export async function GET(request: Request, ctx: { params: Promise<{ id: string }> }) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
const id = idFrom((await ctx.params).id);
if (!id) return notFound();
const sql = db();
const [event] = await sql`SELECT title, date FROM board_events WHERE id = ${id} AND chapter = ${user.chapter}`;
if (!event) return notFound();
const rows = await sql`SELECT * FROM board_event_rsvps WHERE event_id = ${id} AND chapter = ${user.chapter} ORDER BY created_at, id`;
const slug = String(event.title).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 50) || "event";
return new Response(rsvpCsv(rows.map(toRsvp)), {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="rsvps-${event.date}-${slug}.csv"`,
"cache-control": "no-store",
},
});
}
35 changes: 35 additions & 0 deletions app/api/board/events/[id]/remind/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { bad, guard, idFrom, notFound } from "@/lib/board/api";
import { db } from "@/lib/board/db";
import { actorOf, logEntry } from "@/lib/board/changelog";
import { inviteSetupProblem } from "@/lib/board/invites";
import { getFullEvent } from "@/lib/events/server";
import { sendEventEmails } from "@/lib/events/email";

// "Send reminder to everyone" (admins): emails every RSVP the event details
// and meeting link again. Noted in the change log.
export async function POST(request: Request, ctx: { params: Promise<{ id: string }> }) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
const id = idFrom((await ctx.params).id);
const event = id ? await getFullEvent(id) : null;
if (!event || event.chapter !== user.chapter) return notFound();
const problem = inviteSetupProblem(user.chapter);
if (problem) return bad(problem, 503);
const rsvps = await db()`SELECT name, email FROM board_event_rsvps WHERE event_id = ${event.id} ORDER BY created_at, id`;
if (rsvps.length === 0) return bad("No one has RSVPed yet, so there's no one to remind.");
const r = await sendEventEmails(
event,
"reminder",
rsvps.map((x) => ({ name: x.name as string, email: x.email as string })),
);
await logEntry(
db(),
user,
actorOf(request),
"note",
`Sent a reminder for "${event.title}" to ${r.sent.length} of ${rsvps.length} ${rsvps.length === 1 ? "person" : "people"}`,
[],
);
if (r.sent.length === 0) return bad("The reminders didn't go through. Try again.", 502);
return Response.json({ ok: true, sent: r.sent.length, failed: r.failed });
}
46 changes: 46 additions & 0 deletions app/api/board/events/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { bad, guard, idFrom, notFound, readJson } from "@/lib/board/api";
import { db } from "@/lib/board/db";
import { actorOf, logEntry, snapshot } from "@/lib/board/changelog";
import { parseEvent } from "@/lib/events/admin";

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

// Edits one of this chapter's events (admins). Logged.
export async function PATCH(request: Request, ctx: Ctx) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
const id = idFrom((await ctx.params).id);
if (!id) return notFound();
const body = await readJson(request);
if (!body) return bad("Check the form.");
const cols = await parseEvent(body, user.chapter);
if ("error" in cols) return bad(cols.error);
const done = await db().begin(async (tx) => {
const before = await snapshot(tx, "board_events", user.chapter, id);
if (!before) return false;
await tx`UPDATE board_events SET ${tx(cols)} WHERE id = ${id} AND chapter = ${user.chapter}`;
const after = await snapshot(tx, "board_events", user.chapter, id);
await logEntry(tx, user, actorOf(request), "update", `Edited the event "${cols.title}"`, [{ table: "board_events", id, before, after }]);
return true;
});
return done ? Response.json({ ok: true }) : notFound();
}

// Deletes an event (admins). Its RSVPs stay in the database, so restoring
// the event from the change log brings them back.
export async function DELETE(request: Request, ctx: Ctx) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
const id = idFrom((await ctx.params).id);
if (!id) return notFound();
const done = await db().begin(async (tx) => {
const before = await snapshot(tx, "board_events", user.chapter, id);
if (!before) return false;
await tx`DELETE FROM board_events WHERE id = ${id} AND chapter = ${user.chapter}`;
await logEntry(tx, user, actorOf(request), "delete", `Deleted the event "${before.title}"`, [
{ table: "board_events", id, before, after: null },
]);
return true;
});
return done ? Response.json({ ok: true }) : notFound();
}
37 changes: 37 additions & 0 deletions app/api/board/events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { randomUUID } from "node:crypto";
import { bad, guard, readJson } from "@/lib/board/api";
import { db } from "@/lib/board/db";
import { actorOf, logEntry, snapshot } from "@/lib/board/changelog";
import { listAdminEvents } from "@/lib/events/server";
import { parseEvent } from "@/lib/events/admin";

// The hub's Events tab (admins only): this chapter's public events with
// their RSVPs, and creating a new one. Every change goes in the change log.
export async function GET(request: Request) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
return Response.json({ events: await listAdminEvents(user.chapter) }, { headers: { "cache-control": "no-store" } });
}

export async function POST(request: Request) {
const user = await guard(request, ["admin"]);
if (user instanceof Response) return user;
const body = await readJson(request);
if (!body) return bad("Check the form.");
const cols = await parseEvent(body, user.chapter);
if ("error" in cols) return bad(cols.error);
const sql = db();
const id = await sql.begin(async (tx) => {
const [row] = await tx`INSERT INTO board_events ${tx({
...cols,
chapter: user.chapter,
uid: `${user.chapter}-${randomUUID()}@getvindicated.org`,
})} RETURNING id`;
const after = await snapshot(tx, "board_events", user.chapter, row.id);
await logEntry(tx, user, actorOf(request), "create", `Added the event "${cols.title}"`, [
{ table: "board_events", id: row.id, before: null, after },
]);
return row.id as number;
});
return Response.json({ ok: true, id }, { status: 201 });
}
21 changes: 21 additions & 0 deletions app/api/events/[id]/flyer/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { idFrom } from "@/lib/board/api";
import { db } from "@/lib/board/db";
import { canSeeEvents, eventsPublic } from "@/lib/events/server";

// An event's flyer, for the public page. Flyers are uploaded on the hub and
// stored with the hub photos; only a photo an event points at is served here.
export async function GET(request: Request, ctx: { params: Promise<{ id: string }> }) {
if (!canSeeEvents(request.headers) || !process.env.DATABASE_URL) return new Response("Not found", { status: 404 });
const id = idFrom((await ctx.params).id);
if (!id) return new Response("Not found", { status: 404 });
const [row] = await db()`SELECT p.content_type, p.data FROM board_events e
JOIN board_photos p ON p.id = e.flyer_id AND p.chapter = e.chapter WHERE e.id = ${id}`;
if (!row) return new Response("Not found", { status: 404 });
return new Response(new Uint8Array(row.data as Buffer), {
headers: {
"content-type": row.content_type,
// Short, so a new flyer shows up soon after an admin changes it.
"cache-control": eventsPublic() ? "public, max-age=300" : "private, max-age=300",
},
});
}
20 changes: 20 additions & 0 deletions app/api/events/[id]/ics/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { idFrom } from "@/lib/board/api";
import { canSeeEvents, getFullEvent } from "@/lib/events/server";
import { buildEventIcs } from "@/lib/events/ics";

// The "Download for Apple / Outlook" file. Anyone who can see the page can
// fetch it, so it never has the meeting link (that's in the confirmation
// email's copy).
export async function GET(request: Request, ctx: { params: Promise<{ id: string }> }) {
if (!canSeeEvents(request.headers)) return new Response("Not found", { status: 404 });
const id = idFrom((await ctx.params).id);
const event = id ? await getFullEvent(id) : null;
if (!event) return new Response("Not found", { status: 404 });
return new Response(buildEventIcs(event, null), {
headers: {
"content-type": "text/calendar; charset=utf-8",
"content-disposition": `attachment; filename="vindicated-event-${event.id}.ics"`,
"cache-control": "no-store",
},
});
}
8 changes: 8 additions & 0 deletions app/api/events/next/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { eventsPublic, nextPublicEvent } from "@/lib/events/server";

// The next event for the home page strip, once events are public.
export async function GET() {
if (!eventsPublic()) return new Response("Not found", { status: 404 });
const event = await nextPublicEvent().catch(() => null);
return Response.json({ event }, { headers: { "cache-control": "public, max-age=60" } });
}
Loading