Add ticketed events, Jitsi meeting integration, social features, and calendar system

- Ticketed events: full CRUD, ticket tiers (free/paid/donation), Stripe checkout,
  QR-based check-in scanner, public event pages, ticket confirmation emails
- Event formats: IN_PERSON/ONLINE/HYBRID with auto Jitsi meeting room lifecycle,
  ticket-gated meeting access, moderator JWT tokens, feature-flag guarded
- Social engagement: challenges with scoring/leaderboards, referral tracking,
  volunteer spotlight, impact stories, campaign celebrations, wall of fame
- Social calendar: personal calendar layers, shared calendar items with
  recurrence, scheduling polls, mobile day view
- MCP server: events tool pack with full admin CRUD + meeting token generation
- Unified calendar: eventFormat-aware tags, online event indicators
- Updated docs site, pangolin configs, and various admin UI improvements

Bunker Admin
This commit is contained in:
2026-03-06 14:33:33 -07:00
parent 62fc116c06
commit 08d8066157
215 changed files with 23128 additions and 1968 deletions

View File

@@ -17,6 +17,7 @@ import { smsPack } from './tools/packs/sms.js';
import { paymentsPack } from './tools/packs/payments.js';
import { mediaPack } from './tools/packs/media.js';
import { adminPack } from './tools/packs/admin.js';
import { eventsPack } from './tools/packs/events.js';
// Tier 3 composite workflows
import { dailyBriefing } from './tools/composite/daily-briefing.js';
@@ -76,8 +77,9 @@ export async function createServer(config: ServerConfig) {
registry.registerPack(paymentsPack);
registry.registerPack(mediaPack);
registry.registerPack(adminPack);
registry.registerPack(eventsPack);
console.error('[MCP] Server initialized with core tools + 5 on-demand packs');
console.error('[MCP] Server initialized with core tools + 6 on-demand packs');
return server;
}

View File

@@ -4,10 +4,11 @@ import type { ApiClient } from '../../api-client.js';
async function handler(_args: Record<string, unknown>, api: ApiClient): Promise<string> {
// Fire multiple requests in parallel for speed
const [summary, connectivity, shifts, activity] = await Promise.allSettled([
const [summary, connectivity, shifts, events, activity] = await Promise.allSettled([
api.request('GET', '/api/dashboard/summary'),
api.request('GET', '/api/dashboard/connectivity'),
api.request('GET', '/api/dashboard/upcoming-shifts'),
api.request('GET', '/api/ticketed-events/admin', { status: 'PUBLISHED', limit: 5 }),
api.request('GET', '/api/dashboard/activity', { limit: 10 }),
]);
@@ -34,6 +35,13 @@ async function handler(_args: Record<string, unknown>, api: ApiClient): Promise<
sections.push('## Upcoming Shifts\n(Failed to fetch)');
}
// Upcoming ticketed events
if (events.status === 'fulfilled') {
sections.push('## Upcoming Ticketed Events\n' + JSON.stringify(events.value, null, 2));
} else {
sections.push('## Upcoming Ticketed Events\n(Failed to fetch)');
}
// Recent activity
if (activity.status === 'fulfilled') {
sections.push('## Recent Activity\n' + JSON.stringify(activity.value, null, 2));
@@ -48,7 +56,7 @@ export const dailyBriefing: CompositeToolDef = {
name: 'daily_briefing',
description:
'Get a comprehensive daily briefing: platform summary (campaign/user/location counts), ' +
'service health checks, upcoming volunteer shifts, and recent activity feed. ' +
'service health checks, upcoming volunteer shifts, upcoming ticketed events, and recent activity feed. ' +
'Use this as the first call to understand the current state of everything.',
inputSchema: {},
handler,

View File

@@ -0,0 +1,295 @@
import { z } from 'zod';
import type { AnyToolDef, ToolDef, CompositeToolDef, ToolPack } from '../../tool-registry.js';
import type { ApiClient } from '../../api-client.js';
// ---------- Standard CRUD Tools ----------
const eventTools: ToolDef[] = [
// --- Admin Event Management ---
{
name: 'events_list',
description:
'List ticketed events. Filter by status (DRAFT, PENDING_APPROVAL, PUBLISHED, CANCELLED, COMPLETED) ' +
'or search by title. Returns paginated results with tier counts and ticket stats.',
inputSchema: {
page: z.coerce.number().int().positive().default(1).describe('Page number'),
limit: z.coerce.number().int().positive().max(100).default(20).describe('Items per page'),
status: z.enum(['DRAFT', 'PENDING_APPROVAL', 'PUBLISHED', 'CANCELLED', 'COMPLETED']).optional()
.describe('Filter by event status'),
search: z.string().optional().describe('Search events by title'),
},
method: 'GET',
path: '/api/ticketed-events/admin',
tier: 2,
},
{
name: 'events_get',
description:
'Get full details of a ticketed event by ID, including ticket tiers, ' +
'venue info, visibility settings, and Gancio sync status.',
inputSchema: {
id: z.string().describe('Event ID'),
},
method: 'GET',
path: '/api/ticketed-events/admin/:id',
tier: 2,
},
{
name: 'events_create',
description:
'Create a new ticketed event. Starts in DRAFT status. Include date (YYYY-MM-DD), ' +
'startTime/endTime (HH:MM), venue info, and optionally initial ticket tiers. ' +
'Set visibility to PUBLIC, UNLISTED, or PRIVATE.',
inputSchema: {
title: z.string().min(1).max(200).describe('Event title'),
description: z.string().max(2000).optional().describe('Event description'),
date: z.string().describe('Event date (YYYY-MM-DD)'),
startTime: z.string().describe('Start time (HH:MM)'),
endTime: z.string().describe('End time (HH:MM)'),
doorsOpenTime: z.string().optional().describe('Doors open time (HH:MM)'),
eventFormat: z.enum(['IN_PERSON', 'ONLINE', 'HYBRID']).optional().describe('Event format (default: IN_PERSON). ONLINE/HYBRID auto-create a Jitsi meeting room.'),
venueName: z.string().max(200).optional().describe('Venue name'),
venueAddress: z.string().max(500).optional().describe('Venue address'),
visibility: z.enum(['PUBLIC', 'UNLISTED', 'PRIVATE']).optional().describe('Event visibility (default: PUBLIC)'),
maxAttendees: z.number().int().positive().optional().describe('Maximum total attendees'),
organizerName: z.string().max(200).optional().describe('Organizer display name'),
organizerEmail: z.string().email().optional().describe('Organizer contact email'),
},
method: 'POST',
path: '/api/ticketed-events/admin',
tier: 2,
},
{
name: 'events_update',
description:
'Update a ticketed event. Only provided fields are changed. ' +
'Cannot change status directly — use events_publish, events_cancel, or events_complete.',
inputSchema: {
id: z.string().describe('Event ID'),
title: z.string().min(1).max(200).optional().describe('Event title'),
description: z.string().max(2000).optional().describe('Event description'),
date: z.string().optional().describe('Event date (YYYY-MM-DD)'),
startTime: z.string().optional().describe('Start time (HH:MM)'),
endTime: z.string().optional().describe('End time (HH:MM)'),
doorsOpenTime: z.string().optional().describe('Doors open time (HH:MM)'),
eventFormat: z.enum(['IN_PERSON', 'ONLINE', 'HYBRID']).optional().describe('Event format. Changing to ONLINE/HYBRID auto-creates a Jitsi room.'),
venueName: z.string().max(200).optional().describe('Venue name'),
venueAddress: z.string().max(500).optional().describe('Venue address'),
visibility: z.enum(['PUBLIC', 'UNLISTED', 'PRIVATE']).optional().describe('Event visibility'),
maxAttendees: z.number().int().positive().optional().describe('Maximum total attendees'),
},
method: 'PUT',
path: '/api/ticketed-events/admin/:id',
tier: 2,
},
{
name: 'events_delete',
description:
'Delete a ticketed event. Only DRAFT events can be deleted. ' +
'Published or completed events must be cancelled instead.',
inputSchema: {
id: z.string().describe('Event ID'),
},
method: 'DELETE',
path: '/api/ticketed-events/admin/:id',
tier: 2,
},
// --- Event Status Transitions ---
{
name: 'events_publish',
description:
'Publish a DRAFT event, making it visible to the public. ' +
'Requires at least one active ticket tier. Also syncs to Gancio community calendar.',
inputSchema: {
id: z.string().describe('Event ID'),
},
method: 'POST',
path: '/api/ticketed-events/admin/:id/publish',
tier: 2,
},
{
name: 'events_cancel',
description:
'Cancel a published event. All VALID tickets are cancelled. ' +
'Removed from public listings and community calendar.',
inputSchema: {
id: z.string().describe('Event ID'),
},
method: 'POST',
path: '/api/ticketed-events/admin/:id/cancel',
tier: 2,
},
{
name: 'events_complete',
description:
'Mark a published event as completed (e.g., after the event date has passed).',
inputSchema: {
id: z.string().describe('Event ID'),
},
method: 'POST',
path: '/api/ticketed-events/admin/:id/complete',
tier: 2,
},
// --- Ticket Tier Management ---
{
name: 'events_add_tier',
description:
'Add a ticket tier to an event. Types: FREE (no charge), PAID (fixed price in cents CAD), ' +
'DONATION (minimum donation amount). Set maxQuantity for limited availability.',
inputSchema: {
id: z.string().describe('Event ID'),
name: z.string().min(1).max(100).describe('Tier name (e.g., "General Admission", "VIP")'),
tierType: z.enum(['PAID', 'FREE', 'DONATION']).describe('Ticket type'),
priceCAD: z.number().int().min(0).default(0).describe('Price in cents CAD (e.g., 2500 = $25.00)'),
maxQuantity: z.number().int().positive().optional().describe('Maximum tickets for this tier'),
maxPerOrder: z.number().int().min(1).max(100).default(10).describe('Max tickets per order'),
description: z.string().max(500).optional().describe('Tier description'),
sortOrder: z.number().int().default(0).describe('Display order (lower = first)'),
},
method: 'POST',
path: '/api/ticketed-events/admin/:id/tiers',
tier: 2,
},
{
name: 'events_update_tier',
description: 'Update a ticket tier. Only provided fields are changed.',
inputSchema: {
id: z.string().describe('Event ID'),
tierId: z.string().describe('Tier ID'),
name: z.string().min(1).max(100).optional().describe('Tier name'),
tierType: z.enum(['PAID', 'FREE', 'DONATION']).optional().describe('Ticket type'),
priceCAD: z.number().int().min(0).optional().describe('Price in cents CAD'),
maxQuantity: z.number().int().positive().optional().describe('Maximum tickets'),
maxPerOrder: z.number().int().min(1).max(100).optional().describe('Max per order'),
isActive: z.boolean().optional().describe('Enable or disable this tier'),
},
method: 'PUT',
path: '/api/ticketed-events/admin/:id/tiers/:tierId',
tier: 2,
},
{
name: 'events_delete_tier',
description: 'Remove a ticket tier. Only possible if no tickets have been sold for this tier.',
inputSchema: {
id: z.string().describe('Event ID'),
tierId: z.string().describe('Tier ID'),
},
method: 'DELETE',
path: '/api/ticketed-events/admin/:id/tiers/:tierId',
tier: 2,
},
// --- Ticket & Check-in Management ---
{
name: 'events_tickets',
description:
'List tickets sold for an event. Includes holder info, status (VALID, CHECKED_IN, CANCELLED, REFUNDED), ' +
'tier assignment, and check-in timestamp.',
inputSchema: {
id: z.string().describe('Event ID'),
page: z.coerce.number().int().positive().default(1).describe('Page number'),
limit: z.coerce.number().int().positive().max(100).default(20).describe('Items per page'),
},
method: 'GET',
path: '/api/ticketed-events/admin/:id/tickets',
tier: 2,
},
{
name: 'events_checkins',
description:
'Get check-in audit log for an event. Shows who was checked in, by whom, ' +
'the method used (QR, MANUAL, CODE), and timestamps.',
inputSchema: {
id: z.string().describe('Event ID'),
page: z.coerce.number().int().positive().default(1).describe('Page number'),
limit: z.coerce.number().int().positive().max(100).default(20).describe('Items per page'),
},
method: 'GET',
path: '/api/ticketed-events/admin/:id/checkins',
tier: 2,
},
{
name: 'events_cancel_ticket',
description: 'Cancel a specific ticket. Decrements sold count and frees capacity.',
inputSchema: {
id: z.string().describe('Event ID'),
ticketId: z.string().describe('Ticket ID'),
},
method: 'POST',
path: '/api/ticketed-events/admin/:id/tickets/:ticketId/cancel',
tier: 2,
},
// --- Meeting ---
{
name: 'events_meeting_token',
description:
'Generate a moderator JWT token for joining an event\'s Jitsi meeting room. ' +
'Returns the token and a ready-to-use Jitsi URL. Only works for ONLINE/HYBRID events.',
inputSchema: {
id: z.string().describe('Event ID'),
},
method: 'POST',
path: '/api/ticketed-events/admin/:id/meeting-token',
tier: 2,
},
];
// ---------- Composite Tool: Event Stats ----------
async function eventStatsHandler(args: Record<string, unknown>, api: ApiClient): Promise<string> {
const id = args.id as string;
if (!id) throw new Error('Event ID is required');
const [event, stats, availability] = await Promise.allSettled([
api.request('GET', `/api/ticketed-events/admin/${id}`),
api.request('GET', `/api/ticketed-events/admin/${id}/stats`),
api.request('GET', `/api/ticketed-events/${(await api.request('GET', `/api/ticketed-events/admin/${id}`) as any).slug}/availability`),
]);
const sections: string[] = [];
if (event.status === 'fulfilled') {
const e = event.value as any;
sections.push(
`## Event: ${e.title}\n` +
`- **Status:** ${e.status}\n` +
`- **Date:** ${e.date?.split('T')[0]} ${e.startTime}${e.endTime}\n` +
`- **Venue:** ${e.venueName || 'TBD'}\n` +
`- **Visibility:** ${e.visibility}\n` +
`- **Capacity:** ${e.currentAttendees}/${e.maxAttendees || '∞'}\n`
);
}
if (stats.status === 'fulfilled') {
sections.push('## Ticket Stats\n' + JSON.stringify(stats.value, null, 2));
}
if (availability.status === 'fulfilled') {
sections.push('## Tier Availability\n' + JSON.stringify(availability.value, null, 2));
}
return sections.join('\n\n');
}
const eventStats: CompositeToolDef = {
name: 'events_stats',
description:
'Get comprehensive event statistics: ticket sales, revenue, check-in counts, ' +
'per-tier availability, and capacity info. Combines event detail, stats, and availability.',
inputSchema: {
id: z.string().describe('Event ID'),
},
handler: eventStatsHandler,
tier: 3,
};
// ---------- Pack Export ----------
export const eventsPack: ToolPack = {
name: 'events',
description: 'Ticketed events management: create events, manage ticket tiers, track sales and check-ins',
tools: [...eventTools, eventStats],
};