Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
# `commerce`
Booking engines, contact management, payment plugins, and commerce primitives.
<!-- begin:auto-docs -->
## Subcategories
2 subcategories, 5 packages.
### `booking`
Universal booking engine with availability, conflict detection, and pricing.
| Package | Description |
|---|---|
| [`@suaveplan/booking`](./booking/README.md) | Universal, JSON-configurable booking engine with pluggable strategies for availability resolution, conflict detection, pricing calculation, and rule evaluation… |
### `contacts`
Contact and group management with deduplication and import/export.
| Package | Description |
|---|---|
| [`@suaveplan/api-plugin-contacts`](./api-plugin-contacts/README.md) | Contacts and Groups HTTP plugin for `@suaveplan/api`. Mounts the `/contacts` and `/groups` route trees on a Hono `OpenAPIHono`, backed by SurrealDB implementat… |
| [`@suaveplan/audience-rules`](./audience-rules/README.md) | Universal audience segmentation: rule-based filtering on contact data with 14 comparison operators, AND/OR chaining, dot-notation field resolution including ar… |
| [`@suaveplan/contacts`](./contacts/README.md) | Universal contact and group management with hierarchical nesting, audience resolution, contact merger, and CSV/JSON import-export. Multi-tenant, channel-agnost… |
| [`@suaveplan/macos-contacts`](./macos-contacts/README.md) | Universal bridge to macOS Contacts.app via AppleScript. Reads contacts and groups through the platform `osascript` binary, parses the output into typed records… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1,3 @@
# `commerce`
Booking engines, contact management, payment plugins, and commerce primitives.

View File

@@ -0,0 +1,13 @@
# `booking`
Universal booking engine with availability, conflict detection, and pricing.
<!-- begin:auto-docs -->
## Packages in `booking`
1 package.
| Package | Description |
|---|---|
| [`@suaveplan/booking`](./booking/README.md) | Universal, JSON-configurable booking engine with pluggable strategies for availability resolution, conflict detection, pricing calculation, and rule evaluation… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Universal booking engine with availability, conflict detection, and pricing.

View File

@@ -0,0 +1,220 @@
# @suaveplan/booking - Universal JSON-configurable booking engine: pluggable availability resolution, conflict detection, pricing pipeline, rule evaluation, configurable status state machine, typed event bus, in-package data-transform Hooks pipeline, six domain presets (seats, accommodation, appointments, resources, activities, transport), Zod validation schemas, and typed BookingError hierarchy with stable BOOKING.* code registry.
Universal, JSON-configurable booking engine with pluggable strategies for availability resolution, conflict detection, pricing calculation, and rule evaluation. Ships six built-in domain presets (seats, accommodation, appointments, resources, activities, transport), a configurable status state machine, a typed lifecycle event bus, an in-package `Hooks<TMap>` data-transform pipeline, Zod validation schemas, and a typed `BookingError` hierarchy.
<!-- begin:auto-docs -->
## Documentation tree
- [resolver](./src/availability/resolver/resolver.md) — Pluggable availability-resolver registry. The booking engine asks "is this window available?" by delegating to a strate…
- [resolver](./src/availability/resolver/resolver.md) — Pluggable availability-resolver registry. The booking engine asks "is this window available?" by delegating to a strate…
- [strategies](./src/availability/strategies/strategies.md) — Three built-in `AvailabilityStrategyFn` implementations covering the three most common booking semantics: `exactMatchSt…
- [strategies](./src/availability/strategies/strategies.md) — Three built-in `AvailabilityStrategyFn` implementations covering the three most common booking semantics: `exactMatchSt…
- [detector](./src/conflict/detector/detector.md) — Provides the pluggable conflict-detection registry used by the booking engine to decide whether a proposed window colli…
- [detector](./src/conflict/detector/detector.md) — Provides the pluggable conflict-detection registry used by the booking engine to decide whether a proposed window colli…
- [exact-match](./src/conflict/exact-match/exact-match.md) — Conflict-detection strategy for seat-style bookings where two reservations conflict only when their start AND end times…
- [exact-match](./src/conflict/exact-match/exact-match.md) — Conflict-detection strategy for seat-style bookings where two reservations conflict only when their start AND end times…
- [provider-overlap](./src/conflict/provider-overlap/provider-overlap.md) — Conflict-detection strategy for appointment-style bookings where the constraint is "this provider's calendar." Used for…
- [provider-overlap](./src/conflict/provider-overlap/provider-overlap.md) — Conflict-detection strategy for appointment-style bookings where the constraint is "this provider's calendar." Used for…
- [range-overlap](./src/conflict/range-overlap/range-overlap.md) — Conflict-detection strategy for accommodation-style bookings where any temporal overlap counts as a conflict. Models ho…
- [range-overlap](./src/conflict/range-overlap/range-overlap.md) — Conflict-detection strategy for accommodation-style bookings where any temporal overlap counts as a conflict. Models ho…
- [slot-capacity](./src/conflict/slot-capacity/slot-capacity.md) — Conflict-detection strategy for group bookings — meeting rooms, tour groups, activity classes, anything where multiple…
- [slot-capacity](./src/conflict/slot-capacity/slot-capacity.md) — Conflict-detection strategy for group bookings — meeting rooms, tour groups, activity classes, anything where multiple…
- [engine](./src/core/engine/engine.md) — `BookingEngine` is the booking system's pipeline orchestrator. It composes the registry of bookables, the persistent ma…
- [engine](./src/core/engine/engine.md) — `BookingEngine` is the booking system's pipeline orchestrator. It composes the registry of bookables, the persistent ma…
- [event-bus](./src/core/event-bus/event-bus.md) — Typed publish/subscribe event bus for booking lifecycle events. The engine emits the 15 standard `BookingEventType` eve…
- [event-bus](./src/core/event-bus/event-bus.md) — Typed publish/subscribe event bus for booking lifecycle events. The engine emits the 15 standard `BookingEventType` eve…
- [state-machine](./src/core/state-machine/state-machine.md) — Configurable booking-status state machine. Consumers pass an array of `StatusTransition` rows (from `BookingSystemConfi…
- [state-machine](./src/core/state-machine/state-machine.md) — Configurable booking-status state machine. Consumers pass an array of `StatusTransition` rows (from `BookingSystemConfi…
- [domains](./src/domains/shared.md) — Provides `createBaseConfig(tenantId, tenantName)` — the canonical default `BookingSystemConfig` factory that every doma…
- [accommodation](./src/domains/accommodation/accommodation.md) — Factory for the accommodation booking domain — hotels, vacation rentals, hostels. Each bookable represents one bookable…
- [accommodation](./src/domains/accommodation/accommodation.md) — Factory for the accommodation booking domain — hotels, vacation rentals, hostels. Each bookable represents one bookable…
- [activities](./src/domains/activities/activities.md) — Factory for the activity/tour booking domain — group tours, classes, workshops, group events. Each bookable represents…
- [activities](./src/domains/activities/activities.md) — Factory for the activity/tour booking domain — group tours, classes, workshops, group events. Each bookable represents…
- [appointments](./src/domains/appointments/appointments.md) — Factory for the appointment booking domain — medical, dental, salon, consulting. Each bookable represents an appointmen…
- [appointments](./src/domains/appointments/appointments.md) — Factory for the appointment booking domain — medical, dental, salon, consulting. Each bookable represents an appointmen…
- [resources](./src/domains/resources/resources.md) — Factory for the resource booking domain — meeting rooms, equipment, shared vehicles. Each bookable represents a resourc…
- [resources](./src/domains/resources/resources.md) — Factory for the resource booking domain — meeting rooms, equipment, shared vehicles. Each bookable represents a resourc…
- [seats](./src/domains/seats/seats.md) — Factory for the seat-based booking domain — stadiums, theatres, cinemas, flights. Each bookable represents one physical…
- [seats](./src/domains/seats/seats.md) — Factory for the seat-based booking domain — stadiums, theatres, cinemas, flights. Each bookable represents one physical…
- [transport](./src/domains/transport/transport.md) — Factory for the transport booking domain — flights, trains, buses. Each bookable represents a single transport-seat on…
- [transport](./src/domains/transport/transport.md) — Factory for the transport booking domain — flights, trains, buses. Each bookable represents a single transport-seat on…
- [shared](./src/domains/shared.md) — Provides `createBaseConfig(tenantId, tenantName)` — the canonical default `BookingSystemConfig` factory that every doma…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `BookingError` class for `@suaveplan/booking`. Every failure path the…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `BookingError` class for `@suaveplan/booking`. Every failure path the…
- [hooks](./src/hooks/hooks.md) — In-package data-transform pipeline used by `BookingEngine` to give consumers a hook into the create-booking and cancel-…
- [hooks](./src/hooks/hooks.md) — In-package data-transform pipeline used by `BookingEngine` to give consumers a hook into the create-booking and cancel-…
- [calculator](./src/pricing/calculator/calculator.md) — Implements the booking engine's price-calculation pipeline. `calculatePrice` is the canonical entry point: it runs base…
- [calculator](./src/pricing/calculator/calculator.md) — Implements the booking engine's price-calculation pipeline. `calculatePrice` is the canonical entry point: it runs base…
- [dynamic](./src/pricing/dynamic/dynamic.md) — Implements dynamic-pricing rule evaluation. Three trigger types are supported — `occupancy_threshold`, `advance_days`,…
- [dynamic](./src/pricing/dynamic/dynamic.md) — Implements dynamic-pricing rule evaluation. Three trigger types are supported — `occupancy_threshold`, `advance_days`,…
- [seasonal](./src/pricing/seasonal/seasonal.md) — Applies seasonal-rate multipliers to a base price based on month/day windows. Supports wrap-around-year intervals (e.g.…
- [seasonal](./src/pricing/seasonal/seasonal.md) — Applies seasonal-rate multipliers to a base price based on month/day windows. Supports wrap-around-year intervals (e.g.…
- [conditions](./src/rules/conditions/conditions.md) — Pre-built `RuleCondition` factories for the most common booking constraints — minimum/maximum duration, advance-booking…
- [conditions](./src/rules/conditions/conditions.md) — Pre-built `RuleCondition` factories for the most common booking constraints — minimum/maximum duration, advance-booking…
- [evaluator](./src/rules/evaluator/evaluator.md) — The rules-engine evaluator walks a list of `BookingRule[]` against an `EvaluationContext` (booking + bookable + window…
- [evaluator](./src/rules/evaluator/evaluator.md) — The rules-engine evaluator walks a list of `BookingRule[]` against an `EvaluationContext` (booking + bookable + window…
- [booking.schema](./src/schemas/booking.schema.md) — Zod validation schemas covering every entity at the booking engine's input boundary — `Bookable`, `Booking`, `BookingRe…
- [config.schema](./src/schemas/config.schema.md) — Validates the full `BookingSystemConfig` JSON tree at runtime. The schema mirrors the `config.types.ts` shape and is th…
- [recurrence](./src/temporal/recurrence/recurrence.md) — Expands `RecurrenceRule` definitions into concrete `TimeWindow[]` occurrences across a date range. Supports daily, week…
- [recurrence](./src/temporal/recurrence/recurrence.md) — Expands `RecurrenceRule` definitions into concrete `TimeWindow[]` occurrences across a date range. Supports daily, week…
- [slots](./src/temporal/slots/slots.md) — Generates and manipulates `TimeSlot` lists. `generateTimeSlots` produces fixed-interval slots covering a single `TimeWi…
- [slots](./src/temporal/slots/slots.md) — Generates and manipulates `TimeSlot` lists. `generateTimeSlots` produces fixed-interval slots covering a single `TimeWi…
- [windows](./src/temporal/windows/windows.md) — Defines pure functions for reasoning about `TimeWindow` intervals — overlap, merge, split, containment, adjacency, vali…
- [windows](./src/temporal/windows/windows.md) — Defines pure functions for reasoning about `TimeWindow` intervals — overlap, merge, split, containment, adjacency, vali…
- [utils](./src/utils/utils.md) — Shared, dependency-free helpers used across `@suaveplan/booking`. Every export here is pure, side-effect-free, and runs…
- [utils](./src/utils/utils.md) — Shared, dependency-free helpers used across `@suaveplan/booking`. Every export here is pure, side-effect-free, and runs…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/booking
```
## Quick start
```ts
import { createBookingEngine, createSeatConfig } from "@suaveplan/booking";
const engine = createBookingEngine();
engine.configure(createSeatConfig("arena-1", "Main Arena"));
engine.registerBookable({
identifierRef: { id: "seat-A12", tenantId: "arena-1", categoryId: "vip" },
name: "Seat A12",
description: "Aisle seat",
bookableType: "seat",
status: "active",
layoutNodeRef: null,
capacityRef: "cap-A12",
priceRuleRefs: [],
timeSlotRefs: [],
attributes: {},
});
const result = engine.checkAvailability({
bookableRef: "seat-A12",
windowRef: {
startTime: "2026-06-01T19:00:00Z",
endTime: "2026-06-01T22:00:00Z",
timezone: "UTC",
},
quantity: 1,
});
console.log(result.available);
```
## Architecture
```text
Availability → Conflict detection → Pricing → Rules → State machine → Events
```
Every stage is pluggable via registries (`registerDetector`, `registerAvailabilityStrategy`) and the `Hooks<BookingHookMap>` accessor exposes pre-persistence transform points (`booking:creating`, `booking:cancelling`).
## Modules
### Foundation
| Module | Documentation |
|---|---|
| Error codes (`BOOKING_CODES`, `BookingError`) | [src/error-codes/error-codes.md](src/error-codes/error-codes.md) |
| Hooks (in-package `Hooks<TMap>` pipeline) | [src/hooks/hooks.md](src/hooks/hooks.md) |
| Utils (`parseDurationMs`, …) | [src/utils/utils.md](src/utils/utils.md) |
### Types
| Module | Documentation |
|---|---|
| Bookable | [src/types/bookable.types.md](src/types/bookable.types.md) |
| Booking | [src/types/booking.types.md](src/types/booking.types.md) |
| Booking hooks | [src/types/booking-hooks.types.md](src/types/booking-hooks.types.md) |
| Capacity | [src/types/capacity.types.md](src/types/capacity.types.md) |
| Config | [src/types/config.types.md](src/types/config.types.md) |
| Events | [src/types/events.types.md](src/types/events.types.md) |
| Layout | [src/types/layout.types.md](src/types/layout.types.md) |
| Pricing | [src/types/pricing.types.md](src/types/pricing.types.md) |
| Rules | [src/types/rules.types.md](src/types/rules.types.md) |
| Temporal | [src/types/temporal.types.md](src/types/temporal.types.md) |
### Temporal
| Module | Documentation |
|---|---|
| Windows | [src/temporal/windows/windows.md](src/temporal/windows/windows.md) |
| Recurrence | [src/temporal/recurrence/recurrence.md](src/temporal/recurrence/recurrence.md) |
| Slots | [src/temporal/slots/slots.md](src/temporal/slots/slots.md) |
### Conflict detection
| Module | Documentation |
|---|---|
| Detector registry | [src/conflict/detector/detector.md](src/conflict/detector/detector.md) |
| Exact match | [src/conflict/exact-match/exact-match.md](src/conflict/exact-match/exact-match.md) |
| Range overlap | [src/conflict/range-overlap/range-overlap.md](src/conflict/range-overlap/range-overlap.md) |
| Slot capacity | [src/conflict/slot-capacity/slot-capacity.md](src/conflict/slot-capacity/slot-capacity.md) |
| Provider overlap | [src/conflict/provider-overlap/provider-overlap.md](src/conflict/provider-overlap/provider-overlap.md) |
### Pricing
| Module | Documentation |
|---|---|
| Calculator | [src/pricing/calculator/calculator.md](src/pricing/calculator/calculator.md) |
| Seasonal | [src/pricing/seasonal/seasonal.md](src/pricing/seasonal/seasonal.md) |
| Dynamic | [src/pricing/dynamic/dynamic.md](src/pricing/dynamic/dynamic.md) |
### Rules
| Module | Documentation |
|---|---|
| Evaluator | [src/rules/evaluator/evaluator.md](src/rules/evaluator/evaluator.md) |
| Conditions | [src/rules/conditions/conditions.md](src/rules/conditions/conditions.md) |
### Availability
| Module | Documentation |
|---|---|
| Resolver registry | [src/availability/resolver/resolver.md](src/availability/resolver/resolver.md) |
| Built-in strategies | [src/availability/strategies/strategies.md](src/availability/strategies/strategies.md) |
### Domains
| Module | Documentation |
|---|---|
| Shared base | [src/domains/shared.md](src/domains/shared.md) |
| Seats | [src/domains/seats/seats.md](src/domains/seats/seats.md) |
| Accommodation | [src/domains/accommodation/accommodation.md](src/domains/accommodation/accommodation.md) |
| Appointments | [src/domains/appointments/appointments.md](src/domains/appointments/appointments.md) |
| Resources | [src/domains/resources/resources.md](src/domains/resources/resources.md) |
| Activities | [src/domains/activities/activities.md](src/domains/activities/activities.md) |
| Transport | [src/domains/transport/transport.md](src/domains/transport/transport.md) |
### Schemas
| Module | Documentation |
|---|---|
| Booking schema | [src/schemas/booking.schema.md](src/schemas/booking.schema.md) |
| Config schema | [src/schemas/config.schema.md](src/schemas/config.schema.md) |
### Core
| Module | Documentation |
|---|---|
| Booking engine | [src/core/engine/engine.md](src/core/engine/engine.md) |
| State machine | [src/core/state-machine/state-machine.md](src/core/state-machine/state-machine.md) |
| Event bus | [src/core/event-bus/event-bus.md](src/core/event-bus/event-bus.md) |
## Tree-shaking
Every feature module ships at its own subpath export:
```ts
import { windowsOverlap } from "@suaveplan/booking/temporal/windows/windows";
import { calculatePrice } from "@suaveplan/booking/pricing/calculator/calculator";
import { createSeatConfig } from "@suaveplan/booking/domains/seats/seats";
```
## License
MIT — © Suaveplan Team

View File

@@ -0,0 +1,78 @@
# resolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pluggable availability-resolver registry. The booking engine asks "is this window available?" by delegating to a strategy registered under a domain-specific id (e.g. `"capacity"`, `"exact"`, `"range"`). The registry is module-level; consumers seed it with built-ins from `src/availability/strategies/strategies.ts` and can add custom strategies for domains the package doesn't ship out of the box. Unknown strategy ids raise `BookingError({ code: BOOKING.UNKNOWN_AVAILABILITY_STRATEGY, strategy })` so missing registrations fail loudly.
## Features
- `registerAvailabilityStrategy(id, fn)` — registry insert.
- `resolveAvailability(id, bookable, query, existing)` — dispatch.
- `getRegisteredStrategies()` — snapshot of registry keys.
- `clearStrategyRegistry()` — reset (mostly for tests).
- Generic over `TMeta` so domain-typed metadata flows through end-to-end.
## Usage: Basic
```ts
import {
capacityStrategy, registerAvailabilityStrategy, resolveAvailability,
} from "@suaveplan/booking";
registerAvailabilityStrategy("capacity", capacityStrategy);
const result = resolveAvailability("capacity", bookable, query, existingBookings);
```
## Usage: Advanced
```ts
import {
clearStrategyRegistry, getRegisteredStrategies, registerAvailabilityStrategy,
} from "@suaveplan/booking";
import type { AvailabilityStrategyFn } from "@suaveplan/booking/availability/resolver/resolver";
clearStrategyRegistry();
const allowEveryOther: AvailabilityStrategyFn = (_bookable, query, _existing) => {
const day = new Date(query.windowRef.startTime).getUTCDay();
const available = day % 2 === 0;
return {
available, remainingCapacity: available ? 1 : 0,
conflictingBookings: [], suggestedAlternatives: [],
};
};
registerAvailabilityStrategy("every-other-day", allowEveryOther);
console.log(getRegisteredStrategies()); // ["every-other-day"]
```
## API Reference
### `registerAvailabilityStrategy(strategyId: string, strategy: AvailabilityStrategyFn): void`
Inserts the strategy into the module-level registry. Idempotent — re-registering replaces.
### `resolveAvailability<TMeta>(strategyId, bookable, query, existingBookings): AvailabilityResult`
Looks up the strategy and delegates. Throws `BookingError({ code: BOOKING.UNKNOWN_AVAILABILITY_STRATEGY, strategy })` if no strategy is registered for the id.
### `getRegisteredStrategies(): string[]`
Snapshot of currently registered ids.
### `clearStrategyRegistry(): void`
Empties the registry. Primarily for tests that need an isolated starting state.
### `AvailabilityStrategyFn<TMeta>`
`(bookable: Bookable<TMeta>, query: AvailabilityQuery, existing: Booking<TMeta>[]) => AvailabilityResult`. Strategies are pure functions — no side effects, no IO.
## Implementation Notes
- The registry is module-level (singleton) for parity with upstream. Scoped registries can land as a follow-up change.
- The generic `TMeta` is threaded through `resolveAvailability` so callers who pin a literal type (e.g. `SeatBookableMeta`) keep type safety into the strategy body.
- The runtime cast `strategy as AvailabilityStrategyFn<TMeta>` is necessary because the registry stores erased function references; the cast is contained inside the resolver so consumers never see it.

View File

@@ -0,0 +1,75 @@
# strategies
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Three built-in `AvailabilityStrategyFn` implementations covering the three most common booking semantics: `exactMatchStrategy` (seat-style; conflict iff identical window), `rangeOverlapStrategy` (accommodation-style; any overlap; honours boundary turnover), `capacityStrategy` (group-style; sums quantities up to a hard 100 cap). Consumers register the ones they need via `registerAvailabilityStrategy` and the engine dispatches on the configured strategy id. All three are pure functions with no module-level state of their own.
## Features
- `exactMatchStrategy` — seat semantics; identical-window conflict.
- `rangeOverlapStrategy` — accommodation semantics; uses `windowsOverlap(..., true)` for boundary turnover.
- `capacityStrategy` — group semantics; sums existing quantities, fixed 100 cap.
- All three generic over `TMeta` for typed metadata flow-through.
## Usage: Basic
```ts
import {
capacityStrategy, exactMatchStrategy, rangeOverlapStrategy,
registerAvailabilityStrategy,
} from "@suaveplan/booking";
registerAvailabilityStrategy("exact", exactMatchStrategy);
registerAvailabilityStrategy("range", rangeOverlapStrategy);
registerAvailabilityStrategy("capacity", capacityStrategy);
```
## Usage: Advanced
```ts
import { capacityStrategy } from "@suaveplan/booking";
import type { Bookable, Booking } from "@suaveplan/booking";
interface ClassMeta extends Record<string, unknown> { maxParticipants: number }
const yogaClass: Bookable<ClassMeta> = {
identifierRef: { id: "yoga-mon-9am", tenantId: "studio-1", categoryId: "class" },
name: "Sunrise Yoga",
description: "Vinyasa flow",
bookableType: "activity",
status: "active",
layoutNodeRef: null, capacityRef: "cap-yoga",
priceRuleRefs: [], timeSlotRefs: [],
attributes: { maxParticipants: 20 },
};
const existing: Booking<ClassMeta>[] = [/* … 18 bookings of quantity 1 … */];
const result = capacityStrategy(yogaClass, {
bookableRef: yogaClass.identifierRef.id,
windowRef: { startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T10:00:00Z", timezone: "UTC" },
quantity: 3,
}, existing);
// result.available depends on how many existing bookings overlap & their quantities.
```
## API Reference
| Strategy | Conflict definition | `remainingCapacity` |
|---|---|---|
| `exactMatchStrategy` | Same `bookableRef` AND identical start+end | 1 or 0 |
| `rangeOverlapStrategy` | Same `bookableRef` AND `windowsOverlap(..., true)` | 1 or 0 |
| `capacityStrategy` | Same `bookableRef` AND `windowsOverlap` AND summed quantity > 100 | `max(0, 100 - bookedQuantity)` |
All return `AvailabilityResult { available, remainingCapacity, conflictingBookings, suggestedAlternatives }`. `suggestedAlternatives` is always `[]` — the engine composes alternatives separately.
## Implementation Notes
- `rangeOverlapStrategy` calls `windowsOverlap(a, b, true)` — passing `allowBoundaryTurnover=true` so identical boundaries don't count as a conflict. This matches typical accommodation semantics (check-out at 11, next check-in at 14).
- `capacityStrategy`'s 100 cap is a placeholder for the upstream behaviour; production consumers should compose a custom strategy that reads `bookable.attributes.capacity` (or similar) for the true cap. A future change can thread an injectable capacity-source.
- The leading `_bookable` parameter is intentionally unused in two strategies; future strategies can use it to read `attributes.tier`, `attributes.maxOccupancy`, etc.
- All three are pure functions — registering them does not introduce hidden module state.

View File

@@ -0,0 +1,90 @@
# detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the pluggable conflict-detection registry used by the booking engine to decide whether a proposed window collides with existing ones. The registry is keyed by `ConflictDetectorConfig.mode`; built-in modes (`exact_match`, `range_overlap`, `slot_capacity`, `provider_overlap`) live in sibling directories and self-register, but consumers can add custom modes by calling `registerDetector("my-mode", fn)`. Unknown modes raise `BookingError({ code: BOOKING.UNKNOWN_CONFLICT_MODE, mode })` so missing registrations fail loudly rather than silently passing.
## Features
- `registerDetector(mode, fn)` — registry insert.
- `detectConflicts(proposed, existing, config)` — dispatches to the configured mode.
- `getRegisteredModes()` — list of currently registered mode ids.
- `clearDetectorRegistry()` — reset (mostly for tests).
- Typed errors via `BookingError`; no plain throws.
## Usage: Basic
```ts
import {
detectConflicts, detectRangeOverlap, registerDetector,
} from "@suaveplan/booking";
registerDetector("range_overlap", detectRangeOverlap);
const result = detectConflicts(
{ startTime: "2026-06-01T14:00:00Z", endTime: "2026-06-03T11:00:00Z", timezone: "UTC" },
[],
{
mode: "range_overlap", allowSameBoundaryTurnover: true,
bufferDuration: "PT0S", holdDuration: "PT10M", capacityMode: "absolute",
}
);
// result.hasConflict === false
```
## Usage: Advanced
```ts
import {
clearDetectorRegistry, detectConflicts, getRegisteredModes, registerDetector,
} from "@suaveplan/booking";
import type { ConflictDetectorFn } from "@suaveplan/booking/conflict/detector/detector";
clearDetectorRegistry();
const myMode: ConflictDetectorFn = (proposed, existing, _config) => {
const conflicts = existing.filter((w) => w.startTime === proposed.startTime);
return {
hasConflict: conflicts.length > 0,
conflictingWindowRefs: conflicts,
suggestedAlternativeWindows: [],
};
};
registerDetector("same-start", myMode);
console.log(getRegisteredModes()); // ["same-start"]
```
## API Reference
### `registerDetector(mode: string, detector: ConflictDetectorFn): void`
Inserts the detector into the module-level registry. Idempotent — re-registering replaces.
### `detectConflicts(proposed, existing, config): OverlapResult`
Looks up `config.mode` in the registry and delegates. Throws `BookingError({ code: BOOKING.UNKNOWN_CONFLICT_MODE, mode })` if no detector is registered for that mode.
### `getRegisteredModes(): string[]`
Snapshot of the registry's keys.
### `clearDetectorRegistry(): void`
Empties the registry. Primarily for tests that need an isolated starting state.
### Types
- `OverlapResult` — `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows }`.
- `ConflictDetectionMode` — `"exact_match" | "range_overlap" | "slot_capacity" | "provider_overlap" | "composite"`.
- `ConflictDetectorFn` — `(proposed, existing, config) => OverlapResult`.
## Implementation Notes
- The registry is module-level (singleton) for parity with upstream. A future change can scope it to an engine instance if consumers need isolation.
- The dispatch carries the original `config` through to the detector unchanged so detectors can read `bufferDuration`, `allowSameBoundaryTurnover`, etc.
- Built-in detectors do not auto-register themselves at import time — call `registerDetector(mode, fn)` explicitly so the engine consumer controls which modes are active.

View File

@@ -0,0 +1,61 @@
# exact-match
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conflict-detection strategy for seat-style bookings where two reservations conflict only when their start AND end times match exactly. This is the right mode for stadiums, theatres, cinemas, and flights — domains where a seat is bookable for a specific event window and "overlapping" only means "claiming the same instance of the same seat." Compared to `range_overlap`, this mode is strictly stricter; it never flags a partial overlap as a conflict.
## Features
- O(n) scan over existing windows.
- Pure function; no side effects.
- Ignores `bufferDuration` and `allowSameBoundaryTurnover` (intentionally).
- Returns the standard `OverlapResult` shape.
## Usage: Basic
```ts
import { detectExactMatch, registerDetector } from "@suaveplan/booking";
registerDetector("exact_match", detectExactMatch);
```
## Usage: Advanced
```ts
import { detectExactMatch } from "@suaveplan/booking";
const proposed = {
startTime: "2026-05-30T19:00:00Z",
endTime: "2026-05-30T22:00:00Z",
timezone: "Europe/London",
};
const existing = [
{ startTime: "2026-05-30T19:00:00Z", endTime: "2026-05-30T22:00:00Z", timezone: "Europe/London" },
{ startTime: "2026-05-30T19:00:00Z", endTime: "2026-05-30T21:00:00Z", timezone: "Europe/London" },
];
const result = detectExactMatch(proposed, existing, {
mode: "exact_match", allowSameBoundaryTurnover: false,
bufferDuration: "PT0S", holdDuration: "PT0M", capacityMode: "absolute",
});
// result.hasConflict === true; result.conflictingWindowRefs.length === 1 (only the exact match)
```
## API Reference
### `detectExactMatch(proposed, existing, _config): OverlapResult`
Returns `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows: [] }`. A window in `existing` is conflicting iff both its start and its end timestamps equal `proposed.startTime` and `proposed.endTime` respectively (in milliseconds).
## Implementation Notes
- Timestamps are compared via `Date.parse(...)` to handle different ISO formats normalising to the same instant.
- Suggested alternatives are intentionally empty — the engine layer can compose alternatives from slot inventory; the detector's job is "is there a conflict?".
- The `_config` parameter is accepted to satisfy the `ConflictDetectorFn` signature; this mode ignores buffer / turnover semantics.
- The detector is fully pure and stateless — safe to call from many call sites concurrently without coordination, including the hot path of high-throughput ticketing flows.
- For multi-seat group sales where a single request covers many seats, callers iterate this detector per seat rather than combining seats into a single call; the detector is intentionally narrow-scope to keep the math obvious.
- Pairing this detector with `exactMatchStrategy` in `src/availability/strategies/strategies.ts` gives a complete seat-style availability + conflict pipeline that handles the canonical "double-book the same seat" failure mode without ambiguity.

View File

@@ -0,0 +1,62 @@
# provider-overlap
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conflict-detection strategy for appointment-style bookings where the constraint is "this provider's calendar." Used for medical, dental, salon, and consulting domains where a provider sees one client at a time and needs buffer time between appointments. The implementation extends every existing booking by `bufferDuration` on each side and reports a conflict whenever the proposed window touches the extended range.
## Features
- Symmetric buffer extension via `ConflictDetectorConfig.bufferDuration`.
- Half-open overlap inside the extended range.
- Pure function; no side effects.
- Returns the standard `OverlapResult` shape.
## Usage: Basic
```ts
import { detectProviderOverlap, registerDetector } from "@suaveplan/booking";
registerDetector("provider_overlap", detectProviderOverlap);
```
## Usage: Advanced
```ts
import { detectProviderOverlap } from "@suaveplan/booking";
const proposed = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T10:30:00Z",
timezone: "UTC",
};
const previousAppointment = [
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T09:50:00Z", timezone: "UTC" },
];
// 15-minute buffer: previous ends 09:50, extended to 10:05 → conflicts with the 10:00 start.
detectProviderOverlap(proposed, previousAppointment, {
mode: "provider_overlap", allowSameBoundaryTurnover: false,
bufferDuration: "PT15M", holdDuration: "PT5M", capacityMode: "absolute",
}).hasConflict; // true
```
## API Reference
### `detectProviderOverlap(proposed, existing, config): OverlapResult`
Returns `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows: [] }`. A window in `existing` is conflicting iff its `[start - bufferMs, end + bufferMs)` range overlaps `proposed`.
## Implementation Notes
- `bufferDuration` is parsed via `parseDurationMs`; the same ISO-8601 subset is used throughout the package.
- The detector ignores `allowSameBoundaryTurnover` because provider-overlap semantics require a buffer to be honoured at the boundary too.
- `conflictingWindowRefs` carries the original existing windows; the buffer is purely a comparison artefact.
- Pass an empty `existing[]` for new providers to skip the scan entirely.
- The detector is fully pure and stateless — safe to call from many call sites concurrently without coordination, including hot-path booking flows.
- For domains with per-bookable buffer overrides (a 5-minute buffer for haircuts but 30 minutes for surgeries) consumers wrap this detector and read the bookable's `attributes.bufferBeforeMinutes` / `bufferAfterMinutes` to compute a per-call `bufferDuration`.
- When pairing with `enableRecurring: true` (the appointment preset's default), make sure recurrence expansion runs BEFORE this detector so every materialised occurrence appears in `existing`.
- The half-open semantics inside the buffered range mean that two appointments touching at the buffered boundary (e.g. `[09:00,09:30)` + 5min buffer = `[08:55,09:35)`, second is `[09:35,10:00)`) are NOT considered conflicting; the first ends exactly when the buffered range ends.

View File

@@ -0,0 +1,65 @@
# range-overlap
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conflict-detection strategy for accommodation-style bookings where any temporal overlap counts as a conflict. Models hotel rooms, vacation rentals, hostels, and any other domain where a single bookable can host at most one booking at a time and bookings span continuous date ranges. Honours both `bufferDuration` (e.g. 4-hour cleaning turnover) and `allowSameBoundaryTurnover` (whether check-out at 11 followed by check-in at 14 counts as a conflict).
## Features
- Half-open `[start, end)` overlap test.
- Optional buffer extends the existing window symmetrically.
- `allowSameBoundaryTurnover` excludes boundary-touching cases when true.
- Pure function; no side effects.
## Usage: Basic
```ts
import { detectRangeOverlap, registerDetector } from "@suaveplan/booking";
registerDetector("range_overlap", detectRangeOverlap);
```
## Usage: Advanced
```ts
import { detectRangeOverlap } from "@suaveplan/booking";
const proposed = {
startTime: "2026-06-03T14:00:00Z",
endTime: "2026-06-05T11:00:00Z",
timezone: "UTC",
};
const existing = [
{ startTime: "2026-06-01T14:00:00Z", endTime: "2026-06-03T11:00:00Z", timezone: "UTC" },
];
// With turnover allowed, 11→14 gap is fine: no conflict.
detectRangeOverlap(proposed, existing, {
mode: "range_overlap", allowSameBoundaryTurnover: true,
bufferDuration: "PT0S", holdDuration: "PT10M", capacityMode: "absolute",
}).hasConflict; // false
// With a 4-hour cleaning buffer and turnover disallowed, the existing window
// extends to 15:00, conflicting with the 14:00 proposed start.
detectRangeOverlap(proposed, existing, {
mode: "range_overlap", allowSameBoundaryTurnover: false,
bufferDuration: "PT4H", holdDuration: "PT15M", capacityMode: "absolute",
}).hasConflict; // true
```
## API Reference
### `detectRangeOverlap(proposed, existing, config): OverlapResult`
Returns `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows: [] }`. A window in `existing` is conflicting iff its buffer-extended range overlaps `proposed` AND (when `allowSameBoundaryTurnover === true`) the proposed window does not touch the existing window's ORIGINAL (un-buffered) start/end.
## Implementation Notes
- The buffer is symmetric — `bufferDuration` extends both the start and end of the existing window — so the parameter models cleaning/preparation time on either side.
- When `allowSameBoundaryTurnover === true`, the boundary check uses the original (un-buffered) start/end so the policy works regardless of buffer size.
- `bufferDuration` is parsed via `parseDurationMs` — the ISO-8601 subset (D, H, M, S) is the package's standard duration syntax.
- The detector returns the buffered windows in `conflictingWindowRefs`? No — it returns the original existing windows; only the comparison is buffered. Callers debug their data, not the buffer arithmetic.

View File

@@ -0,0 +1,73 @@
# slot-capacity
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Conflict-detection strategy for group bookings — meeting rooms, tour groups, activity classes, anything where multiple bookings can share a slot up to a capacity limit. Unlike `exact_match` and `range_overlap`, this strategy never says "no" purely on the basis of overlap; it reports a conflict only when the proposed booking would push the total occupancy past `totalCapacity`. When no `CapacityContext` is supplied, the detector returns `hasConflict: false` because it cannot make a capacity decision.
## Features
- Overlap detection scoped to slots whose window intersects the proposed window.
- Capacity arithmetic via `CapacityContext { totalCapacity, currentBookedQuantity, requestedQuantity }`.
- Defensive default — no context = no conflict reported.
- Pure function; no side effects.
## Usage: Basic
```ts
import { detectSlotCapacity, registerDetector } from "@suaveplan/booking";
registerDetector("slot_capacity", detectSlotCapacity);
```
## Usage: Advanced
```ts
import { detectSlotCapacity } from "@suaveplan/booking";
const proposed = {
startTime: "2026-06-01T10:00:00Z",
endTime: "2026-06-01T11:00:00Z",
timezone: "UTC",
};
const overlapping = [
{ startTime: "2026-06-01T10:00:00Z", endTime: "2026-06-01T11:00:00Z", timezone: "UTC" },
];
// Class capacity 20, already 18 booked, asking for 5 more
const tooFull = detectSlotCapacity(proposed, overlapping, {
mode: "slot_capacity", allowSameBoundaryTurnover: false,
bufferDuration: "PT0S", holdDuration: "PT10M", capacityMode: "absolute",
}, {
totalCapacity: 20,
currentBookedQuantity: 18,
requestedQuantity: 5,
});
// tooFull.hasConflict === true, tooFull.conflictingWindowRefs.length === 1
```
## API Reference
### `detectSlotCapacity(proposed, existing, _config, context?): OverlapResult`
| Parameter | Type | Notes |
|---|---|---|
| `proposed` | `TimeWindow` | The new booking window. |
| `existing` | `TimeWindow[]` | All existing bookings; only overlapping ones are considered. |
| `_config` | `ConflictDetectorConfig` | Accepted for signature parity; not consulted. |
| `context?` | `CapacityContext` | When provided, drives the conflict decision. |
Returns `{ hasConflict, conflictingWindowRefs, suggestedAlternativeWindows: [] }`. `conflictingWindowRefs` is populated only when `hasConflict` is true — callers can use it to surface "you're competing with these other group bookings".
### `CapacityContext`
`{ totalCapacity, currentBookedQuantity, requestedQuantity }`. Used to decide `currentBookedQuantity + requestedQuantity > totalCapacity`.
## Implementation Notes
- Without `context`, the detector cannot do capacity math; it returns `hasConflict: false` so the engine doesn't reject a request the caller hasn't actually pre-checked.
- Overlap uses half-open intervals; a slot ending exactly when another begins is NOT considered overlapping.
- The detector returns the original `existing` windows in `conflictingWindowRefs`, not buffered or adjusted versions.

View File

@@ -0,0 +1,104 @@
# engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BookingEngine` is the booking system's pipeline orchestrator. It composes the registry of bookables, the persistent map of confirmed bookings + holds + waitlist entries, the `StateMachine` (driven by `BookingSystemConfig.statusTransitions`), the `EventBus` (emitting 15 lifecycle events), and the in-package `Hooks<BookingHookMap>` (transforming `booking:creating` and `booking:cancelling` data) into a single typed entry point that consumers wire from their domain layer. Every public method raises a `BookingError` on failure so callers can dispatch on stable codes (`BOOKING.ENGINE_NOT_CONFIGURED`, `BOOKING.BOOKING_NOT_FOUND`, `BOOKING.BOOKING_CONFLICT`, `BOOKING.TRANSITION_NOT_ALLOWED`).
## Features
- Generic over `TBookableMeta` and `TBookingMeta` for typed metadata flow-through.
- 17 public methods + the `bookingHooks` accessor exposing the engine's `Hooks` instance.
- In-memory `Map<id, …>` registries for bookables, bookings, and holds.
- Typed errors on every failure path.
- Pluggable hooks pipeline for transforming create/cancel data before persistence.
## Usage: Basic
```ts
import { createBookingEngine, createSeatConfig } from "@suaveplan/booking";
const engine = createBookingEngine();
engine.configure(createSeatConfig("arena", "Main Arena"));
```
## Usage: Advanced
```ts
import {
BookingError, BOOKING_CODES, createBookingEngine, createSeatConfig,
} from "@suaveplan/booking";
const engine = createBookingEngine();
engine.configure(createSeatConfig("arena", "Main Arena"));
engine.bookingHooks.hook("booking:creating", (data) => ({
...data, customerRef: data.customerRef.toLowerCase(),
}));
engine.on("booking:confirmed", (event) => {
console.log("Confirmed:", event.payload);
});
engine.registerBookable({
identifierRef: { id: "seat-A12", tenantId: "arena", categoryId: "vip" },
name: "Seat A12", description: "Aisle", bookableType: "seat",
status: "active", layoutNodeRef: null, capacityRef: "cap-A12",
priceRuleRefs: [], timeSlotRefs: [], attributes: {},
});
try {
const booking = engine.confirmBooking({
bookableRef: "seat-A12",
customerRef: "Customer-42",
windowRef: { startTime: "2026-06-01T19:00:00Z", endTime: "2026-06-01T22:00:00Z", timezone: "UTC" },
quantity: 1,
});
console.log("Booking id:", booking.identifierRef.id);
console.log("Customer (lowercased):", booking.customerRef);
} catch (err) {
if (err instanceof BookingError && err.code === BOOKING_CODES.BOOKING_CONFLICT) {
console.warn("Conflict with", err.bookingId);
}
}
```
## API Reference
### `class BookingEngine<TBookableMeta, TBookingMeta>`
| Method | Returns | Throws |
|---|---|---|
| `configure(config)` | `void` | — |
| `bookingHooks` (getter) | `Hooks<BookingHookMap>` | — |
| `registerBookable(b)` | `void` | `ENGINE_NOT_CONFIGURED` |
| `getBookable(id)` | `Bookable \| null` | — |
| `listBookables(query)` | `BookableQueryResult` | — |
| `checkAvailability(query)` | `AvailabilityResult` | `ENGINE_NOT_CONFIGURED` |
| `getAvailableSlots(id, window)` | `TimeSlot[]` | `ENGINE_NOT_CONFIGURED` |
| `createHold(req)` | `BookingHold` | `ENGINE_NOT_CONFIGURED` |
| `confirmBooking(req)` | `Booking` | `ENGINE_NOT_CONFIGURED`, `BOOKING_CONFLICT` |
| `cancelBooking(id, reason)` | `CancellationResult` | `ENGINE_NOT_CONFIGURED`, `BOOKING_NOT_FOUND`, `TRANSITION_NOT_ALLOWED` |
| `modifyBooking(id, changes)` | `Booking` | `ENGINE_NOT_CONFIGURED`, `BOOKING_NOT_FOUND` |
| `calculatePrice(req)` | `BookingPrice` | `ENGINE_NOT_CONFIGURED` |
| `joinWaitlist(req)` | `WaitlistEntry` | `ENGINE_NOT_CONFIGURED` |
| `on(event, handler)` | `string` | — |
| `off(event, handlerOrId)` | `void` | — |
| `getBooking(id)` | `Booking \| null` | — |
| `getHold(id)` | `BookingHold \| null` | — |
### `createBookingEngine<TBookableMeta, TBookingMeta>(): BookingEngine<…>`
Factory; equivalent to `new BookingEngine()`.
## Implementation Notes
- Bookables, bookings, and holds are stored in in-memory `Map<>`s; consumers wanting persistence wrap their own backing store.
- The engine consults its `StateMachine` (built from `config.statusTransitions`) on cancellation; the role is hard-coded to `"customer"` to match upstream. Future change can thread the caller's role through.
- Hooks fire BEFORE availability and pricing checks, so handlers can rewrite the booking shape (e.g. lowercase email, expand quantity) without engine-side validation re-runs.
- `confirmBooking` deletes the supplied `holdId` before checking availability so a held window does not flag itself as a conflict.
- `checkAvailability` returns `{ available: false, ... }` (not throw) when the bookable is unknown — the engine treats unknown bookables as fully booked from the consumer's perspective.
- `getAvailableSlots` uses a fixed 1-hour slot duration; consumers wanting variable slot duration call `generateTimeSlots` from `src/temporal/slots/` directly.

View File

@@ -0,0 +1,80 @@
# event-bus
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Typed publish/subscribe event bus for booking lifecycle events. The engine emits the 15 standard `BookingEventType` events (booking confirmed, cancelled, held, waitlisted, …) through this bus; consumers register handlers via `on(eventType, handler)` and receive a wrapped `BookingEvent { eventType, timestamp, tenantId, payload }`. Subscriptions can be removed by id or by handler reference; the bus carries no global state beyond its own subscription map.
## Features
- `EventBus` class + `createEventBus` factory.
- Typed `on<TPayload>(eventType, handler) => string` returning a subscription id.
- `off` accepts either the id or the handler reference.
- `emit<TPayload>(eventType, tenantId, payload)` wraps payload into `BookingEvent`.
- `clear()` drops every subscription.
- `listenerCount(eventType)` and `eventTypes()` introspection.
## Usage: Basic
```ts
import { createEventBus } from "@suaveplan/booking";
const bus = createEventBus();
const id = bus.on("booking:confirmed", (event) => {
console.log("Confirmed", event.payload);
});
bus.emit("booking:confirmed", "tenant-1", { bookingId: "abc" });
bus.off("booking:confirmed", id);
```
## Usage: Advanced
```ts
import { createEventBus } from "@suaveplan/booking";
import type { Booking, BookingEventHandler } from "@suaveplan/booking";
const bus = createEventBus();
const onConfirmed: BookingEventHandler<Booking> = (event) => {
sendReceipt(event.payload.identifierRef.id, event.payload.customerRef);
};
bus.on("booking:confirmed", onConfirmed);
bus.on("hold:created", (e) => console.log("hold", e.payload));
// Remove by handler reference (no id needed)
bus.off("booking:confirmed", onConfirmed);
// Observability snapshot
console.log({
types: bus.eventTypes(),
holdListeners: bus.listenerCount("hold:created"),
});
```
## API Reference
### `class EventBus`
| Method | Returns | Notes |
|---|---|---|
| `on<TPayload>(eventType, handler)` | `string` | Subscription id. |
| `off(eventType, handlerOrId)` | `void` | Accepts id OR handler reference. |
| `emit<TPayload>(eventType, tenantId, payload)` | `void` | Wraps payload in `BookingEvent`. |
| `clear()` | `void` | Drops every subscription. |
| `listenerCount(eventType)` | `number` | 0 if none. |
| `eventTypes()` | `BookingEventType[]` | Currently subscribed types. |
### `createEventBus(): EventBus`
Factory. Equivalent to `new EventBus()`.
## Implementation Notes
- Subscriptions are stored as `Map<BookingEventType, EventSubscription[]>` so dispatch is O(n) over handlers for one event, never over all events.
- `generateSubscriptionId` produces process-unique ids via a module-level counter + `Date.now()` — adequate for in-memory tracking; replace if multi-process correlation is needed.
- `emit` is synchronous; handlers run in registration order. Uncaught exceptions propagate to the emitter — wrap handler bodies in try/catch if necessary.
- The bus does not buffer — emitting before any subscription is registered drops the event silently. Consumers needing replay semantics should layer their own store on top.

View File

@@ -0,0 +1,82 @@
# state-machine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Configurable booking-status state machine. Consumers pass an array of `StatusTransition` rows (from `BookingSystemConfig.statusTransitions`) to the constructor; the machine then answers `canTransition`, lists available transitions per role, surfaces auto-transition candidates, and enumerates every known status. Because the rules are pure data, the same engine instance can serve every domain (seats, accommodation, appointments, …) without subclassing.
## Features
- `StateMachine` class + `createStateMachine` factory.
- `canTransition(request)` — returns `{ allowed, from, to, message }`.
- Three failure modes — undefined transition, role-restricted, reason-required.
- `getAvailableTransitions(from, role)` — every transition currently legal for a role.
- `getAutoTransitions(from)` — transitions carrying `autoTransitionAfter` (consumers schedule the firing).
- `getAllStatuses()` — union of every `from` and `to` referenced.
## Usage: Basic
```ts
import { createStateMachine } from "@suaveplan/booking";
const sm = createStateMachine([
{ from: "pending", to: "confirmed", allowedRoles: ["system"], requiresReason: false, autoTransitionAfter: null },
{ from: "confirmed", to: "cancelled", allowedRoles: ["customer"], requiresReason: true, autoTransitionAfter: null },
]);
sm.canTransition({ from: "pending", to: "confirmed", role: "system" });
// { allowed: true, ... }
```
## Usage: Advanced
```ts
import { StateMachine } from "@suaveplan/booking";
const sm = new StateMachine(cfg.statusTransitions);
// What can a staff member do to a checked-in booking?
const next = sm.getAvailableTransitions("checked_in", "staff");
// [{ from: "checked_in", to: "completed", ... }]
// Auto-cancel anything that's been confirmed for 24h without check-in
const autos = sm.getAutoTransitions("confirmed");
for (const t of autos) scheduleAutoFire(t.to, t.autoTransitionAfter);
// What statuses exist altogether?
const all = sm.getAllStatuses();
// ["pending", "confirmed", "checked_in", "completed", "cancelled", "no_show"]
```
## API Reference
### `class StateMachine`
| Method | Returns | Notes |
|---|---|---|
| `canTransition(request)` | `TransitionResult` | Single source of truth for "can X happen?". |
| `getAvailableTransitions(from, role)` | `StatusTransition[]` | Filtered by `from` AND `allowedRoles.includes(role)`. |
| `getAutoTransitions(from)` | `StatusTransition[]` | Filtered by `from` AND `autoTransitionAfter !== null`. |
| `getAllStatuses()` | `string[]` | Union of every from/to reference. |
### `TransitionRequest`
`{ from, to, role, reason? }`. `reason` is optional but required for transitions whose row has `requiresReason: true`.
### `TransitionResult`
`{ allowed, from, to, message }`. `message` is human-readable; consumers can surface it directly.
### `createStateMachine(transitions): StateMachine`
Convenience factory; equivalent to `new StateMachine(transitions)`.
## Implementation Notes
- The machine does not enforce auto-transitions itself — `autoTransitionAfter` is metadata for consumers to schedule.
- `canTransition` returns `allowed: false` rather than throwing because the state-machine is consulted on every cancellation/modification request and exceptions would hurt observability.
- Failure messages name the relevant field (transition / role / reason) so consumers can surface accurate diagnostics to end-users.
- `getAllStatuses` uses a `Set` for deduplication; the returned array order matches insertion order.

View File

@@ -0,0 +1,77 @@
# accommodation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the accommodation booking domain — hotels, vacation rentals, hostels. Each bookable represents one bookable room that hosts at most one booking at a time across continuous date ranges. The preset configures `conflictRef.mode = "range_overlap"` with `allowSameBoundaryTurnover: true` and a 4-hour cleaning `bufferDuration`, plus a 15-minute hold default. The `maxAdvanceBookingDays` is raised to 365 to accommodate next-year travel planning. The `bookableTypeRegistry` defines required (`roomNumber`, `roomTypeId`, `maxOccupancy`) and optional (`floor`, `viewType`) attributes.
## Features
- `createAccommodationConfig(tenantId, tenantName, overrides?)` — preset factory.
- `AccommodationBookableMeta` — typed attribute bag for accommodation bookables.
- Defaults `pricingModel` to `"per_duration"`, `conflictMode` to `"range_overlap"`.
- 4-hour cleaning buffer, boundary turnover allowed.
- 365-day advance booking window.
## Usage: Basic
```ts
import { createAccommodationConfig } from "@suaveplan/booking";
const cfg = createAccommodationConfig("hotel-1", "Seaside Hotel");
```
## Usage: Advanced
```ts
import { createAccommodationConfig } from "@suaveplan/booking";
import type { AccommodationBookableMeta, Bookable } from "@suaveplan/booking";
const cfg = createAccommodationConfig("hotel-1", "Seaside Hotel", {
pricingRef: {
defaultCurrency: "EUR",
supportedCurrencies: ["EUR", "USD", "GBP"],
taxStrategy: "inclusive",
roundingMode: "ceil",
roundingPrecision: 2,
},
});
const room101: Bookable<AccommodationBookableMeta> = {
identifierRef: { id: "room-101", tenantId: "hotel-1", categoryId: "deluxe" },
name: "Deluxe Sea View 101",
description: "King bed, balcony",
bookableType: "hotel-room",
status: "active",
layoutNodeRef: "node-room-101",
capacityRef: "cap-room-101",
priceRuleRefs: ["rate-nightly"],
timeSlotRefs: [],
attributes: {
roomNumber: "101", roomTypeId: "deluxe",
floor: 1, bedConfiguration: "king",
maxOccupancy: 2, viewType: "sea",
amenityIds: ["wifi", "minibar", "ac"],
},
};
```
## API Reference
### `createAccommodationConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a `BookingSystemConfig` based on `createBaseConfig` with accommodation-specific overrides (range_overlap mode, 4h buffer, 365-day advance) applied first, then user `overrides` applied last.
### `AccommodationBookableMeta`
`{ roomNumber, roomTypeId, floor, bedConfiguration, maxOccupancy, amenityIds, viewType }`.
## Implementation Notes
- `allowSameBoundaryTurnover: true` lets check-out at 11 and check-in at 14 coexist for the same room without conflicting at the boundary.
- The 4-hour cleaning buffer is symmetric — it extends existing windows on both sides for overlap checks.
- `defaultPricingModel: "per_duration"` flags that the calculator should treat `unitDuration` as the rate unit (typically `"P1D"` for nightly).
- Consumers can switch to per-stay (fixed) pricing via per-rule overrides without modifying the preset.

View File

@@ -0,0 +1,74 @@
# activities
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the activity/tour booking domain — group tours, classes, workshops, group events. Each bookable represents a scheduled group activity with min/max participant counts. The preset configures `conflictRef.mode = "slot_capacity"`, `engineRef.enableWaitlist: true` (popular tours commonly overflow), and a `bookableTypeRegistry` entry with required (`activityTypeId`, `maxParticipants`) and optional (`minParticipants`, `instructorRef`, `difficultyLevel`) attributes.
## Features
- `createActivityConfig(tenantId, tenantName, overrides?)` — preset factory.
- `ActivityBookableMeta` — typed attribute bag.
- Defaults `pricingModel` to `"per_unit"` (per participant), `conflictMode` to `"slot_capacity"`, `supportsLayout: false`.
- `enableWaitlist: true` by default.
## Usage: Basic
```ts
import { createActivityConfig } from "@suaveplan/booking";
const cfg = createActivityConfig("studio-1", "Mountain Adventures");
```
## Usage: Advanced
```ts
import { createActivityConfig } from "@suaveplan/booking";
import type { ActivityBookableMeta, Bookable } from "@suaveplan/booking";
const cfg = createActivityConfig("studio-1", "Mountain Adventures");
const climbing: Bookable<ActivityBookableMeta> = {
identifierRef: { id: "tour-climb-friday", tenantId: "studio-1", categoryId: "outdoor" },
name: "Friday Climbing Tour",
description: "Half-day intermediate climb",
bookableType: "activity",
status: "active",
layoutNodeRef: null,
capacityRef: "cap-climb-friday",
priceRuleRefs: ["rate-perperson"],
timeSlotRefs: [],
attributes: {
activityTypeId: "climbing",
instructorRef: "guide-mary",
minParticipants: 3,
maxParticipants: 8,
difficultyLevel: "intermediate",
equipmentIncluded: true,
meetingPointRef: "base-camp-A",
},
};
```
## API Reference
### `createActivityConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a config with activity-specific defaults (slot_capacity mode, per_unit pricing, waitlist enabled).
### `ActivityBookableMeta`
`{ activityTypeId, instructorRef, minParticipants, maxParticipants, difficultyLevel, equipmentIncluded, meetingPointRef }`.
## Implementation Notes
- `per_unit` pricing means the calculator multiplies the rate by the booking `quantity` (participants), matching standard per-person tour pricing.
- `minParticipants` is metadata only — the engine does not cancel undersold activities itself; consumers schedule cancellation logic.
- `equipmentIncluded` and `meetingPointRef` give the UI extra context for confirmation emails without requiring a custom schema.
- `supportsLayout: false` because group activities typically don't have a spatial selection UI.
- The default `engineRef.maxAdvanceBookingDays` of 90 (inherited from the base preset) suits short-cycle activity bookings well; long-lead expeditions or trekking trips that need 6-month visibility override this through the third argument.
- Pair the activity preset with `slot_capacity` conflict detection plus a custom `CapacityContext` that reads `attributes.maxParticipants` per bookable for accurate occupancy math.
- Consumers integrating with notification systems can attach hooks on `engine.bookingHooks` to send instructor-assignment confirmations at create time and waitlist-promotion alerts on cancellation.

View File

@@ -0,0 +1,75 @@
# appointments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the appointment booking domain — medical, dental, salon, consulting. Each bookable represents an appointment slot tied to a service provider whose calendar must never double-book. The preset configures `conflictRef.mode = "provider_overlap"` with a 15-minute buffer, sets `engineRef.enableRecurring` and `enableWaitlist` to true (recurring appointments and overflow queues are standard in this domain), and registers a `bookableTypeRegistry` entry with required (`providerId`, `serviceTypeId`, `durationMinutes`) and optional (`locationId`, `bufferBeforeMinutes`, `bufferAfterMinutes`) attributes.
## Features
- `createAppointmentConfig(tenantId, tenantName, overrides?)` — preset factory.
- `AppointmentBookableMeta` — typed attribute bag (extends to allow-concurrent semantics).
- Defaults `pricingModel` to `"fixed"`, `conflictMode` to `"provider_overlap"`, `supportsLayout: false`.
- 15-minute buffer between back-to-back appointments.
- Recurring AND waitlist enabled by default.
## Usage: Basic
```ts
import { createAppointmentConfig } from "@suaveplan/booking";
const cfg = createAppointmentConfig("clinic-1", "Heartbeat Clinic");
```
## Usage: Advanced
```ts
import { createAppointmentConfig } from "@suaveplan/booking";
import type { AppointmentBookableMeta, Bookable } from "@suaveplan/booking";
const cfg = createAppointmentConfig("clinic-1", "Heartbeat Clinic", {
holdRef: { enabled: true, defaultDurationMinutes: 5, maxDurationMinutes: 10, autoReleaseEnabled: true },
});
const checkup: Bookable<AppointmentBookableMeta> = {
identifierRef: { id: "appt-dr-jones", tenantId: "clinic-1", categoryId: "general" },
name: "GP Consultation",
description: "Routine checkup with Dr. Jones",
bookableType: "appointment",
status: "active",
layoutNodeRef: null,
capacityRef: "cap-dr-jones",
priceRuleRefs: ["rate-consult"],
timeSlotRefs: [],
attributes: {
providerId: "dr-jones",
serviceTypeId: "checkup",
durationMinutes: 30,
locationId: "clinic-east",
bufferBeforeMinutes: 5,
bufferAfterMinutes: 10,
allowConcurrent: false,
maxConcurrent: 1,
},
};
```
## API Reference
### `createAppointmentConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a config with appointment-specific defaults (provider_overlap, 15-min buffer, recurring + waitlist enabled).
### `AppointmentBookableMeta`
`{ providerId, serviceTypeId, durationMinutes, locationId, bufferBeforeMinutes, bufferAfterMinutes, allowConcurrent, maxConcurrent }`.
## Implementation Notes
- `enableRecurring: true` reflects the common medical-clinic pattern of weekly therapy slots; the engine doesn't auto-generate occurrences but `expandRecurrence` from `src/temporal/recurrence/` is the right helper for consumers.
- `bufferDuration: "PT15M"` applies engine-wide for this preset; per-bookable buffer fields (`bufferBeforeMinutes`/`bufferAfterMinutes`) give finer-grained control consumers can read from the attribute bag.
- `supportsLayout: false` because providers do not need spatial visualisation.
- `enableWaitlist: true` because clinics regularly queue patients for next-available appointments.

View File

@@ -0,0 +1,83 @@
# resources
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the resource booking domain — meeting rooms, equipment, shared vehicles. Each bookable represents a resource with a typed capacity that can be partially consumed by overlapping bookings. The preset configures `conflictRef.mode = "slot_capacity"` (so the engine reports conflicts only when total occupancy would exceed capacity), `engineRef.domainPreset = "resources"`, and a `bookableTypeRegistry` entry with required (`resourceTypeId`, `capacity`) and optional (`locationId`, `requiresApproval`) attributes.
## Features
- `createResourceConfig(tenantId, tenantName, overrides?)` — preset factory.
- `ResourceBookableMeta` — typed attribute bag.
- Defaults `pricingModel` to `"per_duration"`, `conflictMode` to `"slot_capacity"`, `supportsLayout: true`.
- No buffer, 10-min hold.
## Usage: Basic
```ts
import { createResourceConfig } from "@suaveplan/booking";
const cfg = createResourceConfig("co-1", "Coworking Space");
```
## Usage: Advanced
```ts
import { createResourceConfig } from "@suaveplan/booking";
import type { Bookable, ResourceBookableMeta } from "@suaveplan/booking";
const cfg = createResourceConfig("co-1", "Coworking Space", {
engineRef: {
domainPreset: "resources",
customStrategies: [],
enableHolds: true,
enableWaitlist: true,
enableRecurring: true,
maxBookingsPerCustomer: 50,
maxAdvanceBookingDays: 60,
},
});
const meetingRoom: Bookable<ResourceBookableMeta> = {
identifierRef: { id: "room-large-conference", tenantId: "co-1", categoryId: "meeting-room" },
name: "Large Conference Room",
description: "Seats 20",
bookableType: "resource",
status: "active",
layoutNodeRef: "node-room-1",
capacityRef: "cap-room-1",
priceRuleRefs: ["rate-hourly"],
timeSlotRefs: [],
attributes: {
resourceTypeId: "meeting-room",
locationId: "floor-3",
capacity: 20,
featureIds: ["projector", "whiteboard", "video-conferencing"],
requiresApproval: false,
approverRef: "",
},
};
```
## API Reference
### `createResourceConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a config with resource-specific defaults (slot_capacity mode, per_duration pricing).
### `ResourceBookableMeta`
`{ resourceTypeId, locationId, capacity, featureIds, requiresApproval, approverRef }`.
## Implementation Notes
- `slot_capacity` mode allows multiple bookings to share a resource up to the per-bookable `capacity` field (the engine reads it from `attributes` via the `slot_capacity` detector's `CapacityContext`).
- `requiresApproval` is metadata only — the engine does not enforce approval workflows; consumers gate confirmation on this field.
- `supportsLayout: true` lets floor-plan UIs render the resource as a clickable area.
- `per_duration` pricing means hourly/daily rates with `unitDuration` driving the unit.
- Resources commonly need recurring bookings (a meeting room booked every Tuesday); pair the preset with the recurrence helpers from `@suaveplan/booking/temporal/recurrence/recurrence` to materialise occurrences in advance.
- The `featureIds` attribute is a free-form bag of capability tokens (projector, whiteboard, video-conferencing). Search UIs can filter resources by feature without the engine knowing what each feature means.
- When `requiresApproval` is true, consumers typically combine the preset with a `BookingRule` (action `deny` until status flips to `approved`) so the engine treats unapproved bookings as conflicts.

View File

@@ -0,0 +1,77 @@
# seats
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the seat-based booking domain — stadiums, theatres, cinemas, flights. Each bookable represents one physical seat that can be booked at most once per event window. The preset configures `conflictRef.mode = "exact_match"` (because the only conflict for a seat is "same seat, same event"), `engineRef.domainPreset = "seats"`, and a `bookableTypeRegistry` entry describing the required (`seatNumber`, `rowLabel`, `sectionLabel`) and optional (`tier`, `isAccessible`, `hasObstructedView`) attributes for a seat. The companion `SeatBookableMeta` type exposes the attribute shape consumers should attach to `Bookable<SeatBookableMeta>`.
## Features
- `createSeatConfig(tenantId, tenantName, overrides?)` — preset factory.
- `SeatBookableMeta` — typed attribute bag for seat bookables.
- Defaults `pricingModel` to `"fixed"`, `conflictMode` to `"exact_match"`, `supportsLayout` to `true`.
- Three required attributes (`seatNumber`, `rowLabel`, `sectionLabel`), three optional (`tier`, `isAccessible`, `hasObstructedView`).
## Usage: Basic
```ts
import { createSeatConfig } from "@suaveplan/booking";
const cfg = createSeatConfig("arena-1", "Main Arena");
```
## Usage: Advanced
```ts
import { createSeatConfig } from "@suaveplan/booking";
import type { Bookable, SeatBookableMeta } from "@suaveplan/booking";
const cfg = createSeatConfig("arena-1", "Main Arena", {
engineRef: {
domainPreset: "seats",
customStrategies: [],
enableHolds: true,
enableWaitlist: true,
enableRecurring: false,
maxBookingsPerCustomer: 8,
maxAdvanceBookingDays: 180,
},
});
const seatA12: Bookable<SeatBookableMeta> = {
identifierRef: { id: "seat-A12", tenantId: "arena-1", categoryId: "lower-bowl" },
name: "Seat A12",
description: "Aisle seat, lower bowl",
bookableType: "seat",
status: "active",
layoutNodeRef: "node-seat-A12",
capacityRef: "cap-seat-A12",
priceRuleRefs: ["rate-standard"],
timeSlotRefs: [],
attributes: {
seatNumber: "12", rowLabel: "A", sectionLabel: "Lower Bowl",
tier: "premium", isAccessible: true, hasObstructedView: false,
adjacentSeatIds: ["seat-A11", "seat-A13"],
},
};
```
## API Reference
### `createSeatConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a `BookingSystemConfig` based on `createBaseConfig` with seat-specific overrides applied first, then user `overrides` applied last (so callers always win).
### `SeatBookableMeta`
`{ seatNumber, rowLabel, sectionLabel, tier, isAccessible, hasObstructedView, adjacentSeatIds }`.
## Implementation Notes
- `conflictRef.mode = "exact_match"` because two bookings only conflict for the same seat if they share start AND end times.
- `supportsLayout: true` flags this domain as renderable via the layout system; downstream visualisations (seat maps) consume this hint.
- `adjacentSeatIds` enables group-booking adjacency requirements; the engine itself does not enforce adjacency but consumers can read this field to drive UI.
- The factory spreads user `overrides` LAST, so a caller asking for `enableWaitlist: true` wins over the seat-preset default.

View File

@@ -0,0 +1,74 @@
# shared
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createBaseConfig(tenantId, tenantName)` — the canonical default `BookingSystemConfig` factory that every domain preset spreads over and overrides. Centralising defaults here means the five status transitions, the holds policy, the availability cache window, the pricing rounding, the locale defaults, and the empty rules/conditions/price-catalogue arrays all stay in one place. Domain presets then override only the fields that matter for their domain (typically `engineRef.domainPreset`, `conflictRef.mode`, and `bookableTypeRegistry`).
## Features
- Single base factory returning a fully-populated `BookingSystemConfig`.
- Five default status transitions covering pending → confirmed → checked_in → completed plus cancelled/no_show.
- Sensible-by-default holds (enabled, 10-min default duration, 30-min cap).
- Pricing defaults to USD with exclusive tax and 2-decimal rounding.
- Availability cache enabled with 60s TTL and 30s refresh.
## Usage: Basic
```ts
import { createBaseConfig } from "@suaveplan/booking";
const cfg = createBaseConfig("tenant-1", "Example Co.");
```
## Usage: Advanced
```ts
import { createBaseConfig } from "@suaveplan/booking";
import type { BookingSystemConfig } from "@suaveplan/booking";
const cfg: BookingSystemConfig = {
...createBaseConfig("clinic-1", "Heartbeat Clinic"),
engineRef: {
...createBaseConfig("clinic-1", "Heartbeat Clinic").engineRef,
enableWaitlist: true,
maxBookingsPerCustomer: 3,
},
pricingRef: {
defaultCurrency: "EUR",
supportedCurrencies: ["EUR", "USD"],
taxStrategy: "inclusive",
roundingMode: "round",
roundingPrecision: 2,
},
};
```
## API Reference
### `createBaseConfig(tenantId: string, tenantName: string): BookingSystemConfig`
Returns a fresh `BookingSystemConfig` with:
- `schemaVersion: "1.0.0"`
- `tenantRef: { tenantId, tenantName, multiTenant: false, isolationLevel: "row" }`
- `engineRef.domainPreset: "custom"`, `enableHolds: true`, `maxAdvanceBookingDays: 90`
- `availabilityRef`: `{ refreshIntervalMs: 30000, cacheEnabled: true, cacheTtlMs: 60000 }`
- `conflictRef`: `range_overlap` with no buffer, no boundary turnover
- `pricingRef`: USD, exclusive tax, round, precision 2
- `holdRef`: enabled, 10-min default, 30-min cap, auto-release
- `waitlistRef`: disabled by default
- `notificationRef`: disabled by default
- Empty `rules`, `conditions`, all price arrays, `bookableTypeRegistry`
- `statusTransitions`: the five canonical transitions
- `layoutRef: null`, en-US locale, 12h time, MM/dd/yyyy date
## Implementation Notes
- The factory returns a fresh object on each call — safe to spread into preset configs without sharing references.
- The status transitions are JSON-safe; consumers can serialise the entire config tree losslessly.
- The `auto_transition_after: "PT24H"` on the `confirmed → no_show` transition is metadata; the engine does not schedule the firing itself.
- Presets override fields by spreading the base then assigning, so adding a new field here propagates to every preset without changes.

View File

@@ -0,0 +1,79 @@
# transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Factory for the transport booking domain — flights, trains, buses. Each bookable represents a single transport-seat on a specific vehicle and route at a specific departure time. The preset configures `conflictRef.mode = "exact_match"` (a seat is booked iff that exact seat+leg is already taken), `engineRef.enableWaitlist: true` (overbooked transports run waitlists routinely), and a `bookableTypeRegistry` entry with required (`vehicleId`, `routeId`, `classId`) and optional (`seatNumber`, `legIndex`) attributes.
## Features
- `createTransportConfig(tenantId, tenantName, overrides?)` — preset factory.
- `TransportBookableMeta` — typed attribute bag (extends with `departureStationId`, `arrivalStationId`).
- Defaults `pricingModel` to `"fixed"`, `conflictMode` to `"exact_match"`, `supportsLayout: true`.
- 15-minute hold default; longer than other presets because transport checkout flows take longer.
## Usage: Basic
```ts
import { createTransportConfig } from "@suaveplan/booking";
const cfg = createTransportConfig("airline-1", "Albatross Airways");
```
## Usage: Advanced
```ts
import { createTransportConfig } from "@suaveplan/booking";
import type { Bookable, TransportBookableMeta } from "@suaveplan/booking";
const cfg = createTransportConfig("airline-1", "Albatross Airways", {
pricingRef: {
defaultCurrency: "USD",
supportedCurrencies: ["USD", "EUR", "GBP", "JPY"],
taxStrategy: "exclusive",
roundingMode: "round",
roundingPrecision: 2,
},
});
const seat24A: Bookable<TransportBookableMeta> = {
identifierRef: { id: "flight-ABC123-24A", tenantId: "airline-1", categoryId: "domestic" },
name: "Flight ABC123 - 24A",
description: "Window seat, economy",
bookableType: "transport-seat",
status: "active",
layoutNodeRef: "node-seat-24A",
capacityRef: "cap-flight-ABC123",
priceRuleRefs: ["rate-economy"],
timeSlotRefs: [],
attributes: {
vehicleId: "B737-MAX-001",
routeId: "JFK-LHR-2026-06-15",
classId: "economy",
seatNumber: "24A",
departureStationId: "JFK",
arrivalStationId: "LHR",
legIndex: 0,
},
};
```
## API Reference
### `createTransportConfig(tenantId, tenantName, overrides?): BookingSystemConfig`
Returns a config with transport-specific defaults (exact_match mode, fixed pricing, waitlist enabled).
### `TransportBookableMeta`
`{ vehicleId, routeId, classId, seatNumber, departureStationId, arrivalStationId, legIndex }`. `seatNumber` is nullable for unassigned-seating modes.
## Implementation Notes
- `exact_match` mode is correct for seat-assigned transport — two bookings only conflict when the same `bookableRef` covers the same window.
- `legIndex` enables multi-leg itineraries where one ticket covers several segments; consumers compose multiple bookables when needed.
- `supportsLayout: true` enables seat-map renderers (similar to the seats domain) so airlines can offer visual seat selection.
- `enableWaitlist: true` accommodates standby passenger queues that transport operators routinely run.

View File

@@ -0,0 +1,94 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed `BookingError` class for `@suaveplan/booking`. Every failure path the package exposes raises a `BookingError` carrying one of the seven codes registered here (`ENGINE_NOT_CONFIGURED`, `BOOKING_NOT_FOUND`, `BOOKING_CONFLICT`, `TRANSITION_NOT_ALLOWED`, `UNKNOWN_CONFLICT_MODE`, `UNKNOWN_AVAILABILITY_STRATEGY`, `HOOK_RETURNED_PROMISE_IN_SYNC`). Codes are namespaced strings (`"BOOKING.BOOKING_CONFLICT"`) so consumers can programmatically dispatch without parsing messages, and the typed error narrows discriminants (`bookingId`, `holdId`, `mode`, `strategy`) for diagnostics.
## Features
- `BOOKING_CODES` — `defineCodes("BOOKING", [...])` registry; the canonical source of truth.
- `BookingError extends SuaveplanError<BookingErrorCode>` — typed error class with optional discriminants.
- `BookingErrorCode` — union of every registered code.
- `BookingErrorOptions` — Zod-friendly options bag extending `SuaveplanErrorOptions`.
- Discriminants flow through to OTel span events via `SuaveplanError.toSpanEvent()`.
## Usage: Basic
```ts
import { BOOKING_CODES, BookingError } from "@suaveplan/booking";
function getBooking(id: string) {
const stored = lookup(id);
if (!stored) {
throw new BookingError(
BOOKING_CODES.BOOKING_NOT_FOUND,
`Booking '${id}' not found`,
{ bookingId: id }
);
}
return stored;
}
```
## Usage: Advanced
```ts
import { BOOKING_CODES, BookingError } from "@suaveplan/booking";
try {
engine.confirmBooking(req);
} catch (err) {
if (err instanceof BookingError) {
switch (err.code) {
case BOOKING_CODES.ENGINE_NOT_CONFIGURED:
return reconfigureAndRetry();
case BOOKING_CODES.BOOKING_CONFLICT:
return suggestAlternativeSlot(err.bookingId);
case BOOKING_CODES.TRANSITION_NOT_ALLOWED:
return promptForReason(err.bookingId);
default:
throw err;
}
}
throw err;
}
```
## API Reference
### `BOOKING_CODES`
A frozen registry created via `defineCodes("BOOKING", [...] as const)`. Each entry maps the bare key (e.g. `ENGINE_NOT_CONFIGURED`) to its namespaced string (`"BOOKING.ENGINE_NOT_CONFIGURED"`).
### `BookingErrorCode`
Union type `(typeof BOOKING_CODES)[keyof typeof BOOKING_CODES]`. Use to type-check `error.code` in catch blocks.
### `BookingError(code, message, options?)`
Constructor parameters:
- `code: BookingErrorCode` — required.
- `message: string` — required, English diagnostic.
- `options.bookingId?: string` — attaches `error.bookingId`.
- `options.holdId?: string` — attaches `error.holdId`.
- `options.mode?: string` — attaches `error.mode` (conflict-mode).
- `options.strategy?: string` — attaches `error.strategy` (availability strategy id).
- All `SuaveplanErrorOptions` fields (cause, context, retriable, etc.) are forwarded to the base.
Throws nothing. Construction never fails.
### `BookingErrorOptions`
Interface extending `SuaveplanErrorOptions` with the four optional discriminants above.
## Implementation Notes
- `BOOKING_CODES` is declared `as const` so TypeScript can infer the union from the literal tuple — adding a new code requires updating callers because the union widens.
- Discriminants are assigned conditionally (only when defined) so `JSON.stringify(error)` does not emit `"bookingId": undefined`.
- The class extends `SuaveplanError<BookingErrorCode>` with the generic threaded so `instanceof BookingError` narrows `code` to the union.
- Sole consumer rule: every `throw` in `src/` that surfaces to the public API must construct a `BookingError`. The conformance test and gate 6.3 enforce that every code is reachable from a production path.

View File

@@ -0,0 +1,94 @@
# hooks
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-package data-transform pipeline used by `BookingEngine` to give consumers a hook into the create-booking and cancel-booking lifecycle. Each registered handler receives the data flowing through the chain plus a `HookContext` and can transform, abort, or pass through. The implementation replaces the upstream `@suaveplan/events/hooks` dependency (not yet exposed ) with a small in-package class whose public surface is preserved verbatim, so consumers can adopt the engine without a separate events subpath migration.
## Features
- Priority-sorted handler chain (higher priority runs first).
- Synchronous (`applyHooks`) and asynchronous (`applyHooksAsync`) entry points.
- Detailed reporting via `applyHooksWithResult` (`{ data, aborted, handlersRun, labels }`).
- Once-handlers auto-remove after they fire.
- `context.abort()` short-circuits the chain.
- Typed via `Hooks<TMap extends HookMap>` so each hook name pins a payload type.
- Sync entry point throws `BookingError({ code: BOOKING.HOOK_RETURNED_PROMISE_IN_SYNC })` if a handler returns a thenable.
## Usage: Basic
```ts
import { Hooks } from "@suaveplan/booking";
interface Map { "order:processing": { items: string[]; total: number } }
const hooks = new Hooks<Map>();
hooks.hook("order:processing", (data) => ({ ...data, total: data.total * 1.2 }));
const out = hooks.applyHooks("order:processing", { items: ["a"], total: 100 });
// out.total === 120
```
## Usage: Advanced
```ts
import { Hooks } from "@suaveplan/booking";
interface Map { "user:creating": { name: string; email: string } }
const hooks = new Hooks<Map>();
const unsub = hooks.hook(
"user:creating",
(data, ctx) => {
if (!data.email.includes("@")) ctx.abort();
return { ...data, email: data.email.toLowerCase() };
},
{ priority: 10, label: "normalize-email" }
);
hooks.hook("user:creating", async (data) => {
const ok = await checkBlocklist(data.email);
return ok ? data : { ...data, name: "[blocked]" };
}, { once: true });
const result = await hooks.applyHooksWithResult("user:creating", {
name: "Alice", email: "ALICE@EXAMPLE.COM",
});
// result.handlersRun, result.labels === ["normalize-email"], result.aborted
unsub();
```
## API Reference
### `class Hooks<TMap extends HookMap>`
| Method | Returns | Notes |
|---|---|---|
| `hook(name, handler, options?)` | `() => void` | Register; returns an unsubscribe. Options: `priority`, `once`, `label`. |
| `unhook(name, handler)` | `void` | Remove by handler reference. |
| `unhookAll(name?)` | `void` | Remove all handlers for a hook (or all hooks). |
| `applyHooks(name, data)` | `TMap[K]` | Sync chain; throws on async return. |
| `applyHooksAsync(name, data)` | `Promise<TMap[K]>` | Async chain; awaits each handler. |
| `applyHooksWithResult(name, data)` | `Promise<HookResult<TMap[K]>>` | As above + `{ aborted, handlersRun, labels }`. |
| `hookNames()` | `(keyof TMap)[]` | Currently registered hook names. |
| `hookCount(name)` | `number` | Number of handlers for a hook. |
| `hasHook(name)` | `boolean` | True iff at least one handler is registered. |
### Types
- `HookMap` — index signature `{ [key: string]: unknown }`; extend for typed payloads.
- `HookHandler<T>` — `(data: T, ctx: HookContext) => T | undefined | Promise<T | undefined>`.
- `HookContext` — `{ hook, index, total, abort(), aborted, meta }`.
- `HookHandlerOptions` — `{ priority?, once?, label? }`.
- `HookResult<T>` — `{ data, aborted, handlersRun, labels }`.
- `RegisteredHook<T>` — internal record (`handler`, `priority`, `once`, `label`).
## Implementation Notes
- Storage uses `Map<key, RegisteredHook<unknown>[]>` with per-call narrowing to `HookHandler<TMap[K]>`; no `any` escape hatches.
- Once handlers are collected during iteration and removed in a post-pass so removal mid-chain does not skew the loop index.
- Async return inside `applyHooks` raises `BookingError({ code: BOOKING.HOOK_RETURNED_PROMISE_IN_SYNC })` rather than the upstream `console.warn` — universal libraries do not write to stdout.
- `applyHooksWithResult` is always async even when no handlers are registered (it returns a resolved promise) so consumers can treat it uniformly.
- Aborting only short-circuits the chain; data already transformed by previous handlers is returned.

View File

@@ -0,0 +1,71 @@
# calculator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the booking engine's price-calculation pipeline. `calculatePrice` is the canonical entry point: it runs base → tiers → modifiers → tax → rounding in a single deterministic pass against a `PriceCalculationContext`. The individual steps (`calculateBasePrice`, `applyTiers`, `applyModifiers`, `calculateTax`) are also exported so consumers can compose alternative pipelines or pre-compute partial values. Rounding honours `round` / `floor` / `ceil` modes at configurable precision.
## Features
- Four pricing models — `fixed`, `per_unit`, `per_duration`, `tiered` (plus permissive fallback).
- Quantity tiers via `applyTiers(baseAmount, quantity, tiers, ruleRefs)`.
- Four modifier types — `discount`, `surcharge`, `fee`, `tax`.
- Tax strategy: `exclusive` (10% default) or `inclusive` (no add-on tax when no tax modifier exists).
- Three rounding modes: `round`, `floor`, `ceil`.
- `BookingPrice.breakdown` carries the audit trail (`[...ruleIds, ...modifierIds]`).
## Usage: Basic
```ts
import { calculatePrice } from "@suaveplan/booking";
import type { PriceCalculationContext } from "@suaveplan/booking/pricing/calculator/calculator";
const ctx: PriceCalculationContext = {
bookableRef: "room-101",
windowRef: { startTime: "2026-06-01T14:00:00Z", endTime: "2026-06-03T11:00:00Z", timezone: "UTC" },
quantity: 1,
rules: [{ ruleId: "nightly", amount: 150, currency: "USD", pricingModel: "per_duration", unitDuration: "P1D" }],
tiers: [], modifiers: [], currency: "USD",
taxStrategy: "exclusive", roundingMode: "round", roundingPrecision: 2,
};
const price = calculatePrice(ctx);
// price.subtotal === 300, price.tax === 30, price.total === 330
```
## Usage: Advanced
```ts
import { applyModifiers, applyTiers, calculateBasePrice, calculateTax } from "@suaveplan/booking";
const base = calculateBasePrice(rules, 7, windowRef);
const tiered = applyTiers(base, 7, tiers, rules.map((r) => r.ruleId));
const { adjusted, fees, discount, applied } = applyModifiers(tiered, modifiers);
const tax = calculateTax(adjusted, "exclusive", modifiers);
console.log({ subtotal: adjusted, tax, fees, total: adjusted + tax + fees, discount, applied });
```
## API Reference
| Function | Returns | Notes |
|---|---|---|
| `calculateBasePrice(rules, quantity, windowRef)` | `number` | Handles `fixed` / `per_unit` / `per_duration` / `tiered` / default. |
| `applyTiers(baseAmount, quantity, tiers, ruleRefs)` | `number` | Picks the bracket whose `[minQuantity, maxQuantity]` contains `quantity`. |
| `applyModifiers(subtotal, modifiers)` | `ApplyModifiersResult` | `{ adjusted, fees, discount, applied }`. |
| `calculateTax(subtotal, taxStrategy, modifiers)` | `number` | Tax modifiers win; otherwise 10% on `exclusive`, 0 on `inclusive`. |
| `calculatePrice(context)` | `BookingPrice` | Runs the full pipeline with rounding. |
### `ApplyModifiersResult`
`{ adjusted, fees, discount, applied: string[] }`. `applied` lists every modifier id traversed.
## Implementation Notes
- `per_duration` falls back to a 1-day unit when `unitDuration` parses to `0` so missing data does not produce divide-by-zero.
- Tier brackets are sorted ascending by `minQuantity`; the first matching bracket wins. If no bracket matches, the un-tiered `baseAmount` is returned unchanged.
- `applyModifiers` walks modifiers in order; `discount` reduces the running subtotal AND increments the audit `discount` field, `surcharge` increases it, `fee` accumulates separately, `tax` is skipped here and accounted for in `calculateTax`.
- Rounding is applied to every component of `BookingPrice` independently so the breakdown stays consistent with the displayed total.
- `breakdown` concatenates rule ids first then modifier ids — preserving the order matters for downstream receipt rendering.

View File

@@ -0,0 +1,68 @@
# dynamic
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements dynamic-pricing rule evaluation. Three trigger types are supported — `occupancy_threshold`, `advance_days`, and `demand_score` — and matching rules compose multiplicatively against a base amount. Used by the engine (or by consumers composing custom pipelines) to model surge pricing during peak occupancy, last-minute discounts based on lead time, and demand-driven adjustments derived from external signals.
## Features
- Three trigger types (extensible via custom rules with `triggerType: "..."` and a custom evaluator if needed).
- `findApplicableDynamicRules(rules, context)` — every matching rule.
- `calculateDynamicMultiplier(rules, context)` — product of matching multipliers (1.0 when none match).
- `applyDynamicPricing(baseAmount, rules, context)` — convenience composition.
- `createDynamicContext(...)` — helper for building a `DynamicPricingContext` with default demand.
## Usage: Basic
```ts
import { applyDynamicPricing, createDynamicContext } from "@suaveplan/booking";
const rules = [
{ ruleId: "surge", priceRuleRef: "p", triggerType: "occupancy_threshold", threshold: 0.8, multiplier: 1.5 },
];
const ctx = createDynamicContext(0.9, 30);
applyDynamicPricing(100, rules, ctx); // 150
```
## Usage: Advanced
```ts
import {
calculateDynamicMultiplier, findApplicableDynamicRules,
} from "@suaveplan/booking";
const rules = [
{ ruleId: "surge", priceRuleRef: "p", triggerType: "occupancy_threshold", threshold: 0.85, multiplier: 1.3 },
{ ruleId: "lastmin", priceRuleRef: "p", triggerType: "advance_days", threshold: 3, multiplier: 0.85 },
{ ruleId: "viral", priceRuleRef: "p", triggerType: "demand_score", threshold: 80, multiplier: 1.2 },
];
const ctx = { occupancyRate: 0.9, advanceDays: 2, demandScore: 90 };
const matched = findApplicableDynamicRules(rules, ctx);
const m = calculateDynamicMultiplier(rules, ctx); // 1.3 * 0.85 * 1.2 ≈ 1.326
console.log(`Matched ${matched.length} rules, total multiplier: ${m.toFixed(3)}`);
```
## API Reference
| Function | Returns | Notes |
|---|---|---|
| `findApplicableDynamicRules(rules, context)` | `DynamicPricingRule[]` | Unknown `triggerType` evaluates `false`. |
| `calculateDynamicMultiplier(rules, context)` | `number` | 1.0 on no match. |
| `applyDynamicPricing(base, rules, context)` | `number` | base × multiplier. |
| `createDynamicContext(occupancyRate, advanceDays, demandScore = 0)` | `DynamicPricingContext` | Convenience constructor. |
### `DynamicPricingContext`
`{ occupancyRate, advanceDays, demandScore }` — three named axes the rules dispatch on.
## Implementation Notes
- Triggers use different comparison directions: `occupancy_threshold` and `demand_score` use `>=`, `advance_days` uses `<=` (last-minute discounts kick in BELOW the threshold).
- Unknown `triggerType` values evaluate `false` so a typo cannot silently amplify prices. Pair with the Zod schema to validate input.
- The multiplier composition is purely multiplicative; rules' priorities are not honoured here (the engine treats them all as independent).
- `createDynamicContext` defaults `demandScore` to `0` so consumers with no demand-tracking still get a well-typed context.

View File

@@ -0,0 +1,64 @@
# seasonal
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Applies seasonal-rate multipliers to a base price based on month/day windows. Supports wrap-around-year intervals (e.g. December 1 → February 28) for ski-season-style pricing. Used by the engine when a config carries `seasonalRates[]` and the booking window's calendar dates fall inside one or more configured seasons. Multiple matching seasons compose multiplicatively.
## Features
- `findApplicableRates(rates, windowRef)` — every season whose window overlaps the booking dates.
- Wrap-around-year handling via disjunctive overlap test.
- `calculateSeasonalMultiplier(rates)` — product of every applicable rate's multiplier.
- `applySeasonalRate(baseAmount, rates, windowRef)` — convenience over the above two.
- `getSeasonName(rates, windowRef)` — name of the first applicable season (or `null`).
## Usage: Basic
```ts
import { applySeasonalRate } from "@suaveplan/booking";
const summer = { rateId: "s", priceRuleRef: "p", seasonName: "Summer",
startMonth: 6, startDay: 1, endMonth: 8, endDay: 31, multiplier: 1.5 };
applySeasonalRate(100, [summer], {
startTime: "2026-07-15T14:00:00Z", endTime: "2026-07-20T11:00:00Z", timezone: "UTC",
}); // 150
```
## Usage: Advanced
```ts
import {
calculateSeasonalMultiplier, findApplicableRates, getSeasonName,
} from "@suaveplan/booking";
const winter = { rateId: "w", priceRuleRef: "p", seasonName: "Winter",
startMonth: 12, startDay: 1, endMonth: 2, endDay: 28, multiplier: 1.4 };
const peak = { rateId: "px", priceRuleRef: "p", seasonName: "Peak Week",
startMonth: 12, startDay: 24, endMonth: 12, endDay: 31, multiplier: 1.2 };
const window = { startTime: "2026-12-26T14:00:00Z", endTime: "2026-12-30T11:00:00Z", timezone: "UTC" };
const applicable = findApplicableRates([winter, peak], window);
calculateSeasonalMultiplier(applicable); // 1.4 * 1.2 = 1.68
getSeasonName([winter, peak], window); // "Winter" (first applicable)
```
## API Reference
| Function | Returns | Notes |
|---|---|---|
| `findApplicableRates(rates, windowRef)` | `SeasonalRate[]` | Empty array if none match. |
| `calculateSeasonalMultiplier(rates)` | `number` | Returns 1.0 for empty input. |
| `applySeasonalRate(baseAmount, rates, windowRef)` | `number` | Composes the two above. |
| `getSeasonName(rates, windowRef)` | `string \| null` | First applicable season's name. |
## Implementation Notes
- Season windows are evaluated in the booking window's start year — long bookings spanning a year boundary use the start year's calendar.
- Wrap-around test: when `seasonStart > seasonEnd` (December → February), the booking matches iff it's before `seasonEnd` OR after `seasonStart` — the disjunction correctly captures the wrap.
- `getSeasonName` returns `null` (not `""`) when no season matches so callers can branch on truthiness without ambiguity.
- All arithmetic happens via `Date.UTC` to avoid local-timezone surprises.

View File

@@ -0,0 +1,71 @@
# conditions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pre-built `RuleCondition` factories for the most common booking constraints — minimum/maximum duration, advance-booking lead time, quantity range, custom attribute matching, weekend detection, status matching, and per-customer booking limits. Each factory returns a fully-formed `RuleCondition` referencing the right field path (`window.durationHours`, `booking.status`, `customer.bookingCount`, etc.) and the right operator. The factories save consumers from hand-rolling identical conditions across rules and ensure the field paths stay consistent with the evaluator's `resolveField` dispatch.
## Features
- Eight factories covering duration / advance / quantity / attribute / weekend / status / customer-limit / max-duration constraints.
- Pure functions — call once, store the returned `RuleCondition` in `BookingSystemConfig.conditions`.
- Reference by id from any `BookingRule.conditionRefs` array.
## Usage: Basic
```ts
import { createMinDurationCondition, createWeekendCondition } from "@suaveplan/booking";
const conditions = [
createMinDurationCondition("c-min-1h", 1),
createWeekendCondition("c-weekend"),
];
```
## Usage: Advanced
```ts
import {
createAdvanceBookingCondition, createAttributeCondition,
createCustomerLimitCondition, createMaxDurationCondition,
createQuantityRangeCondition, createStatusCondition,
} from "@suaveplan/booking";
const conditions = [
createMaxDurationCondition("c-max-24h", 24),
createAdvanceBookingCondition("c-need-2h", 2),
createQuantityRangeCondition("c-q-1-10", 1, 10),
createAttributeCondition("c-vip-only", "tier", "eq", "vip"),
createStatusCondition("c-confirmed", "confirmed"),
createCustomerLimitCondition("c-loyalty", 10),
];
const rule = {
ruleId: "vip-perk", name: "VIP loyalty perk", priority: 1, enabled: true,
conditionRefs: ["c-vip-only", "c-loyalty"], conditionLogic: "all",
actionRef: { actionType: "apply_modifier", message: "VIP 10% off", modifierRef: "m-vip-10" },
};
```
## API Reference
| Factory | Field | Operator | Value |
|---|---|---|---|
| `createMinDurationCondition(id, h)` | `window.durationHours` | `lt` | `h` (triggers if duration < h) |
| `createMaxDurationCondition(id, h)` | `window.durationHours` | `gt` | `h` (triggers if duration > h) |
| `createAdvanceBookingCondition(id, h)` | `window.advanceHours` | `lt` | `h` (triggers if advance < h) |
| `createQuantityRangeCondition(id, min, max)` | `booking.quantity` | `between` | `[min, max]` |
| `createAttributeCondition(id, path, op, val)` | `bookable.attributes.<path>` | `op` | `val` |
| `createWeekendCondition(id)` | `window.isWeekend` | `eq` | `true` |
| `createStatusCondition(id, status)` | `booking.status` | `eq` | `status` |
| `createCustomerLimitCondition(id, max)` | `customer.bookingCount` | `gte` | `max` |
## Implementation Notes
- Field paths assume the consumer's `EvaluationContext` populates the dotted keys correctly — the engine itself does not currently derive `window.durationHours` or `window.advanceHours`; consumers compute these before invoking the evaluator (or wire a hook).
- The "minimum" and "maximum" duration conditions use `lt` / `gt` semantics because they are TRUE when the BAD case holds (so the rule's `deny` action fires).
- `createAttributeCondition` takes the operator string verbatim so consumers can use any of the 11 supported operators.
- Every factory returns a fresh object on each call; safe to call inside a loop without sharing references.

View File

@@ -0,0 +1,98 @@
# evaluator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The rules-engine evaluator walks a list of `BookingRule[]` against an `EvaluationContext` (booking + bookable + window + customer) and computes a single `RuleEvaluationResult` carrying every failure, every applied modifier, and every user-facing message. `evaluateRule(rule, conditions, context)` is the per-rule predicate; `evaluateRules(rules, conditions, context)` is the orchestrator that sorts by priority, walks every enabled rule, and aggregates outcomes. Supports 11 operators and two logic modes (`all`, `any`).
## Features
- Path-addressed field resolution (`booking.quantity`, `bookable.attributes.tier`, `window.durationHours`, `customer.bookingCount`).
- 11 operators — `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `between`, `regex`, plus a permissive default returning `false`.
- Logic modes — `all` (default; every condition must pass) and `any`.
- Two action types — `deny` (records failed rule) and `apply_modifier` (records modifier id).
- Priority-sorted execution (lower number = earlier).
- Disabled rules are skipped completely.
## Usage: Basic
```ts
import { evaluateRules } from "@suaveplan/booking";
const rules = [{
ruleId: "no-short", name: "Minimum 2h", priority: 1, enabled: true,
conditionRefs: ["c-min"], conditionLogic: "all",
actionRef: { actionType: "deny", message: "Booking must be ≥2 hours", modifierRef: null },
}];
const conditions = [{ conditionId: "c-min", field: "window.durationHours", operator: "lt", value: 2 }];
const result = evaluateRules(rules, conditions, {
booking: { quantity: 1 },
bookable: {}, window: { durationHours: 1.5 }, customer: {},
});
// result.passed === false; result.messages[0] === "Booking must be ≥2 hours"
```
## Usage: Advanced
```ts
import { evaluateRule, evaluateRules } from "@suaveplan/booking";
import type { EvaluationContext } from "@suaveplan/booking/rules/evaluator/evaluator";
const ctx: EvaluationContext = {
booking: { status: "pending", quantity: 5 },
bookable: { attributes: { tier: "vip" } },
window: { durationHours: 4, isWeekend: true },
customer: { bookingCount: 12 },
};
const conditions = [
{ conditionId: "c-vip", field: "bookable.attributes.tier", operator: "eq", value: "vip" },
{ conditionId: "c-loyalty", field: "customer.bookingCount", operator: "gte", value: 10 },
{ conditionId: "c-quantity", field: "booking.quantity", operator: "between", value: [1, 10] },
];
const weekendSurcharge = {
ruleId: "surch-weekend", name: "Weekend +15%", priority: 5, enabled: true,
conditionRefs: ["c-vip", "c-loyalty", "c-quantity"], conditionLogic: "all" as const,
actionRef: { actionType: "apply_modifier", message: "Weekend surcharge", modifierRef: "m-weekend-15" },
};
const result = evaluateRules([weekendSurcharge], conditions, ctx);
console.log(result.appliedModifiers); // ["m-weekend-15"]
console.log(evaluateRule(weekendSurcharge, conditions, ctx)); // true
```
## API Reference
### `evaluateRule(rule, conditions, context): boolean`
Returns `true` iff the rule's gated conditions match (or it has no conditions). Disabled rules return `true` unconditionally (so they don't block downstream rules).
### `evaluateRules(rules, conditions, context): RuleEvaluationResult`
Walks rules sorted ascending by `priority`. For each matching enabled rule:
- `deny` → push to `failedRules`, push message to `messages`.
- `apply_modifier` (with non-empty `modifierRef`) → push `modifierRef` to `appliedModifiers`, push message.
Returns `{ passed: failedRules.length === 0, failedRules, appliedModifiers, messages }`.
### `EvaluationContext`
`{ booking, bookable, window, customer }`. Each is `Record<string, unknown>`; the evaluator walks paths via `getNestedValue`.
### `ConditionEvaluatorFn`
`(fieldValue, operator, compareValue) => boolean`. The internal evaluator type, useful for extending with custom operators.
## Implementation Notes
- Field paths without a recognised root (`booking`/`bookable`/`window`/`customer`) fall back to `context.booking`, matching upstream behaviour.
- `gt`/`gte`/`lt`/`lte`/`between` coerce both operands to numbers via `Number(...)`; non-numeric inputs become `NaN` and fail the comparison.
- `regex` requires both `fieldValue` and `compareValue` to be strings; otherwise returns `false`.
- Unknown `operator` values fall to the `default: false` branch — the evaluator never throws, so a typo cannot crash an engine in production.
- Priority sorts ASCENDING; rule `priority: 0` runs first.

View File

@@ -0,0 +1,71 @@
# booking.schema
## Purpose
Zod validation schemas covering every entity at the booking engine's input boundary — `Bookable`, `Booking`, `BookingRequest`, `HoldRequest`, `TimeWindow`, plus the sub-types `BookableId`, `BookingId`, `BookingPrice`, and `CancellationTerms`. Each schema mirrors the corresponding `.types.ts` shape; consumers parse incoming JSON through these at the network/RPC boundary to catch malformed input before it reaches the engine. Three convenience validators (`validateBookable`, `validateBooking`, `validateBookingRequest`) provide throw-on-invalid entry points.
## Features
- `timeWindowSchema` — datetime + non-empty timezone.
- `bookableSchema` / `bookingSchema` — full entity validation.
- `bookingRequestSchema` / `holdRequestSchema` — mutation input validation.
- `validateBookable` / `validateBooking` / `validateBookingRequest` — throw-on-invalid helpers returning the parsed (typed) data.
- Schemas compose — `bookingSchema` references `bookingIdSchema`, `timeWindowSchema`, `bookingPriceSchema`, `cancellationTermsSchema`.
## Usage: Basic
```ts
import { validateBookingRequest } from "@suaveplan/booking";
const req = validateBookingRequest(JSON.parse(rawBody));
engine.confirmBooking(req);
```
## Usage: Advanced
```ts
import {
bookableSchema, bookingRequestSchema, bookingSchema,
} from "@suaveplan/booking";
const partialBookable = bookableSchema.partial();
const parseResult = bookingRequestSchema.safeParse(input);
if (!parseResult.success) {
return Response.json({ error: parseResult.error.flatten() }, { status: 400 });
}
const req = parseResult.data;
const bookingsParse = bookingSchema.array().safeParse(history);
if (bookingsParse.success) {
const bookings = bookingsParse.data;
console.log(`Loaded ${bookings.length} valid bookings`);
}
```
## API Reference
| Schema | Validates |
|---|---|
| `timeWindowSchema` | `{ startTime, endTime, timezone }` (datetimes + non-empty tz) |
| `bookableIdSchema` | `{ id, tenantId, categoryId }` |
| `bookingIdSchema` | `{ id, tenantId, createdAt }` |
| `bookingPriceSchema` | Non-negative subtotal/tax/fees/discount, 3-char currency. |
| `cancellationTermsSchema` | `{ refundable, deadlineBeforeStart, penaltyCalculation, penaltyValue }` |
| `bookableSchema` | Full `Bookable` shape (nullable `layoutNodeRef`). |
| `bookingSchema` | Full `Booking` shape; `quantity >= 1`. |
| `holdRequestSchema` | Hold input. |
| `bookingRequestSchema` | Booking input (optional `notes`/`metadata`/`holdId`). |
| Helper | Returns | On error |
|---|---|---|
| `validateBookable(data)` | parsed `Bookable` | Zod throws. |
| `validateBooking(data)` | parsed `Booking` | Zod throws. |
| `validateBookingRequest(data)` | parsed `BookingRequest` | Zod throws. |
## Implementation Notes
- Currency codes are checked for length 3 only; consumers wanting ISO-4217 enforcement compose `z.literal("USD").or(...)` separately.
- `metadata` is `z.record(z.string(), z.unknown())` — consumers who want typed metadata can `.refine()` or substitute their own.
- `validate*` helpers `throw` so they fit a request-handler `try/catch` cleanly; consumers wanting non-throwing parsing call `bookableSchema.safeParse(...)` directly.
- `quantity` is bounded `>= 1` to match upstream and prevent zero-quantity bookings reaching the engine.

View File

@@ -0,0 +1,69 @@
# config.schema
## Purpose
Validates the full `BookingSystemConfig` JSON tree at runtime. The schema mirrors the `config.types.ts` shape and is the right entry point for loading a config from disk or the network: invalid input fails fast with a structured Zod error rather than producing weird runtime behaviour deep inside the engine. Provides both throwing (`validateConfig`) and non-throwing (`safeValidateConfig`) entry points so consumers pick the right error-handling style.
## Features
- `bookingSystemConfigSchema` — the root config schema.
- Sub-schemas for every nested shape (`tenantConfigSchema`, `engineConfigSchema`, `holdConfigSchema`, etc.).
- `validateConfig(data)` — throws on invalid.
- `safeValidateConfig(data)` — returns `{ success, data | error }` discriminated.
## Usage: Basic
```ts
import { validateConfig } from "@suaveplan/booking";
const cfg = validateConfig(JSON.parse(rawJson));
engine.configure(cfg);
```
## Usage: Advanced
```ts
import { safeValidateConfig } from "@suaveplan/booking";
const parseResult = safeValidateConfig(JSON.parse(rawJson));
if (!parseResult.success) {
console.error("Invalid booking config:", parseResult.error.flatten());
process.exit(1);
}
const cfg = parseResult.data;
// Schema reuse: validate a partial update against a subset
import { engineConfigSchema } from "@suaveplan/booking/schemas/config.schema";
const engineSchema = engineConfigSchema.partial();
const update = engineSchema.parse({ maxBookingsPerCustomer: 10 });
```
## API Reference
| Schema | Notes |
|---|---|
| `bookingSystemConfigSchema` | Root; references every sub-schema. |
| `tenantConfigSchema` | Tenant id + name + multitenant flag + isolation level. |
| `engineConfigSchema` | Domain preset + custom strategies + feature flags + limits. |
| `holdConfigSchema` | Hold defaults; `defaultDurationMinutes >= 1`. |
| `waitlistConfigSchema` | Waitlist policy. |
| `pricingEngineConfigSchema` | 3-char currency, taxStrategy enum, roundingMode enum, precision 0..10. |
| `availabilityConfigSchema` | Cache + refresh policy. |
| `conflictDetectorConfigSchema` | Mode + buffer + capacity. |
| `localeConfigSchema` | Locale + date/time format + firstDayOfWeek 0..6. |
| `statusTransitionSchema` | One transition row. |
| `attributeDefinitionSchema` | Attribute spec inside a `BookableTypeDefinition`. |
| `bookableTypeDefinitionSchema` | One bookable-type registry entry. |
| `strategyRegistrationSchema` | Custom strategy registration row. |
| Helper | Returns | On error |
|---|---|---|
| `validateConfig(data)` | parsed `BookingSystemConfig` | Zod throws. |
| `safeValidateConfig(data)` | `{ success, data \| error }` | Returns failure result. |
## Implementation Notes
- The schema validates SHAPE; it does NOT cross-check refs (e.g. that `bookableTypeRegistry[i].defaultConflictMode` matches a registered detector). Cross-checking is a higher-level concern.
- `rules`, `conditions`, `priceRules`, `priceTiers`, `priceModifiers`, `seasonalRates`, `dynamicPricingRules`, `layoutRef` are typed as `z.unknown()` (or `.nullable()` for layout) because validating their internal shape requires the corresponding rules/pricing-types schemas. Callers compose those validations as needed.
- `taxStrategy` and `roundingMode` are real enums so a typo (`"flooor"`) fails validation immediately.
- `safeValidateConfig` is the right call from a long-running process; `validateConfig` suits startup / one-shot scripts where throwing is acceptable.

View File

@@ -0,0 +1,77 @@
# recurrence
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Expands `RecurrenceRule` definitions into concrete `TimeWindow[]` occurrences across a date range. Supports daily, weekly, monthly, and yearly cadences with optional `daysOfWeek` filtering, `exceptions` for one-off skips, and a hard 1000-iteration guard so a malformed rule cannot turn into a runaway loop. Used by the engine when consumers ask for available slots over a future range.
## Features
- Four frequency cadences (`daily`, `weekly`, `monthly`, `yearly`) plus a permissive fallback.
- `daysOfWeek` filter for weekly schedules (0=Sun..6=Sat, UTC).
- `exceptions` skip-list keyed by ISO date prefix (YYYY-MM-DD).
- Range narrowing — only occurrences inside `[rangeStart, min(rangeEnd, rule.endDate)]` are emitted.
- Hard `MAX_ITERATIONS = 1000` guard.
- `isRecurring(rule)` and `createRecurrenceRule(...)` helpers.
## Usage: Basic
```ts
import { createRecurrenceRule, expandRecurrence } from "@suaveplan/booking";
const rule = createRecurrenceRule("daily", 1, { endDate: "2026-06-07T00:00:00Z" });
const slots = expandRecurrence(
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T10:00:00Z", timezone: "UTC" },
rule,
"2026-06-01T00:00:00Z",
"2026-06-07T00:00:00Z"
);
// slots.length === 7
```
## Usage: Advanced
```ts
import { createRecurrenceRule, expandRecurrence, isRecurring } from "@suaveplan/booking";
const monWedYoga = createRecurrenceRule("weekly", 1, {
daysOfWeek: [1, 3],
exceptions: ["2026-12-23T00:00:00Z", "2026-12-25T00:00:00Z"],
endDate: "2026-12-31T00:00:00Z",
});
if (isRecurring(monWedYoga)) {
const occurrences = expandRecurrence(
{ startTime: "2026-12-01T18:00:00Z", endTime: "2026-12-01T19:00:00Z", timezone: "UTC" },
monWedYoga,
"2026-12-01T00:00:00Z",
"2026-12-31T23:59:59Z"
);
for (const occ of occurrences) console.log(occ.startTime);
}
```
## API Reference
### `expandRecurrence(baseWindow, rule, rangeStart, rangeEnd): TimeWindow[]`
Emits one occurrence per cadence step that lies inside `[rangeStart, min(rangeEnd, rule.endDate)]`, is not in `rule.exceptions`, and (for weekly rules with non-empty `daysOfWeek`) matches one of the configured days. Each occurrence has the same duration as `baseWindow`.
### `isRecurring(rule: RecurrenceRule | null): boolean`
Returns `true` iff the rule is non-null and its `frequency` is non-empty.
### `createRecurrenceRule(frequency, interval = 1, options?): RecurrenceRule`
Convenience constructor. `options` is a `Partial<RecurrenceRule>` filling in `daysOfWeek`, `endDate`, `exceptions` (each defaults to its empty sentinel).
## Implementation Notes
- The 1000-iteration hard cap protects against pathological inputs (e.g. `frequency: "yearly"` with `interval: 0`). Real-world cadences stay well under it.
- `daysOfWeek` is consulted only when `frequency === "weekly"` AND the list is non-empty; weekly rules with empty `daysOfWeek` emit every weekday-stepped occurrence (the same as upstream).
- `exceptions` are compared by the `toISOString().split("T")[0]` date prefix, so the time-of-day portion of an exception is ignored.
- Unknown `frequency` values fall through to daily advancement so missing data still produces some output (callers can validate via the Zod schema before passing in).
- The function uses UTC throughout — month boundaries, week boundaries, and date keys all resolve in UTC regardless of `baseWindow.timezone`.

View File

@@ -0,0 +1,63 @@
# slots
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Generates and manipulates `TimeSlot` lists. `generateTimeSlots` produces fixed-interval slots covering a single `TimeWindow`; `generateDailySlots` produces slots covering a daily working-hour band across a date range; `filterAvailableSlots` keeps slots with sufficient capacity; `reduceSlotCapacity` is a pure update that floors capacity at zero. Used by the engine when consumers ask "what's available?" over a range or when a booking consumes capacity.
## Features
- Fixed-interval slot generation (`generateTimeSlots`).
- Daily working-hour slot generation across a multi-day range (`generateDailySlots`).
- Capacity filter (`filterAvailableSlots`).
- Pure capacity update floored at zero (`reduceSlotCapacity`).
- Slots carry unique ids via `generateId("slot")`.
## Usage: Basic
```ts
import { generateTimeSlots } from "@suaveplan/booking";
const slots = generateTimeSlots(
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T12:00:00Z", timezone: "UTC" },
30 * 60 * 1000,
10
);
// slots.length === 6, each capacityRemaining === 10
```
## Usage: Advanced
```ts
import {
filterAvailableSlots, generateDailySlots, reduceSlotCapacity,
} from "@suaveplan/booking";
const week = generateDailySlots(
"2026-06-01T00:00:00Z", "2026-06-07T00:00:00Z",
9, 17, 60, "UTC", 5
);
const consumed = week.map((s) => reduceSlotCapacity(s, 3));
const stillBookable = filterAvailableSlots(consumed, 2);
// `consumed` has capacity 2; `stillBookable` keeps every entry.
```
## API Reference
| Function | Signature | Notes |
|---|---|---|
| `generateTimeSlots` | `(window, slotDurationMs, capacity) => TimeSlot[]` | Only emits slots whose end fits inside the window. |
| `generateDailySlots` | `(startDate, endDate, dailyStartHour, dailyEndHour, slotDurationMinutes, timezone, capacity) => TimeSlot[]` | Per-day; hours are 0..23 UTC. |
| `filterAvailableSlots` | `(slots, minCapacity = 1) => TimeSlot[]` | Returns a new array. |
| `reduceSlotCapacity` | `(slot, amount) => TimeSlot` | Returns a new slot object with `capacityRemaining: max(0, …)`. |
## Implementation Notes
- `generateTimeSlots` requires `current + slotDurationMs <= end` — the last slot must fit completely, so no truncated slot is emitted.
- `generateDailySlots` advances day-by-day with `setUTCDate(getUTCDate() + 1)` to stay safe across month boundaries and DST transitions.
- All slot ids come from `generateId("slot")` — they are unique within a single process but not across processes; pair with a persistent id allocator if you need globally unique values.
- `reduceSlotCapacity` is pure: callers must replace the slot in their store rather than mutating in place.

View File

@@ -0,0 +1,79 @@
# windows
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines pure functions for reasoning about `TimeWindow` intervals — overlap, merge, split, containment, adjacency, validity, and duration. Every helper treats windows as half-open `[start, end)` intervals: a booking ending exactly when another starts does NOT overlap, which models the standard "check-out at 11, next guest checks in at 14" hotel semantics correctly. Used by the engine's availability and conflict-detection paths.
## Features
- `windowsOverlap(a, b, allowBoundaryTurnover?)` — half-open overlap; optional flag excludes exact-boundary touches.
- `windowsOverlapWithBuffer(proposed, existing, bufferMs)` — overlap with an extended existing window.
- `mergeWindows(windows)` — collapses adjacent / overlapping windows into the minimal covering set.
- `splitWindow(window, intervalMs)` — fixed-interval slicing.
- `windowDurationMs(window)` — `end - start` in milliseconds.
- `windowContains(outer, inner)` — inclusive containment.
- `createWindow(start, end, timezone?)` — convenience constructor (defaults timezone to UTC).
- `isValidWindow(window)` — `start < end` with valid datetimes.
- `windowsAdjacent(a, b)` — true iff they touch at a boundary.
## Usage: Basic
```ts
import { createWindow, windowsOverlap } from "@suaveplan/booking";
const a = createWindow("2026-06-01T14:00:00Z", "2026-06-03T11:00:00Z");
const b = createWindow("2026-06-03T11:00:00Z", "2026-06-05T11:00:00Z");
windowsOverlap(a, b); // false — half-open at boundary
```
## Usage: Advanced
```ts
import {
mergeWindows, splitWindow, windowsOverlap, windowsOverlapWithBuffer,
} from "@suaveplan/booking";
// 1-hour cleaning buffer on each side
windowsOverlapWithBuffer(proposed, existing, 3_600_000);
// Collapse a fragmented schedule
const free = mergeWindows([
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T10:00:00Z", timezone: "UTC" },
{ startTime: "2026-06-01T10:00:00Z", endTime: "2026-06-01T12:00:00Z", timezone: "UTC" },
]);
// free.length === 1, free[0].endTime === "2026-06-01T12:00:00Z"
// 30-minute slots over a 2-hour window
const slots = splitWindow(
{ startTime: "2026-06-01T09:00:00Z", endTime: "2026-06-01T11:00:00Z", timezone: "UTC" },
30 * 60 * 1000
);
// slots.length === 4
```
## API Reference
| Function | Signature | Notes |
|---|---|---|
| `windowsOverlap` | `(a, b, allowBoundaryTurnover?: boolean) => boolean` | Half-open. |
| `windowsOverlapWithBuffer` | `(proposed, existing, bufferMs) => boolean` | Buffer expands existing. |
| `mergeWindows` | `(windows: TimeWindow[]) => TimeWindow[]` | Sorts by start; merges contiguous/overlapping. |
| `splitWindow` | `(window, intervalMs) => TimeWindow[]` | Last slot clamped to window end. |
| `windowDurationMs` | `(window) => number` | `end - start`. |
| `windowContains` | `(outer, inner) => boolean` | Closed containment. |
| `createWindow` | `(start, end, timezone = "UTC") => TimeWindow` | Convenience. |
| `isValidWindow` | `(window) => boolean` | Validity + ordering. |
| `windowsAdjacent` | `(a, b) => boolean` | Exact-boundary touch. |
## Implementation Notes
- All arithmetic happens via `Date.parse` (UTC); the `timezone` field on `TimeWindow` is informational. Callers who need wall-clock-correct slicing convert separately.
- `mergeWindows` preserves the timezone of the first window in each merged group.
- `splitWindow` always emits the last slot ending exactly at `window.endTime` even if `intervalMs` does not divide evenly — callers don't get phantom slots past the end.
- The `allowBoundaryTurnover` flag flips the strict half-open semantics on for the hotel-turnover case (where "11am check-out, 2pm check-in" should count as a conflict at the boundary).
- `windowsAdjacent` checks both directions (`aEnd === bStart || bEnd === aStart`) so callers don't need to sort the inputs first.

View File

@@ -0,0 +1,68 @@
# bookable.types
## Purpose
Defines the `Bookable` entity — the thing being booked — plus the query shapes used to look it up. Generic over a `TMeta extends Record<string, unknown>` parameter so each domain (seats, accommodation, appointments, …) can attach its own typed attribute bag (`SeatBookableMeta`, `AccommodationBookableMeta`, …) without losing the rest of the engine's type safety. Identifiers are split into a structured `BookableId` so multi-tenant systems can co-locate bookables from different tenants in a single registry.
## Features
- `BookableId` — `{ id, tenantId, categoryId }` structured identifier.
- `Bookable<TMeta>` — generic entity with `priceRuleRefs`, `timeSlotRefs`, `layoutNodeRef`, and `attributes: TMeta`.
- `BookableQuery` — filterable search (tenant + type + status + category + attributes + paging).
- `BookableQueryResult<TMeta>` — `{ items, total, limit, offset }` paging envelope.
## Usage: Basic
```ts
import type { Bookable } from "@suaveplan/booking";
const room: Bookable = {
identifierRef: { id: "room-101", tenantId: "hotel-1", categoryId: "deluxe" },
name: "Deluxe Suite 101",
description: "Ocean-view king",
bookableType: "hotel-room",
status: "active",
layoutNodeRef: null,
capacityRef: "cap-room-101",
priceRuleRefs: ["rate-standard-night"],
timeSlotRefs: [],
attributes: { roomNumber: "101", floor: 1 },
};
```
## Usage: Advanced
```ts
import type { Bookable, BookableQuery, BookableQueryResult } from "@suaveplan/booking";
import type { SeatBookableMeta } from "@suaveplan/booking/domains/seats/seats";
const query: BookableQuery = {
tenantId: "stadium-x",
bookableType: "seat",
status: "active",
limit: 100,
offset: 0,
};
function pickAccessible(
result: BookableQueryResult<SeatBookableMeta>
): Bookable<SeatBookableMeta>[] {
return result.items.filter((seat) => seat.attributes.isAccessible);
}
```
## API Reference
| Type | Fields |
|---|---|
| `BookableId` | `id`, `tenantId`, `categoryId` |
| `Bookable<TMeta>` | `identifierRef`, `name`, `description`, `bookableType`, `status`, `layoutNodeRef`, `capacityRef`, `priceRuleRefs`, `timeSlotRefs`, `attributes: TMeta` |
| `BookableQuery` | `tenantId`, `bookableType?`, `status?`, `categoryId?`, `attributes?`, `limit?`, `offset?` |
| `BookableQueryResult<TMeta>` | `items: Bookable<TMeta>[]`, `total`, `limit`, `offset` |
## Implementation Notes
- `TMeta extends Record<string, unknown>` defaults to `Record<string, unknown>` so consumers who don't care about domain meta still get a usable type.
- References (`priceRuleRefs`, `timeSlotRefs`, `layoutNodeRef`, `capacityRef`) are string ids, never embedded objects — this keeps `Bookable` JSON-safe and side-tables-friendly.
- `BookableQuery.attributes` is a free-form record; the engine's in-memory `listBookables` ignores it today, but persistent stores may translate it into a `WHERE attributes->...` predicate.
- `BookableQueryResult.total` is the count BEFORE paging, so callers can compute total pages from `total / limit`.

View File

@@ -0,0 +1,73 @@
# booking-hooks.types
## Purpose
Defines the typed hook map and handler aliases consumed by the booking engine's in-package `Hooks<BookingHookMap>` instance. The engine exposes two transform-pipeline points — `booking:creating` (runs before a booking is persisted) and `booking:cancelling` (runs before the state machine evaluates a cancellation) — and consumers register handlers via `engine.bookingHooks.hook(...)`. These types let TypeScript prove that a handler matches the right pipeline at compile time.
## Features
- `BookingCreatingHookData` — full data flowing into the create pipeline (`bookableRef`, `customerRef`, `windowRef`, `quantity`, `notes`, `metadata`).
- `BookingCancellingHookData` — data flowing into the cancel pipeline (`bookingId`, `reason`, `currentStatus`).
- `BookingHookMap extends HookMap` — discriminated map keyed by hook name.
- `BookingCreatingHandler` / `BookingCancellingHandler` — named aliases over `HookHandler<...>`.
## Usage: Basic
```ts
import { createBookingEngine } from "@suaveplan/booking";
import type { BookingCreatingHandler } from "@suaveplan/booking";
const engine = createBookingEngine();
const lowercaseCustomer: BookingCreatingHandler = (data) => ({
...data,
customerRef: data.customerRef.toLowerCase(),
});
engine.bookingHooks.hook("booking:creating", lowercaseCustomer);
```
## Usage: Advanced
```ts
import { createBookingEngine } from "@suaveplan/booking";
import type {
BookingCancellingHandler,
BookingCreatingHandler,
BookingHookMap,
} from "@suaveplan/booking";
const engine = createBookingEngine();
const trim: BookingCreatingHandler = (data) => ({
...data,
notes: data.notes.trim(),
});
const requireReason: BookingCancellingHandler = (data, ctx) => {
if (!data.reason) ctx.abort();
return data;
};
engine.bookingHooks.hook("booking:creating", trim, { priority: 10, label: "trim-notes" });
engine.bookingHooks.hook("booking:cancelling", requireReason);
function listAllHooks(): (keyof BookingHookMap)[] {
return engine.bookingHooks.hookNames();
}
```
## API Reference
| Type | Notes |
|---|---|
| `BookingCreatingHookData` | `bookableRef`, `customerRef`, `windowRef`, `quantity`, `notes`, `metadata` |
| `BookingCancellingHookData` | `bookingId`, `reason`, `currentStatus` |
| `BookingHookMap` | Indexed by `"booking:creating"` and `"booking:cancelling"`; extends `HookMap` so unknown keys still type-check as `unknown`. |
| `BookingCreatingHandler` | `HookHandler<BookingCreatingHookData>` |
| `BookingCancellingHandler` | `HookHandler<BookingCancellingHookData>` |
## Implementation Notes
- The `BookingHookMap` extends `HookMap` (an index-signature alias) so consumers can attach additional hook names without forking the type. The engine itself only fires the two documented entries.
- `metadata` is `Record<string, unknown>` to keep the map shape consistent across the two hook payloads; consumers wanting typed metadata cast inside the handler.
- Handlers receive `(data, context)` from the in-package `Hooks` implementation; calling `context.abort()` stops the chain and the engine receives whichever transformed data the previous handler produced.
- The named handler aliases save consumers a repetitive `HookHandler<BookingCreatingHookData>` annotation at every call site.

View File

@@ -0,0 +1,87 @@
# booking.types
## Purpose
Defines the `Booking` entity — the confirmed reservation — and the request/modification/cancellation shapes that surround it. `Booking` is generic over `TMeta extends Record<string, unknown>` so callers can attach typed metadata without losing the engine's type safety on the rest of the shape. Identifier and pricing references are flat — `BookingId` carries the composite `{ id, tenantId, createdAt }`, `totalPriceRef` carries the resolved `BookingPrice`. Keeping the shapes flat and JSON-safe makes the engine trivially serialisable to any store.
## Features
- `BookingId` — composite identifier (`id`, `tenantId`, `createdAt`).
- `CancellationTerms` — refund/penalty policy on the booking.
- `Booking<TMeta>` — confirmed booking entity.
- `HoldRequest`, `BookingRequest<TMeta>`, `BookingModification`, `WaitlistRequest` — input shapes for the engine's mutators.
- `CancellationResult` — output of `cancelBooking`.
## Usage: Basic
```ts
import type { BookingRequest } from "@suaveplan/booking";
const req: BookingRequest = {
bookableRef: "room-101",
customerRef: "cust-42",
windowRef: {
startTime: "2026-06-01T14:00:00Z",
endTime: "2026-06-03T11:00:00Z",
timezone: "UTC",
},
quantity: 1,
};
```
## Usage: Advanced
```ts
import type {
Booking, BookingModification, CancellationResult, HoldRequest, WaitlistRequest,
} from "@suaveplan/booking";
interface MyMeta extends Record<string, unknown> {
bedConfig: "king" | "twin";
requestedBreakfast: boolean;
}
const change: BookingModification = { quantity: 2, notes: "Extra guest" };
function isFullyRefundable(b: Booking<MyMeta>, c: CancellationResult): boolean {
return b.cancellationRef.refundable && c.penaltyAmount === 0;
}
function quickHold(bookableRef: string, customerRef: string): HoldRequest {
return {
bookableRef, customerRef, quantity: 1,
windowRef: {
startTime: new Date().toISOString(),
endTime: new Date(Date.now() + 3_600_000).toISOString(),
timezone: "UTC",
},
};
}
function joinWl(customerRef: string, bookableRef: string): WaitlistRequest {
return {
bookableRef, customerRef, priority: 1,
requestedWindowRef: quickHold(bookableRef, customerRef).windowRef,
};
}
```
## API Reference
| Type | Fields |
|---|---|
| `BookingId` | `id`, `tenantId`, `createdAt` |
| `CancellationTerms` | `refundable`, `deadlineBeforeStart`, `penaltyCalculation`, `penaltyValue` |
| `Booking<TMeta>` | `identifierRef`, `bookableRef`, `customerRef`, `windowRef`, `status`, `quantity`, `totalPriceRef`, `holdExpiresAt`, `cancellationRef`, `notes`, `metadata: TMeta` |
| `HoldRequest` | `bookableRef`, `customerRef`, `windowRef`, `quantity` |
| `BookingRequest<TMeta>` | `bookableRef`, `customerRef`, `windowRef`, `quantity`, `notes?`, `metadata?`, `holdId?` |
| `BookingModification` | `windowRef?`, `quantity?`, `notes?` |
| `CancellationResult` | `bookingId`, `refundAmount`, `penaltyAmount`, `status` |
| `WaitlistRequest` | `bookableRef`, `customerRef`, `requestedWindowRef`, `priority?` |
## Implementation Notes
- `Booking.holdExpiresAt` carries the original hold's expiry (or `""` if the booking didn't come from a hold) so audit trails preserve the upgrade path.
- `BookingRequest.metadata` is optional and typed via `TMeta`; consumers pinning a literal type get full IDE completion and compile-time safety.
- `CancellationResult.refundAmount` is the gross refund; `penaltyAmount` is whatever penalty was applied. `refundAmount - penaltyAmount` is the net to the customer.
- `BookingModification` is partial — undefined fields leave the existing booking unchanged.

View File

@@ -0,0 +1,76 @@
# capacity.types
## Purpose
Defines the data shapes used by the booking engine for capacity tracking, short-lived holds, and waitlist entries. These are the storage-tier shapes — the engine keeps them in `Map<string, ...>` registries today, but they round-trip cleanly through any persistent store. Keeping them flat and JSON-safe ensures the same shape works in-memory, on-disk, and on the wire.
## Features
- `OverbookingStrategy` — `"none" | "waitlist" | "percentage"` union.
- `CapacityPool` — totals + reserved + overbooking policy for a bookable.
- `BookingHold` — short-lived reservation with `expiresAt` and `quantity`.
- `WaitlistEntry` — priority-sorted queue entry with `requestedWindowRef`.
## Usage: Basic
```ts
import type { BookingHold } from "@suaveplan/booking";
const hold: BookingHold = {
holdId: "hold_abc",
bookableRef: "room-101",
customerRef: "cust-42",
windowRef: {
startTime: "2026-06-01T14:00:00Z",
endTime: "2026-06-03T11:00:00Z",
timezone: "UTC",
},
expiresAt: "2026-05-17T19:10:00Z",
quantity: 1,
};
```
## Usage: Advanced
```ts
import type { CapacityPool, OverbookingStrategy, WaitlistEntry } from "@suaveplan/booking";
const strategy: OverbookingStrategy = "waitlist";
const pool: CapacityPool = {
poolId: "p-stadium-block-a",
bookableRef: "stadium-2026-05-finals",
totalCapacity: 12000, reservedCount: 11850,
overbookingLimit: 0, overbookingStrategy: strategy,
};
const waiter: WaitlistEntry = {
entryId: "w_001",
bookableRef: pool.bookableRef,
customerRef: "cust-77",
requestedWindowRef: {
startTime: "2026-05-30T19:00:00Z",
endTime: "2026-05-30T22:00:00Z",
timezone: "Europe/London",
},
priority: 5,
createdAt: "2026-05-17T18:00:00Z",
notifiedAt: null,
};
```
## API Reference
| Type | Fields |
|---|---|
| `OverbookingStrategy` | `"none"` / `"waitlist"` / `"percentage"` literal union |
| `CapacityPool` | `poolId`, `bookableRef`, `totalCapacity`, `reservedCount`, `overbookingLimit`, `overbookingStrategy` |
| `BookingHold` | `holdId`, `bookableRef`, `customerRef`, `windowRef`, `expiresAt`, `quantity` |
| `WaitlistEntry` | `entryId`, `bookableRef`, `customerRef`, `requestedWindowRef`, `priority`, `createdAt`, `notifiedAt` |
## Implementation Notes
- `expiresAt` and `createdAt` are ISO-8601 strings — the engine's clock arithmetic happens in `Date.parse(...)` form to avoid coupling to a runtime `Date` instance.
- `WaitlistEntry.priority` is a numeric sort key — higher wins. The engine does not interpret it beyond `sort((a, b) => b.priority - a.priority)` at notify time.
- `notifiedAt` distinguishes the "queued" and "notified" states cleanly with `null` vs an ISO timestamp.
- `OverbookingStrategy === "percentage"` is metadata only — capacity policy enforcement is the consumer's concern (the booking engine does not implement overbooking math today).

View File

@@ -0,0 +1,75 @@
# config.types
## Purpose
Defines the complete JSON configuration tree for the booking engine. A single `BookingSystemConfig` value drives the entire pipeline — tenant identity, engine policy, availability cache settings, conflict-detection mode, pricing engine policy, holds/waitlist behaviour, notifications, rules + conditions, price catalogue, status state machine, bookable-type registry, layout, and locale. Every domain preset in `src/domains/` is essentially a factory returning a populated `BookingSystemConfig`, and the Zod schema in `src/schemas/config.schema.ts` validates JSON loaded from disk before it ever reaches the engine.
## Features
- `BookingSystemConfig` — the root configuration tree.
- 16 sub-types modelling every facet (`TenantConfig`, `EngineConfig`, `StrategyRegistration`, `HoldConfig`, `WaitlistConfig`, `NotificationConfig`, `NotificationTrigger`, `StatusTransition`, `AttributeDefinition`, `BookableTypeDefinition`, `LayoutConfig`, `LocaleConfig`, `AvailabilityConfig`, `ConflictDetectorConfig`, `PricingEngineConfig`).
- All shapes are pure data — no methods, no closures — so the entire tree round-trips through JSON.
## Usage: Basic
```ts
import { createSeatConfig } from "@suaveplan/booking";
import type { BookingSystemConfig } from "@suaveplan/booking";
const cfg: BookingSystemConfig = createSeatConfig("stadium-x", "Stadium X");
```
## Usage: Advanced
```ts
import type { BookingSystemConfig, StatusTransition } from "@suaveplan/booking";
import { createBaseConfig } from "@suaveplan/booking";
const refundOnlyOnceTransition: StatusTransition = {
from: "confirmed", to: "refunded",
allowedRoles: ["admin"], requiresReason: true, autoTransitionAfter: null,
};
const cfg: BookingSystemConfig = {
...createBaseConfig("clinic-1", "Heartbeat Clinic"),
pricingRef: {
defaultCurrency: "EUR",
supportedCurrencies: ["EUR", "USD"],
taxStrategy: "inclusive",
roundingMode: "round",
roundingPrecision: 2,
},
statusTransitions: [
...createBaseConfig("clinic-1", "Heartbeat Clinic").statusTransitions,
refundOnlyOnceTransition,
],
};
```
## API Reference
| Type | Notes |
|---|---|
| `TenantConfig` | `tenantId`, `tenantName`, `multiTenant`, `isolationLevel` |
| `EngineConfig` | `domainPreset`, `customStrategies`, `enableHolds`, `enableWaitlist`, `enableRecurring`, `maxBookingsPerCustomer`, `maxAdvanceBookingDays` |
| `StrategyRegistration` | `strategyType`, `strategyId`, `factoryFn` |
| `HoldConfig` | `enabled`, `defaultDurationMinutes`, `maxDurationMinutes`, `autoReleaseEnabled` |
| `WaitlistConfig` | `enabled`, `maxEntriesPerCustomer`, `autoNotify`, `notificationWindowMinutes`, `autoExpireAfterMinutes` |
| `NotificationTrigger` | `triggerId`, `eventName`, `channelRefs`, `templateRef`, `delayMinutes` |
| `NotificationConfig` | `enabled`, `channels`, `events: NotificationTrigger[]` |
| `StatusTransition` | `from`, `to`, `allowedRoles`, `requiresReason`, `autoTransitionAfter` |
| `AttributeDefinition` | `attributeId`, `label`, `dataType`, `enumValues`, `required`, `defaultValue` |
| `BookableTypeDefinition` | `typeId`, `label`, `description`, `requiredAttributes`, `optionalAttributes`, `defaultPricingModel`, `defaultConflictMode`, `supportsLayout` |
| `LayoutConfig` | `enabled`, `rootNodeRef`, `interactionMode`, `selectionMode`, `maxSelections`, `highlightAvailability`, `colorSchemeRef` |
| `LocaleConfig` | `defaultLocale`, `supportedLocales`, `dateFormat`, `timeFormat`, `firstDayOfWeek`, `currencyDisplay` |
| `AvailabilityConfig` | `refreshIntervalMs`, `cacheEnabled`, `cacheTtlMs` |
| `ConflictDetectorConfig` | `mode`, `allowSameBoundaryTurnover`, `bufferDuration`, `holdDuration`, `capacityMode` |
| `PricingEngineConfig` | `defaultCurrency`, `supportedCurrencies`, `taxStrategy`, `roundingMode`, `roundingPrecision` |
| `BookingSystemConfig` | Root — references every sub-type above plus `rules`, `conditions`, `priceRules`, `priceTiers`, `priceModifiers`, `seasonalRates`, `dynamicPricingRules`, `bookableTypeRegistry`, `layoutRef`, `localeRef`. |
## Implementation Notes
- `LayoutConfig` is nullable on the root (`layoutRef: LayoutConfig | null`) because not every domain uses spatial layouts.
- `autoTransitionAfter` is an ISO-8601 duration string or `null`. The engine does not implement the auto-transition timer itself; consumers schedule the firing.
- `bufferDuration` / `holdDuration` / `unitDuration` are ISO-8601 duration strings (`"PT15M"`, `"P1D"`); the engine parses them through `parseDurationMs`.
- The Zod schema in `src/schemas/config.schema.ts` mirrors this tree shape-for-shape; bumping a field here requires a paired update to the schema.

View File

@@ -0,0 +1,73 @@
# events.types
## Purpose
Defines the event vocabulary the booking engine emits. `BookingEventType` is the closed union of every lifecycle event consumers may subscribe to via `EventBus.on`. `BookingEvent<TPayload>` is the wrapper shape that flows through every handler. Closed enumeration over `BookingEventType` lets TypeScript catch typos at the call site (`engine.on("booking:confimed", …)` no longer compiles), and the parameterised `BookingEvent<TPayload>` keeps payload narrowing optional but available.
## Features
- `BookingEventType` — 15-entry string literal union covering booking, hold, waitlist, availability, and capacity lifecycle.
- `BookingEvent<TPayload>` — `{ eventType, timestamp, tenantId, payload }` envelope.
- `BookingEventHandler<TPayload>` — `(event: BookingEvent<TPayload>) => void` signature.
- `EventSubscription` — internal subscription record (`eventType`, `handler`, `id`).
## Usage: Basic
```ts
import { createBookingEngine } from "@suaveplan/booking";
import type { BookingEvent } from "@suaveplan/booking";
const engine = createBookingEngine();
engine.on("booking:confirmed", (event: BookingEvent) => {
console.log("confirmed at", event.timestamp, event.payload);
});
```
## Usage: Advanced
```ts
import type { Booking, BookingEvent, BookingEventHandler } from "@suaveplan/booking";
const onConfirmed: BookingEventHandler<Booking> = (event) => {
const booking = event.payload;
sendReceipt(booking.identifierRef.id, booking.customerRef);
};
const onCancelled: BookingEventHandler<{ bookingId: string; reason: string }> = (event) => {
const { bookingId, reason } = event.payload;
logCancellation(event.tenantId, bookingId, reason);
};
engine.on("booking:confirmed", onConfirmed);
engine.on("booking:cancelled", onCancelled);
```
## API Reference
### `BookingEventType`
Union of 15 string literals: `"booking:created"`, `"booking:confirmed"`, `"booking:cancelled"`, `"booking:modified"`, `"booking:completed"`, `"booking:no_show"`, `"booking:checked_in"`, `"hold:created"`, `"hold:expired"`, `"hold:converted"`, `"waitlist:joined"`, `"waitlist:notified"`, `"waitlist:promoted"`, `"availability:changed"`, `"capacity:updated"`.
### `BookingEvent<TPayload>`
| Field | Type | Notes |
|---|---|---|
| `eventType` | `BookingEventType` | One of the 15 literals. |
| `timestamp` | `string` | ISO-8601 datetime; engine fills this in. |
| `tenantId` | `string` | Always present; sourced from `BookingSystemConfig.tenantRef.tenantId`. |
| `payload` | `TPayload` | Per-event payload (booking, hold, waitlist entry, etc.). |
### `BookingEventHandler<TPayload>`
`(event: BookingEvent<TPayload>) => void`. Handlers are invoked synchronously in registration order. They MUST NOT throw — uncaught errors propagate to the engine.
### `EventSubscription`
Internal record returned/managed by `EventBus`. Consumers only see the subscription `id` returned from `on`.
## Implementation Notes
- The 15-entry union is intentionally closed; adding a new event requires changing this file and is treated as a breaking change in spirit (consumers may have exhaustive switches).
- Payload typing is by convention — the engine emits the documented payload for each event but does not enforce it at the type level (would require a discriminated `BookingEventMap`). Consumers typing handler payloads via the `BookingEventHandler<MyPayload>` generic get the narrowing they want.
- `timestamp` is always present at emit time — handlers should not regenerate it.
- The engine does not retain handlers across `clear()`; consumers can re-register after a reset.

View File

@@ -0,0 +1,63 @@
# layout.types
## Purpose
Defines the geometric shapes used by visualisation consumers (seat maps, floor plans, venue diagrams) when rendering the booking engine's bookables. The engine itself does not interpret these shapes — it carries them as opaque payloads — but they ship in the typed core so every consumer agrees on the wire format. The layout types are deliberately framework-agnostic: `NodePosition` covers SVG, Canvas, and WebGL renderers; `LayoutColorScheme` covers any palette implementation.
## Features
- `NodePosition` — 2D position + size + rotation + shape + svgPath.
- `LayoutNode` — recursive tree node (parent ref + metadata bag).
- `LayoutColorScheme` — six colour roles (`available`, `selected`, `unavailable`, `held`, `disabled`, `hover`).
- All shapes are JSON-safe — no DOM nodes, no React refs, no canvas contexts.
## Usage: Basic
```ts
import type { LayoutColorScheme } from "@suaveplan/booking";
const palette: LayoutColorScheme = {
available: "#22c55e",
selected: "#3b82f6",
unavailable: "#ef4444",
held: "#f59e0b",
disabled: "#9ca3af",
hover: "#a855f7",
};
```
## Usage: Advanced
```ts
import type { LayoutNode, NodePosition } from "@suaveplan/booking";
const seatPos: NodePosition = {
x: 120, y: 80, width: 32, height: 32, rotation: 0,
shape: "circle", svgPath: "",
};
const seat: LayoutNode = {
nodeId: "seat-A12",
parentNodeId: "row-A",
label: "A12",
nodeType: "seat",
positionRef: seatPos,
metadata: { tier: "vip", isAccessible: true },
};
```
## API Reference
| Type | Fields |
|---|---|
| `NodePosition` | `x`, `y`, `width`, `height`, `rotation`, `shape`, `svgPath` |
| `LayoutNode` | `nodeId`, `parentNodeId` (nullable), `label`, `nodeType`, `positionRef`, `metadata` |
| `LayoutColorScheme` | `available`, `selected`, `unavailable`, `held`, `disabled`, `hover` (all `string`) |
## Implementation Notes
- `parentNodeId: null` marks the root node; otherwise renderers walk parent→child to compose the tree.
- `shape` is a string (e.g. `"circle"`, `"rect"`, `"path"`) so renderers can dispatch without coupling to an enum the package owns.
- `svgPath` is only meaningful when `shape === "path"`; otherwise empty string.
- `metadata` is `Record<string, unknown>` so domains can attach arbitrary annotations (tier, isAccessible, hasObstructedView) without widening the core type.
- Colours are stored as strings; the engine does not validate format. Renderers parse them as CSS / hex / Three.js inputs as needed.

View File

@@ -0,0 +1,80 @@
# pricing.types
## Purpose
Defines the data shapes used by the booking engine's pricing pipeline. Every entity here is JSON-serialisable so prices, rules, modifiers, and the seasonal/dynamic strategies that drive them can travel from a config file to an in-process registry to a network boundary without any reshape. The shapes intentionally model "what" rather than "how" — there are no calculation methods on the interfaces; `src/pricing/calculator/calculator.ts` and its siblings own the pipeline.
## Features
- `PriceRule` — base unit of pricing (`fixed`, `per_unit`, `per_duration`, `tiered`).
- `PriceTier` — quantity-bracket override for `tiered` rules.
- `PriceModifier` — `discount`, `surcharge`, `fee`, or `tax` applied on top.
- `SeasonalRate` — month/day window with a multiplier (supports wrap-around-year).
- `DynamicPricingRule` — `occupancy_threshold`, `advance_days`, or `demand_score` trigger.
- `BookingPrice` — fully resolved price line carrying subtotal/tax/fees/discount/total + breakdown.
## Usage: Basic
```ts
import type { BookingPrice, PriceRule } from "@suaveplan/booking";
const nightRate: PriceRule = {
ruleId: "rate-standard-night",
amount: 12000,
currency: "USD",
pricingModel: "per_duration",
unitDuration: "P1D",
};
const charged: BookingPrice = {
subtotal: 24000, tax: 2400, fees: 0, discount: 0, total: 26400,
currency: "USD", breakdown: ["rate-standard-night"],
};
```
## Usage: Advanced
```ts
import type {
DynamicPricingRule, PriceModifier, PriceTier, SeasonalRate,
} from "@suaveplan/booking";
const groupDiscount: PriceTier = {
tierId: "t-group", ruleRef: "rate-standard-night",
minQuantity: 6, maxQuantity: 20, amount: 9000,
};
const seasonal: SeasonalRate = {
rateId: "winter", priceRuleRef: "rate-standard-night",
seasonName: "Winter", startMonth: 12, startDay: 1,
endMonth: 2, endDay: 28, multiplier: 1.25,
};
const surge: DynamicPricingRule = {
ruleId: "surge-90", priceRuleRef: "rate-standard-night",
triggerType: "occupancy_threshold", threshold: 0.9, multiplier: 1.5,
};
const cleaningFee: PriceModifier = {
modifierId: "m-cleaning", type: "fee",
calculationMethod: "flat", value: 5000, conditionRef: "",
};
```
## API Reference
| Type | Fields |
|---|---|
| `PriceRule` | `ruleId`, `amount`, `currency`, `pricingModel`, `unitDuration` |
| `PriceTier` | `tierId`, `ruleRef`, `minQuantity`, `maxQuantity`, `amount` |
| `PriceModifier` | `modifierId`, `type` (`discount`/`surcharge`/`fee`/`tax`), `calculationMethod` (`flat`/`percentage`), `value`, `conditionRef` |
| `SeasonalRate` | `rateId`, `priceRuleRef`, `seasonName`, `startMonth`, `startDay`, `endMonth`, `endDay`, `multiplier` |
| `DynamicPricingRule` | `ruleId`, `priceRuleRef`, `triggerType` (`occupancy_threshold`/`advance_days`/`demand_score`), `threshold`, `multiplier` |
| `BookingPrice` | `subtotal`, `tax`, `fees`, `discount`, `total`, `currency`, `breakdown` |
## Implementation Notes
- Amounts are stored as `number` (units defined by the calling system — typically minor units when integrating with `@suaveplan/money`).
- `SeasonalRate` supports wrap-around-year intervals (e.g. Dec 1 → Feb 28) via the disjunctive overlap test in `src/pricing/seasonal/seasonal.ts`.
- `PriceModifier.type === "tax"` is special-cased — tax modifiers are summed by `calculateTax` and ignored by `applyModifiers` to avoid double counting.
- `BookingPrice.breakdown` carries the audit trail: every applied rule + modifier id, in order.

View File

@@ -0,0 +1,73 @@
# rules.types
## Purpose
Defines the data shapes the rules engine evaluates. `BookingRule` is the top-level entity referenced from `BookingSystemConfig.rules`; each rule references one or more `RuleCondition` ids (`conditionRefs`) plus a terminal `RuleAction`. The evaluator in `src/rules/evaluator/evaluator.ts` walks this graph, picks matching conditions out of the config's `conditions[]` array, and produces a `RuleEvaluationResult` that the engine consults before confirming a booking. Keeping the shapes flat and id-based — rather than embedding conditions inside rules — makes the config trivially editable, diff-able, and serialisable.
## Features
- `RuleAction` — terminal action of a rule (`actionType`, `message`, optional `modifierRef`).
- `RuleCondition` — pure predicate (`field`, `operator`, `value`).
- `BookingRule` — ordered collection of condition refs + action + priority + enabled flag.
- `RuleEvaluationResult` — `{ passed, failedRules, appliedModifiers, messages }` returned to the engine.
## Usage: Basic
```ts
import type { BookingRule, RuleCondition } from "@suaveplan/booking";
const deny: BookingRule = {
ruleId: "no-bookings-under-1h",
name: "Minimum booking length",
priority: 1,
enabled: true,
conditionRefs: ["c-min-1h"],
conditionLogic: "all",
actionRef: { actionType: "deny", message: "Minimum 1 hour", modifierRef: null },
};
const cond: RuleCondition = {
conditionId: "c-min-1h",
field: "window.durationHours",
operator: "lt",
value: 1,
};
```
## Usage: Advanced
```ts
import type { RuleAction, RuleEvaluationResult } from "@suaveplan/booking";
const apply: RuleAction = {
actionType: "apply_modifier",
message: "Weekend surcharge",
modifierRef: "m-weekend-15pct",
};
function handle(result: RuleEvaluationResult): boolean {
if (!result.passed) {
console.warn("Booking denied:", result.messages.join("; "));
return false;
}
for (const id of result.appliedModifiers) applyModifier(id);
return true;
}
```
## API Reference
| Type | Fields |
|---|---|
| `RuleAction` | `actionType` (`"deny"`/`"apply_modifier"`/etc.), `message`, `modifierRef` (nullable) |
| `RuleCondition` | `conditionId`, `field`, `operator` (11 supported), `value` (`unknown`) |
| `BookingRule` | `ruleId`, `name`, `priority` (lower = first), `enabled`, `conditionRefs[]`, `conditionLogic` (`"all"`/`"any"`), `actionRef` |
| `RuleEvaluationResult` | `passed`, `failedRules`, `appliedModifiers`, `messages` |
## Implementation Notes
- Conditions are referenced by id, not embedded, so a single condition can be reused across many rules without duplication.
- `conditionLogic` is `"all"` by default; `"any"` is permissive. Unknown values fall through to `"all"` semantics in the evaluator.
- `priority: 0` runs first; the evaluator sorts ascending. Higher-priority rules can short-circuit by emitting `deny`.
- `value: unknown` is intentional — operators like `between` and `in` accept arrays; `regex` accepts strings; `eq`/`neq` accept any JSON-comparable value.
- `RuleEvaluationResult.failedRules` carries the entire `BookingRule` (not just its id) so callers can surface `actionRef.message` directly to the user.

View File

@@ -0,0 +1,83 @@
# temporal.types
## Purpose
Defines the shared shape of every time-based value the booking engine handles. `TimeWindow` is the canonical half-open interval used by every overlap, merge, split, and validity helper in `src/temporal/windows`; `RecurrenceRule` is the JSON-serialisable rule consumed by `src/temporal/recurrence` to expand repeating occurrences; `TimeSlot` is the materialised result that consumers display, hold, and book. Keeping these as pure data interfaces — no methods, no behaviour — lets every layer of the package (and downstream consumers) reason about them without coupling to a runtime implementation.
## Features
- `TimeWindow` — `{ startTime, endTime, timezone }` triple in ISO-8601 form.
- `RecurrenceRule` — `{ frequency, interval, daysOfWeek, endDate, exceptions }` capable of representing daily/weekly/monthly/yearly recurrences with weekday filters and date exceptions.
- `TimeSlot` — `{ slotId, windowRef, recurrenceRef, capacityRemaining }` carrying the bookable window plus capacity.
- All three shapes are JSON-safe: no `Date` objects, no symbols, no functions.
## Usage: Basic
```ts
import type { TimeWindow } from "@suaveplan/booking";
const dinner: TimeWindow = {
startTime: "2026-05-17T19:00:00Z",
endTime: "2026-05-17T21:00:00Z",
timezone: "Europe/London",
};
```
## Usage: Advanced
```ts
import type { RecurrenceRule, TimeSlot, TimeWindow } from "@suaveplan/booking";
const everyMonWed: RecurrenceRule = {
frequency: "weekly",
interval: 1,
daysOfWeek: [1, 3],
endDate: "2026-12-31T00:00:00Z",
exceptions: ["2026-12-25T00:00:00Z"],
};
function holdSlot(window: TimeWindow): TimeSlot {
return {
slotId: `slot_${Date.now().toString(36)}`,
windowRef: window,
recurrenceRef: null,
capacityRemaining: 10,
};
}
```
## API Reference
### `TimeWindow`
| Field | Type | Notes |
|---|---|---|
| `startTime` | `string` | ISO-8601 datetime; half-open lower bound. |
| `endTime` | `string` | ISO-8601 datetime; half-open upper bound. |
| `timezone` | `string` | IANA zone, e.g. `"America/New_York"`. |
### `RecurrenceRule`
| Field | Type | Notes |
|---|---|---|
| `frequency` | `string` | `"daily"`, `"weekly"`, `"monthly"`, `"yearly"`. |
| `interval` | `number` | Step between occurrences (1 = every period). |
| `daysOfWeek` | `number[]` | 0=Sun..6=Sat. Only meaningful for weekly. |
| `endDate` | `string` | ISO-8601 datetime or empty string. |
| `exceptions` | `string[]` | ISO-8601 dates whose day-key skips the occurrence. |
### `TimeSlot`
| Field | Type | Notes |
|---|---|---|
| `slotId` | `string` | Unique identifier. |
| `windowRef` | `TimeWindow` | The slot's bookable window. |
| `recurrenceRef` | `RecurrenceRule \| null` | Pattern this slot was expanded from, if any. |
| `capacityRemaining` | `number` | Currently available capacity (>= 0). |
## Implementation Notes
- Half-open intervals: `windowsOverlap(a, b)` is true iff `a.start < b.end && b.start < a.end`. A booking ending exactly when another starts does NOT overlap (use `allowSameBoundaryTurnover` to model hotel-turnover policies).
- Timezone is informational; arithmetic uses UTC via `Date.parse(...)`. Callers wanting wall-clock semantics convert separately.
- `RecurrenceRule.exceptions` are matched by the ISO date-only prefix (YYYY-MM-DD); time-of-day in the exception is ignored.
- The package keeps these shapes flat by design — no nested `start: { iso, tz }` objects, no `Date` instances — so they round-trip through `JSON.parse(JSON.stringify(...))` losslessly.

View File

@@ -0,0 +1,74 @@
# utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared, dependency-free helpers used across `@suaveplan/booking`. Every export here is pure, side-effect-free, and runs in any JavaScript runtime (Bun, Node, browser, Web Worker). Keeping these in one module avoids duplication across feature modules and gives the conflict/availability/pricing code a single place to look for primitives like ISO-8601 duration parsing or recursive object freezing.
## Features
- `parseDurationMs(duration)` — minimal ISO-8601 duration parser supporting `D`, `H`, `M`, `S` components.
- `generateId(prefix)` — `<prefix>_<base36 ms>_<base36 random>` identifier.
- `clampNumber(value, min, max)` — interval clamp.
- `isNonEmptyString(value)` — type-guard for non-empty strings.
- `deepFreeze(obj)` — recursive `Object.freeze`.
## Usage: Basic
```ts
import { clampNumber, parseDurationMs } from "@suaveplan/booking";
parseDurationMs("PT15M"); // 900000
clampNumber(150, 0, 100); // 100
```
## Usage: Advanced
```ts
import { deepFreeze, generateId, isNonEmptyString } from "@suaveplan/booking";
const id = generateId("hold");
// id matches /^hold_[0-9a-z]+_[0-9a-z]+$/
const config = deepFreeze({
pricing: { defaultCurrency: "USD" },
limits: { maxBookingsPerCustomer: 5 },
});
// config.pricing.defaultCurrency = "EUR"; ← throws in strict mode
function take(x: unknown): string {
if (!isNonEmptyString(x)) throw new Error("expected non-empty string");
return x.toUpperCase();
}
```
## API Reference
### `parseDurationMs(duration: string): number`
Parses a subset of ISO-8601 duration syntax (`P…D…T…H…M…S`). Empty string returns `0`. Components missing from the input contribute `0`. Year/month components are NOT supported (the engine never needs them — recurrence uses calendar arithmetic).
### `generateId(prefix: string): string`
Returns `<prefix>_<base36 Date.now()>_<6 base36 chars>`. Uses `Math.random()` — adequate for in-memory uniqueness, not cryptographic.
### `clampNumber(value: number, min: number, max: number): number`
Returns `Math.min(Math.max(value, min), max)`. Undefined behaviour if `min > max`.
### `isNonEmptyString(value: unknown): value is string`
Type-guard returning `true` iff `typeof value === "string" && value.length > 0`.
### `deepFreeze<T>(obj: T): Readonly<T>`
Recursively freezes every nested object. Arrays are frozen as well. Cycles are not supported (would infinite-loop) — only acyclic structures.
## Implementation Notes
- `parseDurationMs` is deliberately minimal — it covers every duration the engine sees in production (`bufferDuration`, `holdDuration`, `unitDuration`, `autoTransitionAfter`). Years/months are excluded because the engine does not interpret them.
- `generateId` is deterministic enough for in-memory tests (the random suffix makes collisions vanishingly unlikely across the call counts a booking engine handles) but is not crypto-grade. A future change can thread an injectable `IdGenerator` from `@suaveplan/types` once consumers need deterministic ids.
- `deepFreeze` mutates its argument (calling `Object.freeze`) and returns the same reference typed `Readonly<T>`. Callers that need a copy should `structuredClone` first.

View File

@@ -0,0 +1,16 @@
# `contacts`
Contact and group management with deduplication and import/export.
<!-- begin:auto-docs -->
## Packages in `contacts`
4 packages.
| Package | Description |
|---|---|
| [`@suaveplan/api-plugin-contacts`](./api-plugin-contacts/README.md) | Contacts and Groups HTTP plugin for `@suaveplan/api`. Mounts the `/contacts` and `/groups` route trees on a Hono `OpenAPIHono`, backed by SurrealDB implementat… |
| [`@suaveplan/audience-rules`](./audience-rules/README.md) | Universal audience segmentation: rule-based filtering on contact data with 14 comparison operators, AND/OR chaining, dot-notation field resolution including ar… |
| [`@suaveplan/contacts`](./contacts/README.md) | Universal contact and group management with hierarchical nesting, audience resolution, contact merger, and CSV/JSON import-export. Multi-tenant, channel-agnost… |
| [`@suaveplan/macos-contacts`](./macos-contacts/README.md) | Universal bridge to macOS Contacts.app via AppleScript. Reads contacts and groups through the platform `osascript` binary, parses the output into typed records… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Contact and group management with deduplication and import/export.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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".

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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(...)`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -0,0 +1,111 @@
# @suaveplan/audience-rules - Universal audience segmentation: rule-based filtering with 14 comparison operators, AND/OR chaining, dot-notation field resolution including array indices, three refresh modes (realtime/scheduled/manual), seven predicate factories, pluggable SegmentStore with InMemorySegmentStore (Clock + IdGenerator injectable), SegmentRefresher with typed EventBus emission, Zod-validated boundaries, and AudienceRulesError with stable AUDIENCE_RULES.* code registry.
Universal audience segmentation: rule-based filtering on contact data
with 14 comparison operators, AND/OR chaining, dot-notation field
resolution including array indices, three refresh modes
(realtime/scheduled/manual), seven predicate factory helpers, a pluggable
`SegmentStore` with an `InMemorySegmentStore` (injectable `Clock` +
`IdGenerator`), and a `SegmentRefresher` that emits `segment:refreshed`
on a typed `EventBus<SegmentRefresherEventMap>`. Inputs are
Zod-validated; failures raise `AudienceRulesError` with a stable
`AUDIENCE_RULES.*` code registry.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable, namespaced error-code registry for the `@suaveplan/audience-rules` package, alongs…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` defines the stable, namespaced error-code registry for the `@suaveplan/audience-rules` package, alongs…
- [predicates](./src/predicates/predicates.md) — `predicates.ts` ships seven factory functions that produce `SegmentRule` values for the most common audience filters: t…
- [predicates](./src/predicates/predicates.md) — `predicates.ts` ships seven factory functions that produce `SegmentRule` values for the most common audience filters: t…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes the Zod validators that guard every public input boundary on the `@suaveplan/audience-rules` store…
- [schemas](./src/schemas/schemas.md) — `schemas.ts` exposes the Zod validators that guard every public input boundary on the `@suaveplan/audience-rules` store…
- [segment-evaluator](./src/segment-evaluator/segment-evaluator.md) — `segment-evaluator.ts` provides the pure-function rule engine that decides, for a given `Segment` and a tenant's `Conta…
- [segment-evaluator](./src/segment-evaluator/segment-evaluator.md) — `segment-evaluator.ts` provides the pure-function rule engine that decides, for a given `Segment` and a tenant's `Conta…
- [segment-refresher](./src/segment-refresher/segment-refresher.md) — `segment-refresher.ts` orchestrates re-evaluation of one or many segments and persists the resulting `contactIds` back…
- [segment-refresher](./src/segment-refresher/segment-refresher.md) — `segment-refresher.ts` orchestrates re-evaluation of one or many segments and persists the resulting `contactIds` back…
- [store](./src/store/store.md) — `store.ts` defines the `SegmentStore` interface that abstracts segment persistence behind a six-method contract (`creat…
- [store](./src/store/store.md) — `store.ts` defines the `SegmentStore` interface that abstracts segment persistence behind a six-method contract (`creat…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/audience-rules
```
## Quick start
```ts
import {
InMemorySegmentStore,
SegmentEvaluator,
SegmentRefresher,
hasTag,
statusIs,
} from "@suaveplan/audience-rules";
const provider = async () => [
{ id: "c1", tags: ["vip"], status: "active" },
{ id: "c2", tags: ["basic"], status: "active" },
{ id: "c3", tags: ["vip"], status: "unsubscribed" },
];
const store = new InMemorySegmentStore();
const evaluator = new SegmentEvaluator(provider);
const refresher = new SegmentRefresher(store, evaluator);
const segment = await store.create({
tenantId: "tenant-1",
name: "Active VIPs",
rules: [hasTag("vip"), statusIs("active")],
refreshMode: "manual",
});
refresher.events.on("segment:refreshed", ({ segmentId, result }) => {
console.log(`${segmentId}: ${result.total} matches`);
});
await refresher.refresh(segment.id);
```
## Modules
- [error-codes](src/error-codes/error-codes.md) — `AUDIENCE_RULES_CODES`
registry + `AudienceRulesError`.
- [schemas](src/schemas/schemas.md) — Zod validators + `parseOrThrow`.
- [store](src/store/store.md) — `SegmentStore` interface +
`InMemorySegmentStore` with `Clock` + `IdGenerator` injection.
- [segment-evaluator](src/segment-evaluator/segment-evaluator.md) —
14-operator rule engine with dot-notation field resolution and AND/OR
chaining.
- [segment-refresher](src/segment-refresher/segment-refresher.md) —
re-evaluation orchestrator with typed `EventBus` emission.
- [predicates](src/predicates/predicates.md) — seven `SegmentRule` factory
helpers for tags, channels, status, activity dates, and metadata.
## Features
- 14 operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`,
`not_contains`, `in`, `not_in`, `exists`, `not_exists`, `starts_with`,
`ends_with`.
- AND/OR logical chaining via `logicalOp` on each rule.
- Dot-notation field paths with numeric array indices
(`channels.0.type`).
- Three refresh modes for cache policy: `realtime`, `scheduled`, `manual`.
- Multi-tenant isolation via `tenantId` on `Segment` and `list`.
- Predicate factories for the seven most common audience filters.
- Pluggable storage — provide a `SegmentStore` implementation backed by
the persistence layer of your choice. `InMemorySegmentStore` is the
reference implementation.
- Typed event emission via `EventBus<SegmentRefresherEventMap>` from
`@suaveplan/events`.
- Stable `AUDIENCE_RULES.*` code registry with `INVALID_INPUT`,
`SEGMENT_NOT_FOUND`, `UNKNOWN_OPERATOR`, `PROVIDER_FAILED`.
## See also
- `@suaveplan/contacts` — contact and group management, provides the
`ContactData` consumed by audience-rules.
- `@suaveplan/events` — typed `EventBus<TMap>` substrate.
- `@suaveplan/error` — `SuaveplanError` + `defineCodes`.

View File

@@ -0,0 +1,93 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` defines the stable, namespaced error-code registry for the
`@suaveplan/audience-rules` package, alongside the discriminated
`AudienceRulesError` class that every failure path raises. It exists so that
consumers can dispatch on a stable string (`"AUDIENCE_RULES.SEGMENT_NOT_FOUND"`)
rather than parsing error messages, and so that diagnostic context
(`segmentId` / `tenantId`) survives transport across logger sinks, OTel span
events, and structured-output channels without polluting the base
`SuaveplanError` `context` map.
## Features
- `AUDIENCE_RULES_CODES` registry built via `defineCodes("AUDIENCE_RULES", [...])`
with four stable codes: `INVALID_INPUT`, `SEGMENT_NOT_FOUND`,
`UNKNOWN_OPERATOR`, `PROVIDER_FAILED`.
- `AudienceRulesErrorCode` union type narrowing every legal `code` value.
- `AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` carrying
optional `segmentId` and `tenantId` discriminants.
- `AudienceRulesErrorOptions` extending `SuaveplanErrorOptions` with the two
discriminant fields.
- Compatible with the `@suaveplan/error` cause-chain traversal, redaction, and
OTel span-event adapter without further wiring.
## Usage: Basic Example
```ts
import {
AUDIENCE_RULES_CODES,
AudienceRulesError,
} from "@suaveplan/audience-rules";
throw new AudienceRulesError(
AUDIENCE_RULES_CODES.SEGMENT_NOT_FOUND,
"segment 'seg_42' does not exist",
{ segmentId: "seg_42", tenantId: "tenant-1" }
);
```
## Usage: Advanced Example
```ts
import {
AudienceRulesError,
AUDIENCE_RULES_CODES,
} from "@suaveplan/audience-rules";
function handleRefresh(error: unknown) {
if (
error instanceof AudienceRulesError &&
error.code === AUDIENCE_RULES_CODES.PROVIDER_FAILED
) {
// narrow tenantId for diagnostics, then re-throw with extra context
throw new AudienceRulesError(
AUDIENCE_RULES_CODES.PROVIDER_FAILED,
"contact provider failed during scheduled refresh",
{ tenantId: error.tenantId, cause: error }
);
}
throw error;
}
```
## API Reference
- `AUDIENCE_RULES_CODES` — frozen record mapping every short name to its
namespaced string (e.g. `INVALID_INPUT → "AUDIENCE_RULES.INVALID_INPUT"`).
- `type AudienceRulesErrorCode` — union of every namespaced string value.
- `interface AudienceRulesErrorOptions extends SuaveplanErrorOptions` — accepts
`segmentId?: string`, `tenantId?: string`, plus every option from the base
`SuaveplanErrorOptions` (e.g. `cause`, `context`).
- `class AudienceRulesError extends SuaveplanError<AudienceRulesErrorCode>` —
constructor `(code, message, options?)`; exposes `segmentId?: string` and
`tenantId?: string` as readonly own-properties.
## Implementation Notes
- The registry is intentionally small at v0.1.0. Adding a code requires bumping
the package and updating the OpenSpec delta; deleting a code is a breaking
change. See `AGENTS.md §2c` — codes are never removed just because a
call site has not been wired yet.
- `segmentId` / `tenantId` are assigned only when the constructor option is
non-`undefined`, so the runtime shape stays compact for logger redaction
passes that walk own-property keys.
- `defineCodes` from `@suaveplan/error` guarantees the namespaced prefix at
type-time, so a typo in the literal (`"AUDIENCE_RULE.X"`) is a compile error,
not a silent registry shift.

View File

@@ -0,0 +1,78 @@
# predicates
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`predicates.ts` ships seven factory functions that produce `SegmentRule`
values for the most common audience filters: tag membership, channel type,
status equality, activity-date windows, and metadata field equality /
substring. Centralising these factories trims boilerplate when building
segments programmatically (e.g. from a UI builder) and pins the field-path
convention (`tags`, `channels.0.type`, `metadata.<field>`) so callers do not
have to remember it.
## Features
- `hasTag(tag)` — matches `tags` contains tag.
- `channelIs(type)` — matches first channel of given type.
- `statusIs(status)` — matches exact status string.
- `lastActivityBefore(date)` — matches `lastActivityAt < date.toISOString()`.
- `lastActivityAfter(date)` — matches `lastActivityAt > date.toISOString()`.
- `metadataEquals(field, value)` — matches `metadata.<field> === value`.
- `metadataContains(field, value)` — matches `metadata.<field>` contains
value (string substring or array membership).
## Usage: Basic Example
```ts
import { hasTag, statusIs } from "@suaveplan/audience-rules";
const rules = [hasTag("vip"), statusIs("active")];
```
## Usage: Advanced Example
```ts
import {
channelIs,
lastActivityAfter,
metadataEquals,
type SegmentRule,
} from "@suaveplan/audience-rules";
// "US-based contacts who opened email since Jan 1, OR have a US push channel"
const rules: SegmentRule[] = [
metadataEquals("country", "US"),
lastActivityAfter(new Date("2026-01-01")),
{ ...channelIs("push"), logicalOp: "or" },
];
```
## API Reference
- `hasTag(tag: string): SegmentRule`
- `channelIs(type: string): SegmentRule`
- `statusIs(status: string): SegmentRule`
- `lastActivityBefore(date: Date): SegmentRule`
- `lastActivityAfter(date: Date): SegmentRule`
- `metadataEquals(field: string, value: unknown): SegmentRule`
- `metadataContains(field: string, value: unknown): SegmentRule`
Each factory returns a `SegmentRule` with `logicalOp` left undefined, so the
default `"and"` chaining applies. Override `logicalOp` via spread to OR-chain
a predicate with the previous rule.
## Implementation Notes
- The date predicates serialise the boundary date as ISO 8601. The
evaluator's `gt` / `lt` operators compare via unchecked numeric casts,
which works because ISO date strings are lexicographically ordered.
- `channelIs` targets `channels.0.type` rather than walking the full
channel array; this matches the upstream convention and reflects the
fact that contacts are typically created with a primary channel at
index 0.
- All seven factories are pure and stateless, so they are safe to call in
hot paths (e.g. on every keystroke in a segment builder UI).

View File

@@ -0,0 +1,96 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas.ts` exposes the Zod validators that guard every public input
boundary on the `@suaveplan/audience-rules` store API, plus the `parseOrThrow`
helper that converts a `ZodError` into a typed `AudienceRulesError` with code
`AUDIENCE_RULES.INVALID_INPUT`. Centralising the validators in one module
keeps the constraint surface auditable and lets consumers reuse the same
schemas (e.g. for HTTP-layer body parsing in a future `api-plugin-audience-rules`)
without depending on internal helpers.
## Features
- `segmentOperatorSchema` — Zod enum mirroring the 14 `SegmentOperator`
values.
- `logicalOpSchema` — Zod enum for `"and" | "or"`.
- `refreshModeSchema` — Zod enum for `"realtime" | "scheduled" | "manual"`.
- `segmentRuleSchema` — validates `{ field, operator, value?, logicalOp? }`.
- `createSegmentInputSchema` — validates the full `CreateSegmentInput`.
- `updateSegmentInputSchema` — validates the partial `UpdateSegmentInput`
and rejects an empty patch (all fields undefined).
- `parseOrThrow(schema, input, label)` — runs `safeParse`, returns the typed
value on success, throws `AudienceRulesError` with `INVALID_INPUT` and a
human-readable `path: message` summary on failure.
## Usage: Basic Example
```ts
import {
createSegmentInputSchema,
parseOrThrow,
} from "@suaveplan/audience-rules";
const input = parseOrThrow(
createSegmentInputSchema,
{ tenantId: "t1", name: "VIPs", rules: [], refreshMode: "manual" },
"createSegment"
);
```
## Usage: Advanced Example
```ts
import {
AUDIENCE_RULES_CODES,
AudienceRulesError,
parseOrThrow,
segmentRuleSchema,
} from "@suaveplan/audience-rules";
try {
parseOrThrow(
segmentRuleSchema,
{ field: "", operator: "eq", value: "x" },
"segmentRule"
);
} catch (err) {
if (
err instanceof AudienceRulesError &&
err.code === AUDIENCE_RULES_CODES.INVALID_INPUT
) {
// err.cause is the original ZodError with the full issues array
}
}
```
## API Reference
- `segmentOperatorSchema: z.ZodEnum<SegmentOperator>`
- `logicalOpSchema: z.ZodEnum<LogicalOp>`
- `refreshModeSchema: z.ZodEnum<RefreshMode>`
- `segmentRuleSchema: z.ZodObject<{...}>` — produces a `SegmentRule`.
- `createSegmentInputSchema: z.ZodObject<{...}>` — produces a
`CreateSegmentInput`.
- `updateSegmentInputSchema: z.ZodObject<{...}>` — produces an
`UpdateSegmentInput`; refines that at least one optional field is present.
- `parseOrThrow<T>(schema: z.ZodType<T>, input: unknown, label: string): T` —
returns parsed value, or throws `AudienceRulesError` with code
`AUDIENCE_RULES.INVALID_INPUT` and the original `ZodError` preserved in
`cause`.
## Implementation Notes
- Schemas use `z.string().min(1)` for identity-like fields (`tenantId`,
`name`, `field`) so empty strings are rejected at the boundary.
- The `updateSegmentInputSchema` `.refine` predicate prevents callers from
shipping an empty patch — every `update` MUST change at least one field, or
it is rejected with `INVALID_INPUT`.
- The error message produced by `parseOrThrow` lists every issue as
`path: message; path: message` so server logs and OTel span events surface
every validation failure, not just the first.

View File

@@ -0,0 +1,120 @@
# segment-evaluator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`segment-evaluator.ts` provides the pure-function rule engine that decides,
for a given `Segment` and a tenant's `ContactData[]`, which contacts match.
It supports fourteen comparison operators, AND/OR logical chaining, and
dot-notation field resolution including numeric array indices (e.g.
`channels.0.type`). It is intentionally storage-agnostic — contact data is
supplied by an injected `ContactDataProvider`, so the evaluator runs equally
well against an in-memory test array, a paginated database cursor, or a
remote API.
## Features
- 14 operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`,
`not_contains`, `in`, `not_in`, `exists`, `not_exists`, `starts_with`,
`ends_with`.
- AND/OR logical chaining via `logicalOp` on each subsequent rule (default
`"and"`; first rule's `logicalOp` is ignored).
- Dot-notation field resolution with numeric array index access.
- Empty-rules segment matches every contact.
- `evaluateRule` exposed for ad-hoc rule testing against a single contact.
- Unknown operator raises `AudienceRulesError` with
`AUDIENCE_RULES.UNKNOWN_OPERATOR` — the discriminated `SegmentOperator`
union makes this branch reachable only via a deliberate cast.
## Usage: Basic Example
```ts
import { SegmentEvaluator } from "@suaveplan/audience-rules";
const provider = async () => [
{ id: "c1", tags: ["vip"], status: "active" },
{ id: "c2", tags: ["basic"], status: "active" },
];
const evaluator = new SegmentEvaluator(provider);
const result = await evaluator.evaluate({
id: "seg1",
tenantId: "t1",
name: "VIPs",
rules: [{ field: "tags", operator: "contains", value: "vip" }],
refreshMode: "manual",
createdAt: new Date(),
updatedAt: new Date(),
});
// result.contactIds === ["c1"]
```
## Usage: Advanced Example
```ts
import { SegmentEvaluator } from "@suaveplan/audience-rules";
const provider = async () => [
{
id: "c1",
status: "active",
metadata: { country: "US", score: 90 },
channels: [{ type: "email", value: "a@b.com" }],
},
];
const evaluator = new SegmentEvaluator(provider);
// "active AND US, OR score >= 95"
const result = await evaluator.evaluate({
id: "seg",
tenantId: "t1",
name: "complex",
refreshMode: "manual",
rules: [
{ field: "status", operator: "eq", value: "active" },
{
field: "metadata.country",
operator: "eq",
value: "US",
logicalOp: "and",
},
{
field: "metadata.score",
operator: "gte",
value: 95,
logicalOp: "or",
},
],
createdAt: new Date(),
updatedAt: new Date(),
});
```
## API Reference
- `type ContactDataProvider = (tenantId: string) => Promise<readonly ContactData[]>`
- `class SegmentEvaluator` — constructor `(provider: ContactDataProvider)`.
- `evaluator.evaluate(segment: Segment): Promise<SegmentEvaluationResult>` —
fetches contacts via the provider, evaluates every rule against every
contact, returns matching IDs + `total` + `evaluatedAt` + `matchRate`.
- `evaluator.evaluateRule(contact: ContactData, rule: SegmentRule): boolean`
— pure helper; resolves the dot-notation field and applies the operator.
- Throws `AudienceRulesError` with `AUDIENCE_RULES.UNKNOWN_OPERATOR` when an
operator outside the closed `SegmentOperator` union is supplied.
## Implementation Notes
- `resolveField` returns `undefined` for any missing intermediate path
segment, so `not_exists` correctly matches deeply-nested missing fields.
- Comparison operators apply unchecked casts (`fieldValue as number`) — this
matches the upstream behaviour and lets callers compare ISO date strings
lexicographically, which is what the predicate helpers `lastActivityBefore`
/ `lastActivityAfter` rely on.
- `matchesRules` is left-to-right and does not short-circuit aggressively;
it always evaluates every rule. This keeps the algorithm simple and
deterministic; for very large rule lists the cost is `O(rules * contacts)`.
- The evaluator is stateless past the injected provider, so the same instance
can serve concurrent segment evaluations safely.

View File

@@ -0,0 +1,103 @@
# segment-refresher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`segment-refresher.ts` orchestrates re-evaluation of one or many segments
and persists the resulting `contactIds` back to the `SegmentStore` cache. It
exposes a typed `EventBus<SegmentRefresherEventMap>` so downstream consumers
(broadcast schedulers, real-time dashboards, OTel exporters) can subscribe to
`segment:refreshed` without coupling to the refresher's internals. The
refresher is intentionally driver-agnostic — it does not own a timer; the
consumer drives `refresh` / `refreshAll` from whatever cadence policy
(cron, push, manual button) it prefers.
## Features
- `refresh(segmentId)` — single-segment re-evaluation; returns `undefined`
for an unknown ID without emitting.
- `refreshAll(tenantId)` — multi-segment fan-out; emits once per refreshed
segment.
- Typed `EventBus<SegmentRefresherEventMap>` — `events.on("segment:refreshed",
handler)` receives `{ segmentId, result }`.
- Cache write through `store.updateCache(segmentId, contactIds)` on every
refresh, updating `cachedContactIds`, `cachedCount`, `lastRefreshedAt`,
`updatedAt`.
- Pure composition of `SegmentEvaluator` + `SegmentStore` — both are
constructor-injected so test doubles wire in cleanly.
## Usage: Basic Example
```ts
import {
InMemorySegmentStore,
SegmentEvaluator,
SegmentRefresher,
} from "@suaveplan/audience-rules";
const store = new InMemorySegmentStore();
const evaluator = new SegmentEvaluator(async () => [
{ id: "c1", tags: ["vip"] },
]);
const refresher = new SegmentRefresher(store, evaluator);
const segment = await store.create({
tenantId: "t1",
name: "VIPs",
rules: [{ field: "tags", operator: "contains", value: "vip" }],
refreshMode: "manual",
});
const result = await refresher.refresh(segment.id);
```
## Usage: Advanced Example
```ts
import {
InMemorySegmentStore,
SegmentEvaluator,
SegmentRefresher,
} from "@suaveplan/audience-rules";
const refresher = new SegmentRefresher(
new InMemorySegmentStore(),
new SegmentEvaluator(async () => [])
);
refresher.events.on("segment:refreshed", ({ segmentId, result }) => {
// Forward to OTel, dashboard, downstream pub/sub, etc.
console.log(
`refreshed ${segmentId}: ${result.total} (${(result.matchRate * 100).toFixed(1)}%)`
);
});
await refresher.refreshAll("tenant-1");
```
## API Reference
- `interface SegmentRefresherEventMap` — `"segment:refreshed": { segmentId, result }`.
- `class SegmentRefresher` — constructor `(store: SegmentStore, evaluator: SegmentEvaluator)`.
- `refresher.events: EventBus<SegmentRefresherEventMap>` — typed event bus
from `@suaveplan/events`; `on` / `off` / `emit` / `use` / `clear`.
- `refresher.refresh(segmentId: string): Promise<SegmentEvaluationResult | undefined>`
- `refresher.refreshAll(tenantId: string): Promise<Map<string, SegmentEvaluationResult>>`
## Implementation Notes
- The migration swaps upstream's untyped `EventEmitter` for the suaveplan
`EventBus<TMap>`. The consumer API (`on("segment:refreshed", handler)`)
is preserved verbatim; only the type signature of `handler` becomes
precise.
- `refresh` returns `undefined` for a missing segment because the absence
is a normal (not exceptional) outcome — the consumer typically retries
with a corrected ID or surfaces a `404` at the API layer.
- `refreshAll` iterates sequentially; concurrent fan-out is intentionally
left to the caller (Promise.all over selected segment IDs) so the
refresher does not need to own a concurrency policy.
- The `events` field is publicly readable but the `EventBus` is mutable —
consumers are expected to subscribe, not replace.

View File

@@ -0,0 +1,93 @@
# store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`store.ts` defines the `SegmentStore` interface that abstracts segment
persistence behind a six-method contract (`create`, `getById`, `update`,
`delete`, `list`, `updateCache`) and ships `InMemorySegmentStore` — a
single-process, map-backed implementation suitable for development, tests,
and lightweight production deployments. The in-memory store is built around
injectable `Clock` and `IdGenerator` primitives from `@suaveplan/types`, so
production paths never call `Date.now()` / `new Date()` directly or mutate a
module-scope counter.
## Features
- `SegmentStore` interface — six async methods, all returning the persisted
`Segment` (or `undefined` when the identifier does not exist).
- `InMemorySegmentStore` — single-process map-backed implementation.
- `InMemorySegmentStoreOptions { clock?, idGenerator? }` — both injection
points are optional with sensible defaults.
- Zod boundary on `create` and `update` — invalid input raises
`AudienceRulesError` with code `AUDIENCE_RULES.INVALID_INPUT` (never a raw
`ZodError`).
- Multi-tenant isolation on `list(tenantId)` — segments owned by other
tenants are invisible.
- `updateCache(id, contactIds)` updates `cachedContactIds`, `cachedCount`,
`lastRefreshedAt`, and `updatedAt` atomically.
## Usage: Basic Example
```ts
import { InMemorySegmentStore } from "@suaveplan/audience-rules";
const store = new InMemorySegmentStore();
const segment = await store.create({
tenantId: "tenant-1",
name: "Active VIPs",
rules: [{ field: "tags", operator: "contains", value: "vip" }],
refreshMode: "manual",
});
```
## Usage: Advanced Example
```ts
import { InMemorySegmentStore } from "@suaveplan/audience-rules";
// Deterministic store for tests: pinned clock + monotonic ID generator
let n = 0;
const store = new InMemorySegmentStore({
clock: () => 1_700_000_000_000,
idGenerator: () => {
n += 1;
return `test_seg_${n}`;
},
});
const a = await store.create({
tenantId: "t1",
name: "A",
rules: [],
refreshMode: "manual",
});
// a.id === "test_seg_1"; a.createdAt.getTime() === 1_700_000_000_000
```
## API Reference
- `interface SegmentStore` — see Features.
- `interface InMemorySegmentStoreOptions` — `clock?: Clock`,
`idGenerator?: IdGenerator` (both from `@suaveplan/types`).
- `class InMemorySegmentStore implements SegmentStore` — constructor accepts
`InMemorySegmentStoreOptions`. Throws `AudienceRulesError` with
`AUDIENCE_RULES.INVALID_INPUT` on Zod failure in `create` / `update`, and
on `list` with an empty `tenantId`.
## Implementation Notes
- The default `IdGenerator` is a per-instance closure, so two separate stores
in the same process produce independent ID sequences. This matches the
`@suaveplan/contacts` `InMemoryContactStore` convention.
- `create` and `update` validate input with `parseOrThrow` before mutating
the internal map — failure leaves the store untouched.
- `list` takes an `O(n)` scan; the package is a development-tier store and
multi-tenant filtering happens in-memory. Production stores backed by a
database can index on `tenantId` directly.
- Every mutation updates `updatedAt` via the injected clock, so OTel
histograms and snapshot diffs in consumers stay deterministic under
`FakeClock`.

View File

@@ -0,0 +1,105 @@
# segment.types
## Purpose
`segment.types.ts` defines every shared type, interface, and closed union
consumed by `@suaveplan/audience-rules`. Centralising the data shapes in one
module gives the package a single auditable contract for the segmentation
domain — `Segment`, `SegmentRule`, the `SegmentOperator` and `LogicalOp`
discriminated unions, `RefreshMode`, the `SegmentEvaluationResult` carried
back from the evaluator, the `ContactData` shape consumed by it, and the
`CreateSegmentInput` / `UpdateSegmentInput` boundary types that the store's
Zod schemas validate against.
## Features
- `SegmentOperator` — closed 14-member union of comparison operators.
- `LogicalOp` — closed `"and" | "or"` union.
- `RefreshMode` — closed `"realtime" | "scheduled" | "manual"` union.
- `SegmentRule` interface — `field`, `operator`, optional `value` and
`logicalOp`. Optional members are typed `T | undefined` to satisfy
`exactOptionalPropertyTypes` against Zod-inferred outputs.
- `Segment` interface — full persisted shape with optional cache fields
(`cachedContactIds`, `cachedCount`, `lastRefreshedAt`).
- `CreateSegmentInput` / `UpdateSegmentInput` — boundary types matching the
Zod schemas in `schemas/schemas.ts`.
- `SegmentEvaluationResult` — return shape from `SegmentEvaluator.evaluate`.
- `ContactData` + `ContactDataChannel` + `ContactDataDelivery` — contact
shape consumed by the evaluator's `ContactDataProvider`.
## Usage: Basic Example
```ts
import type {
Segment,
SegmentRule,
} from "@suaveplan/audience-rules";
const rule: SegmentRule = {
field: "status",
operator: "eq",
value: "active",
};
```
## Usage: Advanced Example
```ts
import type {
ContactData,
LogicalOp,
Segment,
SegmentEvaluationResult,
} from "@suaveplan/audience-rules";
function combine(
a: SegmentEvaluationResult,
b: SegmentEvaluationResult,
op: LogicalOp
): readonly string[] {
if (op === "or") {
return Array.from(new Set([...a.contactIds, ...b.contactIds]));
}
const bSet = new Set(b.contactIds);
return a.contactIds.filter((id) => bSet.has(id));
}
const contact: ContactData = {
id: "c1",
tags: ["vip"],
metadata: { country: "US" },
};
```
## API Reference
- `type SegmentOperator` — `"eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "contains" | "not_contains" | "in" | "not_in" | "exists" | "not_exists" | "starts_with" | "ends_with"`.
- `type LogicalOp` — `"and" | "or"`.
- `type RefreshMode` — `"realtime" | "scheduled" | "manual"`.
- `interface SegmentRule` — see Features.
- `interface Segment` — see Features.
- `interface CreateSegmentInput` — `tenantId`, `name`, `description?`,
`rules`, `refreshMode`.
- `interface UpdateSegmentInput` — every field optional; the
corresponding Zod schema rejects an empty patch.
- `interface SegmentEvaluationResult` — `contactIds`, `total`,
`evaluatedAt`, `matchRate`.
- `interface ContactData` — `id`, optional `tags`, `channels`,
`status`, `metadata`, `lastActivityAt`, `deliveryHistory`.
- `interface ContactDataChannel` — `type`, `value`.
- `interface ContactDataDelivery` — `date`, `channel`, `status`.
## Implementation Notes
- Every optional property is explicitly typed `T | undefined` rather than
using just `?:` because the suaveplan tsconfig sets
`exactOptionalPropertyTypes: true`. This lets the Zod-inferred outputs
(which always include `undefined` in optional unions) assign cleanly to
these interfaces without intermediate casts.
- `ContactData.metadata` is `Readonly<Record<string, unknown>>` so the
evaluator's `resolveField` can walk nested metadata while keeping the
surface immutable for callers.
- Adding a new `SegmentOperator` is a breaking change — it widens the
closed union and forces consumers' switch tables to update. The
preferred extension path is a sibling predicate factory, not a new
operator.

View File

@@ -0,0 +1,120 @@
# @suaveplan/contacts - Universal contact and group management: hierarchical n-level groups (adjacency + materialized path), channel-agnostic contacts with dedup, many-to-many memberships with ancestor count propagation, audience resolution with descendant walk + dedup + filters, contact merger, CSV/JSON import-export, injectable Clock + IdGenerator, Zod input boundaries, six OTel counters and three histograms, and typed ContactsError hierarchy with stable CONTACTS.* code registry.
Universal contact and group management with hierarchical nesting, audience resolution, contact merger, and CSV/JSON import-export. Multi-tenant, channel-agnostic, deterministic (injectable `Clock` + `IdGenerator`), Zod-validated at every public boundary, OTel-instrumented via `@suaveplan/opentelemetry`, and ships a typed `ContactsError` hierarchy with a stable `CONTACTS.*` code registry.
<!-- begin:auto-docs -->
## Documentation tree
- [audience-resolver](./src/core/audience-resolver/audience-resolver.md) — `AudienceResolver` flattens a set of groups into a deduplicated, filtered contact list — the typical "send this campaig…
- [audience-resolver](./src/core/audience-resolver/audience-resolver.md) — `AudienceResolver` flattens a set of groups into a deduplicated, filtered contact list — the typical "send this campaig…
- [contact-manager](./src/core/contact-manager/contact-manager.md) — `ContactManager` is the public CRUD surface for contacts. It validates inputs against the corresponding Zod schemas (`c…
- [contact-manager](./src/core/contact-manager/contact-manager.md) — `ContactManager` is the public CRUD surface for contacts. It validates inputs against the corresponding Zod schemas (`c…
- [contact-merger](./src/core/contact-merger/contact-merger.md) — `ContactMerger` consolidates duplicate contacts into a single primary record. It merges channels (dedup-by-`type:value`…
- [contact-merger](./src/core/contact-merger/contact-merger.md) — `ContactMerger` consolidates duplicate contacts into a single primary record. It merges channels (dedup-by-`type:value`…
- [group-manager](./src/core/group-manager/group-manager.md) — `GroupManager` is the public surface for hierarchical groups. It manages n-level nesting via a hybrid adjacency-list +…
- [group-manager](./src/core/group-manager/group-manager.md) — `GroupManager` is the public surface for hierarchical groups. It manages n-level nesting via a hybrid adjacency-list +…
- [membership-manager](./src/core/membership-manager/membership-manager.md) — `MembershipManager` orchestrates many-to-many contact-group memberships. It owns the integrity guarantee that `Group.co…
- [membership-manager](./src/core/membership-manager/membership-manager.md) — `MembershipManager` orchestrates many-to-many contact-group memberships. It owns the integrity guarantee that `Group.co…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `ContactsError` class for `@suaveplan/contacts`. Every failure path th…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `ContactsError` class for `@suaveplan/contacts`. Every failure path th…
- [contact-exporter](./src/import-export/contact-exporter/contact-exporter.md) — `ContactExporter` serializes contacts to JSON or CSV with optional field inclusion flags. CSV output follows RFC 4180 q…
- [contact-exporter](./src/import-export/contact-exporter/contact-exporter.md) — `ContactExporter` serializes contacts to JSON or CSV with optional field inclusion flags. CSV output follows RFC 4180 q…
- [contact-importer](./src/import-export/contact-importer/contact-importer.md) — `ContactImporter` ingests contacts from already-parsed record arrays or raw CSV content. It applies caller-supplied fie…
- [contact-importer](./src/import-export/contact-importer/contact-importer.md) — `ContactImporter` ingests contacts from already-parsed record arrays or raw CSV content. It applies caller-supplied fie…
- [otel](./src/otel/instruments.md) — Exposes the bundle of OpenTelemetry metric instruments emitted by `@suaveplan/contacts`. The bundle binds lazily agains…
- [instruments](./src/otel/instruments.md) — Exposes the bundle of OpenTelemetry metric instruments emitted by `@suaveplan/contacts`. The bundle binds lazily agains…
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary of `@suaveplan/contacts` plus the `parseOrThrow` helper that converts Zod v…
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary of `@suaveplan/contacts` plus the `parseOrThrow` helper that converts Zod v…
- [contact-store](./src/stores/contact-store/contact-store.md) — Defines the `ContactStore` storage interface that `ContactManager` and `AudienceResolver` consume, plus the `InMemoryCo…
- [contact-store](./src/stores/contact-store/contact-store.md) — Defines the `ContactStore` storage interface that `ContactManager` and `AudienceResolver` consume, plus the `InMemoryCo…
- [group-store](./src/stores/group-store/group-store.md) — Defines the `GroupStore` storage interface that `GroupManager` and `AudienceResolver` consume, plus the `InMemoryGroupS…
- [group-store](./src/stores/group-store/group-store.md) — Defines the `GroupStore` storage interface that `GroupManager` and `AudienceResolver` consume, plus the `InMemoryGroupS…
- [membership-store](./src/stores/membership-store/membership-store.md) — Defines the `MembershipStore` storage interface that `MembershipManager`, `ContactMerger`, and `AudienceResolver` consu…
- [membership-store](./src/stores/membership-store/membership-store.md) — Defines the `MembershipStore` storage interface that `MembershipManager`, `ContactMerger`, and `AudienceResolver` consu…
- [tree](./src/tree/tree-utils.md) — Pure tree utilities for building, flattening, traversing, and inspecting `GroupTreeNode<TMeta>` hierarchies. None of th…
- [tree-utils](./src/tree/tree-utils.md) — Pure tree utilities for building, flattening, traversing, and inspecting `GroupTreeNode<TMeta>` hierarchies. None of th…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/contacts
```
## Quick start
```ts
import {
AudienceResolver,
ContactManager,
GroupManager,
InMemoryContactStore,
InMemoryGroupStore,
InMemoryMembershipStore,
MembershipManager,
} from "@suaveplan/contacts";
const contactStore = new InMemoryContactStore();
const groupStore = new InMemoryGroupStore();
const membershipStore = new InMemoryMembershipStore();
const contacts = new ContactManager({ store: contactStore });
const groups = new GroupManager({ store: groupStore });
const memberships = new MembershipManager({
contactStore,
groupStore,
membershipStore,
});
const resolver = new AudienceResolver({
contactStore,
groupStore,
membershipStore,
});
const vip = await groups.create({ tenantId: "acme", name: "VIP Customers" });
const alice = await contacts.create({
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@example.com", primary: true, verified: true },
],
});
await memberships.addContactToGroup(vip.id, alice.id);
const audience = await resolver.resolve({ groupIds: [vip.id] });
console.log(audience.totalCount); // 1
```
## Architecture
```text
ContactStore GroupStore MembershipStore <- pluggable storage
│ │ │
└─── ContactManager / GroupManager / MembershipManager <- CRUD + validation + OTel
│
┌─────────────┴─────────────┐
│ │
AudienceResolver ContactMerger / ContactImporter / ContactExporter
(group walk + dedup) (data ops + import/export pipelines)
```
Every public mutating method validates via Zod, raises a typed `ContactsError` (never a bare `Error`), and emits on at least one OTel instrument. All temporal and id-generating paths accept injectable `Clock` and `IdGenerator` from `@suaveplan/types`.
## Modules
| Module | Documentation |
|---|---|
| Error codes (`CONTACTS_CODES`, `ContactsError`) | [src/error-codes/error-codes.md](src/error-codes/error-codes.md) |
| OTel instruments (`getContactsInstruments`) | [src/otel/instruments.md](src/otel/instruments.md) |
| Zod schemas (`createContactInputSchema`, …, `parseOrThrow`) | [src/schemas/schemas.md](src/schemas/schemas.md) |
| Contact store (`InMemoryContactStore`) | [src/stores/contact-store/contact-store.md](src/stores/contact-store/contact-store.md) |
| Group store (`InMemoryGroupStore`) | [src/stores/group-store/group-store.md](src/stores/group-store/group-store.md) |
| Membership store (`InMemoryMembershipStore`) | [src/stores/membership-store/membership-store.md](src/stores/membership-store/membership-store.md) |
| `ContactManager` | [src/core/contact-manager/contact-manager.md](src/core/contact-manager/contact-manager.md) |
| `GroupManager` | [src/core/group-manager/group-manager.md](src/core/group-manager/group-manager.md) |
| `MembershipManager` | [src/core/membership-manager/membership-manager.md](src/core/membership-manager/membership-manager.md) |
| `ContactMerger` | [src/core/contact-merger/contact-merger.md](src/core/contact-merger/contact-merger.md) |
| `AudienceResolver` | [src/core/audience-resolver/audience-resolver.md](src/core/audience-resolver/audience-resolver.md) |
| `ContactExporter` | [src/import-export/contact-exporter/contact-exporter.md](src/import-export/contact-exporter/contact-exporter.md) |
| `ContactImporter` | [src/import-export/contact-importer/contact-importer.md](src/import-export/contact-importer/contact-importer.md) |
| Tree utilities (`buildGroupTree`, …) | [src/tree/tree-utils.md](src/tree/tree-utils.md) |

View File

@@ -0,0 +1,83 @@
# audience-resolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AudienceResolver` flattens a set of groups into a deduplicated, filtered contact list — the typical "send this campaign to everyone in VIP + Recent Purchasers minus Unsubscribed" use case. It recursively expands every input group via `GroupStore.getDescendants`, applies symmetrical exclusion expansion, looks up memberships in bulk, dedups by contact id while counting duplicates, filters by `channelType` and `statusFilter`, and reports `{ contacts, totalCount, groupsResolved, duplicatesRemoved }` so a UI can show the user exactly what was resolved and why. Resolution time is captured on the `audience.resolve_duration_ms` OTel histogram.
## Features
- Recursive group expansion via materialized path (`getDescendants`).
- Symmetric `excludeGroupIds` expansion — every descendant of every exclude is also excluded.
- `excludeContactIds` for individual opt-outs.
- `channelType` filter for "email-only" / "sms-only" sends.
- `statusFilter` for keeping only `active` (etc.) contacts.
- Dedup with `duplicatesRemoved` count surfaced in the result.
- `previewCount(options)` convenience returning just `totalCount`.
- Zod-validated options; `INVALID_INPUT` on malformed inputs.
- `audience.resolve_duration_ms` OTel histogram around every `resolve` call.
## Usage: Basic
```ts
import {
AudienceResolver,
InMemoryContactStore,
InMemoryGroupStore,
InMemoryMembershipStore,
} from "@suaveplan/contacts";
const resolver = new AudienceResolver({
contactStore: new InMemoryContactStore(),
groupStore: new InMemoryGroupStore(),
membershipStore: new InMemoryMembershipStore(),
});
const audience = await resolver.resolve({ groupIds: ["g_vip"] });
console.log(`${audience.totalCount} contacts`);
```
## Usage: Advanced
```ts
import { AudienceResolver } from "@suaveplan/contacts";
async function emailAudience(resolver: AudienceResolver) {
const result = await resolver.resolve({
groupIds: ["g_all-customers"],
excludeGroupIds: ["g_unsubscribed"],
excludeContactIds: ["c_blacklist-1", "c_blacklist-2"],
channelType: "email",
statusFilter: ["active"],
});
return result.contacts;
}
// Preview-only path for UI count display before send
async function showCount(resolver: AudienceResolver, groupIds: readonly string[]) {
const n = await resolver.previewCount({ groupIds });
return `Will send to ${n} people.`;
}
```
## API Reference
### `class AudienceResolver`
Constructor: `new AudienceResolver({ contactStore, groupStore, membershipStore, clock? })`.
- `resolve(options): Promise<AudienceResult>` — validates options, expands include + exclude groups, looks up memberships, dedups, filters, returns `{ contacts, totalCount, groupsResolved, duplicatesRemoved }`. Records duration on `audience.resolve_duration_ms`.
- `previewCount(options): Promise<number>` — convenience wrapper over `resolve` returning only `totalCount`.
## Implementation Notes
Group expansion uses `Set<string>` for both include and exclude sets, which makes the symmetric difference (`include − exclude`) O(N + M). Each `getDescendants` call is delegated to the store, so the cost depends on the store implementation — the in-memory store is O(N) per call; a database adapter should index `path` for `LIKE 'parent/%'` scans.
Dedup counts every encounter of a contact id beyond the first, so the count corresponds to "people who would have been emailed twice if we didn't dedup." This is the metric typical campaign UIs want to show.
`channelType` and `statusFilter` are applied after contact materialization, not at the store level. This is intentional — they can't be expressed cleanly in the bulk-membership lookup, and the contact slice is typically small enough by that point (post-group-expansion + dedup) that a JS filter is cheap. Database-backed stores wanting to push the filter down can override `contactStore.findByIds` to accept additional filter arguments.
`AudienceResolveOptions.deduplicate` is accepted for forward compatibility but does not change behavior today — the resolver always dedupes. A future version may make it opt-out for callers who want raw membership scans.

View File

@@ -0,0 +1,99 @@
# contact-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ContactManager` is the public CRUD surface for contacts. It validates inputs against the corresponding Zod schemas (`createContactInputSchema`, `updateContactInputSchema`) before any side effect, raises typed `ContactsError` codes on every failure path, optionally deduplicates by channel (default on) to prevent two contacts from owning the same `(type, value)` in a tenant, and emits OTel counters (`contacts.created_total`, `contacts.updated_total`, `contacts.deleted_total`) on every mutation. It composes around any `ContactStore` so it works with the in-memory reference store, a database adapter, or a custom backend.
## Features
- Zod-validated boundaries — `INVALID_INPUT` raised before any side effect on bad input.
- Channel deduplication — `CONTACT_CHANNEL_EXISTS` on create-collision, `CONTACT_CHANNEL_EXISTS_ON_OTHER` on update / addChannel collision; configurable.
- Primary channel auto-promotion — the first channel is marked primary when none are flagged.
- Tag operations — `addTag` (idempotent), `removeTag`.
- Channel operations — `addChannel` honors the same dedup rules as `create` / `update`.
- OTel counters with `tenant` attribute on every mutating method.
- All read paths (`getById`, `findByChannel`, `findByIds`, `list`) pass through to the store.
## Usage: Basic
```ts
import {
ContactManager,
InMemoryContactStore,
} from "@suaveplan/contacts";
const mgr = new ContactManager({ store: new InMemoryContactStore() });
const alice = await mgr.create({
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
});
await mgr.addTag(alice.id, "vip");
```
## Usage: Advanced
```ts
import {
CONTACTS_CODES,
ContactManager,
ContactsError,
InMemoryContactStore,
} from "@suaveplan/contacts";
const mgr = new ContactManager({
store: new InMemoryContactStore(),
deduplicateByChannel: true,
});
try {
await mgr.create({
tenantId: "acme",
displayName: "Bob",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
});
} catch (err) {
if (
err instanceof ContactsError &&
err.code === CONTACTS_CODES.CONTACT_CHANNEL_EXISTS
) {
// Offer to merge with err.contactId
} else {
throw err;
}
}
```
## API Reference
### `class ContactManager`
Constructor: `new ContactManager({ store, deduplicateByChannel? = true })`.
- `create(input)` — validates, dedups, auto-promotes primary, emits `contacts.created_total`. Returns the persisted `Contact`.
- `getById(id)` — returns `Contact | null`.
- `update(id, input)` — validates, raises `CONTACT_NOT_FOUND` if missing, dedups when channels are in the patch, emits `contacts.updated_total`.
- `delete(id)` — idempotent, emits `contacts.deleted_total` only when a contact was actually present.
- `list(query)` — paginated read passing through to the store.
- `findByChannel(tenantId, type, value)` / `findByIds(ids)` — straight passes.
- `addTag(id, tag)` — idempotent; no-op if tag already present.
- `removeTag(id, tag)` — removes tag (no-op if absent).
- `addChannel(id, channel)` — appends; dedup-checked when enabled.
All async; all may raise `ContactsError`.
## Implementation Notes
`promotePrimary` is intentionally non-mutating — it returns a new array with the first channel marked primary when none are flagged. This keeps `CreateContactInput.channels` `readonly`-compatible.
`deduplicateByChannel: false` exists for two real use cases: bulk imports that already trust the source, and migrations where you're knowingly bringing in a temporary collision and will fix it in a follow-up step. The flag is configurable per `ContactManager` instance so a single store can host both strict and lenient managers.
OTel emission happens after a successful side effect, never before. Failed operations don't increment the success counters — this is important for dashboards that compute error rates from counter ratios.

View File

@@ -0,0 +1,92 @@
# contact-merger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ContactMerger` consolidates duplicate contacts into a single primary record. It merges channels (dedup-by-`type:value`, secondaries demoted from primary), tags (set union), and metadata (primary wins on key collision), transfers every secondary's group memberships to the primary unless the primary is already a member, and then deletes the secondaries. The operation is atomic from the consumer's perspective (a single async call), uses an injected `Clock` for the timestamp on every transferred membership, raises typed `ContactsError` codes on every failure, and emits the `contacts.merged_total` OTel counter.
## Features
- Channel merge with primary-flag demotion of secondary channels.
- Tag union via `Set` semantics.
- Metadata merge with primary-priority key resolution.
- Membership transfer without duplicates (skip when primary is already a member).
- Secondary deletion with full membership cleanup.
- `MERGE_PRIMARY_NOT_FOUND` / `MERGE_SECONDARY_NOT_FOUND` typed errors.
- `contacts.merged_total{tenant, secondaries}` OTel counter.
- Injectable `Clock` for deterministic membership `addedAt`.
## Usage: Basic
```ts
import {
ContactMerger,
InMemoryContactStore,
InMemoryMembershipStore,
} from "@suaveplan/contacts";
const merger = new ContactMerger({
contactStore: new InMemoryContactStore(),
membershipStore: new InMemoryMembershipStore(),
});
const { mergedContact, removedContactIds } = await merger.merge(
"primary-id",
["secondary-1", "secondary-2"],
);
```
## Usage: Advanced
```ts
import {
CONTACTS_CODES,
ContactsError,
ContactMerger,
} from "@suaveplan/contacts";
async function safeMerge(
merger: ContactMerger,
primaryId: string,
secondaryIds: readonly string[],
) {
try {
return await merger.merge(primaryId, secondaryIds);
} catch (err) {
if (err instanceof ContactsError) {
switch (err.code) {
case CONTACTS_CODES.MERGE_PRIMARY_NOT_FOUND:
return { error: "primary deleted by another session", err };
case CONTACTS_CODES.MERGE_SECONDARY_NOT_FOUND:
return { error: "one or more secondaries are missing", err };
default:
throw err;
}
}
throw err;
}
}
```
## API Reference
### `class ContactMerger`
Constructor: `new ContactMerger({ contactStore, membershipStore, clock? })`.
- `merge(primaryId, secondaryIds): Promise<MergeResult>` — validates primary + every secondary exists, merges channels / tags / metadata into the primary via `contactStore.update`, transfers secondary memberships, removes all secondary memberships, deletes every secondary, emits `contacts.merged_total{tenant, secondaries: <count>}`. Returns `{ mergedContact, removedContactIds }`.
`MergeResult.mergedContact` is the post-update primary. `MergeResult.removedContactIds` is the list of secondary ids that were deleted.
## Implementation Notes
Channel merge dedup uses `${type}:${value}` as the canonical key. When a secondary contributes a channel that is already on the primary, the channel is dropped (the primary's flags win). When a secondary contributes a new channel, it is appended with `primary: false` regardless of the secondary's original flag — the primary's primary channel stays primary.
Metadata merge is shallow (`if (!(key in merged)) merged[key] = value`). Nested objects on the primary are not deep-merged with secondaries; consumers wanting deep merge should pre-process and pass a flat metadata bag.
Membership transfer happens before secondary deletion, in order: for each secondary, walk its memberships; skip when the primary is already in that group; otherwise insert a new membership for the primary with the merger's `Clock`-derived `addedAt`. Then `removeAllForContact(secondary.id)` removes the secondary's stale memberships, and finally `contactStore.delete(secondary.id)` removes the secondary.
The operation is not transactional at the store level — the in-memory implementation is single-threaded so the sequencing is effectively atomic, but a database-backed `ContactStore` should wrap the merger in a transaction to maintain the same guarantee.

View File

@@ -0,0 +1,100 @@
# group-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GroupManager` is the public surface for hierarchical groups. It manages n-level nesting via a hybrid adjacency-list + materialized-path strategy, automatically derives `path` and `depth` from the parent on create, propagates path rewrites through descendants on rename or move, blocks circular references on move, and cascade-deletes descendants on delete. Every input is Zod-validated, every failure path raises a typed `ContactsError`, and every successful create emits the `groups.created_total` OTel counter.
## Features
- Hierarchical create — child `path` derives from parent, `depth = parent.depth + 1`.
- Rename propagation — changing a group's name rewrites the slug in its `path` and every descendant's `path`.
- Move with cycle detection — `GROUP_CIRCULAR_REF` blocks moving a group under one of its own descendants (or itself).
- Move with descendant rewrite — descendants follow the new path; `depth` adjusts by the depth delta.
- Cascade delete — `delete` removes the group and every descendant in DFS order.
- `GROUP_PARENT_NOT_FOUND` on create with missing parent; `GROUP_NEW_PARENT_NOT_FOUND` on move to missing parent.
- Injectable `Clock` for deterministic `createdAt` / `updatedAt`.
- Reads: `list`, `getChildren`, `getDescendants`, `getRoots` pass through to the store.
## Usage: Basic
```ts
import {
GroupManager,
InMemoryGroupStore,
} from "@suaveplan/contacts";
const mgr = new GroupManager({ store: new InMemoryGroupStore() });
const root = await mgr.create({ tenantId: "acme", name: "All Customers" });
const vip = await mgr.create({
tenantId: "acme",
name: "VIP",
parentId: root.id,
});
console.log(vip.path); // "/all-customers/vip"
console.log(vip.depth); // 1
```
## Usage: Advanced
```ts
import {
CONTACTS_CODES,
ContactsError,
GroupManager,
InMemoryGroupStore,
} from "@suaveplan/contacts";
const store = new InMemoryGroupStore();
const mgr = new GroupManager({ store, clock: () => 1_700_000_000_000 });
const a = await mgr.create({ tenantId: "t", name: "Region A" });
const b = await mgr.create({ tenantId: "t", name: "Region B" });
const child = await mgr.create({
tenantId: "t",
name: "Office",
parentId: a.id,
});
try {
await mgr.move(a.id, child.id); // would create a cycle
} catch (err) {
if (
err instanceof ContactsError &&
err.code === CONTACTS_CODES.GROUP_CIRCULAR_REF
) {
console.warn("cannot move parent under its own descendant");
}
}
// Move office under region B — descendants follow
await mgr.move(child.id, b.id);
```
## API Reference
### `class GroupManager`
Constructor: `new GroupManager({ store, clock? })`.
- `create(input)` — derives path + depth from parent; emits `groups.created_total`.
- `getById(id)` / `list(query)` / `getChildren(id)` / `getRoots(tenantId)` — straight passes.
- `getDescendants(id)` — returns `[]` if id is missing; otherwise descendants of the group's path.
- `update(id, input)` — patch; renaming rewrites path + descendants. Raises `INVALID_INPUT` / `GROUP_NOT_FOUND`.
- `delete(id)` — cascade-deletes descendants then the group itself. No-op for missing id.
- `move(id, newParentId)` — re-parents with cycle detection and descendant path rewrite. `newParentId === null` flattens to root. Raises `GROUP_NOT_FOUND` / `GROUP_CIRCULAR_REF` / `GROUP_NEW_PARENT_NOT_FOUND`.
All async; all may raise `ContactsError`.
## Implementation Notes
Slugification (`slugify(name)`) lowercases, replaces non-alphanumeric runs with `-`, and trims leading/trailing dashes. It's intentionally simple — i18n-aware slugging is the consumer's responsibility (e.g. via `@suaveplan/i18n` formatters before calling `create`).
Path rewrite is non-recursive: `updateDescendantPaths` fetches all descendants once via `store.getDescendants(oldPath)`, then issues parallel `store.update` calls keyed by id. The same primitive powers both rename propagation and move-with-descendants, with `depthDelta` defaulting to 0 for rename and computed for move.
Cycle detection uses descendant scanning rather than ancestor walking because `getDescendants` is already implemented as an O(N) path-prefix scan; doing an ancestor walk would require multiple `getById` round-trips. The trade-off is fine for typical hierarchies (tens to thousands of nodes per tenant).
`delete` does not currently emit a `groups.deleted_total` counter — only `create` is metered. This matches the upstream design and keeps the surface minimal; consumers wanting deletion metrics can wrap the manager.

View File

@@ -0,0 +1,95 @@
# membership-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MembershipManager` orchestrates many-to-many contact-group memberships. It owns the integrity guarantee that `Group.contactCount` (direct) and `Group.totalContactCount` (recursive, ancestor-propagated) stay consistent with the actual membership records — every add/remove walks the ancestor chain and updates the rolled-up totals so list views can render hierarchical totals without an extra round-trip per group. It also exposes bulk helpers (`addContactToMultipleGroups`, `addMultipleContactsToGroup`) that swallow `MEMBERSHIP_EXISTS` collisions and continue, which is the convenient default for batch imports.
## Features
- Single-pair add / remove with full ancestor count propagation.
- Bulk helpers that swallow `MEMBERSHIP_EXISTS` but rethrow every other error.
- Existence + lookup helpers (`isContactInGroup`, `getGroupsForContact`, `getMembersOfGroup`, `countMembersInGroup`).
- Cascade helper (`removeAllMembershipsForContact`) used by `ContactMerger` and by GDPR-delete consumers.
- Typed `ContactsError` codes (`MEMBERSHIP_GROUP_NOT_FOUND`, `MEMBERSHIP_CONTACT_NOT_FOUND`, `MEMBERSHIP_EXISTS`) on every failure.
- Injectable `Clock` for deterministic `addedAt` timestamps.
- `memberships.added_total` OTel counter with `tenant` attribute.
## Usage: Basic
```ts
import {
InMemoryContactStore,
InMemoryGroupStore,
InMemoryMembershipStore,
MembershipManager,
} from "@suaveplan/contacts";
const cs = new InMemoryContactStore();
const gs = new InMemoryGroupStore();
const ms = new InMemoryMembershipStore();
const mgr = new MembershipManager({
contactStore: cs,
groupStore: gs,
membershipStore: ms,
});
await mgr.addContactToGroup("g_1", "c_1");
```
## Usage: Advanced
```ts
import {
CONTACTS_CODES,
ContactsError,
MembershipManager,
} from "@suaveplan/contacts";
// Bulk add tolerating duplicates
await mgr.addMultipleContactsToGroup("g_vip", ["c_1", "c_2", "c_3"]);
// Bulk add to multiple groups for one contact (raises on first non-duplicate failure)
try {
await mgr.addContactToMultipleGroups("c_42", ["g_a", "g_b", "g_c"]);
} catch (err) {
if (
err instanceof ContactsError &&
err.code === CONTACTS_CODES.MEMBERSHIP_GROUP_NOT_FOUND
) {
console.warn(`group ${err.groupId} does not exist`);
} else {
throw err;
}
}
await mgr.removeAllMembershipsForContact("c_off-boarded");
```
## API Reference
### `class MembershipManager`
Constructor: `new MembershipManager({ contactStore, groupStore, membershipStore, clock? })`.
- `addContactToGroup(groupId, contactId, metadata?)` — validates both ids exist, raises `MEMBERSHIP_EXISTS` on duplicate, increments `contactCount` + every ancestor `totalContactCount`, emits `memberships.added_total`.
- `removeContactFromGroup(groupId, contactId)` — decrements (floor 0) and propagates negative delta up the chain.
- `addContactToMultipleGroups(contactId, groupIds, metadata?)` — strict; rethrows on any failure.
- `addMultipleContactsToGroup(groupId, contactIds, metadata?)` — lenient; swallows `MEMBERSHIP_EXISTS` and continues.
- `getGroupsForContact(contactId)` / `getMembersOfGroup(groupId)` — full membership records.
- `isContactInGroup(groupId, contactId)` — boolean lookup.
- `removeAllMembershipsForContact(contactId)` — cascade; calls `removeContactFromGroup` per membership to keep totals consistent.
- `countMembersInGroup(groupId)` — direct count, distinct from `Group.contactCount`.
All async; all may raise `ContactsError`.
## Implementation Notes
Ancestor count propagation walks `parentId` chains one step at a time via `groupStore.getById`. If the chain is broken (parent has been deleted out from under us), the loop terminates safely. The behavior is exercised by a dedicated test that constructs a dangling-parent state.
The "swallow duplicates" bulk add is intentional: typical use cases (import a CSV of 10,000 rows into a group; tag a customer with their five existing categories) routinely encounter duplicates and treating them as errors would force callers to pre-check every pair. The strict `addContactToGroup` and `addContactToMultipleGroups` are available for callers who want explicit validation.
`floor 0` on remove (`Math.max(0, contactCount - 1)`) prevents the count from going negative if the same contact is removed twice. This is defensive — the store-level `remove` is already idempotent — but the count math is not, so we clamp.

View File

@@ -0,0 +1,86 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and typed `ContactsError` class for `@suaveplan/contacts`. Every failure path the package exposes raises a `ContactsError` carrying one of fourteen registered codes (`INVALID_INPUT`, `CONTACT_NOT_FOUND`, `CONTACT_CHANNEL_EXISTS`, `CONTACT_CHANNEL_EXISTS_ON_OTHER`, `GROUP_NOT_FOUND`, `GROUP_PARENT_NOT_FOUND`, `GROUP_CIRCULAR_REF`, `GROUP_NEW_PARENT_NOT_FOUND`, `MEMBERSHIP_EXISTS`, `MEMBERSHIP_GROUP_NOT_FOUND`, `MEMBERSHIP_CONTACT_NOT_FOUND`, `MERGE_PRIMARY_NOT_FOUND`, `MERGE_SECONDARY_NOT_FOUND`, `IMPORT_VALIDATION_FAILED`). Codes are namespaced strings (`"CONTACTS.GROUP_CIRCULAR_REF"`) so consumers can dispatch programmatically without parsing messages, and the typed error narrows the `contactId` / `groupId` / `tenantId` discriminants for diagnostics.
## Features
- `CONTACTS_CODES` — `defineCodes("CONTACTS", [...])` registry; the canonical source of truth for every code emitted by the package.
- `ContactsError extends SuaveplanError<ContactsErrorCode>` — typed error class. `instanceof` narrows `code` to a literal-string union.
- `ContactsErrorCode` — union of every registered code.
- `ContactsErrorOptions` — options bag extending `SuaveplanErrorOptions` with `contactId`, `groupId`, `tenantId` discriminants and standard `cause` chaining.
- Discriminants flow through to OTel span events via `SuaveplanError.toSpanEvent()` (inherited from base).
## Usage: Basic
```ts
import { CONTACTS_CODES, ContactsError } from "@suaveplan/contacts";
function requireContact(id: string) {
const c = lookup(id);
if (!c) {
throw new ContactsError(
CONTACTS_CODES.CONTACT_NOT_FOUND,
`Contact '${id}' not found`,
{ contactId: id },
);
}
return c;
}
```
## Usage: Advanced
```ts
import { CONTACTS_CODES, ContactsError } from "@suaveplan/contacts";
try {
await contacts.create(input);
} catch (err) {
if (err instanceof ContactsError) {
switch (err.code) {
case CONTACTS_CODES.INVALID_INPUT:
return showValidationToast(err.message);
case CONTACTS_CODES.CONTACT_CHANNEL_EXISTS:
return openMergeDialog(err.contactId);
default:
throw err;
}
}
throw err;
}
```
## API Reference
### `CONTACTS_CODES`
A frozen registry produced by `defineCodes("CONTACTS", [...] as const)`. Each entry maps a bare key (e.g. `MERGE_PRIMARY_NOT_FOUND`) to its namespaced string (`"CONTACTS.MERGE_PRIMARY_NOT_FOUND"`).
### `ContactsErrorCode`
Union type `(typeof CONTACTS_CODES)[keyof typeof CONTACTS_CODES]`. Use to type-check `error.code` in catch blocks.
### `ContactsError(code, message, options?)`
Constructor parameters:
- `code: ContactsErrorCode` — required, the discriminant.
- `message: string` — required, English diagnostic.
- `options.contactId?: string` — attaches `error.contactId`.
- `options.groupId?: string` — attaches `error.groupId`.
- `options.tenantId?: string` — attaches `error.tenantId`.
- `options.cause?: unknown` — standard ES `Error.cause`; preserved through serialization and span events.
## Implementation Notes
The discriminants live as direct properties on the instance rather than inside `options.context` so consumers can do `if (err.contactId)` without an extra unwrap. Codes are validated at registry time by `defineCodes`, which freezes the result and enforces unique namespace tokens.
Adding a new code requires three concurrent edits: append the literal to the `CONTACTS_CODES` array, raise it from at least one production call site, and add a test that round-trips an instance with the new code. The OpenSpec conformance script (`bun scripts/conformance.ts @suaveplan/contacts`) and the package's own `contacts.spec-conformance.test.ts` both enforce the "every code thrown" rule — removing a code to placate a gate is banned per `AGENTS.md` §2c.
Error-code strings are part of the public API. Renaming one is a breaking change. Add a new code instead and let consumers migrate explicitly.

View File

@@ -0,0 +1,78 @@
# contact-exporter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ContactExporter` serializes contacts to JSON or CSV with optional field inclusion flags. CSV output follows RFC 4180 quoting rules (cells containing commas, double quotes, or newlines are wrapped in double quotes; embedded quotes are doubled). The unified `export(contacts, options)` entry point validates options via `exportOptionsSchema` and emits the `contacts.export_duration_ms` OTel histogram so dashboards can track export performance against payload size.
## Features
- `exportToJson(contacts, options?)` — pretty-printed JSON array via `JSON.stringify(..., 2)`.
- `exportToCsv(contacts, options?)` — RFC 4180-compliant CSV with header row.
- `export(contacts, options)` — Zod-validated entry that dispatches to JSON or CSV.
- Field-inclusion flags — `includeChannels`, `includeTags`, `includeMetadata`.
- Primary channel resolution — exports the first channel of type `email` (primary) and the first of type `sms` or `phone` (primary).
- OTel histogram with `format` attribute on every `export` call.
- Injectable `Clock` for deterministic duration measurement.
## Usage: Basic
```ts
import { ContactExporter } from "@suaveplan/contacts";
const exporter = new ContactExporter();
const csv = exporter.export(contacts, { format: "csv" });
const json = exporter.export(contacts, { format: "json" });
```
## Usage: Advanced
```ts
import { ContactExporter } from "@suaveplan/contacts";
const exporter = new ContactExporter({ clock: () => Date.now() });
// Minimal CSV — no channels, no tags, no metadata
const minimal = exporter.exportToCsv(contacts, {
format: "csv",
includeChannels: false,
includeTags: false,
});
// Full JSON dump including metadata as a JSON string column
const full = exporter.exportToJson(contacts, {
format: "json",
includeMetadata: true,
});
await Deno.writeTextFile("contacts.csv", minimal);
```
## API Reference
### `class ContactExporter`
Constructor: `new ContactExporter({ clock? })`.
- `export(contacts, options): string` — validates `options` via `exportOptionsSchema`, dispatches to `exportToJson` or `exportToCsv`, records duration. Raises `ContactsError(INVALID_INPUT)` if options are malformed.
- `exportToJson(contacts, options?): string` — direct JSON entry; does not record duration.
- `exportToCsv(contacts, options?): string` — direct CSV entry; does not record duration; returns `""` when the input is empty.
### `interface ExportOptions`
- `format: "csv" | "json"` — required dispatcher.
- `fields?: readonly string[]` — reserved for future column-selection; ignored today.
- `includeChannels?: boolean` — defaults to `true`. When false, omits `email` and `phone` columns.
- `includeTags?: boolean` — defaults to `true`. When false, omits the `tags` column.
- `includeMetadata?: boolean` — defaults to `false`. When true, serializes metadata as a JSON string column.
## Implementation Notes
The CSV column set is fixed (`id, displayName, firstName, lastName, status` plus optional `email, phone, tags, metadata`). Customising the column projection is reserved for the `fields` option in a future version; the current API matches the upstream behavior so existing consumers don't need to migrate.
Phone resolution accepts both `sms` and `phone` channel types because legacy upstream data sometimes used `phone` for the same field SMS sends use. The primary-flag heuristic (`primary: true`) picks the most-recently-flagged channel rather than guessing — call sites are expected to maintain the flag correctly.
The duration histogram fires only from the top-level `export(...)` call. `exportToJson` and `exportToCsv` are intentionally instrument-free so they remain pure-functional when consumers want to bypass the dispatcher (and the OTel emission) for hot-path serialization.

View File

@@ -0,0 +1,110 @@
# contact-importer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ContactImporter` ingests contacts from already-parsed record arrays or raw CSV content. It applies caller-supplied field mappings (e.g. `Name → displayName`, `Email → email`), prepends default tags, optionally checks for channel-level duplicates before insert, and captures per-row exceptions into a structured `ImportResult.errors` array so a partial import never throws on the first failure. Total wall-clock duration is recorded on the `contacts.import_duration_ms` OTel histogram for capacity-planning dashboards.
## Features
- `importFromRecords(records, options)` — accepts pre-parsed `Record<string, string>[]`.
- `importFromCsv(csv, options)` — RFC 4180-style quoted-field parser, then forwards to `importFromRecords`.
- Field mapping — `displayName`, `firstName`, `lastName`, `email`, `phone`, `tags` plus open custom slots.
- `displayName` fallback — combines first + last name when no direct mapping is provided.
- `defaultTags` — prepended to every imported contact's tag list.
- `skipDuplicates` — checks every channel against `ContactStore.findByChannelValue` before insert.
- Per-row error capture — exceptions are recorded with 1-based row number and the offending record.
- OTel histogram `contacts.import_duration_ms{tenant}` on every import.
## Usage: Basic
```ts
import {
ContactImporter,
InMemoryContactStore,
} from "@suaveplan/contacts";
const store = new InMemoryContactStore();
const importer = new ContactImporter({ store });
const result = await importer.importFromCsv(
"Name,Email\nAlice,alice@x.io\nBob,bob@x.io",
{
tenantId: "acme",
fieldMapping: { displayName: "Name", email: "Email" },
},
);
console.log(result.imported); // 2
```
## Usage: Advanced
```ts
import { ContactImporter, InMemoryContactStore } from "@suaveplan/contacts";
const importer = new ContactImporter({ store: new InMemoryContactStore() });
const records = [
{ First: "Jane", Last: "Doe", Email: "jane@x.io", Tags: "vip,2026-cohort" },
{ First: "John", Last: "", Email: "john@x.io" },
];
const result = await importer.importFromRecords(records, {
tenantId: "acme",
fieldMapping: {
firstName: "First",
lastName: "Last",
email: "Email",
tags: "Tags",
},
defaultTags: ["imported-2026"],
skipDuplicates: true,
});
if (result.errors.length > 0) {
console.error(`failed ${result.errors.length} rows:`);
for (const e of result.errors) console.error(` row ${e.row}: ${e.message}`);
}
```
## API Reference
### `class ContactImporter`
Constructor: `new ContactImporter({ store, clock? })`.
- `importFromRecords(records, options): Promise<ImportResult>` — main entry. Validates options, iterates records, maps, dedups, inserts, captures errors. Records duration.
- `importFromCsv(csv, options): Promise<ImportResult>` — parses CSV, forwards to `importFromRecords`. Wraps non-`ContactsError` parse failures into `ContactsError(IMPORT_VALIDATION_FAILED)`.
### `interface FieldMapping`
Open-shape map from source column name to contact field. Named slots: `displayName`, `firstName`, `lastName`, `email`, `phone`, `tags`. Custom keys are tolerated and ignored by the mapper today (reserved for future custom-field metadata).
### `interface ImportOptions`
- `tenantId: string` — required.
- `fieldMapping: FieldMapping` — required.
- `defaultTags?: readonly string[]` — prepended to every contact's tags.
- `skipDuplicates?: boolean` — when true, channel collisions cause `skipped++` instead of an insert.
- `channelType?: string` — reserved for future per-import channel-type override.
### `interface ImportResult`
`{ imported: number, skipped: number, errors: readonly ImportError[] }`.
### `interface ImportError`
`{ row: number, message: string, data?: Record<string, string> }` with 1-based `row`.
## Implementation Notes
`displayName` resolution prefers the explicit `displayName` mapping when present and non-empty; otherwise it joins first + last with a space and trims. If both paths produce an empty string, the row is silently skipped (counted in `skipped`, not `errors`) — the assumption is that the row is intentionally blank and not a bug.
`skipDuplicates` walks every channel in the candidate input and queries `findByChannelValue` against the store — if any channel already exists, the entire row is skipped. This means a contact with two channels skips on the first match; the order is the input order.
The CSV parser is a minimal RFC 4180 implementation that handles quoted fields containing commas. It does not handle multi-line quoted fields (a quoted value spanning a literal newline) — consumers needing that should pre-parse with a richer CSV library and call `importFromRecords` directly.
Per-row error capture wraps every iteration in `try / catch` so one bad row never blocks the rest. Non-`Error` throwables are coerced via `String(err)` so the `ImportError.message` field is always populated.

View File

@@ -0,0 +1,86 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the bundle of OpenTelemetry metric instruments emitted by `@suaveplan/contacts`. The bundle binds lazily against the current global `MeterProvider`, so callers can install a custom provider (e.g. an `InMemoryMetricExporter` in tests) before the first `getContactsInstruments()` call. `resetContactsInstruments()` drops the cache so a freshly-installed provider can take effect mid-process — essential for test isolation.
## Features
- Six counters: `contacts.created_total`, `contacts.updated_total`, `contacts.deleted_total`, `contacts.merged_total`, `groups.created_total`, `memberships.added_total`.
- Three histograms (unit `ms`): `contacts.import_duration_ms`, `contacts.export_duration_ms`, `audience.resolve_duration_ms`.
- Counter attributes: `tenant` on every contact / group operation, plus `secondaries` on the merge counter and `format` on the export histogram.
- Lazy, cached binding to the global `MeterProvider` for zero-cost no-op until first use.
- `resetContactsInstruments()` releases the cache so test setup can rebind after installing a fresh provider.
## Usage: Basic
```ts
import {
ContactManager,
getContactsInstruments,
InMemoryContactStore,
} from "@suaveplan/contacts";
const mgr = new ContactManager({ store: new InMemoryContactStore() });
await mgr.create({
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
});
const instruments = getContactsInstruments();
console.log(instruments.contactsCreatedTotal); // OTel Counter handle
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { resetContactsInstruments } from "@suaveplan/contacts";
const exporter = new InMemoryMetricExporter(0);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetContactsInstruments();
// … run operations …
await provider.forceFlush();
console.log(exporter.getMetrics()); // ResourceMetrics[]
```
## API Reference
### `interface ContactsInstruments`
Read-only object exposing every Counter and Histogram. See Features for the full list. Counters: `contactsCreatedTotal`, `contactsUpdatedTotal`, `contactsDeletedTotal`, `contactsMergedTotal`, `groupsCreatedTotal`, `membershipsAddedTotal`. Histograms: `contactsImportDurationMs`, `contactsExportDurationMs`, `audienceResolveDurationMs`.
### `getContactsInstruments(): ContactsInstruments`
Returns the cached bundle, lazily creating it on first call against `metrics.getMeter("@suaveplan/contacts")`. Subsequent calls return the same reference (`===`).
### `resetContactsInstruments(): void`
Drops the cache. Required in test setup after `metrics.setGlobalMeterProvider(...)`, otherwise the cached instruments stay bound to the previous provider.
## Implementation Notes
Instrument names follow the OTel naming convention `<domain>.<metric>_total` (counters) and `<domain>.<metric>_duration_ms` (histograms). The duration histograms are populated by the production managers using a single `clock()` snapshot before and after the operation; that means duration is wall-clock against the injected `Clock`, which a `FakeClock` can advance deterministically.
The cache pattern (`let cached: ContactsInstruments | undefined`) is intentional: re-binding on every call would create new Counter instances per operation and fragment the export. The same pattern is used elsewhere in the project (`@suaveplan/api-plugin-event-bus`) and is the canonical shape for "lazy-bound, test-resettable" OTel surfaces.
All instruments are pure metrics — no spans are emitted from this package. Span correlation, if needed, is the responsibility of the caller via the active OTel context (e.g. an upstream Hono middleware).

View File

@@ -0,0 +1,93 @@
# schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas for every public input boundary of `@suaveplan/contacts` plus the `parseOrThrow` helper that converts Zod validation failures into a typed `ContactsError(INVALID_INPUT)`. Boundary validation is enforced inside the managers (`ContactManager.create`, `GroupManager.create`, etc.) so consumers never see a raw `ZodError` — they always see a stable, dispatchable code with a human-readable message listing the failing field paths.
## Features
- `createContactInputSchema`, `updateContactInputSchema`, `contactQuerySchema` — contact CRUD inputs.
- `createGroupInputSchema`, `updateGroupInputSchema`, `groupQuerySchema` — group CRUD inputs.
- `addMemberInputSchema`, `audienceResolveOptionsSchema` — membership and audience inputs.
- `exportOptionsSchema`, `importOptionsSchema` — import / export bag schemas.
- `contactChannelSchema`, `contactStatusSchema` — primitive schemas reused across composites.
- `parseOrThrow(schema, input, label)` — typed wrapper that throws `ContactsError(INVALID_INPUT)` on failure, preserving the underlying `ZodError` via `cause`.
## Usage: Basic
```ts
import {
createContactInputSchema,
parseOrThrow,
} from "@suaveplan/contacts";
const input = parseOrThrow(
createContactInputSchema,
{
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
},
"MyService.createContact",
);
// `input` is typed as the schema's inferred type.
```
## Usage: Advanced
```ts
import {
audienceResolveOptionsSchema,
CONTACTS_CODES,
ContactsError,
parseOrThrow,
} from "@suaveplan/contacts";
function resolveSafely(raw: unknown) {
try {
const opts = parseOrThrow(
audienceResolveOptionsSchema,
raw,
"resolveSafely",
);
return resolver.resolve(opts);
} catch (err) {
if (
err instanceof ContactsError &&
err.code === CONTACTS_CODES.INVALID_INPUT
) {
logger.warn({ paths: err.message, cause: err.cause }, "bad audience options");
return null;
}
throw err;
}
}
```
## API Reference
### Schema constants
Each `*Schema` is a `z.ZodType<T>` whose inferred shape matches the corresponding public TypeScript interface. Schemas use `z.record(z.string(), z.unknown())` for open metadata bags, `z.array(...)` for collections, and `z.enum(...)` for closed unions like `ContactStatus`.
### `parseOrThrow<T>(schema, input, label): T`
- `schema: z.ZodType<T>` — the schema to apply.
- `input: unknown` — caller-provided data.
- `label: string` — appears in the error message for traceability (e.g. the calling method name).
- Returns the parsed value typed as `T`.
- Throws `ContactsError(INVALID_INPUT)` whose `cause` is the underlying `ZodError`. The message format is `"{label} input failed validation: {field}: {issue}; …"`.
## Implementation Notes
All managers funnel through `parseOrThrow` before performing any side effect, which means the order of effects is deterministic: validation always runs first. Test suites depend on this property to assert "invalid input does not persist anything."
The schemas use `z.string().min(1)` to reject empty strings explicitly because Zod treats `""` as a valid string by default. This matches what consumers reasonably expect ("displayName is required" should reject empty too).
Field-mapping shapes (`importOptionsSchema.fieldMapping`) use `z.record(z.string(), z.string().optional())` to permit caller-defined keys without locking the surface. The TypeScript interface for `FieldMapping` exposes the named slots as an open index signature so the schema and the type stay in sync without duplicating every column.

View File

@@ -0,0 +1,93 @@
# contact-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `ContactStore` storage interface that `ContactManager` and `AudienceResolver` consume, plus the `InMemoryContactStore` reference implementation used for testing, and any single-process deployment that doesn't need a database. The in-memory variant is fully deterministic when `clock` and `idGenerator` are injected from `@suaveplan/types`, which makes the package safe to use under `FakeClock`-driven test harnesses and replay-style integration tests.
## Features
- `ContactStore` interface — eight methods: `create`, `getById`, `update`, `delete`, `list`, `findByChannelValue`, `findByIds`, `countByTenant`.
- `InMemoryContactStore` — concrete implementation with tenant-scoped reads, channel-search, tag / status / search / channel-type filters, offset+limit pagination, default limit 50.
- Injectable `Clock` for deterministic `createdAt` / `updatedAt`.
- Injectable `IdGenerator` for deterministic ids; default counter `contact_1, contact_2, …`.
- `ContactsError(CONTACT_NOT_FOUND)` on update of a missing id; never throws bare `Error`.
## Usage: Basic
```ts
import {
ContactManager,
InMemoryContactStore,
} from "@suaveplan/contacts";
const store = new InMemoryContactStore();
const mgr = new ContactManager({ store });
const alice = await mgr.create({
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
});
```
## Usage: Advanced
```ts
import {
InMemoryContactStore,
} from "@suaveplan/contacts";
// Test setup — deterministic Clock + IdGenerator
let pinned = 1_700_000_000_000;
const store = new InMemoryContactStore({
clock: () => pinned,
idGenerator: (() => {
let i = 0;
return () => `c_${++i}`;
})(),
});
const a = await store.create({
tenantId: "t1",
displayName: "A",
channels: [],
});
console.log(a.id); // "c_1"
console.log(a.createdAt.getTime()); // 1700000000000
pinned += 10_000;
const u = await store.update(a.id, { status: "archived" });
console.log(u.updatedAt.getTime()); // 1700000010000
```
## API Reference
### `interface ContactStore`
Every method returns a `Promise` so the same interface works for in-memory, SurrealDB, Postgres, or any other future backend.
- `create(input)` — persist a new contact and return it with id + timestamps.
- `getById(id)` — return the contact or `null`.
- `update(id, input)` — patch fields; raises `CONTACT_NOT_FOUND` when id is missing.
- `delete(id)` — idempotent; no error when id is missing.
- `list(query)` — paginated read; returns `{ contacts, total }`.
- `findByChannelValue(tenantId, type, value)` — first contact whose channel matches in that tenant; `null` if none.
- `findByIds(ids)` — preserves input order, drops missing ids.
- `countByTenant(tenantId)` — number of contacts in the tenant.
### `class InMemoryContactStore implements ContactStore`
Constructor takes `{ clock?, idGenerator? }`. Defaults: `clock = () => Date.now()`, `idGenerator` is a per-instance monotonic counter formatted `contact_<n>`. The instance owns a private `Map<id, Contact>`.
## Implementation Notes
Search filter is case-insensitive substring match across `displayName`, `firstName`, `lastName`, and every channel value. Tag filter is "any-of" (contact has at least one of the requested tags). Empty `tags: []` is treated as "no filter" so callers can pass canonical request shapes without special-casing.
Pagination uses array-slice on the filtered set, which is fine for the in-memory tier but means database adapters MUST implement equivalent semantics (LIMIT / OFFSET with the same filter order) to keep `ContactStore` substitutable.
The default `IdGenerator` is intentionally per-instance, not per-class, so two stores in the same process don't collide. For production deployments where deterministic ids matter across restarts, inject your own (e.g. ULID or KSUID generator).

View File

@@ -0,0 +1,88 @@
# group-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `GroupStore` storage interface that `GroupManager` and `AudienceResolver` consume, plus the `InMemoryGroupStore` reference implementation. `GroupStore` exposes `generateId()` on the interface (not the concrete class) so `GroupManager.create` can call it directly without a type-launder cast — fixing the upstream `(store as InMemoryGroupStore).generateId` pattern. The in-memory variant accepts injectable `Clock` and `IdGenerator` for deterministic test runs.
## Features
- `GroupStore` interface — nine methods including `generateId`, `create`, `getById`, `update`, `delete`, `list`, `getChildren`, `getDescendants`, `getRoots`, `findByIds`.
- `getDescendants(path)` uses `path.startsWith(parent.path + '/')` so it excludes the parent itself; matches what consumers expect.
- `InMemoryGroupStore` — concrete implementation with tenant-scoped reads, parent + search filters, offset+limit pagination.
- Injectable `Clock` for deterministic `updatedAt` on every update.
- Injectable `IdGenerator` for deterministic group ids; default counter `group_1, group_2, …`.
- `ContactsError(GROUP_NOT_FOUND)` on update of a missing id; never throws bare `Error`.
## Usage: Basic
```ts
import {
GroupManager,
InMemoryGroupStore,
} from "@suaveplan/contacts";
const store = new InMemoryGroupStore();
const mgr = new GroupManager({ store });
const root = await mgr.create({ tenantId: "acme", name: "All Customers" });
const vip = await mgr.create({
tenantId: "acme",
name: "VIP",
parentId: root.id,
});
console.log(vip.path); // "/all-customers/vip"
```
## Usage: Advanced
```ts
import { InMemoryGroupStore } from "@suaveplan/contacts";
import type { GroupStore } from "@suaveplan/contacts";
// Custom GroupStore for an alternate backend
class MyGroupStore implements GroupStore {
public generateId(): string {
return crypto.randomUUID();
}
// … implement create / getById / update / etc.
public async create(g: Parameters<GroupStore["create"]>[0]) { return g; }
public async getById() { return null; }
public async update(_id: string) { throw new Error("not implemented"); }
public async delete() {}
public async list() { return { groups: [], total: 0 }; }
public async getChildren() { return []; }
public async getDescendants() { return []; }
public async getRoots() { return []; }
public async findByIds() { return []; }
}
```
## API Reference
### `interface GroupStore`
- `generateId()` — sync; returns a new id. Called by `GroupManager.create`.
- `create(group)` — persist a fully-formed `Group` record.
- `getById(id)` — return the group or `null`.
- `update(id, patch)` — partial update; raises `GROUP_NOT_FOUND` when missing.
- `delete(id)` — idempotent.
- `list(query)` — paginated read.
- `getChildren(parentId)` — direct children only.
- `getDescendants(path)` — strict descendants, excluding the path itself.
- `getRoots(tenantId)` — top-level groups (`parentId === null`) for the tenant.
- `findByIds(ids)` — preserves input order, drops missing ids.
### `class InMemoryGroupStore implements GroupStore`
Constructor takes `{ clock?, idGenerator? }` with the same defaults as `InMemoryContactStore`. The instance owns a private `Map<id, Group>`.
## Implementation Notes
`getDescendants` uses the `path` field rather than recursively walking `parentId` chains. This is O(N) over the whole group set but constant-time per match, which is dramatically faster than the recursive walk for any non-trivial hierarchy. A SQL implementation would index `path` and use `LIKE 'parent_path/%'`.
Promoting `generateId()` to the interface (originally a concrete-class-only method on `InMemoryGroupStore`) fixes the type-launder hole in upstream — `GroupManager` no longer needs `(store as InMemoryGroupStore).generateId`, and custom `GroupStore` implementations are obligated to provide an id generator that matches their backend's id space (UUID, ULID, SurrealId, etc.).
Filtering by `parentId` distinguishes three cases: `undefined` means "any parent" (no filter), `null` means "root groups only", a string means "direct children of that id". Callers in the test suite exercise all three to guard against accidental conflation.

View File

@@ -0,0 +1,82 @@
# membership-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `MembershipStore` storage interface that `MembershipManager`, `ContactMerger`, and `AudienceResolver` consume, plus the `InMemoryMembershipStore` reference implementation. The store is the canonical home for many-to-many contact-to-group relations, with a composite-key index on `(groupId, contactId)` so add/exists/remove are constant-time. Like the sibling stores, the in-memory variant accepts an injectable `IdGenerator` so test runs and replay-style integration tests stay deterministic.
## Features
- `MembershipStore` interface — `generateId`, `add`, `remove`, `getByGroup`, `getByContact`, `exists`, `removeAllForContact`, `removeAllForGroup`, `getContactIdsByGroups`, `countByGroup`.
- `add` raises `ContactsError(MEMBERSHIP_EXISTS)` on duplicate `(groupId, contactId)` — `MembershipManager` translates this into its own duplicate-membership handling.
- Injectable `IdGenerator` for deterministic membership ids; default `membership_1, membership_2, …`.
- `getContactIdsByGroups(groupIds)` returns a flat array of contact ids across the requested groups — the canonical bulk fetch used by `AudienceResolver`.
- `removeAllForContact` / `removeAllForGroup` are the bulk-deletion paths used by merge and group-delete cascades.
## Usage: Basic
```ts
import {
InMemoryMembershipStore,
MembershipManager,
InMemoryContactStore,
InMemoryGroupStore,
} from "@suaveplan/contacts";
const ms = new InMemoryMembershipStore();
const mgr = new MembershipManager({
contactStore: new InMemoryContactStore(),
groupStore: new InMemoryGroupStore(),
membershipStore: ms,
});
```
## Usage: Advanced
```ts
import { InMemoryMembershipStore } from "@suaveplan/contacts";
// Deterministic id generator for test snapshots
let n = 0;
const store = new InMemoryMembershipStore({
idGenerator: () => `m_${++n}`,
});
await store.add({
id: store.generateId(),
groupId: "g_1",
contactId: "c_1",
addedAt: new Date(0),
});
const contactIds = await store.getContactIdsByGroups(["g_1", "g_2"]);
```
## API Reference
### `interface MembershipStore`
- `generateId()` — sync; produces a new membership id.
- `add(membership)` — raises `MEMBERSHIP_EXISTS` on duplicate `(groupId, contactId)`.
- `remove(groupId, contactId)` — idempotent.
- `getByGroup(groupId)` / `getByContact(contactId)` — full membership records.
- `exists(groupId, contactId)` — boolean lookup, used by `MembershipManager` and `ContactMerger`.
- `removeAllForContact(contactId)` — bulk delete; called by `ContactMerger` after transfer.
- `removeAllForGroup(groupId)` — bulk delete; called by `GroupManager.delete` cascade in larger deployments.
- `getContactIdsByGroups(groupIds)` — flat array, may contain duplicates if a contact is in multiple groups (resolver dedups).
- `countByGroup(groupId)` — direct count, distinct from `Group.contactCount` which the manager maintains.
### `class InMemoryMembershipStore implements MembershipStore`
Constructor takes `{ idGenerator? }`. The instance owns a private `Map<"groupId:contactId", GroupMembership>` for constant-time composite-key access.
## Implementation Notes
The composite key (`groupId:contactId` joined with a `:`) is a private encoding detail and never leaks through the API. It avoids a nested `Map<groupId, Map<contactId, GroupMembership>>` while keeping every operation O(1).
`getContactIdsByGroups` returns duplicates when a contact is a member of more than one of the requested groups. This is intentional — `AudienceResolver` uses the duplicate count to populate `AudienceResult.duplicatesRemoved`. Database adapters should preserve this contract or compute the equivalent count separately.
Membership ids are produced by `generateId()` rather than computed from `(groupId, contactId)` because some consumers (audit logs, sync engines) want a stable, opaque membership identity that survives a remove+re-add. The composite key is internal; the public identity is `id`.

View File

@@ -0,0 +1,94 @@
# tree-utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure tree utilities for building, flattening, traversing, and inspecting `GroupTreeNode<TMeta>` hierarchies. None of these functions perform IO, call `Date.now()`, or pull randomness — they're deterministic transformations over arrays. Consumers build a tree from a flat group list with `buildGroupTree`, render it with `flattenGroupTree` (which annotates every row with `level`, `hasChildren`, `isLastChild` for tree-prefix rendering), and inspect it with the named helpers.
## Features
- `buildGroupTree(groups)` — assembles a `GroupTreeNode[]` from a flat list, promoting orphans to roots when their parent is absent.
- `flattenGroupTree(roots)` — depth-first flatten with rendering metadata.
- `getAncestorPaths(path)` — returns every proper ancestor of a slash-separated path.
- `validateNoCircularRef(groupId, targetParentId, groups)` — returns `true` when the move is safe, `false` when it would close a cycle.
- `countTreeContacts(node)` — recursive sum of `contactCount` across the subtree.
- `findInTree(roots, groupId)` — DFS lookup returning the node or `null`.
- `collectAllGroupIds(roots)` — DFS pre-order enumeration of every group id.
## Usage: Basic
```ts
import {
buildGroupTree,
flattenGroupTree,
} from "@suaveplan/contacts";
const flat = (await groups.list({ tenantId: "acme" })).groups;
const tree = buildGroupTree(flat);
const rows = flattenGroupTree(tree);
for (const row of rows) {
console.log(`${" ".repeat(row.level * 2)}${row.group.name}`);
}
```
## Usage: Advanced
```ts
import {
collectAllGroupIds,
countTreeContacts,
findInTree,
getAncestorPaths,
validateNoCircularRef,
} from "@suaveplan/contacts";
// Render a breadcrumb from a group's path
const crumbs = getAncestorPaths("/all-customers/vip/asia");
// → ["/all-customers", "/all-customers/vip"]
// Guard against creating a cycle before issuing a move
if (!validateNoCircularRef(groupId, targetParentId, allGroups)) {
throw new Error("would create a cycle");
}
// Aggregate counts across a subtree
const tree = buildGroupTree(allGroups);
const node = findInTree(tree, "g_vip");
if (node) console.log(`VIP subtree: ${countTreeContacts(node)} contacts`);
// Bulk-export every group id reachable from a subtree
const ids = collectAllGroupIds(tree);
```
## API Reference
### `buildGroupTree<TMeta>(groups): GroupTreeNode<TMeta>[]`
Builds an in-memory map by id, then iterates a second time to wire children to parents. Groups whose `parentId` is missing from the input are promoted to roots — this matches the typical "I've fetched a subtree by tenant" use case.
### `flattenGroupTree<TMeta>(roots, level = 0): FlattenedGroupNode<TMeta>[]`
DFS flatten. The root array's index determines `isLastChild` for top-level entries; recursion uses the child array's index for nested entries.
### `getAncestorPaths(path): string[]`
Splits on `/`, drops empties, and returns every proper prefix as a `/`-prefixed string. Returns `[]` for `/`, `/a`, or any single-segment path.
### `validateNoCircularRef(groupId, targetParentId, groups): boolean`
Returns `true` when moving `groupId` under `targetParentId` would NOT create a cycle. Returns `false` when source and target are the same, when the source is missing from the input set, or when traversing target's ancestor chain reaches the source. Note the inverted semantics: `true` means "safe", `false` means "blocked".
### `countTreeContacts(node)` / `findInTree(roots, id)` / `collectAllGroupIds(roots)`
Pure recursive helpers. `countTreeContacts` sums `node.group.contactCount` plus every descendant's contribution. `findInTree` returns the first matching node or `null`. `collectAllGroupIds` returns a DFS pre-order id list (root then children left-to-right).
## Implementation Notes
`buildGroupTree` uses a `MutableNode<TMeta>` interface internally so children can be appended without violating the publicly-exposed `readonly` arrays on `GroupTreeNode`. The function returns the structurally-compatible `GroupTreeNode[]` shape and consumers see the immutable surface.
`validateNoCircularRef` uses ancestor walking (via `parentId` chain) for the target side because callers typically have the full group set in memory by this point — the alternative (descendant scanning via `path`) requires the source group's path, which means an extra lookup that the chain-walk avoids.
All functions are exported as named functions (not class methods) so consumers can tree-shake unused utilities away. Importing only `getAncestorPaths` from `@suaveplan/contacts/tree/tree-utils` pulls only that function's code into the bundle.

View File

@@ -0,0 +1,85 @@
# contact.types
## Purpose
Defines the runtime-free TypeScript shapes for contacts in `@suaveplan/contacts`: the `Contact<TMeta>` entity, its `ContactChannel` and `ContactStatus` discriminators, and the `CreateContactInput` / `UpdateContactInput` / `ContactQuery` / `ContactListResult` / `MergeResult` companions consumed by every manager and store in the package. Every field is `readonly` so accidental in-place mutation is caught at compile time. The optional `TMeta` generic lets consumers pin their domain's metadata shape without losing inference on the entity.
## Features
- `Contact<TMeta>` — immutable entity with id, tenant, channels, tags, status, metadata, and ISO timestamps.
- `ContactChannel` — `{ type, value, primary, verified, metadata? }` discriminator for email, sms, phone, slack, etc.
- `ContactStatus` — closed union `"active" | "unsubscribed" | "bounced" | "complained" | "archived"`.
- `CreateContactInput<TMeta>` — input shape for `ContactManager.create` (optional `tags`, `status`, `metadata`).
- `UpdateContactInput<TMeta>` — all-fields-optional patch shape; metadata merges shallowly.
- `ContactQuery` — pagination + filter shape (`search`, `tags`, `status`, `channelType`, `limit`, `offset`).
- `ContactListResult<TMeta>` — `{ contacts, total }` envelope returned by list operations.
- `MergeResult<TMeta>` — `{ mergedContact, removedContactIds }` envelope returned by `ContactMerger.merge`.
## Usage: Basic
```ts
import type { Contact, CreateContactInput } from "@suaveplan/contacts";
const input: CreateContactInput = {
tenantId: "acme",
displayName: "Alice",
channels: [
{ type: "email", value: "alice@x.io", primary: true, verified: false },
],
};
const persisted: Contact = await contacts.create(input);
```
## Usage: Advanced
```ts
import type { Contact, ContactQuery } from "@suaveplan/contacts";
interface MarketingMeta {
readonly source: string;
readonly utm: Record<string, string>;
}
const query: ContactQuery = {
tenantId: "acme",
tags: ["vip"],
channelType: "email",
limit: 100,
offset: 0,
};
const page = await contacts.list(query);
const typed: readonly Contact<MarketingMeta>[] = page.contacts as readonly Contact<
MarketingMeta
>[];
const sources = typed.map((c) => c.metadata.source);
```
## API Reference
### `Contact<TMeta = Record<string, unknown>>`
Immutable record returned by every read path. All fields are `readonly`. `createdAt` and `updatedAt` are JavaScript `Date` objects derived from the injected `Clock`.
### `ContactChannel`
A communication endpoint. `primary` is callers' choice; the package promotes the first channel to primary if none are flagged. `metadata` is an open bag for caller-specific provenance (verification timestamps, source webhook id, etc.).
### `ContactStatus`
Closed union. Adding a status is a breaking change; consumers exhaustively switch on it.
### `CreateContactInput<TMeta>` / `UpdateContactInput<TMeta>` / `ContactQuery`
Input shapes; each has a matching Zod schema in `src/schemas/schemas.ts` and is validated at the manager boundary before any side effect.
### `ContactListResult<TMeta>` / `MergeResult<TMeta>`
Result envelopes. `ContactListResult.total` is the unpaginated count; `contacts` is the paginated slice. `MergeResult.removedContactIds` is the list of secondaries that were merged into the primary and deleted from the store.
## Implementation Notes
The `TMeta` generic flows through `CreateContactInput`, `UpdateContactInput`, `ContactListResult`, `MergeResult`, and `Contact` so a single `Contact<MyMeta>` declaration narrows every read. Defaults to `Record<string, unknown>` to keep the surface convenient when consumers don't care.
Timestamps are wall-clock-equivalent (`new Date(clock())`) — the injected `Clock` returns milliseconds. Tests use a `FakeClock` from `@suaveplan/testing` to pin reproducible timestamps.
`readonly` is enforced through every array and field. Callers wanting to mutate locally should spread (`[...contact.channels, newChannel]`) and then route through `ContactManager.update`.

View File

@@ -0,0 +1,74 @@
# group.types
## Purpose
Defines the runtime-free TypeScript shapes for groups in `@suaveplan/contacts`: the `Group<TMeta>` entity (with its hybrid adjacency-list + materialized-path fields), the `CreateGroupInput` / `UpdateGroupInput` / `GroupQuery` companions, the `GroupListResult` envelope returned by list operations, and the `GroupTreeNode<TMeta>` shape consumed by the tree utilities. Every field is `readonly` so consumers see compile-time errors on accidental in-place mutation.
## Features
- `Group<TMeta>` — immutable entity with id, tenant, name, optional description, `parentId` (adjacency), `path` (materialized), `depth`, metadata, direct + recursive contact counts, and timestamps.
- `CreateGroupInput<TMeta>` — input shape for `GroupManager.create` (`parentId` optional / nullable for roots).
- `UpdateGroupInput<TMeta>` — all-fields-optional patch shape; `name` change triggers slug + path propagation.
- `GroupQuery` — pagination + filter shape (`parentId`, `search`, `limit`, `offset`).
- `GroupListResult<TMeta>` — `{ groups, total }` envelope returned by list operations.
- `GroupTreeNode<TMeta>` — `{ group, children }` recursive shape consumed by the tree utilities (`buildGroupTree`, `flattenGroupTree`, …).
## Usage: Basic
```ts
import type { CreateGroupInput, Group } from "@suaveplan/contacts";
const input: CreateGroupInput = {
tenantId: "acme",
name: "VIP Customers",
};
const persisted: Group = await groups.create(input);
console.log(persisted.path); // "/vip-customers"
```
## Usage: Advanced
```ts
import { buildGroupTree } from "@suaveplan/contacts";
import type { Group, GroupTreeNode } from "@suaveplan/contacts";
interface OrgMeta {
readonly costCenter: string;
}
const flat: readonly Group<OrgMeta>[] = (await groups.list({ tenantId: "acme" })).groups as readonly Group<OrgMeta>[];
const tree: GroupTreeNode<OrgMeta>[] = buildGroupTree(flat);
function totals(node: GroupTreeNode<OrgMeta>): number {
return node.group.totalContactCount;
}
```
## API Reference
### `Group<TMeta = Record<string, unknown>>`
Immutable record. `path` is a slash-prefixed slug chain (e.g. `/all-customers/vip`) maintained by `GroupManager`; consumers MUST NOT construct or rewrite it directly. `depth` is the number of `/`-separated segments minus one (root = 0). `contactCount` is the direct count; `totalContactCount` is the recursive count maintained by `MembershipManager` via ancestor walks.
### `CreateGroupInput<TMeta>`
`parentId` may be omitted, `null`, or an existing group id. Validation lives in `createGroupInputSchema`.
### `UpdateGroupInput<TMeta>`
Patch shape. `metadata` is shallow-merged with the existing record; supply explicit `null` semantics in your meta type if you need three-state fields.
### `GroupQuery`
`parentId === undefined` means "any parent"; `parentId === null` filters to root groups; a string id filters to that parent's direct children.
### `GroupListResult<TMeta>` / `GroupTreeNode<TMeta>`
`GroupListResult.total` is the unpaginated count; `groups` is the paginated slice. `GroupTreeNode` is recursive and `readonly`; mutate only through the manager.
## Implementation Notes
The hybrid adjacency-list + materialized-path strategy was chosen because it lets the store answer "all descendants of group X" with a single `path.startsWith(parent.path + '/')` scan rather than a recursive query — important for the in-memory tier and pleasant for SQL stores too.
`path` is regenerated on every name change; `GroupManager.update` cascades the rewrite through every descendant via `store.getDescendants`. Tests pin the propagation behaviour to guard against regression.
`contactCount` and `totalContactCount` are stored on the entity (not computed on read) so list views can render hierarchical totals without an extra round-trip per group. They're maintained by `MembershipManager.addContactToGroup` / `removeContactFromGroup` walking the ancestor chain.

View File

@@ -0,0 +1,80 @@
# membership.types
## Purpose
Defines the runtime-free TypeScript shapes for many-to-many contact-group memberships and the audience-resolution surface in `@suaveplan/contacts`: the `GroupMembership` join record, the `AddMemberInput` shape consumed by managers, the `AudienceResolveOptions` bag passed to `AudienceResolver.resolve`, and the `AudienceResult<TMeta>` envelope returned from resolution. Every field is `readonly` so consumers can't accidentally mutate a returned record in place.
## Features
- `GroupMembership` — `{ id, groupId, contactId, addedAt, metadata? }` join record returned by every membership-store read.
- `AddMemberInput` — minimal `{ groupId, contactId, metadata? }` input bag accepted by managers.
- `AudienceResolveOptions` — group ids to include, optional exclude lists (groups + contacts), optional channel and status filters, dedup flag.
- `AudienceResult<TMeta>` — `{ contacts, totalCount, groupsResolved, duplicatesRemoved }` envelope reporting both the materialized contact slice and the bookkeeping numbers a UI typically wants.
- All shapes flow through the corresponding Zod schemas in `src/schemas/schemas.ts` before being passed to any side effect.
## Usage: Basic
```ts
import type { AddMemberInput, GroupMembership } from "@suaveplan/contacts";
const input: AddMemberInput = {
groupId: "g_1",
contactId: "c_1",
};
const membership: GroupMembership = await memberships.addContactToGroup(
input.groupId,
input.contactId,
input.metadata,
);
```
## Usage: Advanced
```ts
import type { AudienceResolveOptions, AudienceResult } from "@suaveplan/contacts";
interface CampaignMeta {
readonly campaignId: string;
}
const opts: AudienceResolveOptions = {
groupIds: ["g_vip", "g_recent"],
excludeGroupIds: ["g_unsubscribed"],
channelType: "email",
statusFilter: ["active"],
deduplicate: true,
};
const result: AudienceResult<CampaignMeta> = (await resolver.resolve(
opts,
)) as AudienceResult<CampaignMeta>;
console.log(
`${result.totalCount} contacts across ${result.groupsResolved} groups ` +
`(${result.duplicatesRemoved} duplicates removed)`,
);
```
## API Reference
### `GroupMembership`
Returned by every membership read (`getByGroup`, `getByContact`, `exists` indirectly, `add`). `id` is supplied by the store's `generateId()` so consumers can join across additional caller-defined tables.
### `AddMemberInput`
Convenience shape — managers also accept positional `(groupId, contactId, metadata?)` arguments. Use the input when you have caller-side validation already in a record form.
### `AudienceResolveOptions`
`groupIds` is the include set; every descendant of each entry is added via `GroupStore.getDescendants`. `excludeGroupIds` is symmetric — each entry's descendants are removed. `excludeContactIds` filters the final contact set. `channelType` keeps only contacts that own a channel of that type. `statusFilter` keeps only contacts whose status is in the list. `deduplicate` defaults to true conceptually — the resolver always dedups by contact id today; the flag is reserved for forward-compatible behaviour.
### `AudienceResult<TMeta>`
`contacts` is the post-filter contact slice (typed as `readonly Contact<TMeta>[]`). `totalCount` matches `contacts.length`. `groupsResolved` is the cardinality of the expanded-and-excluded group set. `duplicatesRemoved` is the count of contact-id encounters skipped because the id was already in the include set.
## Implementation Notes
`metadata` on `GroupMembership` is an open `Record<string, unknown>` rather than generic — membership metadata is intentionally schemaless because it's typically caller-supplied join-time data (e.g. `{ source: "import", batch: 42 }`).
The `AudienceResolveOptions` shape mirrors what an email-campaign UI typically composes: include groups, exclude groups, exclude individuals, optional channel filter for "email-only" sends, optional status filter to skip unsubscribed. Adding new fields is additive and non-breaking as long as defaults are sensible.
The result envelope reports `groupsResolved` and `duplicatesRemoved` so a UI can show "we found 1,234 people across 7 groups, 12 of whom were in more than one group." That kind of feedback prevents users second-guessing whether the audience selection is correct.

View File

@@ -0,0 +1,61 @@
# @suaveplan/macos-contacts - Universal macOS Contacts.app bridge via AppleScript: injectable AppleScriptRunner seam (run tests on any platform without macOS), free-function ContactParser surface, injectable Clock for sync timestamps, six OTel counters + two histograms with outcome-partitioned attributes, typed MacOSContactsError hierarchy with stable MACOS_CONTACTS.* code registry, and Zod input validation at every boundary.
Universal bridge to macOS Contacts.app via AppleScript. Reads contacts and groups through the platform `osascript` binary, parses the output into typed records, and exposes the result behind an injectable runner seam so tests run on any platform without macOS.
<!-- begin:auto-docs -->
## Documentation tree
- [applescript](./src/applescript/applescript.md) — AppleScript template builders for querying macOS Contacts.app. Each function returns a complete AppleScript string suit…
- [applescript](./src/applescript/applescript.md) — AppleScript template builders for querying macOS Contacts.app. Each function returns a complete AppleScript string suit…
- [bridge](./src/bridge/bridge.md) — `MacOSContactsBridge` is the high-level entry point for `@suaveplan/macos-contacts`. It composes the four building bloc…
- [bridge](./src/bridge/bridge.md) — `MacOSContactsBridge` is the high-level entry point for `@suaveplan/macos-contacts`. It composes the four building bloc…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `MacOSContactsError` class for `@suaveplan/macos-contacts`. Every fail…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and typed `MacOSContactsError` class for `@suaveplan/macos-contacts`. Every fail…
- [executor](./src/executor/executor.md) — Defines the `AppleScriptRunner` injectable seam that `MacOSContactsBridge` depends on, plus a default implementation ba…
- [executor](./src/executor/executor.md) — Defines the `AppleScriptRunner` injectable seam that `MacOSContactsBridge` depends on, plus a default implementation ba…
- [otel](./src/otel/instruments.md) — Bundle of OpenTelemetry instruments emitted by `@suaveplan/macos-contacts`. The accessor `getMacOSContactsInstruments()…
- [instruments](./src/otel/instruments.md) — Bundle of OpenTelemetry instruments emitted by `@suaveplan/macos-contacts`. The accessor `getMacOSContactsInstruments()…
- [parser](./src/parser/parser.md) — Pure free-function parser for the raw text stream emitted by the AppleScript templates that drive `MacOSContactsBridge`…
- [parser](./src/parser/parser.md) — Pure free-function parser for the raw text stream emitted by the AppleScript templates that drive `MacOSContactsBridge`…
- [schemas](./src/schemas/schemas.md) — Zod schemas guarding every public input boundary of `@suaveplan/macos-contacts`. Validation runs before any side effect…
- [schemas](./src/schemas/schemas.md) — Zod schemas guarding every public input boundary of `@suaveplan/macos-contacts`. Validation runs before any side effect…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/macos-contacts
```
## Quick start
```ts
import { MacOSContactsBridge } from "@suaveplan/macos-contacts";
const bridge = new MacOSContactsBridge({ timeoutMs: 30_000 });
if (await bridge.isAvailable()) {
const contacts = await bridge.getAllContacts();
console.log(`${contacts.length} contacts`);
const family = await bridge.getGroupMembers("Family");
}
```
## Features
- Injectable `AppleScriptRunner` seam — tests run on any platform without macOS by passing a fake runner that returns fixture strings.
- Free-function parser surface (`parseContacts`, `parseGroups`, `normalizePhone`) — no static-only class, tree-shakeable at function granularity.
- Typed `MacOSContactsError` hierarchy with the `MACOS_CONTACTS.*` code registry (`INVALID_INPUT`, `CONTACTS_NOT_FOUND`, `CONTACTS_PERMISSION_DENIED`, `CONTACTS_ACCESS_FAILED`, `CONTACTS_PARSE_FAILED`, `CONTACTS_UNAVAILABLE`).
- Six OTel counters (partitioned by `outcome`) and two histograms wired through `@suaveplan/opentelemetry`.
- Zod input validation at every public boundary; injectable `Clock` for sync timestamps; no `Date.now()` / `new Date()` in the production hot path.
- `sync()` and `isAvailable()` never throw — failures surface through structured result fields.
## Modules
- [`error-codes`](./src/error-codes/error-codes.md) — stable code registry + typed `MacOSContactsError`.
- [`schemas`](./src/schemas/schemas.md) — Zod schemas + `parseOrThrow` boundary helper.
- [`otel/instruments`](./src/otel/instruments.md) — OpenTelemetry counter + histogram bundle.
- [`parser`](./src/parser/parser.md) — free-function parser for raw AppleScript output.
- [`applescript`](./src/applescript/applescript.md) — AppleScript template builders.
- [`executor`](./src/executor/executor.md) — `AppleScriptRunner` interface + default platform implementation.
- [`bridge`](./src/bridge/bridge.md) — `MacOSContactsBridge` orchestrator.

View File

@@ -0,0 +1,74 @@
# applescript
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
AppleScript template builders for querying macOS Contacts.app. Each function returns a complete AppleScript string suitable for handing to the platform `osascript` binary via the `AppleScriptRunner` seam. Templates are pure — no side effects, no IO — so consumers can preview or modify the script before invoking it (e.g. for logging, custom escaping, or wrapping in additional `tell` blocks).
## Features
- `getAllContactsScript()` — every person, full detail (id, names, organisation, birthday, notes, image flag, phones, emails, addresses).
- `searchContactsScript(query)` — every person whose name contains the supplied query, full detail. Escapes embedded `"` characters before splicing the query into the script.
- `getGroupsScript()` — every group with member counts.
- `getGroupMembersScript(groupName)` — every person in a named group, full detail. Escapes embedded `"` characters before splicing the name into the script.
## Usage: Basic
```ts
import { getAllContactsScript } from "@suaveplan/macos-contacts";
const script = getAllContactsScript();
// Use with any AppleScriptRunner, including the bundled default:
// const runner = createDefaultAppleScriptRunner({ timeoutMs: 30_000 });
// const raw = await runner.run(script);
```
## Usage: Advanced
```ts
import {
createDefaultAppleScriptRunner,
parseContacts,
searchContactsScript,
} from "@suaveplan/macos-contacts";
async function findByName(query: string) {
const runner = createDefaultAppleScriptRunner({ timeoutMs: 10_000 });
const script = searchContactsScript(query);
const raw = await runner.run(script);
return parseContacts(raw);
}
// Embedded quotes are escaped automatically:
const safe = searchContactsScript('John "Jack" Doe');
// safe contains name contains "John \"Jack\" Doe" in valid AppleScript form.
```
## API Reference
### `getAllContactsScript(): string`
Returns a complete AppleScript that emits one `---CONTACT---`-delimited block per person. Output format matches the contract parsed by `parseContacts`.
### `searchContactsScript(query: string): string`
Same output format as `getAllContactsScript`, restricted to people whose `name contains` the supplied query. The query is splice-escaped so embedded `"` characters do not break the script.
### `getGroupsScript(): string`
Returns a complete AppleScript that emits one line per group in `id|||name|||memberCount` form, suitable for `parseGroups`.
### `getGroupMembersScript(groupName: string): string`
Returns a complete AppleScript that resolves `first group whose name is "<escaped groupName>"` and emits the contact loop over its members. Output format matches `parseContacts`.
## Implementation Notes
Templates are built from shared sub-strings (`CONTACT_PROPERTIES`, `CONTACT_DETAILS`, `contactLoop`) so the four public builders stay short and reviewable. The output schema is deliberately string-stable: any change to the field order or delimiters in this module must be matched by a change in `parser.ts`, and both must be covered by the bridge's end-to-end fixture tests.
Quote-escaping is the bare minimum needed to keep the splice safe — replace `"` with `\"`. AppleScript does not interpret backslashes inside string literals the way C does, but `osascript -e` accepts the doubled form, and macOS' AppleScript editor treats it identically. If a future code path needs richer escaping (e.g. for embedded `\\` or `\n`), extend the helpers here rather than open-coding in the bridge.
The bridge depends on these builders directly; consumers that want the raw scripts (for logging, dry-run preview, or custom transports) can import them from the package root or the `./applescript/applescript` subpath.

View File

@@ -0,0 +1,115 @@
# bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MacOSContactsBridge` is the high-level entry point for `@suaveplan/macos-contacts`. It composes the four building blocks — `AppleScriptRunner` (IO), the AppleScript template builders (script generation), the parser free functions (output → typed records), and the typed error registry — into a single object with `getAllContacts` / `searchContacts` / `getContactById` / `getGroups` / `getGroupMembers` / `sync` / `isAvailable`. Every public method validates its input via Zod, wraps the runner with typed `MacOSContactsError` mapping, and emits the corresponding OTel counter (with `outcome` attribute) and the shared `applescript_duration_ms` histogram.
## Features
- Injectable `AppleScriptRunner` — tests run on any platform without macOS; production wires `createDefaultAppleScriptRunner`.
- Injectable `Clock` (from `@suaveplan/types`) — `SyncResult.syncedAt` and the duration histograms use the injected clock; no `Date.now()` or `new Date()` in the hot path.
- Zod-validated config — `timeoutMs` must be a positive integer; invalid config raises `MacOSContactsError(INVALID_INPUT)` at construction time.
- Typed error mapping — runner rejections matching `/not authorized|permission denied|access denied|user denied/i` map to `CONTACTS_PERMISSION_DENIED`; everything else maps to `CONTACTS_ACCESS_FAILED`. Discriminants (`query`, `groupName`, `contactId`) flow onto the raised error so consumers can dispatch without parsing the message.
- OTel emission on every public op — six counters partitioned by `outcome` (`success` | `permission_denied` | `access_failed`) and two histograms.
- `sync()` never throws — failures land in `SyncResult.errors`.
- `isAvailable()` never throws — runner exceptions are swallowed and reported as `false`.
## Usage: Basic
```ts
import { MacOSContactsBridge } from "@suaveplan/macos-contacts";
const bridge = new MacOSContactsBridge({ timeoutMs: 30_000 });
if (await bridge.isAvailable()) {
const contacts = await bridge.getAllContacts();
console.log(`${contacts.length} contacts`);
}
```
## Usage: Advanced
```ts
import {
MacOSContactsBridge,
MacOSContactsError,
type AppleScriptRunner,
} from "@suaveplan/macos-contacts";
// Inject a fake runner for tests / non-macOS environments.
const fakeRunner: AppleScriptRunner = {
async run() {
return "id|||A|||B||| ||| ||| |||false\nPHONES:\nEMAILS:\nADDRESSES:";
},
};
const bridge = new MacOSContactsBridge(
{ timeoutMs: 5_000 },
{ runner: fakeRunner, clock: () => 1_700_000_000_000 },
);
try {
const result = await bridge.sync();
if (result.errors.length > 0) {
for (const err of result.errors) {
if (err.code === "MACOS_CONTACTS.CONTACTS_PERMISSION_DENIED") {
showPermissionPrompt();
}
}
}
} catch (err) {
if (err instanceof MacOSContactsError) {
// sync itself never throws, but other methods may.
}
}
```
## API Reference
### `new MacOSContactsBridge(config?, deps?)`
- `config?.timeoutMs?: number` — per-invocation timeout in ms. Defaults to 30 000. Must be a positive integer when supplied.
- `config?.batchSize?: number` — reserved for future batched paths. Defaults to 500.
- `deps?.runner?: AppleScriptRunner` — injectable runner seam. Defaults to `createDefaultAppleScriptRunner({ timeoutMs })`.
- `deps?.clock?: Clock` — injectable time source. Defaults to `() => Date.now()`.
### `bridge.isAvailable(): Promise<boolean>`
Probes the runner with a harmless `System Events` script. Returns `true` when stdout is non-empty, `false` otherwise. Never throws.
### `bridge.getAllContacts(): Promise<MacOSContact[]>`
Invokes `getAllContactsScript()`, parses stdout via `parseContacts`. Runner failures raise `MacOSContactsError`.
### `bridge.searchContacts(query): Promise<MacOSContact[]>`
Empty / whitespace `query` short-circuits to `[]`. Otherwise invokes `searchContactsScript(query)`. Failures attach the `query` discriminant.
### `bridge.getContactById(id): Promise<MacOSContact | null>`
Empty / whitespace `id` short-circuits to `null`. Otherwise calls `getAllContacts()` and returns the first match or `null`. Failures attach the `contactId` discriminant.
### `bridge.getGroups(): Promise<ContactGroup[]>`
Invokes `getGroupsScript()`, parses stdout via `parseGroups`.
### `bridge.getGroupMembers(groupName): Promise<MacOSContact[]>`
Empty / whitespace `groupName` short-circuits to `[]`. Failures attach the `groupName` discriminant.
### `bridge.sync(): Promise<SyncResult>`
Calls `getAllContacts()` and captures any failure in `SyncResult.errors`. `syncedAt` is built from the injected clock; the call also records to the `macos_contacts.sync_duration_ms` histogram.
## Implementation Notes
The bridge intentionally keeps the runner ignorant of `MacOSContactsError`. The runner contract is "run this AppleScript, resolve with stdout, reject with `Error` on any failure"; the bridge layers the typed error mapping on top so the test surface is small (one fake runner per test, no error-mapping logic to mock).
Error-mapping uses a single shared regex (`PERMISSION_PATTERN`) so the policy lives in one place. `classify(cause)` returns the `outcome` attribute for OTel; `toBridgeError(cause, discriminants)` constructs the typed error. Both call sites stay synchronous so the catch arms don't accidentally swallow rejections from instrumentation.
`isAvailable()` and `sync()` are the only methods that catch and swallow exceptions. Every other method propagates failures through the typed error so callers can dispatch on `error.code` without inspecting strings.
The histogram contract is "duration of the script invocation, in milliseconds". The bridge wraps the runner call with `runWithHistogram` so the value is recorded even if the runner rejects — observability survives even when the underlying call fails. `Math.max(0, …)` guards against clock skew producing negative samples.

Some files were not shown because too many files have changed in this diff Show More