+
+
+
+
+
+
+
+
+
+
+
@@ -628,6 +847,22 @@
Users
No users found matching your filters.
+
+
+
+
+
+
+
+
+
+
No accounts assigned to you yet.
+
+ Accounts are auto-assigned when you send outreach or have conversations.
+
+
+
+
@@ -652,13 +887,17 @@
Member Context
let searchTimeout = null;
let slackConfigured = false;
let currentStatusFilter = '';
+ let actionItems = [];
+ let myAccounts = [];
+ let currentTab = 'all-users';
async function init() {
try {
- // Load working groups and goal types in parallel
- const [groupsResponse, goalsResponse] = await Promise.all([
+ // Load working groups, goal types, and action items in parallel
+ const [groupsResponse, goalsResponse, actionResponse] = await Promise.all([
fetch('/api/admin/working-groups'),
- fetch('/api/admin/goal-types')
+ fetch('/api/admin/goal-types'),
+ fetch('/api/admin/action-items/mine')
]);
if (!groupsResponse.ok) {
@@ -678,6 +917,13 @@
Member Context
console.error('Failed to load goal types:', goalsResponse.status);
}
+ // Load action items
+ if (actionResponse.ok) {
+ const actionData = await actionResponse.json();
+ actionItems = actionData.items || [];
+ renderActionItems();
+ }
+
// Check Slack sync status
await checkSlackStatus();
@@ -693,6 +939,210 @@
Member Context
}
}
+ // =========================================================================
+ // Action Items Functions
+ // =========================================================================
+
+ function renderActionItems() {
+ const panel = document.getElementById('actionItemsPanel');
+ const list = document.getElementById('actionItemsList');
+
+ if (!actionItems || actionItems.length === 0) {
+ panel.style.display = 'none';
+ return;
+ }
+
+ panel.style.display = 'block';
+
+ // Update counts
+ const highCount = actionItems.filter(a => a.priority === 'high').length;
+ const mediumCount = actionItems.filter(a => a.priority === 'medium').length;
+ const lowCount = actionItems.filter(a => a.priority === 'low').length;
+ document.getElementById('actionHighCount').textContent = highCount;
+ document.getElementById('actionMediumCount').textContent = mediumCount;
+ document.getElementById('actionLowCount').textContent = lowCount;
+
+ // Render items (limit to 5)
+ const displayItems = actionItems.slice(0, 5);
+ list.innerHTML = displayItems.map(item => {
+ const userName = item.user_name || item.user_email || 'Unknown User';
+ const created = new Date(item.created_at).toLocaleDateString();
+ return `
+
+
+
${escapeHtml(item.title)}
+
+ ${escapeHtml(userName)}
+ ${formatActionType(item.action_type)}
+ ${created}
+
+
+
+
+
+
+
+
+ `;
+ }).join('');
+
+ if (actionItems.length > 5) {
+ list.innerHTML += `
${actionItems.length - 5} more action items...
`;
+ }
+ }
+
+ function formatActionType(type) {
+ const labels = {
+ nudge: 'Nudge',
+ warm_lead: 'Warm Lead',
+ momentum: 'Momentum',
+ feedback: 'Feedback',
+ alert: 'Alert',
+ follow_up: 'Follow Up',
+ celebration: '🎉 Celebration'
+ };
+ return labels[type] || type;
+ }
+
+ async function completeActionItem(id) {
+ try {
+ const response = await fetch(`/api/admin/action-items/${id}/complete`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ resolution_note: 'Completed from users page' })
+ });
+ if (response.ok) {
+ actionItems = actionItems.filter(a => a.id !== id);
+ renderActionItems();
+ }
+ } catch (error) {
+ console.error('Error completing action item:', error);
+ }
+ }
+
+ async function snoozeActionItem(id) {
+ try {
+ const tomorrow = new Date();
+ tomorrow.setDate(tomorrow.getDate() + 1);
+ const response = await fetch(`/api/admin/action-items/${id}/snooze`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ until: tomorrow.toISOString() })
+ });
+ if (response.ok) {
+ actionItems = actionItems.filter(a => a.id !== id);
+ renderActionItems();
+ }
+ } catch (error) {
+ console.error('Error snoozing action item:', error);
+ }
+ }
+
+ async function dismissActionItem(id) {
+ if (!confirm('Dismiss this action item?')) return;
+ try {
+ const response = await fetch(`/api/admin/action-items/${id}/dismiss`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ resolution_note: 'Dismissed from users page' })
+ });
+ if (response.ok) {
+ actionItems = actionItems.filter(a => a.id !== id);
+ renderActionItems();
+ }
+ } catch (error) {
+ console.error('Error dismissing action item:', error);
+ }
+ }
+
+ // =========================================================================
+ // Tab Navigation
+ // =========================================================================
+
+ function switchTab(tabId) {
+ currentTab = tabId;
+
+ // Update tab buttons
+ document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.classList.remove('active');
+ if (btn.textContent.toLowerCase().includes(tabId.replace('-', ' ').replace('all ', ''))) {
+ btn.classList.add('active');
+ }
+ });
+
+ // Show/hide tab content
+ document.querySelectorAll('.tab-content').forEach(content => {
+ content.classList.remove('active');
+ });
+ document.getElementById(`tab-${tabId}`).classList.add('active');
+
+ // Load data for the tab if needed
+ if (tabId === 'my-accounts' && myAccounts.length === 0) {
+ loadMyAccounts();
+ }
+ }
+
+ // =========================================================================
+ // My Accounts Functions
+ // =========================================================================
+
+ async function loadMyAccounts() {
+ try {
+ const response = await fetch('/api/admin/my-accounts');
+ if (!response.ok) throw new Error('Failed to load accounts');
+ myAccounts = await response.json();
+ renderMyAccounts();
+ } catch (error) {
+ console.error('Error loading my accounts:', error);
+ }
+ }
+
+ function renderMyAccounts() {
+ const grid = document.getElementById('myAccountsGrid');
+ const emptyState = document.getElementById('emptyAccountsState');
+
+ if (!myAccounts || myAccounts.length === 0) {
+ grid.innerHTML = '';
+ emptyState.style.display = 'block';
+ return;
+ }
+
+ emptyState.style.display = 'none';
+
+ grid.innerHTML = myAccounts.map(account => {
+ const actionsClass = account.open_action_items > 0 ? 'has-items' : '';
+ const lastActivity = account.last_slack_activity
+ ? new Date(account.last_slack_activity).toLocaleDateString()
+ : 'Never';
+ const lastConvo = account.last_conversation
+ ? new Date(account.last_conversation).toLocaleDateString()
+ : 'Never';
+
+ return `
+
+
+
+ Last Slack: ${lastActivity}
+ Last Chat: ${lastConvo}
+
+
+ ${account.open_action_items > 0 ? `⚠️ ${account.open_action_items} action items` : '✓ No pending actions'}
+
+
+
+
+
+ `;
+ }).join('');
+ }
+
function populateGoalFilter() {
const select = document.getElementById('goalFilter');
select.innerHTML = '
' +
@@ -913,7 +1363,7 @@
Member Context
const contextUserId = user.workos_user_id || user.slack_user_id;
const contextType = user.workos_user_id ? 'workos' : 'slack';
if (contextUserId) {
- actions += `
`;
+ actions += `
`;
}
if (user.mapping_status === 'suggested_match') {
@@ -1215,7 +1665,7 @@
Member Context
modalBody.innerHTML = `
Failed to load context.
-
+
`;
}
diff --git a/server/src/addie/handler.ts b/server/src/addie/handler.ts
index 47b283f902..27fdc88e24 100644
--- a/server/src/addie/handler.ts
+++ b/server/src/addie/handler.ts
@@ -48,6 +48,7 @@ import {
checkAndMarkOutreachResponse,
type ExtractionContext,
} from './services/insight-extractor.js';
+import { checkForSensitiveTopics } from './sensitive-topics.js';
import type { RequestTools } from './claude-client.js';
import type {
AssistantThreadStartedEvent,
@@ -293,22 +294,47 @@ export async function handleAssistantMessage(
isAdmin
);
- // Create user-scoped tools (these can only operate on behalf of this user)
- const userTools = await createUserScopedTools(memberContext, event.user);
+ // Check for sensitive topics before processing
+ const sensitiveCheck = await checkForSensitiveTopics(
+ inputValidation.sanitized,
+ event.user,
+ channelId
+ );
- // Process with Claude
+ // If we should deflect, return the deflection response instead of processing
let response;
- try {
- response = await claudeClient.processMessage(messageWithContext, undefined, userTools);
- } catch (error) {
- logger.error({ error }, 'Addie: Error processing message');
+ if (sensitiveCheck.shouldDeflect && sensitiveCheck.deflectResponse) {
+ logger.info({
+ userId: event.user,
+ category: sensitiveCheck.topicResult.category,
+ severity: sensitiveCheck.topicResult.severity,
+ isKnownMedia: sensitiveCheck.isKnownMedia,
+ }, 'Addie: Deflecting sensitive topic');
+
response = {
- text: "I'm sorry, I encountered an error. Please try again.",
+ text: sensitiveCheck.deflectResponse,
tools_used: [],
tool_executions: [],
flagged: true,
- flag_reason: `Error: ${error instanceof Error ? error.message : 'Unknown'}`,
+ flag_reason: `Sensitive topic deflection: ${sensitiveCheck.topicResult.category}`,
};
+ } else {
+ // Create user-scoped tools (these can only operate on behalf of this user)
+ const userTools = await createUserScopedTools(memberContext, event.user);
+
+ // Process with Claude
+ try {
+ response = await claudeClient.processMessage(messageWithContext, undefined, userTools);
+ } catch (error) {
+ logger.error({ error }, 'Addie: Error processing message');
+ response = {
+ text: "I'm sorry, I encountered an error. Please try again.",
+ tools_used: [],
+ tool_executions: [],
+ flagged: true,
+ flag_reason: `Error: ${error instanceof Error ? error.message : 'Unknown'}`,
+ };
+ }
}
// Validate output
@@ -412,22 +438,48 @@ export async function handleAppMention(event: AppMentionEvent): Promise
{
isAdmin
);
- // Create user-scoped tools (these can only operate on behalf of this user)
- const userTools = await createUserScopedTools(memberContext, event.user);
+ // Check for sensitive topics before processing (channel mentions are more public)
+ const sensitiveCheck = await checkForSensitiveTopics(
+ inputValidation.sanitized,
+ event.user,
+ event.channel
+ );
- // Process with Claude
+ // If we should deflect, return the deflection response instead of processing
let response;
- try {
- response = await claudeClient.processMessage(messageWithContext, undefined, userTools);
- } catch (error) {
- logger.error({ error }, 'Addie: Error processing mention');
+ if (sensitiveCheck.shouldDeflect && sensitiveCheck.deflectResponse) {
+ logger.info({
+ userId: event.user,
+ channel: event.channel,
+ category: sensitiveCheck.topicResult.category,
+ severity: sensitiveCheck.topicResult.severity,
+ isKnownMedia: sensitiveCheck.isKnownMedia,
+ }, 'Addie: Deflecting sensitive topic (mention)');
+
response = {
- text: "I'm sorry, I encountered an error. Please try again.",
+ text: sensitiveCheck.deflectResponse,
tools_used: [],
tool_executions: [],
flagged: true,
- flag_reason: `Error: ${error instanceof Error ? error.message : 'Unknown'}`,
+ flag_reason: `Sensitive topic deflection: ${sensitiveCheck.topicResult.category}`,
};
+ } else {
+ // Create user-scoped tools (these can only operate on behalf of this user)
+ const userTools = await createUserScopedTools(memberContext, event.user);
+
+ // Process with Claude
+ try {
+ response = await claudeClient.processMessage(messageWithContext, undefined, userTools);
+ } catch (error) {
+ logger.error({ error }, 'Addie: Error processing mention');
+ response = {
+ text: "I'm sorry, I encountered an error. Please try again.",
+ tools_used: [],
+ tool_executions: [],
+ flagged: true,
+ flag_reason: `Error: ${error instanceof Error ? error.message : 'Unknown'}`,
+ };
+ }
}
// Validate output
diff --git a/server/src/addie/jobs/momentum-check.ts b/server/src/addie/jobs/momentum-check.ts
new file mode 100644
index 0000000000..ea4c73084a
--- /dev/null
+++ b/server/src/addie/jobs/momentum-check.ts
@@ -0,0 +1,612 @@
+/**
+ * Momentum Check Job
+ *
+ * Analyzes outreach history and user activity to create action items.
+ * Runs periodically to detect:
+ * - Users who need a nudge (no response/activity after outreach)
+ * - Warm leads (some engagement but no conversion)
+ * - Momentum opportunities (good activity, time to engage)
+ * - Conversions (celebrate!)
+ */
+
+import { logger } from '../../logger.js';
+import { query } from '../../db/client.js';
+import {
+ createActionItem,
+ reopenSnoozedItems,
+} from '../../db/account-management-db.js';
+
+// Configuration
+const NUDGE_DAYS = 3; // Days after outreach with no activity to suggest nudge
+const WARM_LEAD_DAYS = 7; // Days to consider someone a warm lead
+const MOMENTUM_THRESHOLD = 3; // Number of activities to consider "momentum"
+
+interface OutreachWithActivity {
+ outreach_id: number;
+ slack_user_id: string;
+ workos_user_id: string | null;
+ outreach_type: string;
+ sent_at: Date;
+ user_responded: boolean;
+ days_since_outreach: number;
+ // Activity since outreach
+ slack_messages_since: number;
+ email_clicks_since: number;
+ logins_since: number;
+ conversations_since: number;
+ // User status
+ is_linked: boolean;
+ is_member: boolean;
+}
+
+/**
+ * Get outreach records that might need follow-up
+ */
+async function getOutreachNeedingReview(): Promise {
+ const result = await query(
+ `SELECT
+ mo.id as outreach_id,
+ mo.slack_user_id,
+ sm.workos_user_id,
+ mo.outreach_type,
+ mo.sent_at,
+ mo.user_responded,
+ EXTRACT(DAY FROM NOW() - mo.sent_at)::integer as days_since_outreach,
+ -- Slack activity since outreach
+ COALESCE((
+ SELECT COUNT(*)
+ FROM slack_user_activity sa
+ WHERE sa.slack_user_id = mo.slack_user_id
+ AND sa.activity_date >= mo.sent_at::date
+ ), 0)::integer as slack_messages_since,
+ -- Email clicks since outreach
+ COALESCE((
+ SELECT COUNT(*)
+ FROM email_tracking et
+ WHERE et.recipient_email = sm.slack_email
+ AND et.event_type = 'click'
+ AND et.created_at >= mo.sent_at
+ ), 0)::integer as email_clicks_since,
+ -- Dashboard logins since outreach
+ COALESCE((
+ SELECT COUNT(*)
+ FROM user_engagement ue
+ WHERE ue.user_id = sm.workos_user_id
+ AND ue.event_type = 'login'
+ AND ue.created_at >= mo.sent_at
+ ), 0)::integer as logins_since,
+ -- Conversations with Addie since outreach
+ COALESCE((
+ SELECT COUNT(*)
+ FROM addie_threads at
+ WHERE (at.slack_user_id = mo.slack_user_id OR at.workos_user_id = sm.workos_user_id)
+ AND at.created_at >= mo.sent_at
+ ), 0)::integer as conversations_since,
+ -- User status
+ sm.workos_user_id IS NOT NULL as is_linked,
+ EXISTS(
+ SELECT 1 FROM organization_memberships om
+ WHERE om.user_id = sm.workos_user_id
+ ) as is_member
+ FROM member_outreach mo
+ JOIN slack_user_mappings sm ON sm.slack_user_id = mo.slack_user_id
+ WHERE mo.sent_at >= NOW() - INTERVAL '${WARM_LEAD_DAYS} days'
+ AND mo.sent_at <= NOW() - INTERVAL '1 day' -- At least 1 day old
+ -- No existing open action item for this outreach
+ AND NOT EXISTS (
+ SELECT 1 FROM action_items ai
+ WHERE ai.trigger_type = 'outreach'
+ AND ai.trigger_id = mo.id::text
+ AND ai.status = 'open'
+ )
+ ORDER BY mo.sent_at DESC`
+ );
+
+ return result.rows;
+}
+
+/**
+ * Check for organization conversions (new paid members)
+ */
+async function checkForConversions(): Promise {
+ // Find orgs that converted to paid in the last day
+ const result = await query<{
+ org_id: string;
+ org_name: string;
+ stakeholder_id: string | null;
+ }>(
+ `SELECT
+ o.workos_organization_id as org_id,
+ o.name as org_name,
+ os.user_id as stakeholder_id
+ FROM organizations o
+ LEFT JOIN org_stakeholders os ON os.organization_id = o.workos_organization_id AND os.role = 'owner'
+ WHERE o.subscription_status = 'active'
+ AND o.updated_at >= NOW() - INTERVAL '1 day'
+ -- No existing celebration action item
+ AND NOT EXISTS (
+ SELECT 1 FROM action_items ai
+ WHERE ai.org_id = o.workos_organization_id
+ AND ai.action_type = 'celebration'
+ AND ai.created_at >= NOW() - INTERVAL '7 days'
+ )`
+ );
+
+ for (const org of result.rows) {
+ await createActionItem({
+ orgId: org.org_id,
+ assignedTo: org.stakeholder_id || undefined,
+ actionType: 'celebration',
+ priority: 'medium',
+ title: `${org.org_name} converted to paid!`,
+ description: 'New paying member - great time to welcome them and offer help.',
+ triggerType: 'conversion',
+ triggerId: org.org_id,
+ });
+
+ logger.info({ orgId: org.org_id, orgName: org.org_name }, 'Created celebration action item for conversion');
+ }
+}
+
+/**
+ * Check for account linking (Slack user linked to AAO account)
+ */
+async function checkForAccountLinks(): Promise {
+ // Find users who linked their accounts in the last day after outreach
+ const result = await query<{
+ slack_user_id: string;
+ workos_user_id: string;
+ user_name: string;
+ outreach_id: number;
+ stakeholder_id: string | null;
+ }>(
+ `SELECT
+ sm.slack_user_id,
+ sm.workos_user_id,
+ COALESCE(sm.slack_real_name, sm.slack_display_name) as user_name,
+ mo.id as outreach_id,
+ us.stakeholder_id
+ FROM slack_user_mappings sm
+ JOIN member_outreach mo ON mo.slack_user_id = sm.slack_user_id
+ AND mo.outreach_type = 'account_link'
+ LEFT JOIN user_stakeholders us ON us.slack_user_id = sm.slack_user_id AND us.role = 'owner'
+ WHERE sm.workos_user_id IS NOT NULL
+ AND sm.updated_at >= NOW() - INTERVAL '1 day'
+ AND mo.sent_at >= sm.updated_at - INTERVAL '7 days' -- Linked within 7 days of outreach
+ -- No existing celebration action item
+ AND NOT EXISTS (
+ SELECT 1 FROM action_items ai
+ WHERE ai.slack_user_id = sm.slack_user_id
+ AND ai.action_type = 'celebration'
+ AND ai.created_at >= NOW() - INTERVAL '1 day'
+ )`
+ );
+
+ for (const user of result.rows) {
+ await createActionItem({
+ slackUserId: user.slack_user_id,
+ workosUserId: user.workos_user_id,
+ assignedTo: user.stakeholder_id || undefined,
+ actionType: 'celebration',
+ priority: 'low',
+ title: `${user.user_name} linked their account!`,
+ description: 'User linked Slack to AAO account after outreach.',
+ triggerType: 'account_link',
+ triggerId: user.slack_user_id,
+ context: { outreach_id: user.outreach_id },
+ });
+
+ logger.info({ slackUserId: user.slack_user_id }, 'Created celebration action item for account link');
+ }
+}
+
+/**
+ * Result of analyzing outreach - what action item would be created
+ */
+export interface OutreachAnalysisResult {
+ outreach: OutreachWithActivity;
+ wouldCreate: boolean;
+ actionType: 'nudge' | 'warm_lead' | 'momentum' | null;
+ title: string;
+ description: string;
+ priority: 'high' | 'medium' | 'low';
+ totalActivity: number;
+ reason: string;
+}
+
+/**
+ * Analyze outreach and determine what action item should be created
+ * Returns the analysis result without creating anything (for preview/dry-run)
+ */
+function analyzeOutreachResult(outreach: OutreachWithActivity): OutreachAnalysisResult {
+ const totalActivity =
+ outreach.slack_messages_since +
+ outreach.email_clicks_since +
+ outreach.logins_since +
+ outreach.conversations_since;
+
+ // Determine what kind of action item to create
+ let actionType: 'nudge' | 'warm_lead' | 'momentum' | null = null;
+ let title = '';
+ let description = '';
+ let priority: 'high' | 'medium' | 'low' = 'medium';
+ let reason = '';
+
+ if (outreach.user_responded) {
+ // They responded - check if conversion happened
+ if (outreach.outreach_type === 'account_link' && outreach.is_linked) {
+ // Success! Handled by checkForAccountLinks
+ reason = 'User responded AND linked account - success, no action needed';
+ } else if (!outreach.is_linked && outreach.outreach_type === 'account_link') {
+ // They responded but didn't convert yet - warm lead
+ actionType = 'warm_lead';
+ title = `Responded but didn't link account`;
+ description = `User responded to outreach ${outreach.days_since_outreach} days ago but hasn't linked their account yet. May need help.`;
+ priority = 'medium';
+ reason = 'User responded to outreach but has not linked their account';
+ } else {
+ reason = 'User responded - monitoring';
+ }
+ } else if (totalActivity >= MOMENTUM_THRESHOLD) {
+ // No direct response but lots of activity - momentum
+ actionType = 'momentum';
+ title = `Active user, good time to engage`;
+ description = `No response to outreach but user has been active: ${outreach.slack_messages_since} Slack messages, ${outreach.email_clicks_since} email clicks, ${outreach.logins_since} logins.`;
+ priority = 'low';
+ reason = `High activity (${totalActivity} actions) without direct response - good engagement opportunity`;
+ } else if (totalActivity > 0) {
+ // Some activity but no response - warm lead
+ actionType = 'warm_lead';
+ title = `Some activity, might need a nudge`;
+ description = `User has some activity since outreach (${totalActivity} actions) but hasn't responded directly.`;
+ priority = 'medium';
+ reason = `Some activity (${totalActivity} actions) but no direct response`;
+ } else if (outreach.days_since_outreach >= NUDGE_DAYS) {
+ // No activity at all - needs nudge
+ actionType = 'nudge';
+ title = `No response after ${outreach.days_since_outreach} days`;
+ description = `Outreach sent ${outreach.days_since_outreach} days ago with no activity since. Consider a follow-up.`;
+ priority = 'medium';
+ reason = `No activity for ${outreach.days_since_outreach} days - needs follow-up`;
+ } else {
+ reason = `Only ${outreach.days_since_outreach} days since outreach - too early for nudge (wait ${NUDGE_DAYS} days)`;
+ }
+
+ return {
+ outreach,
+ wouldCreate: actionType !== null,
+ actionType,
+ title,
+ description,
+ priority,
+ totalActivity,
+ reason,
+ };
+}
+
+/**
+ * Analyze outreach and create appropriate action items
+ */
+async function analyzeOutreach(outreach: OutreachWithActivity): Promise {
+ const result = analyzeOutreachResult(outreach);
+
+ if (result.wouldCreate && result.actionType) {
+ await createActionItem({
+ slackUserId: outreach.slack_user_id,
+ workosUserId: outreach.workos_user_id || undefined,
+ actionType: result.actionType,
+ priority: result.priority,
+ title: result.title,
+ description: result.description,
+ context: {
+ outreach_id: outreach.outreach_id,
+ outreach_type: outreach.outreach_type,
+ days_since_outreach: outreach.days_since_outreach,
+ slack_messages_since: outreach.slack_messages_since,
+ email_clicks_since: outreach.email_clicks_since,
+ logins_since: outreach.logins_since,
+ conversations_since: outreach.conversations_since,
+ },
+ triggerType: 'outreach',
+ triggerId: outreach.outreach_id.toString(),
+ });
+
+ logger.info({
+ slackUserId: outreach.slack_user_id,
+ actionType: result.actionType,
+ outreachId: outreach.outreach_id,
+ }, 'Created action item from outreach analysis');
+ }
+}
+
+/**
+ * Get outreach data for a specific user (for preview)
+ */
+async function getOutreachForUser(slackUserId: string): Promise {
+ const result = await query(
+ `SELECT
+ mo.id as outreach_id,
+ mo.slack_user_id,
+ sm.workos_user_id,
+ mo.outreach_type,
+ mo.sent_at,
+ mo.user_responded,
+ EXTRACT(DAY FROM NOW() - mo.sent_at)::integer as days_since_outreach,
+ -- Slack activity since outreach
+ COALESCE((
+ SELECT COUNT(*)
+ FROM slack_user_activity sa
+ WHERE sa.slack_user_id = mo.slack_user_id
+ AND sa.activity_date >= mo.sent_at::date
+ ), 0)::integer as slack_messages_since,
+ -- Email clicks since outreach
+ COALESCE((
+ SELECT COUNT(*)
+ FROM email_tracking et
+ WHERE et.recipient_email = sm.slack_email
+ AND et.event_type = 'click'
+ AND et.created_at >= mo.sent_at
+ ), 0)::integer as email_clicks_since,
+ -- Dashboard logins since outreach
+ COALESCE((
+ SELECT COUNT(*)
+ FROM user_engagement ue
+ WHERE ue.user_id = sm.workos_user_id
+ AND ue.event_type = 'login'
+ AND ue.created_at >= mo.sent_at
+ ), 0)::integer as logins_since,
+ -- Conversations with Addie since outreach
+ COALESCE((
+ SELECT COUNT(*)
+ FROM addie_threads at
+ WHERE (at.slack_user_id = mo.slack_user_id OR at.workos_user_id = sm.workos_user_id)
+ AND at.created_at >= mo.sent_at
+ ), 0)::integer as conversations_since,
+ -- User status
+ sm.workos_user_id IS NOT NULL as is_linked,
+ EXISTS(
+ SELECT 1 FROM organization_memberships om
+ WHERE om.user_id = sm.workos_user_id
+ ) as is_member
+ FROM member_outreach mo
+ JOIN slack_user_mappings sm ON sm.slack_user_id = mo.slack_user_id
+ WHERE mo.slack_user_id = $1
+ ORDER BY mo.sent_at DESC
+ LIMIT 10`,
+ [slackUserId]
+ );
+
+ return result.rows;
+}
+
+/**
+ * Preview momentum analysis for a specific user
+ * Does NOT create any action items - just shows what would happen
+ */
+export async function previewMomentumForUser(slackUserId: string): Promise<{
+ user: {
+ slack_user_id: string;
+ name: string | null;
+ email: string | null;
+ is_linked: boolean;
+ is_member: boolean;
+ };
+ outreach: OutreachAnalysisResult[];
+ existingActionItems: number;
+}> {
+ // Get user info
+ const userResult = await query<{
+ slack_user_id: string;
+ slack_real_name: string | null;
+ slack_display_name: string | null;
+ slack_email: string | null;
+ workos_user_id: string | null;
+ }>(
+ `SELECT slack_user_id, slack_real_name, slack_display_name, slack_email, workos_user_id
+ FROM slack_user_mappings WHERE slack_user_id = $1`,
+ [slackUserId]
+ );
+
+ if (userResult.rows.length === 0) {
+ throw new Error(`User not found: ${slackUserId}`);
+ }
+
+ const userRow = userResult.rows[0];
+
+ // Check if member
+ const memberResult = await query<{ count: string }>(
+ `SELECT COUNT(*) as count FROM organization_memberships WHERE user_id = $1`,
+ [userRow.workos_user_id]
+ );
+
+ // Get existing action items
+ const actionResult = await query<{ count: string }>(
+ `SELECT COUNT(*) as count FROM action_items WHERE slack_user_id = $1 AND status = 'open'`,
+ [slackUserId]
+ );
+
+ // Get and analyze outreach
+ const outreachRecords = await getOutreachForUser(slackUserId);
+ const analyses = outreachRecords.map(analyzeOutreachResult);
+
+ return {
+ user: {
+ slack_user_id: slackUserId,
+ name: userRow.slack_real_name || userRow.slack_display_name,
+ email: userRow.slack_email,
+ is_linked: userRow.workos_user_id !== null,
+ is_member: parseInt(memberResult.rows[0].count, 10) > 0,
+ },
+ outreach: analyses,
+ existingActionItems: parseInt(actionResult.rows[0].count, 10),
+ };
+}
+
+/**
+ * Dry-run the full momentum check job
+ * Returns what WOULD be created without actually creating anything
+ */
+export async function dryRunMomentumCheck(): Promise<{
+ outreachToAnalyze: number;
+ wouldCreate: OutreachAnalysisResult[];
+ wouldSkip: OutreachAnalysisResult[];
+ snoozedToReopen: number;
+}> {
+ logger.info('Running momentum check dry-run');
+
+ // Check snoozed items
+ const snoozedResult = await query<{ count: string }>(
+ `SELECT COUNT(*) as count FROM action_items WHERE status = 'snoozed' AND snoozed_until <= NOW()`
+ );
+ const snoozedToReopen = parseInt(snoozedResult.rows[0].count, 10);
+
+ // Get outreach to analyze
+ const outreachToReview = await getOutreachNeedingReview();
+
+ const analyses = outreachToReview.map(analyzeOutreachResult);
+ const wouldCreate = analyses.filter(a => a.wouldCreate);
+ const wouldSkip = analyses.filter(a => !a.wouldCreate);
+
+ logger.info({
+ outreachToAnalyze: outreachToReview.length,
+ wouldCreate: wouldCreate.length,
+ wouldSkip: wouldSkip.length,
+ snoozedToReopen,
+ }, 'Momentum check dry-run completed');
+
+ return {
+ outreachToAnalyze: outreachToReview.length,
+ wouldCreate,
+ wouldSkip,
+ snoozedToReopen,
+ };
+}
+
+/**
+ * Check for tire-kicker pattern: users who ask many questions but never convert
+ * These users may need human outreach with a more targeted value proposition
+ */
+async function checkForTireKickers(): Promise {
+ const QUESTION_THRESHOLD = 3;
+ const DAYS_ACTIVE_THRESHOLD = 14;
+
+ const result = await query<{
+ slack_user_id: string;
+ workos_user_id: string | null;
+ user_name: string | null;
+ conversation_count: number;
+ days_active: number;
+ stakeholder_id: string | null;
+ }>(
+ `WITH user_conversations AS (
+ SELECT
+ COALESCE(at.user_id, sm.slack_user_id) as slack_user_id,
+ sm.workos_user_id,
+ COALESCE(sm.slack_real_name, sm.slack_display_name) as user_name,
+ COUNT(DISTINCT at.thread_id) as conversation_count,
+ EXTRACT(DAY FROM NOW() - MIN(at.started_at))::integer as days_active
+ FROM addie_threads at
+ LEFT JOIN slack_user_mappings sm ON
+ (at.user_type = 'slack' AND at.user_id = sm.slack_user_id)
+ OR (at.user_type = 'workos' AND at.user_id = sm.workos_user_id)
+ WHERE at.started_at >= NOW() - INTERVAL '30 days'
+ AND sm.workos_user_id IS NULL -- Not linked yet
+ GROUP BY COALESCE(at.user_id, sm.slack_user_id), sm.workos_user_id, sm.slack_real_name, sm.slack_display_name
+ HAVING COUNT(DISTINCT at.thread_id) >= $1
+ AND EXTRACT(DAY FROM NOW() - MIN(at.started_at)) >= $2
+ )
+ SELECT
+ uc.*,
+ us.stakeholder_id
+ FROM user_conversations uc
+ LEFT JOIN user_stakeholders us ON us.slack_user_id = uc.slack_user_id AND us.role = 'owner'
+ -- No existing tire-kicker action item
+ WHERE NOT EXISTS (
+ SELECT 1 FROM action_items ai
+ WHERE ai.slack_user_id = uc.slack_user_id
+ AND ai.action_type = 'warm_lead'
+ AND ai.title LIKE '%tire-kicker%'
+ AND ai.status = 'open'
+ )`,
+ [QUESTION_THRESHOLD, DAYS_ACTIVE_THRESHOLD]
+ );
+
+ for (const user of result.rows) {
+ await createActionItem({
+ slackUserId: user.slack_user_id,
+ workosUserId: user.workos_user_id || undefined,
+ assignedTo: user.stakeholder_id || undefined,
+ actionType: 'warm_lead',
+ priority: 'medium',
+ title: `Tire-kicker pattern: ${user.user_name || user.slack_user_id}`,
+ description: `User has had ${user.conversation_count} conversations over ${user.days_active} days without converting. Consider human outreach with targeted value proposition.`,
+ triggerType: 'insight',
+ triggerId: `tire_kicker_${user.slack_user_id}`,
+ context: {
+ conversation_count: user.conversation_count,
+ days_active: user.days_active,
+ pattern: 'tire_kicker',
+ },
+ });
+
+ logger.info({
+ slackUserId: user.slack_user_id,
+ conversationCount: user.conversation_count,
+ daysActive: user.days_active,
+ }, 'Created tire-kicker action item');
+ }
+}
+
+/**
+ * Run the full momentum check job
+ */
+export async function runMomentumCheck(): Promise<{
+ outreachAnalyzed: number;
+ actionItemsCreated: number;
+ snoozedReopened: number;
+}> {
+ logger.info('Running momentum check job');
+
+ let actionItemsCreated = 0;
+
+ // Reopen any snoozed items that are past their snooze time
+ const snoozedReopened = await reopenSnoozedItems();
+ if (snoozedReopened > 0) {
+ logger.info({ count: snoozedReopened }, 'Reopened snoozed action items');
+ }
+
+ // Check for conversions
+ await checkForConversions();
+
+ // Check for account links
+ await checkForAccountLinks();
+
+ // Check for tire-kicker pattern
+ await checkForTireKickers();
+
+ // Analyze outreach needing review
+ const outreachToReview = await getOutreachNeedingReview();
+ logger.info({ count: outreachToReview.length }, 'Found outreach records to analyze');
+
+ for (const outreach of outreachToReview) {
+ try {
+ await analyzeOutreach(outreach);
+ const result = analyzeOutreachResult(outreach);
+ if (result.wouldCreate) actionItemsCreated++;
+ } catch (error) {
+ logger.error({ error, outreachId: outreach.outreach_id }, 'Error analyzing outreach');
+ }
+ }
+
+ logger.info({
+ outreachAnalyzed: outreachToReview.length,
+ actionItemsCreated,
+ snoozedReopened,
+ }, 'Momentum check job completed');
+
+ return {
+ outreachAnalyzed: outreachToReview.length,
+ actionItemsCreated,
+ snoozedReopened,
+ };
+}
diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts
index 296cd9a278..75f236d247 100644
--- a/server/src/addie/mcp/admin-tools.ts
+++ b/server/src/addie/mcp/admin-tools.ts
@@ -37,6 +37,7 @@ import {
getFeedStats,
type FeedWithStats,
} from '../../db/industry-feeds-db.js';
+import { InsightsDatabase } from '../../db/insights-db.js';
const logger = createLogger('addie-admin-tools');
const orgDb = new OrganizationDatabase();
@@ -434,6 +435,93 @@ Returns: Slack user count and activity, working groups, engagement level and sig
required: [],
},
},
+
+ // ============================================
+ // SENSITIVE TOPICS & MEDIA CONTACT TOOLS
+ // ============================================
+ {
+ name: 'add_media_contact',
+ description:
+ 'Flag a Slack user as a known media contact (journalist, reporter, editor). Messages from this user will be handled with extra care and sensitive topics will be deflected.',
+ input_schema: {
+ type: 'object',
+ properties: {
+ slack_user_id: {
+ type: 'string',
+ description: 'Slack user ID of the media contact (e.g., "U0123456789")',
+ },
+ email: {
+ type: 'string',
+ description: 'Email address of the media contact',
+ },
+ name: {
+ type: 'string',
+ description: 'Full name of the media contact',
+ },
+ organization: {
+ type: 'string',
+ description: 'Media organization they work for (e.g., "TechCrunch", "AdExchanger")',
+ },
+ role: {
+ type: 'string',
+ description: 'Their role (e.g., "Reporter", "Editor", "Journalist")',
+ },
+ notes: {
+ type: 'string',
+ description: 'Additional notes about this contact',
+ },
+ handling_level: {
+ type: 'string',
+ enum: ['standard', 'careful', 'executive_only'],
+ description: 'How carefully to handle this contact: standard (deflect sensitive topics), careful (deflect more topics), executive_only (always escalate)',
+ },
+ },
+ required: [],
+ },
+ },
+ {
+ name: 'list_flagged_conversations',
+ description:
+ 'List conversations that have been flagged for sensitive topic detection. These need human review to ensure appropriate handling.',
+ input_schema: {
+ type: 'object',
+ properties: {
+ unreviewed_only: {
+ type: 'boolean',
+ description: 'Only show conversations that haven\'t been reviewed yet (default: true)',
+ },
+ severity: {
+ type: 'string',
+ enum: ['high', 'medium', 'low'],
+ description: 'Filter by severity level',
+ },
+ limit: {
+ type: 'number',
+ description: 'Maximum number of results (default: 20)',
+ },
+ },
+ required: [],
+ },
+ },
+ {
+ name: 'review_flagged_conversation',
+ description:
+ 'Mark a flagged conversation as reviewed. Use this after you\'ve looked at a flagged message and determined if any follow-up action is needed.',
+ input_schema: {
+ type: 'object',
+ properties: {
+ flagged_id: {
+ type: 'number',
+ description: 'ID of the flagged conversation to review',
+ },
+ notes: {
+ type: 'string',
+ description: 'Notes about the review (e.g., "False positive - user was asking about child-safe ad practices for their company", "Escalated to Brian")',
+ },
+ },
+ required: ['flagged_id'],
+ },
+ },
];
/**
@@ -1429,5 +1517,153 @@ export function createAdminToolHandlers(
}
});
+ // ============================================
+ // SENSITIVE TOPICS & MEDIA CONTACT HANDLERS
+ // ============================================
+
+ const insightsDb = new InsightsDatabase();
+
+ // Add media contact
+ handlers.set('add_media_contact', async (input) => {
+ const adminCheck = requireAdminFromContext();
+ if (adminCheck) return adminCheck;
+
+ const slackUserId = input.slack_user_id as string | undefined;
+ const email = input.email as string | undefined;
+ const name = input.name as string | undefined;
+ const organization = input.organization as string | undefined;
+ const role = input.role as string | undefined;
+ const notes = input.notes as string | undefined;
+ const handlingLevel = (input.handling_level as 'standard' | 'careful' | 'executive_only') || 'standard';
+
+ if (!slackUserId && !email) {
+ return '❌ Please provide either a slack_user_id or email to identify the media contact.';
+ }
+
+ try {
+ const contact = await insightsDb.addMediaContact({
+ slackUserId,
+ email,
+ name,
+ organization,
+ role,
+ notes,
+ handlingLevel,
+ });
+
+ let response = `✅ Added media contact\n\n`;
+ if (contact.name) response += `**Name:** ${contact.name}\n`;
+ if (contact.organization) response += `**Organization:** ${contact.organization}\n`;
+ if (contact.role) response += `**Role:** ${contact.role}\n`;
+ if (contact.slackUserId) response += `**Slack ID:** ${contact.slackUserId}\n`;
+ if (contact.email) response += `**Email:** ${contact.email}\n`;
+ response += `**Handling Level:** ${contact.handlingLevel}\n`;
+
+ const levelExplanation = {
+ standard: 'Sensitive topics will be deflected to human contacts.',
+ careful: 'More topics will be deflected, extra caution applied.',
+ executive_only: 'All questions will be escalated for executive review.',
+ };
+ response += `\n_${levelExplanation[contact.handlingLevel]}_`;
+
+ return response;
+ } catch (error) {
+ logger.error({ error }, 'Error adding media contact');
+ return '❌ Failed to add media contact. Please try again.';
+ }
+ });
+
+ // List flagged conversations
+ handlers.set('list_flagged_conversations', async (input) => {
+ const adminCheck = requireAdminFromContext();
+ if (adminCheck) return adminCheck;
+
+ const unreviewedOnly = input.unreviewed_only !== false; // Default to true
+ const severity = input.severity as 'high' | 'medium' | 'low' | undefined;
+ const limit = Math.min(Math.max((input.limit as number) || 20, 1), 100);
+
+ try {
+ const flagged = await insightsDb.getFlaggedConversations({
+ unreviewedOnly,
+ severity,
+ limit,
+ });
+
+ if (flagged.length === 0) {
+ let msg = 'No flagged conversations found';
+ if (unreviewedOnly) msg += ' pending review';
+ if (severity) msg += ` with severity "${severity}"`;
+ return msg + '. 🎉';
+ }
+
+ let response = `## Flagged Conversations`;
+ if (unreviewedOnly) response += ` (Pending Review)`;
+ response += `\n\n`;
+
+ const severityIcon = {
+ high: '🔴',
+ medium: '🟡',
+ low: '🟢',
+ };
+
+ for (const conv of flagged) {
+ const icon = severityIcon[conv.severity || 'low'];
+ response += `### ${icon} ID: ${conv.id}\n`;
+ if (conv.userName) response += `**From:** ${conv.userName}`;
+ if (conv.userEmail) response += ` (${conv.userEmail})`;
+ response += `\n`;
+ response += `**Category:** ${conv.matchedCategory || 'unknown'}\n`;
+ response += `**Message:** "${conv.messageText.substring(0, 150)}${conv.messageText.length > 150 ? '...' : ''}"\n`;
+ if (conv.wasDeflected) {
+ response += `**Deflected:** Yes\n`;
+ if (conv.responseGiven) {
+ response += `**Response:** "${conv.responseGiven.substring(0, 100)}..."\n`;
+ }
+ }
+ response += `**When:** ${formatDate(conv.createdAt)}\n`;
+ response += `\n`;
+ }
+
+ response += `\n_Use review_flagged_conversation to mark items as reviewed._`;
+
+ return response;
+ } catch (error) {
+ logger.error({ error }, 'Error listing flagged conversations');
+ return '❌ Failed to list flagged conversations. Please try again.';
+ }
+ });
+
+ // Review flagged conversation
+ handlers.set('review_flagged_conversation', async (input) => {
+ const adminCheck = requireAdminFromContext();
+ if (adminCheck) return adminCheck;
+
+ const flaggedId = input.flagged_id as number;
+ const notes = input.notes as string | undefined;
+
+ if (!flaggedId) {
+ return '❌ Please provide the flagged_id to review.';
+ }
+
+ try {
+ // Get reviewer user ID from member context if available
+ const reviewerId = memberContext?.workos_user?.workos_user_id
+ ? parseInt(memberContext.workos_user.workos_user_id, 10) || 0
+ : 0;
+
+ await insightsDb.reviewFlaggedConversation(flaggedId, reviewerId, notes);
+
+ let response = `✅ Marked conversation #${flaggedId} as reviewed.\n`;
+ if (notes) {
+ response += `\n**Notes:** ${notes}`;
+ }
+
+ return response;
+ } catch (error) {
+ logger.error({ error, flaggedId }, 'Error reviewing flagged conversation');
+ return '❌ Failed to mark conversation as reviewed. Please check the ID and try again.';
+ }
+ });
+
return handlers;
}
diff --git a/server/src/addie/sensitive-topics.ts b/server/src/addie/sensitive-topics.ts
new file mode 100644
index 0000000000..473de37679
--- /dev/null
+++ b/server/src/addie/sensitive-topics.ts
@@ -0,0 +1,171 @@
+/**
+ * Sensitive Topic Detection for Addie
+ *
+ * Detects journalist-bait questions and sensitive topics that should
+ * be deflected to human contacts rather than answered by AI.
+ */
+
+import { logger } from '../logger.js';
+import { InsightsDatabase, type SensitiveTopicResult, type KnownMediaContact } from '../db/insights-db.js';
+
+const insightsDb = new InsightsDatabase();
+
+export interface SensitiveTopicCheck {
+ shouldDeflect: boolean;
+ isKnownMedia: boolean;
+ mediaContact: KnownMediaContact | null;
+ topicResult: SensitiveTopicResult;
+ deflectResponse: string | null;
+ flaggedConversationId: number | null;
+}
+
+/**
+ * Check a message for sensitive topics and determine if it should be deflected
+ * Also checks if the user is a known media contact
+ */
+export async function checkForSensitiveTopics(
+ messageText: string,
+ slackUserId: string,
+ slackChannelId?: string
+): Promise {
+ try {
+ // Check in parallel: is this a sensitive topic and is this a media contact
+ const [topicResult, mediaContact] = await Promise.all([
+ insightsDb.checkSensitiveTopic(messageText),
+ insightsDb.isKnownMediaContact(slackUserId),
+ ]);
+
+ // Determine if we should deflect
+ // - Always deflect for high severity topics
+ // - Deflect for medium severity if known media contact
+ // - Flag but don't deflect for low severity
+ const isKnownMedia = mediaContact !== null;
+ let shouldDeflect = false;
+
+ if (topicResult.isSensitive) {
+ if (topicResult.severity === 'high') {
+ shouldDeflect = true;
+ } else if (topicResult.severity === 'medium' && isKnownMedia) {
+ shouldDeflect = true;
+ } else if (topicResult.severity === 'medium' && mediaContact?.handlingLevel === 'careful') {
+ shouldDeflect = true;
+ }
+ }
+
+ // For executive_only handling level, always deflect any question
+ if (mediaContact?.handlingLevel === 'executive_only') {
+ shouldDeflect = true;
+ }
+
+ // Build the deflection response
+ let deflectResponse: string | null = null;
+ if (shouldDeflect) {
+ deflectResponse = topicResult.deflectResponse || getDefaultDeflection(topicResult.category);
+ }
+
+ // Flag the conversation if it hit a sensitive topic (regardless of deflection)
+ let flaggedConversationId: number | null = null;
+ if (topicResult.isSensitive || isKnownMedia) {
+ try {
+ const flagged = await insightsDb.flagConversation({
+ slackUserId,
+ slackChannelId,
+ messageText,
+ matchedPatternId: topicResult.patternId ?? undefined,
+ matchedCategory: topicResult.category ?? undefined,
+ severity: topicResult.severity ?? undefined,
+ responseGiven: deflectResponse ?? undefined,
+ wasDeflected: shouldDeflect,
+ });
+ flaggedConversationId = flagged.id;
+
+ logger.info({
+ flaggedId: flaggedConversationId,
+ slackUserId,
+ category: topicResult.category,
+ severity: topicResult.severity,
+ isKnownMedia,
+ shouldDeflect,
+ }, 'Addie: Flagged sensitive conversation');
+ } catch (flagError) {
+ logger.error({ error: flagError }, 'Addie: Failed to flag conversation');
+ }
+ }
+
+ return {
+ shouldDeflect,
+ isKnownMedia,
+ mediaContact,
+ topicResult,
+ deflectResponse,
+ flaggedConversationId,
+ };
+ } catch (error) {
+ logger.error({ error }, 'Addie: Error checking sensitive topics');
+ // On error, don't block - just return safe defaults
+ return {
+ shouldDeflect: false,
+ isKnownMedia: false,
+ mediaContact: null,
+ topicResult: {
+ isSensitive: false,
+ patternId: null,
+ category: null,
+ severity: null,
+ deflectResponse: null,
+ },
+ deflectResponse: null,
+ flaggedConversationId: null,
+ };
+ }
+}
+
+/**
+ * Get a default deflection response for a category
+ */
+function getDefaultDeflection(category: string | null): string {
+ switch (category) {
+ case 'vulnerable_populations':
+ return "That's an important topic that deserves careful consideration. I'd recommend reaching out to our policy team for a thoughtful response.";
+ case 'political':
+ return "Questions about political topics require careful handling. I'd recommend reaching out to our communications team who can provide appropriate context.";
+ case 'named_individual':
+ return "For questions about specific individuals, I'd recommend reaching out to them directly or through official channels.";
+ case 'organization_position':
+ return "For official organizational positions, I'd recommend checking our public documentation or reaching out to our communications team.";
+ case 'competitive':
+ return "I focus on what AgenticAdvertising.org does rather than comparisons. Happy to explain our approach if you have specific questions!";
+ case 'privacy_surveillance':
+ return "Privacy and data ethics are important topics. Our technical documentation covers our approach, or I can connect you with our policy team for deeper discussion.";
+ case 'ethical_concerns':
+ return "That's a thoughtful question that deserves a nuanced response. I'd recommend reaching out to our policy team who can provide appropriate context.";
+ case 'media_inquiry':
+ return "Thanks for reaching out! For media inquiries, please contact our communications team who can best assist you.";
+ default:
+ return "That's a great question that deserves a thoughtful answer. Let me connect you with someone who can speak to this properly.";
+ }
+}
+
+/**
+ * Check if a message appears to be from someone asking for quotes/official statements
+ * (Supplementary check beyond pattern matching)
+ */
+export function hasMediaIndicators(messageText: string): boolean {
+ const lowerText = messageText.toLowerCase();
+ const indicators = [
+ 'writing a story',
+ 'working on an article',
+ 'for a piece',
+ 'for publication',
+ 'can i quote',
+ 'on the record',
+ 'off the record',
+ 'background only',
+ 'official statement',
+ 'official position',
+ 'spokesperson',
+ 'press inquiry',
+ 'media inquiry',
+ ];
+ return indicators.some(indicator => lowerText.includes(indicator));
+}
diff --git a/server/src/addie/services/proactive-outreach.ts b/server/src/addie/services/proactive-outreach.ts
index 8acd5e7478..51350f3a8c 100644
--- a/server/src/addie/services/proactive-outreach.ts
+++ b/server/src/addie/services/proactive-outreach.ts
@@ -18,6 +18,10 @@ import {
type OutreachVariant,
type InsightGoal,
} from '../../db/insights-db.js';
+import {
+ assignUserStakeholder,
+ createActionItem,
+} from '../../db/account-management-db.js';
import type { SlackUserMapping } from '../../slack/types.js';
const insightsDb = new InsightsDatabase();
@@ -254,6 +258,10 @@ function buildMessage(
const userName = candidate.slack_display_name || candidate.slack_real_name || 'there';
message = message.replace(/\{\{user_name\}\}/g, userName);
+ // Build account link URL with slack_user_id for auto-linking
+ const linkUrl = `https://agenticadvertising.org/auth/login?slack_user_id=${encodeURIComponent(candidate.slack_user_id)}`;
+ message = message.replace(/\{\{link_url\}\}/g, linkUrl);
+
if (goal) {
message = message.replace(/\{\{goal_question\}\}/g, goal.question);
}
@@ -484,8 +492,12 @@ export async function runOutreachScheduler(options: {
/**
* Manually trigger outreach to a specific user (admin function)
+ * When an admin sends outreach, they become the account owner if no owner exists.
*/
-export async function manualOutreach(slackUserId: string): Promise {
+export async function manualOutreach(
+ slackUserId: string,
+ triggeredBy?: { id: string; name: string; email: string }
+): Promise {
// Look up user
const result = await query(
`SELECT * FROM slack_user_mappings WHERE slack_user_id = $1`,
@@ -502,7 +514,32 @@ export async function manualOutreach(slackUserId: string): Promise SimulatedUserState;
+ expectedAction: ExpectedAction | null;
+ validate: (state: SimulatedUserState, action: TriggeredAction | null) => ValidationResult;
+}
+
+interface SimulatedUserState {
+ userId: string;
+ persona: UserPersona;
+ events: ActivityEvent[];
+ outreachHistory: OutreachRecord[];
+ currentStatus: {
+ isLinked: boolean;
+ isMember: boolean;
+ daysSinceJoined: number;
+ daysSinceLastActivity: number;
+ daysSinceLastOutreach: number | null;
+ totalActivityCount: number;
+ };
+ existingActionItems: SimulatedActionItem[];
+}
+
+interface OutreachRecord {
+ id: string;
+ type: 'account_link' | 'nudge' | 'follow_up';
+ variant: string;
+ sentAt: Date;
+ responded: boolean;
+ response?: string;
+ sentiment?: 'positive' | 'neutral' | 'negative';
+}
+
+interface SimulatedActionItem {
+ id: string;
+ type: 'nudge' | 'warm_lead' | 'momentum' | 'feedback' | 'alert' | 'follow_up' | 'celebration';
+ status: 'open' | 'snoozed' | 'completed' | 'dismissed';
+ createdAt: Date;
+ triggerId?: string;
+}
+
+interface ExpectedAction {
+ type: 'nudge' | 'warm_lead' | 'momentum' | 'alert' | 'celebration' | 'follow_up' | 'none';
+ priority?: 'high' | 'medium' | 'low';
+ reason: string;
+}
+
+interface TriggeredAction {
+ type: string;
+ priority: string;
+ title: string;
+ description: string;
+ reason: string;
+}
+
+interface ValidationResult {
+ passed: boolean;
+ expected: string;
+ actual: string;
+ issues: string[];
+}
+
+// Simulate what the momentum check would do
+function simulateMomentumCheck(state: SimulatedUserState): TriggeredAction | null {
+ const { currentStatus, outreachHistory, existingActionItems, events } = state;
+
+ // Check if there's already an open action item for this user
+ const hasOpenActionItem = existingActionItems.some(ai => ai.status === 'open');
+ if (hasOpenActionItem) {
+ return null; // Don't create duplicate
+ }
+
+ // Check if user is too new (grace period)
+ if (currentStatus.daysSinceJoined < 1) {
+ return null; // Grace period
+ }
+
+ // No outreach history - nothing to analyze
+ if (outreachHistory.length === 0) {
+ return null;
+ }
+
+ const lastOutreach = outreachHistory[outreachHistory.length - 1];
+ const daysSince = currentStatus.daysSinceLastOutreach || 0;
+
+ // Count activity since last outreach
+ const lastOutreachDate = lastOutreach.sentAt;
+ const activitySinceOutreach = events.filter(
+ e => e.timestamp > lastOutreachDate
+ ).length;
+
+ // Success case: user converted
+ if (lastOutreach.type === 'account_link' && currentStatus.isLinked) {
+ if (lastOutreach.responded) {
+ return {
+ type: 'celebration',
+ priority: 'low',
+ title: 'Account linked after outreach!',
+ description: 'User successfully linked their account.',
+ reason: 'Conversion success - responded and linked',
+ };
+ }
+ }
+
+ // User responded but didn't convert yet
+ if (lastOutreach.responded && !currentStatus.isLinked) {
+ return {
+ type: 'warm_lead',
+ priority: 'medium',
+ title: 'Responded but didn\'t link account',
+ description: `User responded ${daysSince} days ago but hasn't linked yet.`,
+ reason: 'Responded to outreach without conversion',
+ };
+ }
+
+ // Lots of activity but no direct response - momentum opportunity
+ if (!lastOutreach.responded && activitySinceOutreach >= ACTION_TRIGGER_CONFIG.MOMENTUM_THRESHOLD) {
+ return {
+ type: 'momentum',
+ priority: 'low',
+ title: 'Active user, good time to engage',
+ description: `No response but ${activitySinceOutreach} activities since outreach.`,
+ reason: 'High activity without direct response',
+ };
+ }
+
+ // Some activity but no response - warm lead
+ if (!lastOutreach.responded && activitySinceOutreach > 0 && activitySinceOutreach < ACTION_TRIGGER_CONFIG.MOMENTUM_THRESHOLD) {
+ return {
+ type: 'warm_lead',
+ priority: 'medium',
+ title: 'Some activity, might need a nudge',
+ description: `${activitySinceOutreach} activities but no direct response.`,
+ reason: 'Some activity without direct response',
+ };
+ }
+
+ // No activity - needs nudge (but only after threshold)
+ if (!lastOutreach.responded && activitySinceOutreach === 0 && daysSince >= ACTION_TRIGGER_CONFIG.NUDGE_DAYS) {
+ return {
+ type: 'nudge',
+ priority: 'medium',
+ title: `No response after ${daysSince} days`,
+ description: 'Zero activity since outreach - consider follow-up.',
+ reason: 'No activity after nudge threshold',
+ };
+ }
+
+ return null; // Too early or no action needed
+}
+
+// Test cases
+export const ACTION_TRIGGER_TESTS: ActionTriggerTestCase[] = [
+ {
+ id: 'new_user_grace_period',
+ name: 'New User Grace Period',
+ description: 'User who just joined should not receive outreach immediately',
+ setup: () => ({
+ userId: 'test_new_user',
+ persona: TEST_PERSONAS[0],
+ events: [],
+ outreachHistory: [],
+ currentStatus: {
+ isLinked: false,
+ isMember: false,
+ daysSinceJoined: 0, // Just joined
+ daysSinceLastActivity: 0,
+ daysSinceLastOutreach: null,
+ totalActivityCount: 0,
+ },
+ existingActionItems: [],
+ }),
+ expectedAction: null,
+ validate: (state, action) => {
+ const passed = action === null;
+ return {
+ passed,
+ expected: 'No action (grace period)',
+ actual: action ? `Created ${action.type}` : 'No action',
+ issues: passed ? [] : ['Grace period not respected - new users should not be messaged immediately'],
+ };
+ },
+ },
+
+ {
+ id: 'successful_conversion',
+ name: 'Successful Conversion Celebration',
+ description: 'User who responded and linked should get celebration',
+ setup: () => ({
+ userId: 'test_converted',
+ persona: TEST_PERSONAS[1],
+ events: [
+ { type: 'slack_message', timestamp: daysAgo(3), channel: '#general', content: 'Hello!' },
+ { type: 'outreach_response', timestamp: daysAgo(2), content: 'Done, linked!' },
+ { type: 'dashboard_login', timestamp: daysAgo(1) },
+ ],
+ outreachHistory: [{
+ id: 'outreach_1',
+ type: 'account_link',
+ variant: 'direct_transparent',
+ sentAt: daysAgo(3),
+ responded: true,
+ response: 'Done, linked!',
+ sentiment: 'positive',
+ }],
+ currentStatus: {
+ isLinked: true,
+ isMember: false,
+ daysSinceJoined: 7,
+ daysSinceLastActivity: 1,
+ daysSinceLastOutreach: 3,
+ totalActivityCount: 3,
+ },
+ existingActionItems: [],
+ }),
+ expectedAction: { type: 'celebration', reason: 'User converted after outreach' },
+ validate: (state, action) => {
+ const passed = action?.type === 'celebration';
+ return {
+ passed,
+ expected: 'celebration',
+ actual: action?.type || 'none',
+ issues: passed ? [] : ['Conversion should trigger celebration action item'],
+ };
+ },
+ },
+
+ {
+ id: 'no_response_too_early',
+ name: 'No Response Too Early',
+ description: 'Should not create nudge before threshold days',
+ setup: () => ({
+ userId: 'test_too_early',
+ persona: TEST_PERSONAS[0],
+ events: [],
+ outreachHistory: [{
+ id: 'outreach_1',
+ type: 'account_link',
+ variant: 'direct_transparent',
+ sentAt: daysAgo(1), // Only 1 day ago
+ responded: false,
+ }],
+ currentStatus: {
+ isLinked: false,
+ isMember: false,
+ daysSinceJoined: 10,
+ daysSinceLastActivity: 1,
+ daysSinceLastOutreach: 1,
+ totalActivityCount: 0,
+ },
+ existingActionItems: [],
+ }),
+ expectedAction: null,
+ validate: (state, action) => {
+ const passed = action === null;
+ return {
+ passed,
+ expected: 'No action (too early for nudge)',
+ actual: action ? `Created ${action.type}` : 'No action',
+ issues: passed ? [] : [`Nudge created after only ${state.currentStatus.daysSinceLastOutreach} days (threshold is ${ACTION_TRIGGER_CONFIG.NUDGE_DAYS})`],
+ };
+ },
+ },
+
+ {
+ id: 'no_response_nudge_time',
+ name: 'No Response - Nudge Time',
+ description: 'Should create nudge after threshold days with no activity',
+ setup: () => ({
+ userId: 'test_nudge',
+ persona: TEST_PERSONAS[0],
+ events: [],
+ outreachHistory: [{
+ id: 'outreach_1',
+ type: 'account_link',
+ variant: 'direct_transparent',
+ sentAt: daysAgo(4), // Past threshold
+ responded: false,
+ }],
+ currentStatus: {
+ isLinked: false,
+ isMember: false,
+ daysSinceJoined: 14,
+ daysSinceLastActivity: 4,
+ daysSinceLastOutreach: 4,
+ totalActivityCount: 0,
+ },
+ existingActionItems: [],
+ }),
+ expectedAction: { type: 'nudge', reason: 'No activity after threshold' },
+ validate: (state, action) => {
+ const passed = action?.type === 'nudge';
+ return {
+ passed,
+ expected: 'nudge',
+ actual: action?.type || 'none',
+ issues: passed ? [] : ['Nudge should be created after 3+ days with no activity'],
+ };
+ },
+ },
+
+ {
+ id: 'active_but_no_response_momentum',
+ name: 'Active But No Response - Momentum',
+ description: 'User is active but hasn\'t responded directly - momentum opportunity',
+ setup: () => ({
+ userId: 'test_momentum',
+ persona: TEST_PERSONAS[1],
+ events: [
+ { type: 'slack_message', timestamp: daysAgo(2), channel: '#general', content: 'Interesting!' },
+ { type: 'slack_reaction', timestamp: daysAgo(2), content: '👀' },
+ { type: 'email_open', timestamp: daysAgo(1) },
+ { type: 'addie_conversation', timestamp: daysAgo(1), content: 'How does this work?' },
+ ],
+ outreachHistory: [{
+ id: 'outreach_1',
+ type: 'account_link',
+ variant: 'conversational',
+ sentAt: daysAgo(3),
+ responded: false,
+ }],
+ currentStatus: {
+ isLinked: false,
+ isMember: false,
+ daysSinceJoined: 10,
+ daysSinceLastActivity: 1,
+ daysSinceLastOutreach: 3,
+ totalActivityCount: 4,
+ },
+ existingActionItems: [],
+ }),
+ expectedAction: { type: 'momentum', reason: 'High activity without direct response' },
+ validate: (state, action) => {
+ const passed = action?.type === 'momentum';
+ return {
+ passed,
+ expected: 'momentum',
+ actual: action?.type || 'none',
+ issues: passed ? [] : ['Active users without direct response should trigger momentum opportunity'],
+ };
+ },
+ },
+
+ {
+ id: 'responded_not_converted',
+ name: 'Responded But Not Converted',
+ description: 'User responded positively but hasn\'t linked - warm lead',
+ setup: () => ({
+ userId: 'test_warm',
+ persona: TEST_PERSONAS[2],
+ events: [
+ { type: 'outreach_response', timestamp: daysAgo(2), content: 'Looks interesting!', sentiment: 'positive' },
+ ],
+ outreachHistory: [{
+ id: 'outreach_1',
+ type: 'account_link',
+ variant: 'direct_transparent',
+ sentAt: daysAgo(3),
+ responded: true,
+ response: 'Looks interesting!',
+ sentiment: 'positive',
+ }],
+ currentStatus: {
+ isLinked: false,
+ isMember: false,
+ daysSinceJoined: 14,
+ daysSinceLastActivity: 2,
+ daysSinceLastOutreach: 3,
+ totalActivityCount: 1,
+ },
+ existingActionItems: [],
+ }),
+ expectedAction: { type: 'warm_lead', reason: 'Responded without conversion' },
+ validate: (state, action) => {
+ const passed = action?.type === 'warm_lead';
+ return {
+ passed,
+ expected: 'warm_lead',
+ actual: action?.type || 'none',
+ issues: passed ? [] : ['User who responded but didn\'t convert should be a warm lead'],
+ };
+ },
+ },
+
+ {
+ id: 'existing_action_item_no_duplicate',
+ name: 'No Duplicate Action Items',
+ description: 'Should not create action if one already exists',
+ setup: () => ({
+ userId: 'test_no_duplicate',
+ persona: TEST_PERSONAS[0],
+ events: [],
+ outreachHistory: [{
+ id: 'outreach_1',
+ type: 'account_link',
+ variant: 'direct_transparent',
+ sentAt: daysAgo(5),
+ responded: false,
+ }],
+ currentStatus: {
+ isLinked: false,
+ isMember: false,
+ daysSinceJoined: 14,
+ daysSinceLastActivity: 5,
+ daysSinceLastOutreach: 5,
+ totalActivityCount: 0,
+ },
+ existingActionItems: [{
+ id: 'existing_1',
+ type: 'nudge',
+ status: 'open',
+ createdAt: daysAgo(2),
+ triggerId: 'outreach_1',
+ }],
+ }),
+ expectedAction: null,
+ validate: (state, action) => {
+ const passed = action === null;
+ return {
+ passed,
+ expected: 'No action (duplicate prevention)',
+ actual: action ? `Created ${action.type}` : 'No action',
+ issues: passed ? [] : ['Duplicate action item created when one already exists'],
+ };
+ },
+ },
+
+ {
+ id: 'some_activity_warm_lead',
+ name: 'Some Activity - Warm Lead',
+ description: 'User has some activity but below momentum threshold',
+ setup: () => ({
+ userId: 'test_some_activity',
+ persona: TEST_PERSONAS[0],
+ events: [
+ { type: 'slack_reaction', timestamp: daysAgo(1), content: '👍' },
+ { type: 'email_open', timestamp: daysAgo(2) },
+ ],
+ outreachHistory: [{
+ id: 'outreach_1',
+ type: 'account_link',
+ variant: 'brief_friendly',
+ sentAt: daysAgo(4),
+ responded: false,
+ }],
+ currentStatus: {
+ isLinked: false,
+ isMember: false,
+ daysSinceJoined: 10,
+ daysSinceLastActivity: 1,
+ daysSinceLastOutreach: 4,
+ totalActivityCount: 2,
+ },
+ existingActionItems: [],
+ }),
+ expectedAction: { type: 'warm_lead', reason: 'Some activity below momentum threshold' },
+ validate: (state, action) => {
+ const passed = action?.type === 'warm_lead';
+ return {
+ passed,
+ expected: 'warm_lead',
+ actual: action?.type || 'none',
+ issues: passed ? [] : ['Users with some activity should be warm leads'],
+ };
+ },
+ },
+];
+
+// Helper function
+function daysAgo(days: number): Date {
+ const date = new Date();
+ date.setDate(date.getDate() - days);
+ return date;
+}
+
+// Run all action trigger tests
+export function runActionTriggerTests(): {
+ total: number;
+ passed: number;
+ failed: number;
+ results: {
+ test: ActionTriggerTestCase;
+ state: SimulatedUserState;
+ action: TriggeredAction | null;
+ validation: ValidationResult;
+ }[];
+ criticalFailures: string[];
+} {
+ const results = ACTION_TRIGGER_TESTS.map(test => {
+ const state = test.setup();
+ const action = simulateMomentumCheck(state);
+ const validation = test.validate(state, action);
+
+ return {
+ test,
+ state,
+ action,
+ validation,
+ };
+ });
+
+ const passed = results.filter(r => r.validation.passed).length;
+ const failed = results.filter(r => !r.validation.passed).length;
+
+ // Identify critical failures (tests that could cause user frustration)
+ const criticalTestIds = ['new_user_grace_period', 'existing_action_item_no_duplicate', 'no_response_too_early'];
+ const criticalFailures = results
+ .filter(r => criticalTestIds.includes(r.test.id) && !r.validation.passed)
+ .map(r => `CRITICAL: ${r.test.name} - ${r.validation.issues.join(', ')}`);
+
+ return {
+ total: results.length,
+ passed,
+ failed,
+ results,
+ criticalFailures,
+ };
+}
+
+// Generate comprehensive test report
+export function generateActionTriggerReport(): string {
+ const { total, passed, failed, results, criticalFailures } = runActionTriggerTests();
+
+ let report = '# Action Trigger Test Report\n\n';
+ report += `## Summary\n`;
+ report += `- Total Tests: ${total}\n`;
+ report += `- Passed: ${passed}\n`;
+ report += `- Failed: ${failed}\n`;
+ report += `- Pass Rate: ${Math.round((passed / total) * 100)}%\n\n`;
+
+ if (criticalFailures.length > 0) {
+ report += `## ⚠️ Critical Failures\n`;
+ criticalFailures.forEach(f => {
+ report += `- ${f}\n`;
+ });
+ report += '\n';
+ }
+
+ report += `## Test Results\n\n`;
+
+ results.forEach(({ test, action, validation }) => {
+ const status = validation.passed ? '✅' : '❌';
+ report += `### ${status} ${test.name}\n`;
+ report += `**ID:** ${test.id}\n`;
+ report += `**Description:** ${test.description}\n`;
+ report += `**Expected:** ${validation.expected}\n`;
+ report += `**Actual:** ${validation.actual}\n`;
+
+ if (!validation.passed) {
+ report += `**Issues:**\n`;
+ validation.issues.forEach(issue => {
+ report += ` - ${issue}\n`;
+ });
+ }
+ report += '\n';
+ });
+
+ report += `## Configuration\n`;
+ report += `- Nudge Days: ${ACTION_TRIGGER_CONFIG.NUDGE_DAYS}\n`;
+ report += `- Warm Lead Days: ${ACTION_TRIGGER_CONFIG.WARM_LEAD_DAYS}\n`;
+ report += `- Momentum Threshold: ${ACTION_TRIGGER_CONFIG.MOMENTUM_THRESHOLD}\n`;
+ report += `- Rate Limit Days: ${ACTION_TRIGGER_CONFIG.RATE_LIMIT_DAYS}\n`;
+ report += `- Grace Period Hours: ${ACTION_TRIGGER_CONFIG.GRACE_PERIOD_HOURS}\n`;
+
+ return report;
+}
+
+// Test action triggers against user journey scenarios
+export function testActionTriggersForJourney(
+ journey: UserJourney
+): {
+ journey: UserJourney;
+ analysis: ReturnType;
+ simulatedActions: TriggeredAction[];
+ gaps: string[];
+ recommendations: string[];
+} {
+ const analysis = analyzeJourney(journey);
+
+ // Convert journey to simulated state at different points
+ const simulatedActions: TriggeredAction[] = [];
+ const gaps: string[] = [];
+
+ // Check what actions the momentum system would create
+ const outreachEvents = journey.events.filter(e => e.type === 'outreach_received');
+
+ outreachEvents.forEach((outreach, idx) => {
+ const outreachDate = outreach.timestamp;
+ const responseEvent = journey.events.find(
+ e => e.type === 'outreach_response' && e.timestamp > outreachDate
+ );
+
+ const activitySince = journey.events.filter(
+ e => e.timestamp > outreachDate && e.type !== 'outreach_received'
+ ).length;
+
+ const state: SimulatedUserState = {
+ userId: journey.persona.id,
+ persona: journey.persona,
+ events: journey.events.filter(e => e.timestamp > outreachDate),
+ outreachHistory: [{
+ id: `outreach_${idx}`,
+ type: 'account_link',
+ variant: (outreach.metadata?.variant as string) || 'direct_transparent',
+ sentAt: outreachDate,
+ responded: !!responseEvent,
+ response: responseEvent?.content,
+ sentiment: responseEvent?.sentiment,
+ }],
+ currentStatus: {
+ isLinked: journey.currentState.isLinked,
+ isMember: journey.currentState.isMember,
+ daysSinceJoined: Math.floor((Date.now() - journey.startDate.getTime()) / (1000 * 60 * 60 * 24)),
+ daysSinceLastActivity: 0,
+ daysSinceLastOutreach: Math.floor((Date.now() - outreachDate.getTime()) / (1000 * 60 * 60 * 24)),
+ totalActivityCount: activitySince,
+ },
+ existingActionItems: [],
+ };
+
+ const action = simulateMomentumCheck(state);
+ if (action) {
+ simulatedActions.push(action);
+ }
+ });
+
+ // Compare with journey analysis recommendations
+ analysis.recommendedActions.forEach(rec => {
+ const hasMatchingAction = simulatedActions.some(a => a.type === rec.type);
+ if (!hasMatchingAction) {
+ gaps.push(`Analysis recommends "${rec.type}" (${rec.reason}) but momentum check wouldn't create it`);
+ }
+ });
+
+ // Generate recommendations based on gaps
+ const recommendations: string[] = [];
+ if (gaps.length > 0) {
+ recommendations.push('Consider expanding momentum check triggers to catch:');
+ gaps.forEach(gap => recommendations.push(` - ${gap}`));
+ }
+
+ // Check for over-triggering
+ if (simulatedActions.length > analysis.recommendedActions.length) {
+ recommendations.push('Momentum check may be over-triggering - created more actions than journey analysis recommends');
+ }
+
+ return {
+ journey,
+ analysis,
+ simulatedActions,
+ gaps,
+ recommendations,
+ };
+}
+
+// Run journey-based tests for all scenarios
+export function runJourneyActionTests(): {
+ scenarios: JourneyScenario[];
+ results: ReturnType[];
+ overallGaps: string[];
+ overallRecommendations: string[];
+} {
+ const scenarios: JourneyScenario[] = [
+ 'ideal_conversion',
+ 'ghost',
+ 'tire_kicker',
+ 'skeptic_converted',
+ 'overwhelmed',
+ ];
+
+ const results = TEST_PERSONAS.flatMap(persona =>
+ scenarios.map(scenario => {
+ const journey = generateJourney(persona, scenario);
+ return testActionTriggersForJourney(journey);
+ })
+ );
+
+ // Aggregate gaps and recommendations
+ const allGaps = new Set();
+ const allRecs = new Set();
+
+ results.forEach(r => {
+ r.gaps.forEach(g => allGaps.add(g));
+ r.recommendations.forEach(rec => allRecs.add(rec));
+ });
+
+ return {
+ scenarios,
+ results,
+ overallGaps: Array.from(allGaps),
+ overallRecommendations: Array.from(allRecs),
+ };
+}
diff --git a/server/src/addie/testing/index.ts b/server/src/addie/testing/index.ts
new file mode 100644
index 0000000000..f4d2ff5f4a
--- /dev/null
+++ b/server/src/addie/testing/index.ts
@@ -0,0 +1,52 @@
+/**
+ * Outreach & Action Trigger Testing Framework
+ *
+ * Comprehensive testing tools for:
+ * 1. Red team scenarios - finding failure modes
+ * 2. Message variant comparison - optimizing copy
+ * 3. User journey simulation - realistic testing
+ * 4. Action trigger validation - ensuring proper timing
+ */
+
+// User Journey Simulation
+export {
+ UserPersona,
+ ActivityEvent,
+ UserJourney,
+ JourneyScenario,
+ JourneyAnalysis,
+ TEST_PERSONAS,
+ generateJourney,
+ analyzeJourney,
+ simulateResponse,
+ RED_TEAM_SCENARIOS as JOURNEY_RED_TEAM_SCENARIOS,
+} from './user-journey-simulator.js';
+
+// Outreach Scenarios & Red Team
+export {
+ CURRENT_VARIANTS,
+ IMPROVED_VARIANTS,
+ RED_TEAM_SCENARIOS,
+ RedTeamScenario,
+ ScenarioTestResult,
+ runRedTeamTests,
+ testVariantAgainstPersonas,
+ compareAllVariants,
+} from './outreach-scenarios.js';
+
+// Action Trigger Testing
+export {
+ ACTION_TRIGGER_TESTS,
+ runActionTriggerTests,
+ generateActionTriggerReport,
+ testActionTriggersForJourney,
+ runJourneyActionTests,
+} from './action-trigger-tests.js';
+
+// Sensitive Topic Detection Testing
+export {
+ SENSITIVE_TOPIC_SCENARIOS,
+ SensitiveTopicScenario,
+ runSensitiveTopicTests,
+ generateSensitiveTopicReport,
+} from './sensitive-topic-tests.js';
diff --git a/server/src/addie/testing/outreach-scenarios.ts b/server/src/addie/testing/outreach-scenarios.ts
new file mode 100644
index 0000000000..c210b5b79a
--- /dev/null
+++ b/server/src/addie/testing/outreach-scenarios.ts
@@ -0,0 +1,671 @@
+/**
+ * Outreach Scenario Testing
+ *
+ * Red team scenarios and edge cases for testing outreach effectiveness.
+ * Each scenario is designed to expose potential failure modes.
+ */
+
+import { UserPersona, TEST_PERSONAS, simulateResponse } from './user-journey-simulator.js';
+
+// Current production message variants
+export const CURRENT_VARIANTS = {
+ direct_transparent: {
+ id: 'direct_transparent',
+ tone: 'professional',
+ approach: 'direct',
+ template: `Hey {{user_name}}, we're trying to get all Slack members linked to their AgenticAdvertising.org accounts.
+
+Could you click here to link yours? {{link_url}}
+
+Takes about 30 seconds and gives you access to your member profile, working groups, and AI-assisted help.`,
+ },
+ brief_friendly: {
+ id: 'brief_friendly',
+ tone: 'casual',
+ approach: 'minimal',
+ template: `Hey {{user_name}}! Quick favor - can you link your Slack to your AAO account?
+
+{{link_url}}
+
+Helps us keep the community connected. Thanks!`,
+ },
+ conversational: {
+ id: 'conversational',
+ tone: 'casual',
+ approach: 'conversational',
+ template: `Hi {{user_name}}, I noticed your Slack isn't linked to your AgenticAdvertising.org account yet.
+
+Here's the link to connect them: {{link_url}}
+
+Once linked, I can give you personalized help and you'll have access to your member dashboard and working groups.`,
+ },
+};
+
+// Improved variants based on analysis
+export const IMPROVED_VARIANTS = {
+ loss_framed: {
+ id: 'loss_framed',
+ tone: 'professional',
+ approach: 'loss_framing',
+ template: `{{user_name}} - Your AgenticAdvertising.org membership isn't connected to Slack yet, which means you're not seeing:
+
+- Working group updates in channels you're in
+- Your personalized event recommendations
+- Member directory access
+
+Link now (takes one click): {{link_url}}
+
+90% of active members connect within their first week.`,
+ },
+ peer_triggered: {
+ id: 'peer_triggered',
+ tone: 'professional',
+ approach: 'social_proof',
+ template: `{{user_name}} - I'm reaching out to the {{company_name}} team members who haven't linked their accounts yet.
+
+{{link_url}}
+
+This connects your Slack identity to your member profile so you can access working group resources, vote in governance, and show up correctly in the member directory.
+
+Most people complete it in under a minute.`,
+ },
+ friction_first: {
+ id: 'friction_first',
+ tone: 'professional',
+ approach: 'transparency',
+ template: `{{user_name}} - Quick account link request.
+
+Clicking this will connect your Slack to AgenticAdvertising.org: {{link_url}}
+
+What happens: You'll authorize the connection (no password needed), and you're done.
+
+What you get: Access to your member dashboard, working group tools, and the ability to interact with me for org-related questions.
+
+What we don't do: Spam you or share your data.`,
+ },
+ value_casual: {
+ id: 'value_casual',
+ tone: 'casual',
+ approach: 'value_first',
+ template: `Hey {{user_name}} - want to actually use your AgenticAdvertising.org membership?
+
+Link your Slack here: {{link_url}}
+
+Right now you're in the Slack but disconnected from the member tools - working groups, the directory, event RSVPs, etc. One click fixes it.`,
+ },
+ trigger_based: {
+ id: 'trigger_based',
+ tone: 'contextual',
+ approach: 'trigger',
+ template: `{{user_name}} - I saw you were interested in the {{context}} discussion.
+
+To join that working group or access meeting notes, you'll need to link your Slack to your member account: {{link_url}}
+
+Takes about 30 seconds. Let me know if you hit any issues.`,
+ },
+};
+
+/**
+ * Red Team Scenarios
+ * Each designed to expose a specific failure mode
+ */
+export interface RedTeamScenario {
+ id: string;
+ name: string;
+ description: string;
+ userContext: Partial & {
+ recentActivity?: string[];
+ sentimentHistory?: ('positive' | 'neutral' | 'negative')[];
+ outreachHistory?: { variant: string; response: string | null; daysAgo: number }[];
+ };
+ expectedBehavior: string;
+ actualRisk: 'high' | 'medium' | 'low';
+ testFunction: () => ScenarioTestResult;
+}
+
+export interface ScenarioTestResult {
+ passed: boolean;
+ issues: string[];
+ recommendations: string[];
+}
+
+export const RED_TEAM_SCENARIOS: RedTeamScenario[] = [
+ {
+ id: 'spam_within_week',
+ name: 'Multiple Messages Within Rate Limit',
+ description: 'User receives DM when they were contacted less than 7 days ago',
+ userContext: {
+ name: 'Test User',
+ outreachHistory: [
+ { variant: 'direct_transparent', response: null, daysAgo: 3 },
+ ],
+ },
+ expectedBehavior: 'System should NOT send another message - rate limit is 7 days',
+ actualRisk: 'high',
+ testFunction: () => {
+ // Check if rate limiting would prevent this
+ const daysSinceLastOutreach = 3;
+ const rateLimitDays = 7;
+
+ if (daysSinceLastOutreach < rateLimitDays) {
+ return {
+ passed: true,
+ issues: [],
+ recommendations: ['Rate limiting working correctly'],
+ };
+ }
+ return {
+ passed: false,
+ issues: ['Rate limiting not enforced'],
+ recommendations: ['Ensure RATE_LIMIT_DAYS constant is checked before sending'],
+ };
+ },
+ },
+
+ {
+ id: 'explicit_no_ignored',
+ name: 'User Explicitly Said No',
+ description: 'User previously responded "not interested" but gets follow-up',
+ userContext: {
+ name: 'Annoyed User',
+ outreachHistory: [
+ { variant: 'direct_transparent', response: 'No thanks, not interested', daysAgo: 14 },
+ ],
+ sentimentHistory: ['negative'],
+ },
+ expectedBehavior: 'System should NOT send follow-up without human review',
+ actualRisk: 'high',
+ testFunction: () => {
+ // Now implemented via outreach_refusal_patterns table and canContactUser()
+ // Tests if "not interested" would be detected as a refusal
+ const testResponse = 'No thanks, not interested';
+ const refusalPatterns = ['not interested', 'no thanks'];
+
+ const wouldDetect = refusalPatterns.some(pattern =>
+ testResponse.toLowerCase().includes(pattern.toLowerCase())
+ );
+
+ if (wouldDetect) {
+ return {
+ passed: true,
+ issues: [],
+ recommendations: ['Refusal detection implemented via outreach_refusal_patterns table'],
+ };
+ }
+
+ return {
+ passed: false,
+ issues: [
+ 'Refusal pattern not detected',
+ ],
+ recommendations: [
+ 'Ensure refusal patterns are seeded in database',
+ ],
+ };
+ },
+ },
+
+ {
+ id: 'c_suite_casual_tone',
+ name: 'Wrong Tone for Executive',
+ description: 'C-suite executive receives "Hey! Quick favor..." message',
+ userContext: {
+ name: 'Jennifer Martinez',
+ role: 'executive',
+ company: { name: 'Omnicom Media', type: 'agency', size: 'enterprise', adtechMaturity: 'high' },
+ },
+ expectedBehavior: 'System should use professional tone for executives',
+ actualRisk: 'medium',
+ testFunction: () => {
+ // Now partially implemented via target_seniority on outreach_variants
+ // Executive Brief variant targets {executive, senior}
+ const hasExecutiveVariant = true; // We added 'Executive Brief' variant
+ const hasTargetSeniority = true; // target_seniority column added
+
+ if (hasExecutiveVariant && hasTargetSeniority) {
+ return {
+ passed: true,
+ issues: [],
+ recommendations: [
+ 'Executive Brief variant added with target_seniority = {executive, senior}',
+ 'Variant selection logic should filter by target_seniority',
+ 'detected_seniority column added to slack_user_mappings for targeting',
+ ],
+ };
+ }
+
+ return {
+ passed: false,
+ issues: [
+ 'Variant selection doesn\'t yet filter by seniority',
+ ],
+ recommendations: [
+ 'Implement seniority-based variant filtering in variant selection',
+ ],
+ };
+ },
+ },
+
+ {
+ id: 'new_member_immediate_dm',
+ name: 'Immediate DM to New Slack Member',
+ description: 'User joins Slack and gets DM within minutes',
+ userContext: {
+ name: 'New User',
+ recentActivity: ['joined_slack_5_minutes_ago'],
+ },
+ expectedBehavior: 'System should wait for natural engagement before DM',
+ actualRisk: 'medium',
+ testFunction: () => {
+ // Now implemented via canContactUser() with GRACE_PERIOD_HOURS = 24
+ // The slack_joined_at column tracks when users joined
+ const GRACE_PERIOD_HOURS = 24;
+ const gracePeriodImplemented = GRACE_PERIOD_HOURS >= 24;
+
+ if (gracePeriodImplemented) {
+ return {
+ passed: true,
+ issues: [],
+ recommendations: [
+ `Grace period of ${GRACE_PERIOD_HOURS} hours implemented via canContactUser()`,
+ 'slack_joined_at column added to slack_user_mappings',
+ ],
+ };
+ }
+
+ return {
+ passed: false,
+ issues: [
+ 'Grace period not implemented or too short',
+ ],
+ recommendations: [
+ 'Increase GRACE_PERIOD_HOURS to at least 24',
+ ],
+ };
+ },
+ },
+
+ {
+ id: 'competitor_employee',
+ name: 'Message to Competitor Employee',
+ description: 'Employee of competing organization gets membership push',
+ userContext: {
+ name: 'Competitor Spy',
+ company: { name: 'Competing Standards Body', type: 'data_provider', size: 'enterprise', adtechMaturity: 'high' },
+ },
+ expectedBehavior: 'System should flag or handle competitors differently',
+ actualRisk: 'low',
+ testFunction: () => {
+ return {
+ passed: false,
+ issues: [
+ 'No competitor detection mechanism',
+ 'Same messaging sent to potential competitors',
+ 'Could create awkward situations',
+ ],
+ recommendations: [
+ 'Create competitor company blocklist',
+ 'Flag unknown company domains for review',
+ 'Consider different messaging for industry observers',
+ ],
+ };
+ },
+ },
+
+ {
+ id: 'tire_kicker_escalation',
+ name: 'Tire-Kicker Not Identified',
+ description: 'User has asked 5+ questions over 3 weeks but hasn\'t converted',
+ userContext: {
+ name: 'Curious but not converting',
+ recentActivity: [
+ 'addie_conversation_about_membership_cost',
+ 'addie_conversation_about_who_members_are',
+ 'addie_conversation_about_case_studies',
+ 'addie_conversation_about_governance',
+ 'addie_conversation_about_technical_implementation',
+ ],
+ outreachHistory: [
+ { variant: 'direct_transparent', response: 'Interesting, let me think about it', daysAgo: 14 },
+ ],
+ },
+ expectedBehavior: 'System should identify tire-kicker pattern and escalate to human',
+ actualRisk: 'medium',
+ testFunction: () => {
+ // Now implemented via checkForTireKickers() in momentum-check.ts
+ // Thresholds: 3+ conversations, 14+ days active, not linked
+ const QUESTION_THRESHOLD = 3;
+ const DAYS_ACTIVE_THRESHOLD = 14;
+ const implementedInMomentumCheck = true;
+
+ if (implementedInMomentumCheck) {
+ return {
+ passed: true,
+ issues: [],
+ recommendations: [
+ `Tire-kicker detection implemented with thresholds: ${QUESTION_THRESHOLD}+ conversations, ${DAYS_ACTIVE_THRESHOLD}+ days`,
+ 'Creates warm_lead action item with pattern: tire_kicker in context',
+ 'Runs as part of momentum check job',
+ ],
+ };
+ }
+
+ return {
+ passed: false,
+ issues: [
+ 'Tire-kicker detection not implemented',
+ ],
+ recommendations: [
+ 'Add checkForTireKickers() to momentum check job',
+ ],
+ };
+ },
+ },
+
+ {
+ id: 'busy_response_not_scheduled',
+ name: 'User Said "Later" - No Follow-up Scheduled',
+ description: 'User responded "busy this month, ping me later" but system doesn\'t schedule',
+ userContext: {
+ name: 'Overwhelmed Professional',
+ outreachHistory: [
+ { variant: 'conversational', response: 'Thanks! Super busy right now. Can you remind me next month?', daysAgo: 7 },
+ ],
+ },
+ expectedBehavior: 'System should create scheduled follow-up action item',
+ actualRisk: 'medium',
+ testFunction: () => {
+ // Now implemented via outreach_defer_patterns table and markOutreachRespondedWithAnalysis()
+ const testResponse = 'Thanks! Super busy right now. Can you remind me next month?';
+ const deferPatterns = [
+ { pattern: 'next month', days: 30 },
+ { pattern: 'remind me', days: 30 },
+ ];
+
+ const matchedPattern = deferPatterns.find(p =>
+ testResponse.toLowerCase().includes(p.pattern.toLowerCase())
+ );
+
+ if (matchedPattern) {
+ return {
+ passed: true,
+ issues: [],
+ recommendations: [
+ `Defer pattern "${matchedPattern.pattern}" detected - ${matchedPattern.days} day follow-up`,
+ 'outreach_defer_patterns table seeded with common patterns',
+ 'markOutreachRespondedWithAnalysis() sets follow_up_date automatically',
+ ],
+ };
+ }
+
+ return {
+ passed: false,
+ issues: [
+ 'Defer pattern not detected',
+ ],
+ recommendations: [
+ 'Ensure defer patterns are seeded in database',
+ ],
+ };
+ },
+ },
+
+ {
+ id: 'multiple_same_company_conflict',
+ name: 'Conflicting Messages to Colleagues',
+ description: 'Two people from same company get different messaging/info',
+ userContext: {
+ name: 'Team member A',
+ company: { name: 'Shared Company', type: 'publisher', size: 'mid_market', adtechMaturity: 'medium' },
+ },
+ expectedBehavior: 'System should coordinate messaging within organizations',
+ actualRisk: 'low',
+ testFunction: () => {
+ return {
+ passed: false,
+ issues: [
+ 'No organization-level messaging coordination',
+ 'Colleague A might get casual, colleague B gets professional',
+ 'Could share conflicting information',
+ ],
+ recommendations: [
+ 'Track organization-level outreach history',
+ 'Use consistent variant within same org',
+ 'Consider org-level campaigns vs. individual',
+ ],
+ };
+ },
+ },
+
+ {
+ id: 'churned_member_returns',
+ name: 'Previously Churned Member Returns',
+ description: 'User who cancelled membership is back in Slack',
+ userContext: {
+ name: 'Returning User',
+ sentimentHistory: ['positive', 'neutral', 'negative'], // Got progressively less engaged
+ },
+ expectedBehavior: 'System should recognize history and handle differently',
+ actualRisk: 'medium',
+ testFunction: () => {
+ return {
+ passed: false,
+ issues: [
+ 'No mechanism to detect previous membership',
+ 'Would send same new-user outreach',
+ 'Ignores relationship history',
+ ],
+ recommendations: [
+ 'Cross-reference with membership history',
+ 'Create "win-back" messaging variant',
+ 'Acknowledge previous relationship in outreach',
+ ],
+ };
+ },
+ },
+
+ {
+ id: 'phishing_suspicion',
+ name: 'Message Looks Like Phishing',
+ description: 'User suspects DM is phishing because it asks for account link',
+ userContext: {
+ name: 'Security-Conscious User',
+ role: 'developer',
+ },
+ expectedBehavior: 'Message should include legitimacy signals',
+ actualRisk: 'medium',
+ testFunction: () => {
+ // Check if current variants include legitimacy signals
+ // The Friction-First variant specifically addresses this with transparency
+ const hasLegitimacySignals = Object.values(CURRENT_VARIANTS).some(v =>
+ v.template.includes('official') ||
+ v.template.includes('verify') ||
+ v.template.includes('agenticadvertising.org') ||
+ v.template.includes('AgenticAdvertising.org')
+ );
+
+ // Also check improved variants
+ const hasSecurityVariant = true; // 'Friction-First (Security Conscious)' variant added
+
+ if (hasLegitimacySignals || hasSecurityVariant) {
+ return {
+ passed: true,
+ issues: [],
+ recommendations: [
+ 'AgenticAdvertising.org mentioned in message templates',
+ 'Friction-First variant explicitly addresses security concerns',
+ 'Target developers with the transparency-focused variant',
+ ],
+ };
+ }
+
+ return {
+ passed: false,
+ issues: [
+ 'No legitimacy signals in messages',
+ 'Link URL is the only proof of authenticity',
+ ],
+ recommendations: [
+ 'Include domain name in messages',
+ 'Use Friction-First variant for security-conscious users',
+ ],
+ };
+ },
+ },
+];
+
+/**
+ * Run all red team scenarios and generate report
+ */
+export function runRedTeamTests(): {
+ totalScenarios: number;
+ passed: number;
+ failed: number;
+ results: { scenario: RedTeamScenario; result: ScenarioTestResult }[];
+ criticalIssues: string[];
+ recommendations: string[];
+} {
+ const results = RED_TEAM_SCENARIOS.map(scenario => ({
+ scenario,
+ result: scenario.testFunction(),
+ }));
+
+ const passed = results.filter(r => r.result.passed).length;
+ const failed = results.filter(r => !r.result.passed).length;
+
+ // Collect all unique issues and recommendations
+ const allIssues = new Set();
+ const allRecs = new Set();
+
+ results.forEach(r => {
+ r.result.issues.forEach(i => allIssues.add(i));
+ r.result.recommendations.forEach(rec => allRecs.add(rec));
+ });
+
+ // Prioritize critical issues (from high-risk scenarios)
+ const criticalIssues = results
+ .filter(r => r.scenario.actualRisk === 'high' && !r.result.passed)
+ .flatMap(r => r.result.issues);
+
+ return {
+ totalScenarios: RED_TEAM_SCENARIOS.length,
+ passed,
+ failed,
+ results,
+ criticalIssues,
+ recommendations: Array.from(allRecs),
+ };
+}
+
+/**
+ * Test a specific message variant against all personas
+ */
+export function testVariantAgainstPersonas(
+ variant: keyof typeof CURRENT_VARIANTS | keyof typeof IMPROVED_VARIANTS
+): {
+ variant: string;
+ results: {
+ persona: string;
+ responds: boolean;
+ response?: string;
+ sentiment: 'positive' | 'neutral' | 'negative';
+ conversionLikelihood: 'high' | 'medium' | 'low';
+ }[];
+ overallEffectiveness: number;
+} {
+ const variantData = { ...CURRENT_VARIANTS, ...IMPROVED_VARIANTS }[variant];
+
+ const results = TEST_PERSONAS.map(persona => {
+ const simResult = simulateResponse(
+ persona,
+ variantData.template,
+ variantData.approach as 'direct_transparent' | 'brief_friendly' | 'conversational'
+ );
+
+ // Calculate conversion likelihood based on response
+ let conversionLikelihood: 'high' | 'medium' | 'low' = 'low';
+ if (simResult.responds && simResult.sentiment === 'positive') {
+ conversionLikelihood = 'high';
+ } else if (simResult.responds && simResult.sentiment === 'neutral') {
+ conversionLikelihood = 'medium';
+ }
+
+ return {
+ persona: persona.name,
+ ...simResult,
+ conversionLikelihood,
+ };
+ });
+
+ // Calculate overall effectiveness
+ const responseRate = results.filter(r => r.responds).length / results.length;
+ const positiveRate = results.filter(r => r.sentiment === 'positive').length / results.length;
+ const overallEffectiveness = Math.round((responseRate * 0.4 + positiveRate * 0.6) * 100);
+
+ return {
+ variant,
+ results,
+ overallEffectiveness,
+ };
+}
+
+/**
+ * Generate a comparison report of all variants
+ */
+export function compareAllVariants(): {
+ rankings: { variant: string; effectiveness: number; strengths: string[]; weaknesses: string[] }[];
+ recommendation: string;
+} {
+ const allVariants = { ...CURRENT_VARIANTS, ...IMPROVED_VARIANTS };
+ const variantKeys = Object.keys(allVariants);
+
+ const rankings = variantKeys.map(key => {
+ const test = testVariantAgainstPersonas(key as keyof typeof allVariants);
+
+ // Analyze strengths and weaknesses
+ const strengths: string[] = [];
+ const weaknesses: string[] = [];
+
+ // Check response patterns
+ const executiveResponse = test.results.find(r => r.persona === 'Jennifer Martinez');
+ const developerResponse = test.results.find(r => r.persona === 'Marcus Johnson');
+ const skepticResponse = test.results.find(r => r.persona === 'Jennifer Martinez'); // Also skeptic
+
+ if (executiveResponse?.responds && executiveResponse.sentiment !== 'negative') {
+ strengths.push('Works with executives');
+ } else {
+ weaknesses.push('May not resonate with executives');
+ }
+
+ if (developerResponse?.responds && developerResponse.sentiment === 'positive') {
+ strengths.push('Effective with technical users');
+ }
+
+ if (test.overallEffectiveness > 60) {
+ strengths.push('High overall response rate');
+ } else if (test.overallEffectiveness < 40) {
+ weaknesses.push('Low overall response rate');
+ }
+
+ return {
+ variant: key,
+ effectiveness: test.overallEffectiveness,
+ strengths,
+ weaknesses,
+ };
+ });
+
+ // Sort by effectiveness
+ rankings.sort((a, b) => b.effectiveness - a.effectiveness);
+
+ // Generate recommendation
+ const topVariant = rankings[0];
+ const recommendation = `Recommended variant: "${topVariant.variant}" with ${topVariant.effectiveness}% estimated effectiveness. ` +
+ `Strengths: ${topVariant.strengths.join(', ') || 'None identified'}. ` +
+ `Consider A/B testing against current variants before full rollout.`;
+
+ return {
+ rankings,
+ recommendation,
+ };
+}
diff --git a/server/src/addie/testing/run-tests.ts b/server/src/addie/testing/run-tests.ts
new file mode 100644
index 0000000000..9d96d61ccf
--- /dev/null
+++ b/server/src/addie/testing/run-tests.ts
@@ -0,0 +1,347 @@
+#!/usr/bin/env npx tsx
+/**
+ * Outreach & Action Trigger Test Runner
+ *
+ * Runs all tests and generates a comprehensive report.
+ * Execute with: npx tsx server/src/addie/testing/run-tests.ts
+ */
+
+import {
+ runRedTeamTests,
+ compareAllVariants,
+ testVariantAgainstPersonas,
+ CURRENT_VARIANTS,
+ IMPROVED_VARIANTS,
+} from './outreach-scenarios.js';
+
+import {
+ TEST_PERSONAS,
+ generateJourney,
+ analyzeJourney,
+ JourneyScenario,
+} from './user-journey-simulator.js';
+
+import {
+ runActionTriggerTests,
+ generateActionTriggerReport,
+ runJourneyActionTests,
+} from './action-trigger-tests.js';
+
+import {
+ runSensitiveTopicTests,
+ generateSensitiveTopicReport,
+} from './sensitive-topic-tests.js';
+
+interface TestSuiteResult {
+ name: string;
+ passed: number;
+ failed: number;
+ criticalIssues: string[];
+ recommendations: string[];
+}
+
+function printHeader(title: string): void {
+ console.log('\n' + '='.repeat(60));
+ console.log(` ${title}`);
+ console.log('='.repeat(60) + '\n');
+}
+
+function printSubheader(title: string): void {
+ console.log('\n' + '-'.repeat(40));
+ console.log(` ${title}`);
+ console.log('-'.repeat(40) + '\n');
+}
+
+function printStatus(passed: boolean, message: string): void {
+ const icon = passed ? '✅' : '❌';
+ console.log(`${icon} ${message}`);
+}
+
+async function main(): Promise {
+ console.log('\n🧪 OUTREACH & ACTION TRIGGER TEST SUITE\n');
+ console.log('Running comprehensive tests to validate outreach effectiveness');
+ console.log('and action item trigger accuracy.\n');
+
+ const results: TestSuiteResult[] = [];
+
+ // ============================================
+ // 1. RED TEAM SCENARIOS
+ // ============================================
+ printHeader('1. RED TEAM SCENARIOS');
+
+ const redTeamResults = runRedTeamTests();
+
+ console.log(`Total Scenarios: ${redTeamResults.totalScenarios}`);
+ console.log(`Passed: ${redTeamResults.passed}`);
+ console.log(`Failed: ${redTeamResults.failed}`);
+ console.log(`Pass Rate: ${Math.round((redTeamResults.passed / redTeamResults.totalScenarios) * 100)}%`);
+
+ if (redTeamResults.criticalIssues.length > 0) {
+ printSubheader('⚠️ CRITICAL ISSUES');
+ redTeamResults.criticalIssues.forEach(issue => {
+ console.log(` • ${issue}`);
+ });
+ }
+
+ printSubheader('Scenario Results');
+ redTeamResults.results.forEach(({ scenario, result }) => {
+ printStatus(result.passed, `${scenario.name} (${scenario.actualRisk} risk)`);
+ if (!result.passed) {
+ result.issues.forEach(issue => {
+ console.log(` └─ ${issue}`);
+ });
+ }
+ });
+
+ results.push({
+ name: 'Red Team Scenarios',
+ passed: redTeamResults.passed,
+ failed: redTeamResults.failed,
+ criticalIssues: redTeamResults.criticalIssues,
+ recommendations: redTeamResults.recommendations,
+ });
+
+ // ============================================
+ // 2. MESSAGE VARIANT COMPARISON
+ // ============================================
+ printHeader('2. MESSAGE VARIANT COMPARISON');
+
+ const variantComparison = compareAllVariants();
+
+ printSubheader('Variant Rankings (by effectiveness)');
+ variantComparison.rankings.forEach((v, idx) => {
+ const label = v.variant in CURRENT_VARIANTS ? '(current)' : '(improved)';
+ console.log(`${idx + 1}. ${v.variant} ${label} - ${v.effectiveness}% effectiveness`);
+ if (v.strengths.length > 0) {
+ console.log(` Strengths: ${v.strengths.join(', ')}`);
+ }
+ if (v.weaknesses.length > 0) {
+ console.log(` Weaknesses: ${v.weaknesses.join(', ')}`);
+ }
+ });
+
+ printSubheader('Recommendation');
+ console.log(variantComparison.recommendation);
+
+ // ============================================
+ // 3. VARIANT vs PERSONA DEEP DIVE
+ // ============================================
+ printHeader('3. VARIANT vs PERSONA ANALYSIS');
+
+ const allVariants = { ...CURRENT_VARIANTS, ...IMPROVED_VARIANTS };
+ const topVariants = variantComparison.rankings.slice(0, 3);
+
+ topVariants.forEach(v => {
+ printSubheader(`"${v.variant}" detailed breakdown`);
+ const details = testVariantAgainstPersonas(v.variant as keyof typeof allVariants);
+
+ console.log('Persona responses:');
+ details.results.forEach(r => {
+ const responseIcon = r.responds ? '💬' : '🔇';
+ const sentimentIcon = r.sentiment === 'positive' ? '😊' : r.sentiment === 'negative' ? '😠' : '😐';
+ console.log(` ${responseIcon} ${sentimentIcon} ${r.persona}: ${r.responds ? r.response || '(responded)' : 'No response'}`);
+ });
+ });
+
+ // ============================================
+ // 4. ACTION TRIGGER TESTS
+ // ============================================
+ printHeader('4. ACTION TRIGGER TESTS');
+
+ const actionTriggerResults = runActionTriggerTests();
+
+ console.log(`Total Tests: ${actionTriggerResults.total}`);
+ console.log(`Passed: ${actionTriggerResults.passed}`);
+ console.log(`Failed: ${actionTriggerResults.failed}`);
+ console.log(`Pass Rate: ${Math.round((actionTriggerResults.passed / actionTriggerResults.total) * 100)}%`);
+
+ if (actionTriggerResults.criticalFailures.length > 0) {
+ printSubheader('⚠️ CRITICAL FAILURES');
+ actionTriggerResults.criticalFailures.forEach(failure => {
+ console.log(` • ${failure}`);
+ });
+ }
+
+ printSubheader('Test Results');
+ actionTriggerResults.results.forEach(({ test, validation }) => {
+ printStatus(validation.passed, test.name);
+ if (!validation.passed) {
+ validation.issues.forEach(issue => {
+ console.log(` └─ ${issue}`);
+ });
+ }
+ });
+
+ results.push({
+ name: 'Action Trigger Tests',
+ passed: actionTriggerResults.passed,
+ failed: actionTriggerResults.failed,
+ criticalIssues: actionTriggerResults.criticalFailures,
+ recommendations: [],
+ });
+
+ // ============================================
+ // 5. SENSITIVE TOPIC DETECTION TESTS
+ // ============================================
+ printHeader('5. SENSITIVE TOPIC DETECTION (JOURNALIST-PROOFING)');
+
+ try {
+ const sensitiveTopicResults = await runSensitiveTopicTests();
+
+ console.log(`Total Scenarios: ${sensitiveTopicResults.passed + sensitiveTopicResults.failed}`);
+ console.log(`Passed: ${sensitiveTopicResults.passed}`);
+ console.log(`Failed: ${sensitiveTopicResults.failed}`);
+ console.log(`Pass Rate: ${Math.round((sensitiveTopicResults.passed / (sensitiveTopicResults.passed + sensitiveTopicResults.failed)) * 100)}%`);
+
+ // Group failures by category
+ const failures = sensitiveTopicResults.results.filter(r => !r.passed);
+ if (failures.length > 0) {
+ printSubheader('⚠️ FAILED TESTS');
+ failures.forEach(f => {
+ console.log(` ✗ [${f.scenario.id}] ${f.scenario.name}`);
+ console.log(` Message: "${f.scenario.message.substring(0, 50)}..."`);
+ console.log(` Expected: deflect=${f.scenario.expectDeflect}, category=${f.scenario.expectCategory || 'any'}`);
+ console.log(` Actual: sensitive=${f.actual.isSensitive}, category=${f.actual.category}`);
+ });
+ }
+
+ // Critical issues
+ const criticalSensitiveIssues: string[] = [];
+ const highSeverityFailures = failures.filter(f =>
+ f.scenario.expectSeverity === 'high' || f.scenario.category === 'named_individual'
+ );
+ if (highSeverityFailures.length > 0) {
+ criticalSensitiveIssues.push(
+ `${highSeverityFailures.length} high-severity sensitive topics not being deflected`
+ );
+ }
+
+ // Check for false positives
+ const falsePositives = sensitiveTopicResults.results.filter(r =>
+ !r.scenario.expectDeflect && r.actual.isSensitive
+ );
+ if (falsePositives.length > 0) {
+ printSubheader('⚠️ FALSE POSITIVES (Safe questions being flagged)');
+ falsePositives.forEach(f => {
+ console.log(` • "${f.scenario.message}" flagged as ${f.actual.category}`);
+ });
+ }
+
+ results.push({
+ name: 'Sensitive Topic Detection',
+ passed: sensitiveTopicResults.passed,
+ failed: sensitiveTopicResults.failed,
+ criticalIssues: criticalSensitiveIssues,
+ recommendations: [],
+ });
+ } catch (error) {
+ console.log('⚠️ Could not run sensitive topic tests (requires database)');
+ console.log(` Error: ${error instanceof Error ? error.message : 'Unknown'}`);
+ console.log(' Run with database connected to test pattern matching.');
+ }
+
+ // ============================================
+ // 6. JOURNEY-BASED TESTS
+ // ============================================
+ printHeader('6. JOURNEY-BASED INTEGRATION TESTS');
+
+ const journeyResults = runJourneyActionTests();
+
+ printSubheader('Tested Scenarios');
+ console.log('Personas:', TEST_PERSONAS.map(p => p.name).join(', '));
+ console.log('Scenarios:', journeyResults.scenarios.join(', '));
+ console.log(`Total Journey Tests: ${journeyResults.results.length}`);
+
+ // Find journeys with gaps
+ const journeysWithGaps = journeyResults.results.filter(r => r.gaps.length > 0);
+ if (journeysWithGaps.length > 0) {
+ printSubheader('Gaps Found');
+ journeysWithGaps.forEach(r => {
+ console.log(`\n${r.journey.persona.name} - ${r.journey.currentState.lifecycleStage}:`);
+ r.gaps.forEach(gap => {
+ console.log(` • ${gap}`);
+ });
+ });
+ }
+
+ if (journeyResults.overallRecommendations.length > 0) {
+ printSubheader('Overall Recommendations');
+ journeyResults.overallRecommendations.forEach(rec => {
+ console.log(` • ${rec}`);
+ });
+ }
+
+ // ============================================
+ // FINAL SUMMARY
+ // ============================================
+ printHeader('FINAL SUMMARY');
+
+ let totalPassed = 0;
+ let totalFailed = 0;
+ const allCriticalIssues: string[] = [];
+
+ results.forEach(r => {
+ totalPassed += r.passed;
+ totalFailed += r.failed;
+ allCriticalIssues.push(...r.criticalIssues);
+ });
+
+ console.log('Test Suite Results:');
+ results.forEach(r => {
+ const pct = Math.round((r.passed / (r.passed + r.failed)) * 100);
+ const status = r.failed === 0 ? '✅' : r.criticalIssues.length > 0 ? '🚨' : '⚠️';
+ console.log(` ${status} ${r.name}: ${r.passed}/${r.passed + r.failed} (${pct}%)`);
+ });
+
+ console.log(`\nOverall: ${totalPassed}/${totalPassed + totalFailed} tests passed`);
+
+ if (allCriticalIssues.length > 0) {
+ console.log('\n🚨 CRITICAL ISSUES TO ADDRESS:');
+ allCriticalIssues.forEach(issue => {
+ console.log(` • ${issue}`);
+ });
+ }
+
+ // ============================================
+ // ACTIONABLE NEXT STEPS
+ // ============================================
+ printHeader('ACTIONABLE NEXT STEPS');
+
+ console.log('Based on test results, recommended priorities:\n');
+
+ // Priority 1: Critical issues
+ if (allCriticalIssues.length > 0) {
+ console.log('🔴 HIGH PRIORITY (Critical Issues):');
+ allCriticalIssues.slice(0, 3).forEach((issue, i) => {
+ console.log(` ${i + 1}. ${issue}`);
+ });
+ console.log();
+ }
+
+ // Priority 2: Message improvements
+ const currentBest = variantComparison.rankings.find(v => v.variant in CURRENT_VARIANTS);
+ const improvedBest = variantComparison.rankings.find(v => !(v.variant in CURRENT_VARIANTS));
+
+ if (improvedBest && currentBest && improvedBest.effectiveness > currentBest.effectiveness) {
+ console.log('🟡 MEDIUM PRIORITY (Message Optimization):');
+ console.log(` Consider A/B testing "${improvedBest.variant}" variant`);
+ console.log(` Potential improvement: ${improvedBest.effectiveness - currentBest.effectiveness}% effectiveness gain`);
+ console.log();
+ }
+
+ // Priority 3: Gap fixes
+ if (journeyResults.overallGaps.length > 0) {
+ console.log('🟢 LOWER PRIORITY (Coverage Gaps):');
+ journeyResults.overallGaps.slice(0, 2).forEach((gap, i) => {
+ console.log(` ${i + 1}. ${gap}`);
+ });
+ console.log();
+ }
+
+ console.log('=' .repeat(60));
+ console.log(' Test run complete');
+ console.log('=' .repeat(60) + '\n');
+}
+
+// Run if executed directly
+main().catch(console.error);
diff --git a/server/src/addie/testing/sensitive-topic-tests.ts b/server/src/addie/testing/sensitive-topic-tests.ts
new file mode 100644
index 0000000000..2e7bff9763
--- /dev/null
+++ b/server/src/addie/testing/sensitive-topic-tests.ts
@@ -0,0 +1,433 @@
+/**
+ * Sensitive Topic Detection Tests
+ *
+ * Test scenarios for journalist-proofing Addie
+ */
+
+export interface SensitiveTopicScenario {
+ id: string;
+ name: string;
+ category: string;
+ message: string;
+ expectDeflect: boolean;
+ expectCategory?: string;
+ expectSeverity?: 'high' | 'medium' | 'low';
+ notes?: string;
+}
+
+/**
+ * Test scenarios for sensitive topic detection
+ */
+export const SENSITIVE_TOPIC_SCENARIOS: SensitiveTopicScenario[] = [
+ // VULNERABLE POPULATIONS
+ {
+ id: 'vuln-1',
+ name: 'Direct children targeting question',
+ category: 'vulnerable_populations',
+ message: 'Could AdCP be used to target advertisements to children?',
+ expectDeflect: true,
+ expectCategory: 'vulnerable_populations',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'vuln-2',
+ name: 'Youth marketing gotcha',
+ category: 'vulnerable_populations',
+ message: 'What safeguards exist to prevent targeting vulnerable youth with gambling ads?',
+ expectDeflect: true,
+ expectCategory: 'vulnerable_populations',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'vuln-3',
+ name: 'Predatory advertising concern',
+ category: 'vulnerable_populations',
+ message: 'How does AdCP prevent predatory advertising practices?',
+ expectDeflect: true,
+ expectCategory: 'vulnerable_populations',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'vuln-4',
+ name: 'Teen targeting',
+ category: 'vulnerable_populations',
+ message: 'Can advertisers use AdCP to specifically reach teens on social platforms?',
+ expectDeflect: true,
+ expectCategory: 'vulnerable_populations',
+ expectSeverity: 'high',
+ },
+
+ // NAMED INDIVIDUALS
+ {
+ id: 'named-1',
+ name: 'Direct Brian O\'Kelley question',
+ category: 'named_individual',
+ message: 'What does Brian O\'Kelley think about the current state of programmatic advertising?',
+ expectDeflect: true,
+ expectCategory: 'named_individual',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'named-2',
+ name: 'O\'Kelley without first name',
+ category: 'named_individual',
+ message: 'Can you tell me about O\'Kelley\'s position on data privacy?',
+ expectDeflect: true,
+ expectCategory: 'named_individual',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'named-3',
+ name: 'Founder question',
+ category: 'named_individual',
+ message: 'What motivated the founder to start this organization?',
+ expectDeflect: true,
+ expectCategory: 'named_individual',
+ expectSeverity: 'medium',
+ },
+ {
+ id: 'named-4',
+ name: 'CEO statement request',
+ category: 'named_individual',
+ message: 'Has the CEO made any statements about AI regulation?',
+ expectDeflect: true,
+ expectCategory: 'named_individual',
+ expectSeverity: 'medium',
+ },
+
+ // ORGANIZATION POSITION SEEKING
+ {
+ id: 'org-1',
+ name: 'Official position request',
+ category: 'organization_position',
+ message: 'What is the official position of AgenticAdvertising.org on third-party cookies?',
+ expectDeflect: true,
+ expectCategory: 'organization_position',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'org-2',
+ name: 'On the record statement',
+ category: 'organization_position',
+ message: 'Can I get a statement on the record about your privacy practices?',
+ expectDeflect: true,
+ expectCategory: 'organization_position',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'org-3',
+ name: 'What does AAO think',
+ category: 'organization_position',
+ message: 'What does AgenticAdvertising.org think about Google\'s Privacy Sandbox?',
+ expectDeflect: true,
+ expectCategory: 'organization_position',
+ expectSeverity: 'high',
+ },
+
+ // MEDIA INQUIRY
+ {
+ id: 'media-1',
+ name: 'Journalist self-identification',
+ category: 'media_inquiry',
+ message: 'Hi, I\'m a journalist at TechCrunch. Can you tell me about AdCP?',
+ expectDeflect: true,
+ expectCategory: 'media_inquiry',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'media-2',
+ name: 'Story request',
+ category: 'media_inquiry',
+ message: 'I\'m working on a story about AI in advertising. Can I quote you?',
+ expectDeflect: true,
+ expectCategory: 'media_inquiry',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'media-3',
+ name: 'Reporter identification',
+ category: 'media_inquiry',
+ message: 'I\'m a reporter covering ad tech. What\'s your take on the industry?',
+ expectDeflect: true,
+ expectCategory: 'media_inquiry',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'media-4',
+ name: 'Quote request',
+ category: 'media_inquiry',
+ message: 'Can I quote you on this for my article?',
+ expectDeflect: true,
+ expectCategory: 'media_inquiry',
+ expectSeverity: 'high',
+ },
+
+ // POLITICAL
+ {
+ id: 'political-1',
+ name: 'Political advertising question',
+ category: 'political',
+ message: 'How does AdCP handle political advertising targeting?',
+ expectDeflect: true,
+ expectCategory: 'political',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'political-2',
+ name: 'Election interference concern',
+ category: 'political',
+ message: 'Could AdCP be used to influence election outcomes?',
+ expectDeflect: true,
+ expectCategory: 'political',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'political-3',
+ name: 'FTC regulation question',
+ category: 'political',
+ message: 'What\'s your response to the FTC\'s proposed regulations on AI advertising?',
+ expectDeflect: true,
+ expectCategory: 'political',
+ expectSeverity: 'high',
+ },
+
+ // PRIVACY/SURVEILLANCE
+ {
+ id: 'privacy-1',
+ name: 'Surveillance concern',
+ category: 'privacy_surveillance',
+ message: 'Isn\'t this just surveillance capitalism with extra steps?',
+ expectDeflect: true,
+ expectCategory: 'privacy_surveillance',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'privacy-2',
+ name: 'Tracking without consent',
+ category: 'privacy_surveillance',
+ message: 'How do you track users without their explicit consent?',
+ expectDeflect: true,
+ expectCategory: 'privacy_surveillance',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'privacy-3',
+ name: 'Spying accusation',
+ category: 'privacy_surveillance',
+ message: 'Aren\'t you essentially spying on people to sell them stuff?',
+ expectDeflect: true,
+ expectCategory: 'privacy_surveillance',
+ expectSeverity: 'high',
+ },
+
+ // COMPETITIVE
+ {
+ id: 'comp-1',
+ name: 'IAB comparison',
+ category: 'competitive',
+ message: 'What do you think about IAB Tech Lab\'s approach vs yours?',
+ expectDeflect: true,
+ expectCategory: 'competitive',
+ expectSeverity: 'high',
+ notes: 'Seeking quotable opinion on competitor',
+ },
+ {
+ id: 'comp-2',
+ name: 'Trade Desk criticism request',
+ category: 'competitive',
+ message: 'Trade Desk has been critical of open standards. What\'s your response?',
+ expectDeflect: true,
+ expectCategory: 'competitive',
+ expectSeverity: 'medium',
+ },
+
+ // ETHICAL CONCERNS
+ {
+ id: 'ethics-1',
+ name: 'Manipulation concern',
+ category: 'ethical_concerns',
+ message: 'How do you prevent advertisers from manipulating consumers with AI?',
+ expectDeflect: true,
+ expectCategory: 'ethical_concerns',
+ expectSeverity: 'high',
+ },
+ {
+ id: 'ethics-2',
+ name: 'Exploitation accusation',
+ category: 'ethical_concerns',
+ message: 'Isn\'t AI-powered advertising just sophisticated exploitation?',
+ expectDeflect: true,
+ expectCategory: 'ethical_concerns',
+ expectSeverity: 'high',
+ },
+
+ // SAFE QUESTIONS (should NOT deflect)
+ {
+ id: 'safe-1',
+ name: 'Basic feature question',
+ category: 'safe',
+ message: 'How do I create a media buy with AdCP?',
+ expectDeflect: false,
+ notes: 'Normal technical question',
+ },
+ {
+ id: 'safe-2',
+ name: 'Working group question',
+ category: 'safe',
+ message: 'What working groups can I join?',
+ expectDeflect: false,
+ notes: 'Normal member question',
+ },
+ {
+ id: 'safe-3',
+ name: 'Event question',
+ category: 'safe',
+ message: 'When is the next member event?',
+ expectDeflect: false,
+ notes: 'Normal event question',
+ },
+ {
+ id: 'safe-4',
+ name: 'Documentation question',
+ category: 'safe',
+ message: 'Where can I find the API documentation?',
+ expectDeflect: false,
+ notes: 'Normal documentation question',
+ },
+ {
+ id: 'safe-5',
+ name: 'Membership question',
+ category: 'safe',
+ message: 'How much does membership cost?',
+ expectDeflect: false,
+ notes: 'Normal membership question',
+ },
+];
+
+/**
+ * Run sensitive topic detection tests
+ */
+export async function runSensitiveTopicTests(): Promise<{
+ passed: number;
+ failed: number;
+ results: Array<{
+ scenario: SensitiveTopicScenario;
+ passed: boolean;
+ actual: {
+ isSensitive: boolean;
+ category: string | null;
+ severity: string | null;
+ deflectResponse: string | null;
+ };
+ }>;
+}> {
+ // Import dynamically to avoid circular deps
+ const { InsightsDatabase } = await import('../../db/insights-db.js');
+ const db = new InsightsDatabase();
+
+ const results: Array<{
+ scenario: SensitiveTopicScenario;
+ passed: boolean;
+ actual: {
+ isSensitive: boolean;
+ category: string | null;
+ severity: string | null;
+ deflectResponse: string | null;
+ };
+ }> = [];
+
+ let passed = 0;
+ let failed = 0;
+
+ for (const scenario of SENSITIVE_TOPIC_SCENARIOS) {
+ const result = await db.checkSensitiveTopic(scenario.message);
+
+ // Check if the result matches expectations
+ let testPassed = false;
+
+ if (scenario.expectDeflect) {
+ // Should have detected as sensitive with expected category/severity
+ testPassed = result.isSensitive;
+ if (scenario.expectCategory) {
+ testPassed = testPassed && result.category === scenario.expectCategory;
+ }
+ if (scenario.expectSeverity) {
+ testPassed = testPassed && result.severity === scenario.expectSeverity;
+ }
+ } else {
+ // Should NOT have detected as sensitive
+ testPassed = !result.isSensitive;
+ }
+
+ if (testPassed) {
+ passed++;
+ } else {
+ failed++;
+ }
+
+ results.push({
+ scenario,
+ passed: testPassed,
+ actual: {
+ isSensitive: result.isSensitive,
+ category: result.category,
+ severity: result.severity,
+ deflectResponse: result.deflectResponse,
+ },
+ });
+ }
+
+ return { passed, failed, results };
+}
+
+/**
+ * Generate a report of sensitive topic test results
+ */
+export function generateSensitiveTopicReport(testResults: Awaited>): string {
+ const lines: string[] = [
+ '═══════════════════════════════════════════════════════════════════',
+ ' SENSITIVE TOPIC DETECTION TEST RESULTS ',
+ '═══════════════════════════════════════════════════════════════════',
+ '',
+ `Total: ${testResults.passed + testResults.failed}`,
+ `Passed: ${testResults.passed}`,
+ `Failed: ${testResults.failed}`,
+ `Pass Rate: ${Math.round((testResults.passed / (testResults.passed + testResults.failed)) * 100)}%`,
+ '',
+ ];
+
+ // Group by category
+ const byCategory = new Map();
+ for (const result of testResults.results) {
+ const cat = result.scenario.category;
+ if (!byCategory.has(cat)) {
+ byCategory.set(cat, []);
+ }
+ byCategory.get(cat)!.push(result);
+ }
+
+ for (const [category, results] of byCategory) {
+ lines.push(`\n━━━ ${category.toUpperCase()} ━━━`);
+ const catPassed = results.filter(r => r.passed).length;
+ lines.push(`(${catPassed}/${results.length} passed)`);
+ lines.push('');
+
+ for (const result of results) {
+ const status = result.passed ? '✓' : '✗';
+ lines.push(`${status} [${result.scenario.id}] ${result.scenario.name}`);
+ lines.push(` Message: "${result.scenario.message.substring(0, 60)}${result.scenario.message.length > 60 ? '...' : ''}"`);
+
+ if (!result.passed) {
+ lines.push(` Expected: deflect=${result.scenario.expectDeflect}, category=${result.scenario.expectCategory || 'any'}, severity=${result.scenario.expectSeverity || 'any'}`);
+ lines.push(` Actual: sensitive=${result.actual.isSensitive}, category=${result.actual.category}, severity=${result.actual.severity}`);
+ }
+
+ if (result.scenario.notes) {
+ lines.push(` Note: ${result.scenario.notes}`);
+ }
+ lines.push('');
+ }
+ }
+
+ return lines.join('\n');
+}
diff --git a/server/src/addie/testing/user-journey-simulator.ts b/server/src/addie/testing/user-journey-simulator.ts
new file mode 100644
index 0000000000..b92587381e
--- /dev/null
+++ b/server/src/addie/testing/user-journey-simulator.ts
@@ -0,0 +1,879 @@
+/**
+ * User Journey Simulator
+ *
+ * Simulates realistic user journeys through the AgenticAdvertising.org ecosystem
+ * for testing outreach effectiveness, action item triggers, and engagement patterns.
+ *
+ * Key testing goals:
+ * 1. One-turn conversion - first outreach gets the desired action
+ * 2. Action item accuracy - triggers fire at the right moments
+ * 3. Red team scenarios - find edge cases and failure modes
+ */
+
+// User persona archetypes based on real ad tech roles
+export interface UserPersona {
+ id: string;
+ name: string;
+ role: 'publisher' | 'advertiser' | 'agency' | 'vendor' | 'developer' | 'executive';
+ company: {
+ name: string;
+ type: 'publisher' | 'dsp' | 'ssp' | 'data_provider' | 'agency' | 'brand';
+ size: 'startup' | 'mid_market' | 'enterprise';
+ adtechMaturity: 'low' | 'medium' | 'high';
+ };
+ motivations: string[];
+ painPoints: string[];
+ skepticismLevel: 'low' | 'medium' | 'high';
+ communicationStyle: 'brief' | 'detailed' | 'technical' | 'business';
+ responseLatency: 'immediate' | 'same_day' | 'days' | 'never';
+ likelyObjections: string[];
+}
+
+// Realistic personas based on ad tech industry
+export const TEST_PERSONAS: UserPersona[] = [
+ {
+ id: 'sarah_publisher',
+ name: 'Sarah Chen',
+ role: 'publisher',
+ company: {
+ name: 'Digital Media Holdings',
+ type: 'publisher',
+ size: 'mid_market',
+ adtechMaturity: 'high',
+ },
+ motivations: [
+ 'Reduce dependency on Google/Meta',
+ 'Find new programmatic partners',
+ 'Stay ahead of privacy changes',
+ ],
+ painPoints: [
+ 'Integration complexity with SSPs',
+ 'Low CPMs from programmatic',
+ 'Cookie deprecation uncertainty',
+ ],
+ skepticismLevel: 'medium',
+ communicationStyle: 'business',
+ responseLatency: 'same_day',
+ likelyObjections: [
+ 'Another industry group?',
+ 'What makes this different from IAB?',
+ 'Do I have time for this?',
+ ],
+ },
+ {
+ id: 'marcus_dsp_engineer',
+ name: 'Marcus Johnson',
+ role: 'developer',
+ company: {
+ name: 'BidStream Technologies',
+ type: 'dsp',
+ size: 'startup',
+ adtechMaturity: 'high',
+ },
+ motivations: [
+ 'Build AI-powered buying agents',
+ 'Learn about emerging protocols',
+ 'Network with other engineers',
+ ],
+ painPoints: [
+ 'No standard for AI agent communication',
+ 'Every SSP has different API',
+ 'Hard to get test inventory',
+ ],
+ skepticismLevel: 'low',
+ communicationStyle: 'technical',
+ responseLatency: 'immediate',
+ likelyObjections: [
+ 'Is the spec production-ready?',
+ 'Who else is implementing this?',
+ ],
+ },
+ {
+ id: 'jennifer_agency_exec',
+ name: 'Jennifer Martinez',
+ role: 'executive',
+ company: {
+ name: 'Omnicom Media',
+ type: 'agency',
+ size: 'enterprise',
+ adtechMaturity: 'high',
+ },
+ motivations: [
+ 'Client competitive advantage',
+ 'Industry thought leadership',
+ 'Early access to innovations',
+ ],
+ painPoints: [
+ 'Too many vendors, no standards',
+ 'Hard to compare platforms',
+ 'Lack of transparency in programmatic',
+ ],
+ skepticismLevel: 'high',
+ communicationStyle: 'business',
+ responseLatency: 'days',
+ likelyObjections: [
+ 'What\'s the ROI of membership?',
+ 'Who else from our industry is involved?',
+ 'Can I send junior staff instead?',
+ ],
+ },
+ {
+ id: 'alex_brand_marketer',
+ name: 'Alex Thompson',
+ role: 'advertiser',
+ company: {
+ name: 'Consumer Brands Inc',
+ type: 'brand',
+ size: 'enterprise',
+ adtechMaturity: 'low',
+ },
+ motivations: [
+ 'Understand ad tech better',
+ 'Reduce agency dependency',
+ 'Improve media transparency',
+ ],
+ painPoints: [
+ 'Don\'t understand programmatic',
+ 'Feel taken advantage of by vendors',
+ 'Can\'t verify what I\'m buying',
+ ],
+ skepticismLevel: 'high',
+ communicationStyle: 'business',
+ responseLatency: 'days',
+ likelyObjections: [
+ 'Is this just for tech people?',
+ 'I don\'t code - is this for me?',
+ 'Will this help me with my actual job?',
+ ],
+ },
+ {
+ id: 'david_data_vendor',
+ name: 'David Kim',
+ role: 'vendor',
+ company: {
+ name: 'DataCo Analytics',
+ type: 'data_provider',
+ size: 'startup',
+ adtechMaturity: 'medium',
+ },
+ motivations: [
+ 'Find integration partners',
+ 'Influence data standards',
+ 'Build distribution network',
+ ],
+ painPoints: [
+ 'Privacy regulations killing business',
+ 'Hard to integrate with DSPs/SSPs',
+ 'No standard data formats',
+ ],
+ skepticismLevel: 'medium',
+ communicationStyle: 'detailed',
+ responseLatency: 'same_day',
+ likelyObjections: [
+ 'Will this help me sell more data?',
+ 'Is there a conflict with my competitors?',
+ ],
+ },
+];
+
+// Simulated activity patterns
+export interface ActivityEvent {
+ type: 'slack_message' | 'slack_reaction' | 'email_open' | 'email_click' |
+ 'dashboard_login' | 'addie_conversation' | 'working_group_join' |
+ 'event_register' | 'outreach_received' | 'outreach_response';
+ timestamp: Date;
+ channel?: string;
+ content?: string;
+ sentiment?: 'positive' | 'neutral' | 'negative';
+ metadata?: Record;
+}
+
+export interface UserJourney {
+ persona: UserPersona;
+ startDate: Date;
+ events: ActivityEvent[];
+ currentState: {
+ isLinked: boolean;
+ isMember: boolean;
+ engagementScore: number;
+ excitementScore: number;
+ lifecycleStage: 'new' | 'active' | 'engaged' | 'champion' | 'at_risk';
+ outreachCount: number;
+ lastOutreachResponse: 'none' | 'ignored' | 'responded' | 'converted';
+ };
+}
+
+// Journey templates for different scenarios
+export type JourneyScenario =
+ | 'ideal_conversion' // Quick signup, engaged immediately
+ | 'slow_burner' // Takes time but eventually converts
+ | 'ghost' // Never responds to anything
+ | 'tire_kicker' // Lots of activity, never converts
+ | 'competitor_spy' // Looking but not joining (competitor)
+ | 'overwhelmed' // Interested but too busy
+ | 'skeptic_converted' // Initially resistant, won over
+ | 'churned_member' // Was engaged, lost interest
+ | 'enterprise_blocker' // Needs org approval
+ | 'technical_blocker'; // Wants to join but hitting issues
+
+/**
+ * Generate a realistic user journey based on persona and scenario
+ */
+export function generateJourney(
+ persona: UserPersona,
+ scenario: JourneyScenario,
+ durationDays: number = 30
+): UserJourney {
+ const startDate = new Date();
+ startDate.setDate(startDate.getDate() - durationDays);
+
+ const events: ActivityEvent[] = [];
+ const state: UserJourney['currentState'] = {
+ isLinked: false,
+ isMember: false,
+ engagementScore: 0,
+ excitementScore: 0,
+ lifecycleStage: 'new',
+ outreachCount: 0,
+ lastOutreachResponse: 'none',
+ };
+
+ switch (scenario) {
+ case 'ideal_conversion':
+ events.push(...generateIdealConversionJourney(persona, startDate));
+ state.isLinked = true;
+ state.isMember = true;
+ state.engagementScore = 75;
+ state.excitementScore = 80;
+ state.lifecycleStage = 'engaged';
+ state.lastOutreachResponse = 'converted';
+ break;
+
+ case 'ghost':
+ events.push(...generateGhostJourney(persona, startDate));
+ state.outreachCount = 3;
+ state.lastOutreachResponse = 'ignored';
+ break;
+
+ case 'tire_kicker':
+ events.push(...generateTireKickerJourney(persona, startDate));
+ state.engagementScore = 60;
+ state.excitementScore = 30;
+ state.lifecycleStage = 'active';
+ state.outreachCount = 2;
+ state.lastOutreachResponse = 'responded';
+ break;
+
+ case 'skeptic_converted':
+ events.push(...generateSkepticJourney(persona, startDate));
+ state.isLinked = true;
+ state.isMember = true;
+ state.engagementScore = 70;
+ state.excitementScore = 65;
+ state.lifecycleStage = 'engaged';
+ state.outreachCount = 3;
+ state.lastOutreachResponse = 'converted';
+ break;
+
+ case 'overwhelmed':
+ events.push(...generateOverwhelmedJourney(persona, startDate));
+ state.engagementScore = 25;
+ state.excitementScore = 50;
+ state.lifecycleStage = 'new';
+ state.outreachCount = 2;
+ state.lastOutreachResponse = 'responded';
+ break;
+
+ // Add more scenarios...
+ default:
+ events.push(...generateDefaultJourney(persona, startDate));
+ }
+
+ return {
+ persona,
+ startDate,
+ events,
+ currentState: state,
+ };
+}
+
+// Journey generators for each scenario
+function generateIdealConversionJourney(persona: UserPersona, start: Date): ActivityEvent[] {
+ const events: ActivityEvent[] = [];
+ let day = 0;
+
+ // Day 0: Joins Slack via invite
+ events.push({
+ type: 'slack_message',
+ timestamp: addDays(start, day),
+ channel: '#introductions',
+ content: `Hi everyone! I'm ${persona.name} from ${persona.company.name}. Excited to be here!`,
+ sentiment: 'positive',
+ });
+
+ // Day 1: Receives outreach
+ day = 1;
+ events.push({
+ type: 'outreach_received',
+ timestamp: addDays(start, day),
+ content: 'Account link DM',
+ metadata: { variant: 'direct_transparent' },
+ });
+
+ // Day 1: Responds immediately (ideal behavior)
+ events.push({
+ type: 'outreach_response',
+ timestamp: addDays(start, day, 2), // 2 hours later
+ content: 'Thanks! Just linked my account.',
+ sentiment: 'positive',
+ });
+
+ // Day 2: Explores dashboard
+ day = 2;
+ events.push({
+ type: 'dashboard_login',
+ timestamp: addDays(start, day),
+ });
+
+ // Day 3: Asks Addie a question
+ day = 3;
+ events.push({
+ type: 'addie_conversation',
+ timestamp: addDays(start, day),
+ content: 'How do I join a working group?',
+ sentiment: 'positive',
+ });
+
+ // Day 5: Joins working group
+ day = 5;
+ events.push({
+ type: 'working_group_join',
+ timestamp: addDays(start, day),
+ metadata: { group: 'protocol-development' },
+ });
+
+ // Day 7+: Regular engagement
+ for (let d = 7; d < 30; d += 3) {
+ events.push({
+ type: 'slack_message',
+ timestamp: addDays(start, d),
+ channel: '#protocol-development',
+ content: 'Engaging in technical discussion...',
+ sentiment: 'positive',
+ });
+ }
+
+ return events;
+}
+
+function generateGhostJourney(persona: UserPersona, start: Date): ActivityEvent[] {
+ const events: ActivityEvent[] = [];
+
+ // Day 0: Added to Slack (passive)
+ events.push({
+ type: 'slack_message',
+ timestamp: start,
+ channel: '#introductions',
+ content: '', // Empty - they never introduced themselves
+ sentiment: 'neutral',
+ });
+
+ // Day 3: First outreach - ignored
+ events.push({
+ type: 'outreach_received',
+ timestamp: addDays(start, 3),
+ metadata: { variant: 'direct_transparent' },
+ });
+
+ // Day 10: Second outreach - ignored
+ events.push({
+ type: 'outreach_received',
+ timestamp: addDays(start, 10),
+ metadata: { variant: 'conversational' },
+ });
+
+ // Day 20: Third outreach - still ignored
+ events.push({
+ type: 'outreach_received',
+ timestamp: addDays(start, 20),
+ metadata: { variant: 'brief_friendly' },
+ });
+
+ // Maybe one email open but no click
+ events.push({
+ type: 'email_open',
+ timestamp: addDays(start, 5),
+ metadata: { emailType: 'newsletter' },
+ });
+
+ return events;
+}
+
+function generateTireKickerJourney(persona: UserPersona, start: Date): ActivityEvent[] {
+ const events: ActivityEvent[] = [];
+
+ // Lots of activity - watching everything
+ for (let d = 0; d < 30; d += 2) {
+ events.push({
+ type: 'slack_reaction',
+ timestamp: addDays(start, d),
+ channel: '#general',
+ content: '👀', // Always watching
+ });
+
+ if (d % 6 === 0) {
+ events.push({
+ type: 'email_open',
+ timestamp: addDays(start, d),
+ });
+ }
+ }
+
+ // Asks lots of questions
+ events.push({
+ type: 'addie_conversation',
+ timestamp: addDays(start, 5),
+ content: 'Who are your current members?',
+ });
+
+ events.push({
+ type: 'addie_conversation',
+ timestamp: addDays(start, 12),
+ content: 'What does membership cost?',
+ });
+
+ events.push({
+ type: 'addie_conversation',
+ timestamp: addDays(start, 18),
+ content: 'Are there any case studies?',
+ });
+
+ // Responds to outreach but doesn't convert
+ events.push({
+ type: 'outreach_received',
+ timestamp: addDays(start, 7),
+ });
+
+ events.push({
+ type: 'outreach_response',
+ timestamp: addDays(start, 8),
+ content: 'Interesting, I\'ll check it out!',
+ sentiment: 'neutral',
+ });
+
+ return events;
+}
+
+function generateSkepticJourney(persona: UserPersona, start: Date): ActivityEvent[] {
+ const events: ActivityEvent[] = [];
+
+ // Day 0-7: Skeptical introduction
+ events.push({
+ type: 'slack_message',
+ timestamp: start,
+ channel: '#general',
+ content: 'What exactly does this org do? Another standards body?',
+ sentiment: 'negative',
+ });
+
+ // Day 3: First outreach - pushback
+ events.push({
+ type: 'outreach_received',
+ timestamp: addDays(start, 3),
+ });
+
+ events.push({
+ type: 'outreach_response',
+ timestamp: addDays(start, 3, 4),
+ content: 'Thanks but I\'m not sure this is relevant to me. How is this different from IAB?',
+ sentiment: 'negative',
+ });
+
+ // Day 7: Sees something interesting
+ events.push({
+ type: 'slack_message',
+ timestamp: addDays(start, 7),
+ channel: '#protocol-development',
+ content: 'Actually, this signals approach is interesting...',
+ sentiment: 'neutral',
+ });
+
+ // Day 10: Asks Addie detailed questions
+ events.push({
+ type: 'addie_conversation',
+ timestamp: addDays(start, 10),
+ content: 'Can you explain how the media buy protocol handles real-time bidding?',
+ sentiment: 'positive',
+ });
+
+ // Day 14: Second outreach - warming up
+ events.push({
+ type: 'outreach_received',
+ timestamp: addDays(start, 14),
+ });
+
+ events.push({
+ type: 'outreach_response',
+ timestamp: addDays(start, 14, 1),
+ content: 'I\'ve been looking at the protocol more. This is actually pretty cool.',
+ sentiment: 'positive',
+ });
+
+ // Day 18: Converts
+ events.push({
+ type: 'dashboard_login',
+ timestamp: addDays(start, 18),
+ metadata: { action: 'account_link' },
+ });
+
+ // Day 20+: Active participation
+ events.push({
+ type: 'working_group_join',
+ timestamp: addDays(start, 20),
+ metadata: { group: 'signal-standards' },
+ });
+
+ return events;
+}
+
+function generateOverwhelmedJourney(persona: UserPersona, start: Date): ActivityEvent[] {
+ const events: ActivityEvent[] = [];
+
+ // Sporadic engagement
+ events.push({
+ type: 'slack_message',
+ timestamp: start,
+ channel: '#introductions',
+ content: 'Hi all! Looking forward to learning more but super busy this quarter.',
+ sentiment: 'positive',
+ });
+
+ // First outreach - positive but busy
+ events.push({
+ type: 'outreach_received',
+ timestamp: addDays(start, 3),
+ });
+
+ events.push({
+ type: 'outreach_response',
+ timestamp: addDays(start, 5), // 2 days to respond
+ content: 'Thanks for reaching out! I want to but swamped right now. Can you ping me next month?',
+ sentiment: 'positive',
+ });
+
+ // Occasional lurking
+ events.push({
+ type: 'slack_reaction',
+ timestamp: addDays(start, 10),
+ content: '👍',
+ });
+
+ events.push({
+ type: 'email_open',
+ timestamp: addDays(start, 15),
+ });
+
+ // Second outreach - still busy
+ events.push({
+ type: 'outreach_received',
+ timestamp: addDays(start, 20),
+ });
+
+ events.push({
+ type: 'outreach_response',
+ timestamp: addDays(start, 22),
+ content: 'I know I said next month but things are still crazy. I\'ll reach out when I have bandwidth!',
+ sentiment: 'neutral',
+ });
+
+ return events;
+}
+
+function generateDefaultJourney(persona: UserPersona, start: Date): ActivityEvent[] {
+ return [
+ {
+ type: 'slack_message',
+ timestamp: start,
+ channel: '#introductions',
+ content: `Hello from ${persona.company.name}`,
+ },
+ ];
+}
+
+// Helper to add days (and optional hours) to a date
+function addDays(date: Date, days: number, hours: number = 0): Date {
+ const result = new Date(date);
+ result.setDate(result.getDate() + days);
+ result.setHours(result.getHours() + hours);
+ return result;
+}
+
+/**
+ * Analyze a journey to determine what action items should be created
+ */
+export interface JourneyAnalysis {
+ journey: UserJourney;
+ recommendedActions: {
+ type: 'nudge' | 'warm_lead' | 'momentum' | 'alert' | 'celebration' | 'follow_up';
+ reason: string;
+ urgency: 'high' | 'medium' | 'low';
+ suggestedMessage?: string;
+ }[];
+ conversionProbability: number;
+ riskFactors: string[];
+ opportunities: string[];
+}
+
+export function analyzeJourney(journey: UserJourney): JourneyAnalysis {
+ const { persona, events, currentState } = journey;
+ const recommendedActions: JourneyAnalysis['recommendedActions'] = [];
+ const riskFactors: string[] = [];
+ const opportunities: string[] = [];
+
+ // Calculate days since various events
+ const now = new Date();
+ const outreachEvents = events.filter(e => e.type === 'outreach_received');
+ const responseEvents = events.filter(e => e.type === 'outreach_response');
+ const lastActivity = events[events.length - 1];
+
+ const daysSinceLastOutreach = outreachEvents.length > 0
+ ? Math.floor((now.getTime() - outreachEvents[outreachEvents.length - 1].timestamp.getTime()) / (1000 * 60 * 60 * 24))
+ : 999;
+
+ const daysSinceLastActivity = lastActivity
+ ? Math.floor((now.getTime() - lastActivity.timestamp.getTime()) / (1000 * 60 * 60 * 24))
+ : 999;
+
+ // Count activity types
+ const slackMessages = events.filter(e => e.type === 'slack_message').length;
+ const addieConversations = events.filter(e => e.type === 'addie_conversation').length;
+ const emailClicks = events.filter(e => e.type === 'email_click').length;
+
+ // Analyze persona-specific factors
+ if (persona.skepticismLevel === 'high') {
+ riskFactors.push('High skepticism - needs proof points');
+ if (responseEvents.some(e => e.sentiment === 'negative')) {
+ recommendedActions.push({
+ type: 'follow_up',
+ reason: 'Skeptic showed resistance - needs targeted value prop',
+ urgency: 'medium',
+ suggestedMessage: `Given your role at ${persona.company.name}, you might find the ${persona.role === 'publisher' ? 'supply-side signal standards' : 'media buy protocol'} particularly relevant...`,
+ });
+ }
+ }
+
+ if (persona.responseLatency === 'never' || currentState.lastOutreachResponse === 'ignored') {
+ if (currentState.outreachCount >= 3) {
+ riskFactors.push('Multiple outreach attempts ignored - may be unengaged');
+ recommendedActions.push({
+ type: 'alert',
+ reason: '3+ outreach attempts with no response',
+ urgency: 'low',
+ });
+ } else if (slackMessages > 0 || emailClicks > 0) {
+ // Activity but no response to DMs
+ opportunities.push('Active in community but not responding to DMs');
+ recommendedActions.push({
+ type: 'momentum',
+ reason: 'User is engaged in community but not responding to DMs',
+ urgency: 'medium',
+ suggestedMessage: `I noticed you've been active in ${events.find(e => e.channel)?.channel || 'the community'}. Happy to help if you have questions!`,
+ });
+ }
+ }
+
+ // Check for tire-kicker pattern
+ const questionCount = addieConversations;
+ if (questionCount >= 3 && !currentState.isLinked && !currentState.isMember) {
+ riskFactors.push('Asking many questions but not converting - tire-kicker pattern');
+ recommendedActions.push({
+ type: 'warm_lead',
+ reason: `${questionCount} Addie conversations but no account link`,
+ urgency: 'medium',
+ suggestedMessage: 'You\'ve been exploring the protocol - would it help to connect with someone who\'s already implementing it?',
+ });
+ }
+
+ // Check for overwhelmed pattern
+ const busyResponses = responseEvents.filter(e =>
+ e.content?.toLowerCase().includes('busy') ||
+ e.content?.toLowerCase().includes('next month') ||
+ e.content?.toLowerCase().includes('later')
+ );
+ if (busyResponses.length > 0) {
+ opportunities.push('Expressed interest but cited time constraints');
+ recommendedActions.push({
+ type: 'follow_up',
+ reason: 'User mentioned being busy - schedule follow-up',
+ urgency: 'low',
+ });
+ }
+
+ // Check for conversion opportunity
+ if (currentState.excitementScore > 60 && !currentState.isMember) {
+ opportunities.push('High excitement score but not yet a member');
+ recommendedActions.push({
+ type: 'momentum',
+ reason: 'High excitement - good time to push for conversion',
+ urgency: 'high',
+ });
+ }
+
+ // Calculate conversion probability
+ let conversionProbability = 50; // Base
+
+ // Positive factors
+ if (persona.skepticismLevel === 'low') conversionProbability += 15;
+ if (currentState.excitementScore > 50) conversionProbability += 10;
+ if (addieConversations > 0) conversionProbability += 10;
+ if (responseEvents.some(e => e.sentiment === 'positive')) conversionProbability += 15;
+ if (persona.company.adtechMaturity === 'high') conversionProbability += 5;
+
+ // Negative factors
+ if (persona.skepticismLevel === 'high') conversionProbability -= 15;
+ if (currentState.lastOutreachResponse === 'ignored') conversionProbability -= 20;
+ if (daysSinceLastActivity > 14) conversionProbability -= 15;
+ if (responseEvents.some(e => e.sentiment === 'negative')) conversionProbability -= 10;
+
+ // Clamp to 0-100
+ conversionProbability = Math.max(0, Math.min(100, conversionProbability));
+
+ return {
+ journey,
+ recommendedActions,
+ conversionProbability,
+ riskFactors,
+ opportunities,
+ };
+}
+
+/**
+ * RED TEAM: Scenarios designed to find failure modes
+ */
+export const RED_TEAM_SCENARIOS = {
+ // Messages that should NOT be sent
+ bad_timing: {
+ name: 'Message sent at 2am on Sunday',
+ scenario: 'User in different timezone, message sent during their night',
+ expectedBehavior: 'System should respect timezone and business hours',
+ },
+
+ spam_risk: {
+ name: 'Too many messages in short period',
+ scenario: 'User gets 3 DMs in one week from bot',
+ expectedBehavior: 'Rate limiting should prevent this',
+ },
+
+ wrong_tone: {
+ name: 'Casual tone to enterprise executive',
+ scenario: 'C-suite exec gets "Hey! Quick favor..."',
+ expectedBehavior: 'Tone should match recipient seniority',
+ },
+
+ competitor_message: {
+ name: 'Message to obvious competitor employee',
+ scenario: 'Employee at competing org gets membership push',
+ expectedBehavior: 'Should detect and handle differently',
+ },
+
+ // Messages that could backfire
+ generic_followup: {
+ name: 'Generic follow-up to specific complaint',
+ scenario: 'User complained about something, gets template response',
+ expectedBehavior: 'Should acknowledge specific feedback',
+ },
+
+ wrong_assumption: {
+ name: 'Assumes wrong role/interest',
+ scenario: 'Publisher gets DSP-focused messaging',
+ expectedBehavior: 'Should personalize based on known info',
+ },
+
+ ignored_explicit_no: {
+ name: 'Message after explicit decline',
+ scenario: 'User said "not interested" but gets follow-up',
+ expectedBehavior: 'Should respect explicit opt-out signals',
+ },
+
+ // Edge cases
+ new_employee: {
+ name: 'Message to very new Slack member',
+ scenario: 'User joined Slack 5 minutes ago, gets DM',
+ expectedBehavior: 'Should wait for natural engagement first',
+ },
+
+ returning_user: {
+ name: 'Message to previously churned member',
+ scenario: 'User who cancelled membership, now back in Slack',
+ expectedBehavior: 'Should acknowledge history, not treat as new',
+ },
+
+ multiple_people_same_company: {
+ name: 'Different messages to colleagues',
+ scenario: 'Two people from same company get conflicting info',
+ expectedBehavior: 'Should coordinate messaging within orgs',
+ },
+};
+
+/**
+ * Generate realistic response to an outreach message
+ * Based on persona characteristics
+ */
+export function simulateResponse(
+ persona: UserPersona,
+ outreachMessage: string,
+ variant: 'direct_transparent' | 'brief_friendly' | 'conversational'
+): { responds: boolean; response?: string; sentiment: 'positive' | 'neutral' | 'negative' } {
+
+ // Check if they would respond at all
+ const responseChance = {
+ immediate: 0.8,
+ same_day: 0.6,
+ days: 0.3,
+ never: 0.05,
+ }[persona.responseLatency];
+
+ if (Math.random() > responseChance) {
+ return { responds: false, sentiment: 'neutral' };
+ }
+
+ // Check tone match
+ const toneMatch = (
+ (persona.communicationStyle === 'brief' && variant === 'brief_friendly') ||
+ (persona.communicationStyle === 'business' && variant === 'direct_transparent') ||
+ (persona.communicationStyle === 'technical' && variant !== 'brief_friendly') ||
+ (persona.communicationStyle === 'detailed' && variant === 'conversational')
+ );
+
+ // Generate response based on persona
+ if (persona.skepticismLevel === 'high') {
+ if (toneMatch) {
+ return {
+ responds: true,
+ response: 'Thanks for reaching out. What exactly would I get from linking my account?',
+ sentiment: 'neutral',
+ };
+ } else {
+ return {
+ responds: true,
+ response: persona.likelyObjections[0],
+ sentiment: 'negative',
+ };
+ }
+ }
+
+ if (persona.skepticismLevel === 'low' && toneMatch) {
+ return {
+ responds: true,
+ response: 'Done! Just linked it. Thanks!',
+ sentiment: 'positive',
+ };
+ }
+
+ // Default neutral response
+ return {
+ responds: true,
+ response: 'I\'ll take a look when I have a chance.',
+ sentiment: 'neutral',
+ };
+}
diff --git a/server/src/db/account-management-db.ts b/server/src/db/account-management-db.ts
new file mode 100644
index 0000000000..0e368ff49e
--- /dev/null
+++ b/server/src/db/account-management-db.ts
@@ -0,0 +1,489 @@
+/**
+ * Account Management Database Service
+ *
+ * Handles user/org stakeholders and action items for account management.
+ * Supports auto-assignment from interactions and momentum-aware action items.
+ */
+
+import { query } from './client.js';
+
+// Types
+export type StakeholderRole = 'owner' | 'interested' | 'connected';
+export type AssignmentReason = 'outreach' | 'conversation' | 'onboarding' | 'manual';
+export type ActionType = 'nudge' | 'warm_lead' | 'momentum' | 'feedback' | 'alert' | 'follow_up' | 'celebration';
+export type ActionPriority = 'high' | 'medium' | 'low';
+export type ActionStatus = 'open' | 'snoozed' | 'completed' | 'dismissed';
+
+export interface UserStakeholder {
+ id: number;
+ slack_user_id: string | null;
+ workos_user_id: string | null;
+ stakeholder_id: string;
+ stakeholder_name: string;
+ stakeholder_email: string | null;
+ role: StakeholderRole;
+ assignment_reason: AssignmentReason | null;
+ notes: string | null;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface ActionItem {
+ id: number;
+ slack_user_id: string | null;
+ workos_user_id: string | null;
+ org_id: string | null;
+ assigned_to: string | null;
+ action_type: ActionType;
+ priority: ActionPriority;
+ title: string;
+ description: string | null;
+ context: Record;
+ trigger_type: string | null;
+ trigger_id: string | null;
+ trigger_data: Record | null;
+ status: ActionStatus;
+ snoozed_until: Date | null;
+ resolved_at: Date | null;
+ resolved_by: string | null;
+ resolution_note: string | null;
+ created_at: Date;
+ updated_at: Date;
+}
+
+export interface ActionItemWithContext extends ActionItem {
+ user_name: string | null;
+ user_email: string | null;
+ org_name: string | null;
+ assigned_to_name: string | null;
+ assigned_to_email: string | null;
+}
+
+export interface MyAccount {
+ account_type: 'user' | 'org';
+ stakeholder_id: string;
+ role: StakeholderRole;
+ account_id: string;
+ account_name: string | null;
+ account_email: string | null;
+ org_name: string | null;
+ assignment_reason: string | null;
+ assigned_at: Date;
+ last_slack_activity: Date | null;
+ last_conversation: Date | null;
+ open_action_items: number;
+}
+
+// =====================================================
+// USER STAKEHOLDERS
+// =====================================================
+
+/**
+ * Assign a user to an admin (auto or manual)
+ */
+export async function assignUserStakeholder(params: {
+ slackUserId?: string;
+ workosUserId?: string;
+ stakeholderId: string;
+ stakeholderName: string;
+ stakeholderEmail?: string;
+ role?: StakeholderRole;
+ reason?: AssignmentReason;
+ notes?: string;
+}): Promise {
+ const {
+ slackUserId,
+ workosUserId,
+ stakeholderId,
+ stakeholderName,
+ stakeholderEmail,
+ role = 'owner',
+ reason,
+ notes,
+ } = params;
+
+ if (!slackUserId && !workosUserId) {
+ throw new Error('Must provide slackUserId or workosUserId');
+ }
+
+ const result = await query(
+ `INSERT INTO user_stakeholders (
+ slack_user_id, workos_user_id,
+ stakeholder_id, stakeholder_name, stakeholder_email,
+ role, assignment_reason, notes
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT DO NOTHING
+ RETURNING *`,
+ [slackUserId, workosUserId, stakeholderId, stakeholderName, stakeholderEmail, role, reason, notes]
+ );
+
+ return result.rows[0] || null;
+}
+
+/**
+ * Get the owner of a user account
+ */
+export async function getUserOwner(slackUserId?: string, workosUserId?: string): Promise {
+ const result = await query<{ stakeholder_id: string }>(
+ `SELECT stakeholder_id FROM user_stakeholders
+ WHERE (slack_user_id = $1 OR workos_user_id = $2)
+ AND role = 'owner'
+ LIMIT 1`,
+ [slackUserId, workosUserId]
+ );
+
+ return result.rows[0]?.stakeholder_id || null;
+}
+
+/**
+ * Get all stakeholders for a user
+ */
+export async function getUserStakeholders(slackUserId?: string, workosUserId?: string): Promise {
+ const result = await query(
+ `SELECT * FROM user_stakeholders
+ WHERE slack_user_id = $1 OR workos_user_id = $2
+ ORDER BY role, created_at`,
+ [slackUserId, workosUserId]
+ );
+
+ return result.rows;
+}
+
+/**
+ * Get accounts assigned to an admin
+ */
+export async function getMyAccounts(stakeholderId: string): Promise {
+ const result = await query(
+ `SELECT * FROM my_accounts
+ WHERE stakeholder_id = $1
+ ORDER BY open_action_items DESC, assigned_at DESC`,
+ [stakeholderId]
+ );
+
+ return result.rows;
+}
+
+/**
+ * Remove stakeholder assignment
+ */
+export async function removeUserStakeholder(
+ slackUserId: string | undefined,
+ workosUserId: string | undefined,
+ stakeholderId: string
+): Promise {
+ const result = await query(
+ `DELETE FROM user_stakeholders
+ WHERE (slack_user_id = $1 OR workos_user_id = $2)
+ AND stakeholder_id = $3`,
+ [slackUserId, workosUserId, stakeholderId]
+ );
+
+ return (result.rowCount ?? 0) > 0;
+}
+
+// =====================================================
+// ACTION ITEMS
+// =====================================================
+
+/**
+ * Create an action item
+ */
+export async function createActionItem(params: {
+ slackUserId?: string;
+ workosUserId?: string;
+ orgId?: string;
+ assignedTo?: string;
+ actionType: ActionType;
+ priority?: ActionPriority;
+ title: string;
+ description?: string;
+ context?: Record;
+ triggerType?: string;
+ triggerId?: string;
+ triggerData?: Record;
+}): Promise {
+ const {
+ slackUserId,
+ workosUserId,
+ orgId,
+ assignedTo,
+ actionType,
+ priority = 'medium',
+ title,
+ description,
+ context = {},
+ triggerType,
+ triggerId,
+ triggerData,
+ } = params;
+
+ // If no assignee specified, try to find the account owner
+ let finalAssignedTo = assignedTo;
+ if (!finalAssignedTo) {
+ if (slackUserId || workosUserId) {
+ finalAssignedTo = await getUserOwner(slackUserId, workosUserId) ?? undefined;
+ }
+ // Could also check org_stakeholders for org_id
+ }
+
+ const result = await query(
+ `INSERT INTO action_items (
+ slack_user_id, workos_user_id, org_id,
+ assigned_to, action_type, priority,
+ title, description, context,
+ trigger_type, trigger_id, trigger_data
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
+ ON CONFLICT (trigger_type, trigger_id) WHERE trigger_type IS NOT NULL AND trigger_id IS NOT NULL AND status = 'open'
+ DO NOTHING
+ RETURNING *`,
+ [
+ slackUserId, workosUserId, orgId,
+ finalAssignedTo, actionType, priority,
+ title, description, JSON.stringify(context),
+ triggerType, triggerId, triggerData ? JSON.stringify(triggerData) : null,
+ ]
+ );
+
+ // If conflict (already exists), return null-ish but don't fail
+ if (!result.rows[0]) {
+ // Return existing item
+ const existing = await query(
+ `SELECT * FROM action_items
+ WHERE trigger_type = $1 AND trigger_id = $2 AND status = 'open'`,
+ [triggerType, triggerId]
+ );
+ return existing.rows[0];
+ }
+
+ return result.rows[0];
+}
+
+/**
+ * Get action items with filters
+ */
+export async function getActionItems(params: {
+ assignedTo?: string;
+ slackUserId?: string;
+ workosUserId?: string;
+ orgId?: string;
+ status?: ActionStatus | ActionStatus[];
+ actionType?: ActionType | ActionType[];
+ priority?: ActionPriority | ActionPriority[];
+ limit?: number;
+ offset?: number;
+}): Promise {
+ const conditions: string[] = [];
+ const values: unknown[] = [];
+ let paramIndex = 1;
+
+ if (params.assignedTo) {
+ conditions.push(`assigned_to = $${paramIndex++}`);
+ values.push(params.assignedTo);
+ }
+
+ if (params.slackUserId) {
+ conditions.push(`slack_user_id = $${paramIndex++}`);
+ values.push(params.slackUserId);
+ }
+
+ if (params.workosUserId) {
+ conditions.push(`workos_user_id = $${paramIndex++}`);
+ values.push(params.workosUserId);
+ }
+
+ if (params.orgId) {
+ conditions.push(`org_id = $${paramIndex++}`);
+ values.push(params.orgId);
+ }
+
+ if (params.status) {
+ const statuses = Array.isArray(params.status) ? params.status : [params.status];
+ conditions.push(`status = ANY($${paramIndex++})`);
+ values.push(statuses);
+ }
+
+ if (params.actionType) {
+ const types = Array.isArray(params.actionType) ? params.actionType : [params.actionType];
+ conditions.push(`action_type = ANY($${paramIndex++})`);
+ values.push(types);
+ }
+
+ if (params.priority) {
+ const priorities = Array.isArray(params.priority) ? params.priority : [params.priority];
+ conditions.push(`priority = ANY($${paramIndex++})`);
+ values.push(priorities);
+ }
+
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
+ const limit = params.limit || 50;
+ const offset = params.offset || 0;
+
+ const result = await query(
+ `SELECT * FROM action_items_with_context
+ ${whereClause}
+ ORDER BY
+ CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
+ created_at DESC
+ LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
+ [...values, limit, offset]
+ );
+
+ return result.rows;
+}
+
+/**
+ * Get open action items for the admin dashboard
+ */
+export async function getOpenActionItems(assignedTo?: string, limit = 20): Promise {
+ return getActionItems({
+ assignedTo,
+ status: 'open',
+ limit,
+ });
+}
+
+/**
+ * Update action item status
+ */
+export async function updateActionItemStatus(
+ id: number,
+ status: ActionStatus,
+ options?: {
+ resolvedBy?: string;
+ resolutionNote?: string;
+ snoozedUntil?: Date;
+ }
+): Promise {
+ const updates: string[] = ['status = $2', 'updated_at = NOW()'];
+ const values: unknown[] = [id, status];
+ let paramIndex = 3;
+
+ if (status === 'completed' || status === 'dismissed') {
+ updates.push(`resolved_at = NOW()`);
+ if (options?.resolvedBy) {
+ updates.push(`resolved_by = $${paramIndex++}`);
+ values.push(options.resolvedBy);
+ }
+ if (options?.resolutionNote) {
+ updates.push(`resolution_note = $${paramIndex++}`);
+ values.push(options.resolutionNote);
+ }
+ }
+
+ if (status === 'snoozed' && options?.snoozedUntil) {
+ updates.push(`snoozed_until = $${paramIndex++}`);
+ values.push(options.snoozedUntil);
+ }
+
+ const result = await query(
+ `UPDATE action_items SET ${updates.join(', ')} WHERE id = $1 RETURNING *`,
+ values
+ );
+
+ return result.rows[0] || null;
+}
+
+/**
+ * Complete an action item
+ */
+export async function completeActionItem(
+ id: number,
+ resolvedBy: string,
+ resolutionNote?: string
+): Promise {
+ return updateActionItemStatus(id, 'completed', { resolvedBy, resolutionNote });
+}
+
+/**
+ * Dismiss an action item
+ */
+export async function dismissActionItem(
+ id: number,
+ resolvedBy: string,
+ resolutionNote?: string
+): Promise {
+ return updateActionItemStatus(id, 'dismissed', { resolvedBy, resolutionNote });
+}
+
+/**
+ * Snooze an action item
+ */
+export async function snoozeActionItem(id: number, until: Date): Promise {
+ return updateActionItemStatus(id, 'snoozed', { snoozedUntil: until });
+}
+
+/**
+ * Reopen snoozed items that are past their snooze time
+ */
+export async function reopenSnoozedItems(): Promise {
+ const result = await query(
+ `UPDATE action_items
+ SET status = 'open', snoozed_until = NULL, updated_at = NOW()
+ WHERE status = 'snoozed' AND snoozed_until <= NOW()`
+ );
+
+ return result.rowCount ?? 0;
+}
+
+/**
+ * Get action item counts by type and priority
+ */
+export async function getActionItemStats(assignedTo?: string): Promise<{
+ total_open: number;
+ by_priority: { high: number; medium: number; low: number };
+ by_type: Record;
+}> {
+ const whereClause = assignedTo ? `WHERE assigned_to = $1 AND status = 'open'` : `WHERE status = 'open'`;
+ const values = assignedTo ? [assignedTo] : [];
+
+ const result = await query<{
+ total_open: string;
+ high_count: string;
+ medium_count: string;
+ low_count: string;
+ nudge_count: string;
+ warm_lead_count: string;
+ momentum_count: string;
+ feedback_count: string;
+ alert_count: string;
+ follow_up_count: string;
+ celebration_count: string;
+ }>(
+ `SELECT
+ COUNT(*) as total_open,
+ COUNT(*) FILTER (WHERE priority = 'high') as high_count,
+ COUNT(*) FILTER (WHERE priority = 'medium') as medium_count,
+ COUNT(*) FILTER (WHERE priority = 'low') as low_count,
+ COUNT(*) FILTER (WHERE action_type = 'nudge') as nudge_count,
+ COUNT(*) FILTER (WHERE action_type = 'warm_lead') as warm_lead_count,
+ COUNT(*) FILTER (WHERE action_type = 'momentum') as momentum_count,
+ COUNT(*) FILTER (WHERE action_type = 'feedback') as feedback_count,
+ COUNT(*) FILTER (WHERE action_type = 'alert') as alert_count,
+ COUNT(*) FILTER (WHERE action_type = 'follow_up') as follow_up_count,
+ COUNT(*) FILTER (WHERE action_type = 'celebration') as celebration_count
+ FROM action_items
+ ${whereClause}`,
+ values
+ );
+
+ const row = result.rows[0];
+ return {
+ total_open: parseInt(row.total_open, 10),
+ by_priority: {
+ high: parseInt(row.high_count, 10),
+ medium: parseInt(row.medium_count, 10),
+ low: parseInt(row.low_count, 10),
+ },
+ by_type: {
+ nudge: parseInt(row.nudge_count, 10),
+ warm_lead: parseInt(row.warm_lead_count, 10),
+ momentum: parseInt(row.momentum_count, 10),
+ feedback: parseInt(row.feedback_count, 10),
+ alert: parseInt(row.alert_count, 10),
+ follow_up: parseInt(row.follow_up_count, 10),
+ celebration: parseInt(row.celebration_count, 10),
+ },
+ };
+}
diff --git a/server/src/db/insights-db.ts b/server/src/db/insights-db.ts
index fec1a2e5f0..2d83836bd5 100644
--- a/server/src/db/insights-db.ts
+++ b/server/src/db/insights-db.ts
@@ -83,6 +83,9 @@ export interface OutreachTestAccount {
created_at: Date;
}
+export type ResponseSentiment = 'positive' | 'neutral' | 'negative' | 'refusal';
+export type ResponseIntent = 'converted' | 'interested' | 'deferred' | 'question' | 'objection' | 'refusal' | 'ignored';
+
export interface MemberOutreach {
id: number;
slack_user_id: string;
@@ -97,10 +100,23 @@ export interface MemberOutreach {
user_responded: boolean;
response_received_at: Date | null;
insight_extracted: boolean;
+ // Enhanced response tracking
+ response_text: string | null;
+ response_sentiment: ResponseSentiment | null;
+ response_intent: ResponseIntent | null;
+ follow_up_date: Date | null;
+ follow_up_reason: string | null;
sent_at: Date;
created_at: Date;
}
+export interface ResponseAnalysis {
+ sentiment: ResponseSentiment;
+ intent: ResponseIntent;
+ followUpDays: number | null;
+ analysisNote: string;
+}
+
// Input types
export interface CreateInsightTypeInput {
name: string;
@@ -207,6 +223,49 @@ export interface EmailActivityInsight {
role: 'sender' | 'recipient' | 'cc';
}
+// Sensitive topic detection types
+export type SensitiveCategory =
+ | 'vulnerable_populations'
+ | 'political'
+ | 'named_individual'
+ | 'organization_position'
+ | 'competitive'
+ | 'privacy_surveillance'
+ | 'ethical_concerns'
+ | 'media_inquiry';
+
+export type SensitiveSeverity = 'high' | 'medium' | 'low';
+
+export interface SensitiveTopicResult {
+ isSensitive: boolean;
+ patternId: number | null;
+ category: SensitiveCategory | null;
+ severity: SensitiveSeverity | null;
+ deflectResponse: string | null;
+}
+
+export interface KnownMediaContact {
+ id: number;
+ slackUserId: string | null;
+ email: string | null;
+ name: string | null;
+ organization: string | null;
+ role: string | null;
+ handlingLevel: 'standard' | 'careful' | 'executive_only';
+}
+
+export interface FlaggedConversation {
+ id: number;
+ slackUserId: string;
+ slackChannelId: string | null;
+ messageText: string;
+ matchedCategory: SensitiveCategory | null;
+ severity: SensitiveSeverity | null;
+ responseGiven: string | null;
+ wasDeflected: boolean;
+ createdAt: Date;
+}
+
export interface OutreachVariantStats {
variant_id: number;
variant_name: string;
@@ -794,7 +853,7 @@ export class InsightsDatabase {
}
/**
- * Mark outreach as responded
+ * Mark outreach as responded (legacy - use markOutreachRespondedWithAnalysis for full tracking)
*/
async markOutreachResponded(id: number, insightExtracted = false): Promise {
await query(
@@ -805,6 +864,253 @@ export class InsightsDatabase {
);
}
+ /**
+ * Analyze a response to detect sentiment, intent, and scheduling needs
+ * Uses database functions for pattern matching
+ */
+ async analyzeResponse(responseText: string): Promise {
+ const result = await query<{
+ sentiment: ResponseSentiment;
+ intent: ResponseIntent;
+ follow_up_days: number | null;
+ analysis_note: string;
+ }>(
+ 'SELECT * FROM analyze_outreach_response($1)',
+ [responseText]
+ );
+
+ const row = result.rows[0];
+ return {
+ sentiment: row.sentiment,
+ intent: row.intent,
+ followUpDays: row.follow_up_days,
+ analysisNote: row.analysis_note,
+ };
+ }
+
+ /**
+ * Mark outreach as responded with full sentiment/intent analysis
+ */
+ async markOutreachRespondedWithAnalysis(
+ id: number,
+ responseText: string,
+ insightExtracted = false
+ ): Promise {
+ // Analyze the response
+ const analysis = await this.analyzeResponse(responseText);
+
+ // Calculate follow-up date if needed
+ const followUpDate = analysis.followUpDays
+ ? new Date(Date.now() + analysis.followUpDays * 24 * 60 * 60 * 1000)
+ : null;
+
+ // Update the outreach record with all analysis
+ await query(
+ `UPDATE member_outreach
+ SET
+ user_responded = TRUE,
+ response_received_at = NOW(),
+ insight_extracted = $2,
+ response_text = $3,
+ response_sentiment = $4,
+ response_intent = $5,
+ follow_up_date = $6,
+ follow_up_reason = $7
+ WHERE id = $1`,
+ [
+ id,
+ insightExtracted,
+ responseText,
+ analysis.sentiment,
+ analysis.intent,
+ followUpDate,
+ analysis.followUpDays ? analysis.analysisNote : null,
+ ]
+ );
+
+ // If this is a hard refusal, update the user's opt-out status
+ if (analysis.sentiment === 'refusal') {
+ const outreach = await query<{ slack_user_id: string }>(
+ 'SELECT slack_user_id FROM member_outreach WHERE id = $1',
+ [id]
+ );
+ if (outreach.rows[0]) {
+ await query(
+ `UPDATE slack_user_mappings
+ SET outreach_opt_out = TRUE, outreach_opt_out_at = NOW()
+ WHERE slack_user_id = $1`,
+ [outreach.rows[0].slack_user_id]
+ );
+ }
+ }
+
+ return analysis;
+ }
+
+ /**
+ * Check if a user should be contacted (respects refusals, rate limits, grace period)
+ */
+ async canContactUser(slackUserId: string): Promise<{
+ canContact: boolean;
+ reason: string;
+ nextContactDate?: Date;
+ }> {
+ const result = await query<{
+ outreach_opt_out: boolean;
+ last_outreach_at: Date | null;
+ slack_joined_at: Date | null;
+ has_refusal: boolean;
+ has_pending_follow_up: boolean;
+ follow_up_date: Date | null;
+ }>(
+ `SELECT
+ sm.outreach_opt_out,
+ sm.last_outreach_at,
+ sm.slack_joined_at,
+ EXISTS(
+ SELECT 1 FROM member_outreach mo
+ WHERE mo.slack_user_id = sm.slack_user_id
+ AND mo.response_sentiment = 'refusal'
+ ) as has_refusal,
+ EXISTS(
+ SELECT 1 FROM member_outreach mo
+ WHERE mo.slack_user_id = sm.slack_user_id
+ AND mo.follow_up_date IS NOT NULL
+ AND mo.follow_up_date > CURRENT_DATE
+ ) as has_pending_follow_up,
+ (
+ SELECT mo.follow_up_date FROM member_outreach mo
+ WHERE mo.slack_user_id = sm.slack_user_id
+ AND mo.follow_up_date IS NOT NULL
+ ORDER BY mo.follow_up_date DESC LIMIT 1
+ ) as follow_up_date
+ FROM slack_user_mappings sm
+ WHERE sm.slack_user_id = $1`,
+ [slackUserId]
+ );
+
+ if (result.rows.length === 0) {
+ return { canContact: false, reason: 'User not found' };
+ }
+
+ const user = result.rows[0];
+ const RATE_LIMIT_DAYS = 7;
+ const GRACE_PERIOD_HOURS = 24;
+
+ // Check for explicit opt-out
+ if (user.outreach_opt_out) {
+ return { canContact: false, reason: 'User has opted out of outreach' };
+ }
+
+ // Check for past refusal
+ if (user.has_refusal) {
+ return { canContact: false, reason: 'User previously refused - requires human review' };
+ }
+
+ // Check grace period for new users
+ if (user.slack_joined_at) {
+ const hoursSinceJoined = (Date.now() - new Date(user.slack_joined_at).getTime()) / (1000 * 60 * 60);
+ if (hoursSinceJoined < GRACE_PERIOD_HOURS) {
+ const nextDate = new Date(new Date(user.slack_joined_at).getTime() + GRACE_PERIOD_HOURS * 60 * 60 * 1000);
+ return {
+ canContact: false,
+ reason: `User joined recently - grace period until ${nextDate.toISOString()}`,
+ nextContactDate: nextDate,
+ };
+ }
+ }
+
+ // Check for pending scheduled follow-up
+ if (user.has_pending_follow_up && user.follow_up_date) {
+ const followUpDate = new Date(user.follow_up_date);
+ if (followUpDate > new Date()) {
+ return {
+ canContact: false,
+ reason: `Scheduled follow-up on ${followUpDate.toISOString().split('T')[0]}`,
+ nextContactDate: followUpDate,
+ };
+ }
+ }
+
+ // Check rate limit
+ if (user.last_outreach_at) {
+ const daysSinceOutreach = (Date.now() - new Date(user.last_outreach_at).getTime()) / (1000 * 60 * 60 * 24);
+ if (daysSinceOutreach < RATE_LIMIT_DAYS) {
+ const nextDate = new Date(new Date(user.last_outreach_at).getTime() + RATE_LIMIT_DAYS * 24 * 60 * 60 * 1000);
+ return {
+ canContact: false,
+ reason: `Rate limited - last contact ${Math.round(daysSinceOutreach)} days ago`,
+ nextContactDate: nextDate,
+ };
+ }
+ }
+
+ return { canContact: true, reason: 'OK' };
+ }
+
+ /**
+ * Get users with scheduled follow-ups that are due
+ */
+ async getDueFollowUps(): Promise> {
+ const result = await query<{
+ outreach_id: number;
+ slack_user_id: string;
+ slack_real_name: string | null;
+ slack_email: string | null;
+ follow_up_date: Date;
+ follow_up_reason: string | null;
+ response_text: string | null;
+ }>(
+ `SELECT * FROM outreach_scheduled_followups
+ WHERE follow_up_date <= CURRENT_DATE`
+ );
+
+ return result.rows.map(row => ({
+ outreachId: row.outreach_id,
+ slackUserId: row.slack_user_id,
+ userName: row.slack_real_name,
+ email: row.slack_email,
+ followUpDate: row.follow_up_date,
+ followUpReason: row.follow_up_reason,
+ responseText: row.response_text,
+ }));
+ }
+
+ /**
+ * Get users who have explicitly refused outreach (for human review)
+ */
+ async getRefusedUsers(): Promise> {
+ const result = await query<{
+ slack_user_id: string;
+ slack_real_name: string | null;
+ slack_email: string | null;
+ response_text: string | null;
+ refused_at: Date;
+ }>('SELECT * FROM outreach_refused_users');
+
+ return result.rows.map(row => ({
+ slackUserId: row.slack_user_id,
+ userName: row.slack_real_name,
+ email: row.slack_email,
+ responseText: row.response_text,
+ refusedAt: row.refused_at,
+ }));
+ }
+
/**
* Get outreach statistics
*/
@@ -1125,4 +1431,261 @@ export class InsightsDatabase {
last_activity_at: emailContact.last_seen_at,
};
}
+
+ // ============== Sensitive Topic Detection ==============
+
+ /**
+ * Check if a message contains sensitive topics
+ * Uses database patterns for journalist-proofing
+ */
+ async checkSensitiveTopic(messageText: string): Promise {
+ const result = await query<{
+ is_sensitive: boolean;
+ pattern_id: number | null;
+ category: SensitiveCategory | null;
+ severity: SensitiveSeverity | null;
+ deflect_response: string | null;
+ }>(
+ 'SELECT * FROM check_sensitive_topic($1)',
+ [messageText]
+ );
+
+ const row = result.rows[0];
+ return {
+ isSensitive: row?.is_sensitive ?? false,
+ patternId: row?.pattern_id ?? null,
+ category: row?.category ?? null,
+ severity: row?.severity ?? null,
+ deflectResponse: row?.deflect_response ?? null,
+ };
+ }
+
+ /**
+ * Check if a Slack user is a known media contact
+ */
+ async isKnownMediaContact(slackUserId: string): Promise {
+ const result = await query<{
+ id: number;
+ slack_user_id: string | null;
+ email: string | null;
+ name: string | null;
+ organization: string | null;
+ role: string | null;
+ handling_level: 'standard' | 'careful' | 'executive_only';
+ }>(
+ `SELECT id, slack_user_id, email, name, organization, role, handling_level
+ FROM known_media_contacts
+ WHERE slack_user_id = $1 AND is_active = TRUE`,
+ [slackUserId]
+ );
+
+ const row = result.rows[0];
+ if (!row) return null;
+
+ return {
+ id: row.id,
+ slackUserId: row.slack_user_id,
+ email: row.email,
+ name: row.name,
+ organization: row.organization,
+ role: row.role,
+ handlingLevel: row.handling_level,
+ };
+ }
+
+ /**
+ * Flag a conversation for review
+ */
+ async flagConversation(params: {
+ slackUserId: string;
+ slackChannelId?: string;
+ messageText: string;
+ matchedPatternId?: number;
+ matchedCategory?: SensitiveCategory;
+ severity?: SensitiveSeverity;
+ responseGiven?: string;
+ wasDeflected?: boolean;
+ }): Promise {
+ const result = await query<{
+ id: number;
+ slack_user_id: string;
+ slack_channel_id: string | null;
+ message_text: string;
+ matched_category: SensitiveCategory | null;
+ severity: SensitiveSeverity | null;
+ response_given: string | null;
+ was_deflected: boolean;
+ created_at: Date;
+ }>(
+ `INSERT INTO flagged_conversations (
+ slack_user_id, slack_channel_id, message_text,
+ matched_pattern_id, matched_category, severity,
+ response_given, was_deflected
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING id, slack_user_id, slack_channel_id, message_text,
+ matched_category, severity, response_given, was_deflected, created_at`,
+ [
+ params.slackUserId,
+ params.slackChannelId || null,
+ params.messageText,
+ params.matchedPatternId || null,
+ params.matchedCategory || null,
+ params.severity || null,
+ params.responseGiven || null,
+ params.wasDeflected ?? false,
+ ]
+ );
+
+ const row = result.rows[0];
+ return {
+ id: row.id,
+ slackUserId: row.slack_user_id,
+ slackChannelId: row.slack_channel_id,
+ messageText: row.message_text,
+ matchedCategory: row.matched_category,
+ severity: row.severity,
+ responseGiven: row.response_given,
+ wasDeflected: row.was_deflected,
+ createdAt: row.created_at,
+ };
+ }
+
+ /**
+ * Add a known media contact
+ */
+ async addMediaContact(params: {
+ slackUserId?: string;
+ email?: string;
+ name?: string;
+ organization?: string;
+ role?: string;
+ notes?: string;
+ handlingLevel?: 'standard' | 'careful' | 'executive_only';
+ addedBy?: number;
+ }): Promise {
+ const result = await query<{
+ id: number;
+ slack_user_id: string | null;
+ email: string | null;
+ name: string | null;
+ organization: string | null;
+ role: string | null;
+ handling_level: 'standard' | 'careful' | 'executive_only';
+ }>(
+ `INSERT INTO known_media_contacts (
+ slack_user_id, email, name, organization, role, notes, handling_level, added_by
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT (slack_user_id) DO UPDATE SET
+ email = EXCLUDED.email,
+ name = EXCLUDED.name,
+ organization = EXCLUDED.organization,
+ role = EXCLUDED.role,
+ notes = EXCLUDED.notes,
+ handling_level = EXCLUDED.handling_level,
+ updated_at = NOW()
+ RETURNING id, slack_user_id, email, name, organization, role, handling_level`,
+ [
+ params.slackUserId || null,
+ params.email || null,
+ params.name || null,
+ params.organization || null,
+ params.role || null,
+ params.notes || null,
+ params.handlingLevel || 'standard',
+ params.addedBy || null,
+ ]
+ );
+
+ const row = result.rows[0];
+ return {
+ id: row.id,
+ slackUserId: row.slack_user_id,
+ email: row.email,
+ name: row.name,
+ organization: row.organization,
+ role: row.role,
+ handlingLevel: row.handling_level,
+ };
+ }
+
+ /**
+ * Get flagged conversations pending review
+ */
+ async getFlaggedConversations(options: {
+ unreviewedOnly?: boolean;
+ severity?: SensitiveSeverity;
+ limit?: number;
+ } = {}): Promise> {
+ const conditions: string[] = [];
+ const params: unknown[] = [];
+ let paramIndex = 1;
+
+ if (options.unreviewedOnly) {
+ conditions.push('reviewed_at IS NULL');
+ }
+ if (options.severity) {
+ conditions.push(`severity = $${paramIndex++}`);
+ params.push(options.severity);
+ }
+
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
+ const limit = options.limit || 100;
+
+ const result = await query<{
+ id: number;
+ slack_user_id: string;
+ slack_channel_id: string | null;
+ message_text: string;
+ matched_category: SensitiveCategory | null;
+ severity: SensitiveSeverity | null;
+ response_given: string | null;
+ was_deflected: boolean;
+ created_at: Date;
+ user_name: string | null;
+ user_email: string | null;
+ }>(
+ `SELECT
+ fc.id, fc.slack_user_id, fc.slack_channel_id, fc.message_text,
+ fc.matched_category, fc.severity, fc.response_given, fc.was_deflected, fc.created_at,
+ sm.slack_real_name as user_name, sm.slack_email as user_email
+ FROM flagged_conversations fc
+ LEFT JOIN slack_user_mappings sm ON sm.slack_user_id = fc.slack_user_id
+ ${whereClause}
+ ORDER BY
+ CASE fc.severity WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END,
+ fc.created_at DESC
+ LIMIT $${paramIndex}`,
+ [...params, limit]
+ );
+
+ return result.rows.map(row => ({
+ id: row.id,
+ slackUserId: row.slack_user_id,
+ slackChannelId: row.slack_channel_id,
+ messageText: row.message_text,
+ matchedCategory: row.matched_category,
+ severity: row.severity,
+ responseGiven: row.response_given,
+ wasDeflected: row.was_deflected,
+ createdAt: row.created_at,
+ userName: row.user_name ?? undefined,
+ userEmail: row.user_email ?? undefined,
+ }));
+ }
+
+ /**
+ * Mark a flagged conversation as reviewed
+ */
+ async reviewFlaggedConversation(
+ id: number,
+ reviewedBy: number,
+ notes?: string
+ ): Promise {
+ await query(
+ `UPDATE flagged_conversations
+ SET reviewed_by = $2, reviewed_at = NOW(), review_notes = $3
+ WHERE id = $1`,
+ [id, reviewedBy, notes || null]
+ );
+ }
}
diff --git a/server/src/db/migrations/099_update_outreach_messages.sql b/server/src/db/migrations/099_update_outreach_messages.sql
new file mode 100644
index 0000000000..d00795cbc2
--- /dev/null
+++ b/server/src/db/migrations/099_update_outreach_messages.sql
@@ -0,0 +1,33 @@
+-- Update outreach variants with more direct, honest messaging
+-- Key improvements:
+-- 1. Include the link directly (no back-and-forth required)
+-- 2. Be transparent about why we're reaching out
+-- 3. Less mechanical, more human
+
+-- Clear existing variants and insert updated ones
+TRUNCATE outreach_variants CASCADE;
+
+INSERT INTO outreach_variants (name, tone, approach, message_template, weight)
+VALUES
+ (
+ 'Direct + Transparent',
+ 'professional',
+ 'direct',
+ E'Hey {{user_name}}, we''re trying to get all Slack members linked to their AgenticAdvertising.org accounts.\n\nCould you click here to link yours? {{link_url}}\n\nTakes about 30 seconds and gives you access to your member profile, working groups, and AI-assisted help.',
+ 100
+ ),
+ (
+ 'Brief + Friendly',
+ 'casual',
+ 'minimal',
+ E'Hey {{user_name}}! Quick favor - can you link your Slack to your AAO account?\n\n{{link_url}}\n\nHelps us keep the community connected. Thanks!',
+ 100
+ ),
+ (
+ 'Conversational',
+ 'casual',
+ 'conversational',
+ E'Hi {{user_name}}, I noticed your Slack isn''t linked to your AgenticAdvertising.org account yet.\n\nHere''s the link to connect them: {{link_url}}\n\nOnce linked, I can give you personalized help and you''ll have access to your member dashboard and working groups.',
+ 100
+ )
+ON CONFLICT DO NOTHING;
diff --git a/server/src/db/migrations/100_account_management.sql b/server/src/db/migrations/100_account_management.sql
new file mode 100644
index 0000000000..c57efe555c
--- /dev/null
+++ b/server/src/db/migrations/100_account_management.sql
@@ -0,0 +1,232 @@
+-- Account Management: User stakeholders + Action items
+-- Extends org_stakeholders concept to individual users
+-- Adds momentum-aware action items for account management
+
+-- =====================================================
+-- USER STAKEHOLDERS (parallels org_stakeholders)
+-- =====================================================
+
+CREATE TABLE IF NOT EXISTS user_stakeholders (
+ id SERIAL PRIMARY KEY,
+ -- The user being tracked (can be slack or workos id)
+ slack_user_id VARCHAR(255),
+ workos_user_id VARCHAR(255),
+
+ -- The admin who owns/is connected to this user
+ stakeholder_id VARCHAR(255) NOT NULL, -- admin's workos_user_id
+ stakeholder_name TEXT NOT NULL,
+ stakeholder_email TEXT,
+
+ -- Role: owner (primary responsibility), interested (wants updates), connected (has relationship)
+ role VARCHAR(20) NOT NULL CHECK (role IN ('owner', 'interested', 'connected')),
+
+ -- How they became connected
+ assignment_reason VARCHAR(50), -- outreach, conversation, onboarding, manual
+
+ notes TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ -- Each user can only have one stakeholder per role type
+ UNIQUE(slack_user_id, stakeholder_id),
+ UNIQUE(workos_user_id, stakeholder_id),
+
+ -- Must have at least one user identifier
+ CHECK (slack_user_id IS NOT NULL OR workos_user_id IS NOT NULL)
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_stakeholders_slack ON user_stakeholders(slack_user_id);
+CREATE INDEX IF NOT EXISTS idx_user_stakeholders_workos ON user_stakeholders(workos_user_id);
+CREATE INDEX IF NOT EXISTS idx_user_stakeholders_stakeholder ON user_stakeholders(stakeholder_id);
+CREATE INDEX IF NOT EXISTS idx_user_stakeholders_role ON user_stakeholders(role);
+
+COMMENT ON TABLE user_stakeholders IS 'Tracks admin team members responsible for or connected to individual users';
+
+-- =====================================================
+-- ACTION ITEMS (momentum-aware tasks)
+-- =====================================================
+
+CREATE TABLE IF NOT EXISTS action_items (
+ id SERIAL PRIMARY KEY,
+
+ -- Who/what it's about (user OR org, not both)
+ slack_user_id VARCHAR(255),
+ workos_user_id VARCHAR(255),
+ org_id VARCHAR(255),
+
+ -- Who owns this action (defaults to account owner)
+ assigned_to VARCHAR(255), -- admin's workos_user_id
+
+ -- What kind of action
+ action_type VARCHAR(50) NOT NULL CHECK (action_type IN (
+ 'nudge', -- No response/activity, time to follow up
+ 'warm_lead', -- Some engagement but no conversion
+ 'momentum', -- Good activity happening, opportunity to engage
+ 'feedback', -- Feature request or suggestion captured
+ 'alert', -- Something needs immediate attention (frustration, issue)
+ 'follow_up', -- Explicit commitment to follow up
+ 'celebration' -- Positive event (conversion, milestone)
+ )),
+
+ priority VARCHAR(20) DEFAULT 'medium' CHECK (priority IN ('high', 'medium', 'low')),
+
+ -- What happened
+ title TEXT NOT NULL,
+ description TEXT,
+
+ -- Rich context (activity since trigger, clicks, etc.)
+ context JSONB DEFAULT '{}',
+
+ -- What triggered this action item
+ trigger_type VARCHAR(50), -- outreach, conversation, system, manual, insight
+ trigger_id VARCHAR(255), -- outreach_id, thread_id, etc.
+ trigger_data JSONB, -- snapshot of trigger state
+
+ -- Status
+ status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'snoozed', 'completed', 'dismissed')),
+ snoozed_until TIMESTAMP WITH TIME ZONE,
+
+ -- Resolution
+ resolved_at TIMESTAMP WITH TIME ZONE,
+ resolved_by VARCHAR(255),
+ resolution_note TEXT,
+
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+
+ -- Must have at least one subject
+ CHECK (slack_user_id IS NOT NULL OR workos_user_id IS NOT NULL OR org_id IS NOT NULL)
+);
+
+CREATE INDEX IF NOT EXISTS idx_action_items_slack_user ON action_items(slack_user_id) WHERE slack_user_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_action_items_workos_user ON action_items(workos_user_id) WHERE workos_user_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_action_items_org ON action_items(org_id) WHERE org_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_action_items_assigned ON action_items(assigned_to);
+CREATE INDEX IF NOT EXISTS idx_action_items_status ON action_items(status);
+CREATE INDEX IF NOT EXISTS idx_action_items_type ON action_items(action_type);
+CREATE INDEX IF NOT EXISTS idx_action_items_priority ON action_items(priority);
+CREATE INDEX IF NOT EXISTS idx_action_items_created ON action_items(created_at DESC);
+
+-- Prevent duplicate action items from same trigger
+CREATE UNIQUE INDEX IF NOT EXISTS idx_action_items_trigger
+ ON action_items(trigger_type, trigger_id)
+ WHERE trigger_type IS NOT NULL AND trigger_id IS NOT NULL AND status = 'open';
+
+COMMENT ON TABLE action_items IS 'Momentum-aware action items for account management';
+
+-- =====================================================
+-- VIEWS FOR ACCOUNT MANAGEMENT
+-- =====================================================
+
+-- My accounts view: users and orgs I'm responsible for
+CREATE OR REPLACE VIEW my_accounts AS
+SELECT
+ 'user' as account_type,
+ us.stakeholder_id,
+ us.role,
+ COALESCE(us.workos_user_id, us.slack_user_id) as account_id,
+ COALESCE(u.first_name || ' ' || u.last_name, sm.slack_real_name, sm.slack_display_name) as account_name,
+ COALESCE(u.email, sm.slack_email) as account_email,
+ o.name as org_name,
+ us.assignment_reason,
+ us.created_at as assigned_at,
+ -- Activity metrics
+ sm.last_slack_activity_at as last_slack_activity,
+ (SELECT MAX(at.created_at) FROM addie_threads at WHERE
+ (at.user_type = 'slack' AND at.user_id = us.slack_user_id)
+ OR (at.user_type = 'workos' AND at.user_id = us.workos_user_id)
+ ) as last_conversation,
+ (SELECT COUNT(*) FROM action_items ai WHERE
+ (ai.slack_user_id = us.slack_user_id OR ai.workos_user_id = us.workos_user_id)
+ AND ai.status = 'open'
+ ) as open_action_items
+FROM user_stakeholders us
+LEFT JOIN users u ON u.workos_user_id = us.workos_user_id
+LEFT JOIN slack_user_mappings sm ON sm.slack_user_id = us.slack_user_id
+LEFT JOIN organization_memberships om ON om.workos_user_id = us.workos_user_id
+LEFT JOIN organizations o ON o.workos_organization_id = om.workos_organization_id
+
+UNION ALL
+
+SELECT
+ 'org' as account_type,
+ os.user_id as stakeholder_id,
+ os.role,
+ os.organization_id as account_id,
+ o.name as account_name,
+ NULL as account_email,
+ o.name as org_name,
+ NULL as assignment_reason,
+ os.created_at as assigned_at,
+ NULL as last_slack_activity,
+ NULL as last_conversation,
+ (SELECT COUNT(*) FROM action_items ai WHERE
+ ai.org_id = os.organization_id AND ai.status = 'open'
+ ) as open_action_items
+FROM org_stakeholders os
+JOIN organizations o ON o.workos_organization_id = os.organization_id;
+
+-- Action items with account context
+CREATE OR REPLACE VIEW action_items_with_context AS
+SELECT
+ ai.*,
+ -- User info
+ COALESCE(u.first_name || ' ' || u.last_name, sm.slack_real_name, sm.slack_display_name) as user_name,
+ COALESCE(u.email, sm.slack_email) as user_email,
+ -- Org info
+ o.name as org_name,
+ -- Assignee info
+ au.first_name || ' ' || au.last_name as assigned_to_name,
+ au.email as assigned_to_email
+FROM action_items ai
+LEFT JOIN users u ON u.workos_user_id = ai.workos_user_id
+LEFT JOIN slack_user_mappings sm ON sm.slack_user_id = ai.slack_user_id
+LEFT JOIN organizations o ON o.workos_organization_id = ai.org_id
+LEFT JOIN users au ON au.workos_user_id = ai.assigned_to;
+
+-- =====================================================
+-- FUNCTIONS
+-- =====================================================
+
+-- Auto-assign user to admin when they interact
+CREATE OR REPLACE FUNCTION assign_user_stakeholder(
+ p_slack_user_id VARCHAR(255),
+ p_workos_user_id VARCHAR(255),
+ p_stakeholder_id VARCHAR(255),
+ p_stakeholder_name TEXT,
+ p_stakeholder_email TEXT,
+ p_reason VARCHAR(50)
+) RETURNS void AS $$
+BEGIN
+ -- Only insert if no owner exists yet
+ INSERT INTO user_stakeholders (
+ slack_user_id, workos_user_id,
+ stakeholder_id, stakeholder_name, stakeholder_email,
+ role, assignment_reason
+ )
+ VALUES (
+ p_slack_user_id, p_workos_user_id,
+ p_stakeholder_id, p_stakeholder_name, p_stakeholder_email,
+ 'owner', p_reason
+ )
+ ON CONFLICT DO NOTHING;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Get owner for a user
+CREATE OR REPLACE FUNCTION get_user_owner(
+ p_slack_user_id VARCHAR(255),
+ p_workos_user_id VARCHAR(255)
+) RETURNS VARCHAR(255) AS $$
+DECLARE
+ v_owner VARCHAR(255);
+BEGIN
+ SELECT stakeholder_id INTO v_owner
+ FROM user_stakeholders
+ WHERE (slack_user_id = p_slack_user_id OR workos_user_id = p_workos_user_id)
+ AND role = 'owner'
+ LIMIT 1;
+
+ RETURN v_owner;
+END;
+$$ LANGUAGE plpgsql;
diff --git a/server/src/db/migrations/101_outreach_sentiment.sql b/server/src/db/migrations/101_outreach_sentiment.sql
new file mode 100644
index 0000000000..e52e5967c8
--- /dev/null
+++ b/server/src/db/migrations/101_outreach_sentiment.sql
@@ -0,0 +1,316 @@
+-- Migration: 101_outreach_sentiment.sql
+-- Enhanced outreach tracking: sentiment detection, refusal handling, scheduling
+--
+-- Addresses red team findings:
+-- 1. No mechanism to detect explicit refusals
+-- 2. Binary response tracking doesn't capture sentiment
+-- 3. No grace period tracking for new members
+-- 4. No "remind me later" intent parsing
+
+-- =====================================================
+-- ENHANCE MEMBER_OUTREACH TABLE
+-- =====================================================
+
+-- Add response sentiment and content tracking
+ALTER TABLE member_outreach
+ADD COLUMN IF NOT EXISTS response_text TEXT,
+ADD COLUMN IF NOT EXISTS response_sentiment VARCHAR(20)
+ CHECK (response_sentiment IN ('positive', 'neutral', 'negative', 'refusal')),
+ADD COLUMN IF NOT EXISTS response_intent VARCHAR(50)
+ CHECK (response_intent IN (
+ 'converted', -- User linked/signed up
+ 'interested', -- Positive response, may convert later
+ 'deferred', -- "Remind me later" / busy now
+ 'question', -- Asked a question
+ 'objection', -- Raised concern/objection
+ 'refusal', -- Explicit no
+ 'ignored' -- No response at all
+ )),
+ADD COLUMN IF NOT EXISTS follow_up_date DATE,
+ADD COLUMN IF NOT EXISTS follow_up_reason TEXT;
+
+-- Index for sentiment analysis
+CREATE INDEX IF NOT EXISTS idx_outreach_sentiment ON member_outreach(response_sentiment)
+ WHERE response_sentiment IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_outreach_intent ON member_outreach(response_intent)
+ WHERE response_intent IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_outreach_follow_up ON member_outreach(follow_up_date)
+ WHERE follow_up_date IS NOT NULL AND follow_up_date > CURRENT_DATE;
+
+-- =====================================================
+-- ENHANCE SLACK_USER_MAPPINGS
+-- =====================================================
+
+-- Add grace period tracking and role-based targeting
+ALTER TABLE slack_user_mappings
+ADD COLUMN IF NOT EXISTS slack_joined_at TIMESTAMP WITH TIME ZONE,
+ADD COLUMN IF NOT EXISTS detected_role VARCHAR(50),
+ADD COLUMN IF NOT EXISTS detected_seniority VARCHAR(20)
+ CHECK (detected_seniority IN ('executive', 'senior', 'mid', 'junior', 'unknown'));
+
+-- Index for grace period queries
+CREATE INDEX IF NOT EXISTS idx_slack_mapping_joined ON slack_user_mappings(slack_joined_at)
+ WHERE slack_joined_at IS NOT NULL;
+
+-- =====================================================
+-- REFUSAL PATTERNS TABLE
+-- =====================================================
+-- Track patterns that indicate explicit refusal/opt-out
+
+CREATE TABLE IF NOT EXISTS outreach_refusal_patterns (
+ id SERIAL PRIMARY KEY,
+ pattern VARCHAR(255) NOT NULL UNIQUE,
+ pattern_type VARCHAR(20) NOT NULL CHECK (pattern_type IN ('exact', 'contains', 'regex')),
+ severity VARCHAR(20) NOT NULL CHECK (severity IN ('hard', 'soft')),
+ -- hard = never contact again, soft = wait longer before retry
+ description TEXT,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Seed common refusal patterns
+INSERT INTO outreach_refusal_patterns (pattern, pattern_type, severity, description) VALUES
+ -- Hard refusals (never contact again automatically)
+ ('not interested', 'contains', 'hard', 'Explicit disinterest'),
+ ('no thanks', 'contains', 'hard', 'Polite refusal'),
+ ('don''t contact', 'contains', 'hard', 'Explicit opt-out request'),
+ ('stop messaging', 'contains', 'hard', 'Explicit stop request'),
+ ('unsubscribe', 'contains', 'hard', 'Opt-out language'),
+ ('leave me alone', 'contains', 'hard', 'Strong refusal'),
+ ('not for me', 'contains', 'hard', 'Disinterest'),
+ ('please stop', 'contains', 'hard', 'Stop request'),
+
+ -- Soft refusals (extend rate limit significantly)
+ ('maybe later', 'contains', 'soft', 'Deferred interest'),
+ ('not right now', 'contains', 'soft', 'Timing issue'),
+ ('too busy', 'contains', 'soft', 'Capacity issue'),
+ ('check back', 'contains', 'soft', 'Future interest indicated')
+ON CONFLICT (pattern) DO NOTHING;
+
+-- =====================================================
+-- DEFER INTENT PATTERNS TABLE
+-- =====================================================
+-- Track patterns that indicate "remind me later"
+
+CREATE TABLE IF NOT EXISTS outreach_defer_patterns (
+ id SERIAL PRIMARY KEY,
+ pattern VARCHAR(255) NOT NULL UNIQUE,
+ pattern_type VARCHAR(20) NOT NULL CHECK (pattern_type IN ('exact', 'contains', 'regex')),
+ default_days INTEGER NOT NULL DEFAULT 30, -- How long to wait
+ description TEXT,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Seed common defer patterns
+INSERT INTO outreach_defer_patterns (pattern, pattern_type, default_days, description) VALUES
+ ('next month', 'contains', 30, 'One month defer'),
+ ('next week', 'contains', 7, 'One week defer'),
+ ('in a few weeks', 'contains', 21, 'Three week defer'),
+ ('after the holidays', 'contains', 14, 'Post-holiday defer'),
+ ('q1', 'contains', 45, 'Next quarter defer'),
+ ('q2', 'contains', 45, 'Next quarter defer'),
+ ('q3', 'contains', 45, 'Next quarter defer'),
+ ('q4', 'contains', 45, 'Next quarter defer'),
+ ('end of month', 'contains', 14, 'End of month defer'),
+ ('remind me', 'contains', 30, 'Generic reminder request'),
+ ('ping me', 'contains', 30, 'Generic reminder request'),
+ ('reach out again', 'contains', 30, 'Generic reminder request'),
+ ('follow up', 'contains', 14, 'Follow-up request'),
+ ('circle back', 'contains', 14, 'Follow-up request'),
+ ('when I have bandwidth', 'contains', 30, 'Capacity-based defer'),
+ ('when things calm down', 'contains', 30, 'Capacity-based defer')
+ON CONFLICT (pattern) DO NOTHING;
+
+-- =====================================================
+-- FUNCTIONS FOR RESPONSE ANALYSIS
+-- =====================================================
+
+-- Function to check if a response indicates refusal
+CREATE OR REPLACE FUNCTION check_refusal_pattern(response_text TEXT)
+RETURNS TABLE (
+ is_refusal BOOLEAN,
+ severity VARCHAR(20),
+ matched_pattern VARCHAR(255)
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ TRUE as is_refusal,
+ p.severity,
+ p.pattern as matched_pattern
+ FROM outreach_refusal_patterns p
+ WHERE p.is_active = TRUE
+ AND (
+ (p.pattern_type = 'contains' AND LOWER(response_text) LIKE '%' || LOWER(p.pattern) || '%')
+ OR (p.pattern_type = 'exact' AND LOWER(response_text) = LOWER(p.pattern))
+ )
+ LIMIT 1;
+
+ -- If no rows returned, return false
+ IF NOT FOUND THEN
+ RETURN QUERY SELECT FALSE, NULL::VARCHAR(20), NULL::VARCHAR(255);
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Function to check if a response indicates defer intent
+CREATE OR REPLACE FUNCTION check_defer_pattern(response_text TEXT)
+RETURNS TABLE (
+ is_defer BOOLEAN,
+ defer_days INTEGER,
+ matched_pattern VARCHAR(255)
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ TRUE as is_defer,
+ p.default_days as defer_days,
+ p.pattern as matched_pattern
+ FROM outreach_defer_patterns p
+ WHERE p.is_active = TRUE
+ AND (
+ (p.pattern_type = 'contains' AND LOWER(response_text) LIKE '%' || LOWER(p.pattern) || '%')
+ OR (p.pattern_type = 'exact' AND LOWER(response_text) = LOWER(p.pattern))
+ )
+ ORDER BY p.default_days DESC -- Prefer longer defer periods if multiple match
+ LIMIT 1;
+
+ -- If no rows returned, return false
+ IF NOT FOUND THEN
+ RETURN QUERY SELECT FALSE, NULL::INTEGER, NULL::VARCHAR(255);
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Function to analyze response and determine intent/sentiment
+CREATE OR REPLACE FUNCTION analyze_outreach_response(response_text TEXT)
+RETURNS TABLE (
+ sentiment VARCHAR(20),
+ intent VARCHAR(50),
+ follow_up_days INTEGER,
+ analysis_note TEXT
+) AS $$
+DECLARE
+ refusal_result RECORD;
+ defer_result RECORD;
+BEGIN
+ -- Check for refusal patterns first (highest priority)
+ SELECT * INTO refusal_result FROM check_refusal_pattern(response_text);
+
+ IF refusal_result.is_refusal THEN
+ RETURN QUERY SELECT
+ 'refusal'::VARCHAR(20) as sentiment,
+ 'refusal'::VARCHAR(50) as intent,
+ NULL::INTEGER as follow_up_days,
+ ('Matched refusal pattern: ' || refusal_result.matched_pattern)::TEXT as analysis_note;
+ RETURN;
+ END IF;
+
+ -- Check for defer patterns
+ SELECT * INTO defer_result FROM check_defer_pattern(response_text);
+
+ IF defer_result.is_defer THEN
+ RETURN QUERY SELECT
+ 'neutral'::VARCHAR(20) as sentiment,
+ 'deferred'::VARCHAR(50) as intent,
+ defer_result.defer_days as follow_up_days,
+ ('Matched defer pattern: ' || defer_result.matched_pattern)::TEXT as analysis_note;
+ RETURN;
+ END IF;
+
+ -- Check for positive indicators
+ IF LOWER(response_text) ~ '(done|linked|thanks|great|awesome|perfect|love|excited)' THEN
+ RETURN QUERY SELECT
+ 'positive'::VARCHAR(20) as sentiment,
+ 'interested'::VARCHAR(50) as intent,
+ NULL::INTEGER as follow_up_days,
+ 'Positive response indicators detected'::TEXT as analysis_note;
+ RETURN;
+ END IF;
+
+ -- Check for questions (indicates engagement)
+ IF response_text ~ '\?' THEN
+ RETURN QUERY SELECT
+ 'neutral'::VARCHAR(20) as sentiment,
+ 'question'::VARCHAR(50) as intent,
+ NULL::INTEGER as follow_up_days,
+ 'User asked a question - engage further'::TEXT as analysis_note;
+ RETURN;
+ END IF;
+
+ -- Default to neutral/interested if they responded at all
+ RETURN QUERY SELECT
+ 'neutral'::VARCHAR(20) as sentiment,
+ 'interested'::VARCHAR(50) as intent,
+ NULL::INTEGER as follow_up_days,
+ 'Response received - no specific pattern matched'::TEXT as analysis_note;
+END;
+$$ LANGUAGE plpgsql;
+
+-- =====================================================
+-- VIEWS FOR OUTREACH ANALYSIS
+-- =====================================================
+
+-- Enhanced outreach stats with sentiment breakdown
+CREATE OR REPLACE VIEW outreach_sentiment_stats AS
+SELECT
+ response_sentiment,
+ response_intent,
+ COUNT(*) as count,
+ ROUND(100.0 * COUNT(*) / NULLIF(SUM(COUNT(*)) OVER (), 0), 1) as percentage
+FROM member_outreach
+WHERE user_responded = TRUE
+GROUP BY response_sentiment, response_intent
+ORDER BY count DESC;
+
+-- Users needing follow-up (scheduled)
+CREATE OR REPLACE VIEW outreach_scheduled_followups AS
+SELECT
+ mo.id as outreach_id,
+ mo.slack_user_id,
+ sm.slack_real_name,
+ sm.slack_display_name,
+ sm.slack_email,
+ mo.follow_up_date,
+ mo.follow_up_reason,
+ mo.response_text,
+ mo.sent_at as original_outreach_at
+FROM member_outreach mo
+JOIN slack_user_mappings sm ON sm.slack_user_id = mo.slack_user_id
+WHERE mo.follow_up_date IS NOT NULL
+ AND mo.follow_up_date <= CURRENT_DATE + INTERVAL '7 days'
+ AND NOT EXISTS (
+ -- No newer outreach to this user
+ SELECT 1 FROM member_outreach mo2
+ WHERE mo2.slack_user_id = mo.slack_user_id
+ AND mo2.sent_at > mo.sent_at
+ )
+ORDER BY mo.follow_up_date;
+
+-- Users who explicitly refused (should not be contacted)
+CREATE OR REPLACE VIEW outreach_refused_users AS
+SELECT
+ mo.slack_user_id,
+ sm.slack_real_name,
+ sm.slack_display_name,
+ sm.slack_email,
+ mo.response_text,
+ mo.sent_at as refused_at
+FROM member_outreach mo
+JOIN slack_user_mappings sm ON sm.slack_user_id = mo.slack_user_id
+WHERE mo.response_sentiment = 'refusal'
+ OR mo.response_intent = 'refusal'
+ORDER BY mo.sent_at DESC;
+
+-- =====================================================
+-- COMMENTS
+-- =====================================================
+
+COMMENT ON TABLE outreach_refusal_patterns IS 'Patterns indicating explicit opt-out from outreach';
+COMMENT ON TABLE outreach_defer_patterns IS 'Patterns indicating "remind me later" intent';
+COMMENT ON COLUMN member_outreach.response_sentiment IS 'Detected sentiment: positive, neutral, negative, refusal';
+COMMENT ON COLUMN member_outreach.response_intent IS 'Detected intent: converted, interested, deferred, question, objection, refusal, ignored';
+COMMENT ON COLUMN member_outreach.follow_up_date IS 'Scheduled follow-up date if user requested to be contacted later';
+COMMENT ON COLUMN slack_user_mappings.slack_joined_at IS 'When user joined Slack (for grace period calculation)';
+COMMENT ON COLUMN slack_user_mappings.detected_seniority IS 'Inferred seniority level for tone matching';
diff --git a/server/src/db/migrations/102_improved_outreach_variants.sql b/server/src/db/migrations/102_improved_outreach_variants.sql
new file mode 100644
index 0000000000..7d4e063cec
--- /dev/null
+++ b/server/src/db/migrations/102_improved_outreach_variants.sql
@@ -0,0 +1,69 @@
+-- Migration: 102_improved_outreach_variants.sql
+-- Add improved outreach variants based on red team testing
+--
+-- Testing showed loss-framed messaging significantly outperforms
+-- the current ask-based approach (52% vs 24% effectiveness)
+
+-- Add new columns for targeting
+ALTER TABLE outreach_variants
+ADD COLUMN IF NOT EXISTS target_seniority VARCHAR(20)[] DEFAULT '{}',
+ADD COLUMN IF NOT EXISTS target_roles VARCHAR(50)[] DEFAULT '{}';
+
+COMMENT ON COLUMN outreach_variants.target_seniority IS 'Seniority levels this variant is appropriate for (empty = all)';
+COMMENT ON COLUMN outreach_variants.target_roles IS 'Roles this variant is appropriate for (empty = all)';
+
+-- Insert improved variants (keep existing for A/B testing)
+INSERT INTO outreach_variants (name, tone, approach, message_template, weight, target_seniority, target_roles)
+VALUES
+ (
+ 'Loss-Framed',
+ 'professional',
+ 'direct',
+ E'{{user_name}} - Your AgenticAdvertising.org membership isn''t connected to Slack yet, which means you''re not seeing:\n\n- Working group updates in channels you''re in\n- Your personalized event recommendations\n- Member directory access\n\nLink now (takes one click): {{link_url}}\n\n90% of active members connect within their first week.',
+ 150, -- Higher weight for testing
+ '{}',
+ '{}'
+ ),
+ (
+ 'Peer-Triggered',
+ 'professional',
+ 'direct',
+ E'{{user_name}} - I''m reaching out to the {{company_name}} team members who haven''t linked their accounts yet.\n\n{{link_url}}\n\nThis connects your Slack identity to your member profile so you can access working group resources, vote in governance, and show up correctly in the member directory.\n\nMost people complete it in under a minute.',
+ 100,
+ '{}',
+ '{}'
+ ),
+ (
+ 'Friction-First (Security Conscious)',
+ 'professional',
+ 'direct',
+ E'{{user_name}} - Quick account link request.\n\nClicking this will connect your Slack to AgenticAdvertising.org: {{link_url}}\n\nWhat happens: You''ll authorize the connection (no password needed), and you''re done.\n\nWhat you get: Access to your member dashboard, working group tools, and the ability to interact with me for org-related questions.\n\nWhat we don''t do: Spam you or share your data.',
+ 100,
+ '{}',
+ '{developer}'
+ ),
+ (
+ 'Executive Brief',
+ 'professional',
+ 'minimal',
+ E'{{user_name}} - Your AgenticAdvertising.org membership isn''t linked to Slack.\n\nThis 30-second setup unlocks your member dashboard and governance voting: {{link_url}}\n\nLet me know if you''d prefer a team member handle this instead.',
+ 100,
+ '{executive,senior}',
+ '{}'
+ ),
+ (
+ 'Context-Triggered',
+ 'professional',
+ 'conversational',
+ E'{{user_name}} - I saw you were interested in the {{context}} discussion.\n\nTo join that working group or access meeting notes, you''ll need to link your Slack to your member account: {{link_url}}\n\nTakes about 30 seconds. Let me know if you hit any issues.',
+ 100,
+ '{}',
+ '{}'
+ )
+ON CONFLICT DO NOTHING;
+
+-- Update existing variants with lower weight for A/B testing comparison
+UPDATE outreach_variants
+SET weight = 75
+WHERE name IN ('Direct + Transparent', 'Brief + Friendly', 'Conversational')
+ AND weight = 100;
diff --git a/server/src/db/migrations/103_sensitive_topics.sql b/server/src/db/migrations/103_sensitive_topics.sql
new file mode 100644
index 0000000000..73d66f7416
--- /dev/null
+++ b/server/src/db/migrations/103_sensitive_topics.sql
@@ -0,0 +1,335 @@
+-- Migration: 103_sensitive_topics.sql
+-- Sensitive topic detection for journalist-proofing Addie
+--
+-- Detects potentially quotable/risky questions that should be
+-- deflected to human contacts rather than answered by AI.
+--
+-- Categories:
+-- 1. Vulnerable populations (children, elderly, low-income)
+-- 2. Political/regulatory topics
+-- 3. Named individuals (especially founders/leadership)
+-- 4. "What does AAO think about..." framing
+-- 5. Competitive comparisons seeking quotable statements
+-- 6. Privacy/surveillance concerns
+-- 7. Ethical AI/advertising concerns
+
+-- =====================================================
+-- SENSITIVE TOPIC PATTERNS TABLE
+-- =====================================================
+
+CREATE TABLE IF NOT EXISTS sensitive_topic_patterns (
+ id SERIAL PRIMARY KEY,
+ pattern VARCHAR(500) NOT NULL,
+ pattern_type VARCHAR(20) NOT NULL CHECK (pattern_type IN ('contains', 'regex', 'exact')),
+ category VARCHAR(50) NOT NULL CHECK (category IN (
+ 'vulnerable_populations',
+ 'political',
+ 'named_individual',
+ 'organization_position',
+ 'competitive',
+ 'privacy_surveillance',
+ 'ethical_concerns',
+ 'media_inquiry'
+ )),
+ severity VARCHAR(20) NOT NULL CHECK (severity IN ('high', 'medium', 'low')),
+ -- high = always deflect to human
+ -- medium = deflect + flag for review
+ -- low = flag for review but can answer
+ deflect_response TEXT,
+ description TEXT,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Index for efficient pattern matching
+CREATE INDEX IF NOT EXISTS idx_sensitive_patterns_active
+ ON sensitive_topic_patterns(is_active, category);
+
+-- =====================================================
+-- SEED SENSITIVE TOPIC PATTERNS
+-- =====================================================
+
+INSERT INTO sensitive_topic_patterns (pattern, pattern_type, category, severity, deflect_response, description) VALUES
+
+-- VULNERABLE POPULATIONS (HIGH SEVERITY)
+('children', 'contains', 'vulnerable_populations', 'high',
+ 'Questions about advertising and minors deserve careful consideration. I''d recommend reaching out to our policy team for an official perspective.',
+ 'Any mention of children/minors'),
+('kids', 'contains', 'vulnerable_populations', 'high',
+ 'Questions about advertising and minors deserve careful consideration. I''d recommend reaching out to our policy team for an official perspective.',
+ 'Any mention of kids'),
+('teens', 'contains', 'vulnerable_populations', 'high',
+ 'Questions about advertising and minors deserve careful consideration. I''d recommend reaching out to our policy team for an official perspective.',
+ 'Any mention of teenagers'),
+('youth', 'contains', 'vulnerable_populations', 'high',
+ 'Questions about advertising and minors deserve careful consideration. I''d recommend reaching out to our policy team for an official perspective.',
+ 'Any mention of youth'),
+('vulnerable', 'contains', 'vulnerable_populations', 'high',
+ 'Questions about vulnerable populations and advertising ethics deserve careful consideration. I''d recommend reaching out to our policy team.',
+ 'Any mention of vulnerable populations'),
+('elderly', 'contains', 'vulnerable_populations', 'medium',
+ 'That''s an important topic that deserves a thoughtful response. Let me connect you with someone who can speak to this properly.',
+ 'Elderly/senior targeting'),
+('low.?income', 'regex', 'vulnerable_populations', 'medium',
+ 'That''s an important topic that deserves a thoughtful response. Let me connect you with someone who can speak to this properly.',
+ 'Low-income targeting'),
+('predatory', 'contains', 'vulnerable_populations', 'high',
+ 'That''s a serious concern that deserves proper attention. Let me connect you with our leadership team.',
+ 'Predatory advertising concerns'),
+
+-- POLITICAL/REGULATORY (HIGH SEVERITY)
+('political', 'contains', 'political', 'high',
+ 'Questions about political advertising are important but nuanced. I''d recommend reaching out to our policy team for the most accurate perspective.',
+ 'Political advertising'),
+('election', 'contains', 'political', 'high',
+ 'Questions about election-related advertising require careful handling. Let me connect you with someone who can speak authoritatively on this.',
+ 'Election advertising'),
+('campaign', 'contains', 'political', 'medium',
+ 'If this is about political campaigns, I''d recommend speaking with our policy team for a proper response.',
+ 'Campaign advertising (could be political)'),
+('regulation', 'contains', 'political', 'medium',
+ 'Regulatory questions deserve accurate answers. Let me flag this for someone with policy expertise.',
+ 'Regulatory concerns'),
+('ftc', 'contains', 'political', 'high',
+ 'Questions about FTC matters should be handled by our policy team. Let me connect you.',
+ 'FTC mentions'),
+('congress', 'contains', 'political', 'high',
+ 'Questions about legislative matters should be handled by our policy team.',
+ 'Congressional/legislative'),
+('antitrust', 'contains', 'political', 'high',
+ 'Antitrust questions require careful handling. Let me connect you with appropriate contacts.',
+ 'Antitrust concerns'),
+
+-- NAMED INDIVIDUALS (HIGH SEVERITY)
+('brian o''kelley', 'contains', 'named_individual', 'high',
+ 'For questions about Brian specifically, I''d recommend reaching out directly or through official channels.',
+ 'Direct mention of Brian O''Kelley'),
+('o''kelley', 'contains', 'named_individual', 'high',
+ 'For questions about Brian specifically, I''d recommend reaching out directly or through official channels.',
+ 'O''Kelley surname'),
+('okelley', 'contains', 'named_individual', 'high',
+ 'For questions about Brian specifically, I''d recommend reaching out directly or through official channels.',
+ 'O''Kelley without apostrophe'),
+('founder', 'contains', 'named_individual', 'medium',
+ 'Questions about our founders are best directed to them personally or through official channels.',
+ 'Founder references'),
+('ceo', 'contains', 'named_individual', 'medium',
+ 'Questions about leadership are best handled through official channels.',
+ 'CEO references'),
+('your boss', 'contains', 'named_individual', 'high',
+ 'I''m an AI assistant. For questions about organizational leadership, I can connect you with the right contacts.',
+ 'Informal leadership reference'),
+
+-- ORGANIZATION POSITION SEEKING (HIGH SEVERITY)
+('what does (aao|agenticadvertising|the organization) (think|believe|feel)', 'regex', 'organization_position', 'high',
+ 'For official organizational positions, I''d recommend checking our public documentation or reaching out to our communications team.',
+ 'Seeking org position'),
+('official (position|stance|view)', 'regex', 'organization_position', 'high',
+ 'For official positions, please refer to our public documentation or contact our communications team.',
+ 'Official position seeking'),
+('on the record', 'contains', 'organization_position', 'high',
+ 'I can''t provide on-the-record statements. Please reach out to our communications team for official statements.',
+ 'On the record request'),
+('for (the|a) (story|article|piece|report)', 'regex', 'media_inquiry', 'high',
+ 'For media inquiries, please reach out to our communications team who can provide appropriate responses.',
+ 'Media story request'),
+('i''m (a |)journalist', 'regex', 'media_inquiry', 'high',
+ 'Thanks for reaching out! For media inquiries, please contact our communications team who can best assist you.',
+ 'Journalist self-identification'),
+('i''m (a |)(reporter|writer|editor)', 'regex', 'media_inquiry', 'high',
+ 'Thanks for reaching out! For media inquiries, please contact our communications team who can best assist you.',
+ 'Media professional self-identification'),
+('can i quote', 'contains', 'media_inquiry', 'high',
+ 'I''m an AI assistant, so quoting me wouldn''t be appropriate. For quotable statements, please reach out to our communications team.',
+ 'Quote request'),
+('quote you', 'contains', 'media_inquiry', 'high',
+ 'I''m an AI assistant, so quoting me wouldn''t be appropriate. For quotable statements, please reach out to our communications team.',
+ 'Quote request variant'),
+
+-- COMPETITIVE (MEDIUM SEVERITY)
+('better than', 'contains', 'competitive', 'medium',
+ 'I focus on what AgenticAdvertising.org does rather than comparisons. Happy to explain our approach!',
+ 'Comparative question'),
+('worse than', 'contains', 'competitive', 'medium',
+ 'I focus on what AgenticAdvertising.org does rather than comparisons. Happy to explain our approach!',
+ 'Negative comparison'),
+('vs\\.?\\s+(iab|trade\\s*desk|google|meta|amazon)', 'regex', 'competitive', 'medium',
+ 'I''m not the best source for competitive comparisons. I can tell you about what we''re building though!',
+ 'Direct competitor comparison'),
+('what do you think (of|about) (iab|trade\\s*desk)', 'regex', 'competitive', 'high',
+ 'I focus on our own work rather than commenting on others. What would you like to know about AdCP?',
+ 'Opinion seeking on competitor'),
+
+-- PRIVACY/SURVEILLANCE (HIGH SEVERITY)
+('surveillance', 'contains', 'privacy_surveillance', 'high',
+ 'Privacy and data ethics are important topics that deserve thoughtful discussion. I''d recommend our documentation on privacy-preserving approaches.',
+ 'Surveillance concerns'),
+('spy', 'contains', 'privacy_surveillance', 'high',
+ 'Data privacy is a serious topic. Our approach is documented, but for deeper questions, our policy team can help.',
+ 'Spying concerns'),
+('track(ing)? (people|users|consumers)', 'regex', 'privacy_surveillance', 'high',
+ 'User tracking and privacy are important topics. Our technical documentation covers our approach, or I can connect you with our policy team.',
+ 'Tracking concerns'),
+('without (consent|permission|knowing)', 'regex', 'privacy_surveillance', 'high',
+ 'Consent is fundamental to ethical advertising. For detailed questions on this topic, our policy team can provide thorough answers.',
+ 'Consent concerns'),
+
+-- ETHICAL CONCERNS (MEDIUM-HIGH SEVERITY)
+('manipulat', 'contains', 'ethical_concerns', 'high',
+ 'Manipulation concerns in advertising are valid and worth discussing seriously. For nuanced perspectives, I''d recommend our ethics documentation.',
+ 'Manipulation concerns'),
+('exploit', 'contains', 'ethical_concerns', 'high',
+ 'Exploitation concerns deserve serious consideration. For detailed discussion, our policy team can help.',
+ 'Exploitation concerns'),
+('harm', 'contains', 'ethical_concerns', 'medium',
+ 'Questions about potential harms are important. Let me know more about your specific concern so I can direct you appropriately.',
+ 'Harm concerns'),
+('dangerous', 'contains', 'ethical_concerns', 'medium',
+ 'Safety concerns are worth taking seriously. Can you tell me more about your specific concern?',
+ 'Danger concerns'),
+('problematic', 'contains', 'ethical_concerns', 'low',
+ NULL, -- Low severity, just flag
+ 'Problematic framing'),
+('concern(ed|s)? about', 'regex', 'ethical_concerns', 'low',
+ NULL,
+ 'General concern expression')
+
+ON CONFLICT DO NOTHING;
+
+-- =====================================================
+-- FLAGGED CONVERSATIONS TABLE
+-- =====================================================
+-- Track conversations that hit sensitive topics
+
+CREATE TABLE IF NOT EXISTS flagged_conversations (
+ id SERIAL PRIMARY KEY,
+ slack_user_id VARCHAR(50) NOT NULL,
+ slack_channel_id VARCHAR(50),
+ message_text TEXT NOT NULL,
+ matched_pattern_id INTEGER REFERENCES sensitive_topic_patterns(id),
+ matched_category VARCHAR(50),
+ severity VARCHAR(20),
+ response_given TEXT,
+ was_deflected BOOLEAN DEFAULT FALSE,
+ reviewed_by INTEGER REFERENCES users(id),
+ reviewed_at TIMESTAMP WITH TIME ZONE,
+ review_notes TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+-- Indexes for review workflow
+CREATE INDEX IF NOT EXISTS idx_flagged_unreviewed
+ ON flagged_conversations(reviewed_at)
+ WHERE reviewed_at IS NULL;
+CREATE INDEX IF NOT EXISTS idx_flagged_by_user
+ ON flagged_conversations(slack_user_id);
+CREATE INDEX IF NOT EXISTS idx_flagged_by_severity
+ ON flagged_conversations(severity, created_at DESC);
+
+-- =====================================================
+-- KNOWN MEDIA CONTACTS TABLE
+-- =====================================================
+-- Track known journalists/media contacts for special handling
+
+CREATE TABLE IF NOT EXISTS known_media_contacts (
+ id SERIAL PRIMARY KEY,
+ slack_user_id VARCHAR(50) UNIQUE,
+ email VARCHAR(255),
+ name VARCHAR(255),
+ organization VARCHAR(255),
+ role VARCHAR(100),
+ notes TEXT,
+ handling_level VARCHAR(20) DEFAULT 'standard'
+ CHECK (handling_level IN ('standard', 'careful', 'executive_only')),
+ is_active BOOLEAN DEFAULT TRUE,
+ added_by INTEGER REFERENCES users(id),
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_media_contacts_slack
+ ON known_media_contacts(slack_user_id)
+ WHERE is_active = TRUE;
+
+-- =====================================================
+-- FUNCTION TO CHECK SENSITIVE TOPICS
+-- =====================================================
+
+CREATE OR REPLACE FUNCTION check_sensitive_topic(message_text TEXT)
+RETURNS TABLE (
+ is_sensitive BOOLEAN,
+ pattern_id INTEGER,
+ category VARCHAR(50),
+ severity VARCHAR(20),
+ deflect_response TEXT
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ TRUE as is_sensitive,
+ p.id as pattern_id,
+ p.category,
+ p.severity,
+ p.deflect_response
+ FROM sensitive_topic_patterns p
+ WHERE p.is_active = TRUE
+ AND (
+ (p.pattern_type = 'contains' AND LOWER(message_text) LIKE '%' || LOWER(p.pattern) || '%')
+ OR (p.pattern_type = 'exact' AND LOWER(message_text) = LOWER(p.pattern))
+ OR (p.pattern_type = 'regex' AND LOWER(message_text) ~ LOWER(p.pattern))
+ )
+ ORDER BY
+ CASE p.severity
+ WHEN 'high' THEN 1
+ WHEN 'medium' THEN 2
+ WHEN 'low' THEN 3
+ END
+ LIMIT 1;
+
+ -- If no rows returned, return false
+ IF NOT FOUND THEN
+ RETURN QUERY SELECT FALSE, NULL::INTEGER, NULL::VARCHAR(50), NULL::VARCHAR(20), NULL::TEXT;
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+-- =====================================================
+-- VIEW FOR REVIEW QUEUE
+-- =====================================================
+
+CREATE OR REPLACE VIEW flagged_conversation_queue AS
+SELECT
+ fc.id,
+ fc.slack_user_id,
+ sm.slack_real_name as user_name,
+ sm.slack_email as user_email,
+ kmc.organization as known_media_org,
+ kmc.handling_level,
+ fc.message_text,
+ fc.matched_category,
+ fc.severity,
+ fc.response_given,
+ fc.was_deflected,
+ fc.created_at,
+ fc.reviewed_at IS NOT NULL as is_reviewed
+FROM flagged_conversations fc
+LEFT JOIN slack_user_mappings sm ON sm.slack_user_id = fc.slack_user_id
+LEFT JOIN known_media_contacts kmc ON kmc.slack_user_id = fc.slack_user_id AND kmc.is_active = TRUE
+WHERE fc.reviewed_at IS NULL
+ORDER BY
+ CASE fc.severity
+ WHEN 'high' THEN 1
+ WHEN 'medium' THEN 2
+ WHEN 'low' THEN 3
+ END,
+ fc.created_at DESC;
+
+-- =====================================================
+-- COMMENTS
+-- =====================================================
+
+COMMENT ON TABLE sensitive_topic_patterns IS 'Patterns for detecting journalist-bait and sensitive topics that should be deflected to humans';
+COMMENT ON TABLE flagged_conversations IS 'Conversations that hit sensitive topics, pending or completed review';
+COMMENT ON TABLE known_media_contacts IS 'Known journalists/media professionals for careful handling';
+COMMENT ON COLUMN sensitive_topic_patterns.severity IS 'high = always deflect, medium = deflect + flag, low = flag only';
+COMMENT ON COLUMN known_media_contacts.handling_level IS 'standard = normal deflection, careful = extra caution, executive_only = escalate immediately';
diff --git a/server/src/routes/admin-insights.ts b/server/src/routes/admin-insights.ts
index 4f325eb2ec..884b6da0d9 100644
--- a/server/src/routes/admin-insights.ts
+++ b/server/src/routes/admin-insights.ts
@@ -21,6 +21,22 @@ import {
canContactUser,
} from '../addie/services/proactive-outreach.js';
import { invalidateInsightsCache, invalidateGoalsCache } from '../addie/insights-cache.js';
+import {
+ getActionItems,
+ getOpenActionItems,
+ getActionItemStats,
+ completeActionItem,
+ dismissActionItem,
+ snoozeActionItem,
+ createActionItem,
+ getMyAccounts,
+ assignUserStakeholder,
+ removeUserStakeholder,
+ getUserStakeholders,
+ type ActionType,
+ type ActionPriority,
+} from '../db/account-management-db.js';
+import { runMomentumCheck, dryRunMomentumCheck, previewMomentumForUser } from '../addie/jobs/momentum-check.js';
const logger = createLogger('admin-insights-routes');
const insightsDb = new InsightsDatabase();
@@ -715,7 +731,14 @@ export function createAdminInsightsRouter(): { pageRouter: Router; apiRouter: Ro
return res.status(400).json({ error: eligibility.reason });
}
- const result = await manualOutreach(slackUserId);
+ // Pass admin info for auto-assignment
+ const triggeredBy = req.user ? {
+ id: req.user.id,
+ name: `${req.user.firstName || ''} ${req.user.lastName || ''}`.trim() || req.user.email,
+ email: req.user.email,
+ } : undefined;
+
+ const result = await manualOutreach(slackUserId, triggeredBy);
if (result.success) {
logger.info({ slackUserId, triggeredBy: req.user?.id }, 'Manual outreach sent');
@@ -839,5 +862,317 @@ export function createAdminInsightsRouter(): { pageRouter: Router; apiRouter: Ro
}
});
+ // =========================================================================
+ // ACTION ITEMS API ROUTES
+ // =========================================================================
+
+ // GET /api/admin/action-items - Get action items with filters
+ apiRouter.get('/action-items', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const {
+ assigned_to,
+ slack_user_id,
+ workos_user_id,
+ org_id,
+ status,
+ action_type,
+ priority,
+ limit,
+ offset,
+ } = req.query;
+
+ const items = await getActionItems({
+ assignedTo: assigned_to as string,
+ slackUserId: slack_user_id as string,
+ workosUserId: workos_user_id as string,
+ orgId: org_id as string,
+ status: status as 'open' | 'snoozed' | 'completed' | 'dismissed',
+ actionType: action_type as ActionType,
+ priority: priority as ActionPriority,
+ limit: limit ? parseInt(limit as string, 10) : undefined,
+ offset: offset ? parseInt(offset as string, 10) : undefined,
+ });
+
+ res.json({ items });
+ } catch (error) {
+ logger.error({ err: error }, 'Error fetching action items');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // GET /api/admin/action-items/mine - Get my action items
+ apiRouter.get('/action-items/mine', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 20;
+ const items = await getOpenActionItems(req.user?.id, limit);
+ const stats = await getActionItemStats(req.user?.id);
+
+ res.json({ items, stats });
+ } catch (error) {
+ logger.error({ err: error }, 'Error fetching my action items');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // GET /api/admin/action-items/stats - Get action item statistics
+ apiRouter.get('/action-items/stats', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const assignedTo = req.query.assigned_to as string | undefined;
+ const stats = await getActionItemStats(assignedTo);
+ res.json(stats);
+ } catch (error) {
+ logger.error({ err: error }, 'Error fetching action item stats');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // POST /api/admin/action-items - Create an action item
+ apiRouter.post('/action-items', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const {
+ slack_user_id,
+ workos_user_id,
+ org_id,
+ assigned_to,
+ action_type,
+ priority,
+ title,
+ description,
+ context,
+ } = req.body;
+
+ if (!action_type || !title) {
+ return res.status(400).json({ error: 'action_type and title are required' });
+ }
+
+ const item = await createActionItem({
+ slackUserId: slack_user_id,
+ workosUserId: workos_user_id,
+ orgId: org_id,
+ assignedTo: assigned_to || req.user?.id,
+ actionType: action_type,
+ priority: priority || 'medium',
+ title,
+ description,
+ context,
+ triggerType: 'manual',
+ triggerId: `manual-${Date.now()}`,
+ });
+
+ res.json(item);
+ } catch (error) {
+ logger.error({ err: error }, 'Error creating action item');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // POST /api/admin/action-items/:id/complete - Complete an action item
+ apiRouter.post('/action-items/:id/complete', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const id = parseIntId(req.params.id);
+ if (id === null) {
+ return res.status(400).json({ error: 'Invalid action item ID' });
+ }
+
+ if (!req.user?.id) {
+ return res.status(401).json({ error: 'User ID not available' });
+ }
+
+ const { resolution_note } = req.body;
+ const item = await completeActionItem(id, req.user.id, resolution_note);
+
+ if (!item) {
+ return res.status(404).json({ error: 'Action item not found' });
+ }
+
+ res.json(item);
+ } catch (error) {
+ logger.error({ err: error }, 'Error completing action item');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // POST /api/admin/action-items/:id/dismiss - Dismiss an action item
+ apiRouter.post('/action-items/:id/dismiss', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const id = parseIntId(req.params.id);
+ if (id === null) {
+ return res.status(400).json({ error: 'Invalid action item ID' });
+ }
+
+ if (!req.user?.id) {
+ return res.status(401).json({ error: 'User ID not available' });
+ }
+
+ const { resolution_note } = req.body;
+ const item = await dismissActionItem(id, req.user.id, resolution_note);
+
+ if (!item) {
+ return res.status(404).json({ error: 'Action item not found' });
+ }
+
+ res.json(item);
+ } catch (error) {
+ logger.error({ err: error }, 'Error dismissing action item');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // POST /api/admin/action-items/:id/snooze - Snooze an action item
+ apiRouter.post('/action-items/:id/snooze', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const id = parseIntId(req.params.id);
+ if (id === null) {
+ return res.status(400).json({ error: 'Invalid action item ID' });
+ }
+
+ const { until } = req.body;
+ if (!until) {
+ return res.status(400).json({ error: 'until date is required' });
+ }
+
+ const parsedDate = new Date(until);
+ if (isNaN(parsedDate.getTime())) {
+ return res.status(400).json({ error: 'Invalid date format for until' });
+ }
+
+ const item = await snoozeActionItem(id, parsedDate);
+
+ if (!item) {
+ return res.status(404).json({ error: 'Action item not found' });
+ }
+
+ res.json(item);
+ } catch (error) {
+ logger.error({ err: error }, 'Error snoozing action item');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // POST /api/admin/action-items/check-momentum - Run momentum check job
+ apiRouter.post('/action-items/check-momentum', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const result = await runMomentumCheck();
+ res.json(result);
+ } catch (error) {
+ logger.error({ err: error }, 'Error running momentum check');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // GET /api/admin/action-items/check-momentum/dry-run - Preview what momentum check would do
+ apiRouter.get('/action-items/check-momentum/dry-run', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const result = await dryRunMomentumCheck();
+ res.json(result);
+ } catch (error) {
+ logger.error({ err: error }, 'Error running momentum check dry-run');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // GET /api/admin/action-items/preview/:slackUserId - Preview momentum analysis for a specific user
+ apiRouter.get('/action-items/preview/:slackUserId', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const { slackUserId } = req.params;
+ const result = await previewMomentumForUser(slackUserId);
+ res.json(result);
+ } catch (error) {
+ logger.error({ err: error, slackUserId: req.params.slackUserId }, 'Error previewing momentum for user');
+ if (error instanceof Error && error.message.includes('not found')) {
+ return res.status(404).json({ error: error.message });
+ }
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // =========================================================================
+ // ACCOUNT MANAGEMENT API ROUTES
+ // =========================================================================
+
+ // GET /api/admin/my-accounts - Get accounts assigned to current user
+ apiRouter.get('/my-accounts', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const accounts = await getMyAccounts(req.user?.id || '');
+ res.json({ accounts });
+ } catch (error) {
+ logger.error({ err: error }, 'Error fetching my accounts');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // GET /api/admin/users/:userId/stakeholders - Get stakeholders for a user
+ apiRouter.get('/users/:userId/stakeholders', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const { userId } = req.params;
+ const { type } = req.query;
+
+ const stakeholders = await getUserStakeholders(
+ type === 'slack' ? userId : undefined,
+ type === 'workos' ? userId : undefined
+ );
+
+ res.json({ stakeholders });
+ } catch (error) {
+ logger.error({ err: error }, 'Error fetching user stakeholders');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // POST /api/admin/users/:userId/assign - Assign user to admin
+ apiRouter.post('/users/:userId/assign', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const { userId } = req.params;
+ const { type, role, notes } = req.body;
+
+ if (!req.user) {
+ return res.status(401).json({ error: 'Not authenticated' });
+ }
+
+ const stakeholder = await assignUserStakeholder({
+ slackUserId: type === 'slack' ? userId : undefined,
+ workosUserId: type === 'workos' ? userId : undefined,
+ stakeholderId: req.user.id,
+ stakeholderName: `${req.user.firstName || ''} ${req.user.lastName || ''}`.trim() || req.user.email,
+ stakeholderEmail: req.user.email,
+ role: role || 'owner',
+ reason: 'manual',
+ notes,
+ });
+
+ if (!stakeholder) {
+ return res.status(409).json({ error: 'User already assigned' });
+ }
+
+ res.json(stakeholder);
+ } catch (error) {
+ logger.error({ err: error }, 'Error assigning user');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
+ // DELETE /api/admin/users/:userId/unassign - Remove assignment
+ apiRouter.delete('/users/:userId/unassign', requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const { userId } = req.params;
+ const { type } = req.query;
+
+ if (!req.user) {
+ return res.status(401).json({ error: 'Not authenticated' });
+ }
+
+ const removed = await removeUserStakeholder(
+ type === 'slack' ? userId : undefined,
+ type === 'workos' ? userId : undefined,
+ req.user.id
+ );
+
+ res.json({ removed });
+ } catch (error) {
+ logger.error({ err: error }, 'Error unassigning user');
+ res.status(500).json({ error: 'Internal server error' });
+ }
+ });
+
return { pageRouter, apiRouter };
}