151 lines
10 KiB
Markdown
151 lines
10 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: api-plugin-contacts
|
|
|
|
**Package**: `@suaveplan/api-plugin-contacts`
|
|
**Status**: ACTIVE (folded 2026-05-20 from migrate-api-plugin-contacts)
|
|
|
|
## ADDED Requirements
|
|
|
|
### Requirement: Plugin factory with validated configuration
|
|
|
|
The package SHALL expose `createContactsPlugin(options?: ContactsPluginOptions): PluginDescriptor<ContactsPluginConfig>` that Zod-validates `options` at construction time and returns a typed `PluginDescriptor` declaring `dependencies: ["database", "auth"]` so the host plugin manager topo-sorts after `@suaveplan/api-plugin-database` and `@suaveplan/api-plugin-auth`. The descriptor SHALL surface the same identifiers as the upstream `1.0.0` API (`name: "contacts"`, `tags: ["contacts", "groups", "audience"]`) but ship at `0.1.0`.
|
|
|
|
#### Scenario: factory rejects invalid options
|
|
|
|
- **WHEN** caller passes `maxGroupDepth: -1`
|
|
- **THEN** factory throws `ContactsApiError` with code `CONTACTS_API.INVALID_INPUT` carrying the `ZodError` as `cause`
|
|
|
|
#### Scenario: descriptor exposes defaults
|
|
|
|
- **WHEN** factory is called with no arguments
|
|
- **THEN** descriptor `defaults` equals `{ routePrefix: "/", maxImportBatchSize: 1000, maxGroupDepth: 10, enforceGroupDepth: true }`
|
|
|
|
**Verified by:** `src/plugin/plugin.test.ts::"createContactsPlugin returns a typed descriptor with default config"` AND `src/plugin/plugin.test.ts::"createContactsPlugin throws CONTACTS_API.INVALID_INPUT on invalid options"` AND `src/api-plugin-contacts.spec-conformance.test.ts::"descriptor advertises name, dependencies, version, tags"`
|
|
|
|
---
|
|
|
|
### Requirement: Stable error-code registry
|
|
|
|
The package SHALL export `CONTACTS_API_CODES` (built with `defineCodes("CONTACTS_API", […])`) and a `ContactsApiError extends SuaveplanError<ContactsApiErrorCode>` class. Every failure path in the package SHALL raise a `ContactsApiError` carrying one of the 12 stable codes.
|
|
|
|
#### Scenario: every registered code is thrown from production code
|
|
|
|
- **WHEN** static analysis greps `throw new ContactsApiError\(CONTACTS_API_CODES\.` across `src/**/*.ts` (excluding tests)
|
|
- **THEN** every member of `CONTACTS_API_CODES` appears at least once
|
|
|
|
#### Scenario: error class preserves cause chain
|
|
|
|
- **WHEN** repository raises with `cause: new Error("boom")`
|
|
- **THEN** `error.cause instanceof Error` and `error.code === "CONTACTS_API.<code>"`
|
|
|
|
**Verified by:** `src/error-codes/error-codes.test.ts::"every code is a member of the CONTACTS_API namespace"` AND `src/error-codes/error-codes.test.ts::"ContactsApiError preserves cause"` AND `src/api-plugin-contacts.spec-conformance.test.ts::"every error code is thrown by at least one production path"`
|
|
|
|
---
|
|
|
|
### Requirement: OpenTelemetry instrument bundle emitted from production paths
|
|
|
|
The package SHALL export `getContactsApiInstruments(): ContactsApiInstruments` and `resetContactsApiInstruments()` and SHALL emit `contacts_api.contact_writes_total`, `contacts_api.contact_reads_total`, `contacts_api.group_writes_total`, `contacts_api.membership_writes_total`, `contacts_api.import_duration_ms` (histogram) and `contacts_api.merge_duration_ms` (histogram) from the corresponding route handlers.
|
|
|
|
#### Scenario: in-memory MeterProvider receives counter increments
|
|
|
|
- **WHEN** integration test installs an `InMemoryMetricExporter`-backed `MeterProvider`, calls `resetContactsApiInstruments`, then triggers `POST /contacts` once
|
|
- **THEN** the collected metrics include `contacts_api.contact_writes_total{outcome="success"}=1`
|
|
|
|
**Verified by:** `src/otel/instruments.test.ts::"getContactsApiInstruments returns the six-instrument bundle"` AND `src/otel/instruments.test.ts::"resetContactsApiInstruments rebinds against a freshly installed MeterProvider"` AND `tests/integration/full-boot.test.ts::"emits contacts_api.contact_writes_total on POST /contacts"`
|
|
|
|
---
|
|
|
|
### Requirement: SurrealDB-backed store implementations
|
|
|
|
The package SHALL export `SurrealContactRepository`, `SurrealGroupRepository`, and `SurrealMembershipRepository` implementing the genesis `@suaveplan/contacts` `ContactStore`, `GroupStore`, and `MembershipStore` interfaces exactly (including `generateId()`). Repositories SHALL accept a connected `SurrealAdapter` via the constructor and SHALL NOT open or close it.
|
|
|
|
#### Scenario: ContactStore roundtrip
|
|
|
|
- **WHEN** integration test calls `repo.create({ tenantId, displayName, channels })` then `repo.getById(id)`
|
|
- **THEN** the returned `Contact` matches the created shape with `createdAt` / `updatedAt` as `Date` instances
|
|
|
|
#### Scenario: GroupStore enforces maxGroupDepth
|
|
|
|
- **WHEN** factory was called with `maxGroupDepth: 2, enforceGroupDepth: true`, the repository builds depth-0 → depth-1 → depth-2 groups, and then attempts to create a depth-3 child
|
|
- **THEN** the create call throws `CONTACTS_API.GROUP_DEPTH_EXCEEDED`
|
|
|
|
#### Scenario: MembershipStore add is idempotent through repository
|
|
|
|
- **WHEN** caller invokes the `POST /groups/:id/members` route twice with the same contact id
|
|
- **THEN** the second call returns `{ added: 0 }` (no `MEMBERSHIP_EXISTS` thrown — uniqueness handled at the route boundary by an existence pre-check)
|
|
|
|
**Verified by:** `src/repositories/contact-repository.test.ts::"create + getById roundtrip against fake adapter"` AND `src/repositories/group-repository.test.ts::"createGroup raises GROUP_DEPTH_EXCEEDED past maxGroupDepth"` AND `src/repositories/membership-repository.test.ts::"add raises MEMBERSHIP_EXISTS on duplicate"` AND `tests/integration/full-boot.test.ts::"contact roundtrip against real SurrealDB"` AND `tests/integration/group-depth.test.ts::"createGroup raises GROUP_DEPTH_EXCEEDED when enforceGroupDepth is on"`
|
|
|
|
---
|
|
|
|
### Requirement: Typed routes with Zod boundaries
|
|
|
|
The package SHALL mount `/contacts` and `/groups` route trees on the host `OpenAPIHono` via `@hono/zod-openapi`. Every route SHALL declare request and response Zod schemas and SHALL read the current `Principal` from `c.get("user")` (populated upstream by `@suaveplan/api-plugin-auth`'s `requireAuth()` middleware). Routes SHALL NOT use `as any` casts.
|
|
|
|
#### Scenario: unauthenticated request is rejected
|
|
|
|
- **WHEN** test posts to `/contacts` without an auth session
|
|
- **THEN** response is 401 with `{ success: false, error: "Unauthorized" }`
|
|
|
|
#### Scenario: principal without tenantId is rejected
|
|
|
|
- **WHEN** test posts to `/contacts` with a `Principal` lacking `tenantId`
|
|
- **THEN** response is 401 with `{ success: false, error: "Unauthorized" }`
|
|
|
|
#### Scenario: well-formed contact create returns 201 + body
|
|
|
|
- **WHEN** authenticated request posts a valid `CreateContactSchema` body
|
|
- **THEN** response is 201 with `{ success: true, data: { id, … } }`
|
|
|
|
**Verified by:** `src/routes/contacts/build-routes.test.ts::"POST /contacts rejects requests without a tenantId"` AND `src/routes/contacts/build-routes.test.ts::"POST /contacts returns 201 on a valid body"` AND `src/routes/groups/build-routes.test.ts::"GET /groups/:id/tree returns a typed GroupTreeNode"` AND `tests/integration/full-boot.test.ts::"full contact + group + membership roundtrip"`
|
|
|
|
---
|
|
|
|
### Requirement: Bulk-import endpoint honours maxImportBatchSize
|
|
|
|
The package SHALL expose `POST /contacts/import` whose body is `{ contacts: CreateContactSchema[], skipDuplicates: boolean }` capped at `maxImportBatchSize`. The route SHALL Zod-reject batches larger than the cap with 400 and SHALL return `{ imported, skipped, errors: [{ index, error }] }` with `errors` populated per failing row.
|
|
|
|
#### Scenario: oversized batch is rejected at the schema boundary
|
|
|
|
- **WHEN** caller posts a batch of length `maxImportBatchSize + 1`
|
|
- **THEN** response is 400 (Zod boundary) and no rows are created
|
|
|
|
#### Scenario: per-row errors reported with original index
|
|
|
|
- **WHEN** caller posts a batch where row index 2 violates a database constraint
|
|
- **THEN** response body `data.errors` contains `{ index: 2, error: <message> }` and `data.imported` equals the count of successful rows
|
|
|
|
**Verified by:** `src/routes/contacts/build-routes.test.ts::"POST /contacts/import rejects oversized batches at the schema boundary"` AND `tests/integration/bulk-import.test.ts::"per-row errors are reported with the original index against real SurrealDB"`
|
|
|
|
---
|
|
|
|
### Requirement: Typed Hono ContextVariableMap and ServiceRegistry augmentation
|
|
|
|
The package SHALL augment Hono's `ContextVariableMap` with `contactRepository`, `groupRepository`, `membershipRepository`, and `eventBus` slots, and SHALL augment `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface with `contactRepository`, `groupRepository`, `membershipRepository` slots so sibling plugins can resolve them via `ctx.getService(…)` without a cast.
|
|
|
|
#### Scenario: type-level access compiles without `as`
|
|
|
|
- **WHEN** consumer writes `const repo = c.get("contactRepository")` inside a Hono handler in TypeScript
|
|
- **THEN** `repo` is typed as `SurrealContactRepository | undefined`
|
|
|
|
**Verified by:** `src/context/context.test.ts::"ContextVariableMap exposes contactRepository slot typed as SurrealContactRepository"` AND `src/api-plugin-contacts.spec-conformance.test.ts::"ServiceRegistry augments contactRepository, groupRepository, membershipRepository slots"`
|
|
|
|
---
|
|
|
|
### Requirement: Schema migration via bundled SurrealQL
|
|
|
|
The package SHALL ship `surql/tables/{contact,contact_group,group_membership}.surql` and SHALL apply them on `onInit` either via the host `MigrationRunner` (when present in `ServiceRegistry`) or via direct `SurrealAdapter.execute` calls otherwise. Errors at apply time SHALL throw `CONTACTS_API.SCHEMA_APPLY_FAILED` with the underlying cause attached.
|
|
|
|
#### Scenario: schema apply succeeds against fresh SurrealDB
|
|
|
|
- **WHEN** plugin `onInit` runs against a fresh Surreal namespace/database
|
|
- **THEN** the three tables exist and a subsequent `INFO FOR TABLE contact` succeeds
|
|
|
|
#### Scenario: schema apply failure is wrapped
|
|
|
|
- **WHEN** the apply step throws (simulated by closing the adapter before `onInit`)
|
|
- **THEN** the `onInit` promise rejects with `CONTACTS_API.SCHEMA_APPLY_FAILED`
|
|
|
|
**Verified by:** `src/plugin/on-init.test.ts::"onInit raises SCHEMA_APPLY_FAILED when execute throws"` AND `tests/integration/full-boot.test.ts::"onInit applies surql tables against real SurrealDB"`
|