first commit
This commit is contained in:
126
docs/commerce/contacts/api-plugin-contacts/README.md
Normal file
126
docs/commerce/contacts/api-plugin-contacts/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# @suaveplan/api-plugin-contacts - Contacts + groups HTTP plugin for @suaveplan/api. Mounts /contacts and /groups (with /groups/:id/members and /groups/:id/resolve) on a Hono OpenAPI registry, backed by SurrealDB-implementations of the @suaveplan/contacts ContactStore / GroupStore / MembershipStore interfaces. Bulk-import with per-row error reporting, repository-level maxGroupDepth enforcement, six OTel instruments, typed Hono ContextVariableMap + ServiceRegistry augmentations, and the 12-code CONTACTS_API.* error namespace.
|
||||
|
||||
Contacts and Groups HTTP plugin for [`@suaveplan/api`](../../api/api).
|
||||
Mounts the `/contacts` and `/groups` route trees on a Hono
|
||||
`OpenAPIHono`, backed by SurrealDB implementations of the
|
||||
[`@suaveplan/contacts`](../contacts) store interfaces.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [context](./src/context/context.md) — Module augmentation declaring four typed slots on Hono's `ContextVariableMap`: `contactRepository`, `groupRepository`,…
|
||||
- [context](./src/context/context.md) — Module augmentation declaring four typed slots on Hono's `ContextVariableMap`: `contactRepository`, `groupRepository`,…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Single source of truth for every failure path inside `@suaveplan/api-plugin-contacts`. Built on the `@suaveplan…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Single source of truth for every failure path inside `@suaveplan/api-plugin-contacts`. Built on the `@suaveplan…
|
||||
- [otel](./src/otel/instruments.md) — Exposes the six OpenTelemetry instruments emitted by `@suaveplan/api-plugin-contacts` in production: four counters cove…
|
||||
- [instruments](./src/otel/instruments.md) — Exposes the six OpenTelemetry instruments emitted by `@suaveplan/api-plugin-contacts` in production: four counters cove…
|
||||
- [plugin](./src/plugin/plugin.md) — Returns the `PluginDescriptor<ContactsPluginConfig>` consumed by the `@suaveplan/api` plugin host. Runs the 5-step `onI…
|
||||
- [plugin](./src/plugin/plugin.md) — Returns the `PluginDescriptor<ContactsPluginConfig>` consumed by the `@suaveplan/api` plugin host. Runs the 5-step `onI…
|
||||
- [schema](./src/plugin/schema.md) — Inlined SurrealQL DDL for the three tables the contacts plugin owns: `contact`, `contact_group`, and `group_membership`…
|
||||
- [contact-repository](./src/repositories/contact-repository.md) — Concrete implementation of the universal `ContactStore` interface from `@suaveplan/contacts`, backed by a connecte…
|
||||
- [group-repository](./src/repositories/group-repository.md) — Concrete implementation of the `GroupStore` interface from `@suaveplan/contacts`, backed by `SurrealAdapter`. I…
|
||||
- [membership-repository](./src/repositories/membership-repository.md) — Concrete implementation of the `MembershipStore` interface from `@suaveplan/contacts`, backed by `SurrealAdapte…
|
||||
- [build-routes](./src/routes/contacts/build-routes.md) — Returns the seven `PluginRouteEntry` values that compose the `/contacts` route tree. Mounted by the plugin's `onInit` v…
|
||||
- [contacts-bulk-routes](./src/routes/contacts/contacts-bulk-routes.md) — Two `PluginRouteEntry` builders for the bulk-operation contact endpoints: `POST /contacts/import` and `POST /contacts/m…
|
||||
- [contacts-crud-routes](./src/routes/contacts/contacts-crud-routes.md) — Five `PluginRouteEntry` builders for the contact CRUD endpoints: `GET /contacts`, `POST /contacts`, `GET /contacts/:id`…
|
||||
- [build-routes](./src/routes/groups/build-routes.md) — Returns the 11 `PluginRouteEntry` values that compose the `/groups` route tree: 5 CRUD (`list`, `create`, `get`, `updat…
|
||||
- [groups-crud-routes](./src/routes/groups/groups-crud-routes.md) — Five `PluginRouteEntry` builders for the group CRUD endpoints: `GET /groups`, `POST /groups`, `GET /groups/:id`, `PATCH…
|
||||
- [groups-members-routes](./src/routes/groups/groups-members-routes.md) — Four `PluginRouteEntry` builders for the group-membership endpoints: `GET /groups/:id/members`, `POST /groups/:id/membe…
|
||||
- [groups-tree-routes](./src/routes/groups/groups-tree-routes.md) — Two `PluginRouteEntry` builders for the hierarchical-group endpoints: `GET /groups/:id/tree` materialises a typed `Grou…
|
||||
- [require-tenant](./src/routes/shared/require-tenant.md) — Centralised guard that extracts and validates the request `Principal` on every `/contacts` and `/groups` handler. Retur…
|
||||
- [strip-undefined](./src/routes/shared/strip-undefined.md) — Bridges Zod's optional-field semantics (`.optional() → string | undefined`) with the `@suaveplan/contacts` inpu…
|
||||
- [contacts-schemas](./src/schemas/contacts-schemas.md) — Zod schemas mounted on every `/contacts` route via `@hono/zod-openapi`. They validate inbound bodies / query strings, s…
|
||||
- [groups-schemas](./src/schemas/groups-schemas.md) — Zod schemas for the seven `/groups` routes (list, create, get, update, delete, tree, move) plus the three `/groups/:id/…
|
||||
- [options](./src/schemas/options.md) — Zod schema that validates the optional bag of options passed to `createContactsPlugin`. Every field is optional with a…
|
||||
- [services](./src/services/registry-augmentation.md) — Adds typed `contactRepository`, `groupRepository`, and `membershipRepository` slots to the shared `@suaveplan/api-plugi…
|
||||
- [registry-augmentation](./src/services/registry-augmentation.md) — Adds typed `contactRepository`, `groupRepository`, and `membershipRepository` slots to the shared `@suaveplan/api-plugi…
|
||||
- [tree](./src/tree/build-tree.md) — Constructs a typed `GroupTreeNode` rooted at the supplied `Group`, given a flat list of descendants in arbitrary order.…
|
||||
- [build-tree](./src/tree/build-tree.md) — Constructs a typed `GroupTreeNode` rooted at the supplied `Group`, given a flat list of descendants in arbitrary order.…
|
||||
- [types](./src/types/types.md) — Pure type declarations that downstream consumers of `@suaveplan/api-plugin-contacts` depend on at compile time. Keeping…
|
||||
- [types](./src/types/types.md) — Pure type declarations that downstream consumers of `@suaveplan/api-plugin-contacts` depend on at compile time. Keeping…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Status
|
||||
|
||||
- Version: `0.1.0`
|
||||
- Tier: server / universal
|
||||
- suaveplan-policy: 100% test coverage, 0 mocks, real SurrealDB via
|
||||
`@suaveplan/testing-integration-surrealdb`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createContactsPlugin } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const plugin = createContactsPlugin({
|
||||
routePrefix: "/api",
|
||||
maxImportBatchSize: 500,
|
||||
maxGroupDepth: 10,
|
||||
enforceGroupDepth: true,
|
||||
});
|
||||
// host.use(plugin);
|
||||
```
|
||||
|
||||
The plugin is suaveplan-compatible with the `@suaveplan/api-plugin`
|
||||
descriptor contract; load it after the database and auth plugins so
|
||||
the ServiceRegistry `db` and `authService` slots resolve.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/contacts` | List contacts (search / status / channelType / tags / paging) |
|
||||
| POST | `/contacts` | Create a contact |
|
||||
| GET | `/contacts/:id` | Read a contact (tenant-scoped) |
|
||||
| PATCH | `/contacts/:id` | Update a contact |
|
||||
| DELETE | `/contacts/:id` | Delete a contact |
|
||||
| POST | `/contacts/import` | Bulk import (Zod-capped) with per-row errors |
|
||||
| POST | `/contacts/merge` | Channel + tag union into a primary contact |
|
||||
| GET | `/groups` | List groups |
|
||||
| POST | `/groups` | Create a group (depth-enforced) |
|
||||
| GET | `/groups/:id` | Read a group |
|
||||
| PATCH | `/groups/:id` | Update a group |
|
||||
| DELETE | `/groups/:id` | Delete a group (cascade members) |
|
||||
| GET | `/groups/:id/tree` | Typed `GroupTreeNode` subtree |
|
||||
| POST | `/groups/:id/move` | Reparent (depth-enforced) |
|
||||
| GET | `/groups/:id/members` | List members |
|
||||
| POST | `/groups/:id/members` | Idempotent add |
|
||||
| DELETE | `/groups/:id/members` | Idempotent remove |
|
||||
| GET | `/groups/:id/resolve` | Flatten group + descendants → unique contacts |
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) — 12 `CONTACTS_API.*` codes + `ContactsApiError`.
|
||||
- [`otel/instruments`](./src/otel/instruments.md) — six OTel instruments.
|
||||
- [`schemas/options`](./src/schemas/options.md) — plugin options Zod schema.
|
||||
- [`schemas/contacts-schemas`](./src/schemas/contacts-schemas.md) — `/contacts` request/response shapes.
|
||||
- [`schemas/groups-schemas`](./src/schemas/groups-schemas.md) — `/groups` request/response shapes.
|
||||
- [`repositories/contact-repository`](./src/repositories/contact-repository.md) — `SurrealContactRepository`.
|
||||
- [`repositories/group-repository`](./src/repositories/group-repository.md) — `SurrealGroupRepository`.
|
||||
- [`repositories/membership-repository`](./src/repositories/membership-repository.md) — `SurrealMembershipRepository`.
|
||||
- [`routes/shared/require-tenant`](./src/routes/shared/require-tenant.md) — Principal → tenantId guard.
|
||||
- [`routes/contacts/build-routes`](./src/routes/contacts/build-routes.md) — `/contacts` aggregator.
|
||||
- [`routes/contacts/contacts-crud-routes`](./src/routes/contacts/contacts-crud-routes.md) — CRUD builders.
|
||||
- [`routes/contacts/contacts-bulk-routes`](./src/routes/contacts/contacts-bulk-routes.md) — import + merge builders.
|
||||
- [`routes/groups/build-routes`](./src/routes/groups/build-routes.md) — `/groups` aggregator.
|
||||
- [`routes/groups/groups-crud-routes`](./src/routes/groups/groups-crud-routes.md) — CRUD builders.
|
||||
- [`routes/groups/groups-tree-routes`](./src/routes/groups/groups-tree-routes.md) — tree + move.
|
||||
- [`routes/groups/groups-members-routes`](./src/routes/groups/groups-members-routes.md) — membership + resolve.
|
||||
- [`tree/build-tree`](./src/tree/build-tree.md) — typed subtree builder.
|
||||
- [`plugin/schema`](./src/plugin/schema.md) — bundled SurrealQL DDL + apply helper.
|
||||
- [`plugin/plugin`](./src/plugin/plugin.md) — `createContactsPlugin` factory.
|
||||
- [`types/types`](./src/types/types.md) — public configuration types.
|
||||
- [`context/context`](./src/context/context.md) — Hono `ContextVariableMap` augmentation.
|
||||
- [`services/registry-augmentation`](./src/services/registry-augmentation.md) — `ServiceRegistry` augmentation.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
bunx turbo test --filter=@suaveplan/api-plugin-contacts
|
||||
bunx turbo test:coverage --filter=@suaveplan/api-plugin-contacts
|
||||
```
|
||||
|
||||
Integration tests under `tests/integration/` boot a SurrealDB
|
||||
testcontainer (Docker required) and exercise the full plugin
|
||||
lifecycle end-to-end.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# context — Hono ContextVariableMap augmentation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Module augmentation declaring four typed slots on Hono's
|
||||
`ContextVariableMap`: `contactRepository`, `groupRepository`,
|
||||
`membershipRepository`, and `eventBus`. Importing this module for its
|
||||
side effects (the package root barrel does it automatically) means
|
||||
every route handler in this plugin's `/contacts` and `/groups` trees
|
||||
reads `c.get("contactRepository")` as `SurrealContactRepository | undefined`
|
||||
without an `as` cast.
|
||||
|
||||
The upstream port shipped ~30 `as any` casts in its route handlers to
|
||||
work around the missing augmentation. suaveplan CLAUDE.md rule 9 forbids
|
||||
static-analysis suppression; this module is the structural fix.
|
||||
|
||||
## Features
|
||||
|
||||
- Declares four typed slots on Hono's request context.
|
||||
- Zero runtime emit — the `declare module` block is removed by the TS
|
||||
transformer.
|
||||
- Imported by `src/index.ts` for side effects so consumers of the
|
||||
package see the augmentation automatically.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "hono"; // brings in our augmentation transitively via the plugin root
|
||||
import type { Context } from "hono";
|
||||
|
||||
function handler(c: Context) {
|
||||
const repo = c.get("contactRepository");
|
||||
// repo is typed as SurrealContactRepository | undefined.
|
||||
return c.json({ ok: repo !== undefined });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { Context } from "hono";
|
||||
import type { SurrealContactRepository } from "@suaveplan/api-plugin-contacts/repositories/contact-repository";
|
||||
|
||||
function listContactsHandler(c: Context) {
|
||||
const repo = c.get("contactRepository");
|
||||
if (repo === undefined) {
|
||||
throw new Error("contactRepository missing — plugin not mounted");
|
||||
}
|
||||
return repo.list({ tenantId: c.get("user")!.tenantId! });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Module augmentation only — no runtime exports. The slots are:
|
||||
- `contactRepository: SurrealContactRepository`
|
||||
- `groupRepository: SurrealGroupRepository`
|
||||
- `membershipRepository: SurrealMembershipRepository`
|
||||
- `eventBus: EventBusLike`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Slots are typed as `T` (not `T | undefined`); `c.get` returns
|
||||
`T | undefined` at the Hono type level, so accidental reads before
|
||||
the plugin middleware runs are caught by the compiler.
|
||||
- Augmentations are global once imported — every Hono app in the
|
||||
process sees them. This is the standard TypeScript module-merging
|
||||
trade-off; sibling plugins in `packages/api/api-plugin-*` rely on the
|
||||
same pattern.
|
||||
- The export `{}` line is required so TypeScript treats the file as a
|
||||
module rather than a global script.
|
||||
@@ -0,0 +1,107 @@
|
||||
# error-codes — stable CONTACTS_API.* registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every failure path inside
|
||||
`@suaveplan/api-plugin-contacts`. Built on the suaveplan
|
||||
`@suaveplan/error` foundation (`defineCodes` + `SuaveplanError`) so
|
||||
codes are typed as a string-literal union and the registry is wire-
|
||||
stable across versions. Consumers (UI translation layers, response
|
||||
encoders, log shippers) branch on `error.code` without sniffing the
|
||||
message text.
|
||||
|
||||
The 12 codes split cleanly into three groups: lifecycle errors
|
||||
(`INVALID_INPUT`, `SERVICE_NOT_AVAILABLE`, `SCHEMA_APPLY_FAILED`,
|
||||
`ROUTE_PREFIX_COLLISION`), boundary errors visible to HTTP callers
|
||||
(`NOT_FOUND`, `UNAUTHORIZED`, `IMPORT_TOO_LARGE`), and operation
|
||||
errors raised by the SurrealDB repositories (`CREATE_FAILED`,
|
||||
`UPDATE_FAILED`, `DELETE_FAILED`, `GROUP_DEPTH_EXCEEDED`,
|
||||
`MEMBERSHIP_EXISTS`).
|
||||
|
||||
## Features
|
||||
|
||||
- Stable code strings prefixed with `CONTACTS_API.` (built via
|
||||
`defineCodes`) — safe to log or send to clients.
|
||||
- `ContactsApiError` subclass of `SuaveplanError` preserves the
|
||||
underlying `cause`, optional structured `context`, and a redactable
|
||||
message.
|
||||
- Codes are exhaustive: every `throw` in the production tree uses
|
||||
exactly one of the 12; the spec-conformance test pins this.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
CONTACTS_API_CODES,
|
||||
ContactsApiError,
|
||||
} from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
throw new ContactsApiError(
|
||||
CONTACTS_API_CODES.NOT_FOUND,
|
||||
"contact not found",
|
||||
{ context: { statusCode: 404 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
CONTACTS_API_CODES,
|
||||
type ContactsApiErrorCode,
|
||||
ContactsApiError,
|
||||
} from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
function toHttpStatus(code: ContactsApiErrorCode): number {
|
||||
switch (code) {
|
||||
case CONTACTS_API_CODES.NOT_FOUND:
|
||||
return 404;
|
||||
case CONTACTS_API_CODES.UNAUTHORIZED:
|
||||
return 401;
|
||||
case CONTACTS_API_CODES.IMPORT_TOO_LARGE:
|
||||
case CONTACTS_API_CODES.GROUP_DEPTH_EXCEEDED:
|
||||
case CONTACTS_API_CODES.INVALID_INPUT:
|
||||
return 400;
|
||||
case CONTACTS_API_CODES.MEMBERSHIP_EXISTS:
|
||||
return 409;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
/* … */
|
||||
} catch (err) {
|
||||
if (err instanceof ContactsApiError) {
|
||||
return Response.json(
|
||||
{ success: false, code: err.code, error: err.message },
|
||||
{ status: toHttpStatus(err.contactsApiCode) }
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CONTACTS_API_CODES` — frozen registry returned by
|
||||
`defineCodes("CONTACTS_API", [...])`.
|
||||
- `ContactsApiErrorCode` — string-literal union of every member of
|
||||
`CONTACTS_API_CODES`.
|
||||
- `class ContactsApiError extends SuaveplanError<ContactsApiErrorCode>`
|
||||
— constructor `(code, message, options?)`. Stores the original code
|
||||
on `contactsApiCode` for ergonomic narrowing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is declared `as const`; `defineCodes` returns the frozen
|
||||
mapping (`{ CODE: "CONTACTS_API.CODE", … }`) so callers cannot
|
||||
accidentally mutate it.
|
||||
- The constructor delegates to `SuaveplanError`, which threads
|
||||
`cause`, `context`, and a default `name` through. No code path here
|
||||
re-implements that — adding a new code only requires adding a string
|
||||
literal to the `as const` array.
|
||||
@@ -0,0 +1,92 @@
|
||||
# otel/instruments — OpenTelemetry instrument bundle
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Exposes the six OpenTelemetry instruments emitted by
|
||||
`@suaveplan/api-plugin-contacts` in production: four counters covering
|
||||
contact reads, contact writes, group writes, and membership writes, and
|
||||
two histograms covering the wall-time of the bulk-import and merge
|
||||
endpoints. Instruments are lazily bound against the global
|
||||
`MeterProvider` at first use and cached for the lifetime of the
|
||||
process; tests call `resetContactsApiInstruments()` to force a re-bind
|
||||
after installing an in-memory `MeterProvider`.
|
||||
|
||||
The bundle is the only telemetry surface the plugin emits; route
|
||||
handlers in `../routes/**` call `getContactsApiInstruments()` once at
|
||||
import time and update the instruments inline. No spans are created
|
||||
here — span propagation comes from the host Hono OpenTelemetry
|
||||
middleware.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy binding — no `MeterProvider` work until the first call.
|
||||
- Singleton cache, with `resetContactsApiInstruments()` escape hatch
|
||||
for tests.
|
||||
- All instrument names share the `contacts_api.` prefix to match the
|
||||
`@suaveplan/api-plugin-contacts` meter scope.
|
||||
- Counter labels follow the `{ outcome: "success" | "error" }`
|
||||
convention used by sibling plugins (`api-plugin-user`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getContactsApiInstruments } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const inst = getContactsApiInstruments();
|
||||
inst.contactWritesTotal.add(1, { outcome: "success" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
AggregationTemporality,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getContactsApiInstruments,
|
||||
resetContactsApiInstruments,
|
||||
} from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetContactsApiInstruments();
|
||||
|
||||
const start = performance.now();
|
||||
getContactsApiInstruments().importDurationMs.record(
|
||||
performance.now() - start,
|
||||
{ outcome: "success" }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ContactsApiInstruments` — `readonly` bundle with the six
|
||||
instruments described above.
|
||||
- `getContactsApiInstruments(): ContactsApiInstruments` — returns the
|
||||
cached bundle, building it lazily.
|
||||
- `resetContactsApiInstruments(): void` — drops the cache. The next
|
||||
call rebinds against the current global `MeterProvider`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Histograms are declared with `unit: "ms"` so OTLP exporters
|
||||
emit a `unit` attribute downstream.
|
||||
- The `contactReadsTotal` counter has no `outcome` label because reads
|
||||
that fail are converted into 4xx/5xx by the route handler and never
|
||||
reach the counter increment.
|
||||
- The cache is process-global; in serverless deployments where one
|
||||
function instance handles a single request the lazy binding cost is
|
||||
paid exactly once.
|
||||
@@ -0,0 +1,79 @@
|
||||
# plugin/plugin — createContactsPlugin factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Returns the `PluginDescriptor<ContactsPluginConfig>` consumed by the
|
||||
`@suaveplan/api` plugin host. Runs the 5-step `onInit` lifecycle:
|
||||
|
||||
1. Validate the supplied options through Zod (`INVALID_INPUT` on fail).
|
||||
2. Resolve the host services (`app`, `db`, optional
|
||||
`openAPIRegistry`, optional `migrationRunner`).
|
||||
3. Apply the bundled SurrealQL schema unless
|
||||
`applySchemaOnInit: false`.
|
||||
4. Construct the three SurrealDB repositories and register them in
|
||||
the `ServiceRegistry`.
|
||||
5. Install a single mount-pattern middleware that injects repositories
|
||||
+ `eventBus` into every request context, then register the 18
|
||||
`/contacts` + `/groups` routes via `definePluginRoutes`.
|
||||
|
||||
The descriptor declares `dependencies: ["database", "auth"]` so the
|
||||
host topo-sorts after `@suaveplan/api-plugin-database` and
|
||||
`@suaveplan/api-plugin-auth`.
|
||||
|
||||
## Features
|
||||
|
||||
+ Single factory entry-point; idempotent within a process (re-calling
|
||||
with the same options is safe — every `onInit` build is a fresh
|
||||
bundle).
|
||||
+ ServiceRegistry slot registration: `contactRepository`,
|
||||
`groupRepository`, `membershipRepository`.
|
||||
+ Hono ContextVariableMap injection: same three slots + `eventBus`.
|
||||
+ Route-prefix collision detection raises
|
||||
`ROUTE_PREFIX_COLLISION`.
|
||||
+ Bare-mount fallback when `openAPIRegistry` is absent (routes
|
||||
still work; the OpenAPI document just excludes them).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createContactsPlugin } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const plugin = createContactsPlugin();
|
||||
// pluginManager.use(plugin);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createContactsPlugin } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const plugin = createContactsPlugin({
|
||||
routePrefix: "/api/v1",
|
||||
maxImportBatchSize: 500,
|
||||
maxGroupDepth: 5,
|
||||
enforceGroupDepth: true,
|
||||
applySchemaOnInit: false, // host has its own migration pipeline
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
+ `createContactsPlugin(options?: ContactsPluginOptions): PluginDescriptor<ContactsPluginConfig>`
|
||||
+ `runOnInit(ctx, config): Promise<void>` — the lifecycle body; exported
|
||||
for testability so tests can inject a hand-built `PluginContext`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
+ The mount-pattern is computed as `*` when `routePrefix === "/"` and
|
||||
`${prefix}/*` otherwise; this matches the convention used by the
|
||||
user plugin and avoids the off-by-one Hono ignores trailing `/`.
|
||||
+ ROUTE_PREFIX_COLLISION uses `${prefix}/contacts` as the probe so a
|
||||
second mount of this plugin (even with a different prefix on the
|
||||
same registry) trips the check.
|
||||
+ The descriptor's `defaults` slot is the post-Zod-parse
|
||||
`ContactsPluginConfig`, frozen by the schema's
|
||||
`passthrough: false` semantics.
|
||||
@@ -0,0 +1,72 @@
|
||||
# plugin/schema — bundled SurrealQL DDL
|
||||
|
||||
## Purpose
|
||||
|
||||
Inlined SurrealQL DDL for the three tables the contacts plugin owns:
|
||||
`contact`, `contact_group`, and `group_membership`. The constants in
|
||||
this module are bundled into the package's `dist/` so consumers
|
||||
never hit the filesystem at runtime; the `surql/tables/*.surql` files
|
||||
in the package root are the canonical editing source and are mirrored
|
||||
verbatim here. The integration test executes
|
||||
`CONTACTS_PLUGIN_SCHEMA` against a real SurrealDB testcontainer to
|
||||
catch drift.
|
||||
|
||||
`applyContactsSchema` exposes the apply entry-point used by the
|
||||
plugin's `onInit`. It prefers the host `MigrationRunner` (recording the
|
||||
apply in the host's migrations table) and falls back to direct
|
||||
`db.execute(...)` when no runner is present.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-table constants (`CONTACT_TABLE_DDL`,
|
||||
`CONTACT_GROUP_TABLE_DDL`, `GROUP_MEMBERSHIP_TABLE_DDL`) plus the
|
||||
combined `CONTACTS_PLUGIN_SCHEMA`.
|
||||
- Idempotent `DEFINE TABLE OVERWRITE … SCHEMAFULL` statements — safe
|
||||
to re-apply.
|
||||
- `applyContactsSchema({ migrationRunner?, db? })` raises typed
|
||||
failures: `SCHEMA_APPLY_FAILED` on execute failure,
|
||||
`SERVICE_NOT_AVAILABLE` when neither transport supplied.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { applyContactsSchema } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
await applyContactsSchema({ db });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
applyContactsSchema,
|
||||
CONTACTS_PLUGIN_SCHEMA,
|
||||
} from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
// Recording the apply via the host migration runner:
|
||||
await applyContactsSchema({ migrationRunner });
|
||||
|
||||
// Or pre-rendering the DDL for inspection / golden tests:
|
||||
console.log(CONTACTS_PLUGIN_SCHEMA);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CONTACT_TABLE_DDL: string`, `CONTACT_GROUP_TABLE_DDL: string`,
|
||||
`GROUP_MEMBERSHIP_TABLE_DDL: string` — per-table inline DDL.
|
||||
- `CONTACTS_PLUGIN_SCHEMA: string` — the three table DDLs joined by
|
||||
blank lines.
|
||||
- `ApplyContactsSchemaOptions` — `{ migrationRunner?, db? }`.
|
||||
- `applyContactsSchema(options): Promise<void>` — apply entry-point.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The DDL does not declare SurrealQL `PERMISSIONS` blocks; tenant
|
||||
scoping is enforced at the handler layer via `Principal.tenantId`.
|
||||
Operators wanting database-level enforcement can apply their own
|
||||
permission overlay after the table DDL.
|
||||
- `DEFINE INDEX` statements use `OVERWRITE` so re-applying the schema
|
||||
does not raise "index already exists" errors.
|
||||
- The migration record name `0001-contacts-plugin-schema` is stable
|
||||
across versions so the host migration table tracks the same logical
|
||||
apply across re-deploys.
|
||||
@@ -0,0 +1,90 @@
|
||||
# repositories/contact-repository — SurrealDB-backed ContactStore
|
||||
|
||||
## Purpose
|
||||
|
||||
Concrete implementation of the universal `ContactStore` interface
|
||||
from `@suaveplan/contacts`, backed by a connected
|
||||
`SurrealAdapter`. Wraps every adapter call in `try/catch` blocks that
|
||||
re-throw failures as `ContactsApiError` with the appropriate
|
||||
`CREATE_FAILED` / `UPDATE_FAILED` / `DELETE_FAILED` code so callers can
|
||||
discriminate without sniffing the underlying SurrealDB driver's
|
||||
exception text.
|
||||
|
||||
The repository is **stateless aside from the adapter handle** — the
|
||||
adapter's lifecycle (connect, disconnect, namespace selection) is the
|
||||
host's responsibility. Multiple repositories may share one adapter.
|
||||
|
||||
## Features
|
||||
|
||||
- Full `ContactStore` surface: `create`, `getById`, `update`, `delete`,
|
||||
`list` (search / status / channelType / tags / paging), `findByChannelValue`,
|
||||
`findByIds`, `countByTenant`.
|
||||
- Channel-typed list filter — `channels[WHERE type = $type]` uses
|
||||
Surreal's array projection so filtering is index-pushable.
|
||||
- Date hydration — string `createdAt` / `updatedAt` values returned by
|
||||
SurrealDB are parsed into native `Date` instances.
|
||||
- Bare ids (`abc`) are auto-prefixed with `contact:` so callers can pass
|
||||
either form.
|
||||
- Failures are wrapped in the typed `ContactsApiError` so HTTP handlers
|
||||
branch on `error.code`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { SurrealContactRepository } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const adapter = new SurrealAdapter({ url, namespace, database });
|
||||
await adapter.connect();
|
||||
const repo = new SurrealContactRepository(adapter);
|
||||
const c = await repo.create({
|
||||
tenantId: "tenant-1",
|
||||
displayName: "Ada Lovelace",
|
||||
channels: [
|
||||
{ type: "email", value: "ada@example.com", primary: true, verified: false },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SurrealContactRepository } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const repo = new SurrealContactRepository(adapter);
|
||||
// Paginated list with channelType filter:
|
||||
const page = await repo.list({
|
||||
tenantId: "tenant-1",
|
||||
channelType: "email",
|
||||
search: "@example.com",
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
// Bulk fetch by ids:
|
||||
const byIds = await repo.findByIds(page.contacts.map((c) => c.id));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SurrealContactRepository implements ContactStore` — constructor
|
||||
`(db: SurrealAdapter)`. Public methods:
|
||||
- `create(input: CreateContactInput): Promise<Contact>`
|
||||
- `getById(id: string): Promise<Contact | null>`
|
||||
- `update(id: string, input: UpdateContactInput): Promise<Contact>`
|
||||
- `delete(id: string): Promise<void>`
|
||||
- `list(query: ContactQuery): Promise<ContactListResult>`
|
||||
- `findByChannelValue(tenantId, channelType, channelValue): Promise<Contact | null>`
|
||||
- `findByIds(ids: readonly string[]): Promise<Contact[]>`
|
||||
- `countByTenant(tenantId: string): Promise<number>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `list` builds the WHERE clause with positional `$name` parameters and
|
||||
passes them to `SurrealAdapter.query` for prepared-statement style
|
||||
binding — no string interpolation of user input.
|
||||
- `findByIds([])` short-circuits with an empty result and never touches
|
||||
the adapter so callers don't pay for a SurrealDB round-trip on an
|
||||
empty input.
|
||||
- `mapContact` strips `null` first/last names before they hit the
|
||||
returned `Contact` (the type marks them optional), matching the
|
||||
universal `InMemoryContactStore` shape exactly.
|
||||
@@ -0,0 +1,91 @@
|
||||
# repositories/group-repository — SurrealDB-backed GroupStore
|
||||
|
||||
## Purpose
|
||||
|
||||
Concrete implementation of the `GroupStore` interface from
|
||||
`@suaveplan/contacts`, backed by `SurrealAdapter`. Implements the
|
||||
hybrid adjacency-list + materialised-path strategy for hierarchical
|
||||
groups: every row carries `parentId`, `path`, and `depth`, so subtree
|
||||
queries become `WHERE string::contains(path, $path)` and lookups of
|
||||
direct children become `WHERE parentId = $parentId`.
|
||||
|
||||
Enforces the plugin's configured `maxGroupDepth` on `createGroup` and
|
||||
`moveGroup` when `enforceGroupDepth` is true — closing the upstream
|
||||
gap where the config was advertised but never enforced.
|
||||
|
||||
## Features
|
||||
|
||||
- Full `GroupStore` surface: `generateId`, `create`, `getById`,
|
||||
`update`, `delete`, `list`, `getChildren`, `getDescendants`,
|
||||
`getRoots`, `findByIds`.
|
||||
- Domain-friendly `createGroup` that computes `path` and `depth` from
|
||||
the optional parent, plus a low-level `create` that takes a
|
||||
pre-built `Group`.
|
||||
- `moveGroup(id, newParentId, tenantId)` — reparent with depth +
|
||||
path recomputation and depth-cap enforcement.
|
||||
- Bare ids (`g1`) are auto-prefixed with `contact_group:`.
|
||||
- Configurable `maxGroupDepth` + `enforceGroupDepth` via constructor
|
||||
options.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealGroupRepository } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const repo = new SurrealGroupRepository(adapter, {
|
||||
maxGroupDepth: 10,
|
||||
enforceGroupDepth: true,
|
||||
});
|
||||
const root = await repo.createGroup({ tenantId: "t", name: "All" });
|
||||
const child = await repo.createGroup({
|
||||
tenantId: "t",
|
||||
name: "Engineering",
|
||||
parentId: root.id,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SurrealGroupRepository } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const repo = new SurrealGroupRepository(adapter, {
|
||||
maxGroupDepth: 5,
|
||||
enforceGroupDepth: true,
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
// Reparent — throws GROUP_DEPTH_EXCEEDED if depth would exceed 5:
|
||||
const moved = await repo.moveGroup(child.id, otherParentId, "t");
|
||||
|
||||
// Walk a subtree:
|
||||
const descendants = await repo.getDescendants(root.path);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SurrealGroupRepositoryOptions` — `{ maxGroupDepth?,
|
||||
enforceGroupDepth?, idGenerator? }`.
|
||||
- `class SurrealGroupRepository implements GroupStore` — constructor
|
||||
`(db: SurrealAdapter, options?: SurrealGroupRepositoryOptions)`.
|
||||
Adds two domain methods on top of the interface:
|
||||
- `createGroup(input: CreateGroupInput): Promise<Group>` — computes
|
||||
path/depth + enforces depth cap.
|
||||
- `moveGroup(id, newParentId, tenantId): Promise<Group>` —
|
||||
reparent + depth cap.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The two-step write (`CREATE` then `UPDATE path`) is used because
|
||||
SurrealDB assigns the record id at `CREATE` time, and the
|
||||
materialised path needs the actual id. The temporary id is stripped
|
||||
in the follow-up patch.
|
||||
- `getDescendants` uses `string::contains(path, $path) AND path !=
|
||||
$path` so the root itself is not included in the result set.
|
||||
- Constructor option `enforceGroupDepth` defaults to `false` so the
|
||||
bare-class behaviour matches the universal `InMemoryGroupStore`
|
||||
in `@suaveplan/contacts`; the plugin factory sets it to `true` by
|
||||
default to enforce the documented config.
|
||||
- `pickFirst` raises `CREATE_FAILED` when SurrealDB returns no row;
|
||||
this catches database-side rejections (assertion failure on a NOT
|
||||
NULL field, for example) that the driver swallowed into an empty
|
||||
array.
|
||||
@@ -0,0 +1,73 @@
|
||||
# repositories/membership-repository — SurrealDB-backed MembershipStore
|
||||
|
||||
## Purpose
|
||||
|
||||
Concrete implementation of the `MembershipStore` interface
|
||||
from `@suaveplan/contacts`, backed by `SurrealAdapter`. Persists the
|
||||
many-to-many `(groupId, contactId)` join with a `UNIQUE` SurrealQL
|
||||
index that raises `MEMBERSHIP_EXISTS` on duplicate insertion.
|
||||
|
||||
The repository owns no business logic — idempotent add semantics are
|
||||
implemented by the route layer (`POST /groups/:id/members` checks
|
||||
`exists` before adding so the second call returns `{ added: 0 }`
|
||||
without surfacing a `MEMBERSHIP_EXISTS` error).
|
||||
|
||||
## Features
|
||||
|
||||
- Full `MembershipStore` surface: `generateId`, `add`, `remove`,
|
||||
`getByGroup`, `getByContact`, `exists`, `removeAllForContact`,
|
||||
`removeAllForGroup`, `getContactIdsByGroups`, `countByGroup`.
|
||||
- Sniffs SurrealDB unique-index violations and translates them into
|
||||
`ContactsApiError(MEMBERSHIP_EXISTS)`; other adapter errors become
|
||||
`CREATE_FAILED` / `DELETE_FAILED`.
|
||||
- `getContactIdsByGroups([])` short-circuits to avoid a useless
|
||||
database round-trip.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SurrealMembershipRepository } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const repo = new SurrealMembershipRepository(adapter);
|
||||
await repo.add({
|
||||
id: repo.generateId(),
|
||||
groupId: "contact_group:engineering",
|
||||
contactId: "contact:ada",
|
||||
addedAt: new Date(),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { SurrealMembershipRepository } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const repo = new SurrealMembershipRepository(adapter, {
|
||||
idGenerator: () => crypto.randomUUID(),
|
||||
});
|
||||
|
||||
const contactIds = await repo.getContactIdsByGroups([
|
||||
"contact_group:engineering",
|
||||
"contact_group:sales",
|
||||
]);
|
||||
const unique = Array.from(new Set(contactIds));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SurrealMembershipRepositoryOptions` — `{ idGenerator? }`.
|
||||
- `class SurrealMembershipRepository implements MembershipStore` —
|
||||
constructor `(db: SurrealAdapter, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `add` inspects the thrown error's message for the substrings
|
||||
`unique`, `UNIQUE`, or `duplicate` to classify a unique-index
|
||||
violation. SurrealDB's exact message format has drifted between
|
||||
versions; the substring approach survives the most common variants.
|
||||
- `exists` uses `count() AS count … GROUP ALL` rather than
|
||||
`SELECT … LIMIT 1` because `count` is consistently zero-or-one in
|
||||
SurrealDB and avoids deserialising the row.
|
||||
- `mapMembership` preserves the original `metadata` field shape —
|
||||
`undefined` stays `undefined` rather than being coerced to `{}`, so
|
||||
callers can distinguish "no metadata" from "empty metadata".
|
||||
@@ -0,0 +1,61 @@
|
||||
# routes/contacts/build-routes — /contacts route aggregator
|
||||
|
||||
## Purpose
|
||||
|
||||
Returns the seven `PluginRouteEntry` values that compose the
|
||||
`/contacts` route tree. Mounted by the plugin's `onInit` via
|
||||
`definePluginRoutes` from `@suaveplan/api-openapi`. The aggregator is
|
||||
the single place that names the route order, so `definePluginRoutes`
|
||||
gets a deterministic shape.
|
||||
|
||||
The individual route builders live in `contacts-crud-routes.ts` (list,
|
||||
create, get, update, delete) and `contacts-bulk-routes.ts` (import,
|
||||
merge); splitting them keeps each file inside the 250-line soft
|
||||
budget. The aggregator's only responsibility is to call them in order
|
||||
and pass through the runtime-bound `maxImportBatchSize` for the import
|
||||
schema cap.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns the entries in canonical mounting order: `list`, `create`,
|
||||
`get`, `update`, `delete`, `import`, `merge`.
|
||||
- Forwards `maxImportBatchSize` to the import route so the Zod
|
||||
boundary uses the plugin's runtime config.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildContactRoutes } from "@suaveplan/api-plugin-contacts/routes/contacts/build-routes";
|
||||
|
||||
const entries = buildContactRoutes({ maxImportBatchSize: 1000 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
import { buildContactRoutes } from "@suaveplan/api-plugin-contacts/routes/contacts/build-routes";
|
||||
|
||||
definePluginRoutes(buildContactRoutes({ maxImportBatchSize: 500 }), {
|
||||
app,
|
||||
registry: openAPIRegistry,
|
||||
pluginName: "contacts",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `BuildContactRoutesOptions` — `{ maxImportBatchSize: number }`.
|
||||
- `buildContactRoutes(options): readonly PluginRouteEntry[]` — returns
|
||||
the 7 contact route entries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The function is intentionally pure and synchronous; no `await` or
|
||||
side effect. All async work happens inside the per-route handlers.
|
||||
- `maxImportBatchSize` is captured by the import builder when it is
|
||||
invoked — the Zod schema is minted per plugin-instance so changing
|
||||
the config across instances does not leak between them.
|
||||
- The route paths use Hono's `:id` syntax rather than OpenAPI's `{id}`
|
||||
because `@hono/zod-openapi` translates Hono paths to OpenAPI at
|
||||
registration time.
|
||||
@@ -0,0 +1,66 @@
|
||||
# routes/contacts/contacts-bulk-routes — import + merge
|
||||
|
||||
## Purpose
|
||||
|
||||
Two `PluginRouteEntry` builders for the bulk-operation contact
|
||||
endpoints: `POST /contacts/import` and `POST /contacts/merge`. Both
|
||||
emit OTel histograms (`importDurationMs`, `mergeDurationMs`) carrying
|
||||
the wall-time of the operation labelled by outcome.
|
||||
|
||||
The import builder accepts a runtime `maxImportBatchSize` so the Zod
|
||||
boundary enforces the cap at parse time (HTTP 400 with `IMPORT_TOO_LARGE`)
|
||||
rather than after spending CPU on a too-large body.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildImportContactsRoute(maxImportBatchSize)` — minted per
|
||||
plugin-instance with the configured cap.
|
||||
- `buildMergeContactsRoute()` — channel + tag union with primary
|
||||
contact preserved; secondaries deleted.
|
||||
- Per-row error reporting for import (`{ index, error }`).
|
||||
- `skipDuplicates: true` consults `findByChannelValue` on the first
|
||||
channel of each row.
|
||||
- Both handlers raise `ContactsApiError` on every failure path; the
|
||||
host's `onError` middleware maps `statusCode` → HTTP status.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildImportContactsRoute } from "@suaveplan/api-plugin-contacts/routes/contacts/contacts-bulk-routes";
|
||||
|
||||
const entry = buildImportContactsRoute(1000);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildImportContactsRoute,
|
||||
buildMergeContactsRoute,
|
||||
} from "@suaveplan/api-plugin-contacts/routes/contacts/contacts-bulk-routes";
|
||||
|
||||
const entries = [
|
||||
buildImportContactsRoute(500),
|
||||
buildMergeContactsRoute(),
|
||||
];
|
||||
// definePluginRoutes(entries, …)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildImportContactsRoute(maxImportBatchSize: number): PluginRouteEntry`.
|
||||
- `buildMergeContactsRoute(): PluginRouteEntry`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The import handler distinguishes `IMPORT_TOO_LARGE` from generic
|
||||
`INVALID_INPUT` by inspecting `parsed.error.issues` for any
|
||||
`code === "too_big"` issue. This means callers see the discriminating
|
||||
error code rather than a generic 400.
|
||||
- The merge handler unions channels by `(type, value)` so duplicate
|
||||
channels from secondaries are silently dropped; primary channels keep
|
||||
their `primary: true` flag and incoming channels are demoted to
|
||||
`primary: false`.
|
||||
- Histograms record the wall-time on both success and failure outcomes;
|
||||
the failure path records `0` so the distribution of error responses
|
||||
is visible in the dashboard.
|
||||
@@ -0,0 +1,69 @@
|
||||
# routes/contacts/contacts-crud-routes — CRUD route builders
|
||||
|
||||
## Purpose
|
||||
|
||||
Five `PluginRouteEntry` builders for the contact CRUD endpoints:
|
||||
`GET /contacts`, `POST /contacts`, `GET /contacts/:id`,
|
||||
`PATCH /contacts/:id`, `DELETE /contacts/:id`. Each builder produces
|
||||
an `@suaveplan/api-openapi`-shaped entry that the plugin mounts via
|
||||
`definePluginRoutes`.
|
||||
|
||||
Every handler shares the same skeleton: `requireTenant(c)` for
|
||||
auth, `c.get("contactRepository")` (typed via the Hono ContextVariableMap
|
||||
augmentation) for storage, Zod-validate the body, perform the op, and
|
||||
emit the appropriate OTel instrument.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-route entry builders: `buildListContactsRoute`,
|
||||
`buildCreateContactRoute`, `buildGetContactRoute`,
|
||||
`buildUpdateContactRoute`, `buildDeleteContactRoute`.
|
||||
- All paths under `/contacts` so the parent route prefix is the
|
||||
plugin's `routePrefix`.
|
||||
- OTel: every write increments `contactWritesTotal{outcome}` and every
|
||||
read increments `contactReadsTotal`.
|
||||
- All errors thrown — no in-handler `c.json({ success: false })`
|
||||
branches. The host's `onError` middleware maps `ContactsApiError`
|
||||
to the right HTTP status via `error.context.statusCode`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildListContactsRoute } from "@suaveplan/api-plugin-contacts/routes/contacts/build-routes";
|
||||
|
||||
const { route, handler } = buildListContactsRoute();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
import {
|
||||
buildListContactsRoute,
|
||||
buildCreateContactRoute,
|
||||
} from "@suaveplan/api-plugin-contacts/routes/contacts/contacts-crud-routes";
|
||||
|
||||
definePluginRoutes(
|
||||
[buildListContactsRoute(), buildCreateContactRoute()],
|
||||
{ app, registry, pluginName: "contacts" }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildListContactsRoute(): PluginRouteEntry` — `GET /contacts`.
|
||||
- `buildCreateContactRoute(): PluginRouteEntry` — `POST /contacts`.
|
||||
- `buildGetContactRoute(): PluginRouteEntry` — `GET /contacts/:id`.
|
||||
- `buildUpdateContactRoute(): PluginRouteEntry` — `PATCH /contacts/:id`.
|
||||
- `buildDeleteContactRoute(): PluginRouteEntry` — `DELETE /contacts/:id`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The handler factory re-parses the body with Zod even though the
|
||||
RouteBuilder declares the body schema; this guards against callers
|
||||
who mount the entry on a Hono app without the OpenAPI middleware.
|
||||
- `getById` failures are categorised as `NOT_FOUND` not `UNAUTHORIZED`
|
||||
to avoid leaking tenant-existence information across tenants.
|
||||
- The list handler runs Zod against the raw query strings (rather than
|
||||
trusting `@hono/zod-openapi`'s automatic decoder) so the bare-mount
|
||||
test code path exercises the same parse logic.
|
||||
@@ -0,0 +1,65 @@
|
||||
# routes/groups/build-routes — /groups route aggregator
|
||||
|
||||
## Purpose
|
||||
|
||||
Returns the 11 `PluginRouteEntry` values that compose the `/groups`
|
||||
route tree: 5 CRUD (`list`, `create`, `get`, `update`, `delete`), 2
|
||||
hierarchy (`tree`, `move`), and 4 membership-and-audience (`list
|
||||
members`, `add members`, `remove members`, `resolve`). Mounted by the
|
||||
plugin via `definePluginRoutes`.
|
||||
|
||||
The aggregator is purely structural; each route lives in a topic-
|
||||
focused file (`groups-crud-routes.ts`, `groups-tree-routes.ts`,
|
||||
`groups-members-routes.ts`) to stay inside the 250-line budget.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns entries in canonical mounting order.
|
||||
- Pure / synchronous; no per-request state.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildGroupRoutes } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const entries = buildGroupRoutes();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
import { buildGroupRoutes } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
definePluginRoutes(buildGroupRoutes(), {
|
||||
app,
|
||||
registry,
|
||||
pluginName: "contacts",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildGroupRoutes(): readonly PluginRouteEntry[]` — 11 entries.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The ordering matters for OpenAPI document generation: nested paths
|
||||
like `/groups/:id/tree` come after the bare `/groups/:id` entries so
|
||||
the registry registers the more-specific route last (Hono treats
|
||||
the entries as registration-ordered).
|
||||
- The aggregator does not take any options because all per-instance
|
||||
config (e.g. `maxGroupDepth`) is read from the request context's
|
||||
`groupRepository`, which is already configured at plugin onInit.
|
||||
- The 11 entries are split across three topic-focused files
|
||||
(`groups-crud-routes.ts`, `groups-tree-routes.ts`,
|
||||
`groups-members-routes.ts`) so each remains inside the 250-line
|
||||
budget while keeping the aggregator a flat list. Adding a new route
|
||||
is therefore a single-line change here plus the corresponding
|
||||
builder in the appropriate topic file.
|
||||
- Every handler returned by the per-topic builders uses the same
|
||||
skeleton — `requireTenant(c)` for auth, `c.get("groupRepository")`
|
||||
(typed via the Hono `ContextVariableMap` augmentation) for storage,
|
||||
and a `ContactsApiError` throw for every failure path. The host's
|
||||
error-handler middleware maps `error.context.statusCode` onto the
|
||||
HTTP response status.
|
||||
@@ -0,0 +1,67 @@
|
||||
# routes/groups/groups-crud-routes — CRUD route builders
|
||||
|
||||
## Purpose
|
||||
|
||||
Five `PluginRouteEntry` builders for the group CRUD endpoints:
|
||||
`GET /groups`, `POST /groups`, `GET /groups/:id`, `PATCH /groups/:id`,
|
||||
`DELETE /groups/:id`. Each handler reads the `groupRepository` (and,
|
||||
for delete, the `membershipRepository`) from the typed Hono
|
||||
`ContextVariableMap` and emits `groupWritesTotal` for write operations.
|
||||
|
||||
`DELETE /groups/:id` cascades through the membership repository so
|
||||
member rows for the deleted group are removed first; this preserves
|
||||
the database invariant that a `group_membership` row never points at
|
||||
a missing group.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-route entry builders: `buildListGroupsRoute`,
|
||||
`buildCreateGroupRoute`, `buildGetGroupRoute`,
|
||||
`buildUpdateGroupRoute`, `buildDeleteGroupRoute`.
|
||||
- `POST /groups` delegates to `repo.createGroup(...)` so
|
||||
`maxGroupDepth` enforcement is centralised in the repository.
|
||||
- `DELETE /groups/:id` removes member rows before the group itself.
|
||||
- Successful writes emit `groupWritesTotal{outcome="success"}`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildCreateGroupRoute } from "@suaveplan/api-plugin-contacts/routes/groups/groups-crud-routes";
|
||||
|
||||
const entry = buildCreateGroupRoute();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
import {
|
||||
buildListGroupsRoute,
|
||||
buildCreateGroupRoute,
|
||||
buildGetGroupRoute,
|
||||
} from "@suaveplan/api-plugin-contacts/routes/groups/groups-crud-routes";
|
||||
|
||||
definePluginRoutes(
|
||||
[buildListGroupsRoute(), buildCreateGroupRoute(), buildGetGroupRoute()],
|
||||
{ app, registry, pluginName: "contacts" }
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildListGroupsRoute(): PluginRouteEntry` — `GET /groups`.
|
||||
- `buildCreateGroupRoute(): PluginRouteEntry` — `POST /groups`.
|
||||
- `buildGetGroupRoute(): PluginRouteEntry` — `GET /groups/:id`.
|
||||
- `buildUpdateGroupRoute(): PluginRouteEntry` — `PATCH /groups/:id`.
|
||||
- `buildDeleteGroupRoute(): PluginRouteEntry` — `DELETE /groups/:id`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `toResponseGroup` serialises `Date` instances on the way out so the
|
||||
wire format matches `GroupResponseSchema`. The bare repository
|
||||
return values are kept as `Date` for downstream use.
|
||||
- Tenant scoping: every handler re-checks `group.tenantId === tenantId`
|
||||
after fetching the row so cross-tenant lookups return 404 (not 403);
|
||||
the upstream port made the same call to avoid leaking existence.
|
||||
- The cascading membership delete uses `removeAllForGroup`, which
|
||||
bypasses the per-row uniqueness machinery for speed.
|
||||
@@ -0,0 +1,71 @@
|
||||
# routes/groups/groups-members-routes — membership routes
|
||||
|
||||
## Purpose
|
||||
|
||||
Four `PluginRouteEntry` builders for the group-membership endpoints:
|
||||
`GET /groups/:id/members`, `POST /groups/:id/members`,
|
||||
`DELETE /groups/:id/members`, and `GET /groups/:id/resolve` (the
|
||||
audience-resolution endpoint that flattens a group + its descendants
|
||||
into a deduplicated contact list).
|
||||
|
||||
`POST` and `DELETE` are idempotent: the handlers consult `exists`
|
||||
before each `add` / `remove` so the second call returns `{ added: 0 }`
|
||||
or `{ removed: 0 }` rather than surfacing `MEMBERSHIP_EXISTS` /
|
||||
`NOT_FOUND` from the repository.
|
||||
|
||||
## Features
|
||||
|
||||
- `buildListMembersRoute()` — list with `total`.
|
||||
- `buildAddMembersRoute()` — idempotent add; emits
|
||||
`membershipWritesTotal` with the actual added count.
|
||||
- `buildRemoveMembersRoute()` — idempotent remove; emits the actual
|
||||
removed count.
|
||||
- `buildResolveGroupRoute()` — walks descendants, queries
|
||||
membership, dedup-fetches contacts.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildAddMembersRoute } from "@suaveplan/api-plugin-contacts/routes/groups/groups-members-routes";
|
||||
|
||||
const entry = buildAddMembersRoute();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildListMembersRoute,
|
||||
buildAddMembersRoute,
|
||||
buildRemoveMembersRoute,
|
||||
buildResolveGroupRoute,
|
||||
} from "@suaveplan/api-plugin-contacts/routes/groups/groups-members-routes";
|
||||
|
||||
const entries = [
|
||||
buildListMembersRoute(),
|
||||
buildAddMembersRoute(),
|
||||
buildRemoveMembersRoute(),
|
||||
buildResolveGroupRoute(),
|
||||
];
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildListMembersRoute(): PluginRouteEntry` — `GET /groups/:id/members`.
|
||||
- `buildAddMembersRoute(): PluginRouteEntry` — `POST /groups/:id/members`.
|
||||
- `buildRemoveMembersRoute(): PluginRouteEntry` — `DELETE /groups/:id/members`.
|
||||
- `buildResolveGroupRoute(): PluginRouteEntry` — `GET /groups/:id/resolve`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The idempotent add uses `memberRepo.exists(id, contactId)` and
|
||||
generates membership ids via `memberRepo.generateId()`, so the
|
||||
database does not need a SurrealQL-side existence check for the
|
||||
happy path.
|
||||
- The resolver builds the `allGroupIds` array from the requested group
|
||||
plus its descendants (returned by `getDescendants(path)`); contact ids
|
||||
are deduplicated via `new Set(...)` before the bulk `findByIds`
|
||||
fetch.
|
||||
- The resolver's response counts `groupsResolved` from
|
||||
`allGroupIds.length`, not the number of contacts — useful for
|
||||
audience-size estimation in upstream UIs.
|
||||
@@ -0,0 +1,67 @@
|
||||
# routes/groups/groups-tree-routes — hierarchy routes
|
||||
|
||||
## Purpose
|
||||
|
||||
Two `PluginRouteEntry` builders for the hierarchical-group endpoints:
|
||||
`GET /groups/:id/tree` materialises a typed `GroupTreeNode` subtree
|
||||
from the `getDescendants(path)` query, and `POST /groups/:id/move`
|
||||
reparents a group, enforcing `maxGroupDepth` at the repository layer.
|
||||
|
||||
The tree builder uses `buildTree` from `../tree/build-tree` rather
|
||||
than reimplementing the recursion inline — preserving full type
|
||||
safety from row to JSON.
|
||||
|
||||
## Features
|
||||
|
||||
- Tree response is a fully-typed `GroupTreeNode` (no `any`).
|
||||
- Move handler delegates to `repo.moveGroup(...)` so depth enforcement
|
||||
is centralised; failures surface as `CONTACTS_API.GROUP_DEPTH_EXCEEDED`.
|
||||
- Both routes serialise `Date` fields recursively before responding.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildGroupTreeRoute, buildMoveGroupRoute } from "@suaveplan/api-plugin-contacts/routes/groups/groups-tree-routes";
|
||||
|
||||
const tree = buildGroupTreeRoute();
|
||||
const move = buildMoveGroupRoute();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Inside a test:
|
||||
const res = await app.request("/groups/g1/move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ newParentId: null }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildGroupTreeRoute(): PluginRouteEntry` — `GET /groups/:id/tree`.
|
||||
- `buildMoveGroupRoute(): PluginRouteEntry` — `POST /groups/:id/move`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The tree wrap function recursively converts `Date` fields. It's
|
||||
declared as a local closure inside the handler to keep the helper
|
||||
scoped to a single response.
|
||||
- `moveGroup` failures (including `GROUP_DEPTH_EXCEEDED`) are caught
|
||||
and re-thrown after incrementing `groupWritesTotal{outcome="error"}`
|
||||
so observability matches the success path.
|
||||
- The tree handler walks the descendant list once into a parent-id
|
||||
bucket map then recurses; with N descendants the total work is
|
||||
O(N). The recursion ends at the leaves where `byParent.get(id)`
|
||||
returns `undefined`, which the `?? []` coalesces to an empty array.
|
||||
- The move handler delegates depth enforcement to
|
||||
`SurrealGroupRepository.moveGroup`; both the depth-cap check and
|
||||
the SurrealDB `UPDATE` happen inside the repository so the route
|
||||
stays a thin Zod + auth + counter wrapper.
|
||||
- Tenant scoping: the move repository checks `group.tenantId ===
|
||||
tenantId` and raises `NOT_FOUND` (not `FORBIDDEN`) on a mismatch
|
||||
so the response does not leak existence information across
|
||||
tenants. The tree handler performs the same check before walking
|
||||
descendants.
|
||||
@@ -0,0 +1,69 @@
|
||||
# routes/shared/require-tenant — Principal → tenantId guard
|
||||
|
||||
## Purpose
|
||||
|
||||
Centralised guard that extracts and validates the request `Principal`
|
||||
on every `/contacts` and `/groups` handler. Returns a typed
|
||||
`{ id, tenantId }` pair when the principal is well-formed (non-empty
|
||||
`tenantId`) and throws `ContactsApiError(UNAUTHORIZED)` mapped to HTTP
|
||||
401 otherwise.
|
||||
|
||||
Replaces the upstream port's repeated four-line `if (!user?.tenantId)
|
||||
return c.json(...)` pattern (and its `as any` cast on the `user`
|
||||
context slot). One helper, one error path, one tested invariant.
|
||||
|
||||
## Features
|
||||
|
||||
- Structural-typing the principal: any `{ id?, tenantId? }` shape is
|
||||
accepted, so the helper does not depend on a specific Better-Auth
|
||||
configuration.
|
||||
- Single, typed error path via `ContactsApiError`.
|
||||
- Defaults missing `id` to `""` to keep handlers from leaking
|
||||
`undefined` into downstream calls.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requireTenant } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
async function handler(c: Context) {
|
||||
const { id, tenantId } = requireTenant(c);
|
||||
return c.json({ ok: true, tenantId });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { requireTenant } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
async function listContactsHandler(c: Context) {
|
||||
const { tenantId } = requireTenant(c);
|
||||
const repo = c.get("contactRepository");
|
||||
if (repo === undefined) {
|
||||
throw new Error("contactRepository missing");
|
||||
}
|
||||
const result = await repo.list({ tenantId, limit: 50 });
|
||||
return c.json({ success: true as const, data: result });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface TenantPrincipal` — `{ readonly id: string; readonly tenantId: string }`.
|
||||
- `requireTenant(c: Context): TenantPrincipal` — extracts + validates;
|
||||
throws `ContactsApiError(CONTACTS_API.UNAUTHORIZED)` with HTTP-401
|
||||
context on failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The helper reads `c.get("user")` typed as the universal Principal
|
||||
shape via the `@suaveplan/api-plugin-auth` module augmentation; this
|
||||
package does not re-augment Hono's variable map for `user` (the auth
|
||||
plugin owns that slot).
|
||||
- The four guards (null, undefined, non-string tenantId, empty
|
||||
tenantId) are exercised by separate `it(...)` blocks so every branch
|
||||
is covered.
|
||||
- `id` defaulting to `""` when missing keeps later GDPR audit logs
|
||||
from carrying `"undefined"` strings; downstream code that needs a
|
||||
real id should re-check.
|
||||
@@ -0,0 +1,59 @@
|
||||
# routes/shared/strip-undefined — drop undefined-valued keys
|
||||
|
||||
## Purpose
|
||||
|
||||
Bridges Zod's optional-field semantics
|
||||
(`.optional() → string | undefined`) with the suaveplan
|
||||
`@suaveplan/contacts` input shapes, which use
|
||||
`exactOptionalPropertyTypes: true` and therefore forbid the
|
||||
`| undefined` half of optional members. Returning a shallow copy with
|
||||
`undefined` keys removed lets the route handlers feed Zod output
|
||||
directly into `repo.create({ ...stripUndefined(parsed.data), tenantId })`
|
||||
without losing type safety.
|
||||
|
||||
Without this helper every route handler would need to manually
|
||||
re-build the input object key-by-key, or every Zod schema would have
|
||||
to redefine its optionality via `.transform()`. The helper is the
|
||||
focused fix.
|
||||
|
||||
## Features
|
||||
|
||||
- Shallow — preserves nested undefineds because the consumer Zod
|
||||
schemas only produce top-level optionals.
|
||||
- Preserves `null`, `0`, `""`, and `false` (only `undefined` is dropped).
|
||||
- Pure / type-safe — returns `Partial<T>`-compatible shape.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { stripUndefined } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const parsed = CreateContactSchema.parse(input);
|
||||
const repoInput = stripUndefined(parsed);
|
||||
await repo.create({ ...repoInput, tenantId });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { stripUndefined } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
function safeMerge<T extends Record<string, unknown>>(base: T, patch: Partial<T>): T {
|
||||
return { ...base, ...stripUndefined(patch) } as T;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `stripUndefined<T>(input: T): { [K in keyof T]?: T[K] }` — shallow
|
||||
copy with `undefined` keys omitted.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Uses `Object.entries` so symbol-keyed properties are ignored; route
|
||||
inputs are always plain objects so this is fine.
|
||||
- Does not deep-clone — the returned object shares nested references
|
||||
with the input.
|
||||
- The return type widens to `Partial<T>` for ergonomics; the runtime
|
||||
contract is stricter (any non-`undefined` original value flows
|
||||
through unchanged).
|
||||
@@ -0,0 +1,74 @@
|
||||
# schemas/contacts-schemas — request/response Zod schemas for /contacts
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas mounted on every `/contacts` route via
|
||||
`@hono/zod-openapi`. They validate inbound bodies / query strings,
|
||||
shape outbound JSON responses, and feed the generated `/openapi.json`
|
||||
document so the API surface is self-describing without hand-written
|
||||
OpenAPI YAML.
|
||||
|
||||
Splitting these schemas out of the route file means they can be
|
||||
consumed by adjacent generators (TypeScript SDKs, JSON Schema dumpers,
|
||||
fixture builders) without pulling in the Hono runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- Boundary-typed inputs: `CreateContactSchema`, `UpdateContactSchema`,
|
||||
`ContactQuerySchema`, `MergeContactsSchema`, plus
|
||||
`buildBulkImportSchema(maxBatchSize)` for the size-bounded import.
|
||||
- Boundary-typed outputs: `ContactResponseSchema`,
|
||||
`ContactListResponseSchema`, `BulkImportResponseSchema`,
|
||||
`MergeResponseSchema`.
|
||||
- `ContactChannelSchema` defaults `primary` and `verified` to `false`
|
||||
to match the SurrealQL schema defaults.
|
||||
- `ContactQuerySchema` coerces query-string numeric values (`limit`,
|
||||
`offset`) so callers can use plain strings.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CreateContactSchema } from "@suaveplan/api-plugin-contacts/schemas/contacts-schemas";
|
||||
|
||||
const body = CreateContactSchema.parse({
|
||||
displayName: "Ada",
|
||||
channels: [{ type: "email", value: "ada@example.com" }],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { buildBulkImportSchema } from "@suaveplan/api-plugin-contacts/schemas/contacts-schemas";
|
||||
|
||||
const schema = buildBulkImportSchema(500);
|
||||
const result = schema.safeParse({ contacts: rows, skipDuplicates: true });
|
||||
if (!result.success) {
|
||||
// 400 with Zod issues
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ContactChannelSchema`, `ContactStatusSchema` — leaf primitives.
|
||||
- `CreateContactSchema`, `UpdateContactSchema`, `ContactQuerySchema`,
|
||||
`MergeContactsSchema` — request bodies / query params.
|
||||
- `ContactResponseSchema`, `ContactListResponseSchema`,
|
||||
`BulkImportResponseSchema`, `MergeResponseSchema` — response shapes.
|
||||
- `buildBulkImportSchema(maxBatchSize)` — factory that returns the
|
||||
per-instance bulk-import body schema with the supplied cap.
|
||||
- `BulkImportSchema` — pre-built variant at the default cap (1000) used
|
||||
for OpenAPI introspection.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Per-instance bulk-import schemas are built inside
|
||||
`createContactsPlugin` so the runtime `maxImportBatchSize` config
|
||||
value drives Zod's `.max()`. This means rejection happens at the
|
||||
schema boundary (HTTP 400 with Zod issues) rather than inside the
|
||||
handler.
|
||||
- Response schemas treat dates as ISO strings; repositories serialise
|
||||
`Date` instances into ISO format on the way out.
|
||||
- Empty-object metadata is permitted (`z.record(z.string(),
|
||||
z.unknown())`) to allow tenants to extend the contact shape without
|
||||
breaking the schema.
|
||||
@@ -0,0 +1,78 @@
|
||||
# schemas/groups-schemas — request/response Zod schemas for /groups
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas for the seven `/groups` routes (list, create, get, update,
|
||||
delete, tree, move) plus the three `/groups/:id/members` and the
|
||||
`/groups/:id/resolve` audience endpoint. Mounted on `OpenAPIHono` via
|
||||
`@hono/zod-openapi` so request validation, response shaping, and
|
||||
`/openapi.json` document generation share one declaration.
|
||||
|
||||
The recursive `GroupTreeNodeSchema` mirrors the `GroupTreeNode` type
|
||||
from `@suaveplan/contacts`; the lazy-self-reference pattern is the
|
||||
canonical way to express a recursive shape in Zod.
|
||||
|
||||
## Features
|
||||
|
||||
- Inputs: `CreateGroupSchema`, `UpdateGroupSchema`, `GroupQuerySchema`,
|
||||
`MoveGroupSchema`, `AddMembersSchema`, `RemoveMembersSchema`.
|
||||
- Outputs: `GroupResponseSchema`, `GroupListResponseSchema`,
|
||||
`GroupTreeNodeSchema`, `MembersListResponseSchema`,
|
||||
`ResolvedAudienceSchema`, `MembershipResponseSchema`.
|
||||
- `AddMembersSchema` / `RemoveMembersSchema` cap batch size at 500
|
||||
contact ids per request to keep a single round-trip predictable.
|
||||
- `MoveGroupSchema` accepts an explicit `null` parent for root reparent.
|
||||
- `GroupQuerySchema` coerces numeric query parameters.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CreateGroupSchema } from "@suaveplan/api-plugin-contacts/schemas/groups-schemas";
|
||||
|
||||
const body = CreateGroupSchema.parse({
|
||||
name: "Engineering",
|
||||
description: "all engineers",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AddMembersSchema,
|
||||
GroupTreeNodeSchema,
|
||||
} from "@suaveplan/api-plugin-contacts/schemas/groups-schemas";
|
||||
|
||||
const batch = AddMembersSchema.parse({
|
||||
contactIds: ["c1", "c2", "c3"],
|
||||
});
|
||||
|
||||
function walk(node: import("zod").infer<typeof GroupTreeNodeSchema>) {
|
||||
yield node.group.id;
|
||||
for (const child of node.children) yield* walk(child);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CreateGroupSchema`, `UpdateGroupSchema`, `GroupQuerySchema`,
|
||||
`MoveGroupSchema`, `AddMembersSchema`, `RemoveMembersSchema` —
|
||||
request shapes.
|
||||
- `GroupResponseSchema`, `GroupListResponseSchema`,
|
||||
`MembersListResponseSchema`, `ResolvedAudienceSchema`,
|
||||
`MembershipResponseSchema` — response shapes.
|
||||
- `GroupTreeNodeSchema` — recursive subtree node; pin `GroupTreeNode`
|
||||
from `@suaveplan/contacts` at the type level.
|
||||
- `GroupTreeNodeResponse` — inferred runtime shape, exported so route
|
||||
builders can avoid `z.infer` boilerplate.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `parentId` is consistently nullable (not optional + null) so the wire
|
||||
format always carries an explicit value, simplifying downstream JSON
|
||||
diffing.
|
||||
- `GroupTreeNodeSchema` is declared with `z.ZodType<GroupTreeNodeResponse>`
|
||||
to keep TypeScript happy with the recursive reference inside a
|
||||
`z.lazy(…)`.
|
||||
- `description.max(1000)` mirrors the SurrealQL field shape; longer
|
||||
blobs belong in `metadata`.
|
||||
@@ -0,0 +1,66 @@
|
||||
# schemas/options — plugin options Zod schema
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schema that validates the optional bag of options passed to
|
||||
`createContactsPlugin`. Every field is optional with a documented
|
||||
default so consumers can call `createContactsPlugin()` with no
|
||||
arguments and still receive a fully-resolved `ContactsPluginConfig`.
|
||||
|
||||
Validation runs at construction time inside `createContactsPlugin`:
|
||||
parse failures become `ContactsApiError(CONTACTS_API.INVALID_INPUT)`
|
||||
with the underlying `ZodError` on `cause`. The plugin therefore fails
|
||||
fast at boot rather than at first request.
|
||||
|
||||
## Features
|
||||
|
||||
- Defaults applied at parse time (`routePrefix: "/"`,
|
||||
`maxImportBatchSize: 1000`, `maxGroupDepth: 10`,
|
||||
`enforceGroupDepth: true`, `applySchemaOnInit: true`).
|
||||
- Numeric guards (`int().positive()`) on `maxGroupDepth` and
|
||||
`maxImportBatchSize`.
|
||||
- Logger shape validated structurally — any `LoggerLike` with the four
|
||||
level methods is accepted.
|
||||
- `routePrefix` requires a leading slash to keep route-mount math
|
||||
predictable.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { ContactsPluginOptionsSchema } from "@suaveplan/api-plugin-contacts/schemas/options";
|
||||
|
||||
const cfg = ContactsPluginOptionsSchema.parse({ maxGroupDepth: 5 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { ContactsPluginOptionsSchema } from "@suaveplan/api-plugin-contacts/schemas/options";
|
||||
|
||||
const result = ContactsPluginOptionsSchema.safeParse(userSuppliedJson);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`bad contacts-plugin config: ${result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
const cfg = result.data;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ContactsPluginOptionsSchema` — the validating Zod schema.
|
||||
- `ContactsPluginOptionsInput` — pre-parse input type (`z.input`).
|
||||
- `ContactsPluginOptionsParsed` — post-parse output type (`z.output`),
|
||||
identical in shape to `ContactsPluginConfig`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The schema is intentionally `.passthrough()`-free: unknown keys are
|
||||
stripped to keep the descriptor's `defaults` slot deterministic.
|
||||
- The logger sub-schema uses `z.function()` rather than `z.any()` so
|
||||
obvious mis-supplied loggers (`{ info: 123 }`) fail at parse time.
|
||||
- Adding a field requires updating both this schema and
|
||||
`ContactsPluginConfig` in `../types/types.ts`; the spec-conformance
|
||||
test pins that the keys stay in lock-step.
|
||||
@@ -0,0 +1,73 @@
|
||||
# services/registry-augmentation — ServiceRegistry slot typing
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Adds typed `contactRepository`, `groupRepository`, and
|
||||
`membershipRepository` slots to the shared
|
||||
`@suaveplan/api-plugin/service-registry` `ServiceRegistry` interface so
|
||||
sibling plugins (campaigns, audience-engine, segments) can resolve the
|
||||
contacts repositories via `ctx.getService("contactRepository")` with no
|
||||
cast. Also redeclares host-side slots (`app`, `db`, `authService`,
|
||||
`migrationRunner`, `openAPIRegistry`, `logger`, `eventBus`, `clock`) so
|
||||
the contacts plugin's own `onInit` resolves them with full typing even
|
||||
when the plugin is imported in isolation.
|
||||
|
||||
This module emits zero runtime code; it is imported for side effects
|
||||
only by the package root barrel.
|
||||
|
||||
## Features
|
||||
|
||||
- Adds three new typed `ServiceRegistry` slots.
|
||||
- Re-declares host slots for type-safe `ctx.getService` resolution.
|
||||
- Sibling plugins automatically pick up the augmentation when they
|
||||
import `@suaveplan/api-plugin-contacts`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-contacts"; // brings in the augmentation
|
||||
import type { PluginContext } from "@suaveplan/api-plugin";
|
||||
|
||||
async function downstream(ctx: PluginContext) {
|
||||
const repo = ctx.getService("contactRepository");
|
||||
// repo is SurrealContactRepository | undefined.
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-contacts";
|
||||
import type { PluginContext } from "@suaveplan/api-plugin";
|
||||
|
||||
export function buildAudienceService(ctx: PluginContext) {
|
||||
const groupRepo = ctx.getService("groupRepository");
|
||||
const memberRepo = ctx.getService("membershipRepository");
|
||||
if (groupRepo === undefined || memberRepo === undefined) {
|
||||
throw new Error(
|
||||
"contacts plugin must be loaded before audience plugin"
|
||||
);
|
||||
}
|
||||
return { groupRepo, memberRepo };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Module augmentation only — no runtime symbols.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The host slots (`app`, `db`, `authService`, etc.) are declared as
|
||||
required on the augmented interface; TypeScript's interface-merging
|
||||
semantics intersect them across plugins, so a slot declared optional
|
||||
in one plugin and required in another resolves to required.
|
||||
- The `export {}` line keeps this module file an ES module rather than
|
||||
a global script.
|
||||
- The companion test file is intentionally absent — the augmentation
|
||||
is a pure type-level construct and is exercised by every test that
|
||||
calls `ctx.getService(...)`.
|
||||
@@ -0,0 +1,70 @@
|
||||
# tree/build-tree — typed subtree builder
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Constructs a typed `GroupTreeNode` rooted at the supplied `Group`,
|
||||
given a flat list of descendants in arbitrary order. Replaces the
|
||||
upstream port's untyped `function buildTree(root: any, descendants:
|
||||
any[]): any` and aligns the output shape exactly with the
|
||||
`GroupTreeNode` interface from `@suaveplan/contacts`.
|
||||
|
||||
Used by the `GET /groups/:id/tree` route to materialise a subtree from
|
||||
the result of `GroupStore.getDescendants(path)`.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-safe in and out — no `any`, no casts.
|
||||
- O(N) construction via a `Map<parentId, Group[]>` bucket; recursion
|
||||
is linear in the number of descendants.
|
||||
- Tolerates orphan descendants (whose `parentId` does not appear in
|
||||
the input) by silently dropping them, matching the upstream
|
||||
behaviour while making it explicit in the contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildTree } from "@suaveplan/api-plugin-contacts/tree/build-tree";
|
||||
|
||||
const tree = buildTree(rootGroup, descendantGroups);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { buildTree } from "@suaveplan/api-plugin-contacts/tree/build-tree";
|
||||
|
||||
const sorted = [...descendantGroups].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
const tree = buildTree(rootGroup, sorted);
|
||||
// Determinism: with the descendants pre-sorted, child arrays are
|
||||
// emitted in name order.
|
||||
|
||||
function flatten(node: typeof tree): string[] {
|
||||
return [
|
||||
node.group.id,
|
||||
...node.children.flatMap(flatten),
|
||||
];
|
||||
}
|
||||
const ids = flatten(tree);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildTree(root: Group, descendants: readonly Group[]): GroupTreeNode`
|
||||
— root + flat descendants → nested tree.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The bucket map keys on `parentId`. Descendants whose `parentId` is
|
||||
`null` are bucketed under the empty string so they do not collide
|
||||
with rows whose `parentId` happens to equal an actual group id.
|
||||
- The function does not sort children; callers that need a stable
|
||||
emission order should sort the descendants array beforehand.
|
||||
- Cycle detection is not performed; the underlying SurrealQL schema
|
||||
guarantees acyclicity by populating the materialised `path` at
|
||||
write time.
|
||||
@@ -0,0 +1,74 @@
|
||||
# types — public type bundle
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pure type declarations that downstream consumers of
|
||||
`@suaveplan/api-plugin-contacts` depend on at compile time. Keeping
|
||||
them in a dedicated module means importing `ContactsPluginConfig` or
|
||||
`ContactsPluginOptions` carries no runtime cost — the file emits a
|
||||
zero-byte `.js` chunk after `vite build`.
|
||||
|
||||
This bundle is deliberately small: it covers the two configuration
|
||||
shapes (`ContactsPluginOptions` accepted by `createContactsPlugin` and
|
||||
the parsed `ContactsPluginConfig` carried inside the descriptor) and
|
||||
nothing more. Hono `ContextVariableMap` augmentations live in
|
||||
`../context/context.ts`; ServiceRegistry augmentations live in
|
||||
`../services/registry-augmentation.ts`; route Zod schemas live in
|
||||
`../schemas/`.
|
||||
|
||||
## Features
|
||||
|
||||
- Optional-input shape (`ContactsPluginOptions`) and parsed-config
|
||||
shape (`ContactsPluginConfig`) for the plugin factory.
|
||||
- `readonly`-everywhere for safety inside the descriptor.
|
||||
- Type-only: zero runtime imports, zero runtime emit.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ContactsPluginOptions } from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
const options: ContactsPluginOptions = { maxGroupDepth: 5 };
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type {
|
||||
ContactsPluginConfig,
|
||||
ContactsPluginOptions,
|
||||
} from "@suaveplan/api-plugin-contacts";
|
||||
|
||||
function buildOverride(
|
||||
base: ContactsPluginConfig,
|
||||
override: ContactsPluginOptions
|
||||
): ContactsPluginConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
// `enforceGroupDepth` must stay set — the override might omit it.
|
||||
enforceGroupDepth: override.enforceGroupDepth ?? base.enforceGroupDepth,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ContactsPluginOptions` — `Partial`-shaped input to
|
||||
`createContactsPlugin`. Every field is optional and defaulted by
|
||||
`ContactsPluginOptionsSchema`.
|
||||
- `ContactsPluginConfig` — frozen post-parse config that travels in
|
||||
`PluginDescriptor.defaults` and is handed back into `onInit` via
|
||||
`ctx.config`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- This module imports `LoggerLike` from `@suaveplan/types` purely as a
|
||||
type — no runtime dep is added to the bundle.
|
||||
- The shapes line up 1:1 with `ContactsPluginOptionsSchema.shape` in
|
||||
`../schemas/options.ts`; any field added to the schema must also be
|
||||
added here. The spec-conformance test pins that the two stay in sync.
|
||||
Reference in New Issue
Block a user