From ea6ddc35fc3edcc9fcb5271d44f84e570ee80a76 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 27 Dec 2025 17:59:49 -0400 Subject: [PATCH] Add member thank you email feature and refactor dashboard navigation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements email notification system for member thank you emails with database tracking, preferences, and admin dashboard. Refactors dashboard and admin pages to use shared sidebar navigation component with organization switcher support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- .changeset/afraid-pigs-sit.md | 8 + .env.local.example | 9 + CLAUDE.md | 16 + package-lock.json | 97 +++ package.json | 1 + server/public/admin-agreements.html | 6 +- server/public/admin-analytics.html | 6 +- server/public/admin-audit.html | 6 +- server/public/admin-email.html | 632 ++++++++++++++++++ server/public/admin-members.html | 6 +- server/public/admin-org-detail.html | 6 +- server/public/admin-perspectives.html | 6 +- server/public/admin-prospects.html | 6 +- server/public/admin-sidebar.js | 369 ++++++++++ server/public/admin-users.html | 6 +- server/public/admin-working-groups.html | 6 +- server/public/admin.html | 12 +- server/public/dashboard-billing.html | 493 ++++++++++++++ server/public/dashboard-emails.html | 422 ++++++++++++ server/public/dashboard-nav.js | 506 ++++++++++++++ server/public/dashboard-settings.html | 519 ++++++++++++++ server/public/dashboard.html | 538 +-------------- server/src/db/email-db.ts | 282 ++++++++ server/src/db/email-preferences-db.ts | 612 +++++++++++++++++ .../src/db/migrations/037_email_tracking.sql | 90 +++ .../db/migrations/038_email_preferences.sql | 187 ++++++ server/src/http.ts | 509 ++++++++++++++ server/src/notifications/email.ts | 538 +++++++++++++++ server/tests/unit/email-notification.test.ts | 340 ++++++++++ 29 files changed, 5705 insertions(+), 529 deletions(-) create mode 100644 .changeset/afraid-pigs-sit.md create mode 100644 server/public/admin-email.html create mode 100644 server/public/admin-sidebar.js create mode 100644 server/public/dashboard-billing.html create mode 100644 server/public/dashboard-emails.html create mode 100644 server/public/dashboard-nav.js create mode 100644 server/public/dashboard-settings.html create mode 100644 server/src/db/email-db.ts create mode 100644 server/src/db/email-preferences-db.ts create mode 100644 server/src/db/migrations/037_email_tracking.sql create mode 100644 server/src/db/migrations/038_email_preferences.sql create mode 100644 server/src/notifications/email.ts create mode 100644 server/tests/unit/email-notification.test.ts diff --git a/.changeset/afraid-pigs-sit.md b/.changeset/afraid-pigs-sit.md new file mode 100644 index 000000000..c4d6ca905 --- /dev/null +++ b/.changeset/afraid-pigs-sit.md @@ -0,0 +1,8 @@ +--- +--- + +Add member welcome and user signup email notifications via Resend. + +- New member thank you email sent after Stripe subscription created +- User signup email with conditional content based on org subscription status +- Updated naming from "Alliance for Agentic Advertising" to "AgenticAdvertising.org" diff --git a/.env.local.example b/.env.local.example index 676f2c0ac..127d6a2f7 100644 --- a/.env.local.example +++ b/.env.local.example @@ -129,6 +129,15 @@ WORKOS_REDIRECT_URI=http://localhost:3000/auth/callback # - users:read (list users) # - users:read.email (get user emails for auto-mapping) +# ============================================================================ +# NOTIFICATIONS - Email (Optional) +# ============================================================================ + +# Resend API Key for transactional emails (welcome emails, etc.) +# Get this from: https://resend.com/api-keys +# Sends: welcome email to new members after subscription +# RESEND_API_KEY=re_... + # ============================================================================ # PRODUCTION DEPLOYMENT NOTES # ============================================================================ diff --git a/CLAUDE.md b/CLAUDE.md index 95d14eac3..5877c4a63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,22 @@ Implementation details can be mentioned as: - Write for an audience implementing the protocol, not using a specific implementation - Keep examples generic and illustrative +### Organization Naming - CRITICAL + +**The organization name is "AgenticAdvertising.org" - NOT "Alliance for Agentic Advertising" or "AAO".** + +**Rules:** +- ✅ Use **AgenticAdvertising.org** in all user-facing content (emails, UI, documentation) +- ✅ Use **agenticadvertising.org** for URLs and domain references +- ❌ DO NOT use "Alliance for Agentic Advertising" +- ❌ DO NOT use "AAO" or "AAO Team" (use "AgenticAdvertising.org Team") +- ❌ DO NOT use adcontextprotocol.org as the organization name (that's the protocol docs site) + +**Why this matters:** +- AgenticAdvertising.org is the member organization/community +- AdCP (Ad Context Protocol) is the technical protocol specification +- These are related but distinct - members join AgenticAdvertising.org, they use AdCP + ### Schema Compliance - CRITICAL RULE **🚨 ABSOLUTE REQUIREMENT: All documentation, code examples, and API usage MUST match the current JSON schemas exactly.** diff --git a/package-lock.json b/package-lock.json index 1ef67d8e4..d1caea8d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "pg": "^8.16.3", "pino": "^9.14.0", "pino-pretty": "^13.1.3", + "resend": "^6.6.0", "stripe": "^20.0.0" }, "devDependencies": { @@ -6744,6 +6745,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -11169,6 +11176,12 @@ "benchmarks" ] }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/esast-util-from-estree": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", @@ -11753,6 +11766,12 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -18641,6 +18660,12 @@ ], "license": "MIT" }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -19415,6 +19440,32 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resend": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.6.0.tgz", + "integrity": "sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==", + "license": "MIT", + "dependencies": { + "svix": "1.76.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -20993,6 +21044,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.76.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.76.1.tgz", + "integrity": "sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "@types/node": "^22.7.5", + "es6-promise": "^4.2.8", + "fast-sha256": "^1.3.0", + "url-parse": "^1.5.10", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/@types/node": { + "version": "22.19.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", + "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -22144,6 +22231,16 @@ "dev": true, "license": "MIT" }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/urlpattern-polyfill": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", diff --git a/package.json b/package.json index 0ec58a954..233bfb7f1 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "pg": "^8.16.3", "pino": "^9.14.0", "pino-pretty": "^13.1.3", + "resend": "^6.6.0", "stripe": "^20.0.0" }, "devDependencies": { diff --git a/server/public/admin-agreements.html b/server/public/admin-agreements.html index 2374bdce1..78b387861 100644 --- a/server/public/admin-agreements.html +++ b/server/public/admin-agreements.html @@ -5,8 +5,9 @@ Admin - Agreements - AdCP Registry - + + - + +
Loading... diff --git a/server/public/admin-analytics.html b/server/public/admin-analytics.html index 3f1796850..b11f5186f 100644 --- a/server/public/admin-analytics.html +++ b/server/public/admin-analytics.html @@ -5,8 +5,9 @@ Analytics - AdCP Admin - + + -
+ +

Analytics Dashboard

diff --git a/server/public/admin-audit.html b/server/public/admin-audit.html index abec6bddc..d74fd03a6 100644 --- a/server/public/admin-audit.html +++ b/server/public/admin-audit.html @@ -5,8 +5,9 @@ Audit Log - AdCP Admin - + + -
+ +

Audit Log

diff --git a/server/public/admin-email.html b/server/public/admin-email.html new file mode 100644 index 000000000..912ec9cb6 --- /dev/null +++ b/server/public/admin-email.html @@ -0,0 +1,632 @@ + + + + + + + Email Management - AdCP Admin + + + + + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/server/public/admin-members.html b/server/public/admin-members.html index c30846f22..d7b0880c4 100644 --- a/server/public/admin-members.html +++ b/server/public/admin-members.html @@ -5,8 +5,9 @@ Admin - Members - AdCP Registry - + + - + +
Loading members... diff --git a/server/public/admin-org-detail.html b/server/public/admin-org-detail.html index 34346e8b2..2066eecca 100644 --- a/server/public/admin-org-detail.html +++ b/server/public/admin-org-detail.html @@ -5,8 +5,9 @@ Organization Details - AdCP Registry - + + - + +
Loading organization details... diff --git a/server/public/admin-perspectives.html b/server/public/admin-perspectives.html index d10efe076..f2d88c1dc 100644 --- a/server/public/admin-perspectives.html +++ b/server/public/admin-perspectives.html @@ -5,8 +5,9 @@ Admin - Perspectives - AdCP Registry - + + - + +
Loading... diff --git a/server/public/admin-prospects.html b/server/public/admin-prospects.html index 3ee24ba48..999d4571b 100644 --- a/server/public/admin-prospects.html +++ b/server/public/admin-prospects.html @@ -5,8 +5,9 @@ Admin - Account Management - AdCP Registry - + + - + +
Loading accounts... diff --git a/server/public/admin-sidebar.js b/server/public/admin-sidebar.js new file mode 100644 index 000000000..d1dbae632 --- /dev/null +++ b/server/public/admin-sidebar.js @@ -0,0 +1,369 @@ +// Shared admin sidebar navigation component +// Include this in any admin page with: + +(function() { + 'use strict'; + + // Navigation configuration + const NAV_CONFIG = { + logo: 'Admin', + sections: [ + { + label: 'Overview', + items: [ + { href: '/admin', label: 'Dashboard', icon: '📊' }, + ] + }, + { + label: 'Members', + items: [ + { href: '/admin/prospects', label: 'Prospects', icon: '🎯' }, + { href: '/admin/members', label: 'Members', icon: '🏢' }, + { href: '/admin/users', label: 'Users', icon: '👤' }, + ] + }, + { + label: 'Community', + items: [ + { href: '/admin/working-groups', label: 'Working Groups', icon: '🏛️' }, + { href: '/admin/perspectives', label: 'Perspectives', icon: '💡' }, + ] + }, + { + label: 'System', + items: [ + { href: '/admin/agreements', label: 'Agreements', icon: '📋' }, + { href: '/admin/email', label: 'Email', icon: '📧' }, + { href: '/admin/analytics', label: 'Analytics', icon: '📈' }, + { href: '/admin/audit', label: 'Audit Log', icon: '📜' }, + ] + } + ] + }; + + // Sidebar styles + const SIDEBAR_STYLES = ` + .admin-layout { + display: flex; + min-height: 100vh; + padding-top: 60px; /* Space for top nav */ + } + + .admin-sidebar { + width: 260px; + background: var(--color-bg-card); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + position: fixed; + top: 60px; /* Below top nav */ + left: 0; + bottom: 0; + z-index: 100; + transition: transform 0.3s ease; + } + + .admin-sidebar-header { + padding: 20px 24px; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; + background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-primary-600) 100%); + } + + .admin-sidebar-logo { + font-size: 18px; + font-weight: 600; + color: white; + text-decoration: none; + display: flex; + align-items: center; + gap: 10px; + } + + .admin-sidebar-logo img { + width: 28px; + height: 28px; + } + + .admin-sidebar-nav { + flex: 1; + overflow-y: auto; + padding: 16px 0; + } + + .admin-nav-section { + margin-bottom: 8px; + } + + .admin-nav-section-label { + padding: 8px 24px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted); + } + + .admin-nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 24px; + color: var(--color-text-secondary); + text-decoration: none; + font-size: 14px; + transition: all 0.15s ease; + border-left: 3px solid transparent; + } + + .admin-nav-item:hover { + background: var(--color-bg-subtle); + color: var(--color-text-heading); + } + + .admin-nav-item.active { + background: var(--color-primary-50); + color: var(--color-brand); + border-left-color: var(--color-brand); + font-weight: 500; + } + + .admin-nav-icon { + font-size: 16px; + width: 20px; + text-align: center; + } + + .admin-sidebar-footer { + padding: 16px 24px; + border-top: 1px solid var(--color-border); + } + + .admin-back-link { + display: flex; + align-items: center; + gap: 8px; + color: var(--color-text-secondary); + text-decoration: none; + font-size: 13px; + padding: 8px 0; + transition: color 0.15s; + } + + .admin-back-link:hover { + color: var(--color-brand); + } + + .admin-main { + flex: 1; + margin-left: 260px; + min-height: 100vh; + background: var(--color-bg-page); + } + + /* Mobile sidebar toggle */ + .admin-sidebar-toggle { + display: none; + position: fixed; + top: 76px; + left: 16px; + z-index: 101; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; + font-size: 20px; + } + + .admin-sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 99; + } + + /* Mobile responsive */ + @media (max-width: 768px) { + .admin-sidebar { + transform: translateX(-100%); + } + + .admin-sidebar.open { + transform: translateX(0); + } + + .admin-sidebar-toggle { + display: block; + } + + .admin-sidebar-overlay.show { + display: block; + } + + .admin-main { + margin-left: 0; + } + } + `; + + // Inject styles + function injectStyles() { + if (document.getElementById('admin-sidebar-styles')) return; + + const styleEl = document.createElement('style'); + styleEl.id = 'admin-sidebar-styles'; + styleEl.textContent = SIDEBAR_STYLES; + document.head.appendChild(styleEl); + } + + // Create sidebar HTML + function createSidebarHTML() { + const currentPath = window.location.pathname; + + const sectionsHTML = NAV_CONFIG.sections.map(section => { + const itemsHTML = section.items.map(item => { + const isActive = currentPath === item.href || + (item.href !== '/admin' && currentPath.startsWith(item.href)); + const activeClass = isActive ? 'active' : ''; + return ` + + ${item.icon} + ${item.label} + + `; + }).join(''); + + return ` +
+ + ${itemsHTML} +
+ `; + }).join(''); + + return ` + +
+ + `; + } + + // Wrap content in main container + function wrapContent() { + // Find existing content wrapper or body content + const existingMain = document.querySelector('.admin-main'); + if (existingMain) return; // Already wrapped + + // Get all body children except scripts and the sidebar + const bodyChildren = Array.from(document.body.children).filter(el => + el.tagName !== 'SCRIPT' && + !el.classList.contains('admin-sidebar') && + !el.classList.contains('admin-sidebar-toggle') && + !el.classList.contains('admin-sidebar-overlay') && + el.id !== 'adcp-nav' + ); + + // Create main wrapper + const mainWrapper = document.createElement('main'); + mainWrapper.className = 'admin-main'; + + // Move children to wrapper + bodyChildren.forEach(child => { + mainWrapper.appendChild(child); + }); + + // Add wrapper to body + document.body.appendChild(mainWrapper); + } + + // Initialize navigation + function init() { + injectStyles(); + + // Remove old header nav if present + const oldHeader = document.querySelector('.admin-header'); + if (oldHeader) { + oldHeader.remove(); + } + + // Insert sidebar at start of body (after adcp-nav if present) + const sidebarHTML = createSidebarHTML(); + const adcpNav = document.getElementById('adcp-nav'); + if (adcpNav) { + adcpNav.insertAdjacentHTML('afterend', sidebarHTML); + } else { + document.body.insertAdjacentHTML('afterbegin', sidebarHTML); + } + + // Add layout class to body + document.body.classList.add('admin-layout'); + + // Wrap existing content + wrapContent(); + } + + // Toggle sidebar (mobile) + function toggleSidebar() { + const sidebar = document.getElementById('adminSidebar'); + const overlay = document.querySelector('.admin-sidebar-overlay'); + sidebar?.classList.toggle('open'); + overlay?.classList.toggle('show'); + } + + function closeSidebar() { + const sidebar = document.getElementById('adminSidebar'); + const overlay = document.querySelector('.admin-sidebar-overlay'); + sidebar?.classList.remove('open'); + overlay?.classList.remove('show'); + } + + // Utility function to redirect to login with return_to parameter + function redirectToLogin() { + const returnUrl = encodeURIComponent(window.location.pathname + window.location.search); + window.location.href = `/auth/login?return_to=${returnUrl}`; + } + + // Auto-initialize only on admin pages + function shouldInitialize() { + return window.location.pathname.startsWith('/admin'); + } + + if (shouldInitialize()) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + } + + // Export API + window.AdminSidebar = { + config: NAV_CONFIG, + init, + toggleSidebar, + closeSidebar, + redirectToLogin + }; +})(); diff --git a/server/public/admin-users.html b/server/public/admin-users.html index 8001b336e..a3c0ee301 100644 --- a/server/public/admin-users.html +++ b/server/public/admin-users.html @@ -5,8 +5,9 @@ Admin - Users - AdCP Registry - + + + +
+
Loading...
diff --git a/server/public/admin-working-groups.html b/server/public/admin-working-groups.html index 312fb340a..3d6d6a63b 100644 --- a/server/public/admin-working-groups.html +++ b/server/public/admin-working-groups.html @@ -5,8 +5,9 @@ Admin - Working Groups - AdCP Registry - + + + +
+
Loading...
diff --git a/server/public/admin.html b/server/public/admin.html index 1f7d2b8eb..35d3cc063 100644 --- a/server/public/admin.html +++ b/server/public/admin.html @@ -5,8 +5,9 @@ Admin - AdCP Registry - + + - + +
Loading... @@ -198,6 +200,12 @@

Perspectives

Analytics

View revenue metrics, customer health, subscription analytics, and business intelligence dashboards powered by Metabase.

+ + +
📧
+

Email

+

Manage email templates, send newsletters and announcements. View email engagement stats and manage campaigns.

+
diff --git a/server/public/dashboard-billing.html b/server/public/dashboard-billing.html new file mode 100644 index 000000000..629a43661 --- /dev/null +++ b/server/public/dashboard-billing.html @@ -0,0 +1,493 @@ + + + + + + Billing - Dashboard + + + + + + + + + + +
+ +
+ + + +
+
Loading billing information...
+ + +
+ + + + diff --git a/server/public/dashboard-emails.html b/server/public/dashboard-emails.html new file mode 100644 index 000000000..f61ac18ad --- /dev/null +++ b/server/public/dashboard-emails.html @@ -0,0 +1,422 @@ + + + + + + Email Preferences - Dashboard + + + + + + + + +
+ +
+ + + +
+
Loading preferences...
+ + +
+ + + + diff --git a/server/public/dashboard-nav.js b/server/public/dashboard-nav.js new file mode 100644 index 000000000..dadf9cd52 --- /dev/null +++ b/server/public/dashboard-nav.js @@ -0,0 +1,506 @@ +// Shared dashboard navigation component with sidebar +// Include this in any dashboard page with: + +(function() { + 'use strict'; + + // Navigation configuration + const NAV_CONFIG = { + logo: 'Dashboard', + sections: [ + { + label: 'Overview', + items: [ + { href: '/dashboard', label: 'Home', icon: '🏠' }, + ] + }, + { + label: 'Organization', + items: [ + { href: '/member-profile', label: 'Member Profile', icon: '🏢' }, + { href: '/team', label: 'Team', icon: '👥' }, + { href: '/working-groups', label: 'Working Groups', icon: '🏛️' }, + ] + }, + { + label: 'Account', + items: [ + { href: '/dashboard/billing', label: 'Billing', icon: '💳' }, + { href: '/dashboard/settings', label: 'Settings', icon: '⚙️' }, + { href: '/dashboard/emails', label: 'Email Preferences', icon: '📧' }, + ] + } + ], + backLink: { href: 'https://agenticadvertising.org', label: '← Back to AAO' } + }; + + // Sidebar styles + // Note: top nav is ~60px, so sidebar starts below it + const SIDEBAR_STYLES = ` + .dashboard-layout { + display: flex; + min-height: 100vh; + padding-top: 60px; /* Space for top nav */ + } + + .dashboard-sidebar { + width: 260px; + background: var(--color-bg-card); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + position: fixed; + top: 60px; /* Below top nav */ + left: 0; + bottom: 0; + z-index: 100; + transition: transform 0.3s ease; + } + + .dashboard-sidebar-header { + padding: 20px 24px; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + justify-content: space-between; + } + + .dashboard-sidebar-logo { + font-size: 18px; + font-weight: 600; + color: var(--color-text-heading); + text-decoration: none; + display: flex; + align-items: center; + gap: 10px; + } + + .dashboard-sidebar-logo img { + width: 28px; + height: 28px; + } + + .dashboard-sidebar-nav { + flex: 1; + overflow-y: auto; + padding: 16px 0; + } + + .dashboard-nav-section { + margin-bottom: 8px; + } + + .dashboard-nav-section-label { + padding: 8px 24px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-muted); + } + + .dashboard-nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 24px; + color: var(--color-text-secondary); + text-decoration: none; + font-size: 14px; + transition: all 0.15s ease; + border-left: 3px solid transparent; + } + + .dashboard-nav-item:hover { + background: var(--color-bg-subtle); + color: var(--color-text-heading); + } + + .dashboard-nav-item.active { + background: var(--color-primary-50); + color: var(--color-brand); + border-left-color: var(--color-brand); + font-weight: 500; + } + + .dashboard-nav-icon { + font-size: 16px; + width: 20px; + text-align: center; + } + + .dashboard-sidebar-footer { + padding: 16px 24px; + border-top: 1px solid var(--color-border); + } + + .dashboard-back-link { + display: flex; + align-items: center; + gap: 8px; + color: var(--color-text-secondary); + text-decoration: none; + font-size: 13px; + padding: 8px 0; + transition: color 0.15s; + } + + .dashboard-back-link:hover { + color: var(--color-brand); + } + + .dashboard-main { + flex: 1; + margin-left: 260px; + min-height: 100vh; + background: var(--color-bg-page); + } + + /* Mobile sidebar toggle */ + .dashboard-sidebar-toggle { + display: none; + position: fixed; + top: 16px; + left: 16px; + z-index: 101; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; + font-size: 20px; + } + + .dashboard-sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 99; + } + + /* Mobile responsive */ + @media (max-width: 768px) { + .dashboard-sidebar { + transform: translateX(-100%); + } + + .dashboard-sidebar.open { + transform: translateX(0); + } + + .dashboard-sidebar-toggle { + display: block; + } + + .dashboard-sidebar-overlay.show { + display: block; + } + + .dashboard-main { + margin-left: 0; + } + } + + /* Org switcher in sidebar */ + .dashboard-org-switcher { + padding: 12px 24px; + border-bottom: 1px solid var(--color-border); + } + + .dashboard-org-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: 8px; + cursor: pointer; + font-size: 13px; + color: var(--color-text-heading); + transition: all 0.15s; + } + + .dashboard-org-btn:hover { + border-color: var(--color-brand); + } + + .dashboard-org-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 160px; + } + + .dashboard-org-dropdown { + display: none; + position: absolute; + left: 24px; + right: 24px; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: var(--shadow-lg); + z-index: 200; + max-height: 300px; + overflow-y: auto; + margin-top: 4px; + } + + .dashboard-org-dropdown.show { + display: block; + } + + .dashboard-org-option { + display: block; + width: 100%; + padding: 10px 12px; + text-align: left; + background: none; + border: none; + cursor: pointer; + font-size: 13px; + color: var(--color-text-secondary); + transition: background 0.15s; + } + + .dashboard-org-option:hover { + background: var(--color-bg-subtle); + } + + .dashboard-org-option.selected { + background: var(--color-primary-50); + color: var(--color-brand); + font-weight: 500; + } + + /* Admin link in sidebar */ + .dashboard-admin-link { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--color-brand); + color: white; + text-decoration: none; + font-size: 12px; + font-weight: 500; + border-radius: 6px; + margin-top: 12px; + transition: opacity 0.15s; + } + + .dashboard-admin-link:hover { + opacity: 0.9; + } + `; + + // Inject styles + function injectStyles() { + if (document.getElementById('dashboard-nav-styles')) return; + + const styleEl = document.createElement('style'); + styleEl.id = 'dashboard-nav-styles'; + styleEl.textContent = SIDEBAR_STYLES; + document.head.appendChild(styleEl); + } + + // Create sidebar HTML + function createSidebarHTML(options = {}) { + const currentPath = window.location.pathname; + const { showAdmin = false, showOrgSwitcher = false, currentOrgName = 'Select Organization' } = options; + + const sectionsHTML = NAV_CONFIG.sections.map(section => { + const itemsHTML = section.items.map(item => { + const isActive = currentPath === item.href || + (item.href !== '/dashboard' && currentPath.startsWith(item.href)); + const activeClass = isActive ? 'active' : ''; + return ` + + ${item.icon} + ${item.label} + + `; + }).join(''); + + return ` +
+ + ${itemsHTML} +
+ `; + }).join(''); + + const orgSwitcherHTML = showOrgSwitcher ? ` +
+ +
+
+ ` : ''; + + const adminLinkHTML = showAdmin ? ` + + 🔒 Admin Panel + + ` : ''; + + return ` + +
+ + `; + } + + // Wrap content in main container + function wrapContent() { + // Find existing content wrapper or body content + const existingMain = document.querySelector('.dashboard-main'); + if (existingMain) return; // Already wrapped + + // Get all body children except scripts and the sidebar + const bodyChildren = Array.from(document.body.children).filter(el => + el.tagName !== 'SCRIPT' && + !el.classList.contains('dashboard-sidebar') && + !el.classList.contains('dashboard-sidebar-toggle') && + !el.classList.contains('dashboard-sidebar-overlay') + ); + + // Create main wrapper + const mainWrapper = document.createElement('main'); + mainWrapper.className = 'dashboard-main'; + + // Move children to wrapper + bodyChildren.forEach(child => { + mainWrapper.appendChild(child); + }); + + // Add wrapper to body + document.body.appendChild(mainWrapper); + } + + // Initialize navigation + function init(options = {}) { + injectStyles(); + + // Insert sidebar at start of body + const sidebarHTML = createSidebarHTML(options); + document.body.insertAdjacentHTML('afterbegin', sidebarHTML); + + // Add layout class to body + document.body.classList.add('dashboard-layout'); + + // Wrap existing content + wrapContent(); + } + + // Toggle sidebar (mobile) + function toggleSidebar() { + const sidebar = document.getElementById('dashboardSidebar'); + const overlay = document.querySelector('.dashboard-sidebar-overlay'); + sidebar?.classList.toggle('open'); + overlay?.classList.toggle('show'); + } + + function closeSidebar() { + const sidebar = document.getElementById('dashboardSidebar'); + const overlay = document.querySelector('.dashboard-sidebar-overlay'); + sidebar?.classList.remove('open'); + overlay?.classList.remove('show'); + } + + // Org dropdown functions + function toggleOrgDropdown() { + const dropdown = document.getElementById('dashboardOrgDropdown'); + dropdown?.classList.toggle('show'); + } + + function closeOrgDropdown() { + const dropdown = document.getElementById('dashboardOrgDropdown'); + dropdown?.classList.remove('show'); + } + + function setOrgName(name) { + const el = document.getElementById('dashboardOrgName'); + if (el) el.textContent = name; + } + + function setOrgOptions(orgs, selectedId, onSelect) { + const dropdown = document.getElementById('dashboardOrgDropdown'); + if (!dropdown) return; + + dropdown.innerHTML = orgs.map(org => ` + + `).join(''); + + // Store callback + window._dashboardOrgSelectCallback = onSelect; + } + + function selectOrg(orgId) { + closeOrgDropdown(); + if (window._dashboardOrgSelectCallback) { + window._dashboardOrgSelectCallback(orgId); + } + } + + // Show/hide admin link + function showAdminLink(show) { + const footer = document.querySelector('.dashboard-sidebar-footer'); + const existingLink = footer?.querySelector('.dashboard-admin-link'); + + if (show && !existingLink && footer) { + footer.insertAdjacentHTML('beforeend', ` + + 🔒 Admin Panel + + `); + } else if (!show && existingLink) { + existingLink.remove(); + } + } + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!e.target.closest('.dashboard-org-switcher')) { + closeOrgDropdown(); + } + }); + + // Export API + window.DashboardNav = { + config: NAV_CONFIG, + init, + toggleSidebar, + closeSidebar, + toggleOrgDropdown, + closeOrgDropdown, + setOrgName, + setOrgOptions, + selectOrg, + showAdminLink + }; +})(); diff --git a/server/public/dashboard-settings.html b/server/public/dashboard-settings.html new file mode 100644 index 000000000..c21e0b36f --- /dev/null +++ b/server/public/dashboard-settings.html @@ -0,0 +1,519 @@ + + + + + + Settings - Dashboard + + + + + + + + +
+ +
+ + + + + + + + + +
+
Loading settings...
+ + +
+ + + + diff --git a/server/public/dashboard.html b/server/public/dashboard.html index f538b25d4..43267b0c5 100644 --- a/server/public/dashboard.html +++ b/server/public/dashboard.html @@ -18,20 +18,6 @@ background: var(--color-bg-page); } - .dashboard-toolbar { - background: var(--color-bg-subtle); - border-bottom: var(--border-1) solid var(--color-border); - padding: var(--space-2.5) var(--space-5); - } - - .dashboard-toolbar-content { - max-width: var(--container-xl); - margin: 0 auto; - display: flex; - justify-content: flex-start; - align-items: center; - } - .container { max-width: var(--container-xl); margin: var(--space-10) auto; @@ -134,122 +120,6 @@ - .btn .btn-sm = smaller size */ - /* Admin Link - uses brand color */ - .admin-link { - margin-left: auto; - padding: var(--space-2) var(--space-4); - background: var(--color-brand); - color: white; - text-decoration: none; - border-radius: var(--radius-md); - font-size: var(--text-sm); - font-weight: var(--font-medium); - transition: var(--transition-colors); - } - - .admin-link:hover { - background: var(--color-brand-hover); - } - - /* Org Switcher */ - .org-switcher { - position: relative; - } - - .org-switcher-btn { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - background: var(--color-bg-page); - border: var(--border-1) solid var(--color-border); - border-radius: var(--radius-md); - cursor: pointer; - font-size: var(--text-sm); - color: var(--color-text-heading); - min-width: 180px; - transition: var(--transition-colors); - } - - .org-switcher-btn:hover { - background: var(--color-gray-200); - } - - .org-switcher-btn .org-name { - flex: 1; - text-align: left; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .org-switcher-btn .arrow { - font-size: var(--text-xs); - color: var(--color-text-secondary); - } - - .org-switcher-dropdown { - display: none; - position: absolute; - top: 100%; - left: 0; - right: 0; - margin-top: var(--space-1); - background: var(--color-bg-card); - border: var(--border-1) solid var(--color-border); - border-radius: var(--radius-md); - box-shadow: var(--shadow-lg); - z-index: var(--z-dropdown); - max-height: 300px; - overflow-y: auto; - } - - .org-switcher-dropdown.show { - display: block; - } - - .org-switcher-item { - padding: var(--space-2.5) var(--space-3); - cursor: pointer; - border-bottom: var(--border-1) solid var(--color-gray-100); - transition: var(--transition-colors); - } - - .org-switcher-item:last-child { - border-bottom: none; - } - - .org-switcher-item:hover { - background: var(--color-bg-subtle); - } - - .org-switcher-item.selected { - background: var(--color-info-50); - } - - .org-switcher-item .item-name { - font-weight: var(--font-medium); - color: var(--color-text-heading); - } - - .org-switcher-item .item-badge { - display: inline-block; - padding: var(--space-0.5) var(--space-1.5); - font-size: var(--text-xs); - border-radius: var(--radius-full); - margin-left: var(--space-1.5); - } - - .org-switcher-item .item-badge.active { - background: var(--color-primary-100); - color: var(--color-success-700); - } - - .org-switcher-item .item-badge.inactive { - background: var(--color-gray-100); - color: var(--color-text-secondary); - } - /* Responsive layout for dashboard */ @media (max-width: 900px) { .dashboard-grid { @@ -257,59 +127,6 @@ } } - /* Delete workspace styles */ - .btn-danger { - background: #dc2626; - color: white; - border: none; - padding: 10px 20px; - border-radius: 6px; - cursor: pointer; - font-size: 14px; - } - .btn-danger:hover { - background: #b91c1c; - } - .delete-warning { - background: #fef2f2; - border-left: 4px solid #ef4444; - padding: 15px; - border-radius: 6px; - margin-bottom: 15px; - } - .delete-warning p { - margin: 0 0 10px 0; - color: #991b1b; - } - .delete-warning ul { - margin: 0; - padding-left: 20px; - color: #991b1b; - } - .delete-error { - background: #fee; - border-left: 4px solid #c33; - padding: 10px; - border-radius: 4px; - margin-top: 15px; - color: #c33; - } - .confirm-input { - width: 100%; - padding: 10px; - border: 1px solid #ddd; - border-radius: 6px; - font-size: 14px; - margin-top: 10px; - } - .workspace-name-highlight { - background: #fee; - padding: 2px 6px; - border-radius: 4px; - font-family: monospace; - font-weight: bold; - } - /* Toast notification */ .toast { position: fixed; @@ -384,49 +201,13 @@

Rename Organization

- - - - +
- - -
-
- - -
-
+ + +
@@ -525,16 +306,6 @@

- - -
- - -
@@ -590,69 +348,24 @@

- ${org.name} - ${badgeText} - - `; - }).join(''); - - // Update selected name - const selected = allOrganizations.find(o => o.id === selectedOrgId); - if (selected) { - selectedName.textContent = selected.name; - } + // Re-render the selected org + renderSelectedOrg(); } // Render the currently selected organization @@ -662,12 +375,6 @@

Create Your Pro const data = await response.json(); - // Show admin link if user is admin - if (data.user?.isAdmin) { - document.getElementById('adminLink').style.display = 'block'; - } + // Initialize sidebar navigation + const isAdmin = data.user?.isAdmin || false; + const hasMultipleOrgs = data.organizations && data.organizations.length > 1; if (data.organizations && data.organizations.length > 0) { // Load billing info for each organization @@ -1541,8 +1230,20 @@

Create Your Pro window.history.replaceState({}, '', url); } - // Update org switcher dropdown - updateOrgSwitcher(); + // Initialize sidebar with org switcher + const selectedOrg = orgsWithBilling.find(o => o.id === selectedOrgId) || orgsWithBilling[0]; + DashboardNav.init({ + showOrgSwitcher: hasMultipleOrgs, + currentOrgName: selectedOrg?.name || 'Dashboard', + showAdmin: isAdmin + }); + + // Set up org switcher in sidebar if multiple orgs + if (hasMultipleOrgs) { + DashboardNav.setOrgOptions(orgsWithBilling, selectedOrgId, (newOrgId) => { + selectOrg(newOrgId); + }); + } // Render the selected organization renderSelectedOrg(); @@ -2074,110 +1775,6 @@

Create Your Pro } } - // Organization settings helper labels - const companyTypeLabels = { - 'brand': 'Brand / Marketer', - 'publisher': 'Publisher / Media Network', - 'agency': 'Agency', - 'adtech': 'Technology Provider', - 'other': 'Other' - }; - - const revenueTierLabels = { - 'under_1m': '< $1M', - '1m_5m': '$1-5M', - '5m_50m': '$5-50M', - '50m_250m': '$50-250M', - '250m_1b': '$250M-1B', - '1b_plus': '$1B+' - }; - - // Render organization settings form - function renderOrgSettings(org) { - const container = document.getElementById('orgSettingsDetails'); - if (!container) return; - - // Get current values from billing data (which includes company_type, revenue_tier) - const companyType = org.billing?.company_type || null; - const revenueTier = org.billing?.revenue_tier || null; - - container.innerHTML = ` -
-
- - -
- -
- - -
- - - - -
- `; - } - - // Save organization settings - async function saveOrgSettings(orgId) { - const companyType = document.getElementById('orgSettingsCompanyType').value || null; - const revenueTier = document.getElementById('orgSettingsRevenueTier').value || null; - const messageEl = document.getElementById('orgSettingsMessage'); - - try { - const response = await fetch(`/api/organizations/${orgId}/settings`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - company_type: companyType, - revenue_tier: revenueTier - }), - credentials: 'same-origin' - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.message || 'Failed to save settings'); - } - - // Update the local org data - if (currentOrg && currentOrg.billing) { - currentOrg.billing.company_type = companyType; - currentOrg.billing.revenue_tier = revenueTier; - } - - // Show success message - messageEl.style.display = 'block'; - messageEl.style.background = 'var(--color-success-50)'; - messageEl.style.color = 'var(--color-success-700)'; - messageEl.textContent = 'Settings saved successfully'; - - setTimeout(() => { - messageEl.style.display = 'none'; - }, 3000); - } catch (error) { - console.error('Error saving org settings:', error); - messageEl.style.display = 'block'; - messageEl.style.background = 'var(--color-error-50)'; - messageEl.style.color = 'var(--color-error-700)'; - messageEl.textContent = error.message; - } - } - loadUserData(); // Handle browser back/forward cache (bfcache) restoration @@ -2192,87 +1789,6 @@

Create Your Pro } }); - // Delete workspace modal functions - function openDeleteWorkspaceModal() { - if (!currentOrg) return; - document.getElementById('deleteWorkspaceName').textContent = currentOrg.name; - document.getElementById('deleteConfirmInput').value = ''; - document.getElementById('deleteWorkspaceError').style.display = 'none'; - document.getElementById('confirmDeleteWorkspaceBtn').disabled = true; - document.getElementById('confirmDeleteWorkspaceBtn').style.opacity = '0.5'; - document.getElementById('deleteWorkspaceModal').classList.add('show'); - document.getElementById('deleteConfirmInput').focus(); - } - - function closeDeleteWorkspaceModal() { - document.getElementById('deleteWorkspaceModal').classList.remove('show'); - document.getElementById('deleteWorkspaceError').style.display = 'none'; - } - - function checkDeleteWorkspaceConfirmation() { - if (!currentOrg) return; - const input = document.getElementById('deleteConfirmInput').value; - const btn = document.getElementById('confirmDeleteWorkspaceBtn'); - if (input === currentOrg.name) { - btn.disabled = false; - btn.style.opacity = '1'; - } else { - btn.disabled = true; - btn.style.opacity = '0.5'; - } - } - - async function confirmDeleteWorkspace() { - if (!currentOrg) return; - - const confirmation = document.getElementById('deleteConfirmInput').value; - const errorEl = document.getElementById('deleteWorkspaceError'); - const btn = document.getElementById('confirmDeleteWorkspaceBtn'); - - // Disable button during request - btn.disabled = true; - btn.textContent = 'Deleting...'; - errorEl.style.display = 'none'; - - try { - const response = await fetch(`/api/organizations/${currentOrg.id}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ confirmation }), - credentials: 'same-origin', - }); - - const data = await response.json(); - - if (!response.ok) { - // Use the API's message which is more specific about the issue - throw new Error(data.message || data.error || 'Failed to delete workspace'); - } - - // Success - redirect to onboarding to show join/create options - alert('Workspace deleted successfully.'); - localStorage.removeItem('selectedOrgId'); - window.location.href = '/onboarding'; - } catch (error) { - errorEl.textContent = error.message; - errorEl.style.display = 'block'; - btn.disabled = false; - btn.textContent = 'Delete Workspace'; - } - } - - // Handle Enter key in delete confirmation input - document.addEventListener('DOMContentLoaded', () => { - const deleteInput = document.getElementById('deleteConfirmInput'); - if (deleteInput) { - deleteInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter' && !document.getElementById('confirmDeleteWorkspaceBtn').disabled) { - confirmDeleteWorkspace(); - } - }); - } - }); - // Toast notification helper function showToast(message, duration = 3000) { const toast = document.getElementById('toast'); diff --git a/server/src/db/email-db.ts b/server/src/db/email-db.ts new file mode 100644 index 000000000..e7a9a2aaf --- /dev/null +++ b/server/src/db/email-db.ts @@ -0,0 +1,282 @@ +import { query } from './client.js'; +import crypto from 'crypto'; + +export interface EmailEvent { + id: string; + tracking_id: string; + email_type: string; + recipient_email: string; + subject: string | null; + workos_user_id: string | null; + workos_organization_id: string | null; + sent_at: Date | null; + resend_email_id: string | null; + opened_at: Date | null; + open_count: number; + first_clicked_at: Date | null; + click_count: number; + delivered_at: Date | null; + bounced_at: Date | null; + bounce_reason: string | null; + metadata: Record | null; + created_at: Date; + updated_at: Date; +} + +export interface EmailClick { + id: number; + email_event_id: string; + link_name: string | null; + destination_url: string; + clicked_at: Date; + ip_address: string | null; + user_agent: string | null; + referrer: string | null; + utm_source: string | null; + utm_medium: string | null; + utm_campaign: string | null; +} + +/** + * Generate a short tracking ID for URLs + */ +function generateTrackingId(): string { + return crypto.randomBytes(12).toString('base64url'); +} + +/** + * Database operations for email tracking + */ +export class EmailDatabase { + /** + * Create a new email event record (before sending) + */ + async createEmailEvent(data: { + email_type: string; + recipient_email: string; + subject?: string; + workos_user_id?: string; + workos_organization_id?: string; + metadata?: Record; + }): Promise { + const trackingId = generateTrackingId(); + + const result = await query( + `INSERT INTO email_events ( + tracking_id, email_type, recipient_email, subject, + workos_user_id, workos_organization_id, metadata + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + trackingId, + data.email_type, + data.recipient_email, + data.subject || null, + data.workos_user_id || null, + data.workos_organization_id || null, + data.metadata ? JSON.stringify(data.metadata) : null, + ] + ); + + return result.rows[0]; + } + + /** + * Mark email as sent with Resend email ID + */ + async markEmailSent(trackingId: string, resendEmailId?: string): Promise { + await query( + `UPDATE email_events + SET sent_at = NOW(), resend_email_id = $2, updated_at = NOW() + WHERE tracking_id = $1`, + [trackingId, resendEmailId || null] + ); + } + + /** + * Get email event by tracking ID + */ + async getByTrackingId(trackingId: string): Promise { + const result = await query( + 'SELECT * FROM email_events WHERE tracking_id = $1', + [trackingId] + ); + return result.rows[0] || null; + } + + /** + * Get email event by Resend email ID (for webhook processing) + */ + async getByResendId(resendEmailId: string): Promise { + const result = await query( + 'SELECT * FROM email_events WHERE resend_email_id = $1', + [resendEmailId] + ); + return result.rows[0] || null; + } + + /** + * Check if we've already sent a specific email type to a user + */ + async hasEmailBeenSent(data: { + email_type: string; + workos_user_id: string; + }): Promise { + const result = await query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM email_events + WHERE email_type = $1 + AND workos_user_id = $2 + AND sent_at IS NOT NULL + ) as exists`, + [data.email_type, data.workos_user_id] + ); + return result.rows[0]?.exists || false; + } + + /** + * Record a click event + */ + async recordClick(data: { + tracking_id: string; + link_name?: string; + destination_url: string; + ip_address?: string; + user_agent?: string; + referrer?: string; + utm_source?: string; + utm_medium?: string; + utm_campaign?: string; + }): Promise<{ emailEvent: EmailEvent; click: EmailClick } | null> { + // Get the email event + const emailEvent = await this.getByTrackingId(data.tracking_id); + if (!emailEvent) { + return null; + } + + // Record the click + const clickResult = await query( + `INSERT INTO email_clicks ( + email_event_id, link_name, destination_url, + ip_address, user_agent, referrer, + utm_source, utm_medium, utm_campaign + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + emailEvent.id, + data.link_name || null, + data.destination_url, + data.ip_address || null, + data.user_agent || null, + data.referrer || null, + data.utm_source || null, + data.utm_medium || null, + data.utm_campaign || null, + ] + ); + + // Update aggregate click count on email event + await query( + `UPDATE email_events + SET click_count = click_count + 1, + first_clicked_at = COALESCE(first_clicked_at, NOW()), + updated_at = NOW() + WHERE id = $1`, + [emailEvent.id] + ); + + // Refresh the email event to get updated counts + const updatedEvent = await this.getByTrackingId(data.tracking_id); + + return { + emailEvent: updatedEvent!, + click: clickResult.rows[0], + }; + } + + /** + * Record email open (from Resend webhook) + */ + async recordOpen(resendEmailId: string): Promise { + await query( + `UPDATE email_events + SET open_count = open_count + 1, + opened_at = COALESCE(opened_at, NOW()), + updated_at = NOW() + WHERE resend_email_id = $1`, + [resendEmailId] + ); + } + + /** + * Record email delivery (from Resend webhook) + */ + async recordDelivery(resendEmailId: string): Promise { + await query( + `UPDATE email_events + SET delivered_at = NOW(), updated_at = NOW() + WHERE resend_email_id = $1`, + [resendEmailId] + ); + } + + /** + * Record email bounce (from Resend webhook) + */ + async recordBounce(resendEmailId: string, reason?: string): Promise { + await query( + `UPDATE email_events + SET bounced_at = NOW(), bounce_reason = $2, updated_at = NOW() + WHERE resend_email_id = $1`, + [resendEmailId, reason || null] + ); + } + + /** + * Get email stats for a user + */ + async getUserEmailStats(workosUserId: string): Promise<{ + total_sent: number; + total_opened: number; + total_clicked: number; + }> { + const result = await query<{ + total_sent: string; + total_opened: string; + total_clicked: string; + }>( + `SELECT + COUNT(*) FILTER (WHERE sent_at IS NOT NULL) as total_sent, + COUNT(*) FILTER (WHERE opened_at IS NOT NULL) as total_opened, + COUNT(*) FILTER (WHERE first_clicked_at IS NOT NULL) as total_clicked + FROM email_events + WHERE workos_user_id = $1`, + [workosUserId] + ); + + return { + total_sent: parseInt(result.rows[0]?.total_sent || '0', 10), + total_opened: parseInt(result.rows[0]?.total_opened || '0', 10), + total_clicked: parseInt(result.rows[0]?.total_clicked || '0', 10), + }; + } + + /** + * Get recent emails for an organization (for admin view) + */ + async getOrgEmails( + workosOrganizationId: string, + limit = 50 + ): Promise { + const result = await query( + `SELECT * FROM email_events + WHERE workos_organization_id = $1 + ORDER BY created_at DESC + LIMIT $2`, + [workosOrganizationId, limit] + ); + return result.rows; + } +} + +export const emailDb = new EmailDatabase(); diff --git a/server/src/db/email-preferences-db.ts b/server/src/db/email-preferences-db.ts new file mode 100644 index 000000000..bec173d92 --- /dev/null +++ b/server/src/db/email-preferences-db.ts @@ -0,0 +1,612 @@ +import { query } from './client.js'; +import crypto from 'crypto'; + +export interface EmailCategory { + id: string; + name: string; + description: string | null; + default_enabled: boolean; + sort_order: number; + created_at: Date; +} + +export interface UserEmailPreferences { + id: string; + workos_user_id: string; + email: string; + unsubscribe_token: string; + global_unsubscribe: boolean; + global_unsubscribe_at: Date | null; + created_at: Date; + updated_at: Date; +} + +export interface UserCategoryPreference { + id: string; + user_preference_id: string; + category_id: string; + enabled: boolean; + updated_at: Date; +} + +export interface EmailTemplate { + id: string; + name: string; + description: string | null; + subject_template: string; + html_template: string; + text_template: string; + category_id: string | null; + available_variables: Record | null; + version: number; + last_edited_by: string | null; + last_edited_at: Date | null; + created_at: Date; + updated_at: Date; +} + +export interface EmailCampaign { + id: string; + name: string; + description: string | null; + subject: string; + html_content: string; + text_content: string; + category_id: string; + target_audience: string; + status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled'; + scheduled_for: Date | null; + started_at: Date | null; + completed_at: Date | null; + total_recipients: number; + sent_count: number; + failed_count: number; + open_count: number; + click_count: number; + unsubscribe_count: number; + created_by: string | null; + created_at: Date; + updated_at: Date; +} + +/** + * Generate a secure unsubscribe token + */ +function generateUnsubscribeToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * Database operations for email preferences + */ +export class EmailPreferencesDatabase { + // ==================== Categories ==================== + + /** + * Get all email categories + */ + async getCategories(): Promise { + const result = await query( + 'SELECT * FROM email_categories ORDER BY sort_order ASC' + ); + return result.rows; + } + + /** + * Get a single category by ID + */ + async getCategoryById(categoryId: string): Promise { + const result = await query( + 'SELECT * FROM email_categories WHERE id = $1', + [categoryId] + ); + return result.rows[0] || null; + } + + // ==================== User Preferences ==================== + + /** + * Get or create user email preferences + * Creates a new record with defaults if none exists + */ + async getOrCreateUserPreferences(data: { + workos_user_id: string; + email: string; + }): Promise { + // Try to get existing + const existing = await query( + 'SELECT * FROM user_email_preferences WHERE workos_user_id = $1', + [data.workos_user_id] + ); + + if (existing.rows[0]) { + // Update email if changed + if (existing.rows[0].email !== data.email) { + await query( + 'UPDATE user_email_preferences SET email = $2, updated_at = NOW() WHERE workos_user_id = $1', + [data.workos_user_id, data.email] + ); + existing.rows[0].email = data.email; + } + return existing.rows[0]; + } + + // Create new with default preferences + const token = generateUnsubscribeToken(); + const result = await query( + `INSERT INTO user_email_preferences (workos_user_id, email, unsubscribe_token) + VALUES ($1, $2, $3) + RETURNING *`, + [data.workos_user_id, data.email, token] + ); + + return result.rows[0]; + } + + /** + * Get user preferences by unsubscribe token (no auth required) + */ + async getUserPreferencesByToken(token: string): Promise { + const result = await query( + 'SELECT * FROM user_email_preferences WHERE unsubscribe_token = $1', + [token] + ); + return result.rows[0] || null; + } + + /** + * Get user preferences by user ID + */ + async getUserPreferencesByUserId(workosUserId: string): Promise { + const result = await query( + 'SELECT * FROM user_email_preferences WHERE workos_user_id = $1', + [workosUserId] + ); + return result.rows[0] || null; + } + + /** + * Global unsubscribe (one-click unsubscribe from all non-transactional emails) + */ + async globalUnsubscribe(token: string): Promise { + const result = await query( + `UPDATE user_email_preferences + SET global_unsubscribe = true, global_unsubscribe_at = NOW(), updated_at = NOW() + WHERE unsubscribe_token = $1 + RETURNING id`, + [token] + ); + return (result.rowCount ?? 0) > 0; + } + + /** + * Re-subscribe (undo global unsubscribe) + */ + async resubscribe(workosUserId: string): Promise { + const result = await query( + `UPDATE user_email_preferences + SET global_unsubscribe = false, global_unsubscribe_at = NULL, updated_at = NOW() + WHERE workos_user_id = $1 + RETURNING id`, + [workosUserId] + ); + return (result.rowCount ?? 0) > 0; + } + + // ==================== Category Preferences ==================== + + /** + * Get user's category preferences with defaults applied + */ + async getUserCategoryPreferences(workosUserId: string): Promise< + Array<{ + category_id: string; + category_name: string; + category_description: string | null; + enabled: boolean; + is_override: boolean; + }> + > { + // Get user preference record + const userPrefs = await this.getUserPreferencesByUserId(workosUserId); + + // Get all categories with any overrides + const result = await query<{ + category_id: string; + category_name: string; + category_description: string | null; + default_enabled: boolean; + override_enabled: boolean | null; + }>( + `SELECT + c.id as category_id, + c.name as category_name, + c.description as category_description, + c.default_enabled, + ucp.enabled as override_enabled + FROM email_categories c + LEFT JOIN user_email_category_preferences ucp + ON ucp.category_id = c.id + AND ucp.user_preference_id = $1 + ORDER BY c.sort_order ASC`, + [userPrefs?.id || null] + ); + + return result.rows.map((row) => ({ + category_id: row.category_id, + category_name: row.category_name, + category_description: row.category_description, + enabled: row.override_enabled !== null ? row.override_enabled : row.default_enabled, + is_override: row.override_enabled !== null, + })); + } + + /** + * Set preference for a specific category + */ + async setCategoryPreference(data: { + workos_user_id: string; + email: string; + category_id: string; + enabled: boolean; + }): Promise { + // Ensure user preferences exist + const userPrefs = await this.getOrCreateUserPreferences({ + workos_user_id: data.workos_user_id, + email: data.email, + }); + + // Upsert category preference + await query( + `INSERT INTO user_email_category_preferences (user_preference_id, category_id, enabled) + VALUES ($1, $2, $3) + ON CONFLICT (user_preference_id, category_id) + DO UPDATE SET enabled = $3, updated_at = NOW()`, + [userPrefs.id, data.category_id, data.enabled] + ); + } + + /** + * Check if a user wants to receive a specific category of email + */ + async shouldSendEmail(data: { + workos_user_id: string; + category_id: string; + }): Promise { + const userPrefs = await this.getUserPreferencesByUserId(data.workos_user_id); + + // If no preferences exist, use category default + if (!userPrefs) { + const category = await this.getCategoryById(data.category_id); + return category?.default_enabled ?? true; + } + + // Check global unsubscribe first + if (userPrefs.global_unsubscribe) { + return false; + } + + // Check category-specific preference + const result = await query<{ enabled: boolean; default_enabled: boolean }>( + `SELECT + ucp.enabled, + c.default_enabled + FROM email_categories c + LEFT JOIN user_email_category_preferences ucp + ON ucp.category_id = c.id + AND ucp.user_preference_id = $1 + WHERE c.id = $2`, + [userPrefs.id, data.category_id] + ); + + if (result.rows[0]) { + const row = result.rows[0]; + return row.enabled !== null ? row.enabled : row.default_enabled; + } + + return true; // Default to sending if category doesn't exist + } + + // ==================== Templates ==================== + + /** + * Get all email templates + */ + async getTemplates(): Promise { + const result = await query( + 'SELECT * FROM email_templates ORDER BY name ASC' + ); + return result.rows; + } + + /** + * Get a template by ID + */ + async getTemplateById(templateId: string): Promise { + const result = await query( + 'SELECT * FROM email_templates WHERE id = $1', + [templateId] + ); + return result.rows[0] || null; + } + + /** + * Update a template + */ + async updateTemplate( + templateId: string, + data: { + subject_template?: string; + html_template?: string; + text_template?: string; + last_edited_by: string; + } + ): Promise { + const result = await query( + `UPDATE email_templates SET + subject_template = COALESCE($2, subject_template), + html_template = COALESCE($3, html_template), + text_template = COALESCE($4, text_template), + last_edited_by = $5, + last_edited_at = NOW(), + version = version + 1, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [ + templateId, + data.subject_template || null, + data.html_template || null, + data.text_template || null, + data.last_edited_by, + ] + ); + return result.rows[0] || null; + } + + // ==================== Campaigns ==================== + + /** + * Create a new email campaign + */ + async createCampaign(data: { + name: string; + description?: string; + subject: string; + html_content: string; + text_content: string; + category_id: string; + target_audience?: string; + created_by?: string; + }): Promise { + const result = await query( + `INSERT INTO email_campaigns ( + name, description, subject, html_content, text_content, + category_id, target_audience, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + data.name, + data.description || null, + data.subject, + data.html_content, + data.text_content, + data.category_id, + data.target_audience || 'all_subscribers', + data.created_by || null, + ] + ); + return result.rows[0]; + } + + /** + * Get all campaigns + */ + async getCampaigns(filters?: { + status?: string; + category_id?: string; + }): Promise { + let sql = 'SELECT * FROM email_campaigns WHERE 1=1'; + const params: unknown[] = []; + + if (filters?.status) { + params.push(filters.status); + sql += ` AND status = $${params.length}`; + } + + if (filters?.category_id) { + params.push(filters.category_id); + sql += ` AND category_id = $${params.length}`; + } + + sql += ' ORDER BY created_at DESC'; + + const result = await query(sql, params); + return result.rows; + } + + /** + * Get a campaign by ID + */ + async getCampaignById(campaignId: string): Promise { + const result = await query( + 'SELECT * FROM email_campaigns WHERE id = $1', + [campaignId] + ); + return result.rows[0] || null; + } + + /** + * Update campaign + */ + async updateCampaign( + campaignId: string, + data: Partial<{ + name: string; + description: string; + subject: string; + html_content: string; + text_content: string; + category_id: string; + target_audience: string; + status: string; + scheduled_for: Date; + }> + ): Promise { + // Only allow updates to draft campaigns + const campaign = await this.getCampaignById(campaignId); + if (!campaign || campaign.status !== 'draft') { + return null; + } + + const setClauses: string[] = []; + const params: unknown[] = [campaignId]; + + if (data.name !== undefined) { + params.push(data.name); + setClauses.push(`name = $${params.length}`); + } + if (data.description !== undefined) { + params.push(data.description); + setClauses.push(`description = $${params.length}`); + } + if (data.subject !== undefined) { + params.push(data.subject); + setClauses.push(`subject = $${params.length}`); + } + if (data.html_content !== undefined) { + params.push(data.html_content); + setClauses.push(`html_content = $${params.length}`); + } + if (data.text_content !== undefined) { + params.push(data.text_content); + setClauses.push(`text_content = $${params.length}`); + } + if (data.category_id !== undefined) { + params.push(data.category_id); + setClauses.push(`category_id = $${params.length}`); + } + if (data.target_audience !== undefined) { + params.push(data.target_audience); + setClauses.push(`target_audience = $${params.length}`); + } + if (data.status !== undefined) { + params.push(data.status); + setClauses.push(`status = $${params.length}`); + } + if (data.scheduled_for !== undefined) { + params.push(data.scheduled_for); + setClauses.push(`scheduled_for = $${params.length}`); + } + + if (setClauses.length === 0) { + return campaign; + } + + setClauses.push('updated_at = NOW()'); + + const result = await query( + `UPDATE email_campaigns SET ${setClauses.join(', ')} WHERE id = $1 RETURNING *`, + params + ); + return result.rows[0] || null; + } + + /** + * Update campaign stats + */ + async updateCampaignStats( + campaignId: string, + stats: Partial<{ + total_recipients: number; + sent_count: number; + failed_count: number; + open_count: number; + click_count: number; + unsubscribe_count: number; + }> + ): Promise { + const setClauses: string[] = []; + const params: unknown[] = [campaignId]; + + Object.entries(stats).forEach(([key, value]) => { + if (value !== undefined) { + params.push(value); + setClauses.push(`${key} = $${params.length}`); + } + }); + + if (setClauses.length > 0) { + setClauses.push('updated_at = NOW()'); + await query( + `UPDATE email_campaigns SET ${setClauses.join(', ')} WHERE id = $1`, + params + ); + } + } + + /** + * Get campaign stats summary + */ + async getCampaignStats(): Promise<{ + total_campaigns: number; + total_sent: number; + total_opened: number; + total_clicked: number; + avg_open_rate: number; + avg_click_rate: number; + }> { + const result = await query<{ + total_campaigns: string; + total_sent: string; + total_opened: string; + total_clicked: string; + }>( + `SELECT + COUNT(*) as total_campaigns, + SUM(sent_count) as total_sent, + SUM(open_count) as total_opened, + SUM(click_count) as total_clicked + FROM email_campaigns + WHERE status = 'sent'` + ); + + const row = result.rows[0]; + const totalSent = parseInt(row?.total_sent || '0', 10); + const totalOpened = parseInt(row?.total_opened || '0', 10); + const totalClicked = parseInt(row?.total_clicked || '0', 10); + + return { + total_campaigns: parseInt(row?.total_campaigns || '0', 10), + total_sent: totalSent, + total_opened: totalOpened, + total_clicked: totalClicked, + avg_open_rate: totalSent > 0 ? (totalOpened / totalSent) * 100 : 0, + avg_click_rate: totalSent > 0 ? (totalClicked / totalSent) * 100 : 0, + }; + } + + // ==================== Unsubscribe from Category via Token ==================== + + /** + * Unsubscribe from a specific category using token (no auth) + */ + async unsubscribeFromCategory(token: string, categoryId: string): Promise { + const userPrefs = await this.getUserPreferencesByToken(token); + if (!userPrefs) { + return false; + } + + await query( + `INSERT INTO user_email_category_preferences (user_preference_id, category_id, enabled) + VALUES ($1, $2, false) + ON CONFLICT (user_preference_id, category_id) + DO UPDATE SET enabled = false, updated_at = NOW()`, + [userPrefs.id, categoryId] + ); + + return true; + } +} + +export const emailPrefsDb = new EmailPreferencesDatabase(); diff --git a/server/src/db/migrations/037_email_tracking.sql b/server/src/db/migrations/037_email_tracking.sql new file mode 100644 index 000000000..757a86e7b --- /dev/null +++ b/server/src/db/migrations/037_email_tracking.sql @@ -0,0 +1,90 @@ +-- Migration: 037_email_tracking.sql +-- Track email sends, opens, and clicks for engagement analytics + +-- Email events table - tracks all email lifecycle events +CREATE TABLE IF NOT EXISTS email_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Tracking identifier (used in click URLs) + tracking_id VARCHAR(32) NOT NULL UNIQUE, + + -- Email details + email_type VARCHAR(50) NOT NULL, -- welcome_member, signup_user, etc. + recipient_email VARCHAR(255) NOT NULL, + subject VARCHAR(500), + + -- User/org context (nullable - some emails may not have org context) + workos_user_id VARCHAR(255), + workos_organization_id VARCHAR(255), + + -- Send status + sent_at TIMESTAMP WITH TIME ZONE, + resend_email_id VARCHAR(255), -- Resend's email ID for webhook correlation + + -- Engagement tracking + opened_at TIMESTAMP WITH TIME ZONE, + open_count INTEGER DEFAULT 0, + + -- Click tracking (aggregated - details in email_clicks) + first_clicked_at TIMESTAMP WITH TIME ZONE, + click_count INTEGER DEFAULT 0, + + -- Delivery status + delivered_at TIMESTAMP WITH TIME ZONE, + bounced_at TIMESTAMP WITH TIME ZONE, + bounce_reason TEXT, + + -- Metadata + metadata JSONB, -- Store any additional context (e.g., which email variant) + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Individual click events for detailed analytics +CREATE TABLE IF NOT EXISTS email_clicks ( + id SERIAL PRIMARY KEY, + + -- Link to parent email + email_event_id UUID NOT NULL REFERENCES email_events(id) ON DELETE CASCADE, + + -- Click details + link_name VARCHAR(100), -- e.g., 'cta_dashboard', 'cta_billing', 'footer_link' + destination_url TEXT NOT NULL, + + -- When and from where + clicked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + ip_address VARCHAR(50), + user_agent TEXT, + referrer TEXT, + + -- UTM parameters captured + utm_source VARCHAR(100), + utm_medium VARCHAR(100), + utm_campaign VARCHAR(100) +); + +-- Indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_email_events_tracking_id ON email_events(tracking_id); +CREATE INDEX IF NOT EXISTS idx_email_events_user ON email_events(workos_user_id); +CREATE INDEX IF NOT EXISTS idx_email_events_org ON email_events(workos_organization_id); +CREATE INDEX IF NOT EXISTS idx_email_events_type ON email_events(email_type); +CREATE INDEX IF NOT EXISTS idx_email_events_sent ON email_events(sent_at DESC); +CREATE INDEX IF NOT EXISTS idx_email_events_resend_id ON email_events(resend_email_id); + +CREATE INDEX IF NOT EXISTS idx_email_clicks_event ON email_clicks(email_event_id); +CREATE INDEX IF NOT EXISTS idx_email_clicks_time ON email_clicks(clicked_at DESC); + +-- Trigger for updated_at +DROP TRIGGER IF EXISTS update_email_events_updated_at ON email_events; +CREATE TRIGGER update_email_events_updated_at + BEFORE UPDATE ON email_events + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Comments +COMMENT ON TABLE email_events IS 'Tracks all transactional email sends and engagement'; +COMMENT ON TABLE email_clicks IS 'Individual click events for email link tracking'; +COMMENT ON COLUMN email_events.tracking_id IS 'Short ID used in tracked URLs (e.g., /r/abc123)'; +COMMENT ON COLUMN email_events.email_type IS 'Type: welcome_member, signup_user, etc.'; +COMMENT ON COLUMN email_clicks.link_name IS 'Semantic name for the link (cta_dashboard, cta_billing, etc.)'; diff --git a/server/src/db/migrations/038_email_preferences.sql b/server/src/db/migrations/038_email_preferences.sql new file mode 100644 index 000000000..22938a7b1 --- /dev/null +++ b/server/src/db/migrations/038_email_preferences.sql @@ -0,0 +1,187 @@ +-- Migration: 038_email_preferences.sql +-- User email preferences and unsubscribe management + +-- Email categories that users can subscribe/unsubscribe from +-- Transactional emails (welcome, security) are always sent and not in this list +CREATE TABLE IF NOT EXISTS email_categories ( + id VARCHAR(50) PRIMARY KEY, -- e.g., 'newsletter', 'working_groups', 'releases' + name VARCHAR(100) NOT NULL, + description TEXT, + default_enabled BOOLEAN DEFAULT true, -- Default preference for new users + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- User email preferences - which categories each user wants +CREATE TABLE IF NOT EXISTS user_email_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workos_user_id VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, -- Denormalized for unsubscribe without auth + + -- Unsubscribe token for one-click unsubscribe (no auth required) + unsubscribe_token VARCHAR(64) NOT NULL UNIQUE, + + -- Global opt-out (unsubscribes from ALL non-transactional emails) + global_unsubscribe BOOLEAN DEFAULT false, + global_unsubscribe_at TIMESTAMP WITH TIME ZONE, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(workos_user_id) +); + +-- Per-category preferences (only stores overrides from default) +CREATE TABLE IF NOT EXISTS user_email_category_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_preference_id UUID NOT NULL REFERENCES user_email_preferences(id) ON DELETE CASCADE, + category_id VARCHAR(50) NOT NULL REFERENCES email_categories(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(user_preference_id, category_id) +); + +-- Email templates that admins can edit +CREATE TABLE IF NOT EXISTS email_templates ( + id VARCHAR(50) PRIMARY KEY, -- e.g., 'welcome_member', 'newsletter' + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Template content (supports {{variable}} substitution) + subject_template VARCHAR(500) NOT NULL, + html_template TEXT NOT NULL, + text_template TEXT NOT NULL, + + -- Which category this template belongs to (null = transactional) + category_id VARCHAR(50) REFERENCES email_categories(id), + + -- Template variables documentation + available_variables JSONB, -- e.g., {"firstName": "User's first name", "orgName": "Organization name"} + + -- Versioning + version INTEGER DEFAULT 1, + last_edited_by VARCHAR(255), + last_edited_at TIMESTAMP WITH TIME ZONE, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Email campaigns (one-off sends like newsletters) +CREATE TABLE IF NOT EXISTS email_campaigns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Campaign details + name VARCHAR(200) NOT NULL, + description TEXT, + + -- Content + subject VARCHAR(500) NOT NULL, + html_content TEXT NOT NULL, + text_content TEXT NOT NULL, + + -- Targeting + category_id VARCHAR(50) NOT NULL REFERENCES email_categories(id), + target_audience VARCHAR(50) DEFAULT 'all_subscribers', -- all_subscribers, members_only, non_members + + -- Status + status VARCHAR(20) DEFAULT 'draft' + CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'cancelled')), + + -- Scheduling + scheduled_for TIMESTAMP WITH TIME ZONE, + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + + -- Stats (updated as sends complete) + total_recipients INTEGER DEFAULT 0, + sent_count INTEGER DEFAULT 0, + failed_count INTEGER DEFAULT 0, + open_count INTEGER DEFAULT 0, + click_count INTEGER DEFAULT 0, + unsubscribe_count INTEGER DEFAULT 0, + + -- Who created/edited + created_by VARCHAR(255), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_user_email_prefs_user ON user_email_preferences(workos_user_id); +CREATE INDEX IF NOT EXISTS idx_user_email_prefs_email ON user_email_preferences(email); +CREATE INDEX IF NOT EXISTS idx_user_email_prefs_token ON user_email_preferences(unsubscribe_token); +CREATE INDEX IF NOT EXISTS idx_email_campaigns_status ON email_campaigns(status); +CREATE INDEX IF NOT EXISTS idx_email_campaigns_category ON email_campaigns(category_id); + +-- Trigger for updated_at +DROP TRIGGER IF EXISTS update_user_email_preferences_updated_at ON user_email_preferences; +CREATE TRIGGER update_user_email_preferences_updated_at + BEFORE UPDATE ON user_email_preferences + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_email_templates_updated_at ON email_templates; +CREATE TRIGGER update_email_templates_updated_at + BEFORE UPDATE ON email_templates + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_email_campaigns_updated_at ON email_campaigns; +CREATE TRIGGER update_email_campaigns_updated_at + BEFORE UPDATE ON email_campaigns + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Seed default email categories +INSERT INTO email_categories (id, name, description, default_enabled, sort_order) VALUES + ('newsletter', 'Newsletter', 'Monthly newsletter with industry updates and member news', true, 1), + ('working_groups', 'Working Group Updates', 'Summaries and updates from working groups you''re part of', true, 2), + ('releases', 'Release Announcements', 'New AdCP releases, features, and protocol updates', true, 3), + ('member_directory', 'Member Directory Updates', 'New members joining and member profile updates', true, 4), + ('events', 'Events & Webinars', 'Upcoming events, webinars, and community gatherings', true, 5) +ON CONFLICT (id) DO NOTHING; + +-- Seed default email templates +INSERT INTO email_templates (id, name, description, subject_template, html_template, text_template, category_id, available_variables) VALUES + ( + 'welcome_member', + 'Welcome Email (New Member)', + 'Sent when an organization becomes a paying member', + 'Welcome to AgenticAdvertising.org!', + '', + '', + NULL, -- Transactional + '{"organizationName": "Name of the organization", "productName": "Subscription plan name"}' + ), + ( + 'signup_user', + 'Welcome Email (New User)', + 'Sent when a new user signs up', + 'Welcome to AgenticAdvertising.org', + '', + '', + NULL, -- Transactional + '{"firstName": "User first name", "organizationName": "Organization name", "hasActiveSubscription": "Whether org is a member"}' + ), + ( + 'newsletter', + 'Monthly Newsletter', + 'Monthly digest of news, updates, and member highlights', + '{{month}} Newsletter - AgenticAdvertising.org', + '', + '', + 'newsletter', + '{"month": "Newsletter month (e.g., January 2025)", "articles": "Array of article objects"}' + ) +ON CONFLICT (id) DO NOTHING; + +-- Comments +COMMENT ON TABLE email_categories IS 'Categories of emails users can subscribe/unsubscribe from'; +COMMENT ON TABLE user_email_preferences IS 'User-level email preferences and unsubscribe tokens'; +COMMENT ON TABLE user_email_category_preferences IS 'Per-category preference overrides'; +COMMENT ON TABLE email_templates IS 'Admin-editable email templates'; +COMMENT ON TABLE email_campaigns IS 'One-off email campaigns like newsletters'; +COMMENT ON COLUMN user_email_preferences.unsubscribe_token IS 'Token for one-click unsubscribe without requiring login'; +COMMENT ON COLUMN user_email_preferences.global_unsubscribe IS 'If true, user receives no non-transactional emails'; diff --git a/server/src/http.ts b/server/src/http.ts index daaff93c0..187247176 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -45,6 +45,8 @@ import { notifyWorkingGroupPost, } from "./notifications/slack.js"; import { createAdminRouter } from "./routes/admin.js"; +import { sendWelcomeEmail, sendUserSignupEmail, emailDb } from "./notifications/email.js"; +import { emailPrefsDb } from "./db/email-preferences-db.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -284,6 +286,335 @@ export class HTTPServer { } res.redirect('/team.html' + (req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '')); }); + + // Email click tracker - records clicks and redirects to destination + this.app.get('/r/:trackingId', async (req, res) => { + const { trackingId } = req.params; + const destinationUrl = req.query.to as string; + const linkName = req.query.ln as string; + + if (!destinationUrl) { + logger.warn({ trackingId }, 'Click tracker missing destination URL'); + return res.redirect('/'); + } + + try { + // Record the click + await emailDb.recordClick({ + tracking_id: trackingId, + link_name: linkName, + destination_url: destinationUrl, + ip_address: req.ip, + user_agent: req.get('user-agent'), + referrer: req.get('referer'), + utm_source: req.query.utm_source as string, + utm_medium: req.query.utm_medium as string, + utm_campaign: req.query.utm_campaign as string, + }); + + logger.debug({ trackingId, linkName, destination: destinationUrl }, 'Email click recorded'); + } catch (error) { + // Log but don't fail - always redirect even if tracking fails + logger.error({ error, trackingId }, 'Failed to record email click'); + } + + // Always redirect to destination + res.redirect(destinationUrl); + }); + + // ==================== Email Preferences & Unsubscribe ==================== + + // One-click unsubscribe (no auth required) - POST for RFC 8058 compliance + this.app.post('/unsubscribe/:token', async (req, res) => { + const { token } = req.params; + const { category } = req.body; + + try { + if (category) { + // Unsubscribe from specific category + const success = await emailPrefsDb.unsubscribeFromCategory(token, category); + if (success) { + logger.info({ token: token.substring(0, 8) + '...', category }, 'User unsubscribed from category'); + return res.json({ success: true, message: `Unsubscribed from ${category}` }); + } + } else { + // Global unsubscribe + const success = await emailPrefsDb.globalUnsubscribe(token); + if (success) { + logger.info({ token: token.substring(0, 8) + '...' }, 'User globally unsubscribed'); + return res.json({ success: true, message: 'Unsubscribed from all emails' }); + } + } + + return res.status(404).json({ success: false, message: 'Invalid unsubscribe link' }); + } catch (error) { + logger.error({ error, token: token.substring(0, 8) + '...' }, 'Error processing unsubscribe'); + return res.status(500).json({ success: false, message: 'Error processing unsubscribe' }); + } + }); + + // Unsubscribe page (GET - shows confirmation page, handles one-click via List-Unsubscribe-Post) + this.app.get('/unsubscribe/:token', async (req, res) => { + const { token } = req.params; + + try { + const prefs = await emailPrefsDb.getUserPreferencesByToken(token); + if (!prefs) { + return res.status(404).send('Invalid unsubscribe link'); + } + + // Get categories for the preferences page + const categories = await emailPrefsDb.getCategories(); + const userCategoryPrefs = prefs.workos_user_id + ? await emailPrefsDb.getUserCategoryPreferences(prefs.workos_user_id) + : categories.map(c => ({ + category_id: c.id, + category_name: c.name, + category_description: c.description, + enabled: c.default_enabled, + is_override: false, + })); + + // Serve a simple preferences management page + res.send(` + + + + + + Email Preferences - AgenticAdvertising.org + + + + +

Email Preferences

+

Manage which emails you receive from AgenticAdvertising.org

+ +
Your preferences have been saved.
+ + ${prefs.global_unsubscribe ? ` +
+

You are currently unsubscribed from all emails.

+

You will only receive essential transactional emails (like security alerts).

+ +
+ ` : ` +
+ ${userCategoryPrefs.map(cat => ` +
+
+

${cat.category_name}

+

${cat.category_description || ''}

+
+ +
+ `).join('')} +
+ +
+

Want to stop receiving all non-essential emails?

+ +
+ `} + + + + + `); + } catch (error) { + logger.error({ error }, 'Error rendering unsubscribe page'); + res.status(500).send('Error loading preferences'); + } + }); + + // Update category preference via token (no auth required) + this.app.post('/api/email-preferences/category', async (req, res) => { + const { token, category_id, enabled } = req.body; + + if (!token || !category_id || enabled === undefined) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + try { + const prefs = await emailPrefsDb.getUserPreferencesByToken(token); + if (!prefs) { + return res.status(404).json({ error: 'Invalid token' }); + } + + await emailPrefsDb.setCategoryPreference({ + workos_user_id: prefs.workos_user_id, + email: prefs.email, + category_id, + enabled, + }); + + logger.info({ userId: prefs.workos_user_id, category_id, enabled }, 'Category preference updated'); + res.json({ success: true }); + } catch (error) { + logger.error({ error }, 'Error updating category preference'); + res.status(500).json({ error: 'Error updating preference' }); + } + }); + + // Resubscribe via token (no auth required) + this.app.post('/api/email-preferences/resubscribe', async (req, res) => { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ error: 'Missing token' }); + } + + try { + const prefs = await emailPrefsDb.getUserPreferencesByToken(token); + if (!prefs) { + return res.status(404).json({ error: 'Invalid token' }); + } + + await emailPrefsDb.resubscribe(prefs.workos_user_id); + logger.info({ userId: prefs.workos_user_id }, 'User resubscribed'); + res.json({ success: true }); + } catch (error) { + logger.error({ error }, 'Error processing resubscribe'); + res.status(500).json({ error: 'Error processing resubscribe' }); + } + }); + + // Get email categories (public) + this.app.get('/api/email-preferences/categories', async (req, res) => { + try { + const categories = await emailPrefsDb.getCategories(); + res.json({ categories }); + } catch (error) { + logger.error({ error }, 'Error fetching email categories'); + res.status(500).json({ error: 'Error fetching categories' }); + } + }); + + // Get user's email preferences (authenticated) + this.app.get('/api/email-preferences', requireAuth, async (req, res) => { + try { + const userId = (req as any).user.id; + const userEmail = (req as any).user.email; + + // Get or create preferences + const prefs = await emailPrefsDb.getOrCreateUserPreferences({ + workos_user_id: userId, + email: userEmail, + }); + + // Get category preferences + const categoryPrefs = await emailPrefsDb.getUserCategoryPreferences(userId); + + res.json({ + global_unsubscribe: prefs.global_unsubscribe, + categories: categoryPrefs, + }); + } catch (error) { + logger.error({ error }, 'Error fetching user preferences'); + res.status(500).json({ error: 'Error fetching preferences' }); + } + }); + + // Update user's email preferences (authenticated) + this.app.post('/api/email-preferences', requireAuth, async (req, res) => { + try { + const userId = (req as any).user.id; + const userEmail = (req as any).user.email; + const { category_id, enabled } = req.body; + + if (!category_id || enabled === undefined) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + await emailPrefsDb.setCategoryPreference({ + workos_user_id: userId, + email: userEmail, + category_id, + enabled, + }); + + res.json({ success: true }); + } catch (error) { + logger.error({ error }, 'Error updating preferences'); + res.status(500).json({ error: 'Error updating preferences' }); + } + }); + + // Resubscribe for authenticated users + this.app.post('/api/email-preferences/resubscribe-me', requireAuth, async (req, res) => { + try { + const userId = (req as any).user.id; + + await emailPrefsDb.resubscribe(userId); + logger.info({ userId }, 'User resubscribed via dashboard'); + res.json({ success: true }); + } catch (error) { + logger.error({ error }, 'Error processing resubscribe'); + res.status(500).json({ error: 'Error processing resubscribe' }); + } + }); + this.app.get('/dashboard', async (req, res) => { // Redirect to AAO for auth-requiring pages when on AdCP domain if (this.isAdcpDomain(req)) { @@ -314,6 +645,40 @@ export class HTTPServer { } }); + // Dashboard sub-pages with sidebar navigation + // Helper to serve dashboard pages with template variable replacement + const serveDashboardPage = async (req: express.Request, res: express.Response, filename: string) => { + if (this.isAdcpDomain(req)) { + return res.redirect(`https://agenticadvertising.org/dashboard/${filename.replace('dashboard-', '').replace('.html', '')}`); + } + try { + const fs = await import('fs/promises'); + const pagePath = process.env.NODE_ENV === 'production' + ? path.join(__dirname, `../server/public/${filename}`) + : path.join(__dirname, `../public/${filename}`); + let html = await fs.readFile(pagePath, 'utf-8'); + + // Replace template variables (for billing page with Stripe) + html = html + .replace(/\{\{STRIPE_PUBLISHABLE_KEY\}\}/g, process.env.STRIPE_PUBLISHABLE_KEY || '') + .replace(/\{\{STRIPE_PRICING_TABLE_ID\}\}/g, process.env.STRIPE_PRICING_TABLE_ID || '') + .replace(/\{\{STRIPE_PRICING_TABLE_ID_INDIVIDUAL\}\}/g, process.env.STRIPE_PRICING_TABLE_ID_INDIVIDUAL || process.env.STRIPE_PRICING_TABLE_ID || ''); + + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.send(html); + } catch (error) { + logger.error({ err: error, filename }, 'Error serving dashboard page'); + res.status(500).send('Error loading page'); + } + }; + + this.app.get('/dashboard/settings', (req, res) => serveDashboardPage(req, res, 'dashboard-settings.html')); + this.app.get('/dashboard/billing', (req, res) => serveDashboardPage(req, res, 'dashboard-billing.html')); + this.app.get('/dashboard/emails', (req, res) => serveDashboardPage(req, res, 'dashboard-emails.html')); + // API endpoints // Public config endpoint - returns feature flags and auth state for nav @@ -1069,6 +1434,15 @@ export class HTTPServer { currency: subscription.currency, interval, }).catch(err => logger.error({ err }, 'Failed to send Slack notification')); + + // Send welcome email to new member + sendWelcomeEmail({ + to: userEmail, + organizationName: org.name || 'Unknown Organization', + productName, + workosUserId: workosUser.id, + workosOrganizationId: org.workos_organization_id, + }).catch(err => logger.error({ err }, 'Failed to send welcome email')); } else { logger.error({ userEmail, @@ -3652,6 +4026,13 @@ Disallow: /api/admin/ res.sendFile(usersPath); }); + this.app.get('/admin/email', requireAuth, requireAdmin, (req, res) => { + const emailPath = process.env.NODE_ENV === 'production' + ? path.join(__dirname, '../server/public/admin-email.html') + : path.join(__dirname, '../public/admin-email.html'); + res.sendFile(emailPath); + }); + // Registry API endpoints (consolidated agents, publishers, lookups) this.setupRegistryRoutes(); } @@ -4172,7 +4553,12 @@ Disallow: /api/admin/ // This happens when: // 1. User has never accepted them, OR // 2. The version has been updated since they last accepted + let isFirstTimeUser = false; try { + // Check if user has ANY prior acceptances (to detect first-time users) + const priorAcceptances = await orgDb.getUserAgreementAcceptances(user.id); + isFirstTimeUser = priorAcceptances.length === 0; + const tosAgreement = await orgDb.getCurrentAgreementByType('terms_of_service'); const privacyAgreement = await orgDb.getCurrentAgreementByType('privacy_policy'); @@ -4234,6 +4620,35 @@ Disallow: /api/admin/ logger.debug({ count: memberships.data.length }, 'Organization memberships retrieved'); + // Send welcome email to first-time users (async, don't block auth flow) + if (isFirstTimeUser && memberships.data.length > 0) { + // Get org details to determine subscription status + const firstMembership = memberships.data[0]; + const orgId = firstMembership.organizationId; + + // Fire and forget - don't block the auth callback + (async () => { + try { + const org = await orgDb.getOrganization(orgId); + const workosOrg = await workos!.organizations.getOrganization(orgId); + const hasActiveSubscription = org?.subscription_status === 'active'; + + await sendUserSignupEmail({ + to: user.email, + firstName: user.firstName || undefined, + organizationName: workosOrg?.name || org?.name || undefined, + hasActiveSubscription, + workosUserId: user.id, + workosOrganizationId: orgId, + }); + + logger.info({ userId: user.id, orgId, hasActiveSubscription }, 'First-time user signup email sent'); + } catch (emailError) { + logger.error({ error: emailError, userId: user.id }, 'Failed to send signup email'); + } + })(); + } + // Parse return_to and slack_user_id from state let returnTo = '/dashboard'; let slackUserIdToLink: string | undefined; @@ -9690,6 +10105,100 @@ Disallow: /api/admin/ } }); + // ============== Admin Email Endpoints ============== + + // GET /api/admin/email/stats - Email statistics for admin dashboard + this.app.get('/api/admin/email/stats', requireAuth, requireAdmin, async (req, res) => { + try { + const pool = getPool(); + + // Get total emails sent + const sentResult = await pool.query( + `SELECT COUNT(*) as count FROM email_events WHERE sent_at IS NOT NULL` + ); + const totalSent = parseInt(sentResult.rows[0]?.count || '0'); + + // Get open rate + const openResult = await pool.query( + `SELECT + COUNT(*) FILTER (WHERE opened_at IS NOT NULL) as opened, + COUNT(*) as total + FROM email_events + WHERE sent_at IS NOT NULL` + ); + const avgOpenRate = openResult.rows[0]?.total > 0 + ? (parseInt(openResult.rows[0].opened) / parseInt(openResult.rows[0].total)) * 100 + : 0; + + // Get click rate + const clickResult = await pool.query( + `SELECT + COUNT(*) FILTER (WHERE first_clicked_at IS NOT NULL) as clicked, + COUNT(*) as total + FROM email_events + WHERE sent_at IS NOT NULL` + ); + const avgClickRate = clickResult.rows[0]?.total > 0 + ? (parseInt(clickResult.rows[0].clicked) / parseInt(clickResult.rows[0].total)) * 100 + : 0; + + // Get campaign count + const campaignResult = await pool.query( + `SELECT COUNT(*) as count FROM email_campaigns` + ); + const totalCampaigns = parseInt(campaignResult.rows[0]?.count || '0'); + + res.json({ + total_sent: totalSent, + avg_open_rate: avgOpenRate, + avg_click_rate: avgClickRate, + total_campaigns: totalCampaigns, + }); + } catch (error) { + logger.error({ error }, 'Error fetching email stats'); + res.status(500).json({ error: 'Failed to fetch email stats' }); + } + }); + + // GET /api/admin/email/campaigns - List all campaigns + this.app.get('/api/admin/email/campaigns', requireAuth, requireAdmin, async (req, res) => { + try { + const campaigns = await emailPrefsDb.getCampaigns(); + res.json({ campaigns }); + } catch (error) { + logger.error({ error }, 'Error fetching campaigns'); + res.status(500).json({ error: 'Failed to fetch campaigns' }); + } + }); + + // GET /api/admin/email/templates - List all templates + this.app.get('/api/admin/email/templates', requireAuth, requireAdmin, async (req, res) => { + try { + const templates = await emailPrefsDb.getTemplates(); + res.json({ templates }); + } catch (error) { + logger.error({ error }, 'Error fetching templates'); + res.status(500).json({ error: 'Failed to fetch templates' }); + } + }); + + // GET /api/admin/email/recent - Recent email sends + this.app.get('/api/admin/email/recent', requireAuth, requireAdmin, async (req, res) => { + try { + const pool = getPool(); + const result = await pool.query( + `SELECT * + FROM email_events + ORDER BY created_at DESC + LIMIT 100` + ); + res.json({ emails: result.rows }); + } catch (error) { + logger.error({ error }, 'Error fetching recent emails'); + res.status(500).json({ error: 'Failed to fetch recent emails' }); + } + }); + // ============== Slack Public Endpoints (Slash Commands, Events) ============== // POST /api/slack/commands - Handle Slack slash commands diff --git a/server/src/notifications/email.ts b/server/src/notifications/email.ts new file mode 100644 index 000000000..3f870e288 --- /dev/null +++ b/server/src/notifications/email.ts @@ -0,0 +1,538 @@ +/** + * Email notification service for AgenticAdvertising.org member events + * With click tracking, event-based send recording, and preference management + */ + +import { Resend } from 'resend'; +import { createLogger } from '../logger.js'; +import { emailDb } from '../db/email-db.js'; +import { emailPrefsDb } from '../db/email-preferences-db.js'; + +const logger = createLogger('email'); + +const RESEND_API_KEY = process.env.RESEND_API_KEY; + +const resend = RESEND_API_KEY ? new Resend(RESEND_API_KEY) : null; + +if (!RESEND_API_KEY) { + logger.warn('RESEND_API_KEY not set - email notifications will be disabled'); +} + +const FROM_EMAIL = 'AgenticAdvertising.org '; +const BASE_URL = process.env.BASE_URL || 'https://agenticadvertising.org'; + +/** + * Create a tracked URL that redirects through our click tracker + */ +function trackedUrl(trackingId: string, linkName: string, destinationUrl: string): string { + const params = new URLSearchParams({ + to: destinationUrl, + ln: linkName, + }); + return `${BASE_URL}/r/${trackingId}?${params.toString()}`; +} + +/** + * Generate standard email footer HTML with optional unsubscribe links + * @param trackingId - The tracking ID for URL tracking + * @param unsubscribeToken - Token for one-click unsubscribe (null for transactional emails) + * @param category - Optional category name for specific unsubscribe text + */ +function generateFooterHtml( + trackingId: string, + unsubscribeToken: string | null, + category?: string +): string { + const websiteUrl = trackedUrl(trackingId, 'footer_website', 'https://agenticadvertising.org'); + + let unsubscribeSection = ''; + if (unsubscribeToken) { + const unsubscribeUrl = trackedUrl(trackingId, 'footer_unsubscribe', `${BASE_URL}/unsubscribe/${unsubscribeToken}`); + const preferencesUrl = trackedUrl(trackingId, 'footer_preferences', `${BASE_URL}/unsubscribe/${unsubscribeToken}`); + + unsubscribeSection = ` +

+ Unsubscribe + ${category ? ` from ${category}` : ''} | + Manage email preferences +

`; + } + + return ` +
+ +

+ AgenticAdvertising.org
+ agenticadvertising.org +

+ ${unsubscribeSection}`; +} + +/** + * Generate standard email footer text with optional unsubscribe links + */ +function generateFooterText(unsubscribeToken: string | null, category?: string): string { + let footer = `--- +AgenticAdvertising.org +https://agenticadvertising.org`; + + if (unsubscribeToken) { + footer += ` + +Unsubscribe${category ? ` from ${category}` : ''}: ${BASE_URL}/unsubscribe/${unsubscribeToken} +Manage email preferences: ${BASE_URL}/unsubscribe/${unsubscribeToken}`; + } + + return footer; +} + +/** + * Get or create an unsubscribe token for a user + */ +async function getUnsubscribeToken(workosUserId: string, email: string): Promise { + const prefs = await emailPrefsDb.getOrCreateUserPreferences({ + workos_user_id: workosUserId, + email, + }); + return prefs.unsubscribe_token; +} + +/** + * Email types for tracking + */ +export type EmailType = 'welcome_member' | 'signup_user' | 'signup_user_member' | 'signup_user_nonmember'; + +/** + * Send welcome email to new members after subscription is created + * Now with tracking! + */ +export async function sendWelcomeEmail(data: { + to: string; + organizationName: string; + productName?: string; + workosUserId?: string; + workosOrganizationId?: string; +}): Promise { + if (!resend) { + logger.debug('Resend not configured, skipping welcome email'); + return false; + } + + const emailType: EmailType = 'welcome_member'; + const subject = 'Welcome to AgenticAdvertising.org!'; + + try { + // Create tracking record first + const emailEvent = await emailDb.createEmailEvent({ + email_type: emailType, + recipient_email: data.to, + subject, + workos_user_id: data.workosUserId, + workos_organization_id: data.workosOrganizationId, + metadata: { organizationName: data.organizationName, productName: data.productName }, + }); + + const trackingId = emailEvent.tracking_id; + + // Build tracked URLs + const dashboardUrl = trackedUrl(trackingId, 'cta_dashboard', 'https://agenticadvertising.org/dashboard'); + const websiteUrl = trackedUrl(trackingId, 'footer_website', 'https://agenticadvertising.org'); + + // Welcome email is transactional - no unsubscribe link + const footerHtml = generateFooterHtml(trackingId, null); + const footerText = generateFooterText(null); + + const { data: sendData, error } = await resend.emails.send({ + from: FROM_EMAIL, + to: data.to, + subject, + html: ` + + + + + + + +
+

Welcome to AgenticAdvertising.org!

+
+ +

Hi there,

+ +

Thank you for becoming a member of AgenticAdvertising.org! We're excited to have ${data.organizationName} join us.

+ +

As a member, you now have access to:

+ +
    +
  • Member Directory - Connect with other members working on agentic advertising
  • +
  • Working Groups - Participate in shaping the future of AdCP
  • +
  • Member Profile - Showcase your organization's capabilities
  • +
+ +

To get started, visit your dashboard to set up your member profile:

+ +

+ Go to Dashboard +

+ +

If you have any questions, just reply to this email - we're happy to help.

+ +

+ Best,
+ The AgenticAdvertising.org Team +

+ ${footerHtml} + + + `.trim(), + text: ` +Welcome to AgenticAdvertising.org! + +Hi there, + +Thank you for becoming a member of AgenticAdvertising.org! We're excited to have ${data.organizationName} join us. + +As a member, you now have access to: +- Member Directory - Connect with other members working on agentic advertising +- Working Groups - Participate in shaping the future of AdCP +- Member Profile - Showcase your organization's capabilities + +To get started, visit your dashboard to set up your member profile: +https://agenticadvertising.org/dashboard + +If you have any questions, just reply to this email - we're happy to help. + +Best, +The AgenticAdvertising.org Team + +${footerText} + `.trim(), + }); + + if (error) { + logger.error({ error, to: data.to, trackingId }, 'Failed to send welcome email'); + return false; + } + + // Mark as sent with Resend's email ID + await emailDb.markEmailSent(trackingId, sendData?.id); + + logger.info({ to: data.to, organization: data.organizationName, trackingId }, 'Welcome email sent'); + return true; + } catch (error) { + logger.error({ error, to: data.to }, 'Error sending welcome email'); + return false; + } +} + +/** + * Check if we've already sent a signup email to this user + */ +export async function hasSignupEmailBeenSent(workosUserId: string): Promise { + // Check for any variant of signup email + const memberSent = await emailDb.hasEmailBeenSent({ + email_type: 'signup_user_member', + workos_user_id: workosUserId, + }); + const nonMemberSent = await emailDb.hasEmailBeenSent({ + email_type: 'signup_user_nonmember', + workos_user_id: workosUserId, + }); + // Also check legacy type + const legacySent = await emailDb.hasEmailBeenSent({ + email_type: 'signup_user', + workos_user_id: workosUserId, + }); + + return memberSent || nonMemberSent || legacySent; +} + +/** + * Send signup confirmation email to new users + * Content varies based on whether their organization has an active subscription + * Now with tracking and duplicate prevention! + */ +export async function sendUserSignupEmail(data: { + to: string; + firstName?: string; + organizationName?: string; + hasActiveSubscription: boolean; + workosUserId?: string; + workosOrganizationId?: string; +}): Promise { + if (!resend) { + logger.debug('Resend not configured, skipping signup email'); + return false; + } + + // Check if already sent (if we have user ID) + if (data.workosUserId) { + const alreadySent = await hasSignupEmailBeenSent(data.workosUserId); + if (alreadySent) { + logger.debug({ userId: data.workosUserId }, 'Signup email already sent to this user, skipping'); + return true; // Return true since this isn't a failure + } + } + + const greeting = data.firstName ? `Hi ${data.firstName},` : 'Hi there,'; + const emailType: EmailType = data.hasActiveSubscription ? 'signup_user_member' : 'signup_user_nonmember'; + + // Different content based on subscription status + const { subject, ctaText, ctaDestination, ctaLinkName } = data.hasActiveSubscription + ? { + subject: `Welcome to ${data.organizationName || 'your team'} on AgenticAdvertising.org`, + ctaText: 'Go to Dashboard', + ctaDestination: 'https://agenticadvertising.org/dashboard', + ctaLinkName: 'cta_dashboard', + } + : { + subject: 'Welcome to AgenticAdvertising.org', + ctaText: 'Become a Member', + ctaDestination: 'https://agenticadvertising.org/dashboard/billing', + ctaLinkName: 'cta_billing', + }; + + try { + // Create tracking record + const emailEvent = await emailDb.createEmailEvent({ + email_type: emailType, + recipient_email: data.to, + subject, + workos_user_id: data.workosUserId, + workos_organization_id: data.workosOrganizationId, + metadata: { + firstName: data.firstName, + organizationName: data.organizationName, + hasActiveSubscription: data.hasActiveSubscription, + }, + }); + + const trackingId = emailEvent.tracking_id; + + // Build tracked URLs + const ctaUrl = trackedUrl(trackingId, ctaLinkName, ctaDestination); + + // Signup email is transactional - no unsubscribe link + // Future marketing emails will include unsubscribe via: + // const unsubscribeToken = data.workosUserId ? await getUnsubscribeToken(data.workosUserId, data.to) : null; + const footerHtml = generateFooterHtml(trackingId, null); + const footerText = generateFooterText(null); + + const mainContent = data.hasActiveSubscription + ? ` +

${greeting}

+ +

You've joined ${data.organizationName || 'your organization'} on AgenticAdvertising.org. Your team is already a member!

+ +

Here's what you can do:

+ +
    +
  • View the Member Directory - Connect with other members building agentic advertising
  • +
  • Access your Dashboard - Manage your organization's profile and settings
  • +
  • Invite Teammates - Add more people from your organization
  • +
+ +

Get started by visiting your dashboard:

` + : ` +

${greeting}

+ +

Thanks for signing up for AgenticAdvertising.org${data.organizationName ? ` with ${data.organizationName}` : ''}!

+ +

You've created an account, but your organization isn't a member yet. Membership gives you access to:

+ +
    +
  • Member Directory - Connect with companies building agentic advertising
  • +
  • Working Groups - Participate in shaping the future of AdCP
  • +
  • Member Profile - Showcase your organization's capabilities
  • +
+ +

Ready to become a member?

`; + + const { data: sendData, error } = await resend.emails.send({ + from: FROM_EMAIL, + to: data.to, + subject, + html: ` + + + + + + + +
+

Welcome!

+
+ + ${mainContent} + +

+ ${ctaText} +

+ +

If you have any questions, just reply to this email - we're happy to help.

+ +

+ Best,
+ The AgenticAdvertising.org Team +

+ ${footerHtml} + + + `.trim(), + text: data.hasActiveSubscription + ? `Welcome! + +${data.firstName ? `Hi ${data.firstName},` : 'Hi there,'} + +You've joined ${data.organizationName || 'your organization'} on AgenticAdvertising.org. Your team is already a member! + +Here's what you can do: +- View the Member Directory - Connect with other members building agentic advertising +- Access your Dashboard - Manage your organization's profile and settings +- Invite Teammates - Add more people from your organization + +Get started by visiting your dashboard: +https://agenticadvertising.org/dashboard + +If you have any questions, just reply to this email - we're happy to help. + +Best, +The AgenticAdvertising.org Team + +${footerText}` + : `Welcome! + +${data.firstName ? `Hi ${data.firstName},` : 'Hi there,'} + +Thanks for signing up for AgenticAdvertising.org${data.organizationName ? ` with ${data.organizationName}` : ''}! + +You've created an account, but your organization isn't a member yet. Membership gives you access to: +- Member Directory - Connect with companies building agentic advertising +- Working Groups - Participate in shaping the future of AdCP +- Member Profile - Showcase your organization's capabilities + +Ready to become a member? +https://agenticadvertising.org/dashboard/billing + +If you have any questions, just reply to this email - we're happy to help. + +Best, +The AgenticAdvertising.org Team + +${footerText}`, + }); + + if (error) { + logger.error({ error, to: data.to, trackingId }, 'Failed to send signup email'); + return false; + } + + // Mark as sent + await emailDb.markEmailSent(trackingId, sendData?.id); + + logger.info( + { to: data.to, hasActiveSubscription: data.hasActiveSubscription, trackingId }, + 'User signup email sent' + ); + return true; + } catch (error) { + logger.error({ error, to: data.to }, 'Error sending signup email'); + return false; + } +} + +/** + * Send a marketing/campaign email with unsubscribe capability + * This is used for newsletters, announcements, etc. + */ +export async function sendMarketingEmail(data: { + to: string; + subject: string; + htmlContent: string; + textContent: string; + category: string; + workosUserId: string; + workosOrganizationId?: string; + campaignId?: string; +}): Promise { + if (!resend) { + logger.debug('Resend not configured, skipping marketing email'); + return false; + } + + // Check if user wants to receive this category + const shouldSend = await emailPrefsDb.shouldSendEmail({ + workos_user_id: data.workosUserId, + category_id: data.category, + }); + + if (!shouldSend) { + logger.debug({ userId: data.workosUserId, category: data.category }, 'User opted out of category, skipping'); + return true; // Not a failure, just respecting preferences + } + + try { + // Get unsubscribe token + const unsubscribeToken = await getUnsubscribeToken(data.workosUserId, data.to); + + // Create tracking record + const emailEvent = await emailDb.createEmailEvent({ + email_type: data.category, + recipient_email: data.to, + subject: data.subject, + workos_user_id: data.workosUserId, + workos_organization_id: data.workosOrganizationId, + metadata: { campaignId: data.campaignId }, + }); + + const trackingId = emailEvent.tracking_id; + + // Generate footer with unsubscribe link + const footerHtml = generateFooterHtml(trackingId, unsubscribeToken, data.category); + const footerText = generateFooterText(unsubscribeToken, data.category); + + const { data: sendData, error } = await resend.emails.send({ + from: FROM_EMAIL, + to: data.to, + subject: data.subject, + headers: { + 'List-Unsubscribe': `<${BASE_URL}/unsubscribe/${unsubscribeToken}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + html: ` + + + + + + + + ${data.htmlContent} + ${footerHtml} + + + `.trim(), + text: `${data.textContent} + +${footerText}`, + }); + + if (error) { + logger.error({ error, to: data.to, trackingId }, 'Failed to send marketing email'); + return false; + } + + await emailDb.markEmailSent(trackingId, sendData?.id); + + logger.info({ to: data.to, category: data.category, trackingId }, 'Marketing email sent'); + return true; + } catch (error) { + logger.error({ error, to: data.to }, 'Error sending marketing email'); + return false; + } +} + +// Re-export for use in routes +export { emailDb, emailPrefsDb, getUnsubscribeToken }; diff --git a/server/tests/unit/email-notification.test.ts b/server/tests/unit/email-notification.test.ts new file mode 100644 index 000000000..7e3f0a28f --- /dev/null +++ b/server/tests/unit/email-notification.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Create mock send function we can inspect +const mockSend = vi.fn(); + +// Mock resend before importing the module - needs to be a proper class +vi.mock('resend', () => { + return { + Resend: class MockResend { + emails = { + send: mockSend, + }; + }, + }; +}); + +// Mock the database +const mockCreateEmailEvent = vi.fn(); +const mockMarkEmailSent = vi.fn(); +const mockHasEmailBeenSent = vi.fn(); + +vi.mock('../../src/db/email-db.js', () => { + return { + emailDb: { + createEmailEvent: mockCreateEmailEvent, + markEmailSent: mockMarkEmailSent, + hasEmailBeenSent: mockHasEmailBeenSent, + }, + EmailDatabase: class MockEmailDatabase {}, + }; +}); + +// Mock email preferences database +const mockGetOrCreateUserPreferences = vi.fn(); +const mockShouldSendEmail = vi.fn(); + +vi.mock('../../src/db/email-preferences-db.js', () => { + return { + emailPrefsDb: { + getOrCreateUserPreferences: mockGetOrCreateUserPreferences, + shouldSendEmail: mockShouldSendEmail, + }, + EmailPreferencesDatabase: class MockEmailPreferencesDatabase {}, + }; +}); + +describe('Email Notifications', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + mockSend.mockReset(); + mockCreateEmailEvent.mockReset(); + mockMarkEmailSent.mockReset(); + mockHasEmailBeenSent.mockReset(); + mockGetOrCreateUserPreferences.mockReset(); + mockShouldSendEmail.mockReset(); + + mockSend.mockResolvedValue({ data: { id: 'test-email-id' }, error: null }); + mockCreateEmailEvent.mockResolvedValue({ tracking_id: 'test-tracking-id' }); + mockMarkEmailSent.mockResolvedValue(undefined); + mockHasEmailBeenSent.mockResolvedValue(false); + mockGetOrCreateUserPreferences.mockResolvedValue({ unsubscribe_token: 'test-unsub-token' }); + mockShouldSendEmail.mockResolvedValue(true); + + process.env = { ...originalEnv, RESEND_API_KEY: 'test_api_key' }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('sendWelcomeEmail', () => { + it('should send welcome email with correct parameters', async () => { + const { sendWelcomeEmail } = await import('../../src/notifications/email.js'); + + const result = await sendWelcomeEmail({ + to: 'test@example.com', + organizationName: 'Test Org', + productName: 'Professional Plan', + }); + + expect(result).toBe(true); + expect(mockCreateEmailEvent).toHaveBeenCalledWith( + expect.objectContaining({ + email_type: 'welcome_member', + recipient_email: 'test@example.com', + }) + ); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + subject: 'Welcome to AgenticAdvertising.org!', + from: expect.stringContaining('hello@updates.agenticadvertising.org'), + }) + ); + expect(mockMarkEmailSent).toHaveBeenCalledWith('test-tracking-id', 'test-email-id'); + }); + + it('should include organization name in email body', async () => { + const { sendWelcomeEmail } = await import('../../src/notifications/email.js'); + + await sendWelcomeEmail({ + to: 'test@example.com', + organizationName: 'Acme Corp', + }); + + const sendCall = mockSend.mock.calls[0]?.[0]; + expect(sendCall?.html).toContain('Acme Corp'); + expect(sendCall?.text).toContain('Acme Corp'); + }); + + it('should include tracked URLs in email', async () => { + const { sendWelcomeEmail } = await import('../../src/notifications/email.js'); + + await sendWelcomeEmail({ + to: 'test@example.com', + organizationName: 'Test Org', + }); + + const sendCall = mockSend.mock.calls[0]?.[0]; + // Tracked URLs contain the tracking ID and redirect info + expect(sendCall?.html).toContain('/r/test-tracking-id'); + expect(sendCall?.html).toContain('cta_dashboard'); + }); + + it('should return false when Resend API key is not configured', async () => { + // Reset and import without API key + vi.resetModules(); + process.env = { ...originalEnv }; + delete process.env.RESEND_API_KEY; + + const { sendWelcomeEmail } = await import('../../src/notifications/email.js'); + + const result = await sendWelcomeEmail({ + to: 'test@example.com', + organizationName: 'Test Org', + }); + + expect(result).toBe(false); + }); + + it('should return false when send fails with error', async () => { + mockSend.mockResolvedValue({ + data: null, + error: { message: 'Invalid API key' }, + }); + + const { sendWelcomeEmail } = await import('../../src/notifications/email.js'); + + const result = await sendWelcomeEmail({ + to: 'test@example.com', + organizationName: 'Test Org', + }); + + expect(result).toBe(false); + }); + + it('should return false when send throws exception', async () => { + mockSend.mockRejectedValue(new Error('Network error')); + + const { sendWelcomeEmail } = await import('../../src/notifications/email.js'); + + const result = await sendWelcomeEmail({ + to: 'test@example.com', + organizationName: 'Test Org', + }); + + expect(result).toBe(false); + }); + }); + + describe('sendUserSignupEmail', () => { + it('should send email with member content for users with active subscription', async () => { + const { sendUserSignupEmail } = await import('../../src/notifications/email.js'); + + const result = await sendUserSignupEmail({ + to: 'test@example.com', + firstName: 'John', + organizationName: 'Acme Corp', + hasActiveSubscription: true, + }); + + expect(result).toBe(true); + const sendCall = mockSend.mock.calls[0]?.[0]; + expect(sendCall?.subject).toContain('Acme Corp'); + expect(sendCall?.subject).toContain('AgenticAdvertising.org'); + expect(sendCall?.html).toContain('Hi John,'); + expect(sendCall?.html).toContain('already a member!'); + expect(sendCall?.html).toContain('Invite Teammates'); + }); + + it('should send email with non-member content for users without subscription', async () => { + const { sendUserSignupEmail } = await import('../../src/notifications/email.js'); + + const result = await sendUserSignupEmail({ + to: 'test@example.com', + firstName: 'Jane', + organizationName: 'Startup Inc', + hasActiveSubscription: false, + }); + + expect(result).toBe(true); + const sendCall = mockSend.mock.calls[0]?.[0]; + expect(sendCall?.subject).toBe('Welcome to AgenticAdvertising.org'); + expect(sendCall?.html).toContain('Hi Jane,'); + expect(sendCall?.html).toContain("isn't a member yet"); + expect(sendCall?.html).toContain('Become a Member'); + }); + + it('should handle missing firstName gracefully', async () => { + const { sendUserSignupEmail } = await import('../../src/notifications/email.js'); + + const result = await sendUserSignupEmail({ + to: 'test@example.com', + hasActiveSubscription: false, + }); + + expect(result).toBe(true); + const sendCall = mockSend.mock.calls[0]?.[0]; + expect(sendCall?.html).toContain('Hi there,'); + }); + + it('should skip sending if email already sent to user', async () => { + mockHasEmailBeenSent.mockResolvedValue(true); + + const { sendUserSignupEmail } = await import('../../src/notifications/email.js'); + + const result = await sendUserSignupEmail({ + to: 'test@example.com', + hasActiveSubscription: true, + workosUserId: 'user_123', + }); + + // Should return true (success) but not actually send + expect(result).toBe(true); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('should track email type based on subscription status', async () => { + const { sendUserSignupEmail } = await import('../../src/notifications/email.js'); + + // Member email + await sendUserSignupEmail({ + to: 'member@example.com', + hasActiveSubscription: true, + }); + + expect(mockCreateEmailEvent).toHaveBeenCalledWith( + expect.objectContaining({ + email_type: 'signup_user_member', + }) + ); + + // Reset mocks + mockCreateEmailEvent.mockClear(); + mockSend.mockClear(); + vi.resetModules(); + + // Re-import to get fresh module + const { sendUserSignupEmail: sendEmail2 } = await import('../../src/notifications/email.js'); + + // Non-member email + await sendEmail2({ + to: 'nonmember@example.com', + hasActiveSubscription: false, + }); + + expect(mockCreateEmailEvent).toHaveBeenCalledWith( + expect.objectContaining({ + email_type: 'signup_user_nonmember', + }) + ); + }); + + it('should return false when Resend API key is not configured', async () => { + vi.resetModules(); + process.env = { ...originalEnv }; + delete process.env.RESEND_API_KEY; + + const { sendUserSignupEmail } = await import('../../src/notifications/email.js'); + + const result = await sendUserSignupEmail({ + to: 'test@example.com', + hasActiveSubscription: true, + }); + + expect(result).toBe(false); + }); + + it('should return false when send fails', async () => { + mockSend.mockResolvedValue({ + data: null, + error: { message: 'Send failed' }, + }); + + const { sendUserSignupEmail } = await import('../../src/notifications/email.js'); + + const result = await sendUserSignupEmail({ + to: 'test@example.com', + hasActiveSubscription: true, + }); + + expect(result).toBe(false); + }); + }); + + describe('hasSignupEmailBeenSent', () => { + it('should return true if member signup email was sent', async () => { + mockHasEmailBeenSent.mockImplementation(({ email_type }) => { + return Promise.resolve(email_type === 'signup_user_member'); + }); + + const { hasSignupEmailBeenSent } = await import('../../src/notifications/email.js'); + + const result = await hasSignupEmailBeenSent('user_123'); + expect(result).toBe(true); + }); + + it('should return true if non-member signup email was sent', async () => { + mockHasEmailBeenSent.mockImplementation(({ email_type }) => { + return Promise.resolve(email_type === 'signup_user_nonmember'); + }); + + const { hasSignupEmailBeenSent } = await import('../../src/notifications/email.js'); + + const result = await hasSignupEmailBeenSent('user_123'); + expect(result).toBe(true); + }); + + it('should return false if no signup email was sent', async () => { + mockHasEmailBeenSent.mockResolvedValue(false); + + const { hasSignupEmailBeenSent } = await import('../../src/notifications/email.js'); + + const result = await hasSignupEmailBeenSent('user_123'); + expect(result).toBe(false); + }); + }); +});