8.9 KiB
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
openedevents arrive for the samemessageId - THEN the first call returns the persisted event and the second call returns
null
Scenario: Non-lifecycle events are not deduped
- WHEN two
clickedevents arrive for the samemessageId - 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
DeliveryTrackingErrorwhosecodeisDELIVERY_TRACKING.INVALID_INPUTis 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
sentevent is tracked - THEN every listener subscribed to
delivery:sentreceives the persistedDeliveryEvent
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_000andidGenerator: () => "id-N" - THEN the resulting event has
timestamp.getTime() === 1_700_000_000_000andid === "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.totalsnapshot from the in-memory exporter is2
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/STARTclauses
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.querythrows insiderecord(event) - THEN the caller receives
DeliveryTrackingErrorwithcode === DELIVERY_TRACKING.STORE_FAILURE
Scenario: SurrealAdapter read failure becomes QUERY_FAILURE
- WHEN
SurrealAdapter.querythrows insidegetByCampaign - THEN the caller receives
DeliveryTrackingErrorwithcode === 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
sentevents at10:00,10:30, and11:15are recorded forc1andgetTimeSeriesStats("c1", "hour")is called - THEN the first bucket has
stats.sent === 2and the second hasstats.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 viagen.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
DeliveryTrackingErrorwithcode === DELIVERY_TRACKING.INVALID_TOKENis 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
hrefs 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 andparser.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
DeliveryTrackingErrorwithcode === DELIVERY_TRACKING.UNSUPPORTED_PROVIDERis 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"