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
+
+
+
+
+ Create a Slack channel for attendees to connect before, during, and after the event.
+ Anyone who joins the channel is automatically added to the attendee group.
+
+
+
+
Attendee Group Active
+
+
+
+
+
+ Create Attendee Group + Slack Channel
+
+
+ This will create a public Slack channel for attendees to join.
+
+
+
+
+ Loading attendee group info...
+
+
+
Cancel
Save Event
@@ -641,6 +721,7 @@
${escapeHtml(event.title)}
document.getElementById('eventForm').reset();
document.getElementById('sponsorshipTiers').innerHTML = '';
document.getElementById('stripeProductInfo').style.display = 'none';
+ document.getElementById('eventGroupSection').style.display = 'none';
sponsorshipTierCount = 0;
toggleLocationFields();
toggleSponsorshipTiers();
@@ -711,9 +792,99 @@ ${escapeHtml(event.title)}
}
toggleLocationFields();
+
+ // Show event group section for existing events
+ document.getElementById('eventGroupSection').style.display = 'block';
+ loadEventGroupInfo(id);
+
document.getElementById('eventModal').style.display = 'flex';
}
+ // Load event group info
+ async function loadEventGroupInfo(eventId) {
+ const section = document.getElementById('eventGroupSection');
+ const exists = document.getElementById('eventGroupExists');
+ const notExists = document.getElementById('eventGroupNotExists');
+ const loading = document.getElementById('eventGroupLoading');
+
+ // Show loading
+ exists.style.display = 'none';
+ notExists.style.display = 'none';
+ loading.style.display = 'block';
+
+ try {
+ const res = await fetch(`/api/admin/events/${eventId}/event-group`);
+ if (!res.ok) throw new Error('Failed to load event group');
+
+ const data = await res.json();
+ loading.style.display = 'none';
+
+ if (data.event_group) {
+ // Event group exists
+ exists.style.display = 'block';
+ document.getElementById('eventGroupMemberCount').textContent = data.member_count || 0;
+
+ if (data.event_group.slack_channel_url) {
+ document.getElementById('eventGroupSlackLink').href = data.event_group.slack_channel_url;
+ document.getElementById('eventGroupChannelName').textContent =
+ data.event_group.slack_channel_id ? `#${data.event_group.slug}` : 'Slack Channel';
+ }
+ } else {
+ // No event group yet
+ notExists.style.display = 'block';
+ }
+ } catch (error) {
+ console.error('Error loading event group:', error);
+ loading.style.display = 'none';
+ notExists.style.display = 'block';
+ }
+ }
+
+ // Create event group
+ async function createEventGroup() {
+ if (!editingEventId) return;
+
+ const btn = document.querySelector('#eventGroupNotExists button');
+ btn.disabled = true;
+ btn.textContent = 'Creating...';
+
+ try {
+ const res = await fetch(`/api/admin/events/${editingEventId}/event-group`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ create_slack_channel: true })
+ });
+
+ if (!res.ok) {
+ const error = await res.json();
+ throw new Error(error.message || 'Failed to create event group');
+ }
+
+ const data = await res.json();
+
+ // Refresh the event group info
+ await loadEventGroupInfo(editingEventId);
+
+ if (data.slack_channel_created) {
+ alert('Attendee group and Slack channel created! Share the channel link with attendees.');
+ } else {
+ alert('Attendee group created. Note: Slack channel could not be created automatically - you may need to create it manually.');
+ }
+ } catch (error) {
+ alert(error.message);
+ btn.disabled = false;
+ btn.textContent = 'Create Attendee Group + Slack Channel';
+ }
+ }
+
+ // View event group members
+ function viewEventGroupMembers() {
+ // Navigate to working groups admin filtered by this event group
+ if (editingEventId) {
+ window.open('/admin/working-groups?type=event', '_blank');
+ }
+ }
+
// Save event
async function saveEvent(e) {
e.preventDefault();
@@ -822,6 +993,11 @@ ${escapeHtml(event.title)}
function closeModal() {
editingEventId = null;
document.getElementById('eventModal').style.display = 'none';
+ // Reset event group section
+ document.getElementById('eventGroupSection').style.display = 'none';
+ document.getElementById('eventGroupExists').style.display = 'none';
+ document.getElementById('eventGroupNotExists').style.display = 'none';
+ document.getElementById('eventGroupLoading').style.display = 'none';
}
// Toggle location fields based on event format
diff --git a/server/public/admin-working-groups.html b/server/public/admin-working-groups.html
index 120e28b44..98025abf3 100644
--- a/server/public/admin-working-groups.html
+++ b/server/public/admin-working-groups.html
@@ -80,6 +80,7 @@
.badge-council { background: var(--color-purple-100, #f3e8ff); color: var(--color-purple-700, #7c3aed); }
.badge-chapter { background: var(--color-teal-100, #ccfbf1); color: var(--color-teal-700, #0f766e); }
.badge-governance { background: var(--color-gray-300); color: var(--color-gray-700); }
+ .badge-event { background: var(--color-warning-100); color: var(--color-warning-700); }
.btn {
padding: var(--space-2) var(--space-4);
border: none;
@@ -348,6 +349,7 @@ Committees
Industry Councils
Regional Chapters
Governance
+ Event Groups
All Statuses
@@ -425,6 +427,7 @@ Add Committee
Industry Council
Regional Chapter
Governance
+ Event Group
@@ -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 {