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:
2026-06-02 17:28:48 +02:00
parent 0416dc8d39
commit 5ea0c75fff
3 changed files with 169 additions and 12 deletions

View 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] : [];
}

View File

@@ -29,7 +29,7 @@ import {
import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert'; import { contactFormSalesAlert } from '@/lib/email/templates/contact-form-alert';
import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation'; import { contactFormClientConfirmation } from '@/lib/email/templates/contact-form-client-confirmation';
import { getPortBrandingConfig, getPortEmailConfig } from '@/lib/services/port-config'; 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 { extractInquiryFields } from '@/lib/services/website-intake-fields';
import { createNotification } from '@/lib/services/notifications.service'; import { createNotification } from '@/lib/services/notifications.service';
import { findUsersWithInterestsPermission } from '@/lib/services/inquiry-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 * Resolve staff-alert recipients for a port. Delegates to the shared resolver,
* setting, fall back to the single `inquiry_contact_email`. Returns [] when * which expands the structured {emails,userIds,roleIds,everyone} config (or a
* nothing is configured (caller then skips the alert). * 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[]> { async function resolveRecipients(portId: string, primaryKey: string): Promise<string[]> {
const primary = await getSetting(primaryKey, portId); return resolveNotificationRecipients(portId, primaryKey);
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] : [];
} }
export interface WebsiteSubmissionEmailInput { export interface WebsiteSubmissionEmailInput {

View 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);
});
});