- 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>
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
/**
|
|
* Client groups (CM-1) - first-class mailing/segment groups for clients.
|
|
*
|
|
* A `client_groups` row is a named, per-port group (e.g. a mailing list).
|
|
* `client_group_members` is the M2M join to `clients`. Membership carries its
|
|
* own `port_id` for defense-in-depth tenant isolation (same doctrine as the
|
|
* document-folders aggregated projection - port_id at every join).
|
|
*
|
|
* Optional Mailchimp mapping lives on the group row: `mailchimpTag` is the
|
|
* tag/segment name pushed to the port's single Mailchimp audience. Null until
|
|
* an admin wires Mailchimp up (the integration is inert without creds).
|
|
*/
|
|
|
|
import { sql } from 'drizzle-orm';
|
|
import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
|
|
|
import { clients } from './clients';
|
|
import { ports } from './ports';
|
|
|
|
export const clientGroups = pgTable(
|
|
'client_groups',
|
|
{
|
|
id: text('id')
|
|
.primaryKey()
|
|
.$defaultFn(() => crypto.randomUUID()),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
|
name: text('name').notNull(),
|
|
description: text('description'),
|
|
/** Chip color in the CRM UI. */
|
|
color: text('color').notNull().default('#6B7280'),
|
|
/** CM-1 Mailchimp: the tag/segment name this group maps to in the port's
|
|
* single Mailchimp audience. Null = not synced. */
|
|
mailchimpTag: text('mailchimp_tag'),
|
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
index('idx_client_groups_port').on(table.portId),
|
|
// Per-port, case-insensitive name uniqueness among non-archived groups.
|
|
uniqueIndex('idx_client_groups_port_name')
|
|
.on(table.portId, sql`lower(${table.name})`)
|
|
.where(sql`${table.archivedAt} IS NULL`),
|
|
],
|
|
);
|
|
|
|
export const clientGroupMembers = pgTable(
|
|
'client_group_members',
|
|
{
|
|
groupId: text('group_id')
|
|
.notNull()
|
|
.references(() => clientGroups.id, { onDelete: 'cascade' }),
|
|
clientId: text('client_id')
|
|
.notNull()
|
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
|
portId: text('port_id')
|
|
.notNull()
|
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
},
|
|
(table) => [
|
|
primaryKey({ columns: [table.groupId, table.clientId] }),
|
|
index('idx_cgm_client').on(table.clientId),
|
|
index('idx_cgm_port').on(table.portId),
|
|
],
|
|
);
|
|
|
|
export type ClientGroup = typeof clientGroups.$inferSelect;
|
|
export type NewClientGroup = typeof clientGroups.$inferInsert;
|
|
export type ClientGroupMember = typeof clientGroupMembers.$inferSelect;
|
|
export type NewClientGroupMember = typeof clientGroupMembers.$inferInsert;
|