diff --git a/.changeset/yummy-areas-bet.md b/.changeset/yummy-areas-bet.md new file mode 100644 index 000000000..99bd05931 --- /dev/null +++ b/.changeset/yummy-areas-bet.md @@ -0,0 +1,9 @@ +--- +--- + +Add regional chapters and industry event presence features: +- User location tracking (city, country) for chapter matching +- Event groups (committee_type: 'event') linked to industry events +- Slack channel auto-sync: join channel = join group +- Admin UI for event groups and chapters +- Addie tools for member-driven chapter creation diff --git a/server/public/admin-events.html b/server/public/admin-events.html index 473ce51e8..7b30624c5 100644 --- a/server/public/admin-events.html +++ b/server/public/admin-events.html @@ -264,6 +264,48 @@ font-weight: var(--font-semibold); color: var(--color-text-heading); } + .event-group-section { + margin-top: var(--space-4); + padding: var(--space-4); + background: var(--color-bg-subtle); + border-radius: var(--radius-md); + border: var(--border-1) solid var(--color-gray-200); + } + .event-group-section h4 { + margin: 0 0 var(--space-2) 0; + font-size: var(--text-sm); + color: var(--color-text-heading); + } + .event-group-info { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + } + .event-group-stat { + font-size: var(--text-sm); + color: var(--color-text-secondary); + } + .slack-link { + display: inline-flex; + align-items: center; + gap: var(--space-1); + color: var(--color-brand); + text-decoration: none; + font-size: var(--text-sm); + } + .slack-link:hover { + text-decoration: underline; + } + .badge-event-group { + display: inline-block; + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: var(--font-medium); + background: var(--color-success-100); + color: var(--color-success-700); + } @@ -527,6 +569,44 @@

Add Event

+ + + @@ -682,7 +685,8 @@

Delete Committee

working_group: 'Working Group', council: 'Industry Council', chapter: 'Regional Chapter', - governance: 'Governance' + governance: 'Governance', + event: 'Event Group' }; // Render groups list @@ -721,6 +725,9 @@

Delete Committee

const regionDisplay = committeeType === 'chapter' && group.region ? `๐Ÿ“ ${escapeHtml(group.region)}` : ''; + const eventDateDisplay = committeeType === 'event' && group.event_start_date + ? `๐Ÿ“… ${new Date(group.event_start_date).toLocaleDateString()}` + : ''; html += `
@@ -731,6 +738,7 @@

${escapeHtml(group.name)}

${statusBadge} ${accessBadge} ${regionDisplay} + ${eventDateDisplay} ${group.member_count || 0} members ${group.leaders && group.leaders.length > 0 ? `ยท Leaders: ${group.leaders.map(l => escapeHtml(l.name || 'Unknown')).join(', ')}` : ''}
diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index 588e51a14..e75565743 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -43,6 +43,10 @@ import { type FeedWithStats, } from '../../db/industry-feeds-db.js'; import { InsightsDatabase } from '../../db/insights-db.js'; +import { + createChannel, + setChannelPurpose, +} from '../../slack/client.js'; import { getProductsForCustomer, createCheckoutSession, @@ -735,6 +739,53 @@ The code will be usable at checkout for any customer.`, }, }, + // ============================================ + // CHAPTER MANAGEMENT TOOLS + // ============================================ + { + name: 'create_chapter', + description: `Create a new regional chapter with a Slack channel. Use this when a member wants to start a chapter in their city/region. + +This tool: +1. Creates a working group with committee_type 'chapter' +2. Creates a public Slack channel for the chapter +3. Sets the founding member as the chapter leader + +Example: If someone in Austin says "I want to start a chapter", use this to create an Austin Chapter with a #austin-chapter Slack channel.`, + usage_hints: 'Use this when a member wants to start a chapter. Ask them what they want to call it first (e.g., "Austin Chapter" vs "Texas Chapter").', + input_schema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Chapter name (e.g., "Austin Chapter", "Bay Area Chapter")', + }, + region: { + type: 'string', + description: 'Geographic region this chapter covers (e.g., "Austin", "Bay Area", "Southern California")', + }, + founding_member_id: { + type: 'string', + description: 'WorkOS user ID of the founding member who will become chapter leader', + }, + description: { + type: 'string', + description: 'Optional description for the chapter', + }, + }, + required: ['name', 'region'], + }, + }, + { + name: 'list_chapters', + description: 'List all regional chapters with their member counts and Slack channels.', + input_schema: { + type: 'object', + properties: {}, + required: [], + }, + }, + // ============================================ // ORGANIZATION MANAGEMENT TOOLS // ============================================ @@ -2701,6 +2752,130 @@ export function createAdminToolHandlers( } }); + // ============================================ + // CHAPTER MANAGEMENT HANDLERS + // ============================================ + + // Create chapter + handlers.set('create_chapter', async (input) => { + const adminCheck = requireAdminFromContext(); + if (adminCheck) return adminCheck; + + const name = (input.name as string)?.trim(); + const region = (input.region as string)?.trim(); + const foundingMemberId = input.founding_member_id as string | undefined; + const description = input.description as string | undefined; + + if (!name) { + return 'โŒ Please provide a chapter name (e.g., "Austin Chapter").'; + } + + if (!region) { + return 'โŒ Please provide a region (e.g., "Austin", "Bay Area").'; + } + + // Generate slug from name + const slug = name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .slice(0, 50); + + try { + // Check if chapter with this slug already exists + const existingChapter = await wgDb.getWorkingGroupBySlug(slug); + if (existingChapter) { + return `โš ๏ธ A chapter with slug "${slug}" already exists: **${existingChapter.name}**\n\nJoin their Slack channel: ${existingChapter.slack_channel_url || 'Not set'}`; + } + + // Create Slack channel first + const channelResult = await createChannel(slug); + if (!channelResult) { + return `โŒ Failed to create Slack channel #${slug}. The channel name might already be taken. Try a different chapter name.`; + } + + // Set channel purpose + const purpose = description || `Connect with AgenticAdvertising.org members in the ${region} area.`; + await setChannelPurpose(channelResult.channel.id, purpose); + + // Create the chapter working group + const chapter = await wgDb.createChapter({ + name, + slug, + region, + description: purpose, + slack_channel_url: channelResult.url, + slack_channel_id: channelResult.channel.id, + founding_member_id: foundingMemberId, + }); + + logger.info({ + chapterId: chapter.id, + name: chapter.name, + region, + slackChannelId: channelResult.channel.id, + foundingMemberId, + }, 'Addie: Created new regional chapter'); + + let response = `โœ… Created **${name}**!\n\n`; + response += `**Region:** ${region}\n`; + response += `**Slack Channel:** <#${channelResult.channel.id}>\n`; + response += `**Channel URL:** ${channelResult.url}\n`; + + if (foundingMemberId) { + response += `\n๐ŸŽ‰ The founding member has been set as chapter leader.\n`; + } + + response += `\n_Anyone who joins the Slack channel will automatically be added to the chapter._`; + + return response; + } catch (error) { + logger.error({ error, name, region }, 'Error creating chapter'); + return 'โŒ Failed to create chapter. Please try again.'; + } + }); + + // List chapters + handlers.set('list_chapters', async () => { + const adminCheck = requireAdminFromContext(); + if (adminCheck) return adminCheck; + + try { + const chapters = await wgDb.getChapters(); + + if (chapters.length === 0) { + return 'โ„น๏ธ No regional chapters exist yet. Use create_chapter to start one!'; + } + + let response = `## Regional Chapters\n\n`; + response += `Found **${chapters.length}** chapter(s):\n\n`; + + for (const chapter of chapters) { + response += `### ${chapter.name}\n`; + response += `**Region:** ${chapter.region || 'Not set'}\n`; + response += `**Members:** ${chapter.member_count}\n`; + + if (chapter.slack_channel_id) { + response += `**Slack:** <#${chapter.slack_channel_id}>\n`; + } else { + response += `**Slack:** _No channel linked_\n`; + } + + if (chapter.leaders && chapter.leaders.length > 0) { + const leaderNames = chapter.leaders.map(l => l.name || 'Unknown').join(', '); + response += `**Leaders:** ${leaderNames}\n`; + } + + response += '\n'; + } + + return response; + } catch (error) { + logger.error({ error }, 'Error listing chapters'); + return 'โŒ Failed to list chapters. Please try again.'; + } + }); + // ============================================ // ORGANIZATION MANAGEMENT HANDLERS // ============================================ diff --git a/server/src/db/migrations/107_user_location.sql b/server/src/db/migrations/107_user_location.sql new file mode 100644 index 000000000..1ea3c9043 --- /dev/null +++ b/server/src/db/migrations/107_user_location.sql @@ -0,0 +1,159 @@ +-- Migration: 107_user_location.sql +-- Add location fields to users table for regional chapter matching +-- Also seeds outreach goals for location and chapter interest + +-- ===================================================== +-- USER LOCATION FIELDS +-- ===================================================== + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS city VARCHAR(255), +ADD COLUMN IF NOT EXISTS country VARCHAR(100), +ADD COLUMN IF NOT EXISTS location_source VARCHAR(50), +ADD COLUMN IF NOT EXISTS location_updated_at TIMESTAMP WITH TIME ZONE; + +-- Add constraint for location_source +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'users_location_source_check' + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_location_source_check + CHECK (location_source IN ('manual', 'outreach', 'inferred')); + END IF; +END $$; + +-- Index for finding users by location +CREATE INDEX IF NOT EXISTS idx_users_city ON users(city) WHERE city IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_country ON users(country) WHERE country IS NOT NULL; + +COMMENT ON COLUMN users.city IS 'User city (e.g., "New York", "London", "Austin")'; +COMMENT ON COLUMN users.country IS 'User country (e.g., "USA", "United Kingdom")'; +COMMENT ON COLUMN users.location_source IS 'How location was determined: manual, outreach, inferred'; +COMMENT ON COLUMN users.location_updated_at IS 'When location was last updated'; + +-- ===================================================== +-- UPDATE USER_PROFILE VIEW +-- ===================================================== + +-- Must drop and recreate because we're adding columns in the middle +DROP VIEW IF EXISTS user_profile; + +CREATE VIEW user_profile AS +SELECT + u.workos_user_id, + u.email, + u.first_name, + u.last_name, + u.engagement_score, + u.excitement_score, + u.lifecycle_stage, + u.scores_computed_at, + + -- Location + u.city, + u.country, + u.location_source, + u.location_updated_at, + + -- Component scores + u.slack_activity_score, + u.email_engagement_score, + u.conversation_score, + u.community_score, + + -- Slack identity + u.primary_slack_user_id, + sm.slack_display_name, + sm.slack_real_name, + sm.last_slack_activity_at, + + -- Organization + u.primary_organization_id, + o.name as organization_name, + o.subscription_status, + + -- Computed flags for Addie + CASE + WHEN u.engagement_score >= 50 OR u.excitement_score >= 50 THEN TRUE + ELSE FALSE + END as ready_for_membership_pitch, + + CASE + WHEN u.engagement_score < 30 AND u.excitement_score < 30 THEN TRUE + ELSE FALSE + END as needs_engagement, + + u.created_at, + u.updated_at + +FROM users u +LEFT JOIN slack_user_mappings sm ON sm.slack_user_id = u.primary_slack_user_id +LEFT JOIN organizations o ON o.workos_organization_id = u.primary_organization_id; + +COMMENT ON VIEW user_profile IS 'Complete user profile with identities, scores, location, and Addie flags'; + +-- ===================================================== +-- SEED INSIGHT TYPE FOR LOCATION +-- ===================================================== + +INSERT INTO member_insight_types (name, description, example_values, created_by) +VALUES ( + 'location', + 'User primary city/location for regional chapter matching', + ARRAY['New York', 'London', 'San Francisco', 'Austin', 'Los Angeles', 'Chicago', 'Miami', 'Sydney', 'Paris', 'Amsterdam'], + 'system' +) ON CONFLICT (name) DO NOTHING; + +-- ===================================================== +-- SEED OUTREACH GOAL FOR USER LOCATION +-- ===================================================== + +INSERT INTO insight_goals ( + name, + question, + insight_type_id, + goal_type, + is_enabled, + priority, + suggested_prompt_title, + suggested_prompt_message, + created_by +) +SELECT + 'User Location', + 'What city are you based in? We have regional chapters that host local events and discussions.', + id, + 'persistent', + TRUE, + 65, -- Higher priority + 'Share your location', + 'Tell me where you are based so I can connect you with your local chapter!', + 'system' +FROM member_insight_types WHERE name = 'location' +ON CONFLICT DO NOTHING; + +-- ===================================================== +-- SEED INSIGHT TYPE FOR CHAPTER INTEREST +-- ===================================================== + +INSERT INTO member_insight_types (name, description, example_values, created_by) +VALUES ( + 'chapter_interest', + 'Interest in regional chapters and local meetups', + ARRAY['would attend', 'interested in starting', 'not interested', 'already member'], + 'system' +) ON CONFLICT (name) DO NOTHING; + +-- ===================================================== +-- SEED INSIGHT TYPE FOR EVENT ATTENDANCE +-- ===================================================== + +INSERT INTO member_insight_types (name, description, example_values, created_by) +VALUES ( + 'event_attendance', + 'Industry events the user plans to attend', + ARRAY['CES 2026', 'Cannes Lions 2026', 'AdExchanger Conference', 'POSSIBLE Miami'], + 'system' +) ON CONFLICT (name) DO NOTHING; diff --git a/server/src/db/migrations/108_event_groups.sql b/server/src/db/migrations/108_event_groups.sql new file mode 100644 index 000000000..dc3d8858a --- /dev/null +++ b/server/src/db/migrations/108_event_groups.sql @@ -0,0 +1,110 @@ +-- Migration: 108_event_groups.sql +-- Add 'event' committee type and link working groups to events +-- This enables temporary "chapter-like" groups for industry events (CES, Cannes Lions, etc.) + +-- ===================================================== +-- ADD 'EVENT' COMMITTEE TYPE +-- ===================================================== + +-- Drop and recreate constraint to add 'event' type +ALTER TABLE working_groups +DROP CONSTRAINT IF EXISTS working_groups_committee_type_check; + +ALTER TABLE working_groups +ADD CONSTRAINT working_groups_committee_type_check +CHECK (committee_type IN ('working_group', 'council', 'chapter', 'governance', 'event')); + +-- ===================================================== +-- ADD EVENT LINKAGE COLUMNS +-- ===================================================== + +ALTER TABLE working_groups +ADD COLUMN IF NOT EXISTS linked_event_id UUID REFERENCES events(id) ON DELETE SET NULL, +ADD COLUMN IF NOT EXISTS event_start_date DATE, +ADD COLUMN IF NOT EXISTS event_end_date DATE, +ADD COLUMN IF NOT EXISTS auto_archive_after_event BOOLEAN DEFAULT TRUE; + +-- Index for finding event groups +CREATE INDEX IF NOT EXISTS idx_working_groups_linked_event ON working_groups(linked_event_id) + WHERE linked_event_id IS NOT NULL; + +-- Index for filtering by event committee type +CREATE INDEX IF NOT EXISTS idx_working_groups_event_type ON working_groups(committee_type) + WHERE committee_type = 'event'; + +-- Index for finding upcoming vs past event groups +CREATE INDEX IF NOT EXISTS idx_working_groups_event_dates ON working_groups(event_start_date, event_end_date) + WHERE committee_type = 'event'; + +COMMENT ON COLUMN working_groups.linked_event_id IS 'Links this group to an industry event (for event-type committees)'; +COMMENT ON COLUMN working_groups.event_start_date IS 'Cached from event for display/filtering without join'; +COMMENT ON COLUMN working_groups.event_end_date IS 'Cached from event for display/filtering without join'; +COMMENT ON COLUMN working_groups.auto_archive_after_event IS 'If TRUE, auto-archive group after event ends'; + +-- ===================================================== +-- ADD INTEREST LEVEL TO MEMBERSHIPS +-- ===================================================== + +-- Track why someone joined an event group +ALTER TABLE working_group_memberships +ADD COLUMN IF NOT EXISTS interest_level VARCHAR(50), +ADD COLUMN IF NOT EXISTS interest_source VARCHAR(50); + +-- Add constraints +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'wg_membership_interest_level_check' + ) THEN + ALTER TABLE working_group_memberships + ADD CONSTRAINT wg_membership_interest_level_check + CHECK (interest_level IS NULL OR interest_level IN ('maybe', 'interested', 'attending', 'attended')); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'wg_membership_interest_source_check' + ) THEN + ALTER TABLE working_group_memberships + ADD CONSTRAINT wg_membership_interest_source_check + CHECK (interest_source IS NULL OR interest_source IN ('outreach', 'registration', 'manual', 'slack_join')); + END IF; +END $$; + +COMMENT ON COLUMN working_group_memberships.interest_level IS 'For event groups: maybe, interested, attending, attended'; +COMMENT ON COLUMN working_group_memberships.interest_source IS 'How they expressed interest: outreach, registration, manual, slack_join'; + +-- ===================================================== +-- VIEW: UPCOMING EVENT GROUPS +-- ===================================================== + +CREATE OR REPLACE VIEW upcoming_event_groups AS +SELECT + wg.id, + wg.name, + wg.slug, + wg.description, + wg.slack_channel_url, + wg.slack_channel_id, + wg.status, + wg.linked_event_id, + wg.event_start_date, + wg.event_end_date, + e.title as event_title, + e.venue_name, + e.venue_city as event_city, + e.venue_country as event_country, + e.start_time as event_start_time, + e.end_time as event_end_time, + (SELECT COUNT(*) FROM working_group_memberships m WHERE m.working_group_id = wg.id AND m.status = 'active') as member_count, + (SELECT COUNT(*) FROM working_group_memberships m WHERE m.working_group_id = wg.id AND m.status = 'active' AND m.interest_level = 'attending') as attending_count +FROM working_groups wg +LEFT JOIN events e ON wg.linked_event_id = e.id +WHERE wg.committee_type = 'event' + AND wg.status = 'active' + AND (wg.event_end_date IS NULL OR wg.event_end_date >= CURRENT_DATE) +ORDER BY wg.event_start_date ASC NULLS LAST; + +COMMENT ON VIEW upcoming_event_groups IS 'Active event groups for upcoming or ongoing events'; diff --git a/server/src/db/users-db.ts b/server/src/db/users-db.ts new file mode 100644 index 000000000..9ba7b3ae5 --- /dev/null +++ b/server/src/db/users-db.ts @@ -0,0 +1,124 @@ +import { query } from './client.js'; +import type { UpdateUserLocationInput, UserLocation } from '../types.js'; + +/** + * User record from the users table + */ +export interface User { + workos_user_id: string; + email: string; + first_name?: string; + last_name?: string; + email_verified: boolean; + engagement_score: number; + excitement_score: number; + lifecycle_stage: string; + city?: string; + country?: string; + location_source?: string; + location_updated_at?: Date; + primary_slack_user_id?: string; + primary_organization_id?: string; + created_at: Date; + updated_at: Date; +} + +/** + * Database operations for users + */ +export class UsersDatabase { + /** + * Get a user by their WorkOS user ID + */ + async getUser(workosUserId: string): Promise { + const result = await query( + `SELECT * FROM users WHERE workos_user_id = $1`, + [workosUserId] + ); + return result.rows[0] || null; + } + + /** + * Get user location + */ + async getUserLocation(workosUserId: string): Promise { + const result = await query( + `SELECT city, country, location_source, location_updated_at + FROM users WHERE workos_user_id = $1`, + [workosUserId] + ); + return result.rows[0] || null; + } + + /** + * Update user location + */ + async updateUserLocation(input: UpdateUserLocationInput): Promise { + const result = await query( + `UPDATE users + SET city = COALESCE($2, city), + country = COALESCE($3, country), + location_source = $4, + location_updated_at = NOW(), + updated_at = NOW() + WHERE workos_user_id = $1 + RETURNING *`, + [input.workos_user_id, input.city || null, input.country || null, input.location_source] + ); + return result.rows[0] || null; + } + + /** + * Find users by city + */ + async findUsersByCity(city: string): Promise { + const result = await query( + `SELECT * FROM users + WHERE LOWER(city) = LOWER($1) + ORDER BY engagement_score DESC`, + [city] + ); + return result.rows; + } + + /** + * Find users by country + */ + async findUsersByCountry(country: string): Promise { + const result = await query( + `SELECT * FROM users + WHERE LOWER(country) = LOWER($1) + ORDER BY engagement_score DESC`, + [country] + ); + return result.rows; + } + + /** + * Find users without location set + */ + async findUsersWithoutLocation(limit = 100): Promise { + const result = await query( + `SELECT * FROM users + WHERE city IS NULL AND country IS NULL + ORDER BY engagement_score DESC + LIMIT $1`, + [limit] + ); + return result.rows; + } + + /** + * Get location statistics + */ + async getLocationStats(): Promise<{ city: string; country: string; count: number }[]> { + const result = await query<{ city: string; country: string; count: number }>( + `SELECT city, country, COUNT(*) as count + FROM users + WHERE city IS NOT NULL OR country IS NOT NULL + GROUP BY city, country + ORDER BY count DESC` + ); + return result.rows; + } +} diff --git a/server/src/db/working-group-db.ts b/server/src/db/working-group-db.ts index 41a15f863..9b253f6b0 100644 --- a/server/src/db/working-group-db.ts +++ b/server/src/db/working-group-db.ts @@ -9,6 +9,8 @@ import type { WorkingGroupWithDetails, AddWorkingGroupMemberInput, CommitteeType, + EventInterestLevel, + EventInterestSource, } from '../types.js'; /** @@ -70,8 +72,9 @@ export class WorkingGroupDatabase { const result = await query( `INSERT INTO working_groups ( name, slug, description, slack_channel_url, slack_channel_id, - is_private, status, display_order, committee_type, region - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + is_private, status, display_order, committee_type, region, + linked_event_id, event_start_date, event_end_date, auto_archive_after_event + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, [ input.name, @@ -84,6 +87,10 @@ export class WorkingGroupDatabase { input.display_order ?? 0, input.committee_type ?? 'working_group', input.region || null, + input.linked_event_id || null, + input.event_start_date || null, + input.event_end_date || null, + input.auto_archive_after_event ?? true, ] ); @@ -150,6 +157,10 @@ export class WorkingGroupDatabase { display_order: 'display_order', committee_type: 'committee_type', region: 'region', + linked_event_id: 'linked_event_id', + event_start_date: 'event_start_date', + event_end_date: 'event_end_date', + auto_archive_after_event: 'auto_archive_after_event', }; const setClauses: string[] = []; @@ -862,4 +873,271 @@ export class WorkingGroupDatabase { return result.rows; } + + // ============== Event Groups ============== + + /** + * Create an event group linked to an event + */ + async createEventGroup(input: { + name: string; + slug: string; + description?: string; + linked_event_id: string; + event_start_date?: Date; + event_end_date?: Date; + slack_channel_url?: string; + slack_channel_id?: string; + leader_user_ids?: string[]; + }): Promise { + return this.createWorkingGroup({ + ...input, + committee_type: 'event', + is_private: false, + auto_archive_after_event: true, + }); + } + + /** + * Get event group by linked event ID + */ + async getEventGroupByEventId(eventId: string): Promise { + const result = await query( + `SELECT * FROM working_groups + WHERE linked_event_id = $1 AND committee_type = 'event'`, + [eventId] + ); + if (!result.rows[0]) return null; + + const workingGroup = result.rows[0]; + workingGroup.leaders = await this.getLeaders(workingGroup.id); + return workingGroup; + } + + /** + * Get upcoming event groups (events that haven't ended yet) + */ + async getUpcomingEventGroups(): Promise { + const result = await query( + `SELECT wg.*, COUNT(wgm.id)::int AS member_count + FROM working_groups wg + LEFT JOIN working_group_memberships wgm ON wg.id = wgm.working_group_id AND wgm.status = 'active' + WHERE wg.committee_type = 'event' + AND wg.status = 'active' + AND (wg.event_end_date IS NULL OR wg.event_end_date >= CURRENT_DATE) + GROUP BY wg.id + ORDER BY wg.event_start_date ASC NULLS LAST` + ); + + const groups = result.rows; + const groupIds = groups.map(g => g.id); + const leadersByGroup = await this.getLeadersBatch(groupIds); + + for (const group of groups) { + group.leaders = leadersByGroup.get(group.id) || []; + } + + return groups; + } + + /** + * Get past event groups (for archival reference) + */ + async getPastEventGroups(): Promise { + const result = await query( + `SELECT wg.*, COUNT(wgm.id)::int AS member_count + FROM working_groups wg + LEFT JOIN working_group_memberships wgm ON wg.id = wgm.working_group_id AND wgm.status = 'active' + WHERE wg.committee_type = 'event' + AND wg.event_end_date < CURRENT_DATE + GROUP BY wg.id + ORDER BY wg.event_end_date DESC` + ); + + return result.rows; + } + + // ============== Chapters ============== + + /** + * Get all regional chapters + */ + async getChapters(): Promise { + return this.listWorkingGroups({ + status: 'active', + committee_type: 'chapter', + includePrivate: false, + }); + } + + /** + * Get chapters with their Slack channel info for outreach messages + */ + async getChapterSlackLinks(): Promise> { + const result = await query<{ + id: string; + name: string; + slug: string; + region: string; + slack_channel_url: string; + slack_channel_id: string; + member_count: number; + }>( + `SELECT + wg.id, + wg.name, + wg.slug, + wg.region, + wg.slack_channel_url, + wg.slack_channel_id, + COUNT(wgm.id)::int AS member_count + FROM working_groups wg + LEFT JOIN working_group_memberships wgm ON wg.id = wgm.working_group_id AND wgm.status = 'active' + WHERE wg.committee_type = 'chapter' + AND wg.status = 'active' + AND wg.slack_channel_id IS NOT NULL + GROUP BY wg.id + ORDER BY wg.region, wg.name` + ); + + return result.rows; + } + + /** + * Find chapters near a given city/region + * Simple string matching for now - could be enhanced with geo lookup later + */ + async findChaptersNearLocation(city: string): Promise { + // Escape LIKE wildcards to prevent pattern injection + const escapedCity = escapeLikePattern(city.toLowerCase()); + + const result = await query( + `SELECT wg.*, COUNT(wgm.id)::int AS member_count + FROM working_groups wg + LEFT JOIN working_group_memberships wgm ON wg.id = wgm.working_group_id AND wgm.status = 'active' + WHERE wg.committee_type = 'chapter' + AND wg.status = 'active' + AND (LOWER(wg.region) LIKE $1 OR LOWER(wg.name) LIKE $1) + GROUP BY wg.id + ORDER BY wg.name`, + [`%${escapedCity}%`] + ); + + return result.rows; + } + + /** + * Create a new regional chapter with Slack channel + */ + async createChapter(input: { + name: string; + slug: string; + region: string; + description?: string; + slack_channel_url?: string; + slack_channel_id?: string; + founding_member_id?: string; + }): Promise { + const chapter = await this.createWorkingGroup({ + name: input.name, + slug: input.slug, + region: input.region, + description: input.description || `Connect with AgenticAdvertising.org members in the ${input.region} area.`, + slack_channel_url: input.slack_channel_url, + slack_channel_id: input.slack_channel_id, + committee_type: 'chapter', + is_private: false, + leader_user_ids: input.founding_member_id ? [input.founding_member_id] : undefined, + }); + + return chapter; + } + + // ============== Membership with Interest Tracking ============== + + /** + * Add a member with interest level tracking (for event groups) + */ + async addMembershipWithInterest(input: AddWorkingGroupMemberInput & { + interest_level?: EventInterestLevel; + interest_source?: EventInterestSource; + }): Promise { + const result = await query( + `INSERT INTO working_group_memberships ( + working_group_id, workos_user_id, user_email, user_name, user_org_name, + workos_organization_id, added_by_user_id, interest_level, interest_source + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (working_group_id, workos_user_id) + DO UPDATE SET + status = 'active', + interest_level = COALESCE(EXCLUDED.interest_level, working_group_memberships.interest_level), + interest_source = COALESCE(EXCLUDED.interest_source, working_group_memberships.interest_source), + updated_at = NOW() + RETURNING *`, + [ + input.working_group_id, + input.workos_user_id, + input.user_email || null, + input.user_name || null, + input.user_org_name || null, + input.workos_organization_id || null, + input.added_by_user_id || null, + input.interest_level || null, + input.interest_source || null, + ] + ); + + return result.rows[0]; + } + + /** + * Update member interest level + */ + async updateMemberInterest( + workingGroupId: string, + userId: string, + interestLevel: EventInterestLevel + ): Promise { + const result = await query( + `UPDATE working_group_memberships + SET interest_level = $3, updated_at = NOW() + WHERE working_group_id = $1 AND workos_user_id = $2 + RETURNING *`, + [workingGroupId, userId, interestLevel] + ); + + return result.rows[0] || null; + } + + /** + * Get members of an event group with interest level stats + */ + async getEventGroupAttendees(workingGroupId: string): Promise<{ + members: WorkingGroupMembership[]; + stats: { + total: number; + attending: number; + interested: number; + maybe: number; + }; + }> { + const members = await this.getMembershipsByWorkingGroup(workingGroupId); + + const stats = { + total: members.length, + attending: members.filter(m => m.interest_level === 'attending').length, + interested: members.filter(m => m.interest_level === 'interested').length, + maybe: members.filter(m => m.interest_level === 'maybe').length, + }; + + return { members, stats }; + } } diff --git a/server/src/routes/events.ts b/server/src/routes/events.ts index 9a4319047..b3631a936 100644 --- a/server/src/routes/events.ts +++ b/server/src/routes/events.ts @@ -30,6 +30,8 @@ import type { EventFormat, RegistrationStatus, } from "../types.js"; +import { WorkingGroupDatabase } from "../db/working-group-db.js"; +import { createChannel, setChannelPurpose } from "../slack/client.js"; /** * Luma CSV row structure @@ -107,6 +109,7 @@ function mapLumaStatus(lumaStatus: string): RegistrationStatus { } const orgDb = new OrganizationDatabase(); +const workingGroupDb = new WorkingGroupDatabase(); const logger = createLogger("events-routes"); @@ -755,6 +758,156 @@ export function createEventsRouter(): { } ); + // ========================================================================= + // EVENT GROUP ROUTES (mounted at /api/admin/events) + // ========================================================================= + + // GET /api/admin/events/:id/event-group - Get event group for an event + adminApiRouter.get( + "/:id/event-group", + requireAuth, + requireAdmin, + async (req: Request, res: Response) => { + try { + const { id } = req.params; + + // Validate UUID format + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(id)) { + return res.status(400).json({ + error: "Invalid event ID", + message: "Event ID must be a valid UUID", + }); + } + + const eventGroup = await workingGroupDb.getEventGroupByEventId(id); + + // Get member count if event group exists + let memberCount = 0; + if (eventGroup) { + const memberships = await workingGroupDb.getMembershipsByWorkingGroup(eventGroup.id); + memberCount = memberships.length; + } + + res.json({ event_group: eventGroup, member_count: memberCount }); + } catch (error) { + logger.error({ err: error }, "Error getting event group"); + res.status(500).json({ + error: "Failed to get event group", + message: "An unexpected error occurred", + }); + } + } + ); + + // POST /api/admin/events/:id/event-group - Create event group for an event + adminApiRouter.post( + "/:id/event-group", + requireAuth, + requireAdmin, + async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { name, create_slack_channel } = req.body; + + // Validate UUID format + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(id)) { + return res.status(400).json({ + error: "Invalid event ID", + message: "Event ID must be a valid UUID", + }); + } + + // Get event details + const event = await eventsDb.getEventById(id); + if (!event) { + return res.status(404).json({ + error: "Event not found", + message: "No event found with that ID", + }); + } + + // Check if event group already exists + const existingGroup = await workingGroupDb.getEventGroupByEventId(id); + if (existingGroup) { + return res.status(400).json({ + error: "Event group already exists", + message: "This event already has an attendee group", + event_group: existingGroup, + }); + } + + // Generate slug from event slug or name + const groupName = name || `${event.title} Attendees`; + const slug = (event.slug + "-attendees") + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") + .slice(0, 50); + + // Create Slack channel if requested + let slackChannelUrl: string | undefined; + let slackChannelId: string | undefined; + + if (create_slack_channel !== false) { + const channelName = event.slug + .toLowerCase() + .replace(/\s+/g, "-") + .slice(0, 80); + + const channelResult = await createChannel(channelName); + if (channelResult) { + slackChannelUrl = channelResult.url; + slackChannelId = channelResult.channel.id; + + // Set channel purpose + const purpose = `Connect with AgenticAdvertising.org members attending ${event.title}`; + await setChannelPurpose(channelResult.channel.id, purpose); + + logger.info( + { channelId: channelResult.channel.id, eventId: id }, + "Created Slack channel for event group" + ); + } else { + logger.warn( + { eventSlug: event.slug }, + "Failed to create Slack channel for event group" + ); + } + } + + // Create the event group + const eventGroup = await workingGroupDb.createEventGroup({ + name: groupName, + slug, + description: `Connect with AgenticAdvertising.org members attending ${event.title}`, + linked_event_id: id, + event_start_date: event.start_time ? new Date(event.start_time) : undefined, + event_end_date: event.end_time ? new Date(event.end_time) : undefined, + slack_channel_url: slackChannelUrl, + slack_channel_id: slackChannelId, + }); + + logger.info( + { eventGroupId: eventGroup.id, eventId: id, slackChannelId }, + "Created event group" + ); + + res.status(201).json({ + event_group: eventGroup, + slack_channel_created: !!slackChannelId, + }); + } catch (error) { + logger.error({ err: error }, "Error creating event group"); + res.status(500).json({ + error: "Failed to create event group", + message: "An unexpected error occurred", + }); + } + } + ); + // ========================================================================= // PUBLIC API ROUTES (mounted at /api/events) // ========================================================================= diff --git a/server/src/slack/client.ts b/server/src/slack/client.ts index fe92bb0b1..e3d2d5672 100644 --- a/server/src/slack/client.ts +++ b/server/src/slack/client.ts @@ -556,3 +556,131 @@ export async function testSlackConnection(): Promise<{ return { ok: false, error: errorMessage }; } } + +/** + * Create a new public channel + * + * @param name - Channel name (lowercase, no spaces, max 80 chars) + * @returns The created channel info, or null on error + */ +export async function createChannel( + name: string +): Promise<{ channel: SlackChannel; url: string } | null> { + try { + // Normalize name: lowercase, replace spaces with hyphens, remove invalid chars + const normalizedName = name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-_]/g, '') + .slice(0, 80); + + const response = await slackPostRequest<{ channel: SlackChannel }>('conversations.create', { + name: normalizedName, + is_private: false, + }); + + // Get workspace info for URL + const authInfo = await testSlackConnection(); + const workspaceUrl = authInfo.team_id + ? `https://app.slack.com/client/${authInfo.team_id}/${response.channel.id}` + : `https://agenticads.slack.com/archives/${response.channel.id}`; + + logger.info( + { channelId: response.channel.id, name: normalizedName }, + 'Created Slack channel' + ); + + return { + channel: response.channel, + url: workspaceUrl, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Handle "name_taken" error specifically + if (errorMessage.includes('name_taken')) { + logger.warn({ name }, 'Channel name already taken'); + } else { + logger.error({ error: errorMessage, name }, 'Failed to create Slack channel'); + } + + return null; + } +} + +/** + * Invite users to a channel + * + * @param channelId - The channel to invite to + * @param userIds - Array of Slack user IDs to invite + */ +export async function inviteToChannel( + channelId: string, + userIds: string[] +): Promise<{ ok: boolean; error?: string }> { + if (userIds.length === 0) { + return { ok: true }; + } + + try { + await slackPostRequest<{ ok: boolean }>('conversations.invite', { + channel: channelId, + users: userIds.join(','), + }); + + logger.info({ channelId, userCount: userIds.length }, 'Invited users to channel'); + return { ok: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // "already_in_channel" is not really an error + if (errorMessage.includes('already_in_channel')) { + return { ok: true }; + } + + logger.error({ error: errorMessage, channelId }, 'Failed to invite users to channel'); + return { ok: false, error: errorMessage }; + } +} + +/** + * Set the channel topic + */ +export async function setChannelTopic( + channelId: string, + topic: string +): Promise<{ ok: boolean; error?: string }> { + try { + await slackPostRequest<{ ok: boolean }>('conversations.setTopic', { + channel: channelId, + topic: topic.slice(0, 250), // Max 250 chars + }); + + return { ok: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ error: errorMessage, channelId }, 'Failed to set channel topic'); + return { ok: false, error: errorMessage }; + } +} + +/** + * Set the channel purpose/description + */ +export async function setChannelPurpose( + channelId: string, + purpose: string +): Promise<{ ok: boolean; error?: string }> { + try { + await slackPostRequest<{ ok: boolean }>('conversations.setPurpose', { + channel: channelId, + purpose: purpose.slice(0, 250), // Max 250 chars + }); + + return { ok: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error({ error: errorMessage, channelId }, 'Failed to set channel purpose'); + return { ok: false, error: errorMessage }; + } +} diff --git a/server/src/slack/events.ts b/server/src/slack/events.ts index 0c630065e..6bbf6782f 100644 --- a/server/src/slack/events.ts +++ b/server/src/slack/events.ts @@ -8,6 +8,7 @@ import { logger } from '../logger.js'; import { SlackDatabase } from '../db/slack-db.js'; import { AddieDatabase } from '../db/addie-db.js'; +import { WorkingGroupDatabase } from '../db/working-group-db.js'; import type { SlackUser } from './types.js'; import { getSlackUser, getChannelInfo } from './client.js'; import { @@ -22,6 +23,7 @@ import { const slackDb = new SlackDatabase(); const addieDb = new AddieDatabase(); +const workingGroupDb = new WorkingGroupDatabase(); // Slack event types export interface SlackTeamJoinEvent { @@ -150,6 +152,7 @@ export async function handleTeamJoin(event: SlackTeamJoinEvent): Promise { /** * Handle member_joined_channel event * Records channel join activity for engagement tracking + * Also auto-adds users to working groups (chapters/events) when they join linked Slack channels */ export async function handleMemberJoinedChannel(event: SlackMemberJoinedChannelEvent): Promise { logger.debug( @@ -178,11 +181,84 @@ export async function handleMemberJoinedChannel(event: SlackMemberJoinedChannelE inviter: event.inviter, }, }); + + // Check if this channel is linked to a working group (chapter or event) + // and auto-add the user if they have a WorkOS mapping + if (mapping?.workos_user_id) { + await autoAddToWorkingGroup(event.channel, mapping.workos_user_id, mapping); + } } catch (error) { logger.error({ error, userId: event.user }, 'Failed to record channel join activity'); } } +/** + * Auto-add user to a working group when they join its Slack channel + * This enables "join channel = join group" for chapters and events + */ +async function autoAddToWorkingGroup( + channelId: string, + workosUserId: string, + slackMapping: { slack_email?: string | null; slack_real_name?: string | null; slack_display_name?: string | null } +): Promise { + try { + // Check if this channel is linked to a working group + const workingGroup = await workingGroupDb.getWorkingGroupBySlackChannelId(channelId); + + if (!workingGroup) { + // Channel not linked to any working group + return; + } + + // Only auto-add for chapters and event groups + if (workingGroup.committee_type !== 'chapter' && workingGroup.committee_type !== 'event') { + logger.debug( + { workingGroupId: workingGroup.id, type: workingGroup.committee_type }, + 'Skipping auto-add: not a chapter or event group' + ); + return; + } + + // Check if already a member + const isMember = await workingGroupDb.isMember(workingGroup.id, workosUserId); + if (isMember) { + logger.debug( + { workingGroupId: workingGroup.id, userId: workosUserId }, + 'User already a member of working group' + ); + return; + } + + // Add to working group with interest tracking for event groups + const interestLevel = workingGroup.committee_type === 'event' ? 'interested' : undefined; + const interestSource = 'slack_join'; + + await workingGroupDb.addMembershipWithInterest({ + working_group_id: workingGroup.id, + workos_user_id: workosUserId, + user_email: slackMapping.slack_email || undefined, + user_name: slackMapping.slack_real_name || slackMapping.slack_display_name || undefined, + interest_level: interestLevel, + interest_source: interestSource, + }); + + logger.info( + { + workingGroupId: workingGroup.id, + workingGroupName: workingGroup.name, + userId: workosUserId, + type: workingGroup.committee_type, + }, + 'Auto-added user to working group via Slack channel join' + ); + } catch (error) { + logger.error( + { error, channelId, userId: workosUserId }, + 'Failed to auto-add user to working group' + ); + } +} + /** * Handle message event * Records message activity for engagement tracking diff --git a/server/src/types.ts b/server/src/types.ts index 9b3b6b961..7d1031593 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -315,17 +315,36 @@ export interface ListMemberProfilesOptions { offset?: number; } +// User Location Types + +export type LocationSource = 'manual' | 'outreach' | 'inferred'; + +export interface UserLocation { + city?: string; + country?: string; + location_source?: LocationSource; + location_updated_at?: Date; +} + +export interface UpdateUserLocationInput { + workos_user_id: string; + city?: string; + country?: string; + location_source: LocationSource; +} + // Working Group Types export type WorkingGroupStatus = 'active' | 'inactive' | 'archived'; export type WorkingGroupMembershipStatus = 'active' | 'inactive'; -export type CommitteeType = 'working_group' | 'council' | 'chapter' | 'governance'; +export type CommitteeType = 'working_group' | 'council' | 'chapter' | 'governance' | 'event'; export const VALID_COMMITTEE_TYPES: readonly CommitteeType[] = [ 'working_group', 'council', 'chapter', 'governance', + 'event', ] as const; export const COMMITTEE_TYPE_LABELS: Record = { @@ -333,6 +352,7 @@ export const COMMITTEE_TYPE_LABELS: Record = { council: 'Industry Council', chapter: 'Regional Chapter', governance: 'Governance', + event: 'Event Group', }; export interface WorkingGroupLeader { @@ -354,11 +374,19 @@ export interface WorkingGroup { display_order: number; committee_type: CommitteeType; region?: string; + // Event group fields + linked_event_id?: string; + event_start_date?: Date; + event_end_date?: Date; + auto_archive_after_event?: boolean; created_at: Date; updated_at: Date; leaders?: WorkingGroupLeader[]; } +export type EventInterestLevel = 'maybe' | 'interested' | 'attending' | 'attended'; +export type EventInterestSource = 'outreach' | 'registration' | 'manual' | 'slack_join'; + export interface WorkingGroupMembership { id: string; working_group_id: string; @@ -369,6 +397,9 @@ export interface WorkingGroupMembership { workos_organization_id?: string; status: WorkingGroupMembershipStatus; added_by_user_id?: string; + // Event interest tracking + interest_level?: EventInterestLevel; + interest_source?: EventInterestSource; joined_at: Date; updated_at: Date; } @@ -385,6 +416,11 @@ export interface CreateWorkingGroupInput { display_order?: number; committee_type?: CommitteeType; region?: string; + // Event group fields + linked_event_id?: string; + event_start_date?: Date; + event_end_date?: Date; + auto_archive_after_event?: boolean; } export interface UpdateWorkingGroupInput { @@ -398,6 +434,11 @@ export interface UpdateWorkingGroupInput { display_order?: number; committee_type?: CommitteeType; region?: string; + // Event group fields + linked_event_id?: string; + event_start_date?: Date; + event_end_date?: Date; + auto_archive_after_event?: boolean; } export interface WorkingGroupWithMemberCount extends WorkingGroup {