From fe87f50b90d343082ba76f1b416785c029f5677f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 5 Jan 2026 11:42:07 -0800 Subject: [PATCH 1/2] Add member search, introduction emails, and analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - search_members tool for Addie to find member organizations - request_introduction tool to send introduction emails via Resend - Member search analytics tracking (impressions, clicks, introductions) - Admin analytics endpoint and Addie tool for viewing search analytics - Improved member profile search to include offerings and tokenize queries Security: - Event delegation for member card clicks (no inline onclick) - Email validation for introduction requests - Subject line sanitization for introduction emails šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .changeset/member-search-intros.md | 4 + server/public/chat.html | 272 ++++++++++- server/public/index.html | 423 ++++++++++++++++- server/public/member-profile.html | 83 ++++ server/src/addie/mcp/admin-tools.ts | 141 ++++++ server/src/addie/mcp/member-tools.ts | 369 +++++++++++++++ server/src/addie/prompts.ts | 15 + server/src/addie/router.ts | 27 ++ server/src/db/member-db.ts | 47 +- server/src/db/member-search-analytics-db.ts | 442 ++++++++++++++++++ .../141_member_search_analytics.sql | 43 ++ server/src/http.ts | 47 ++ server/src/notifications/email.ts | 127 +++++ server/src/routes/admin/stats.ts | 86 ++++ 14 files changed, 2112 insertions(+), 14 deletions(-) create mode 100644 .changeset/member-search-intros.md create mode 100644 server/src/db/member-search-analytics-db.ts create mode 100644 server/src/db/migrations/141_member_search_analytics.sql diff --git a/.changeset/member-search-intros.md b/.changeset/member-search-intros.md new file mode 100644 index 0000000000..11dfde4a52 --- /dev/null +++ b/.changeset/member-search-intros.md @@ -0,0 +1,4 @@ +--- +--- + +Member search analytics and introduction email improvements (no protocol changes) diff --git a/server/public/chat.html b/server/public/chat.html index 590d19713b..2f23864aab 100644 --- a/server/public/chat.html +++ b/server/public/chat.html @@ -226,6 +226,142 @@ padding: 16px; } + /* Member cards from search results */ + .member-cards-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; + margin: 12px 0; + } + + .member-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 16px; + text-decoration: none; + color: inherit; + display: flex; + flex-direction: column; + gap: 8px; + transition: all 0.2s; + } + + .member-card:hover { + border-color: var(--color-brand); + box-shadow: 0 4px 12px rgba(26, 54, 180, 0.1); + transform: translateY(-2px); + } + + .member-card-header { + display: flex; + align-items: center; + gap: 12px; + } + + .member-card-logo { + width: 48px; + height: 48px; + border-radius: 8px; + object-fit: contain; + background: var(--color-bg-subtle); + flex-shrink: 0; + } + + .member-card-logo-placeholder { + width: 48px; + height: 48px; + border-radius: 8px; + background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-primary-600) 100%); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 18px; + flex-shrink: 0; + } + + .member-card-title { + flex: 1; + min-width: 0; + } + + .member-card-name { + font-weight: 600; + font-size: 15px; + color: var(--color-text-heading); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .member-card-tagline { + font-size: 13px; + color: var(--color-text-secondary); + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .member-card-description { + font-size: 13px; + color: var(--color-text-secondary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; + } + + .member-card-offerings { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; + } + + .member-card-offering { + font-size: 11px; + padding: 2px 8px; + background: var(--color-primary-50, #eef2ff); + color: var(--color-brand); + border-radius: 12px; + white-space: nowrap; + } + + .member-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: auto; + padding-top: 8px; + border-top: 1px solid var(--color-border); + } + + .member-card-location { + font-size: 12px; + color: var(--color-text-muted); + display: flex; + align-items: center; + gap: 4px; + } + + .member-card-cta { + font-size: 12px; + color: var(--color-brand); + font-weight: 500; + } + + @media (max-width: 600px) { + .member-cards-container { + grid-template-columns: 1fr; + } + } + .message-content ul, .message-content ol { margin: 8px 0; padding-left: 20px; @@ -1256,6 +1392,18 @@

Hi! I'm Addie

// Tab state - persisted to localStorage let activeTabs = []; // [{id, title, channel, isLoading, unreadCount}] let currentTabId = 'home'; // 'home' or conversation_id + + // Event delegation for member card clicks (more secure than inline onclick) + messagesContainer.addEventListener('click', function(e) { + const card = e.target.closest('.member-card'); + if (card) { + const slug = card.dataset.slug; + const sessionId = card.dataset.sessionId || null; + if (slug) { + trackMemberClick(slug, sessionId); + } + } + }); const originalTitle = document.title; // Native app auth token (from URL hash, e.g., #token=xxx) @@ -1840,6 +1988,91 @@

Hi! I'm Addie

sendButton.disabled = !hasText || isLoading || !isReady; } + // Track member card clicks for analytics + function trackMemberClick(slug, searchSessionId) { + // Fire-and-forget analytics tracking + fetch(`/api/members/${encodeURIComponent(slug)}/click`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ search_session_id: searchSessionId }), + credentials: 'include', + }).catch(() => { + // Ignore errors - analytics shouldn't block navigation + }); + } + + // Render member search results as cards + function renderMemberCards(data) { + const searchSessionId = data.search_session_id || null; + const offeringLabels = { + buyer_agent: 'Buyer Agent', + sales_agent: 'Sales Agent', + creative_agent: 'Creative Agent', + signals_agent: 'Signals Agent', + publisher: 'Publisher', + consulting: 'Consulting', + managed_services: 'Managed Services', + implementation: 'Implementation', + other: 'Other', + }; + + const escapeHtml = (str) => { + if (!str) return ''; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + + const cards = data.results.map(member => { + const logoHtml = member.logo_url + ? `` + : `
${escapeHtml(member.display_name.charAt(0))}
`; + + const taglineHtml = member.tagline + ? `

${escapeHtml(member.tagline)}

` + : ''; + + const descriptionHtml = member.description + ? `

${escapeHtml(member.description)}

` + : ''; + + const offeringsHtml = member.offerings && member.offerings.length > 0 + ? `
${member.offerings.slice(0, 3).map(o => + `${escapeHtml(offeringLabels[o] || o)}` + ).join('')}
` + : ''; + + const locationHtml = member.headquarters + ? `šŸ“ ${escapeHtml(member.headquarters)}` + : ''; + + // Add onclick handler to track clicks + const sessionIdAttr = searchSessionId ? `data-session-id="${escapeHtml(searchSessionId)}"` : ''; + + return ` + +
+ ${logoHtml} +
+

${escapeHtml(member.display_name)}

+ ${taglineHtml} +
+
+ ${descriptionHtml} + ${offeringsHtml} + +
+ `; + }).join(''); + + return `
${cards}
`; + } + // Render markdown using marked library function renderMessage(text) { // Configure marked for security and compatibility @@ -1855,6 +2088,21 @@

Hi! I'm Addie

.replace(//g, '>'); + // Check for embedded ADDIE_DATA blocks and extract them + const dataBlockRegex = //g; + const dataBlocks = []; + let processedText = text.replace(dataBlockRegex, (match, jsonStr) => { + try { + const data = JSON.parse(jsonStr); + const placeholder = `__ADDIE_DATA_PLACEHOLDER_${dataBlocks.length}__`; + dataBlocks.push(data); + return placeholder; + } catch (e) { + console.warn('Failed to parse ADDIE_DATA block:', e); + return ''; // Remove invalid blocks + } + }); + // Use marked's built-in renderer with custom link and image handling const renderer = new marked.Renderer(); renderer.link = function(href, title, text) { @@ -1866,9 +2114,12 @@

Hi! I'm Addie

text = link.text; } // Validate URL scheme - only allow safe protocols - const safeHref = /^(https?:\/\/|mailto:|#)/i.test(href) ? href : '#'; + const safeHref = /^(https?:\/\/|mailto:|#|\/)/i.test(href) ? href : '#'; const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''; - return `${text}`; + // Use target="_blank" only for external links + const isExternal = /^https?:\/\//i.test(href); + const targetAttr = isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''; + return `${text}`; }; // Custom image renderer @@ -1889,7 +2140,22 @@

Hi! I'm Addie

return ``; }; - return marked.parse(text, { renderer }); + let html = marked.parse(processedText, { renderer }); + + // Replace placeholders with rendered components + dataBlocks.forEach((data, index) => { + const placeholder = `__ADDIE_DATA_PLACEHOLDER_${index}__`; + let replacement = ''; + + if (data.type === 'member_search_results') { + replacement = renderMemberCards(data); + } + // Add more types here as needed + + html = html.replace(placeholder, replacement); + }); + + return html; } // Render creative preview (iframe or HTML) diff --git a/server/public/index.html b/server/public/index.html index 7f85ac2d3b..42e5c85070 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -252,6 +252,84 @@ .audienceCard_fWZI .button--outline:hover { background-color: rgba(26, 54, 180, 0.1) !important; } + /* Go Agentic Section */ + .goAgenticSection_AdCP { + padding: 3rem 0; + background: white; + border-top: 1px solid var(--ifm-color-emphasis-200, #e5e7eb); + } + .goAgenticCard_AdCP { + background: linear-gradient(135deg, var(--color-primary-50, #eef2ff) 0%, var(--color-primary-100, #e0e7ff) 100%); + border-radius: 12px; + padding: 2.5rem; + text-align: center; + } + .goAgenticCard_AdCP h3 { + font-size: 1.75rem; + margin-bottom: 0.5rem; + color: var(--ifm-heading-color); + } + .goAgenticCard_AdCP .subtitle { + font-size: 1.1rem; + color: var(--ifm-color-emphasis-700); + margin-bottom: 2rem; + } + .promptHints_AdCP { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-bottom: 1.5rem; + } + .promptHint_AdCP { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: white; + border: 1px solid var(--ifm-color-emphasis-300, #d1d5db); + border-radius: 24px; + padding: 0.625rem 1.25rem; + font-size: 0.9rem; + color: var(--ifm-color-emphasis-800); + text-decoration: none; + transition: all 0.2s; + cursor: pointer; + } + .promptHint_AdCP:hover { + background: var(--color-brand, #1a36b4); + color: white; + border-color: var(--color-brand, #1a36b4); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(26, 54, 180, 0.2); + } + .promptHint_AdCP::before { + content: '→'; + font-weight: bold; + } + .goAgenticFooter_AdCP { + font-size: 0.85rem; + color: var(--ifm-color-emphasis-600); + } + .goAgenticFooter_AdCP a { + color: var(--color-brand, #1a36b4); + text-decoration: none; + font-weight: 500; + } + .goAgenticFooter_AdCP a:hover { + text-decoration: underline; + } + @media (max-width: 768px) { + .goAgenticCard_AdCP { + padding: 2rem 1.5rem; + } + .promptHints_AdCP { + flex-direction: column; + align-items: stretch; + } + .promptHint_AdCP { + justify-content: center; + } + } @@ -263,7 +341,350 @@ -

AdCP: The Open Standard for Agentic Advertising

From brief to buy, helping agents advertise anywhere: from CTV to chat, from tiny blog to the World Cup.

šŸŽÆ
Built for outcomes
Buy the way you want to grow
šŸ¤–
Built for agents
Supports MCP and A2A protocols
šŸŒ
Built for everyone
A diverse ecosystem of tech and content
v2.5.0 ReleasedDeveloper experience and API refinement: type safety, batch previews (5-10x faster), schema versioning, and more!Read the release notes →

Why we built AdCP

The advertising ecosystem is fragmented. Every platform has its own API, its own workflow, its own reporting format. Media buyers and agencies waste countless hours navigating this complexity.

The Integration Problem

Each new platform requires custom integration work. APIs change, documentation varies, and maintenance never ends. Teams spend more time on plumbing than on strategy.

The Discovery Problem

Inventory is scattered across platforms with different taxonomies and targeting options. Finding the right audiences means learning multiple systems and manually comparing options.

The Automation Problem

AI agents and automation tools can't easily interact with advertising platforms. Each integration is bespoke, limiting the potential of AI-powered workflows.

We believe there's a better way. A single protocol that any platform can implement and any tool can use. An open standard that makes advertising technology work together, not against each other.

Agentic Advertising

Managed by Agentic Advertising

AdCP is stewarded by Agentic Advertising (AAO), an industry trade association advancing open standards for AI-powered advertising. AAO brings together publishers, platforms, agencies, and technology providers to shape the future of the ecosystem.

Limited time: Founding member pricing ends March 31, 2026

One protocol. Every platform. Total control.

AdCP is the open standard that unifies advertising workflows across all platforms.
Think of it as the USB-C of advertising technology.

Before AdCP

  • 15+ different platform APIs
  • Months of custom integration
  • Manual data reconciliation
  • Fragmented reporting
  • Vendor lock-in

With AdCP

  • One unified interface
  • Deploy in days
  • Automated workflows
  • Consolidated analytics
  • Complete flexibility

See the difference

Traditional Workflow

1. Log into Platform A
2. Search for audiences (30 min)
3. Export to spreadsheet
4. Log into Platform B
5. Manually recreate targeting
6. Wait for approval (2 days)
7. Repeat for 10 more platforms...

AdCP Workflow

"Find sports enthusiasts with
high purchase intent, compare
prices across all platforms,
and activate the best option."

āœ“ Done in minutes

Everything you need, production-ready

AdCP v2.5.0 includes a complete suite of capabilities for modern advertising workflows.

šŸ›’ Media Buy Protocol

Complete campaign lifecycle management with 9 core tasks:

  • get_products - Discover inventory with natural language
  • create_media_buy - Launch campaigns across platforms
  • get_media_buy_delivery - Real-time performance metrics
  • Plus sync, update, feedback, and more

šŸŽØ Creative Protocol

AI-powered creative generation and management:

  • build_creative - Generate creatives from briefs
  • preview_creative - Visual preview generation
  • list_creative_formats - Discover format specs
  • Standard formats library included

šŸ“Š Signals Protocol

First-party data integration:

  • get_signals - Discover available signals
  • activate_signal - Activate for campaigns
  • Privacy-first audience building
  • Platform-agnostic data sharing

⚔ Protocol Features

Enterprise-ready infrastructure:

  • MCP & A2A protocol support
  • Async workflows with webhooks
  • Human-in-the-loop approval
  • JSON Schema validation

How AdCP works

Built on the Model Context Protocol (MCP), AdCP provides a unified interface for advertising operations across any platform.

1

Discovery

Use natural language to describe your target audience. AdCP searches across all connected platforms to find matching inventory and audiences.

"Find sports enthusiasts interested in running gear"
2

Comparison

Get standardized results from all platforms in a consistent format. Compare pricing, reach, and targeting capabilities side by side.

Platform A: $12 CPM • 2.3M reach
Platform B: $18 CPM • 4.1M reach
Platform C: $9 CPM • 1.8M reach
3

Activation

Launch campaigns across multiple platforms with a single command. AdCP handles the technical details while maintaining platform-specific optimizations.

"Activate on Platform B with $10,000 budget"
4

Management

Monitor performance, adjust budgets, and generate reports across all platforms from one interface. Set up automated rules and alerts.

"Show performance metrics for all active campaigns"

Ready to join the revolution?

Whether you're a platform provider or an advertiser, AdCP is your path to the future of advertising.

Platform Providers

Make your inventory accessible to every AI assistant and automation platform.

  • Enable AI-powered workflows for your inventory
  • Simplify integration with a standard protocol
  • Reach new customers through automation platforms
Explore the Spec

Open source • MIT licensed

Advertisers & Agencies

Start using natural language to manage campaigns across all platforms.

  • Manage campaigns with natural language
  • Access unified analytics across platforms
  • Build on open standards, avoid vendor lock-in
Start Building Today

Documentation & guides

Join the conversation

AdCP is an open standard developed in collaboration with the advertising community. We're building this together, and your input matters.

Open Development

All development happens in the open on GitHub. Watch progress, submit issues, and contribute code.

Working Group

Join monthly meetings to discuss protocol evolution, implementation challenges, and future directions.

Implementation Support

Get help implementing AdCP for your platform or building tools that use the protocol.

+
+
+ + +
+
+
+
+

AdCP: The Open Standard for Agentic Advertising

+

From brief to buy, helping agents advertise anywhere: from CTV to chat, from tiny blog to the World Cup.

+
+
+
šŸŽÆ
+
Built for outcomes
Buy the way you want to grow
+
+
+
šŸ¤–
+
Built for agents
Supports MCP and A2A protocols
+
+
+
šŸŒ
+
Built for everyone
A diverse ecosystem of tech and content
+
+
+ +
+
+
+
+ +
+ +
+
+
+ v2.5.0 Released + Developer experience and API refinement: type safety, batch previews (5-10x faster), schema versioning, and more! + Read the release notes → +
+
+
+ + +
+
+
+
+
+

Why we built AdCP

+

The advertising ecosystem is fragmented. Every platform has its own API, its own workflow, its own reporting format. Media buyers and agencies waste countless hours navigating this complexity.

+
+
+

The Integration Problem

+

Each new platform requires custom integration work. APIs change, documentation varies, and maintenance never ends. Teams spend more time on plumbing than on strategy.

+
+
+

The Discovery Problem

+

Inventory is scattered across platforms with different taxonomies and targeting options. Finding the right audiences means learning multiple systems and manually comparing options.

+
+
+

The Automation Problem

+

AI agents and automation tools can't easily interact with advertising platforms. Each integration is bespoke, limiting the potential of AI-powered workflows.

+
+
+
+

We believe there's a better way. A single protocol that any platform can implement and any tool can use. An open standard that makes advertising technology work together, not against each other.

+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ Agentic Advertising +
+
+

Managed by Agentic Advertising

+

AdCP is stewarded by Agentic Advertising (AAO), an industry trade association advancing open standards for AI-powered advertising. AAO brings together publishers, platforms, agencies, and technology providers to shape the future of the ecosystem.

+ +

Limited time: Founding member pricing ends March 31, 2026

+
+
+
+
+
+
+
+ + +
+
+
+
+
+

Go Agentic

+

Tell Addie what you're looking for. She'll help you find the right partners and resources.

+ +

Or browse all members to see what's possible

+
+
+
+
+
+ + +
+
+
+
+

One protocol. Every platform. Total control.

+

AdCP is the open standard that unifies advertising workflows across all platforms.
Think of it as the USB-C of advertising technology.

+
+
+

Before AdCP

+
    +
  • 15+ different platform APIs
  • +
  • Months of custom integration
  • +
  • Manual data reconciliation
  • +
  • Fragmented reporting
  • +
  • Vendor lock-in
  • +
+
+
+

With AdCP

+
    +
  • One unified interface
  • +
  • Deploy in days
  • +
  • Automated workflows
  • +
  • Consolidated analytics
  • +
  • Complete flexibility
  • +
+
+
+
+

See the difference

+
+
+

Traditional Workflow

+ 1. Log into Platform A
2. Search for audiences (30 min)
3. Export to spreadsheet
4. Log into Platform B
5. Manually recreate targeting
6. Wait for approval (2 days)
7. Repeat for 10 more platforms...
+
+
+

AdCP Workflow

+ "Find sports enthusiasts with
high purchase intent, compare
prices across all platforms,
and activate the best option."

āœ“ Done in minutes
+
+
+
+
+
+
+
+ + +
+
+
+
+

Everything you need, production-ready

+

AdCP v2.5.0 includes a complete suite of capabilities for modern advertising workflows.

+
+
+

šŸ›’ Media Buy Protocol

+

Complete campaign lifecycle management with 9 core tasks:

+
    +
  • get_products - Discover inventory with natural language
  • +
  • create_media_buy - Launch campaigns across platforms
  • +
  • get_media_buy_delivery - Real-time performance metrics
  • +
  • Plus sync, update, feedback, and more
  • +
+
+
+

šŸŽØ Creative Protocol

+

AI-powered creative generation and management:

+
    +
  • build_creative - Generate creatives from briefs
  • +
  • preview_creative - Visual preview generation
  • +
  • list_creative_formats - Discover format specs
  • +
  • Standard formats library included
  • +
+
+
+

šŸ“Š Signals Protocol

+

First-party data integration:

+
    +
  • get_signals - Discover available signals
  • +
  • activate_signal - Activate for campaigns
  • +
  • Privacy-first audience building
  • +
  • Platform-agnostic data sharing
  • +
+
+
+

⚔ Protocol Features

+

Enterprise-ready infrastructure:

+
    +
  • MCP & A2A protocol support
  • +
  • Async workflows with webhooks
  • +
  • Human-in-the-loop approval
  • +
  • JSON Schema validation
  • +
+
+
+
+
+
+
+ + +
+
+
+
+

How AdCP works

+

Built on the Model Context Protocol (MCP), AdCP provides a unified interface for advertising operations across any platform.

+
+
+
1
+
+

Discovery

+

Use natural language to describe your target audience. AdCP searches across all connected platforms to find matching inventory and audiences.

+ "Find sports enthusiasts interested in running gear" +
+
+
+
2
+
+

Comparison

+

Get standardized results from all platforms in a consistent format. Compare pricing, reach, and targeting capabilities side by side.

+ Platform A: $12 CPM • 2.3M reach
Platform B: $18 CPM • 4.1M reach
Platform C: $9 CPM • 1.8M reach
+
+
+
+
3
+
+

Activation

+

Launch campaigns across multiple platforms with a single command. AdCP handles the technical details while maintaining platform-specific optimizations.

+ "Activate on Platform B with $10,000 budget" +
+
+
+
4
+
+

Management

+

Monitor performance, adjust budgets, and generate reports across all platforms from one interface. Set up automated rules and alerts.

+ "Show performance metrics for all active campaigns" +
+
+
+
+
+
+
+ + +
+
+
+
+

Ready to join the revolution?

+

Whether you're a platform provider or an advertiser, AdCP is your path to the future of advertising.

+
+
+

Platform Providers

+

Make your inventory accessible to every AI assistant and automation platform.

+
    +
  • Enable AI-powered workflows for your inventory
  • +
  • Simplify integration with a standard protocol
  • +
  • Reach new customers through automation platforms
  • +
+
+ Explore the Spec +

Open source • MIT licensed

+
+
+
+

Advertisers & Agencies

+

Start using natural language to manage campaigns across all platforms.

+
    +
  • Manage campaigns with natural language
  • +
  • Access unified analytics across platforms
  • +
  • Build on open standards, avoid vendor lock-in
  • +
+
+ Start Building Today +

Documentation & guides

+
+
+
+
+
+
+
+ + +
+
+
+
+

Join the conversation

+

AdCP is an open standard developed in collaboration with the advertising community. We're building this together, and your input matters.

+
+
+

Open Development

+

All development happens in the open on GitHub. Watch progress, submit issues, and contribute code.

+
+
+

Working Group

+

Join monthly meetings to discuss protocol evolution, implementation challenges, and future directions.

+
+
+

Implementation Support

+

Get help implementing AdCP for your platform or building tools that use the protocol.

+
+
+ +
+
+
+
+ +
+
+
diff --git a/server/public/member-profile.html b/server/public/member-profile.html index 88ed0e5523..c1c41c4254 100644 --- a/server/public/member-profile.html +++ b/server/public/member-profile.html @@ -86,6 +86,70 @@ min-height: 100px; resize: vertical; } + /* Description guidance panel */ + .description-guidance { + margin-top: var(--space-3); + background: var(--color-primary-50, #eef2ff); + border: var(--border-1) solid var(--color-primary-200, #c7d2fe); + border-radius: var(--radius-md); + font-size: var(--text-sm); + } + .description-guidance summary { + padding: var(--space-3) var(--space-4); + cursor: pointer; + color: var(--color-brand, #1a36b4); + font-weight: var(--font-medium); + list-style: none; + display: flex; + align-items: center; + gap: var(--space-2); + } + .description-guidance summary::-webkit-details-marker { + display: none; + } + .description-guidance summary::before { + content: 'ā–¶'; + font-size: 10px; + transition: transform 0.2s; + } + .description-guidance[open] summary::before { + transform: rotate(90deg); + } + .description-guidance .guidance-content { + padding: 0 var(--space-4) var(--space-4); + color: var(--color-text-secondary); + line-height: 1.6; + } + .description-guidance .guidance-content p { + margin-bottom: var(--space-3); + } + .description-guidance .guidance-content ul { + margin: 0 0 var(--space-4) var(--space-4); + padding: 0; + } + .description-guidance .guidance-content li { + margin-bottom: var(--space-2); + } + .description-guidance .example-label { + font-weight: var(--font-medium); + color: var(--color-text-heading); + margin-top: var(--space-4); + } + .description-guidance .example-prompts { + background: white; + border-radius: var(--radius-sm); + padding: var(--space-3); + margin-left: 0; + } + .description-guidance .example-prompts li { + font-style: italic; + color: var(--color-text-muted); + } + .description-guidance .guidance-note { + font-size: var(--text-xs); + color: var(--color-text-muted); + margin-bottom: 0; + } .form-row { display: grid; grid-template-columns: 1fr 1fr; @@ -729,6 +793,25 @@

Basic Information

The first 200 characters will appear in the directory card
+
+ Tips: Help Addie recommend you to the right people +
+

Addie uses your description to match you with people looking for help. Write in plain language about:

+
    +
  • What you do — Be specific about your services (e.g., "We run managed sales agents for CTV publishers" rather than "We provide advertising solutions")
  • +
  • Who you serve — Describe your ideal customer (e.g., "mid-size publishers with 1M+ monthly impressions" or "brands spending $50K+/month on programmatic")
  • +
  • How you help — Explain your approach: Do you run things for them? Help them build their own? Provide software? Consult?
  • +
+

Example questions people might ask Addie:

+
    +
  • "Find someone to run a sales agent for my publisher inventory"
  • +
  • "I need help implementing AdCP for my DSP"
  • +
  • "Looking for a consultant who understands CTV advertising"
  • +
  • "Who can help a small publisher get started with agentic advertising?"
  • +
+

If your description answers questions like these, Addie will recommend you to the right prospects.

+
+
diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index 6647d03757..033ca6efd9 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -20,6 +20,8 @@ import { OrganizationDatabase } from '../../db/organization-db.js'; import { SlackDatabase } from '../../db/slack-db.js'; import { WorkingGroupDatabase } from '../../db/working-group-db.js'; import { getPool } from '../../db/client.js'; +import { MemberSearchAnalyticsDatabase } from '../../db/member-search-analytics-db.js'; +import { MemberDatabase } from '../../db/member-db.js'; import { getPendingInvoices, getAllOpenInvoices, @@ -1560,6 +1562,33 @@ Returns counts and examples of collected insights by type.`, }, }, }, + + // ============================================ + // MEMBER SEARCH & INTRODUCTION ANALYTICS TOOLS + // ============================================ + { + name: 'get_member_search_analytics', + description: `Get analytics about member profile searches and introductions made through Addie. + +USE THIS when admin asks: +- "How are member searches performing?" +- "Show me introduction stats" +- "What are people searching for?" +- "Which members are getting the most visibility?" +- "How many introductions have we made?" + +Returns: Search counts, impressions, clicks, introduction requests/sent, top search queries, top members by visibility, and recent introductions with full context.`, + usage_hints: 'Use this to monitor the member directory and introduction feature performance.', + input_schema: { + type: 'object' as const, + properties: { + days: { + type: 'number', + description: 'Number of days to look back (default: 30, max: 365)', + }, + }, + }, + }, ]; /** @@ -5893,5 +5922,117 @@ Use add_committee_leader to assign a leader.`; } }); + // ============================================ + // MEMBER SEARCH ANALYTICS HANDLERS + // ============================================ + handlers.set('get_member_search_analytics', async (input) => { + const adminError = requireAdminFromContext(); + if (adminError) return adminError; + + try { + const days = Math.min(Math.max((input.days as number) || 30, 1), 365); + + const memberSearchAnalyticsDb = new MemberSearchAnalyticsDatabase(); + const memberDb = new MemberDatabase(); + + // Get global analytics and recent introductions + const [globalAnalytics, recentIntroductions] = await Promise.all([ + memberSearchAnalyticsDb.getGlobalAnalytics(days), + memberSearchAnalyticsDb.getRecentIntroductionsGlobal(10), + ]); + + // Enrich top members with profile info + const enrichedTopMembers = await Promise.all( + globalAnalytics.top_members.slice(0, 5).map(async (member) => { + const profile = await memberDb.getProfileById(member.member_profile_id); + return { + display_name: profile?.display_name || 'Unknown', + slug: profile?.slug || null, + impressions: member.impressions, + }; + }) + ); + + // Enrich recent introductions with profile info + const enrichedIntroductions = await Promise.all( + recentIntroductions.map(async (intro) => { + const profile = await memberDb.getProfileById(intro.member_profile_id); + return { + event_type: intro.event_type, + member_name: profile?.display_name || 'Unknown', + member_slug: profile?.slug || null, + searcher_name: intro.searcher_name, + searcher_email: intro.searcher_email, + searcher_company: intro.searcher_company, + search_query: intro.search_query, + reasoning: intro.reasoning, + message: intro.message, + created_at: intro.created_at, + }; + }) + ); + + // Build response + let response = `## Member Search Analytics (Last ${days} Days)\n\n`; + + response += `### Summary\n`; + response += `- **Unique searches:** ${globalAnalytics.total_searches}\n`; + response += `- **Total impressions:** ${globalAnalytics.total_impressions}\n`; + response += `- **Profile clicks:** ${globalAnalytics.total_clicks}\n`; + response += `- **Introduction requests:** ${globalAnalytics.total_intro_requests}\n`; + response += `- **Introductions sent:** ${globalAnalytics.total_intros_sent}\n`; + response += `- **Unique searchers:** ${globalAnalytics.unique_searchers}\n\n`; + + // Calculate rates + if (globalAnalytics.total_impressions > 0) { + const clickRate = ((globalAnalytics.total_clicks / globalAnalytics.total_impressions) * 100).toFixed(1); + response += `**Click-through rate:** ${clickRate}%\n`; + } + if (globalAnalytics.total_clicks > 0) { + const introRate = ((globalAnalytics.total_intro_requests / globalAnalytics.total_clicks) * 100).toFixed(1); + response += `**Introduction rate (from clicks):** ${introRate}%\n`; + } + response += '\n'; + + // Top queries + if (globalAnalytics.top_queries.length > 0) { + response += `### Top Search Queries\n`; + for (const q of globalAnalytics.top_queries.slice(0, 5)) { + response += `- "${q.query}" (${q.count} searches)\n`; + } + response += '\n'; + } + + // Top members + if (enrichedTopMembers.length > 0) { + response += `### Top Members by Visibility\n`; + for (const m of enrichedTopMembers) { + response += `- **${m.display_name}** - ${m.impressions} impressions`; + if (m.slug) response += ` ([profile](/members/${m.slug}))`; + response += '\n'; + } + response += '\n'; + } + + // Recent introductions + if (enrichedIntroductions.length > 0) { + response += `### Recent Introductions\n`; + for (const intro of enrichedIntroductions) { + const date = new Date(intro.created_at).toLocaleDateString(); + const status = intro.event_type === 'introduction_sent' ? 'āœ… Sent' : 'šŸ“ Requested'; + response += `- ${status} **${intro.searcher_name}**`; + if (intro.searcher_company) response += ` (${intro.searcher_company})`; + response += ` → **${intro.member_name}** on ${date}\n`; + if (intro.search_query) response += ` - Searched: "${intro.search_query}"\n`; + } + } + + return response; + } catch (error) { + logger.error({ error }, 'Error getting member search analytics'); + return 'āŒ Failed to get member search analytics. Please try again.'; + } + }); + return handlers; } diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 98952d6667..1603c986f9 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -29,9 +29,15 @@ import { createFeedProposal, getPendingProposals, } from '../../db/industry-feeds-db.js'; +import { MemberDatabase } from '../../db/member-db.js'; +import { MemberSearchAnalyticsDatabase } from '../../db/member-search-analytics-db.js'; +import { sendIntroductionEmail } from '../../notifications/email.js'; +import { v4 as uuidv4 } from 'uuid'; const adagentsManager = new AdAgentsManager(); +const memberDb = new MemberDatabase(); const agentContextDb = new AgentContextDatabase(); +const memberSearchAnalyticsDb = new MemberSearchAnalyticsDatabase(); /** * Known open-source agents and their GitHub repositories. @@ -637,6 +643,89 @@ export const MEMBER_TOOLS: AddieTool[] = [ required: ['url'], }, }, + + // ============================================ + // MEMBER SEARCH / FIND HELP + // ============================================ + { + name: 'search_members', + description: + 'Search for member organizations that can help with specific needs. Searches member names, descriptions, taglines, offerings, and tags using natural language. Use this when users ask about finding vendors, consultants, implementation partners, managed services, or anyone who can help them with AdCP adoption. Returns public member profiles with contact info.', + usage_hints: 'use for "find someone to run a sales agent", "who can help me implement AdCP", "find a CTV partner", "looking for managed services", "need a consultant"', + input_schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'What the user is looking for in natural language (e.g., "run a sales agent for me", "help implementing AdCP", "CTV advertising expertise", "managed services for publishers")', + }, + offerings: { + type: 'array', + items: { + type: 'string', + enum: ['buyer_agent', 'sales_agent', 'creative_agent', 'signals_agent', 'publisher', 'consulting', 'managed_services', 'implementation', 'other'], + }, + description: 'Optional: filter by specific service offerings', + }, + limit: { + type: 'number', + description: 'Maximum number of results (default 5, max 10)', + }, + }, + required: ['query'], + }, + }, + { + name: 'request_introduction', + description: + 'Send an introduction email connecting a user with a member organization. Addie sends the email directly on behalf of the requester. Use this when a user explicitly asks to be introduced to or connected with a specific member after seeing search results.', + usage_hints: 'use for "introduce me to X", "connect me with X", "I\'d like to talk to X", "can you put me in touch with X"', + input_schema: { + type: 'object', + properties: { + member_slug: { + type: 'string', + description: 'The slug (URL identifier) of the member to be introduced to', + }, + requester_name: { + type: 'string', + description: 'Full name of the person requesting the introduction', + }, + requester_email: { + type: 'string', + description: 'Email address of the person requesting the introduction', + }, + requester_company: { + type: 'string', + description: 'Company/organization of the person requesting the introduction (optional)', + }, + message: { + type: 'string', + description: 'Brief message from the requester explaining what they\'re looking for or why they want to connect', + }, + search_query: { + type: 'string', + description: 'The original search query the user used to find this member (if applicable)', + }, + reasoning: { + type: 'string', + description: 'Addie\'s explanation of why this member is a good fit for what the requester is looking for. Be specific about matching capabilities.', + }, + }, + required: ['member_slug', 'requester_name', 'requester_email', 'message', 'reasoning'], + }, + }, + { + name: 'get_my_search_analytics', + description: + 'Get search analytics for the user\'s member profile. Shows how many times their profile appeared in searches, profile clicks, and introduction requests. Only works for members with a public profile.', + usage_hints: 'use for "how is my profile performing?", "how many people have seen my profile?", "search analytics", "introduction stats"', + input_schema: { + type: 'object', + properties: {}, + required: [], + }, + }, ]; /** @@ -1817,5 +1906,285 @@ export function createMemberToolHandlers( } }); + // ============================================ + // MEMBER SEARCH / FIND HELP + // ============================================ + handlers.set('search_members', async (input) => { + const searchQuery = input.query as string; + const offeringsFilter = input.offerings as string[] | undefined; + const requestedLimit = (input.limit as number) || 5; + const limit = Math.min(Math.max(requestedLimit, 1), 10); + + // Generate a session ID for this search operation to correlate analytics + const searchSessionId = uuidv4(); + + try { + // Search public member profiles + // The MemberDatabase.listProfiles supports text search across name, tagline, description, tags + const profiles = await memberDb.listProfiles({ + is_public: true, + search: searchQuery, + offerings: offeringsFilter as any, + limit: limit + 5, // Get extra to allow for relevance filtering + }); + + if (profiles.length === 0) { + let response = `No members found matching "${searchQuery}".\n\n`; + response += `This could mean:\n`; + response += `- No members have published profiles matching your needs yet\n`; + response += `- Try broader search terms\n\n`; + response += `You can also:\n`; + response += `- Browse all members at https://agenticadvertising.org/members\n`; + response += `- Ask me for general guidance on getting started with AdCP`; + return response; + } + + const displayProfiles = profiles.slice(0, limit); + + // Track search impressions for analytics (fire-and-forget) + const searcherUserId = memberContext?.workos_user?.workos_user_id; + memberSearchAnalyticsDb + .recordSearchImpressionsBatch( + displayProfiles.map((profile, index) => ({ + member_profile_id: profile.id, + search_query: searchQuery, + search_session_id: searchSessionId, + searcher_user_id: searcherUserId, + context: { + position: index + 1, + total_results: profiles.length, + offerings_filter: offeringsFilter, + }, + })) + ) + .catch((err) => { + logger.warn({ error: err, searchSessionId }, 'Failed to record search impressions'); + }); + + // Return structured data that chat UI can render as cards + // The format is: intro text + special JSON block + follow-up text + const memberCards = displayProfiles.map((profile) => ({ + id: profile.id, + slug: profile.slug, + display_name: profile.display_name, + tagline: profile.tagline || null, + description: profile.description + ? profile.description.length > 200 + ? profile.description.substring(0, 200) + '...' + : profile.description + : null, + logo_url: profile.logo_url || profile.logo_light_url || null, + offerings: profile.offerings || [], + headquarters: profile.headquarters || null, + contact_website: profile.contact_website || null, + })); + + // Embed structured data in a special format the chat UI will recognize + const structuredData = { + type: 'member_search_results', + query: searchQuery, + search_session_id: searchSessionId, + results: memberCards, + total_found: profiles.length, + }; + + // Build response with intro, embedded data block, and follow-up + let response = `Found ${displayProfiles.length} member${displayProfiles.length !== 1 ? 's' : ''} who can help:\n\n`; + response += `\n\n`; + + if (profiles.length > limit) { + response += `_Showing top ${limit} of ${profiles.length} results. [Browse all members](/members) for more options._\n\n`; + } + + response += `Click on a card to see their full profile, or ask me to introduce you to someone.`; + + return response; + } catch (error) { + logger.error({ error, query: searchQuery }, 'Addie: search_members failed'); + return `Failed to search members: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }); + + // ============================================ + // INTRODUCTION REQUESTS + // ============================================ + handlers.set('request_introduction', async (input) => { + const memberSlug = input.member_slug as string; + const requesterName = input.requester_name as string; + const requesterEmail = input.requester_email as string; + const requesterCompany = input.requester_company as string | undefined; + const message = input.message as string; + const searchQuery = input.search_query as string | undefined; + const reasoning = input.reasoning as string; + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!requesterEmail || !emailRegex.test(requesterEmail)) { + return 'Please provide a valid email address for the introduction request.'; + } + + try { + // Get the member profile + const profile = await memberDb.getProfileBySlug(memberSlug); + if (!profile) { + return `I couldn't find a member with the identifier "${memberSlug}". Please check the name and try again, or use search_members to find the right member.`; + } + + if (!profile.is_public) { + return `This member's profile is not currently public. They may not be accepting introductions at this time.`; + } + + // Check if the member has a contact email + if (!profile.contact_email) { + let response = `**${profile.display_name}** doesn't have a contact email listed in their profile.\n\n`; + if (profile.contact_website) { + response += `You can reach them through their website: ${profile.contact_website}`; + } else if (profile.linkedin_url) { + response += `You can connect with them on LinkedIn: ${profile.linkedin_url}`; + } else { + response += `You may want to visit their profile page at https://agenticadvertising.org/members/${profile.slug} for more information.`; + } + return response; + } + + // Record the introduction request for analytics + const searcherUserId = memberContext?.workos_user?.workos_user_id; + await memberSearchAnalyticsDb.recordIntroductionRequest({ + member_profile_id: profile.id, + searcher_user_id: searcherUserId, + searcher_email: requesterEmail, + searcher_name: requesterName, + searcher_company: requesterCompany, + context: { + message, + search_query: searchQuery, + reasoning, + }, + }); + + // Send the introduction email + const emailResult = await sendIntroductionEmail({ + memberEmail: profile.contact_email, + memberName: profile.display_name, + memberSlug: profile.slug, + requesterName, + requesterEmail, + requesterCompany, + requesterMessage: message, + searchQuery, + addieReasoning: reasoning, + }); + + if (!emailResult.success) { + // Email failed but we recorded the request - let user know to follow up manually + logger.warn({ error: emailResult.error, memberSlug, requesterEmail }, 'Introduction email failed to send'); + let response = `I recorded your introduction request to **${profile.display_name}**, but there was an issue sending the email.\n\n`; + response += `Please reach out to them directly at: **${profile.contact_email}**\n\n`; + response += `Here's a suggested message:\n\n---\n\n`; + response += `Hi ${profile.display_name.split(' ')[0] || 'there'},\n\n`; + response += `I found your profile on AgenticAdvertising.org. ${message}\n\n`; + response += `${requesterName}`; + if (requesterCompany) response += `\n${requesterCompany}`; + response += `\n${requesterEmail}\n\n---`; + return response; + } + + // Record that the email was sent + await memberSearchAnalyticsDb.recordIntroductionSent({ + member_profile_id: profile.id, + searcher_email: requesterEmail, + searcher_name: requesterName, + context: { email_id: emailResult.messageId }, + }); + + logger.info( + { memberSlug, requesterEmail, memberProfileId: profile.id, emailId: emailResult.messageId }, + 'Introduction email sent' + ); + + // Build a nice confirmation message + let response = `## Introduction Sent!\n\n`; + response += `I've sent an introduction email to **${profile.display_name}** on your behalf.\n\n`; + response += `**What happens next:**\n`; + response += `- ${profile.display_name} will receive an email with your message and contact info\n`; + response += `- When they reply, it will go directly to ${requesterEmail}\n`; + response += `- The email explains why you're a good match for what you're looking for\n\n`; + response += `Good luck with your conversation!`; + + return response; + } catch (error) { + logger.error({ error, memberSlug }, 'Addie: request_introduction failed'); + return `Failed to process introduction request: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }); + + // ============================================ + // MEMBER SEARCH ANALYTICS + // ============================================ + handlers.set('get_my_search_analytics', async () => { + if (!memberContext?.workos_user?.workos_user_id) { + return 'You need to be logged in to see your search analytics. Please log in at https://agenticadvertising.org/dashboard first.'; + } + + const orgId = memberContext.organization?.workos_organization_id; + if (!orgId) { + return 'Your account is not associated with an organization. Please contact support.'; + } + + try { + // Get the member profile for this organization + const profile = await memberDb.getProfileByOrgId(orgId); + if (!profile) { + return "You don't have a member profile yet. Visit https://agenticadvertising.org/member-profile to create one!"; + } + + if (!profile.is_public) { + return "Your profile is not public yet. Make your profile public to appear in searches and see analytics.\n\nVisit https://agenticadvertising.org/member-profile to update your visibility settings."; + } + + // Get analytics summary + const analytics = await memberSearchAnalyticsDb.getAnalyticsSummary(profile.id); + + let response = `## Search Analytics for ${profile.display_name}\n\n`; + + // Summary stats + response += `### Last 30 Days\n`; + response += `- **Search impressions:** ${analytics.impressions_last_30_days}\n`; + response += `- **Profile clicks:** ${analytics.clicks_last_30_days}\n`; + response += `- **Introduction requests:** ${analytics.intro_requests_last_30_days}\n\n`; + + response += `### Last 7 Days\n`; + response += `- **Search impressions:** ${analytics.impressions_last_7_days}\n`; + response += `- **Profile clicks:** ${analytics.clicks_last_7_days}\n`; + response += `- **Introduction requests:** ${analytics.intro_requests_last_7_days}\n\n`; + + response += `### All Time\n`; + response += `- **Total impressions:** ${analytics.total_impressions}\n`; + response += `- **Total clicks:** ${analytics.total_clicks}\n`; + response += `- **Total introduction requests:** ${analytics.total_intro_requests}\n`; + response += `- **Introductions sent:** ${analytics.total_intros_sent}\n\n`; + + // Conversion insights + if (analytics.total_impressions > 0) { + const clickRate = ((analytics.total_clicks / analytics.total_impressions) * 100).toFixed(1); + response += `### Insights\n`; + response += `- **Click-through rate:** ${clickRate}%\n`; + if (analytics.total_clicks > 0) { + const introRate = ((analytics.total_intro_requests / analytics.total_clicks) * 100).toFixed(1); + response += `- **Introduction request rate:** ${introRate}% (of profile views)\n`; + } + } + + if (analytics.total_impressions === 0) { + response += `\nšŸ’” **Tip:** Your profile hasn't appeared in any searches yet. Make sure your description includes keywords that describe your services. Check your profile at https://agenticadvertising.org/member-profile`; + } + + return response; + } catch (error) { + logger.error({ error }, 'Addie: get_my_search_analytics failed'); + return `Failed to fetch analytics: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }); + return handlers; } diff --git a/server/src/addie/prompts.ts b/server/src/addie/prompts.ts index 831d938d43..2799463e70 100644 --- a/server/src/addie/prompts.ts +++ b/server/src/addie/prompts.ts @@ -149,6 +149,21 @@ When users set up agents or publishers, walk through the full verification chain - get_my_profile: Show user's profile - update_my_profile: Update profile fields (user-scoped) +**Find Help / Partner Discovery:** +- search_members: Search for member organizations that can help with specific needs. USE THIS when users: + - Ask about finding vendors, consultants, or partners + - Need help implementing AdCP ("who can help me set this up?") + - Want managed services ("someone to run a sales agent for me") + - Are looking for specific expertise ("CTV advertising", "sustainability") + - Say "go agentic" or "get started" and need guidance on partners +- request_introduction: Request an introduction to a specific member. Use this when: + - User explicitly asks to be introduced to or connected with a member + - User wants to "talk to" or "contact" a specific member after seeing search results + - Collect their name, email, and message before calling this tool +- get_my_search_analytics: Show the user's profile analytics (search impressions, clicks, intro requests) + +When users ask about finding help, use search_members to find relevant organizations, then present options with clear descriptions of what each can offer. After showing results, remind them they can ask for an introduction if they find a good match. + **Content:** - list_perspectives: Browse community articles diff --git a/server/src/addie/router.ts b/server/src/addie/router.ts index 0842c06e7b..2e575b3bdd 100644 --- a/server/src/addie/router.ts +++ b/server/src/addie/router.ts @@ -150,6 +150,33 @@ export const ROUTING_RULES = { tools: ['get_my_profile', 'list_working_groups', 'join_working_group'], description: 'AgenticAdvertising.org membership', }, + find_help: { + patterns: [ + 'find someone', + 'looking for', + 'who can help', + 'need help with', + 'vendor', + 'consultant', + 'partner', + 'service provider', + 'implementation', + 'managed service', + 'run a', + 'operate a', + 'introduce me', + 'connect me', + 'dsp', + 'ssp', + 'programmatic', + 'ctv', + 'measurement', + 'attribution', + 'creative optimization', + ], + tools: ['search_members', 'request_introduction'], + description: 'Find member organizations who can help with specific needs - searching for vendors, partners, consultants', + }, community: { patterns: ['community', 'discussion', 'slack', 'chat history', 'what did', 'who said'], tools: ['search_slack'], diff --git a/server/src/db/member-db.ts b/server/src/db/member-db.ts index b19afb53ea..551402affb 100644 --- a/server/src/db/member-db.ts +++ b/server/src/db/member-db.ts @@ -233,17 +233,44 @@ export class MemberDatabase { params.push(options.markets); } - // Full-text search + // Full-text search - tokenize query and match ANY word (OR logic) + // This allows "DSP demand-side platform" to match on "DSP" alone if (options.search) { - conditions.push(`( - display_name ILIKE $${paramIndex} OR - tagline ILIKE $${paramIndex} OR - description ILIKE $${paramIndex} OR - headquarters ILIKE $${paramIndex} OR - tags::text ILIKE $${paramIndex} - )`); - params.push(`%${escapeLikePattern(options.search)}%`); - paramIndex++; + // Split search into words, filter short/common words + const searchWords = options.search + .split(/\s+/) + .map(w => w.trim()) + .filter(w => w.length >= 2); // Keep words with 2+ chars + + if (searchWords.length === 0) { + // Fall back to original search if no valid words + conditions.push(`( + display_name ILIKE $${paramIndex} OR + tagline ILIKE $${paramIndex} OR + description ILIKE $${paramIndex} OR + headquarters ILIKE $${paramIndex} OR + tags::text ILIKE $${paramIndex} OR + offerings::text ILIKE $${paramIndex} + )`); + params.push(`%${escapeLikePattern(options.search)}%`); + paramIndex++; + } else { + // Build OR conditions for each word + const wordConditions: string[] = []; + for (const word of searchWords) { + wordConditions.push(`( + display_name ILIKE $${paramIndex} OR + tagline ILIKE $${paramIndex} OR + description ILIKE $${paramIndex} OR + headquarters ILIKE $${paramIndex} OR + tags::text ILIKE $${paramIndex} OR + offerings::text ILIKE $${paramIndex} + )`); + params.push(`%${escapeLikePattern(word)}%`); + paramIndex++; + } + conditions.push(`(${wordConditions.join(' OR ')})`); + } } const whereClause = conditions.length > 0 diff --git a/server/src/db/member-search-analytics-db.ts b/server/src/db/member-search-analytics-db.ts new file mode 100644 index 0000000000..9c19ff9fb3 --- /dev/null +++ b/server/src/db/member-search-analytics-db.ts @@ -0,0 +1,442 @@ +import { query } from './client.js'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Event types for member search analytics + */ +export type MemberSearchEventType = + | 'search_impression' // Member appeared in search results + | 'profile_click' // User clicked to view member profile + | 'introduction_request' // User requested an introduction + | 'introduction_sent'; // Addie sent the introduction email + +/** + * Input for recording a search impression + */ +export interface SearchImpressionInput { + member_profile_id: string; + search_query: string; + search_session_id: string; + searcher_user_id?: string; + addie_thread_id?: string; + addie_message_id?: string; + context?: { + position?: number; + total_results?: number; + offerings_filter?: string[]; + }; +} + +/** + * Input for recording an introduction request + */ +export interface IntroductionRequestInput { + member_profile_id: string; + search_session_id?: string; + searcher_user_id?: string; + searcher_email: string; + searcher_name: string; + searcher_company?: string; + addie_thread_id?: string; + addie_message_id?: string; + context?: { + message?: string; + search_query?: string; + reasoning?: string; + }; +} + +/** + * Analytics summary for a member profile + */ +export interface MemberSearchAnalyticsSummary { + member_profile_id: string; + total_impressions: number; + total_clicks: number; + total_intro_requests: number; + total_intros_sent: number; + impressions_last_7_days: number; + impressions_last_30_days: number; + clicks_last_7_days: number; + clicks_last_30_days: number; + intro_requests_last_7_days: number; + intro_requests_last_30_days: number; +} + +/** + * Database operations for member search analytics + */ +export class MemberSearchAnalyticsDatabase { + /** + * Record a search impression for a member + */ + async recordSearchImpression(input: SearchImpressionInput): Promise { + const id = uuidv4(); + await query( + `INSERT INTO member_search_analytics ( + id, member_profile_id, event_type, search_query, search_session_id, + searcher_user_id, addie_thread_id, addie_message_id, context + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + id, + input.member_profile_id, + 'search_impression', + input.search_query, + input.search_session_id, + input.searcher_user_id || null, + input.addie_thread_id || null, + input.addie_message_id || null, + JSON.stringify(input.context || {}), + ] + ); + return id; + } + + /** + * Record multiple search impressions in batch (more efficient) + */ + async recordSearchImpressionsBatch( + impressions: SearchImpressionInput[] + ): Promise { + if (impressions.length === 0) return []; + + const ids: string[] = []; + const values: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + for (const input of impressions) { + const id = uuidv4(); + ids.push(id); + values.push( + `($${paramIndex++}, $${paramIndex++}, 'search_impression', $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})` + ); + params.push( + id, + input.member_profile_id, + input.search_query, + input.search_session_id, + input.searcher_user_id || null, + input.addie_thread_id || null, + input.addie_message_id || null, + JSON.stringify(input.context || {}) + ); + } + + await query( + `INSERT INTO member_search_analytics ( + id, member_profile_id, event_type, search_query, search_session_id, + searcher_user_id, addie_thread_id, addie_message_id, context + ) VALUES ${values.join(', ')}`, + params + ); + + return ids; + } + + /** + * Record a profile click + */ + async recordProfileClick(input: { + member_profile_id: string; + searcher_user_id?: string; + search_session_id?: string; + addie_thread_id?: string; + }): Promise { + const id = uuidv4(); + await query( + `INSERT INTO member_search_analytics ( + id, member_profile_id, event_type, search_session_id, searcher_user_id, addie_thread_id + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + id, + input.member_profile_id, + 'profile_click', + input.search_session_id || null, + input.searcher_user_id || null, + input.addie_thread_id || null, + ] + ); + return id; + } + + /** + * Record an introduction request + */ + async recordIntroductionRequest(input: IntroductionRequestInput): Promise { + const id = uuidv4(); + await query( + `INSERT INTO member_search_analytics ( + id, member_profile_id, event_type, search_session_id, + searcher_user_id, searcher_email, searcher_name, searcher_company, + addie_thread_id, addie_message_id, context + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + id, + input.member_profile_id, + 'introduction_request', + input.search_session_id || null, + input.searcher_user_id || null, + input.searcher_email, + input.searcher_name, + input.searcher_company || null, + input.addie_thread_id || null, + input.addie_message_id || null, + JSON.stringify(input.context || {}), + ] + ); + return id; + } + + /** + * Record that an introduction email was sent + */ + async recordIntroductionSent(input: { + member_profile_id: string; + introduction_request_id?: string; + searcher_email: string; + searcher_name: string; + context?: { email_id?: string }; + }): Promise { + const id = uuidv4(); + await query( + `INSERT INTO member_search_analytics ( + id, member_profile_id, event_type, searcher_email, searcher_name, context + ) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + id, + input.member_profile_id, + 'introduction_sent', + input.searcher_email, + input.searcher_name, + JSON.stringify(input.context || {}), + ] + ); + return id; + } + + /** + * Get analytics summary for a member profile + */ + async getAnalyticsSummary(memberProfileId: string): Promise { + const result = await query<{ + total_impressions: string; + total_clicks: string; + total_intro_requests: string; + total_intros_sent: string; + impressions_last_7_days: string; + impressions_last_30_days: string; + clicks_last_7_days: string; + clicks_last_30_days: string; + intro_requests_last_7_days: string; + intro_requests_last_30_days: string; + }>( + `SELECT + COUNT(*) FILTER (WHERE event_type = 'search_impression') as total_impressions, + COUNT(*) FILTER (WHERE event_type = 'profile_click') as total_clicks, + COUNT(*) FILTER (WHERE event_type = 'introduction_request') as total_intro_requests, + COUNT(*) FILTER (WHERE event_type = 'introduction_sent') as total_intros_sent, + COUNT(*) FILTER (WHERE event_type = 'search_impression' AND created_at >= NOW() - INTERVAL '7 days') as impressions_last_7_days, + COUNT(*) FILTER (WHERE event_type = 'search_impression' AND created_at >= NOW() - INTERVAL '30 days') as impressions_last_30_days, + COUNT(*) FILTER (WHERE event_type = 'profile_click' AND created_at >= NOW() - INTERVAL '7 days') as clicks_last_7_days, + COUNT(*) FILTER (WHERE event_type = 'profile_click' AND created_at >= NOW() - INTERVAL '30 days') as clicks_last_30_days, + COUNT(*) FILTER (WHERE event_type = 'introduction_request' AND created_at >= NOW() - INTERVAL '7 days') as intro_requests_last_7_days, + COUNT(*) FILTER (WHERE event_type = 'introduction_request' AND created_at >= NOW() - INTERVAL '30 days') as intro_requests_last_30_days + FROM member_search_analytics + WHERE member_profile_id = $1`, + [memberProfileId] + ); + + const row = result.rows[0]; + return { + member_profile_id: memberProfileId, + total_impressions: parseInt(row?.total_impressions || '0', 10), + total_clicks: parseInt(row?.total_clicks || '0', 10), + total_intro_requests: parseInt(row?.total_intro_requests || '0', 10), + total_intros_sent: parseInt(row?.total_intros_sent || '0', 10), + impressions_last_7_days: parseInt(row?.impressions_last_7_days || '0', 10), + impressions_last_30_days: parseInt(row?.impressions_last_30_days || '0', 10), + clicks_last_7_days: parseInt(row?.clicks_last_7_days || '0', 10), + clicks_last_30_days: parseInt(row?.clicks_last_30_days || '0', 10), + intro_requests_last_7_days: parseInt(row?.intro_requests_last_7_days || '0', 10), + intro_requests_last_30_days: parseInt(row?.intro_requests_last_30_days || '0', 10), + }; + } + + /** + * Get global search analytics (for admin dashboard) + */ + async getGlobalAnalytics(days: number = 30): Promise<{ + total_searches: number; + total_impressions: number; + total_clicks: number; + total_intro_requests: number; + total_intros_sent: number; + unique_searchers: number; + top_queries: Array<{ query: string; count: number }>; + top_members: Array<{ member_profile_id: string; impressions: number }>; + }> { + // Get totals + const totalsResult = await query<{ + total_searches: string; + total_impressions: string; + total_clicks: string; + total_intro_requests: string; + total_intros_sent: string; + unique_searchers: string; + }>( + `SELECT + COUNT(DISTINCT search_session_id) FILTER (WHERE event_type = 'search_impression') as total_searches, + COUNT(*) FILTER (WHERE event_type = 'search_impression') as total_impressions, + COUNT(*) FILTER (WHERE event_type = 'profile_click') as total_clicks, + COUNT(*) FILTER (WHERE event_type = 'introduction_request') as total_intro_requests, + COUNT(*) FILTER (WHERE event_type = 'introduction_sent') as total_intros_sent, + COUNT(DISTINCT COALESCE(searcher_user_id, searcher_email)) as unique_searchers + FROM member_search_analytics + WHERE created_at >= NOW() - INTERVAL '1 day' * $1`, + [days] + ); + + // Get top queries + const queriesResult = await query<{ query: string; count: string }>( + `SELECT search_query as query, COUNT(DISTINCT search_session_id) as count + FROM member_search_analytics + WHERE event_type = 'search_impression' + AND search_query IS NOT NULL + AND created_at >= NOW() - INTERVAL '1 day' * $1 + GROUP BY search_query + ORDER BY count DESC + LIMIT 10`, + [days] + ); + + // Get top members by impressions + const membersResult = await query<{ member_profile_id: string; impressions: string }>( + `SELECT member_profile_id, COUNT(*) as impressions + FROM member_search_analytics + WHERE event_type = 'search_impression' + AND created_at >= NOW() - INTERVAL '1 day' * $1 + GROUP BY member_profile_id + ORDER BY impressions DESC + LIMIT 10`, + [days] + ); + + const totals = totalsResult.rows[0]; + return { + total_searches: parseInt(totals?.total_searches || '0', 10), + total_impressions: parseInt(totals?.total_impressions || '0', 10), + total_clicks: parseInt(totals?.total_clicks || '0', 10), + total_intro_requests: parseInt(totals?.total_intro_requests || '0', 10), + total_intros_sent: parseInt(totals?.total_intros_sent || '0', 10), + unique_searchers: parseInt(totals?.unique_searchers || '0', 10), + top_queries: queriesResult.rows.map((r) => ({ + query: r.query, + count: parseInt(r.count, 10), + })), + top_members: membersResult.rows.map((r) => ({ + member_profile_id: r.member_profile_id, + impressions: parseInt(r.impressions, 10), + })), + }; + } + + /** + * Get recent introduction requests for a member (for their dashboard) + */ + async getRecentIntroductions( + memberProfileId: string, + limit: number = 10 + ): Promise> { + const result = await query<{ + id: string; + event_type: MemberSearchEventType; + searcher_name: string; + searcher_email: string; + searcher_company: string | null; + context: string; + created_at: Date; + }>( + `SELECT id, event_type, searcher_name, searcher_email, searcher_company, context, created_at + FROM member_search_analytics + WHERE member_profile_id = $1 + AND event_type IN ('introduction_request', 'introduction_sent') + ORDER BY created_at DESC + LIMIT $2`, + [memberProfileId, limit] + ); + + return result.rows.map((row) => { + const context = typeof row.context === 'string' ? JSON.parse(row.context) : row.context; + return { + id: row.id, + event_type: row.event_type, + searcher_name: row.searcher_name, + searcher_email: row.searcher_email, + searcher_company: row.searcher_company, + search_query: context?.search_query || null, + created_at: row.created_at, + }; + }); + } + + /** + * Get all recent introductions (for admin dashboard) + */ + async getRecentIntroductionsGlobal( + limit: number = 20 + ): Promise> { + const result = await query<{ + id: string; + event_type: MemberSearchEventType; + member_profile_id: string; + searcher_name: string; + searcher_email: string; + searcher_company: string | null; + context: string; + created_at: Date; + }>( + `SELECT id, event_type, member_profile_id, searcher_name, searcher_email, searcher_company, context, created_at + FROM member_search_analytics + WHERE event_type IN ('introduction_request', 'introduction_sent') + ORDER BY created_at DESC + LIMIT $1`, + [limit] + ); + + return result.rows.map((row) => { + const context = typeof row.context === 'string' ? JSON.parse(row.context) : row.context; + return { + id: row.id, + event_type: row.event_type, + member_profile_id: row.member_profile_id, + searcher_name: row.searcher_name, + searcher_email: row.searcher_email, + searcher_company: row.searcher_company, + search_query: context?.search_query || null, + reasoning: context?.reasoning || null, + message: context?.message || null, + created_at: row.created_at, + }; + }); + } +} diff --git a/server/src/db/migrations/141_member_search_analytics.sql b/server/src/db/migrations/141_member_search_analytics.sql new file mode 100644 index 0000000000..26c4c1fb07 --- /dev/null +++ b/server/src/db/migrations/141_member_search_analytics.sql @@ -0,0 +1,43 @@ +-- Member Search Analytics +-- Tracks how member profiles appear in searches and introductions made through Addie + +CREATE TABLE IF NOT EXISTS member_search_analytics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- What member profile was involved (nullable for searches with no results) + member_profile_id UUID REFERENCES member_profiles(id) ON DELETE CASCADE, + + -- Type of event + event_type VARCHAR(50) NOT NULL, -- 'search_impression', 'profile_click', 'introduction_request', 'introduction_sent' + + -- Search context + search_query TEXT, -- The natural language query used + search_session_id UUID, -- Groups events from same search operation + + -- Who was searching (may be anonymous) + searcher_user_id VARCHAR(255), -- WorkOS user ID if logged in + searcher_email VARCHAR(255), -- Email if provided for introduction + searcher_name VARCHAR(255), -- Name if provided for introduction + searcher_company VARCHAR(255), -- Company if provided for introduction + + -- Addie context + addie_thread_id UUID, -- Link to addie thread for full conversation context + addie_message_id UUID, -- Specific message that triggered this + + -- Additional context + context JSONB DEFAULT '{}', -- Additional metadata (position in results, etc.) + + -- Lifecycle + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_member_search_member ON member_search_analytics(member_profile_id); +CREATE INDEX IF NOT EXISTS idx_member_search_event_type ON member_search_analytics(event_type); +CREATE INDEX IF NOT EXISTS idx_member_search_created ON member_search_analytics(created_at); +CREATE INDEX IF NOT EXISTS idx_member_search_session ON member_search_analytics(search_session_id); +CREATE INDEX IF NOT EXISTS idx_member_search_thread ON member_search_analytics(addie_thread_id); + +-- Composite index for member dashboard queries +CREATE INDEX IF NOT EXISTS idx_member_search_dashboard + ON member_search_analytics(member_profile_id, event_type, created_at DESC); diff --git a/server/src/http.ts b/server/src/http.ts index 5f447f63e0..d47c3ee3cf 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -5651,6 +5651,53 @@ Disallow: /api/admin/ } }); + // POST /api/members/:slug/click - Track a profile click for analytics + this.app.post('/api/members/:slug/click', async (req, res) => { + try { + const { slug } = req.params; + const { search_session_id } = req.body; + + // Import analytics db lazily + const { MemberSearchAnalyticsDatabase } = await import('./db/member-search-analytics-db.js'); + const analyticsDb = new MemberSearchAnalyticsDatabase(); + + // Get the profile to get its ID + const profile = await memberDb.getProfileBySlug(slug); + if (!profile) { + return res.status(404).json({ error: 'Member not found' }); + } + + // Get user ID if authenticated + let userId: string | undefined; + const sessionCookie = req.cookies?.['wos-session']; + if (sessionCookie && AUTH_ENABLED && workos) { + try { + const result = await workos.userManagement.authenticateWithSessionCookie({ + sessionData: sessionCookie, + cookiePassword: WORKOS_COOKIE_PASSWORD, + }); + if (result.authenticated && 'user' in result && result.user) { + userId = result.user.id; + } + } catch { + // Not authenticated - that's fine + } + } + + // Record the click + await analyticsDb.recordProfileClick({ + member_profile_id: profile.id, + searcher_user_id: userId, + search_session_id, + }); + + res.json({ success: true }); + } catch (error) { + logger.error({ err: error }, 'Record member click error'); + res.status(500).json({ error: 'Failed to record click' }); + } + }); + // GET /api/public/discover-agent - Public endpoint to discover agent info (for members directory) this.app.get('/api/public/discover-agent', async (req, res) => { const { url } = req.query; diff --git a/server/src/notifications/email.ts b/server/src/notifications/email.ts index 7a1987a3e7..e75e87caaf 100644 --- a/server/src/notifications/email.ts +++ b/server/src/notifications/email.ts @@ -742,5 +742,132 @@ https://agenticadvertising.org${quotedText}`, } } +/** + * Send an introduction email from Addie connecting a searcher with a member + * This is a transactional email (no unsubscribe needed) + */ +export async function sendIntroductionEmail(data: { + memberEmail: string; + memberName: string; + memberSlug: string; + requesterName: string; + requesterEmail: string; + requesterCompany?: string; + requesterMessage: string; + searchQuery?: string; + addieReasoning?: string; +}): Promise<{ success: boolean; messageId?: string; error?: string }> { + if (!resend) { + logger.warn('Resend not configured, cannot send introduction email'); + return { success: false, error: 'Email not configured' }; + } + + const escapeHtml = (str: string): string => + str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/\n/g, '
'); + + // Sanitize and truncate for subject line (prevent injection and excessive length) + const safeName = (data.requesterName || 'Someone').slice(0, 50).replace(/[\r\n]/g, ''); + const safeCompany = data.requesterCompany ? data.requesterCompany.slice(0, 50).replace(/[\r\n]/g, '') : ''; + + // Build the subject line + const subject = `Introduction: ${safeName}${safeCompany ? ` from ${safeCompany}` : ''} wants to connect`; + + // Build the context section if we have search info + let contextHtml = ''; + let contextText = ''; + if (data.searchQuery || data.addieReasoning) { + contextHtml = ` +
+

WHY THIS INTRODUCTION

+ ${data.searchQuery ? `

They searched for: "${escapeHtml(data.searchQuery)}"

` : ''} + ${data.addieReasoning ? `

${escapeHtml(data.addieReasoning)}

` : ''} +
`; + + contextText = `\n---\nWHY THIS INTRODUCTION\n`; + if (data.searchQuery) contextText += `They searched for: "${data.searchQuery}"\n`; + if (data.addieReasoning) contextText += `${data.addieReasoning}\n`; + contextText += `---\n`; + } + + // Build requester info + const requesterInfo = data.requesterCompany + ? `${data.requesterName} from ${data.requesterCompany}` + : data.requesterName; + + try { + const { data: sendData, error } = await resend.emails.send({ + from: 'Addie from AgenticAdvertising.org ', + to: data.memberEmail, + replyTo: data.requesterEmail, + subject, + html: ` + + + + + + + +

Hi ${escapeHtml(data.memberName.split(' ')[0] || data.memberName)},

+ +

${escapeHtml(requesterInfo)} found your profile on AgenticAdvertising.org and asked me to make an introduction.

+ + ${contextHtml} + +
+

THEIR MESSAGE

+

${escapeHtml(data.requesterMessage)}

+
+ +

Reply directly to this email to connect with ${escapeHtml(data.requesterName)} - your response will go straight to them at ${escapeHtml(data.requesterEmail)}.

+ +
+ +

+ This introduction was made through AgenticAdvertising.org.
+ View your member profile | + Update your profile +

+ + + `.trim(), + text: `Hi ${data.memberName.split(' ')[0] || data.memberName}, + +${requesterInfo} found your profile on AgenticAdvertising.org and asked me to make an introduction. +${contextText} +--- +THEIR MESSAGE + +${data.requesterMessage} +--- + +Reply directly to this email to connect with ${data.requesterName} - your response will go straight to them at ${data.requesterEmail}. + +--- +This introduction was made through AgenticAdvertising.org. +View your member profile: https://agenticadvertising.org/members/${data.memberSlug} +Update your profile: https://agenticadvertising.org/member-profile + `.trim(), + }); + + if (error) { + logger.error({ error, to: data.memberEmail }, 'Failed to send introduction email'); + return { success: false, error: error.message }; + } + + logger.info({ + messageId: sendData?.id, + to: data.memberEmail, + from: data.requesterEmail, + memberSlug: data.memberSlug, + }, 'Introduction email sent'); + + return { success: true, messageId: sendData?.id }; + } catch (error) { + logger.error({ error }, 'Error sending introduction email'); + return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; + } +} + // Re-export for use in routes export { emailDb, emailPrefsDb, getUnsubscribeToken }; diff --git a/server/src/routes/admin/stats.ts b/server/src/routes/admin/stats.ts index b8b4371601..17cbb066a6 100644 --- a/server/src/routes/admin/stats.ts +++ b/server/src/routes/admin/stats.ts @@ -14,6 +14,11 @@ import { Router } from "express"; import { getPool } from "../../db/client.js"; import { createLogger } from "../../logger.js"; import { requireAuth, requireAdmin } from "../../middleware/auth.js"; +import { MemberSearchAnalyticsDatabase } from "../../db/member-search-analytics-db.js"; +import { MemberDatabase } from "../../db/member-db.js"; + +const memberSearchAnalyticsDb = new MemberSearchAnalyticsDatabase(); +const memberDb = new MemberDatabase(); const logger = createLogger("admin-stats"); @@ -496,4 +501,85 @@ export function setupStatsRoutes(apiRouter: Router): void { }); } }); + + // GET /api/admin/member-search-analytics - Get member search analytics for admin dashboard + apiRouter.get("/member-search-analytics", requireAuth, requireAdmin, async (req, res) => { + try { + const days = parseInt(req.query.days as string) || 30; + const safeDays = Math.min(Math.max(days, 1), 365); + + // Get global analytics and recent introductions in parallel + const [globalAnalytics, recentIntroductions] = await Promise.all([ + memberSearchAnalyticsDb.getGlobalAnalytics(safeDays), + memberSearchAnalyticsDb.getRecentIntroductionsGlobal(20), + ]); + + // Enrich top_members with profile info + const enrichedTopMembers = await Promise.all( + globalAnalytics.top_members.map(async (member) => { + try { + const profile = await memberDb.getProfileById(member.member_profile_id); + return { + ...member, + display_name: profile?.display_name || 'Unknown', + slug: profile?.slug || null, + }; + } catch { + return { + ...member, + display_name: 'Unknown', + slug: null, + }; + } + }) + ); + + // Enrich recent introductions with profile info + const enrichedIntroductions = await Promise.all( + recentIntroductions.map(async (intro) => { + try { + const profile = await memberDb.getProfileById(intro.member_profile_id); + return { + ...intro, + member_display_name: profile?.display_name || 'Unknown', + member_slug: profile?.slug || null, + }; + } catch { + return { + ...intro, + member_display_name: 'Unknown', + member_slug: null, + }; + } + }) + ); + + res.json({ + period_days: safeDays, + summary: { + total_searches: globalAnalytics.total_searches, + total_impressions: globalAnalytics.total_impressions, + total_clicks: globalAnalytics.total_clicks, + total_intro_requests: globalAnalytics.total_intro_requests, + total_intros_sent: globalAnalytics.total_intros_sent, + unique_searchers: globalAnalytics.unique_searchers, + click_rate: globalAnalytics.total_impressions > 0 + ? ((globalAnalytics.total_clicks / globalAnalytics.total_impressions) * 100).toFixed(1) + '%' + : '0%', + intro_rate: globalAnalytics.total_clicks > 0 + ? ((globalAnalytics.total_intro_requests / globalAnalytics.total_clicks) * 100).toFixed(1) + '%' + : '0%', + }, + top_queries: globalAnalytics.top_queries, + top_members: enrichedTopMembers, + recent_introductions: enrichedIntroductions, + }); + } catch (error) { + logger.error({ err: error }, "Error fetching member search analytics"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to fetch member search analytics", + }); + } + }); } From d8b2e2b769d52433b6fb6f2de4023f64b521ab9b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 5 Jan 2026 15:57:39 -0800 Subject: [PATCH 2/2] Fix migration version conflict (141 -> 142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed member_search_analytics migration from 141 to 142 to avoid conflict with 141_fix_feed_email_slugs.sql on main branch. šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...ember_search_analytics.sql => 142_member_search_analytics.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/src/db/migrations/{141_member_search_analytics.sql => 142_member_search_analytics.sql} (100%) diff --git a/server/src/db/migrations/141_member_search_analytics.sql b/server/src/db/migrations/142_member_search_analytics.sql similarity index 100% rename from server/src/db/migrations/141_member_search_analytics.sql rename to server/src/db/migrations/142_member_search_analytics.sql