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 cfe12196e..6d799a009 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 7c72b5db8..5ea96760c 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 9cf2f0113..e30fca138 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 a3295e5bf..b4e930541 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 80213afb7..81ac871a6 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); @@ -318,6 +320,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)) { @@ -348,6 +679,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 @@ -1104,6 +1469,15 @@ export class HTTPServer { 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')); + // Record to org_activities for prospect tracking const amountStr = amount ? `$${(amount / 100).toFixed(2)}` : ''; const intervalStr = interval ? `/${interval}` : ''; @@ -3827,6 +4201,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(); } @@ -4347,7 +4728,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'); @@ -4409,6 +4795,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; @@ -9927,6 +10342,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); + }); + }); +});