feat(client-groups): CM-1 data layer — groups entity, membership, service, Mailchimp scaffold
- client_groups + client_group_members tables (migration 0094, port_id cascade) - client_groups permission resource (view/manage) in catalog + role backfill - service: CRUD + wipe-and-rewrite membership + member email resolution - mailchimp.service scaffold: config reader + inert one-way sync (mapping deferred until the client's MC account is wired, per CM-1 decision) - 4 integration tests (CRUD, membership, email resolution, port-scope guard) Backend only — API routes + UI to follow. tsc clean, 1635 vitest pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
67
src/lib/services/mailchimp.service.ts
Normal file
67
src/lib/services/mailchimp.service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* CM-1: Mailchimp Marketing API integration (one-way push, CRM → Mailchimp).
|
||||
*
|
||||
* SCOPE NOTE: per the locked CM-1 decision, the exact group → tag/segment
|
||||
* mapping is finalised only once we have the client's actual Mailchimp account.
|
||||
* So this module ships the config plumbing + an inert sync that no-ops until
|
||||
* (a) an admin stores an API key + audience ID and (b) the mapping is wired.
|
||||
* The members viewer + copy-emails features do NOT depend on Mailchimp.
|
||||
*
|
||||
* Settings keys (per-port, in system_settings):
|
||||
* - `mailchimp_api_key` (AES-encrypted at rest, like SMTP/IMAP creds)
|
||||
* - `mailchimp_audience_id` (the single audience all groups map into)
|
||||
*/
|
||||
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getSetting } from '@/lib/services/settings.service';
|
||||
import { decrypt } from '@/lib/utils/encryption';
|
||||
|
||||
export interface MailchimpConfig {
|
||||
apiKey: string;
|
||||
audienceId: string;
|
||||
/** Datacenter prefix derived from the key suffix (e.g. `us21`). */
|
||||
serverPrefix: string;
|
||||
}
|
||||
|
||||
/** Resolve + decrypt the per-port Mailchimp config, or null when unset. */
|
||||
export async function getMailchimpConfig(portId: string): Promise<MailchimpConfig | null> {
|
||||
const keyRow = await getSetting('mailchimp_api_key', portId);
|
||||
const audRow = await getSetting('mailchimp_audience_id', portId);
|
||||
const encKey = typeof keyRow?.value === 'string' ? keyRow.value : null;
|
||||
const audienceId = typeof audRow?.value === 'string' ? audRow.value : null;
|
||||
if (!encKey || !audienceId) return null;
|
||||
let apiKey: string;
|
||||
try {
|
||||
apiKey = decrypt(encKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Mailchimp keys are `<hex>-<dc>`; the datacenter is the API host prefix.
|
||||
const serverPrefix = apiKey.split('-')[1] ?? '';
|
||||
if (!serverPrefix) return null;
|
||||
return { apiKey, audienceId, serverPrefix };
|
||||
}
|
||||
|
||||
export async function isMailchimpConfigured(portId: string): Promise<boolean> {
|
||||
return (await getMailchimpConfig(portId)) !== null;
|
||||
}
|
||||
|
||||
export type MailchimpSyncResult = { skipped: string } | { synced: true; count: number };
|
||||
|
||||
/**
|
||||
* Push a group's members to Mailchimp as a tag/segment on the port's audience.
|
||||
* Inert until configured AND the mapping is confirmed (see SCOPE NOTE).
|
||||
*/
|
||||
export async function syncGroupToMailchimp(
|
||||
groupId: string,
|
||||
portId: string,
|
||||
): Promise<MailchimpSyncResult> {
|
||||
const config = await getMailchimpConfig(portId);
|
||||
if (!config) return { skipped: 'not-configured' };
|
||||
// TODO(CM-1): mapping pending the client's Mailchimp account. Once confirmed,
|
||||
// upsert each member via
|
||||
// PUT https://{serverPrefix}.api.mailchimp.com/3.0/lists/{audienceId}/members/{md5(lowercased-email)}
|
||||
// then apply the group's tag. Only push subscribed/opted-in contacts (GDPR).
|
||||
logger.info({ groupId, portId }, 'Mailchimp sync requested (mapping pending client account)');
|
||||
return { skipped: 'mapping-pending' };
|
||||
}
|
||||
Reference in New Issue
Block a user