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
4 changes: 4 additions & 0 deletions .changeset/long-boats-nail.md
Original file line number Diff line number Diff line change
@@ -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.
50 changes: 50 additions & 0 deletions server/public/event-detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,30 @@ <h2 class="section-title">About This Event</h2>
`;
}

// Email interest form (upcoming events only)
if (!isPast) {
const loggedInEmail = window.__APP_CONFIG__?.user?.email || '';
html += `
<div class="event-section" id="interest-section">
<h2 class="section-title">Stay informed</h2>
<p style="color: var(--color-text-secondary); margin-bottom: 1rem;">Enter your email to be notified about updates for this event.</p>
<form id="interest-form" data-slug="${escapeHtml(event.slug)}" style="display: flex; gap: 0.5rem; flex-wrap: wrap;" onsubmit="submitInterest(event)">
<input
type="email"
id="interest-email"
placeholder="you@company.com"
value="${escapeHtml(loggedInEmail)}"
required
style="flex: 1; min-width: 220px; padding: 0.625rem 0.875rem; border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.9375rem; background: var(--color-surface); color: var(--color-text);"
/>
<button type="submit" class="btn btn-primary">Notify me</button>
</form>
<p id="interest-success" style="display: none; color: var(--color-success, #16a34a); margin-top: 0.5rem;">You're on the list!</p>
<p id="interest-error" style="display: none; color: var(--color-error, #dc2626); margin-top: 0.5rem;">Something went wrong. Please try again.</p>
</div>
`;
}

// Industry Gathering / Attendee Group
if (industryGathering) {
html += `
Expand Down Expand Up @@ -855,6 +879,32 @@ <h3>Become a Sponsor</h3>
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);
Expand Down
103 changes: 102 additions & 1 deletion server/src/addie/mcp/event-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'],
},
Expand Down Expand Up @@ -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'],
},
Expand Down Expand Up @@ -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,
};
Expand All @@ -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
Expand Down Expand Up @@ -1019,14 +1046,37 @@ 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.';
}

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,
Expand All @@ -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;
}
6 changes: 4 additions & 2 deletions server/src/db/events-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 *`,
[
Expand Down Expand Up @@ -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 || {}),
Expand Down Expand Up @@ -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',
};

Expand Down
1 change: 1 addition & 0 deletions server/src/db/migrations/243_event_rsvp_approval.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE events ADD COLUMN require_rsvp_approval BOOLEAN NOT NULL DEFAULT false;
5 changes: 5 additions & 0 deletions server/src/db/migrations/244_registration_source_interest.sql
Original file line number Diff line number Diff line change
@@ -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'));
1 change: 1 addition & 0 deletions server/src/luma/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export interface UpdateEventInput {
meeting_url?: string;
cover_url?: string;
visibility?: 'public' | 'private';
require_rsvp_approval?: boolean;
}

// ============================================================================
Expand Down
51 changes: 51 additions & 0 deletions server/src/routes/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -1026,6 +1028,7 @@ export interface UpdateEventInput {
status?: EventStatus;
published_at?: Date;
max_attendees?: number;
require_rsvp_approval?: boolean;
metadata?: Record<string, unknown>;
}

Expand Down