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.
+
+
You're on the list!
+
Something went wrong. Please try again.
+
+ `;
+ }
+
// 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;
}