From 661187cc79ef1802cd6ac9178e926b73cf2329eb Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 18 Jun 2026 22:28:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(client-groups):=20CM-1=20data=20layer=20?= =?UTF-8?q?=E2=80=94=20groups=20entity,=20membership,=20service,=20Mailchi?= =?UTF-8?q?mp=20scaffold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/lib/auth/permissions.ts | 1 + src/lib/db/migrations/0094_client_groups.sql | 52 +++++ src/lib/db/schema/client-groups.ts | 73 +++++++ src/lib/db/schema/index.ts | 3 + src/lib/db/schema/users.ts | 4 + src/lib/db/seed-permissions.ts | 24 +++ src/lib/services/client-groups.service.ts | 205 +++++++++++++++++++ src/lib/services/mailchimp.service.ts | 67 ++++++ src/lib/validators/client-groups.ts | 25 +++ tests/helpers/factories.ts | 4 + tests/integration/client-groups.test.ts | 81 ++++++++ 11 files changed, 539 insertions(+) create mode 100644 src/lib/db/migrations/0094_client_groups.sql create mode 100644 src/lib/db/schema/client-groups.ts create mode 100644 src/lib/services/client-groups.service.ts create mode 100644 src/lib/services/mailchimp.service.ts create mode 100644 src/lib/validators/client-groups.ts create mode 100644 tests/integration/client-groups.test.ts diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts index 1e042d98..e2ef5d57 100644 --- a/src/lib/auth/permissions.ts +++ b/src/lib/auth/permissions.ts @@ -70,6 +70,7 @@ export const PERMISSION_CATALOG = { residential_clients: ['view', 'create', 'edit', 'delete'], residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'], inquiries: ['view', 'manage'], + client_groups: ['view', 'manage'], } as const satisfies { [R in PermissionResource]: ReadonlyArray & string>; }; diff --git a/src/lib/db/migrations/0094_client_groups.sql b/src/lib/db/migrations/0094_client_groups.sql new file mode 100644 index 00000000..9af63f3c --- /dev/null +++ b/src/lib/db/migrations/0094_client_groups.sql @@ -0,0 +1,52 @@ +-- 0094_client_groups.sql +-- ---------------------------------------------------------------------------- +-- CM-1: first-class client groups (mailing/segment lists) + the membership +-- join, plus the new `client_groups` permission resource (view/manage). +-- +-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + a guarded role backfill. +-- Safe to re-run. + +-- ─── 1. client_groups (per-port named group) ──────────────────────────────── +CREATE TABLE IF NOT EXISTS client_groups ( + id text PRIMARY KEY DEFAULT gen_random_uuid()::text, + port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + color text NOT NULL DEFAULT '#6B7280', + mailchimp_tag text, + archived_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_client_groups_port ON client_groups(port_id); +-- Per-port, case-insensitive name uniqueness among non-archived groups. +CREATE UNIQUE INDEX IF NOT EXISTS idx_client_groups_port_name + ON client_groups(port_id, lower(name)) + WHERE archived_at IS NULL; + +-- ─── 2. client_group_members (M2M join; carries port_id for tenant isolation) ─ +CREATE TABLE IF NOT EXISTS client_group_members ( + group_id text NOT NULL REFERENCES client_groups(id) ON DELETE CASCADE, + client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (group_id, client_id) +); + +CREATE INDEX IF NOT EXISTS idx_cgm_client ON client_group_members(client_id); +CREATE INDEX IF NOT EXISTS idx_cgm_port ON client_group_members(port_id); + +-- ─── 3. `client_groups` permission resource (view/manage) ──────────────────── +-- New-key only + idempotent via the `? 'client_groups'` guard. Defaults to the +-- role's clients access (view ⟵ clients.view, manage ⟵ clients.create) so the +-- right roles light up without a manual per-role edit. +UPDATE roles +SET permissions = permissions || jsonb_build_object( + 'client_groups', jsonb_build_object( + 'view', COALESCE((permissions->'clients'->>'view')::boolean, false), + 'manage', COALESCE((permissions->'clients'->>'create')::boolean, false) + ) +) +WHERE permissions IS NOT NULL + AND NOT (permissions ? 'client_groups'); diff --git a/src/lib/db/schema/client-groups.ts b/src/lib/db/schema/client-groups.ts new file mode 100644 index 00000000..702e56ce --- /dev/null +++ b/src/lib/db/schema/client-groups.ts @@ -0,0 +1,73 @@ +/** + * 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; diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts index 945ffbbf..eaf99063 100644 --- a/src/lib/db/schema/index.ts +++ b/src/lib/db/schema/index.ts @@ -7,6 +7,9 @@ export * from './users'; // Clients export * from './clients'; +// Client groups (CM-1 - mailing/segment groups) +export * from './client-groups'; + // Companies export * from './companies'; diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts index 10f4b4e2..7b7b0c37 100644 --- a/src/lib/db/schema/users.ts +++ b/src/lib/db/schema/users.ts @@ -166,6 +166,10 @@ export type RolePermissions = { view: boolean; manage: boolean; }; + client_groups: { + view: boolean; + manage: boolean; + }; }; /** diff --git a/src/lib/db/seed-permissions.ts b/src/lib/db/seed-permissions.ts index 5018373a..f0df1c80 100644 --- a/src/lib/db/seed-permissions.ts +++ b/src/lib/db/seed-permissions.ts @@ -92,6 +92,10 @@ export const ALL_PERMISSIONS: RolePermissions = { view: true, manage: true, }, + client_groups: { + view: true, + manage: true, + }, }; export const DIRECTOR_PERMISSIONS: RolePermissions = { @@ -175,6 +179,10 @@ export const DIRECTOR_PERMISSIONS: RolePermissions = { view: true, manage: true, }, + client_groups: { + view: true, + manage: true, + }, }; export const SALES_MANAGER_PERMISSIONS: RolePermissions = { @@ -258,6 +266,10 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = { view: true, manage: true, }, + client_groups: { + view: true, + manage: true, + }, }; export const SALES_AGENT_PERMISSIONS: RolePermissions = { @@ -341,6 +353,10 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = { view: true, manage: true, }, + client_groups: { + view: true, + manage: true, + }, }; export const VIEWER_PERMISSIONS: RolePermissions = { @@ -430,6 +446,10 @@ export const VIEWER_PERMISSIONS: RolePermissions = { view: true, manage: false, }, + client_groups: { + view: true, + manage: false, + }, }; // Residential Partner - for an outside party who handles residential @@ -522,4 +542,8 @@ export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = { view: false, manage: false, }, + client_groups: { + view: false, + manage: false, + }, }; diff --git a/src/lib/services/client-groups.service.ts b/src/lib/services/client-groups.service.ts new file mode 100644 index 00000000..c9d71265 --- /dev/null +++ b/src/lib/services/client-groups.service.ts @@ -0,0 +1,205 @@ +/** + * CM-1: client groups (mailing/segment lists) service. + * + * CRUD for `client_groups` + membership management on `client_group_members`, + * plus a member viewer that resolves each client's primary email for the + * copy-emails feature. All reads/writes are port-scoped. Membership replace is + * a wipe-and-rewrite transaction (same shape as setEntityTags). + */ + +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; + +import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit'; +import { db } from '@/lib/db'; +import { clientGroupMembers, clientGroups, clients } from '@/lib/db/schema'; +import { withTransaction } from '@/lib/db/utils'; +import { NotFoundError, ValidationError } from '@/lib/errors'; +import { syncGroupToMailchimp } from '@/lib/services/mailchimp.service'; +import type { + CreateClientGroupInput, + UpdateClientGroupInput, +} from '@/lib/validators/client-groups'; + +export interface ClientGroupWithCount { + id: string; + name: string; + description: string | null; + color: string; + mailchimpTag: string | null; + memberCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface GroupMember { + clientId: string; + fullName: string; + email: string | null; +} + +async function assertGroup(id: string, portId: string) { + const group = await db.query.clientGroups.findFirst({ + where: and(eq(clientGroups.id, id), eq(clientGroups.portId, portId)), + }); + if (!group || group.archivedAt) throw new NotFoundError('Client group not found'); + return group; +} + +export async function listClientGroups(portId: string): Promise { + const groups = await db + .select() + .from(clientGroups) + .where(and(eq(clientGroups.portId, portId), sql`${clientGroups.archivedAt} IS NULL`)) + .orderBy(desc(clientGroups.createdAt)); + + // Member counts in one grouped query (port-scoped). + const counts = await db + .select({ groupId: clientGroupMembers.groupId, n: sql`count(*)::int` }) + .from(clientGroupMembers) + .where(eq(clientGroupMembers.portId, portId)) + .groupBy(clientGroupMembers.groupId); + const countMap = new Map(counts.map((c) => [c.groupId, c.n])); + + return groups.map((g) => ({ + id: g.id, + name: g.name, + description: g.description, + color: g.color, + mailchimpTag: g.mailchimpTag, + memberCount: countMap.get(g.id) ?? 0, + createdAt: g.createdAt, + updatedAt: g.updatedAt, + })); +} + +export async function getClientGroupById(id: string, portId: string) { + return assertGroup(id, portId); +} + +export async function createClientGroup( + portId: string, + data: CreateClientGroupInput, + meta: AuditMeta, +) { + const [group] = await db + .insert(clientGroups) + .values({ + portId, + name: data.name, + description: data.description ?? null, + color: data.color ?? '#6B7280', + mailchimpTag: data.mailchimpTag ?? null, + }) + .returning(); + if (!group) throw new ValidationError('Failed to create client group'); + void createAuditLog({ + ...meta, + action: 'create', + entityType: 'client_group', + entityId: group.id, + newValue: toAuditJson(group), + }); + return group; +} + +export async function updateClientGroup( + id: string, + portId: string, + data: UpdateClientGroupInput, + meta: AuditMeta, +) { + await assertGroup(id, portId); + const [updated] = await db + .update(clientGroups) + .set({ + ...(data.name !== undefined ? { name: data.name } : {}), + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.color !== undefined ? { color: data.color } : {}), + ...(data.mailchimpTag !== undefined ? { mailchimpTag: data.mailchimpTag } : {}), + updatedAt: new Date(), + }) + .where(and(eq(clientGroups.id, id), eq(clientGroups.portId, portId))) + .returning(); + if (!updated) throw new NotFoundError('Client group not found'); + void createAuditLog({ + ...meta, + action: 'update', + entityType: 'client_group', + entityId: id, + newValue: toAuditJson(data), + }); + return updated; +} + +export async function archiveClientGroup(id: string, portId: string, meta: AuditMeta) { + await assertGroup(id, portId); + await db + .update(clientGroups) + .set({ archivedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(clientGroups.id, id), eq(clientGroups.portId, portId))); + void createAuditLog({ + ...meta, + action: 'archive', + entityType: 'client_group', + entityId: id, + }); +} + +/** Members of a group, each with their primary email (for copy-emails). */ +export async function listGroupMembers(groupId: string, portId: string): Promise { + await assertGroup(groupId, portId); + const rows = await db + .select({ + clientId: clients.id, + fullName: clients.fullName, + email: sql`( + SELECT cc.value FROM client_contacts cc + WHERE cc.client_id = ${clients.id} AND cc.channel = 'email' + ORDER BY cc.is_primary DESC + LIMIT 1 + )`, + }) + .from(clientGroupMembers) + .innerJoin(clients, eq(clientGroupMembers.clientId, clients.id)) + .where(and(eq(clientGroupMembers.groupId, groupId), eq(clientGroupMembers.portId, portId))) + .orderBy(clients.fullName); + return rows; +} + +/** Replace a group's membership with exactly `clientIds` (wipe-and-rewrite). */ +export async function setGroupMembers( + groupId: string, + portId: string, + clientIds: string[], + meta: AuditMeta, +): Promise { + await assertGroup(groupId, portId); + const unique = Array.from(new Set(clientIds)); + // Tenant-scope guard: every client must belong to this port. + if (unique.length > 0) { + const valid = await db + .select({ id: clients.id }) + .from(clients) + .where(and(inArray(clients.id, unique), eq(clients.portId, portId))); + if (valid.length !== unique.length) { + throw new ValidationError('One or more clients are not in this port'); + } + } + await withTransaction(async (tx) => { + await tx.delete(clientGroupMembers).where(eq(clientGroupMembers.groupId, groupId)); + if (unique.length > 0) { + await tx + .insert(clientGroupMembers) + .values(unique.map((clientId) => ({ groupId, clientId, portId }))); + } + }); + void createAuditLog({ + ...meta, + action: 'update', + entityType: 'client_group_members', + entityId: groupId, + newValue: toAuditJson({ clientIds: unique }), + }); + // CM-1 Mailchimp: fire-and-forget one-way push (inert until configured). + void syncGroupToMailchimp(groupId, portId).catch(() => {}); +} diff --git a/src/lib/services/mailchimp.service.ts b/src/lib/services/mailchimp.service.ts new file mode 100644 index 00000000..1d3d6895 --- /dev/null +++ b/src/lib/services/mailchimp.service.ts @@ -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 { + 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 `-`; 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 { + 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 { + 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' }; +} diff --git a/src/lib/validators/client-groups.ts b/src/lib/validators/client-groups.ts new file mode 100644 index 00000000..15ca7522 --- /dev/null +++ b/src/lib/validators/client-groups.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +/** CM-1: client groups (mailing/segment lists). */ + +export const createClientGroupSchema = z.object({ + name: z.string().trim().min(1, 'Group name is required').max(120), + description: z.string().trim().max(2000).nullish(), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, 'Color must be a hex value like #6B7280') + .optional(), + /** Mailchimp tag/segment name this group maps to. Null until wired up. */ + mailchimpTag: z.string().trim().max(200).nullish(), +}); + +export const updateClientGroupSchema = createClientGroupSchema.partial(); + +/** Wipe-and-rewrite the group's membership to exactly this set of clients. */ +export const setGroupMembersSchema = z.object({ + clientIds: z.array(z.string().min(1)).max(5000), +}); + +export type CreateClientGroupInput = z.infer; +export type UpdateClientGroupInput = z.infer; +export type SetGroupMembersInput = z.infer; diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index fd8a6310..f4f1ba48 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -385,6 +385,7 @@ export function makeFullPermissions(): RolePermissions { change_stage: true, }, inquiries: { view: true, manage: true }, + client_groups: { view: true, manage: true }, }; } @@ -474,6 +475,7 @@ export function makeViewerPermissions(): RolePermissions { change_stage: false, }, inquiries: { view: true, manage: false }, + client_groups: { view: true, manage: false }, }; } @@ -563,6 +565,7 @@ export function makeSalesAgentPermissions(): RolePermissions { change_stage: false, }, inquiries: { view: true, manage: true }, + client_groups: { view: true, manage: true }, }; } @@ -652,6 +655,7 @@ export function makeSalesManagerPermissions(): RolePermissions { change_stage: true, }, inquiries: { view: true, manage: true }, + client_groups: { view: true, manage: true }, }; } diff --git a/tests/integration/client-groups.test.ts b/tests/integration/client-groups.test.ts new file mode 100644 index 00000000..1eb5c443 --- /dev/null +++ b/tests/integration/client-groups.test.ts @@ -0,0 +1,81 @@ +/** + * CM-1: client-groups service — CRUD, wipe-and-rewrite membership, member + * email resolution (for copy-emails), and the port-scope guard. + */ +import { describe, it, expect } from 'vitest'; + +import { db } from '@/lib/db'; +import { clientContacts } from '@/lib/db/schema'; +import { + archiveClientGroup, + createClientGroup, + getClientGroupById, + listClientGroups, + listGroupMembers, + setGroupMembers, + updateClientGroup, +} from '@/lib/services/client-groups.service'; +import { makeAuditMeta, makeClient, makePort } from '../helpers/factories'; + +describe('client-groups.service (CM-1)', () => { + it('creates a group and lists it with a zero member count', async () => { + const port = await makePort(); + const meta = makeAuditMeta({ portId: port.id }); + const group = await createClientGroup(port.id, { name: 'VIP Mailing' }, meta); + expect(group.name).toBe('VIP Mailing'); + expect(group.color).toBe('#6B7280'); + + const list = await listClientGroups(port.id); + expect(list).toHaveLength(1); + expect(list[0]?.memberCount).toBe(0); + }); + + it('sets members (wipe-and-rewrite) and lists them with primary email', async () => { + const port = await makePort(); + const meta = makeAuditMeta({ portId: port.id }); + const c1 = await makeClient({ portId: port.id }); + const c2 = await makeClient({ portId: port.id }); + await db + .insert(clientContacts) + .values({ clientId: c1.id, channel: 'email', value: 'vip@example.com', isPrimary: true }); + + const group = await createClientGroup(port.id, { name: 'Newsletter' }, meta); + await setGroupMembers(group.id, port.id, [c1.id, c2.id], meta); + + const members = await listGroupMembers(group.id, port.id); + expect(members.map((m) => m.clientId).sort()).toEqual([c1.id, c2.id].sort()); + expect(members.find((m) => m.clientId === c1.id)?.email).toBe('vip@example.com'); + expect(members.find((m) => m.clientId === c2.id)?.email).toBeNull(); + + const list = await listClientGroups(port.id); + expect(list.find((g) => g.id === group.id)?.memberCount).toBe(2); + + // Wipe-and-rewrite: setting to [c2] drops c1. + await setGroupMembers(group.id, port.id, [c2.id], meta); + const after = await listGroupMembers(group.id, port.id); + expect(after.map((m) => m.clientId)).toEqual([c2.id]); + }); + + it('rejects members from a foreign port', async () => { + const portA = await makePort(); + const portB = await makePort(); + const meta = makeAuditMeta({ portId: portA.id }); + const foreign = await makeClient({ portId: portB.id }); + const group = await createClientGroup(portA.id, { name: 'Scoped' }, meta); + await expect(setGroupMembers(group.id, portA.id, [foreign.id], meta)).rejects.toThrow( + /not in this port/, + ); + }); + + it('updates and archives a group', async () => { + const port = await makePort(); + const meta = makeAuditMeta({ portId: port.id }); + const group = await createClientGroup(port.id, { name: 'Temp' }, meta); + const updated = await updateClientGroup(group.id, port.id, { name: 'Renamed' }, meta); + expect(updated.name).toBe('Renamed'); + + await archiveClientGroup(group.id, port.id, meta); + await expect(getClientGroupById(group.id, port.id)).rejects.toThrow(/not found/i); + expect(await listClientGroups(port.id)).toHaveLength(0); + }); +});