Files
2026-05-27 23:29:53 +01:00

286 lines
18 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: contacts (migrate-contacts)
**Package**: `@suaveplan/contacts`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED Requirements
### Requirement: Universal-tier purity
The package SHALL run unchanged in Bun, Node, and browser environments. The `src/` tree SHALL NOT import from `node:*`, access `window` / `document` / `navigator`, or depend on any browser-only or Node-only package.
#### Scenario: tier-purity grep is empty
- **WHEN** the spec-conformance test walks `src/` and looks for `from "node:`, `globalThis.document`, `navigator.userAgent`, etc.
- **THEN** zero offenders are reported
**Verified by:** `src/contacts.spec-conformance.test.ts::"src tree contains no browser/Node-only globals"`
---
### Requirement: Stable error code registry
The package SHALL expose `CONTACTS_CODES` built via `defineCodes("CONTACTS", [...])` and a `ContactsError extends SuaveplanError<ContactsErrorCode>` with optional `contactId` / `groupId` / `tenantId` discriminants. Every failure path in the package SHALL raise a `ContactsError` (never a bare `Error`).
#### Scenario: every code is constructible
- **WHEN** the test instantiates `new ContactsError(code, "msg", {...})` for every member of `CONTACTS_CODES`
- **THEN** the resulting error narrows `code` to the namespaced string and exposes the discriminants
#### Scenario: every code is thrown from production
- **WHEN** `openspec-conformance` greps `src/` for every code in the registry
- **THEN** each code appears in at least one non-test source file
**Verified by:** `src/error-codes/error-codes.test.ts::"every registered code constructs a ContactsError with namespaced code"` AND `src/error-codes/error-codes.test.ts::"every code exposes its discriminants on the instance"`
---
### Requirement: Zod-validated input boundaries
Every public manager method SHALL validate its primary input via a Zod schema before performing any side effect. Validation failure SHALL raise `ContactsError` with code `INVALID_INPUT` (never a raw `ZodError`).
#### Scenario: invalid create input is rejected
- **WHEN** `ContactManager.create({ ...malformed })` is called with a missing required field
- **THEN** a `ContactsError` with code `CONTACTS.INVALID_INPUT` is thrown and no contact is persisted
#### Scenario: invalid group create input is rejected
- **WHEN** `GroupManager.create({ name: "" })` is called
- **THEN** `ContactsError` with code `CONTACTS.INVALID_INPUT` is thrown
**Verified by:** `src/schemas/schemas.test.ts::"rejects malformed create-contact input"` AND `src/core/contact-manager/contact-manager.test.ts::"raises INVALID_INPUT when create input fails Zod validation"` AND `src/core/group-manager/group-manager.test.ts::"raises INVALID_INPUT when create input fails Zod validation"`
---
### Requirement: Injectable Clock and IdGenerator
`ContactManager`, `GroupManager`, `MembershipManager`, `ContactMerger`, `InMemoryContactStore`, `InMemoryGroupStore`, and `InMemoryMembershipStore` SHALL accept optional `clock: Clock` and `idGenerator: IdGenerator` injections from `@suaveplan/types`. Production paths MUST NOT call `Date.now()`, `new Date()`, or `Math.random()` directly. Defaults SHALL be `() => Date.now()` and a deterministic per-instance counter.
#### Scenario: FakeClock controls timestamps
- **WHEN** an `InMemoryContactStore` is constructed with `clock: () => 1_700_000_000_000`
- **THEN** every created/updated `Contact.createdAt` and `updatedAt` reports `new Date(1_700_000_000_000)`
#### Scenario: injected IdGenerator controls IDs
- **WHEN** an `InMemoryGroupStore` is constructed with `idGenerator: () => "fixed_id"`
- **THEN** `store.generateId()` returns `"fixed_id"` for every call
**Verified by:** `src/stores/contact-store/contact-store.test.ts::"uses injected Clock for createdAt and updatedAt"` AND `src/stores/group-store/group-store.test.ts::"uses injected IdGenerator for generateId"` AND `src/stores/membership-store/membership-store.test.ts::"uses injected IdGenerator for membership IDs"`
---
### Requirement: GroupStore.generateId on the interface
The `GroupStore` interface SHALL expose `generateId(): string` as a public method. `GroupManager.create` SHALL call `store.generateId()` directly — no `(store as InMemoryGroupStore).generateId` cast.
#### Scenario: any conforming GroupStore can be passed
- **WHEN** a custom `GroupStore` implementation is passed to `GroupManager`
- **THEN** `create()` calls the store's `generateId()` and the resulting group carries that id
**Verified by:** `src/core/group-manager/group-manager.test.ts::"GroupManager.create uses store.generateId for the new group's id"`
---
### Requirement: OpenTelemetry instrument bundle
The package SHALL expose `getContactsInstruments()` and `resetContactsInstruments()` that return a cached bundle of OTel instruments bound against the current global `MeterProvider`. The bundle SHALL include six counters (`contacts.created_total`, `contacts.updated_total`, `contacts.deleted_total`, `contacts.merged_total`, `groups.created_total`, `memberships.added_total`) and three histograms (`contacts.import_duration_ms`, `contacts.export_duration_ms`, `audience.resolve_duration_ms`). Every public mutating method SHALL emit on at least one instrument; every duration-bearing operation SHALL record on its histogram.
#### Scenario: counter increments after creation
- **WHEN** a test installs an in-memory `MeterProvider`, calls `resetContactsInstruments()`, then creates a contact
- **THEN** reading the metric snapshot shows `contacts.created_total` incremented by 1
#### Scenario: histogram records on audience.resolve
- **WHEN** a test installs an in-memory `MeterProvider`, calls `resetContactsInstruments()`, then `resolver.resolve(...)`
- **THEN** the metric snapshot includes a recorded value on `audience.resolve_duration_ms`
**Verified by:** `src/otel/instruments.test.ts::"contacts.created_total increments after ContactManager.create"` AND `src/otel/instruments.test.ts::"audience.resolve_duration_ms records a sample after AudienceResolver.resolve"`
---
### Requirement: ContactManager CRUD with channel deduplication
`ContactManager` SHALL provide `create`, `getById`, `update`, `delete`, `list`, `findByChannel`, `findByIds`, `addTag`, `removeTag`, and `addChannel`. When `deduplicateByChannel` is true (default), `create` and `update` SHALL raise `CONTACT_CHANNEL_EXISTS` / `CONTACT_CHANNEL_EXISTS_ON_OTHER` if a channel (type+value) is already present in another contact in the same tenant. When false, duplicates are permitted.
#### Scenario: duplicate channel rejected under default policy
- **WHEN** two contacts are created with the same `email` channel in the same tenant
- **THEN** the second call raises `ContactsError` with code `CONTACTS.CONTACT_CHANNEL_EXISTS`
#### Scenario: duplicate channel accepted when disabled
- **WHEN** the manager is constructed with `deduplicateByChannel: false` and two contacts are created with the same channel
- **THEN** both creates succeed
#### Scenario: first channel auto-marked primary
- **WHEN** `create` is called with a channels array whose entries all have `primary: false`
- **THEN** the persisted contact's first channel has `primary: true`
**Verified by:** `src/core/contact-manager/contact-manager.test.ts::"rejects duplicate channels with CONTACT_CHANNEL_EXISTS under default dedup policy"` AND `src/core/contact-manager/contact-manager.test.ts::"permits duplicate channels when deduplicateByChannel is false"` AND `src/core/contact-manager/contact-manager.test.ts::"promotes the first channel to primary when none are flagged"`
---
### Requirement: Hierarchical group management with path arithmetic
`GroupManager` SHALL support n-level nested groups via a hybrid adjacency-list + materialized-path strategy. `create` SHALL compute the new group's `path` from its parent's path + slugified name and set `depth = parent.depth + 1`. `update` SHALL re-slugify on name change and propagate the new path to all descendants. `move` SHALL block circular references with code `GROUP_CIRCULAR_REF`. `delete` SHALL cascade-delete descendants.
#### Scenario: child path derives from parent
- **WHEN** a group "VIP" is created under parent "All Customers"
- **THEN** its `path` is `/all-customers/vip` and `depth` is 1
#### Scenario: rename propagates to descendants
- **WHEN** the parent is renamed
- **THEN** every descendant's `path` is rewritten and `updatedAt` is bumped
#### Scenario: circular move blocked
- **WHEN** `move(parent.id, child.id)` is attempted
- **THEN** `ContactsError` with code `CONTACTS.GROUP_CIRCULAR_REF` is thrown
**Verified by:** `src/core/group-manager/group-manager.test.ts::"derives child path and depth from parent on create"` AND `src/core/group-manager/group-manager.test.ts::"propagates renamed-parent path to all descendants"` AND `src/core/group-manager/group-manager.test.ts::"raises GROUP_CIRCULAR_REF when move would create a cycle"` AND `src/core/group-manager/group-manager.test.ts::"cascade-deletes descendants on delete"`
---
### Requirement: Many-to-many memberships with ancestor count propagation
`MembershipManager` SHALL provide `addContactToGroup`, `removeContactFromGroup`, `addContactToMultipleGroups`, `addMultipleContactsToGroup`, `getGroupsForContact`, `getMembersOfGroup`, `isContactInGroup`, `removeAllMembershipsForContact`, `countMembersInGroup`. Adding a membership SHALL increment the target group's `contactCount` and every ancestor's `totalContactCount`. Removing SHALL decrement (floor 0). Bulk operations SHALL swallow `MEMBERSHIP_EXISTS` collisions and continue.
#### Scenario: ancestor count propagation
- **WHEN** a contact is added to a deeply-nested child group
- **THEN** every ancestor's `totalContactCount` is incremented by 1
#### Scenario: bulk add tolerates duplicates
- **WHEN** `addMultipleContactsToGroup` is called and one contact is already a member
- **THEN** that contact is silently skipped and the rest are added
**Verified by:** `src/core/membership-manager/membership-manager.test.ts::"propagates incremented totalContactCount up the ancestor chain"` AND `src/core/membership-manager/membership-manager.test.ts::"addMultipleContactsToGroup swallows MEMBERSHIP_EXISTS and continues"`
---
### Requirement: ContactMerger preserves channels, tags, metadata, memberships
`ContactMerger.merge(primaryId, secondaryIds)` SHALL produce a `MergeResult` whose `mergedContact` carries the union of all channels (deduped by type+value, secondary channels demoted from primary), the union of all tags, and the merged metadata (primary wins on key collision). All secondary memberships SHALL be transferred to the primary unless the primary is already a member; then secondaries SHALL be deleted.
#### Scenario: missing primary raises MERGE_PRIMARY_NOT_FOUND
- **WHEN** `merge("nope", ["sec1"])` is called
- **THEN** `ContactsError` with code `CONTACTS.MERGE_PRIMARY_NOT_FOUND` is thrown
#### Scenario: missing secondary raises MERGE_SECONDARY_NOT_FOUND
- **WHEN** one of the secondary ids does not exist
- **THEN** `ContactsError` with code `CONTACTS.MERGE_SECONDARY_NOT_FOUND` is thrown
#### Scenario: channels deduplicated, secondary demoted from primary
- **WHEN** primary has email A (primary), secondary has email B (primary)
- **THEN** the merged contact has A (primary) and B (non-primary)
**Verified by:** `src/core/contact-merger/contact-merger.test.ts::"merges channels, tags, and metadata into the primary"` AND `src/core/contact-merger/contact-merger.test.ts::"raises MERGE_PRIMARY_NOT_FOUND when the primary id does not exist"` AND `src/core/contact-merger/contact-merger.test.ts::"raises MERGE_SECONDARY_NOT_FOUND when any secondary id does not exist"` AND `src/core/contact-merger/contact-merger.test.ts::"transfers secondary memberships to the primary without duplicating existing memberships"`
---
### Requirement: AudienceResolver walks groups recursively with exclusions, dedup, filters
`AudienceResolver.resolve(options)` SHALL collect every descendant of every `groupIds[i]` via `getDescendants`, remove every descendant of `excludeGroupIds[i]`, look up every contact via memberships, dedup, apply `excludeContactIds`, then filter by `statusFilter` and `channelType`. The returned `AudienceResult` SHALL report `totalCount`, `groupsResolved`, and `duplicatesRemoved`. `previewCount` SHALL return only the `totalCount`.
#### Scenario: nested groups resolve transitively
- **WHEN** a parent has 1 contact and its child has 1 contact, and `resolve({ groupIds: [parent.id] })` is called
- **THEN** `totalCount` is 2 and `groupsResolved` is 2
#### Scenario: same contact in two groups deduplicated
- **WHEN** a contact is a member of two groups and both are in `groupIds`
- **THEN** `totalCount` is 1 and `duplicatesRemoved` is 1
#### Scenario: channel filter applied
- **WHEN** the audience has 1 email and 1 SMS contact and `channelType: "email"` is set
- **THEN** `totalCount` is 1
**Verified by:** `src/core/audience-resolver/audience-resolver.test.ts::"resolves contacts from a single group"` AND `src/core/audience-resolver/audience-resolver.test.ts::"resolves contacts from nested groups transitively"` AND `src/core/audience-resolver/audience-resolver.test.ts::"deduplicates contacts that are members of multiple groups"` AND `src/core/audience-resolver/audience-resolver.test.ts::"filters by channelType"` AND `src/core/audience-resolver/audience-resolver.test.ts::"filters by status"` AND `src/core/audience-resolver/audience-resolver.test.ts::"previewCount returns only the totalCount"`
---
### Requirement: Tree utilities for hierarchical display
The package SHALL expose `buildGroupTree`, `flattenGroupTree`, `getAncestorPaths`, `validateNoCircularRef`, `countTreeContacts`, `findInTree`, `collectAllGroupIds` as pure functions over `Group[]` / `GroupTreeNode[]`. None of these SHALL call `Date.now()` or perform IO.
#### Scenario: round-trip build/flatten
- **WHEN** a flat group list is passed to `buildGroupTree`, then the resulting tree to `flattenGroupTree`
- **THEN** the flattened nodes carry correct `level`, `hasChildren`, `isLastChild`
**Verified by:** `src/tree/tree-utils.test.ts::"buildGroupTree assembles roots and children from a flat list"` AND `src/tree/tree-utils.test.ts::"flattenGroupTree reports level, hasChildren, isLastChild"` AND `src/tree/tree-utils.test.ts::"getAncestorPaths returns every prefix of a slash-separated path"` AND `src/tree/tree-utils.test.ts::"validateNoCircularRef returns false when the cycle would close"` AND `src/tree/tree-utils.test.ts::"countTreeContacts sums recursively"` AND `src/tree/tree-utils.test.ts::"findInTree returns null when missing and the node when present"` AND `src/tree/tree-utils.test.ts::"collectAllGroupIds enumerates every node in DFS order"`
---
### Requirement: CSV and JSON export with field selection
`ContactExporter` SHALL expose `exportToJson`, `exportToCsv`, and a unified `export(contacts, options)`. The CSV output SHALL escape commas and double-quotes per RFC 4180. The exporter SHALL respect `includeChannels`, `includeTags`, `includeMetadata` flags and record an OTel histogram sample of `contacts.export_duration_ms`.
#### Scenario: CSV escapes embedded comma
- **WHEN** a contact's display name contains a comma
- **THEN** the CSV cell is wrapped in double quotes
#### Scenario: empty input
- **WHEN** `exportToCsv([])` is called
- **THEN** the output is the empty string
**Verified by:** `src/import-export/contact-exporter/contact-exporter.test.ts::"escapes embedded commas and double quotes in CSV output"` AND `src/import-export/contact-exporter/contact-exporter.test.ts::"returns empty string when there are no contacts"` AND `src/import-export/contact-exporter/contact-exporter.test.ts::"export dispatches to JSON or CSV based on options.format"`
---
### Requirement: CSV import with field mapping, default tags, dedup
`ContactImporter.importFromCsv(csv, options)` SHALL parse the CSV (RFC 4180-style with quoted fields), apply `options.fieldMapping` to construct `CreateContactInput`, append `options.defaultTags`, and (when `skipDuplicates`) check `findByChannelValue` before insert. Per-row errors SHALL be captured into `ImportResult.errors` with the 1-based row number. The importer SHALL record an OTel histogram sample of `contacts.import_duration_ms`.
#### Scenario: dedup skips existing channel
- **WHEN** the source CSV contains a row whose email is already present and `skipDuplicates: true`
- **THEN** the row is counted in `skipped`, not `imported`
#### Scenario: row with no resolvable channel is skipped
- **WHEN** a row's mapped email and phone are both empty
- **THEN** the row is counted in `skipped`
**Verified by:** `src/import-export/contact-importer/contact-importer.test.ts::"imports records with field mapping and default tags"` AND `src/import-export/contact-importer/contact-importer.test.ts::"skips rows whose channel already exists when skipDuplicates is true"` AND `src/import-export/contact-importer/contact-importer.test.ts::"skips rows with no resolvable channel"` AND `src/import-export/contact-importer/contact-importer.test.ts::"captures per-row exceptions into ImportResult.errors with 1-based row"`
---
### Requirement: Public barrel + subpath exports
The package SHALL expose a single root barrel (`src/index.ts`) using explicit named re-exports (no `export *`). Every feature folder SHALL also be exposed via a subpath in `package.json#exports` so consumers can tree-shake. Feature-folder `index.ts` files SHALL use explicit named re-exports.
#### Scenario: every feature has a subpath
- **WHEN** the conformance test reads `package.json#exports`
- **THEN** every `<feature>/<feature>` subpath resolves to a real `.ts` source under `src/`
#### Scenario: barrel uses named exports only
- **WHEN** the conformance test scans `src/**/index.ts` for `export *`
- **THEN** zero offenders are reported
**Verified by:** `src/contacts.spec-conformance.test.ts::"every feature index.ts uses explicit named exports"` AND `src/contacts.spec-conformance.test.ts::"every declared subpath export resolves to a real source file"`
<!-- markdownlint-enable MD024 -->