diff --git a/src/lib/services/notification-recipients.ts b/src/lib/services/notification-recipients.ts new file mode 100644 index 00000000..f9a54527 --- /dev/null +++ b/src/lib/services/notification-recipients.ts @@ -0,0 +1,116 @@ +/** + * Notification-recipient resolution for inquiry alerts. + * + * A recipient setting (e.g. `inquiry_notification_recipients`) can be either: + * - the LEGACY shape: a bare `string[]` of email addresses, or + * - the structured shape: `{ emails, userIds, roleIds, everyone }`. + * + * `parseRecipientConfig` normalizes both (legacy array -> explicit emails), so + * no data migration is needed and existing configs keep working. + * `resolveRecipientEmails` expands users / roles / "everyone-with-access" into + * concrete email addresses, merged with any explicit emails and deduped. + */ + +import { and, eq, inArray } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { user, userPortRoles } from '@/lib/db/schema/users'; +import { getSetting } from '@/lib/services/settings.service'; +import { findUsersWithInterestsPermission } from '@/lib/services/inquiry-notifications.service'; + +export interface RecipientConfig { + /** Explicit email addresses. */ + emails: string[]; + /** CRM user ids whose account email should receive the alert. */ + userIds: string[]; + /** Role ids; every user holding the role on the port is included. */ + roleIds: string[]; + /** When true, everyone on the port with the inquiry-view permission. */ + everyone: boolean; +} + +function strArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((v): v is string => typeof v === 'string' && v.trim().length > 0) + : []; +} + +/** Normalize a stored recipient setting value into a RecipientConfig. */ +export function parseRecipientConfig(value: unknown): RecipientConfig { + // Legacy shape: a bare string[] of email addresses. + if (Array.isArray(value)) { + return { emails: strArray(value), userIds: [], roleIds: [], everyone: false }; + } + if (value && typeof value === 'object') { + const o = value as Record; + return { + emails: strArray(o.emails), + userIds: strArray(o.userIds), + roleIds: strArray(o.roleIds), + everyone: o.everyone === true, + }; + } + return { emails: [], userIds: [], roleIds: [], everyone: false }; +} + +/** Expand a RecipientConfig into a deduped list of email addresses. */ +export async function resolveRecipientEmails( + portId: string, + config: RecipientConfig, +): Promise { + const userIdSet = new Set(config.userIds); + + if (config.everyone) { + for (const id of await findUsersWithInterestsPermission(portId)) userIdSet.add(id); + } + + if (config.roleIds.length > 0) { + const rows = await db + .select({ userId: userPortRoles.userId }) + .from(userPortRoles) + .where(and(eq(userPortRoles.portId, portId), inArray(userPortRoles.roleId, config.roleIds))); + for (const r of rows) userIdSet.add(r.userId); + } + + const collected: string[] = [...config.emails]; + if (userIdSet.size > 0) { + const rows = await db + .select({ email: user.email }) + .from(user) + .where(inArray(user.id, Array.from(userIdSet))); + for (const r of rows) if (r.email) collected.push(r.email); + } + + // Case-insensitive dedupe, preserving the first-seen form. + const seen = new Set(); + const result: string[] = []; + for (const raw of collected) { + const email = raw.trim(); + const key = email.toLowerCase(); + if (email && !seen.has(key)) { + seen.add(key); + result.push(email); + } + } + return result; +} + +/** + * Load + resolve a recipient setting to concrete email addresses. Falls back to + * `fallbackKey` (a single-string setting, default `inquiry_contact_email`) when + * the primary resolves to nothing. Pass an empty `fallbackKey` to disable the + * fallback. + */ +export async function resolveNotificationRecipients( + portId: string, + primaryKey: string, + fallbackKey = 'inquiry_contact_email', +): Promise { + const primary = await getSetting(primaryKey, portId); + const resolved = await resolveRecipientEmails(portId, parseRecipientConfig(primary?.value)); + if (resolved.length > 0) return resolved; + + if (!fallbackKey) return []; + const fallback = await getSetting(fallbackKey, portId); + return typeof fallback?.value === 'string' && fallback.value.length > 0 ? [fallback.value] : []; +} diff --git a/src/lib/services/website-intake-email.service.ts b/src/lib/services/website-intake-email.service.ts index 50b97ab0..eb35fc23 100644 --- a/src/lib/services/website-intake-email.service.ts +++ b/src/lib/services/website-intake-email.service.ts @@ -29,7 +29,7 @@ import { import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert'; import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation'; import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config'; -import { getSetting } from '@/lib/services/settings.service'; +import { resolveNotificationRecipients } from '@/lib/services/notification-recipients'; import { extractInquiryFields } from '@/lib/services/website-intake-fields'; import { createNotification } from '@/lib/services/notifications.service'; import { findUsersWithInterestsPermission } from '@/lib/services/inquiry-notifications.service'; @@ -54,19 +54,13 @@ export async function isWebsiteIntakeEmailEnabled(portId: string): Promise { - const primary = await getSetting(primaryKey, portId); - const list = Array.isArray(primary?.value) - ? primary.value.filter((v): v is string => typeof v === 'string') - : []; - if (list.length > 0) return list; - - const fallback = await getSetting('inquiry_contact_email', portId); - return typeof fallback?.value === 'string' && fallback.value.length > 0 ? [fallback.value] : []; + return resolveNotificationRecipients(portId, primaryKey); } export interface WebsiteSubmissionEmailInput { diff --git a/tests/unit/notification-recipients.test.ts b/tests/unit/notification-recipients.test.ts new file mode 100644 index 00000000..d48349a4 --- /dev/null +++ b/tests/unit/notification-recipients.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; + +import { parseRecipientConfig } from '@/lib/services/notification-recipients'; + +describe('parseRecipientConfig', () => { + it('treats a legacy string[] as explicit emails (backward-compat)', () => { + expect(parseRecipientConfig(['a@x.com', 'b@x.com'])).toEqual({ + emails: ['a@x.com', 'b@x.com'], + userIds: [], + roleIds: [], + everyone: false, + }); + }); + + it('reads the structured object shape', () => { + expect( + parseRecipientConfig({ + emails: ['a@x.com'], + userIds: ['u1', 'u2'], + roleIds: ['r1'], + everyone: true, + }), + ).toEqual({ + emails: ['a@x.com'], + userIds: ['u1', 'u2'], + roleIds: ['r1'], + everyone: true, + }); + }); + + it('filters non-string / empty entries and coerces everyone defensively', () => { + expect( + parseRecipientConfig({ + emails: ['a@x.com', 2, '', null], + userIds: 'nope', + everyone: 'yes', + }), + ).toEqual({ emails: ['a@x.com'], userIds: [], roleIds: [], everyone: false }); + }); + + it('returns empty for null / garbage', () => { + const empty = { emails: [], userIds: [], roleIds: [], everyone: false }; + expect(parseRecipientConfig(null)).toEqual(empty); + expect(parseRecipientConfig('nope')).toEqual(empty); + expect(parseRecipientConfig(42)).toEqual(empty); + }); +});