Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
# Add @suaveplan/delivery-tracking
**Package**: `@suaveplan/delivery-tracking`
**Status**: PROPOSED
**Type**: new-package
**Design**: ./design.md
**Spec delta**: ./specs/delivery-tracking/spec.md
## Why
Suaveplan ships a multi-channel campaign + transactional message pipeline. Without a channel-agnostic delivery event tracking surface, every channel (`emailer`, `notification-channel`, future SMS, push, in-app) has to invent its own way to capture sends/opens/clicks/bounces and roll them up for the dashboard. The upstream `@suaveplan/delivery-tracking` package solves that with a typed tracker, pluggable store, webhook parsers for the major email providers, and pixel/link generators. Genesis needs the same surface to unblock downstream consumers and to feed the OpenTelemetry-based analytics already wired up in the API host.
## What changes
This is a brand-new package on genesis. Public surface:
- ADDED: `DeliveryTracker` — Zod-validated write surface with lifecycle dedupe, typed `EventBus` emission, injectable `Clock`/`IdGenerator`, OTel counters.
- ADDED: `DeliveryAggregator` — campaign/contact rollups + time-series bucketing.
- ADDED: `DeliveryEventStore` interface + `InMemoryDeliveryEventStore` + `SurrealDeliveryEventStore` (positional SurQL).
- ADDED: `TrackingPixelGenerator` + `TrackingLinkWrapper` + shared `encodeToken`/`decodeToken` codec.
- ADDED: `WebhookEventParser` with `sendgrid`/`mailgun`/`ses`/`generic` built-ins and runtime `registerProvider`.
- ADDED: `DELIVERY_TRACKING_CODES` + `DeliveryTrackingError` (5 codes: `INVALID_INPUT`, `INVALID_TOKEN`, `UNSUPPORTED_PROVIDER`, `STORE_FAILURE`, `QUERY_FAILURE`).
- ADDED: `getDeliveryTrackingInstruments`/`resetDeliveryTrackingInstruments` OTel counter set.
- ADDED: Domain types — `DeliveryEvent`, `DeliveryEventType`, `DeliveryStats`, `CreateDeliveryEventInput`, `DeliveryEventQuery`, `TrackingConfig`, `TrackingTokenPayload`, `ClickTrackingTokenPayload`, `TimeSeriesBucket`, `DeliveryTrackerEventMap`.
## Out of scope
- HTTP route handlers for pixel/click redirect and webhook ingest. The package exposes the parser + tracker; mounting them into a Hono / api-plugin is the consumer's job.
- HMAC-signed tokens. The shared codec is XOR + base64url for URL compactness; consumers needing tamper resistance layer their own HMAC.
- Multi-tenant access control over reads. The aggregator returns whatever the store returns; authorization belongs to the API layer above.
## Source mapping
This change derives from `../suaveplan/enterprise/delivery-tracking.md`.
| Requirement | Source lines | Notes |
|---|---|---|
| Channel-agnostic delivery event tracking | L11-L13 | verbatim (purpose statement) |
| Zero browser / Node / React deps; Bun + Node compatible | L37 | verbatim — package is server-tier (uses `Buffer`) but no React/DOM dependency |
| Zod schemas for public types | L38 | enforced via `createDeliveryEventInputSchema` |
| Deterministic behaviour — injectable Clock + RNG | L40 | `Clock`, `IdGenerator` from `@suaveplan/types` are injectable |
| OTel instrumentation via shared adapter | L41 | `getDeliveryTrackingInstruments()` lazy-initialised counter set |
| Structured errors extending `@suaveplan/error` | L42 | `DeliveryTrackingError` + `defineCodes` |
| 100% test coverage (Istanbul) | L50 | enforced in `vitest.config.ts` thresholds |
| 100% docs coverage; sibling `.md` per impl file | L51 | every `.ts` outside the §4-exempt set has a sibling `.md` ≥200 words |
| File-size budget 250 soft / 300 hard / 500 test | L52 | every file inside budget |
| No mocks — real services / real OTel exporter | L53 | `InMemoryMetricExporter` from `@opentelemetry/sdk-metrics` |
| `sideEffects: false` + workspace internal deps | L55-L56 | verified in `package.json` |
| Public API stability via Zod schemas | L60 | `createDeliveryEventInputSchema` is the boundary contract |
| Structured logging support via `@suaveplan/logger` | L62 | consumed via `LoggerLike` from `@suaveplan/types` (caller-injected) |
| Plugin / extension interface | L69 | `WebhookEventParser` accepts custom `ProviderParser` registrations |
| Observability surface — counters + histograms | L70 | five named counters cover ingest, dedupe, webhook, token issuance, token rejection |
| Configurability — Zod-validated options w/ defaults | L71 | `DeliveryTrackerOptions`, `TrackingConfig` documented per-field |
| Resilience — typed errors on every IO path | L73 | every adapter exception wrapped in `STORE_FAILURE`/`QUERY_FAILURE` |
| Security — input validation at the boundary | L74 | every tracker write parses through Zod first |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Genesis runner-neutral test imports (`@suaveplan/testing/runner`) | Genesis-wide policy (CLAUDE.md rule 11), not package-specific. |
| Positional SurQL for SurrealDB store | Genesis convention established by `api-plugin-events` to keep query bodies auditable; upstream already followed this pattern. |
| Genesis `0.1.0` start version | Genesis lifecycle rule (CLAUDE.md). |
## Verification approach
Every requirement in the spec delta names concrete `it(...)` titles via `**Verified by:**` lines. The packaged test surface:
- `src/errors/delivery-tracking-error.test.ts` — registry + class behaviour.
- `src/otel/instruments.test.ts` — lazy construction + reset.
- `src/stores/memory/memory-store.test.ts` — interface contract, `computeStats`, filter/paging behaviour.
- `src/stores/surreal/surreal-store.test.ts` — positional SurQL bodies + error wrapping. Stubs the `SurrealAdapter.query` boundary because the surface is intentionally a thin SurQL emitter; a full testcontainer integration test belongs to `testing-integration-surrealdb` consumers per CLAUDE.md.
- `src/core/tracker/tracker.test.ts` — Zod boundary, lifecycle dedupe, bus emission, batch ingest, real `InMemoryMetricExporter` counter assertions.
- `src/core/aggregator/aggregator.test.ts` — rollups + time-series bucketing.
- `src/core/tracking-pixel/token-codec.test.ts` and `src/core/tracking-pixel/tracking-pixel.test.ts` — token round-trip + URL/HTML formatting.
- `src/core/tracking-link/tracking-link.test.ts` — single + bulk wrap + parse.
- `src/core/webhook-parser/webhook-parser.test.ts` — every built-in provider + extensibility + unsupported error.
## Impact
### Affected dependents
No dependents on genesis yet — this is a fresh landing. Run `bunx turbo ls --filter=...@suaveplan/delivery-tracking` to enumerate once the package is published. The `notification-channel` and `emailer` migrations being executed concurrently will consume this surface; their proposals own the wire-in step.
## Migration impact
None — additive only.

View File

@@ -0,0 +1,183 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: delivery-tracking (add-delivery-tracking)
**Package**: `@suaveplan/delivery-tracking`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Channel-agnostic event ingest with lifecycle dedupe
The package SHALL accept `CreateDeliveryEventInput` records, persist them via a pluggable `DeliveryEventStore`, and suppress duplicate `opened`/`delivered`/`bounced`/`unsubscribed`/`complained` events per `messageId`. Other event types MUST be allowed to repeat.
#### Scenario: First lifecycle event is recorded; the second is dropped
- **WHEN** two `opened` events arrive for the same `messageId`
- **THEN** the first call returns the persisted event and the second call returns `null`
#### Scenario: Non-lifecycle events are not deduped
- **WHEN** two `clicked` events arrive for the same `messageId`
- **THEN** both calls return persisted events
**Verified by:** `src/core/tracker/tracker.test.ts::"dedupes lifecycle events per messageId"` AND `src/core/tracker/tracker.test.ts::"does not dedupe non-lifecycle events"`
---
### Requirement: Zod boundary validation on all inbound events
The package SHALL parse every `CreateDeliveryEventInput` with `createDeliveryEventInputSchema` before any I/O. Invalid input MUST raise `DeliveryTrackingError(INVALID_INPUT)`.
#### Scenario: Empty tenantId is rejected
- **WHEN** `tracker.track({ ...input, tenantId: "" })` is called
- **THEN** a `DeliveryTrackingError` whose `code` is `DELIVERY_TRACKING.INVALID_INPUT` is thrown
**Verified by:** `src/core/tracker/tracker.test.ts::"throws DeliveryTrackingError(INVALID_INPUT) on schema violation"`
---
### Requirement: Typed EventBus emission
The package SHALL emit `delivery:<eventType>` topics on an `EventBus<DeliveryTrackerEventMap>` for every persisted event, allowing subscribers to react without polling the store.
#### Scenario: A persisted `sent` event triggers the `delivery:sent` topic
- **WHEN** a `sent` event is tracked
- **THEN** every listener subscribed to `delivery:sent` receives the persisted `DeliveryEvent`
**Verified by:** `src/core/tracker/tracker.test.ts::"emits a typed bus event matching eventType"`
---
### Requirement: Injectable Clock and IdGenerator
The package SHALL accept `clock` and `idGenerator` options from `@suaveplan/types` so tests can pin timestamps and IDs deterministically.
#### Scenario: Pinned clock and id generator produce deterministic events
- **WHEN** the tracker is constructed with `clock: () => 1_700_000_000_000` and `idGenerator: () => "id-N"`
- **THEN** the resulting event has `timestamp.getTime() === 1_700_000_000_000` and `id === "id-N"`
**Verified by:** `src/core/tracker/tracker.test.ts::"uses injected Clock + IdGenerator for deterministic ids"`
---
### Requirement: OpenTelemetry counter set
The package SHALL emit `delivery_tracking.tracked.total`, `delivery_tracking.deduped.total`, `delivery_tracking.webhook_parsed.total`, `delivery_tracking.token_issued.total`, and `delivery_tracking.token_rejected.total` counters and MUST allow tests to assert them via a real `MeterProvider` (no mocks).
#### Scenario: tracked.total reflects every persisted event
- **WHEN** two events are tracked and one is deduped
- **THEN** the `delivery_tracking.tracked.total` snapshot from the in-memory exporter is `2`
#### Scenario: Instrument set is lazy + cached
- **WHEN** `getDeliveryTrackingInstruments()` is called twice in a row
- **THEN** both calls return the identical object reference
**Verified by:** `src/core/tracker/tracker.test.ts::"emits tracked + deduped counters via real MeterProvider"` AND `src/otel/instruments.test.ts::"constructs the instrument set lazily and caches it"`
---
### Requirement: Backend-agnostic persistence contract
The package SHALL define `DeliveryEventStore` covering record + read + stats operations, ship an `InMemoryDeliveryEventStore`, and ship a `SurrealDeliveryEventStore` that uses positional SurQL via `SurrealAdapter.query(sql, params)`.
#### Scenario: InMemory store supports filter + paging
- **WHEN** events of multiple types are recorded and `getByCampaign("c1", { eventType: "sent", channel: "sms", limit: 1, offset: 1 })` is called
- **THEN** the returned slice respects all four filters
#### Scenario: SurrealDB store threads filters into the SQL
- **WHEN** `store.getByCampaign("c1", { eventType, channel, from, to, limit, offset })` is called
- **THEN** the emitted SQL contains every filter predicate and `LIMIT`/`START` clauses
**Verified by:** `src/stores/memory/memory-store.test.ts::"returns events for a campaign and supports filters + paging"` AND `src/stores/surreal/surreal-store.test.ts::"getByCampaign threads filters + paging into the SQL"`
---
### Requirement: Stable typed error codes on every IO path
The package SHALL raise `DeliveryTrackingError` with stable codes (`INVALID_INPUT`, `INVALID_TOKEN`, `UNSUPPORTED_PROVIDER`, `STORE_FAILURE`, `QUERY_FAILURE`) and MUST never leak driver-level exceptions across the public surface.
#### Scenario: SurrealAdapter write failure becomes STORE_FAILURE
- **WHEN** `SurrealAdapter.query` throws inside `record(event)`
- **THEN** the caller receives `DeliveryTrackingError` with `code === DELIVERY_TRACKING.STORE_FAILURE`
#### Scenario: SurrealAdapter read failure becomes QUERY_FAILURE
- **WHEN** `SurrealAdapter.query` throws inside `getByCampaign`
- **THEN** the caller receives `DeliveryTrackingError` with `code === DELIVERY_TRACKING.QUERY_FAILURE`
**Verified by:** `src/stores/surreal/surreal-store.test.ts::"wraps adapter errors in DeliveryTrackingError(STORE_FAILURE)"` AND `src/stores/surreal/surreal-store.test.ts::"wraps adapter errors thrown during select in QUERY_FAILURE"`
---
### Requirement: Time-series aggregation
The package SHALL bucket campaign events by `hour`, `day`, or `week`. The result MUST be empty when the campaign has no events and MUST contain a `computeStats`-derived `stats` field per bucket otherwise.
#### Scenario: Hourly bucketing distributes events across buckets
- **WHEN** three `sent` events at `10:00`, `10:30`, and `11:15` are recorded for `c1` and `getTimeSeriesStats("c1", "hour")` is called
- **THEN** the first bucket has `stats.sent === 2` and the second has `stats.sent === 1`
**Verified by:** `src/core/aggregator/aggregator.test.ts::"buckets events by hour"`
---
### Requirement: Open-pixel and click-link tokens
The package SHALL provide `TrackingPixelGenerator` and `TrackingLinkWrapper` which encode `(messageId, contactId, campaignId)` (and `originalUrl` for clicks) as URL-safe opaque tokens. Decoding a malformed token MUST raise `DeliveryTrackingError(INVALID_TOKEN)`.
#### Scenario: Pixel token round-trips
- **WHEN** `gen.generatePixelUrl(m, u, c)` is called and the resulting token is parsed via `gen.parsePixelToken(token)`
- **THEN** the parsed payload equals `{ messageId: m, contactId: u, campaignId: c }`
#### Scenario: Malformed token raises INVALID_TOKEN
- **WHEN** `decodeToken("!!!not-base64!!!", "secret")` is called
- **THEN** a `DeliveryTrackingError` with `code === DELIVERY_TRACKING.INVALID_TOKEN` is thrown
**Verified by:** `src/core/tracking-pixel/tracking-pixel.test.ts::"parses its own token back into the payload"` AND `src/core/tracking-pixel/token-codec.test.ts::"throws DeliveryTrackingError(INVALID_TOKEN) on malformed token"`
---
### Requirement: Bulk HTML link rewriting
The package SHALL provide `wrapAllLinks(html, messageId, contactId, campaignId?)` which rewrites every `<a href="…">` in the input HTML with a click-tracking URL, preserving the surrounding attributes.
#### Scenario: Every anchor is rewritten
- **WHEN** `wrapAllLinks(htmlWithTwoAnchors, m, u)` is called
- **THEN** the output HTML contains two click-tracking URLs and the original `href`s are gone
**Verified by:** `src/core/tracking-link/tracking-link.test.ts::"rewrites every <a href> in an HTML body"`
---
### Requirement: Pluggable provider webhook parsers
The package SHALL parse SendGrid, Mailgun, AWS SES, and a `generic` shape out of the box and MUST allow custom providers to be registered at construction time or at runtime. Unknown providers MUST raise `DeliveryTrackingError(UNSUPPORTED_PROVIDER)`.
#### Scenario: A custom provider parser can be registered at runtime
- **WHEN** `parser.registerProvider("brevo", { parse: ... })` is called and `parser.parse("brevo", payload)` runs
- **THEN** the custom parser's output is returned
#### Scenario: An unknown provider is rejected
- **WHEN** `parser.parse("mystery", { body: {} })` is called
- **THEN** a `DeliveryTrackingError` with `code === DELIVERY_TRACKING.UNSUPPORTED_PROVIDER` is thrown
**Verified by:** `src/core/webhook-parser/webhook-parser.test.ts::"supports registering a custom provider parser"` AND `src/core/webhook-parser/webhook-parser.test.ts::"raises UNSUPPORTED_PROVIDER for an unknown provider"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,62 @@
# Tasks — Add @suaveplan/delivery-tracking
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/delivery-tracking.md` end-to-end
- [x] 1.2 Confirm `openspec/specs/delivery-tracking/spec.md` does not yet exist
- [x] 1.3 Read `openspec/AGENTS.md` Sections 0a, 2a, 6a, 7, 12, 13, 14
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR is justified
- [x] 1.6 Draft `specs/delivery-tracking/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 Types in `src/types/delivery-tracking.types.ts`
- [x] 2.2 `DeliveryTrackingError` + `defineCodes` registry
- [x] 2.3 OTel counter set in `src/otel/instruments.ts`
- [x] 2.4 `DeliveryEventStore` interface + `InMemoryDeliveryEventStore` + `computeStats` helper
- [x] 2.5 `SurrealDeliveryEventStore` using positional SurQL with typed-error wrapping
- [x] 2.6 `DeliveryTracker` with Zod boundary, lifecycle dedupe, typed `EventBus`, injectable `Clock`/`IdGenerator`
- [x] 2.7 `DeliveryAggregator` with campaign/contact/time-series APIs
- [x] 2.8 `TrackingPixelGenerator` + `TrackingLinkWrapper` sharing a `token-codec` module
- [x] 2.9 `WebhookEventParser` with `sendgrid`/`mailgun`/`ses`/`generic` parsers and a runtime registry
- [x] 2.10 Package root barrel re-exports every public symbol via explicit named exports
- [x] 2.11 Package `vite.config.ts`, `vitest.config.ts`, `tsconfig.lib.json`, `tests/setup/polyfills.ts`
## 3. Tests
- [x] 3.1 Unit tests in each feature directory
- [x] 3.2 OTel counter assertions via real `InMemoryMetricExporter` + `MeterProvider`
- [x] 3.3 Every `it(...)` carries `{ timeout: 5000 }`
- [x] 3.4 No `vitest` / `bun:test` direct imports — every test imports from `@suaveplan/testing/runner`
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [x] 4.2 `README.md` links every co-located `.md` under `## Modules`
## 5. Pre-archive verification (the 5-gate triple)
- [ ] 5.1 `bun run check` — Biome clean
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/delivery-tracking`
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/delivery-tracking` — 100/100/100/100
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/delivery-tracking`
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/delivery-tracking`
## 6. Pre-archive 9-row checklist
- [ ] 6.1 Generics threaded; injectable `Clock`/`IdGenerator` pinned via tests
- [ ] 6.2 Zod schemas wired at the tracker boundary
- [ ] 6.3 Every error code thrown by ≥1 production path (`INVALID_INPUT`/`INVALID_TOKEN`/`UNSUPPORTED_PROVIDER`/`STORE_FAILURE`/`QUERY_FAILURE`)
- [ ] 6.4 Strict-mode behaviours (`exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`) honoured
- [ ] 6.5 OTel instruments emitted and asserted with real `MeterProvider`
- [ ] 6.6 Lifecycle dedupe tested (opened + delivered dedupe; clicked + sent allowed to repeat)
- [ ] 6.7 No retroactive ticking — boxes 5.* and 6.* tick only after the gates pass
- [ ] 6.8 No `export *` in feature barrels (all feature `index.ts` files use explicit named exports)
- [ ] 6.9 No direct `vitest` / `bun:test` imports (every test file imports from `@suaveplan/testing/runner`)
## 7. Archive
- [ ] 7.1 Fold `specs/delivery-tracking/spec.md` into `openspec/specs/delivery-tracking/spec.md`
- [ ] 7.2 Move `openspec/changes/add-delivery-tracking/` → `openspec/archive/2026-05-19-add-delivery-tracking/`