From 1749540742a3cfb4b44247ecf2278279f5e28092 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 23 Feb 2026 19:40:18 -0500 Subject: [PATCH] feat(events): RSVP approval gating and email interest capture - Add require_rsvp_approval to events (stored in DB, synced to Luma on create and update) - Expose require_rsvp_approval in create_event and update_event Addie tools - Implement Luma sync in update_event handler (was a TODO) - Direct registrations respect require_rsvp_approval by setting status to waitlisted - Add POST /api/events/interest public endpoint (no auth) that creates an email_contact and a waitlisted interest registration - Add register_event_interest Addie tool with already-registered detection - Add interest form to event detail page with pre-fill for logged-in users - Migration 243: add require_rsvp_approval column to events - Migration 244: add interest to registration_source CHECK constraint Co-Authored-By: Claude Sonnet 4.6 --- .changeset/long-boats-nail.md | 4 + server/public/event-detail.html | 50 +++++++++ server/src/addie/mcp/event-tools.ts | 103 +++++++++++++++++- server/src/db/events-db.ts | 6 +- .../db/migrations/243_event_rsvp_approval.sql | 1 + .../244_registration_source_interest.sql | 5 + server/src/luma/client.ts | 1 + server/src/routes/events.ts | 51 +++++++++ server/src/types.ts | 5 +- 9 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 .changeset/long-boats-nail.md create mode 100644 server/src/db/migrations/243_event_rsvp_approval.sql create mode 100644 server/src/db/migrations/244_registration_source_interest.sql diff --git a/.changeset/long-boats-nail.md b/.changeset/long-boats-nail.md new file mode 100644 index 0000000000..b804ff71c0 --- /dev/null +++ b/.changeset/long-boats-nail.md @@ -0,0 +1,4 @@ +--- +--- + +Add RSVP approval gating to events and email interest capture. Events now support `require_rsvp_approval` on create and update (synced to Luma). A public interest form on event pages and a new Addie tool allow anyone to register interest, which creates a waitlisted registration record linked to the event for prospecting. diff --git a/server/public/event-detail.html b/server/public/event-detail.html index 661a520ece..46fdf9c1fa 100644 --- a/server/public/event-detail.html +++ b/server/public/event-detail.html @@ -721,6 +721,30 @@

About This Event

`; } + // Email interest form (upcoming events only) + if (!isPast) { + const loggedInEmail = window.__APP_CONFIG__?.user?.email || ''; + html += ` +
+

Stay informed

+

Enter your email to be notified about updates for this event.

+
+ + +
+ + +
+ `; + } + // Industry Gathering / Attendee Group if (industryGathering) { html += ` @@ -855,6 +879,32 @@

Become a Sponsor

alert('Registration coming soon! Check back later.'); } + async function submitInterest(e) { + e.preventDefault(); + const slug = e.target.dataset.slug; + const email = document.getElementById('interest-email').value; + const successEl = document.getElementById('interest-success'); + const errorEl = document.getElementById('interest-error'); + successEl.style.display = 'none'; + errorEl.style.display = 'none'; + + try { + const res = await fetch('/api/events/interest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, event_slug: slug }), + }); + if (res.ok) { + document.getElementById('interest-form').style.display = 'none'; + successEl.style.display = 'block'; + } else { + errorEl.style.display = 'block'; + } + } catch { + errorEl.style.display = 'block'; + } + } + function handleSponsorTier(tierId, tierName, priceCents) { // For now, redirect to contact - full checkout flow will be implemented later const price = formatPrice(priceCents); diff --git a/server/src/addie/mcp/event-tools.ts b/server/src/addie/mcp/event-tools.ts index 6f3a032ff3..aa1f7ae610 100644 --- a/server/src/addie/mcp/event-tools.ts +++ b/server/src/addie/mcp/event-tools.ts @@ -15,15 +15,18 @@ import type { AddieTool } from '../types.js'; import type { MemberContext } from '../member-context.js'; import { isSlackUserAAOAdmin } from './admin-tools.js'; import { eventsDb } from '../../db/events-db.js'; +import { upsertEmailContact } from '../../db/contacts-db.js'; import { query } from '../../db/client.js'; import { createEvent as createLumaEvent, + updateEvent as updateLumaEvent, getEvent as getLumaEvent, getEventGuests, approveGuest, declineGuest, isLumaEnabled, type CreateEventInput as LumaCreateEventInput, + type UpdateEventInput as LumaUpdateEventInput, } from '../../luma/client.js'; import type { CreateEventInput, @@ -412,6 +415,10 @@ Optional: description, end_time, timezone, location details, virtual_url, max_at type: 'boolean', description: 'Publish the event immediately (default: true)', }, + require_rsvp_approval: { + type: 'boolean', + description: 'If true, registrations require admin approval before being confirmed.', + }, }, required: ['title', 'start_time', 'event_type'], }, @@ -526,6 +533,24 @@ the response will suggest they share their location or join industry gathering g enum: ['draft', 'published', 'cancelled'], description: 'Change event status', }, + require_rsvp_approval: { + type: 'boolean', + description: 'If true, registrations require admin approval before being confirmed.', + }, + }, + required: ['event_slug'], + }, + }, + { + name: 'register_event_interest', + description: `Register the current user's interest in an event. Use when someone asks to be notified about an event, added to a waitlist, or wants to express interest without formally registering.`, + input_schema: { + type: 'object' as const, + properties: { + event_slug: { + type: 'string', + description: 'Event slug or ID', + }, }, required: ['event_slug'], }, @@ -605,6 +630,7 @@ export function createEventToolHandlers( venue_country: (input.venue_country as string) || 'United States', virtual_url: input.virtual_url as string | undefined, max_attendees: input.max_attendees as number | undefined, + require_rsvp_approval: (input.require_rsvp_approval as boolean | undefined) ?? false, status: publishImmediately ? 'published' : 'draft', created_by_user_id: memberContext?.workos_user?.workos_user_id || slackUserId, }; @@ -622,6 +648,7 @@ export function createEventToolHandlers( end_at: endTime?.toISOString() || new Date(startTime.getTime() + 2 * 60 * 60 * 1000).toISOString(), timezone, visibility: publishImmediately ? 'public' : 'private', + require_rsvp_approval: (input.require_rsvp_approval as boolean | undefined) ?? false, }; // Add location for in-person events @@ -1019,6 +1046,10 @@ export function createEventToolHandlers( } changes.push(`Status → ${input.status}`); } + if (input.require_rsvp_approval !== undefined) { + updates.require_rsvp_approval = input.require_rsvp_approval; + changes.push(`RSVP approval → ${input.require_rsvp_approval ? 'required' : 'open'}`); + } if (changes.length === 0) { return 'No changes provided. Specify at least one field to update.'; @@ -1026,7 +1057,26 @@ export function createEventToolHandlers( await eventsDb.updateEvent(event.id, updates); - // TODO: Update Luma event if luma_event_id exists + // Sync to Luma if event has a Luma ID + if (event.luma_event_id && isLumaEnabled()) { + const lumaUpdates: LumaUpdateEventInput = {}; + if (updates.title) lumaUpdates.name = updates.title as string; + if (updates.description) lumaUpdates.description = updates.description as string; + if (updates.start_time) lumaUpdates.start_at = (updates.start_time as Date).toISOString(); + if (updates.end_time) lumaUpdates.end_at = (updates.end_time as Date).toISOString(); + if (updates.virtual_url) lumaUpdates.meeting_url = updates.virtual_url as string; + if (updates.status) lumaUpdates.visibility = updates.status === 'published' ? 'public' : 'private'; + if (updates.require_rsvp_approval !== undefined) lumaUpdates.require_rsvp_approval = updates.require_rsvp_approval as boolean; + + if (Object.keys(lumaUpdates).length > 0) { + try { + await updateLumaEvent(event.luma_event_id, lumaUpdates); + logger.info({ lumaEventId: event.luma_event_id, updates: Object.keys(lumaUpdates) }, 'Synced event update to Luma'); + } catch (lumaError) { + logger.warn({ err: lumaError, lumaEventId: event.luma_event_id }, 'Failed to sync event update to Luma'); + } + } + } logger.info({ eventId: event.id, @@ -1037,5 +1087,56 @@ export function createEventToolHandlers( return `✅ Updated **${event.title}**\n\n${changes.map(c => `• ${c}`).join('\n')}`; }); + // Register interest in an event + handlers.set('register_event_interest', async (input) => { + const eventSlug = input.event_slug as string; + + let event = await eventsDb.getEventBySlug(eventSlug); + if (!event) { + event = await eventsDb.getEventById(eventSlug); + } + if (!event) { + return `❌ Event not found: "${eventSlug}"`; + } + + const email = memberContext?.workos_user?.email ?? memberContext?.slack_user?.email ?? null; + if (!email) { + return `I don't have your email address on file. Could you share it so I can add you to the interest list?`; + } + + const displayName = memberContext?.workos_user + ? [memberContext.workos_user.first_name, memberContext.workos_user.last_name].filter(Boolean).join(' ') || undefined + : memberContext?.slack_user?.display_name ?? undefined; + + // Check if already registered so we can give a useful response + const workosUserId = memberContext?.workos_user?.workos_user_id; + if (workosUserId) { + const alreadyRegistered = await eventsDb.isUserRegistered(event.id, workosUserId, email); + if (alreadyRegistered) { + return `You're already registered for **${event.title}** — no need to join the interest list separately.`; + } + } + + const contact = await upsertEmailContact({ email, displayName }); + + try { + await eventsDb.createRegistration({ + event_id: event.id, + email_contact_id: contact.contactId, + email, + name: displayName, + registration_status: 'waitlisted', + registration_source: 'interest', + }); + } catch (err) { + if ((err as { code?: string }).code !== '23505') throw err; + // Unique constraint hit — already expressed interest + } + + logger.info({ eventId: event.id, email, contactId: contact.contactId }, 'Event interest registered via Addie'); + + return `✅ You're on the interest list for **${event.title}**. We'll keep you posted on updates.`; + }); + return handlers; } diff --git a/server/src/db/events-db.ts b/server/src/db/events-db.ts index d0514b671f..0eda1b2555 100644 --- a/server/src/db/events-db.ts +++ b/server/src/db/events-db.ts @@ -45,12 +45,12 @@ export class EventsDatabase { external_registration_url, is_external_event, featured_image_url, sponsorship_enabled, sponsorship_tiers, stripe_product_id, - status, max_attendees, + status, max_attendees, require_rsvp_approval, created_by_user_id, organization_id, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, - $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31 + $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32 ) RETURNING *`, [ @@ -82,6 +82,7 @@ export class EventsDatabase { input.stripe_product_id || null, input.status || 'draft', input.max_attendees || null, + input.require_rsvp_approval ?? false, input.created_by_user_id || null, input.organization_id || null, JSON.stringify(input.metadata || {}), @@ -148,6 +149,7 @@ export class EventsDatabase { status: 'status', published_at: 'published_at', max_attendees: 'max_attendees', + require_rsvp_approval: 'require_rsvp_approval', metadata: 'metadata', }; diff --git a/server/src/db/migrations/243_event_rsvp_approval.sql b/server/src/db/migrations/243_event_rsvp_approval.sql new file mode 100644 index 0000000000..7e425e7592 --- /dev/null +++ b/server/src/db/migrations/243_event_rsvp_approval.sql @@ -0,0 +1 @@ +ALTER TABLE events ADD COLUMN require_rsvp_approval BOOLEAN NOT NULL DEFAULT false; diff --git a/server/src/db/migrations/244_registration_source_interest.sql b/server/src/db/migrations/244_registration_source_interest.sql new file mode 100644 index 0000000000..bd8ae5a7d7 --- /dev/null +++ b/server/src/db/migrations/244_registration_source_interest.sql @@ -0,0 +1,5 @@ +ALTER TABLE event_registrations + DROP CONSTRAINT IF EXISTS event_registrations_registration_source_check; +ALTER TABLE event_registrations + ADD CONSTRAINT event_registrations_registration_source_check + CHECK (registration_source IN ('direct', 'luma', 'import', 'admin', 'interest')); diff --git a/server/src/luma/client.ts b/server/src/luma/client.ts index d3913449ad..ca1d6168cd 100644 --- a/server/src/luma/client.ts +++ b/server/src/luma/client.ts @@ -95,6 +95,7 @@ export interface UpdateEventInput { meeting_url?: string; cover_url?: string; visibility?: 'public' | 'private'; + require_rsvp_approval?: boolean; } // ============================================================================ diff --git a/server/src/routes/events.ts b/server/src/routes/events.ts index cccc9d24a0..1a12d7dbc9 100644 --- a/server/src/routes/events.ts +++ b/server/src/routes/events.ts @@ -966,6 +966,56 @@ export function createEventsRouter(): { } }); + // POST /api/events/interest - Register email interest (no auth required) + publicApiRouter.post("/interest", async (req: Request, res: Response) => { + try { + const { email, name, event_slug } = req.body as { + email?: unknown; + name?: unknown; + event_slug?: unknown; + }; + + if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return res.status(400).json({ error: "Invalid email address" }); + } + + const displayName = typeof name === "string" && name.trim() ? name.trim() : undefined; + const slug = typeof event_slug === "string" && event_slug.trim() ? event_slug.trim() : undefined; + + const contact = await upsertEmailContact({ email: email.toLowerCase().trim(), displayName }); + + // Create a waitlisted registration to record the event-specific interest signal + if (slug) { + const event = await eventsDb.getEventBySlug(slug); + if (event) { + try { + await eventsDb.createRegistration({ + event_id: event.id, + email_contact_id: contact.contactId, + email: email.toLowerCase().trim(), + name: displayName, + registration_status: 'waitlisted', + registration_source: 'interest', + }); + } catch (err) { + if ((err as { code?: string }).code !== '23505') throw err; + // Unique constraint hit — contact already expressed interest or registered + } + } + } + + logger.info( + { contactId: contact.contactId, isNew: contact.isNew, event_slug: slug }, + "Email interest registered" + ); + + res.json({ success: true }); + } catch (error) { + logger.error({ err: error }, "Error registering email interest"); + res.status(500).json({ error: "Failed to register interest" }); + } + }); + // GET /api/events/:slug - Get event by slug (public) publicApiRouter.get("/:slug", async (req: Request, res: Response) => { try { @@ -1054,6 +1104,7 @@ export function createEventsRouter(): { email: user.email, name: `${user.firstName || ""} ${user.lastName || ""}`.trim() || undefined, registration_source: "direct", + registration_status: event.require_rsvp_approval ? "waitlisted" : "registered", }); logger.info( diff --git a/server/src/types.ts b/server/src/types.ts index 2f5911c30a..d47ac715be 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -913,7 +913,7 @@ export type EventType = 'summit' | 'meetup' | 'webinar' | 'workshop' | 'conferen export type EventFormat = 'in_person' | 'virtual' | 'hybrid'; export type EventStatus = 'draft' | 'published' | 'cancelled' | 'completed'; export type RegistrationStatus = 'registered' | 'waitlisted' | 'cancelled' | 'no_show'; -export type RegistrationSource = 'direct' | 'luma' | 'import' | 'admin'; +export type RegistrationSource = 'direct' | 'luma' | 'import' | 'admin' | 'interest'; export type SponsorshipPaymentStatus = 'pending' | 'paid' | 'refunded' | 'cancelled'; export interface SponsorshipTier { @@ -956,6 +956,7 @@ export interface Event { status: EventStatus; published_at?: Date; max_attendees?: number; + require_rsvp_approval: boolean; created_by_user_id?: string; organization_id?: string; metadata: Record; @@ -992,6 +993,7 @@ export interface CreateEventInput { stripe_product_id?: string; status?: EventStatus; max_attendees?: number; + require_rsvp_approval?: boolean; created_by_user_id?: string; organization_id?: string; metadata?: Record; @@ -1026,6 +1028,7 @@ export interface UpdateEventInput { status?: EventStatus; published_at?: Date; max_attendees?: number; + require_rsvp_approval?: boolean; metadata?: Record; }