feat(intake): structured notification-recipient resolver (emails/users/roles/everyone)
parseRecipientConfig (backward-compat: legacy string[] -> emails) + resolveRecipientEmails (expands userIds/roleIds/everyone-with-interests.view into deduped addresses) + resolveNotificationRecipients (load setting, fallback to inquiry_contact_email). Wired into the website-intake email path so berth/contact/residential staff alerts honor the richer recipients. TDD: parseRecipientConfig unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
116
src/lib/services/notification-recipients.ts
Normal file
116
src/lib/services/notification-recipients.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<string[]> {
|
||||
const userIdSet = new Set<string>(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<string>();
|
||||
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<string[]> {
|
||||
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] : [];
|
||||
}
|
||||
@@ -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<boole
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve staff-alert recipients for a port: prefer the kind-specific list
|
||||
* setting, fall back to the single `inquiry_contact_email`. Returns [] when
|
||||
* nothing is configured (caller then skips the alert).
|
||||
* Resolve staff-alert recipients for a port. Delegates to the shared resolver,
|
||||
* which expands the structured {emails,userIds,roleIds,everyone} config (or a
|
||||
* legacy email array) into concrete addresses, falling back to
|
||||
* `inquiry_contact_email`. Returns [] when nothing is configured.
|
||||
*/
|
||||
async function resolveRecipients(portId: string, primaryKey: string): Promise<string[]> {
|
||||
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 {
|
||||
|
||||
47
tests/unit/notification-recipients.test.ts
Normal file
47
tests/unit/notification-recipients.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user