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