diff --git a/docs/implementation/task-ep1.3-business-event-foundation-2026-07-13.md b/docs/implementation/task-ep1.3-business-event-foundation-2026-07-13.md new file mode 100644 index 0000000..d96e1fe --- /dev/null +++ b/docs/implementation/task-ep1.3-business-event-foundation-2026-07-13.md @@ -0,0 +1,211 @@ +# Task EP.1.3 Business Event Foundation - 2026-07-13 + +## Scope + +- introduced canonical Business Event contract +- added machine-readable registry, alias resolution, ownership metadata, naming rules, and payload validation +- added transport-independent publisher abstraction and in-process dispatcher +- added idempotent subscriber handling and unsupported-version detection +- published initial Activity lifecycle business events from Activity service layer +- documented projection consumer contracts, event sequence matrix, replay readiness, compatibility mapping, and versioning strategy +- preserved existing notification, approval, follow-up, dashboard, report, and audit behavior + +## Review Summary + +Reviewed before and during implementation: + +- `AGENTS.md` +- `plans/task-ep.1.3.md` +- `docs/standards/task-catalog.md` +- `docs/standards/project-foundations.md` +- `docs/standards/architecture-rules.md` +- `docs/standards/ui-ux-rules.md` +- `docs/standards/task-review-checklist.md` +- `docs/security/crm-authorization-boundaries.md` +- `docs/implementation/task-ep1.1-activity-domain-foundation-2026-07-10.md` +- `docs/implementation/task-ep1.2-activity-integration-legacy-followup-adapter-2026-07-13.md` +- existing Activity lifecycle service operations +- existing notification event service +- existing approval notification publication behavior +- existing audit-log foundation + +## Implementation Summary + +### Canonical Contract + +Added `BusinessEvent` in `src/features/foundation/business-events/types.ts`. + +The envelope includes: + +- globally unique `eventId` +- registered `eventType` +- explicit `schemaVersion` +- organization, branch, entity, primary record, and related records +- actor, `occurredAt`, `correlationId`, and `causationId` +- visibility metadata for product type, pricing sensitivity, and internal-only events +- payload and metadata + +### Registry And Naming + +Added `src/features/foundation/business-events/registry.ts`. + +The registry freezes canonical names and ownership for Activity, Lead, Opportunity, Quotation, Approval, Customer, and PO families. Naming follows lowercase `.` where practical, with compatibility aliases for existing approval notification names such as `approval.step.approved`. + +Unknown event types are rejected by publisher validation. + +### Publisher And Dispatcher + +Added: + +- `src/features/foundation/business-events/publisher.ts` +- `src/features/foundation/business-events/dispatcher.ts` + +The initial dispatcher is in-process and transport-independent. It supports independent subscriber registration, no-subscriber dispatch, duplicate detection by `consumerName:eventId`, subscriber isolation, unsupported-version reporting, and publish-many ordering. + +No Kafka, RabbitMQ, Redis Streams, persistent event store, replay table, or projection table was introduced. + +### Initial Activity Event Publishing + +Activity lifecycle events now publish from `src/features/crm/activities/server/service.ts`, after successful service-layer mutations: + +- `activity.created` +- `activity.assigned` +- `activity.reassigned` +- `activity.started` +- `activity.rescheduled` +- `activity.completed` +- `activity.cancelled` +- `activity.deleted` + +Route handlers do not publish events directly. + +Activity payloads redact narrative fields when an Activity is pricing-sensitive or internal-only. Visibility metadata remains available so future consumers can enforce CRM authorization before re-querying source details. + +Legacy follow-up adapters do not publish events in EP.1.3. + +### Event Sequence Matrix + +Added `BUSINESS_EVENT_SEQUENCE_MATRIX` in `src/features/foundation/business-events/matrices.ts`. + +It freezes expected order for key operations: + +- Create Activity +- Assign Existing Activity +- Start Activity +- Reschedule Activity +- Complete Activity +- Cancel Activity +- Convert Lead +- Submit Approval +- Receive PO + +### Event Consumer Matrix + +Added `BUSINESS_EVENT_CONSUMER_MATRIX` in `src/features/foundation/business-events/matrices.ts`. + +Consumers are explicitly non-owning: + +- Timeline +- Calendar +- Dashboard +- Notification +- My Day +- Relationship Health +- Forecast + +All listed projection consumers are forbidden from mutating source-domain lifecycle state. + +### Replay Contracts + +Added `src/features/foundation/business-events/replay.ts`. + +Replay is contract-only in EP.1.3: + +- request must be organization-scoped +- dry-run defaults to true +- ordering is `occurred_at_then_event_id` +- idempotency is required +- no replay route or persistent event store was added + +### Existing Event Compatibility + +Added `src/features/foundation/business-events/compatibility.ts`. + +Existing approval notification event behavior remains untouched. Compatibility aliases map current names to canonical business event names where needed: + +- `approval.step.approved` -> `approval.step-approved` +- `approval.step.rejected` -> `approval.step-rejected` + +## Versioning Strategy + +- Every event includes `schemaVersion`. +- Additive optional payload fields can remain on the same event type and schema version when existing consumers are unaffected. +- Incompatible semantic changes require a new schema version. +- Consumers can declare supported versions per event type. +- Unsupported versions are reported by the dispatcher and do not silently process. +- Deprecated event definitions must specify `replacementEventType`. + +## Ordering And Idempotency Rules + +- Global ordering across all domains is not guaranteed. +- Related events in one operation share `correlationId`. +- Child events use `causationId` when one event directly triggers another. +- Consumers must not rely on undocumented timing. +- Dispatcher duplicate handling is in-memory for EP.1.3 tests and contracts only. +- Persistent checkpointing remains a future projection-foundation concern. + +## Observability Hooks + +The dispatch result captures: + +- event id and type +- dispatch status +- dispatch duration +- subscriber name +- subscriber status +- subscriber duration +- subscriber error message +- duplicate and unsupported-version outcomes + +Structured persistence of these observations is intentionally deferred until persistent event/projection infrastructure is approved. + +## Projection Readiness + +Ready for EP.1.4 to define projection storage and consumers using: + +- `BusinessEvent` +- `BusinessEventConsumer` +- `BusinessEventPublisher` +- `BusinessEventDispatcher` +- event registry definitions +- sequence matrix +- consumer matrix +- replay contract + +Not implemented in EP.1.3: + +- Timeline projection +- Calendar projection +- Dashboard projection replacement +- Notification expansion +- My Day +- Manager Workspace +- Executive Workspace +- Relationship Health +- Forecast projection +- Automation engine +- external broker +- persistent replay engine +- projection storage tables + +## Verification + +- `npm run typecheck` +- `node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/features/foundation/business-events/*.test.ts src/features/crm/activities/server/business-events.test.ts` + +## Residual Risks + +- Business events are currently in-process only; no cross-process delivery guarantee exists. +- No persistent event store exists, so replay remains a contract and testing surface only. +- Subscriber failures will fail publisher dispatch; no production subscriber is registered in EP.1.3. +- Existing approval notifications still use the current notification event service and are mapped for compatibility rather than migrated. diff --git a/plans/task-ep.1.3.md b/plans/task-ep.1.3.md new file mode 100644 index 0000000..09d9a36 --- /dev/null +++ b/plans/task-ep.1.3.md @@ -0,0 +1,1034 @@ +# Task EP.1.3 – Business Event Foundation + +Status: Completed +Priority: Critical +Type: Platform Foundation / Business Event Architecture + +## Depends On + +* EP.1.1 Activity Domain Foundation +* EP.1.2 Activity Integration & Legacy Follow-up Adapter +* BU-R.0 Business Blueprint Freeze +* BU-R.0.1 Workspace & Activity Business Blueprint Completion +* BU-R.1 Business Capability Audit +* AR.1 Architecture Transition Plan +* AR.2 Epic & Technical Design +* ENG.0 Engineering Constitution + +--- + +# Objective + +Introduce the shared Business Event Foundation for ALLA OS. + +This task establishes the canonical event contract, event registry, publisher and subscriber abstractions, in-process dispatcher, event ownership rules, event versioning, sequence rules, consumer mappings, projection contracts, replay contracts, testing standards, and observability hooks. + +The Business Event Foundation must enable future Timeline, Calendar, Notification, Dashboard, My Day, Forecast, Relationship Health, and Automation capabilities without requiring those consumers to couple directly to operational domain implementations. + +This task must remain additive and transport-independent. + +It must not introduce Kafka, RabbitMQ, Redis Streams, external message brokers, projection tables, or asynchronous infrastructure. + +--- + +# Background + +EP.1.1 introduced the additive Activity Domain and the first `crm_activities` write model. + +EP.1.2 introduced: + +* legacy lead follow-up adapter +* legacy opportunity follow-up adapter +* legacy quotation follow-up adapter +* canonical `ActivityTimelineItem` +* compatibility read layer +* additive recent-activity integration +* Activity numbering and attachment foundations + +The next architectural requirement is a shared Business Event Foundation. + +Without this foundation, Timeline, Calendar, Notification, Dashboard, My Day, Forecast, Relationship Health, and Automation may independently interpret operational changes and duplicate business logic. + +Business events must become the governed communication contract between source domains and downstream projections. + +--- + +# Architecture Principles + +## 1. Source Domain Ownership + +Operational domains remain the owners of business state. + +Examples: + +* Activity owns Activity lifecycle changes. +* Lead owns Lead assignment and handoff changes. +* Opportunity owns project progress, forecast, Hot Project, and business outcome. +* Quotation owns quotation document lifecycle. +* Approval owns approval workflow decisions. +* Customer owns customer relationship record changes. + +A projection or subscriber must never publish an event that belongs to another domain. + +--- + +## 2. Publish After Successful State Change + +Business events must be published only after the related business mutation succeeds. + +Route Handlers must not publish business events directly. + +Publishing belongs to the owning service or an approved service-layer integration component. + +--- + +## 3. Transport Independence + +Business domains publish through an abstraction. + +Initial implementation: + +```text +Domain Service + ↓ +Business Event Publisher + ↓ +In-Process Dispatcher +``` + +Future implementation may introduce another transport without changing domain event contracts. + +--- + +## 4. Projection over Duplication + +Timeline, Calendar, Dashboard, Notification, My Day, Forecast, and Relationship Health consume business events or governed read contracts. + +They do not become alternate owners of operational lifecycle state. + +--- + +## 5. Backward Compatibility + +Existing APIs, follow-up behavior, dashboard datasets, report datasets, notification behavior, approval events, and audit history must remain operational. + +No existing event name that is already used in production may be renamed without a compatibility plan. + +--- + +## 6. Audit and Event Separation + +Audit Log and Business Event have different responsibilities. + +* Audit Log records immutable mutation evidence. +* Business Event communicates a business fact to downstream consumers. + +Publishing a Business Event does not replace mandatory audit logging. + +--- + +# Review Required + +Review before implementation: + +## Governance + +* `AGENTS.md` +* `docs/standards/engineering-constitution.md` +* `docs/standards/project-foundations.md` +* `docs/standards/architecture-rules.md` +* `docs/standards/ui-ux-rules.md` +* `docs/standards/task-review-checklist.md` +* `docs/standards/task-catalog.md` + +## Business and Architecture + +* Relationship & Sales Workspace Blueprint +* BU-R.0 Implementation Report +* BU-R.0.1 Implementation Report +* BU-R.1 Capability Audit +* AR.1 Architecture Transition Plan +* AR.2 Epic & Technical Design +* AR.2 Workspace UI/UX Design Note + +## Existing Implementation + +* EP.1.1 Activity Domain Foundation +* EP.1.2 Activity Integration & Legacy Follow-up Adapter +* Activity service and lifecycle transitions +* Activity timeline compatibility layer +* existing notification event foundation +* existing approval event publication +* audit-log foundation +* customer service +* lead service +* opportunity service +* quotation service +* approval service +* notification event service +* dashboard and report consumers + +--- + +# Scope + +## Part 1 — Canonical Business Event Contract + +Introduce the canonical Business Event envelope. + +Minimum contract: + +```ts +export type BusinessEvent> = { + eventId: string; + eventType: string; + schemaVersion: number; + + organizationId: string; + branchId?: string | null; + + entityType: string; + entityId: string; + + primaryRecord: { + type: string; + id: string; + }; + + relatedRecords: Array<{ + type: string; + id: string; + }>; + + actorUserId: string | null; + + occurredAt: string; + + correlationId: string; + causationId?: string | null; + + visibility: { + productType?: string | null; + pricingSensitive?: boolean; + internalOnly?: boolean; + }; + + payload: TPayload; + + metadata: Record; +}; +``` + +The implementation may adjust naming to match existing repository conventions, but the same business information must remain represented. + +### Rules + +* `eventId` must be globally unique. +* `eventType` must be registered. +* `schemaVersion` must be explicit. +* `organizationId` is mandatory. +* `occurredAt` must use a stable ISO timestamp. +* `correlationId` must support tracing related mutations. +* pricing-sensitive payloads must not bypass CRM authorization. +* payloads must not contain decrypted secrets or unsafe binary content. + +--- + +## Part 2 — Event Naming Standard + +Freeze the official naming convention. + +Preferred convention: + +```text +. +``` + +Examples: + +```text +activity.created +activity.assigned +activity.reassigned +activity.rescheduled +activity.started +activity.completed +activity.cancelled + +lead.created +lead.assigned +lead.handoff-ready +lead.converted + +opportunity.created +opportunity.reassigned +opportunity.expected-award-changed +opportunity.hot-project-flagged +opportunity.hot-project-cleared +opportunity.won +opportunity.lost +opportunity.reopened + +quotation.created +quotation.revised +quotation.sent +quotation.pending-approval +quotation.approved +quotation.rejected +quotation.returned +quotation.cancelled + +approval.requested +approval.step-approved +approval.step-rejected +approval.returned +approval.completed +approval.escalated + +customer.created +customer.updated +customer.owner-changed + +po.received +``` + +### Rules + +* use lowercase names +* use stable domain prefixes +* use past-tense business facts +* do not encode UI action names +* do not create synonyms for the same business event +* preserve existing production approval event names through aliases or compatibility mapping where required + +--- + +## Part 3 — Event Ownership Matrix + +Define which domain owns each event family. + +Minimum ownership: + +| Event Family | Owning Domain | +| --------------- | ------------------------------------ | +| `activity.*` | Activity | +| `customer.*` | Customer | +| `contact.*` | Customer / Contact | +| `lead.*` | Lead | +| `opportunity.*` | Opportunity | +| `quotation.*` | Quotation | +| `approval.*` | Approval | +| `po.*` | Opportunity outcome / PO integration | + +### Frozen Rule + +The following consumers do not own source-domain business events: + +* Timeline +* Calendar +* Dashboard +* Reports +* Notification +* My Day +* Manager Workspace +* Executive Workspace +* Relationship Health +* Forecast projection + +These components consume events and may emit only their own technical processing signals if required. + +--- + +## Part 4 — Event Registry + +Create the official Business Event Registry. + +Every registered event must define: + +* event type +* owner +* schema version +* business meaning +* trigger condition +* payload contract +* allowed visibility metadata +* expected ordering +* known consumers +* compatibility aliases +* deprecation status + +Recommended structure: + +```ts +export type BusinessEventDefinition = { + eventType: string; + owner: string; + schemaVersion: number; + description: string; + payloadSchema: unknown; + consumers: string[]; + deprecated?: boolean; + replacementEventType?: string; +}; +``` + +Unknown or unregistered events must be handled explicitly according to the frozen error strategy. + +--- + +## Part 5 — Event Publisher Framework + +Implement the publisher abstraction. + +Minimum capability: + +```ts +publish(event) +publishMany(events) +``` + +Responsibilities: + +* validate event registration +* validate payload contract +* enrich event metadata +* propagate correlation and causation IDs +* perform structured logging +* invoke the configured dispatcher +* expose testing seams +* avoid duplicate publication during one service operation + +### Rules + +* service-owned business logic publishes events +* Route Handlers stay thin +* publisher failures must follow a documented transaction and error strategy +* publishing must not silently report success if dispatch failed +* existing production mutation behavior must not be broken by optional consumers + +--- + +## Part 6 — Subscriber and Dispatcher Framework + +Implement transport-independent subscriber and dispatcher contracts. + +Minimum capability: + +```ts +subscribe(eventType, handler) +unsubscribe(eventType, handler) +dispatch(event) +``` + +Initial implementation must be in-process. + +Subscribers must: + +* be independently registered +* declare the event types they consume +* be idempotent or use an idempotency guard +* not directly mutate unrelated source domains +* report processing failures through structured logging +* preserve subscriber isolation where practical + +### Unknown Event Strategy + +Freeze behavior for: + +* unregistered event +* registered event without subscribers +* subscriber exception +* invalid payload +* unsupported schema version + +--- + +## Part 7 — Initial Activity Event Publishing + +Integrate the first production event publishers with Activity lifecycle operations. + +Minimum events: + +* `activity.created` +* `activity.assigned` +* `activity.reassigned` +* `activity.started` +* `activity.rescheduled` +* `activity.completed` +* `activity.cancelled` +* `activity.deleted`, only when deletion is a valid governed operation + +### Rules + +* publish only after successful Activity state mutation +* include Activity owner, assignee, primary context, related references, schedule, priority, and outcome metadata as appropriate +* do not expose pricing-sensitive notes to unrestricted subscribers +* do not change existing Activity APIs +* do not publish events from legacy follow-up adapters in this task unless explicitly required for contract validation + +Legacy follow-ups remain compatibility read sources during EP.1.3. + +--- + +## Part 8 — Existing Event Compatibility + +Review current approval and notification event behavior. + +Produce a compatibility mapping covering: + +* existing event names +* current publisher +* current recipient resolution +* target Business Event equivalent +* whether aliasing is needed +* whether the current event should remain untouched temporarily + +### Frozen Rule + +Do not break existing approval notifications while introducing the shared Business Event abstraction. + +Adapter-first integration is preferred. + +--- + +## Part 9 — Projection Consumer Contracts + +Define consumer contracts for future projections. + +Do not implement the projections. + +Minimum consumers: + +* Timeline +* Calendar +* Dashboard +* Notification +* My Day +* Manager Workspace +* Executive Workspace +* Relationship Health +* Forecast + +Recommended interface: + +```ts +export interface BusinessEventConsumer { + readonly consumerName: string; + readonly supportedEventTypes: string[]; + + handle(event: BusinessEvent): Promise; +} +``` + +### Clarification + +Consumers may still use governed source queries to build complete read models. + +Business events signal that something changed and provide projection context; they do not need to contain every source-field value. + +--- + +## Part 10 — Event Versioning Strategy + +Freeze versioning rules. + +### Rules + +* every event includes `schemaVersion` +* additive optional payload fields do not require a new event type +* incompatible semantic changes require a new schema version +* consumers must declare supported versions +* deprecated versions must have a transition period +* existing production event names must not be renamed without compatibility mapping + +Produce: + +* version-support policy +* deprecation workflow +* payload evolution examples +* consumer compatibility rules + +--- + +## Part 11 — Idempotency and Duplicate Handling + +Define and implement foundational duplicate handling. + +Minimum requirements: + +* every event has a unique `eventId` +* subscribers can identify already-processed events +* duplicate dispatch must not duplicate projection effects +* event retries must remain safe + +This task may use an in-memory idempotency strategy for tests and contracts. + +Do not introduce a production projection checkpoint table unless separately approved. + +--- + +## Part 12 — Event Ordering + +Freeze ordering expectations. + +### Rules + +* global ordering across all domains is not guaranteed +* causal ordering within one business operation must be explicit +* related events use `correlationId` +* child events use `causationId` +* Event Sequence Matrix defines expected business order +* consumers must not rely on undocumented timing + +--- + +## Part 13 — Replay Contracts + +Prepare replay interfaces without implementing a complete replay engine. + +Define: + +```ts +export type ReplayRequest = { + organizationId: string; + eventTypes?: string[]; + entityType?: string; + entityId?: string; + occurredFrom?: string; + occurredTo?: string; + dryRun?: boolean; +}; +``` + +Define: + +* replay request +* replay source abstraction +* ordering expectations +* idempotency expectations +* version compatibility +* security rules +* dry-run behavior + +### Constraint + +No replay API route and no persistent event store are required in EP.1.3 unless separately approved. + +The output is an implementation contract and readiness assessment. + +--- + +## Part 14 — Observability and Testing Foundation + +Add integration points for: + +* structured event logging +* correlation IDs +* causation IDs +* event type +* owner domain +* dispatch duration +* subscriber duration +* dispatch success/failure +* subscriber success/failure +* duplicate detection +* unsupported version handling + +Testing must cover: + +* valid publication +* invalid event rejection +* unregistered event behavior +* successful subscription +* multiple subscribers +* subscriber isolation +* duplicate dispatch +* idempotent consumer behavior +* ordering within a declared sequence +* unsupported schema version +* correlation propagation +* causation propagation + +--- + +# 15. Event Sequence Matrix + +Create and freeze the official Event Sequence Matrix. + +The matrix defines the expected event order for governed business actions. + +It prevents different services from publishing inconsistent event chains for the same business operation. + +## Minimum Matrix + +| Business Action | Owning Service | Required Event Sequence | Conditional Events | Notes | +| --------------------------- | ----------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------- | +| Create Activity | Activity Service | `activity.created` | `activity.assigned` when assignee differs from owner | Create event must occur first | +| Assign Existing Activity | Activity Service | `activity.assigned` or `activity.reassigned` | None | Do not publish both for one transition | +| Start Activity | Activity Service | `activity.started` | None | Only when lifecycle supports explicit start | +| Reschedule Activity | Activity Service | `activity.rescheduled` | reminder-related events in later epic | Activity remains source owner | +| Complete Activity | Activity Service | `activity.completed` | next-action suggestion in later automation epic | Completion event contains outcome summary metadata | +| Cancel Activity | Activity Service | `activity.cancelled` | None | Include cancellation reason where required | +| Create Lead | Lead Service | `lead.created` | assignment event if immediately assigned | Preserve lead ownership rules | +| Handoff Lead | Lead Service | `lead.handoff-ready` | `lead.assigned` when assignment changes | Must not imply opportunity creation until it succeeds | +| Convert Lead | Lead and Opportunity Services | `lead.converted` → `opportunity.created` | `opportunity.reassigned` if final owner differs | Use one correlation ID | +| Create Opportunity Directly | Opportunity Service | `opportunity.created` | assignment event if applicable | No fake Lead event | +| Change Expected Award Date | Opportunity Service | `opportunity.expected-award-changed` | Hot Project suggestion later | Include before/after dates safely | +| Flag Hot Project | Opportunity Service | `opportunity.hot-project-flagged` | None | Hot Project is not lifecycle status | +| Clear Hot Project | Opportunity Service | `opportunity.hot-project-cleared` | None | Preserve manual override metadata | +| Create Quotation | Quotation Service | `quotation.created` | None | Opportunity remains project owner | +| Create Revision | Quotation Service | `quotation.revised` | approval event later when submitted | Link revision chain | +| Submit Approval | Quotation / Approval Service | `quotation.pending-approval` → `approval.requested` | notification delivery in later consumer | Use one correlation ID | +| Approve Quotation | Approval Service | approval step events → `approval.completed` → `quotation.approved` | None | Document workflow only | +| Reject Quotation | Approval Service | approval rejection event → `quotation.rejected` | None | Does not directly mark opportunity lost | +| Send Quotation | Quotation Service | `quotation.sent` | follow-up suggestion later | Must occur after successful send/state mutation | +| Receive PO | Opportunity / PO Service | `po.received` → `opportunity.won` | downstream delivery milestone later | PO evidence governs won outcome | +| Mark Opportunity Lost | Opportunity Service | `opportunity.lost` | reminder cleanup by future consumer | Quotation status does not own outcome | +| Reopen Opportunity | Opportunity Service | `opportunity.reopened` | Hot Project recalculation later | Outcome must return to open state | + +## Sequence Rules + +* Sequence must represent completed business facts, not intended actions. +* All events from one business operation share the same `correlationId`. +* Each downstream event should identify the immediate triggering event through `causationId`. +* Events must not be published for mutations that fail or roll back. +* A required sequence must not be reordered by individual consumers. +* Conditional events must document their trigger. +* A source service must not publish events owned by another domain unless the operation is coordinated through an approved application service. +* Existing approval event order must be preserved or mapped explicitly. + +## Deliverables for Part 15 + +* Event Sequence Matrix +* sequence validation helper or test fixture +* correlation/causation examples +* coordinated-service transaction notes +* failure and partial-publication analysis +* compatibility mapping for current approval sequences + +--- + +# 16. Event Consumer Matrix + +Create and freeze the official Event Consumer Matrix. + +The matrix defines which consumers may subscribe to each event family and what effect they are allowed to produce. + +This matrix becomes the Single Source of Truth for future projection implementation. + +## Minimum Consumer Matrix + +| Consumer | Consumed Events | Intended Effect | May Mutate Source Domain? | Implementation Phase | +| ------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------ | --------------------------- | +| Timeline | Activity, Lead, Opportunity, Quotation, Approval, Customer, PO events | build chronological business-readable entries | No | EP.1.4 / Timeline epic | +| Calendar | scheduled/rescheduled/cancelled Activity events, milestone changes, approval due events | update time-based projection | No | Calendar projection epic | +| Dashboard | opportunity outcome, forecast, Hot Project, activity workload, approval events | refresh KPI and operational aggregates | No | Dashboard projection epic | +| Notification | Activity assignment/due/overdue, approval, quotation, opportunity events | resolve recipients and deliver inbox/email notifications | No | Notification expansion epic | +| My Day | Activity schedule/lifecycle, approval blockers, quotation urgency, Hot Project gaps | update personal action queue | No | My Day epic | +| Manager Workspace | Activity workload, idle opportunity, Hot Project, approval, forecast events | update team control-tower signals | No | Manager Workspace epic | +| Executive Workspace | opportunity outcome, forecast, strategic customer, PO, revenue events | update strategic summaries | No | Executive Workspace epic | +| Relationship Health | customer interaction, activity completion, overdue work, opportunity outcome | recalculate relationship health signal | No | Relationship Health epic | +| Forecast | Expected Award Date, probability, process, Hot Project, outcome events | update forward-looking projections | No | Forecast enhancement epic | +| Audit Bridge | governed event metadata where required | correlate business events with audit evidence | No | Foundation enhancement | +| Future Automation | activity completion, overdue, quotation approval, expected-award changes | suggest or execute governed automation | Only through owning service commands | Future automation epic | + +## Event-to-Consumer Mapping + +At minimum, document consumers for: + +* `activity.created` +* `activity.assigned` +* `activity.reassigned` +* `activity.rescheduled` +* `activity.started` +* `activity.completed` +* `activity.cancelled` +* `lead.created` +* `lead.assigned` +* `lead.handoff-ready` +* `lead.converted` +* `opportunity.created` +* `opportunity.reassigned` +* `opportunity.expected-award-changed` +* `opportunity.hot-project-flagged` +* `opportunity.hot-project-cleared` +* `opportunity.won` +* `opportunity.lost` +* `opportunity.reopened` +* `quotation.created` +* `quotation.revised` +* `quotation.sent` +* `quotation.pending-approval` +* `quotation.approved` +* `quotation.rejected` +* `quotation.returned` +* `approval.requested` +* `approval.completed` +* `approval.escalated` +* `po.received` + +## Consumer Rules + +* Consumers must declare subscribed event types explicitly. +* Consumers must not subscribe to every event through an unrestricted wildcard in production code. +* Consumer effects must be idempotent. +* Consumers must not bypass the owning service to mutate source lifecycle state. +* Consumers requiring source details must re-query through governed services or security-aware read models. +* Consumer failures must not silently corrupt source transactions. +* Pricing-sensitive consumers must enforce quotation pricing visibility. +* Notification delivery state must not become business lifecycle state. +* Timeline and Calendar remain rebuildable projections. +* Dashboard and workspace consumers must not calculate conflicting definitions for the same KPI. +* Consumer registration must be testable and discoverable. + +## Deliverables for Part 16 + +* Event Consumer Matrix +* per-event consumer mapping +* consumer ownership catalog +* subscriber registration map +* idempotency requirement by consumer +* security and data-redaction requirements +* future implementation phase mapping +* unused-event and missing-consumer report + +--- + +# Deliverables + +## 1. Business Event Platform + +Transport-independent publisher, subscriber, registry, and dispatcher foundation. + +## 2. Canonical Business Event Contract + +Shared envelope and validation rules. + +## 3. Business Event Registry + +Official machine-readable event catalog. + +## 4. Event Naming Standard + +Stable naming conventions and compatibility rules. + +## 5. Domain Event Ownership Matrix + +Official event ownership by domain. + +## 6. Publisher Framework + +Service-layer publication abstraction. + +## 7. Subscriber Framework + +Independent, testable consumer registration and dispatch. + +## 8. Initial Activity Event Publishers + +Activity lifecycle events published through the new foundation. + +## 9. Projection Consumer Contracts + +Contracts for Timeline, Calendar, Dashboard, Notification, My Day, Manager, Executive, Forecast, and Relationship Health. + +## 10. Event Versioning Strategy + +Schema evolution and deprecation rules. + +## 11. Replay Contracts + +Replay interfaces and readiness documentation. + +## 12. Event Testing Standards + +Publisher, dispatcher, subscriber, ordering, duplicate, idempotency, and version tests. + +## 13. Observability Integration Contracts + +Structured logging, correlation, tracing, and metrics hooks. + +## 14. Projection Readiness Report + +Readiness assessment for all planned projection and workspace consumers. + +## 15. Event Sequence Matrix + +Official event ordering for governed business actions. + +## 16. Event Consumer Matrix + +Official event-to-consumer mapping and allowed consumer effects. + +## 17. Existing Event Compatibility Report + +Mapping for current approval and notification event behavior. + +## 18. Business Event Catalog + +Human-readable documentation for all registered events. + +--- + +# Constraints + +Must preserve: + +* Customer +* Contact +* Lead +* Opportunity +* Quotation +* Approval +* Activity +* existing follow-up APIs +* existing dashboard and report consumers +* existing notification behavior +* existing audit history +* current security boundaries +* current pricing visibility rules + +Do not implement: + +* Timeline projection +* Calendar projection +* Dashboard projection replacement +* Notification expansion +* My Day +* Manager Workspace +* Executive Workspace +* Relationship Health +* Forecast projection replacement +* Automation engine +* external message broker +* persistent replay engine +* projection storage tables + +No UI changes are required. + +No legacy follow-up migration. + +No follow-up dual-write. + +No dashboard/report source swap. + +No breaking API changes. + +--- + +# Compatibility Strategy + +The Business Event Foundation is additive. + +Existing production events and consumers remain operational. + +Activity lifecycle operations may begin publishing through the new abstraction, but existing API responses and business behavior must remain unchanged. + +Existing approval notification events must remain compatible through one of these approved strategies: + +* preserve current event name and register it +* map current event to canonical event contract +* publish a compatibility alias +* use an adapter at the dispatcher boundary + +Do not delete or rename a live event without a dedicated migration and compatibility task. + +--- + +# Testing Requirements + +Minimum tests: + +## Contract Tests + +* valid canonical event +* missing required metadata +* invalid event type +* unsupported schema version +* unsafe payload rejection where applicable + +## Registry Tests + +* register event +* duplicate event registration +* unknown event lookup +* deprecated event lookup +* compatibility alias resolution + +## Publisher Tests + +* publish one event +* publish multiple events +* correlation propagation +* causation propagation +* metadata enrichment +* publish after successful service mutation + +## Dispatcher Tests + +* no subscriber +* one subscriber +* multiple subscribers +* subscriber exception +* duplicate dispatch +* deterministic handler registration + +## Consumer Tests + +* idempotent processing +* supported version +* unsupported version +* pricing-sensitive redaction boundary +* source-domain mutation prohibition + +## Sequence Tests + +* Activity create sequence +* Lead conversion sequence +* Quotation approval sequence +* PO received / Opportunity won sequence +* failed mutation produces no event sequence +* shared correlation ID +* valid causation chain + +--- + +# Acceptance Criteria + +* A single canonical Business Event contract exists. +* All registered events follow the canonical contract. +* Event names follow one governed naming standard. +* Event ownership is explicit and enforceable. +* Business services publish through one abstraction. +* Route Handlers do not publish domain events directly. +* An in-process dispatcher operates behind a transport-independent interface. +* Subscribers are independently registered and testable. +* Initial Activity lifecycle events are published successfully. +* Existing approval and notification behavior remains compatible. +* Event versioning and deprecation rules are documented. +* Duplicate handling and idempotency requirements are defined and tested. +* Replay contracts exist without requiring a persistent event store. +* Event Sequence Matrix covers the minimum governed business actions. +* Event Consumer Matrix maps the minimum events to their planned consumers. +* Consumers do not become source-domain lifecycle owners. +* No Timeline, Calendar, My Day, Dashboard, Notification, or Automation projection is implemented. +* Existing APIs, dashboard datasets, report datasets, follow-up behavior, and audit history remain unchanged. +* TypeScript, targeted lint, and event-foundation tests pass. + +--- + +# Success Criteria + +ALLA OS has a production-ready, transport-independent Business Event Foundation. + +Operational domains can publish governed business facts without coupling directly to downstream workspace or projection implementations. + +Event ordering is standardized through the Event Sequence Matrix. + +Future consumers and their allowed effects are standardized through the Event Consumer Matrix. + +Timeline, Calendar, Dashboard, Notification, My Day, Manager Workspace, Executive Workspace, Relationship Health, Forecast, and Automation can proceed without redefining event contracts or domain ownership. + +Ready for: + +# EP.1.4 – Projection Foundation diff --git a/src/features/crm/activities/server/business-events.test.ts b/src/features/crm/activities/server/business-events.test.ts new file mode 100644 index 0000000..c07633d --- /dev/null +++ b/src/features/crm/activities/server/business-events.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createActivityBusinessEvent } from './business-events.ts'; +import type { ActivityRecord } from '../api/types.ts'; + +function activity(overrides: Partial = {}): ActivityRecord { + return { + id: 'activity-1', + activityCode: 'ACT2607-001', + organizationId: 'org-1', + primaryEntityType: 'opportunity', + primaryEntityId: 'opp-1', + primaryRecordLabel: 'EN-001 Project', + relatedRecords: [], + customerId: 'customer-1', + contactId: null, + leadId: null, + opportunityId: 'opp-1', + quotationId: null, + activityType: 'follow_up', + subject: 'Follow up', + description: 'Pricing detail', + ownerId: 'owner-1', + ownerName: 'Owner', + assignedToId: 'assignee-1', + assignedToName: 'Assignee', + scheduledStartAt: '2026-07-13T00:00:00.000Z', + scheduledEndAt: null, + dueAt: null, + completedAt: null, + cancelledAt: null, + skippedAt: null, + status: 'scheduled', + effectiveStatus: 'scheduled', + priority: 'normal', + outcomeSummary: 'Sensitive outcome', + cancellationReason: null, + skipReason: null, + nextAction: 'Call buyer', + branchId: 'branch-1', + productType: 'crane', + isInternalOnly: false, + isPricingSensitive: true, + parentActivityId: null, + metadata: null, + createdBy: 'user-1', + createdByName: 'User', + createdAt: '2026-07-13T00:00:00.000Z', + updatedBy: 'user-1', + updatedByName: 'User', + updatedAt: '2026-07-13T00:00:00.000Z', + deletedAt: null, + canViewPricingSensitiveContent: false, + isContentRedacted: true, + ...overrides + }; +} + +test('activity business event redacts pricing-sensitive narrative fields', () => { + const event = createActivityBusinessEvent({ + eventType: 'activity.completed', + activity: activity(), + actorUserId: 'user-1' + }); + + assert.equal(event.visibility.pricingSensitive, true); + assert.equal(event.payload.description, null); + assert.equal(event.payload.outcomeSummary, null); + assert.equal(event.payload.contentRedacted, true); +}); + +test('activity business event preserves correlation and related context', () => { + const event = createActivityBusinessEvent({ + eventType: 'activity.created', + activity: activity({ isPricingSensitive: false, description: 'Normal note' }), + actorUserId: 'user-1', + correlationId: 'corr-1' + }); + + assert.equal(event.correlationId, 'corr-1'); + assert.equal(event.primaryRecord.type, 'opportunity'); + assert.equal(event.relatedRecords.some((record) => record.type === 'customer'), true); + assert.equal(event.payload.description, 'Normal note'); +}); diff --git a/src/features/crm/activities/server/business-events.ts b/src/features/crm/activities/server/business-events.ts new file mode 100644 index 0000000..176bb62 --- /dev/null +++ b/src/features/crm/activities/server/business-events.ts @@ -0,0 +1,114 @@ +import { + createBusinessEvent, + publishBusinessEvent, + type BusinessEvent +} from '../../../foundation/business-events/index.ts'; +import type { ActivityRecord } from '../api/types.ts'; + +export type ActivityBusinessEventType = + | 'activity.created' + | 'activity.assigned' + | 'activity.reassigned' + | 'activity.started' + | 'activity.rescheduled' + | 'activity.completed' + | 'activity.cancelled' + | 'activity.deleted'; + +interface PublishActivityBusinessEventInput { + eventType: ActivityBusinessEventType; + activity: ActivityRecord; + actorUserId: string; + correlationId?: string; + causationId?: string | null; + payload?: Record; + previousActivity?: ActivityRecord | null; +} + +function compactRelatedRecords(activity: ActivityRecord) { + return [ + activity.customerId ? { type: 'customer', id: activity.customerId } : null, + activity.contactId ? { type: 'contact', id: activity.contactId } : null, + activity.leadId ? { type: 'lead', id: activity.leadId } : null, + activity.opportunityId ? { type: 'opportunity', id: activity.opportunityId } : null, + activity.quotationId ? { type: 'quotation', id: activity.quotationId } : null, + ...activity.relatedRecords.map((record) => ({ + type: record.entityType, + id: record.entityId + })) + ].filter((record): record is { type: string; id: string } => Boolean(record)); +} + +function buildSafeActivityPayload( + activity: ActivityRecord, + previousActivity?: ActivityRecord | null, + extraPayload?: Record +) { + const shouldRedactContent = activity.isPricingSensitive || activity.isInternalOnly; + + return { + activityId: activity.id, + activityCode: activity.activityCode, + activityType: activity.activityType, + subject: activity.subject, + description: shouldRedactContent ? null : activity.description, + status: activity.status, + effectiveStatus: activity.effectiveStatus, + previousStatus: previousActivity?.status ?? null, + priority: activity.priority, + ownerId: activity.ownerId, + previousOwnerId: previousActivity?.ownerId ?? null, + assignedToId: activity.assignedToId, + previousAssignedToId: previousActivity?.assignedToId ?? null, + scheduledStartAt: activity.scheduledStartAt, + previousScheduledStartAt: previousActivity?.scheduledStartAt ?? null, + scheduledEndAt: activity.scheduledEndAt, + dueAt: activity.dueAt, + previousDueAt: previousActivity?.dueAt ?? null, + completedAt: activity.completedAt, + cancelledAt: activity.cancelledAt, + skippedAt: activity.skippedAt, + outcomeSummary: shouldRedactContent ? null : activity.outcomeSummary, + cancellationReason: shouldRedactContent ? null : activity.cancellationReason, + nextAction: shouldRedactContent ? null : activity.nextAction, + contentRedacted: shouldRedactContent, + ...extraPayload + }; +} + +export function createActivityBusinessEvent(input: PublishActivityBusinessEventInput): BusinessEvent { + return createBusinessEvent({ + eventType: input.eventType, + organizationId: input.activity.organizationId, + branchId: input.activity.branchId, + entityType: 'activity', + entityId: input.activity.id, + primaryRecord: { + type: input.activity.primaryEntityType, + id: input.activity.primaryEntityId ?? input.activity.id + }, + relatedRecords: compactRelatedRecords(input.activity), + actorUserId: input.actorUserId, + correlationId: input.correlationId, + causationId: input.causationId, + visibility: { + productType: input.activity.productType, + pricingSensitive: input.activity.isPricingSensitive, + internalOnly: input.activity.isInternalOnly + }, + payload: buildSafeActivityPayload(input.activity, input.previousActivity, input.payload), + metadata: { + source: 'activity-service', + primaryRecordLabel: input.activity.primaryRecordLabel + } + }); +} + +export async function publishActivityBusinessEvent( + input: PublishActivityBusinessEventInput +): Promise { + const event = createActivityBusinessEvent(input); + await publishBusinessEvent(event); + + return event; +} diff --git a/src/features/crm/activities/server/service.ts b/src/features/crm/activities/server/service.ts index 892a650..6283443 100644 --- a/src/features/crm/activities/server/service.ts +++ b/src/features/crm/activities/server/service.ts @@ -46,6 +46,7 @@ import type { } from '../api/types'; import { getActivityRow, insertActivity, listActivityRows, updateActivityRow } from './repository'; import { hydrateActivityRecords } from './read-model'; +import { publishActivityBusinessEvent } from './business-events'; type ActivityAccessContext = CrmSecurityContext; @@ -646,7 +647,26 @@ export async function createActivity( updatedBy: userId }); - return getActivityById(created.id, organizationId, context); + const activity = await getActivityById(created.id, organizationId, context); + const correlationId = crypto.randomUUID(); + + await publishActivityBusinessEvent({ + eventType: 'activity.created', + activity, + actorUserId: userId, + correlationId + }); + + if (activity.assignedToId && activity.assignedToId !== activity.ownerId) { + await publishActivityBusinessEvent({ + eventType: 'activity.assigned', + activity, + actorUserId: userId, + correlationId + }); + } + + return activity; } export async function updateActivity( @@ -711,7 +731,64 @@ export async function updateActivity( updatedBy: userId }); - return getActivityById(id, organizationId, context); + const activity = await getActivityById(id, organizationId, context); + const correlationId = crypto.randomUUID(); + + if (current.assignedToId !== activity.assignedToId) { + await publishActivityBusinessEvent({ + eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned', + activity, + previousActivity: current, + actorUserId: userId, + correlationId + }); + } + + if ( + current.scheduledStartAt !== activity.scheduledStartAt || + current.scheduledEndAt !== activity.scheduledEndAt || + current.dueAt !== activity.dueAt + ) { + await publishActivityBusinessEvent({ + eventType: 'activity.rescheduled', + activity, + previousActivity: current, + actorUserId: userId, + correlationId + }); + } + + if (current.status !== 'in_progress' && activity.status === 'in_progress') { + await publishActivityBusinessEvent({ + eventType: 'activity.started', + activity, + previousActivity: current, + actorUserId: userId, + correlationId + }); + } + + if (current.status !== 'completed' && activity.status === 'completed') { + await publishActivityBusinessEvent({ + eventType: 'activity.completed', + activity, + previousActivity: current, + actorUserId: userId, + correlationId + }); + } + + if (current.status !== 'cancelled' && activity.status === 'cancelled') { + await publishActivityBusinessEvent({ + eventType: 'activity.cancelled', + activity, + previousActivity: current, + actorUserId: userId, + correlationId + }); + } + + return activity; } export async function completeActivity( @@ -734,7 +811,15 @@ export async function completeActivity( updatedBy: userId }); - return getActivityById(id, organizationId, context); + const activity = await getActivityById(id, organizationId, context); + await publishActivityBusinessEvent({ + eventType: 'activity.completed', + activity, + previousActivity: current, + actorUserId: userId + }); + + return activity; } export async function cancelActivity( @@ -756,7 +841,15 @@ export async function cancelActivity( updatedBy: userId }); - return getActivityById(id, organizationId, context); + const activity = await getActivityById(id, organizationId, context); + await publishActivityBusinessEvent({ + eventType: 'activity.cancelled', + activity, + previousActivity: current, + actorUserId: userId + }); + + return activity; } export async function assignActivity( @@ -767,7 +860,7 @@ export async function assignActivity( context: ActivityAccessContext ): Promise { assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.reassign); - await getHydratedActivityOrThrow(id, organizationId, context); + const current = await getHydratedActivityOrThrow(id, organizationId, context); if (payload.assignedToId) { await assertMembershipUserBelongsToOrganization(payload.assignedToId, organizationId); @@ -779,7 +872,17 @@ export async function assignActivity( updatedBy: userId }); - return getActivityById(id, organizationId, context); + const activity = await getActivityById(id, organizationId, context); + if (current.assignedToId !== activity.assignedToId) { + await publishActivityBusinessEvent({ + eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned', + activity, + previousActivity: current, + actorUserId: userId + }); + } + + return activity; } export async function rescheduleActivity( @@ -814,7 +917,15 @@ export async function rescheduleActivity( updatedBy: userId }); - return getActivityById(id, organizationId, context); + const activity = await getActivityById(id, organizationId, context); + await publishActivityBusinessEvent({ + eventType: 'activity.rescheduled', + activity, + previousActivity: current, + actorUserId: userId + }); + + return activity; } export async function deleteActivity( @@ -824,11 +935,17 @@ export async function deleteActivity( context: ActivityAccessContext ) { assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.delete); - await getHydratedActivityOrThrow(id, organizationId, context); + const current = await getHydratedActivityOrThrow(id, organizationId, context); await updateActivityRow(id, organizationId, { deletedAt: new Date(), updatedAt: new Date(), updatedBy: userId }); + + await publishActivityBusinessEvent({ + eventType: 'activity.deleted', + activity: current, + actorUserId: userId + }); } diff --git a/src/features/foundation/business-events/compatibility.ts b/src/features/foundation/business-events/compatibility.ts new file mode 100644 index 0000000..a3bf420 --- /dev/null +++ b/src/features/foundation/business-events/compatibility.ts @@ -0,0 +1,66 @@ +export interface ExistingEventCompatibilityMapping { + existingEventType: string; + currentPublisher: string; + currentConsumer: string; + targetBusinessEventType: string; + aliasRequired: boolean; + keepCurrentBehavior: boolean; + notes: string; +} + +export const EXISTING_EVENT_COMPATIBILITY_MAPPINGS: ExistingEventCompatibilityMapping[] = [ + { + existingEventType: 'approval.requested', + currentPublisher: 'Approval Service notification event publisher', + currentConsumer: 'Foundation Notification Event Service', + targetBusinessEventType: 'approval.requested', + aliasRequired: false, + keepCurrentBehavior: true, + notes: 'Existing notification event behavior remains untouched in EP.1.3.' + }, + { + existingEventType: 'approval.step.approved', + currentPublisher: 'Approval Service notification event publisher', + currentConsumer: 'Foundation Notification Event Service', + targetBusinessEventType: 'approval.step-approved', + aliasRequired: true, + keepCurrentBehavior: true, + notes: 'Canonical business event uses kebab past-tense segment; existing notification event remains as compatibility alias.' + }, + { + existingEventType: 'approval.step.rejected', + currentPublisher: 'Approval Service notification event publisher', + currentConsumer: 'Foundation Notification Event Service', + targetBusinessEventType: 'approval.step-rejected', + aliasRequired: true, + keepCurrentBehavior: true, + notes: 'Canonical business event uses kebab past-tense segment; existing notification event remains as compatibility alias.' + }, + { + existingEventType: 'approval.completed', + currentPublisher: 'Approval Service notification event publisher', + currentConsumer: 'Foundation Notification Event Service', + targetBusinessEventType: 'approval.completed', + aliasRequired: false, + keepCurrentBehavior: true, + notes: 'Compatible canonical name.' + }, + { + existingEventType: 'approval.returned', + currentPublisher: 'Approval Service notification event publisher', + currentConsumer: 'Foundation Notification Event Service', + targetBusinessEventType: 'approval.returned', + aliasRequired: false, + keepCurrentBehavior: true, + notes: 'Compatible canonical name.' + }, + { + existingEventType: 'approval.escalated', + currentPublisher: 'Approval automation notification event publisher', + currentConsumer: 'Foundation Notification Event Service', + targetBusinessEventType: 'approval.escalated', + aliasRequired: false, + keepCurrentBehavior: true, + notes: 'Compatible canonical name.' + } +]; diff --git a/src/features/foundation/business-events/dispatcher.test.ts b/src/features/foundation/business-events/dispatcher.test.ts new file mode 100644 index 0000000..1543aaf --- /dev/null +++ b/src/features/foundation/business-events/dispatcher.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { InProcessBusinessEventDispatcher } from './dispatcher.ts'; +import type { BusinessEvent } from './types.ts'; + +function event(overrides: Partial = {}): BusinessEvent { + return { + eventId: 'event-1', + eventType: 'activity.created', + schemaVersion: 1, + organizationId: 'org-1', + branchId: null, + entityType: 'activity', + entityId: 'activity-1', + primaryRecord: { type: 'activity', id: 'activity-1' }, + relatedRecords: [], + actorUserId: 'user-1', + occurredAt: '2026-07-13T00:00:00.000Z', + correlationId: 'corr-1', + causationId: null, + visibility: { pricingSensitive: false, internalOnly: false, productType: null }, + payload: {}, + metadata: {}, + ...overrides + }; +} + +test('dispatcher reports no subscribers without failing', async () => { + const dispatcher = new InProcessBusinessEventDispatcher(); + const result = await dispatcher.dispatch(event()); + + assert.equal(result.status, 'no_subscribers'); + assert.deepEqual(result.subscriberResults, []); +}); + +test('dispatcher handles multiple subscribers and duplicate dispatch', async () => { + const dispatcher = new InProcessBusinessEventDispatcher(); + const handled: string[] = []; + + dispatcher.subscribe('activity.created', { + consumerName: 'consumer-a', + supportedEventTypes: ['activity.created'], + async handle() { + handled.push('a'); + } + }); + dispatcher.subscribe('activity.created', { + consumerName: 'consumer-b', + supportedEventTypes: ['activity.created'], + async handle() { + handled.push('b'); + } + }); + + const first = await dispatcher.dispatch(event()); + const second = await dispatcher.dispatch(event()); + + assert.equal(first.status, 'dispatched'); + assert.deepEqual(handled, ['a', 'b']); + assert.equal(second.subscriberResults.every((result) => result.status === 'duplicate'), true); +}); + +test('dispatcher isolates subscriber exception and reports failure', async () => { + const dispatcher = new InProcessBusinessEventDispatcher(); + + dispatcher.subscribe('activity.created', { + consumerName: 'failing-consumer', + supportedEventTypes: ['activity.created'], + async handle() { + throw new Error('subscriber failed'); + } + }); + + const result = await dispatcher.dispatch(event()); + + assert.equal(result.status, 'failed'); + assert.equal(result.subscriberResults[0]?.status, 'failed'); +}); + +test('dispatcher reports unsupported schema version', async () => { + const dispatcher = new InProcessBusinessEventDispatcher(); + + dispatcher.subscribe('activity.created', { + consumerName: 'v1-consumer', + supportedEventTypes: ['activity.created'], + supportedSchemaVersions: { 'activity.created': [1] }, + async handle() {} + }); + + const result = await dispatcher.dispatch(event({ schemaVersion: 2 })); + + assert.equal(result.status, 'failed'); + assert.equal(result.subscriberResults[0]?.status, 'unsupported_version'); +}); diff --git a/src/features/foundation/business-events/dispatcher.ts b/src/features/foundation/business-events/dispatcher.ts new file mode 100644 index 0000000..48e7c9e --- /dev/null +++ b/src/features/foundation/business-events/dispatcher.ts @@ -0,0 +1,121 @@ +import { performance } from 'node:perf_hooks'; +import type { + BusinessEvent, + BusinessEventConsumer, + BusinessEventDispatchResult, + BusinessEventDispatcher, + BusinessEventSubscriberResult +} from './types.ts'; + +function supportsSchemaVersion(consumer: BusinessEventConsumer, event: BusinessEvent): boolean { + const supportedVersions = consumer.supportedSchemaVersions?.[event.eventType]; + + return !supportedVersions || supportedVersions.includes(event.schemaVersion); +} + +function normalizeError(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown business event subscriber error'; +} + +export class InProcessBusinessEventDispatcher implements BusinessEventDispatcher { + private readonly subscribers = new Map>(); + private readonly processed = new Set(); + + subscribe(eventType: string, consumer: BusinessEventConsumer): () => void { + const eventSubscribers = this.subscribers.get(eventType) ?? new Map(); + eventSubscribers.set(consumer.consumerName, consumer); + this.subscribers.set(eventType, eventSubscribers); + + return () => this.unsubscribe(eventType, consumer.consumerName); + } + + unsubscribe(eventType: string, consumerName: string): void { + const eventSubscribers = this.subscribers.get(eventType); + eventSubscribers?.delete(consumerName); + + if (eventSubscribers?.size === 0) { + this.subscribers.delete(eventType); + } + } + + async dispatch(event: BusinessEvent): Promise { + const startedAt = performance.now(); + const subscribers = [...(this.subscribers.get(event.eventType)?.values() ?? [])]; + + if (!subscribers.length) { + return { + eventId: event.eventId, + eventType: event.eventType, + status: 'no_subscribers', + durationMs: performance.now() - startedAt, + subscriberResults: [] + }; + } + + const subscriberResults: BusinessEventSubscriberResult[] = []; + + for (const consumer of subscribers) { + const subscriberStartedAt = performance.now(); + const idempotencyKey = `${consumer.consumerName}:${event.eventId}`; + + if (this.processed.has(idempotencyKey)) { + subscriberResults.push({ + consumerName: consumer.consumerName, + eventType: event.eventType, + status: 'duplicate', + durationMs: performance.now() - subscriberStartedAt + }); + continue; + } + + if (!supportsSchemaVersion(consumer, event)) { + subscriberResults.push({ + consumerName: consumer.consumerName, + eventType: event.eventType, + status: 'unsupported_version', + durationMs: performance.now() - subscriberStartedAt, + error: `Unsupported schema version ${event.schemaVersion}` + }); + continue; + } + + try { + await consumer.handle(event); + this.processed.add(idempotencyKey); + subscriberResults.push({ + consumerName: consumer.consumerName, + eventType: event.eventType, + status: 'processed', + durationMs: performance.now() - subscriberStartedAt + }); + } catch (error) { + subscriberResults.push({ + consumerName: consumer.consumerName, + eventType: event.eventType, + status: 'failed', + durationMs: performance.now() - subscriberStartedAt, + error: normalizeError(error) + }); + } + } + + const failed = subscriberResults.some( + (result) => result.status === 'failed' || result.status === 'unsupported_version' + ); + + return { + eventId: event.eventId, + eventType: event.eventType, + status: failed ? 'failed' : 'dispatched', + durationMs: performance.now() - startedAt, + subscriberResults + }; + } + + clearForTests(): void { + this.subscribers.clear(); + this.processed.clear(); + } +} + +export const inProcessBusinessEventDispatcher = new InProcessBusinessEventDispatcher(); diff --git a/src/features/foundation/business-events/index.ts b/src/features/foundation/business-events/index.ts new file mode 100644 index 0000000..7c3a31d --- /dev/null +++ b/src/features/foundation/business-events/index.ts @@ -0,0 +1,7 @@ +export * from './compatibility.ts'; +export * from './dispatcher.ts'; +export * from './matrices.ts'; +export * from './publisher.ts'; +export * from './registry.ts'; +export * from './replay.ts'; +export * from './types.ts'; diff --git a/src/features/foundation/business-events/matrices.test.ts b/src/features/foundation/business-events/matrices.test.ts new file mode 100644 index 0000000..6e311f0 --- /dev/null +++ b/src/features/foundation/business-events/matrices.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + assertConsumerMatrixDoesNotMutateSourceDomains, + assertSequenceStartsWith, + BUSINESS_EVENT_CONSUMER_MATRIX, + BUSINESS_EVENT_SEQUENCE_MATRIX +} from './matrices.ts'; + +test('sequence matrix freezes key governed orders', () => { + assertSequenceStartsWith('Create Activity', 'activity.created'); + assertSequenceStartsWith('Submit Approval', 'quotation.pending-approval'); + assert.equal( + BUSINESS_EVENT_SEQUENCE_MATRIX.some((rule) => + rule.requiredEventSequence.includes('po.received') + ), + true + ); +}); + +test('consumer matrix forbids projection consumers from mutating source domains', () => { + assert.doesNotThrow(() => assertConsumerMatrixDoesNotMutateSourceDomains()); + assert.equal(BUSINESS_EVENT_CONSUMER_MATRIX.every((rule) => !rule.mayMutateSourceDomain), true); +}); diff --git a/src/features/foundation/business-events/matrices.ts b/src/features/foundation/business-events/matrices.ts new file mode 100644 index 0000000..4f0317f --- /dev/null +++ b/src/features/foundation/business-events/matrices.ts @@ -0,0 +1,153 @@ +export interface BusinessEventSequenceRule { + businessAction: string; + owningService: string; + requiredEventSequence: string[]; + conditionalEvents: string[]; + notes: string; +} + +export interface BusinessEventConsumerRule { + consumer: string; + consumedEvents: string[]; + intendedEffect: string; + mayMutateSourceDomain: boolean; + implementationPhase: string; +} + +export const BUSINESS_EVENT_SEQUENCE_MATRIX: BusinessEventSequenceRule[] = [ + { + businessAction: 'Create Activity', + owningService: 'Activity Service', + requiredEventSequence: ['activity.created'], + conditionalEvents: ['activity.assigned when assignee differs from owner'], + notes: 'Create event must occur first.' + }, + { + businessAction: 'Assign Existing Activity', + owningService: 'Activity Service', + requiredEventSequence: ['activity.assigned or activity.reassigned'], + conditionalEvents: [], + notes: 'Do not publish both assignment events for one transition.' + }, + { + businessAction: 'Start Activity', + owningService: 'Activity Service', + requiredEventSequence: ['activity.started'], + conditionalEvents: [], + notes: 'Published only when lifecycle explicitly enters in_progress.' + }, + { + businessAction: 'Reschedule Activity', + owningService: 'Activity Service', + requiredEventSequence: ['activity.rescheduled'], + conditionalEvents: [], + notes: 'Activity remains the source owner.' + }, + { + businessAction: 'Complete Activity', + owningService: 'Activity Service', + requiredEventSequence: ['activity.completed'], + conditionalEvents: [], + notes: 'Completion event contains outcome metadata with pricing-safe redaction.' + }, + { + businessAction: 'Cancel Activity', + owningService: 'Activity Service', + requiredEventSequence: ['activity.cancelled'], + conditionalEvents: [], + notes: 'Cancellation reason is redacted when pricing/internal visibility requires it.' + }, + { + businessAction: 'Convert Lead', + owningService: 'Lead and Opportunity Services', + requiredEventSequence: ['lead.converted', 'opportunity.created'], + conditionalEvents: ['opportunity.reassigned when final owner differs'], + notes: 'Use one correlation ID across both domains.' + }, + { + businessAction: 'Submit Approval', + owningService: 'Quotation / Approval Service', + requiredEventSequence: ['quotation.pending-approval', 'approval.requested'], + conditionalEvents: [], + notes: 'Existing notification events remain compatible.' + }, + { + businessAction: 'Receive PO', + owningService: 'Opportunity / PO Service', + requiredEventSequence: ['po.received', 'opportunity.won'], + conditionalEvents: [], + notes: 'PO evidence governs won outcome.' + } +]; + +export const BUSINESS_EVENT_CONSUMER_MATRIX: BusinessEventConsumerRule[] = [ + { + consumer: 'Timeline', + consumedEvents: ['activity.*', 'lead.*', 'opportunity.*', 'quotation.*', 'approval.*', 'customer.*', 'po.*'], + intendedEffect: 'Build chronological business-readable entries.', + mayMutateSourceDomain: false, + implementationPhase: 'EP.1.4 / Timeline epic' + }, + { + consumer: 'Calendar', + consumedEvents: ['activity.created', 'activity.rescheduled', 'activity.cancelled', 'opportunity.expected-award-changed'], + intendedEffect: 'Update time-based projection.', + mayMutateSourceDomain: false, + implementationPhase: 'Calendar projection epic' + }, + { + consumer: 'Dashboard', + consumedEvents: ['activity.*', 'opportunity.*', 'quotation.*', 'approval.*', 'po.received'], + intendedEffect: 'Refresh KPI and operational aggregates.', + mayMutateSourceDomain: false, + implementationPhase: 'Dashboard projection epic' + }, + { + consumer: 'Notification', + consumedEvents: ['activity.assigned', 'activity.reassigned', 'activity.rescheduled', 'approval.*', 'quotation.*'], + intendedEffect: 'Resolve recipients and deliver messages.', + mayMutateSourceDomain: false, + implementationPhase: 'Notification expansion epic' + }, + { + consumer: 'My Day', + consumedEvents: ['activity.*', 'approval.*', 'quotation.*', 'opportunity.hot-project-flagged'], + intendedEffect: 'Update personal action queue.', + mayMutateSourceDomain: false, + implementationPhase: 'My Day epic' + }, + { + consumer: 'Relationship Health', + consumedEvents: ['activity.completed', 'opportunity.won', 'opportunity.lost', 'customer.*'], + intendedEffect: 'Recalculate relationship health signals.', + mayMutateSourceDomain: false, + implementationPhase: 'Relationship Health epic' + }, + { + consumer: 'Forecast', + consumedEvents: ['opportunity.expected-award-changed', 'opportunity.won', 'opportunity.lost', 'po.received'], + intendedEffect: 'Update forward-looking projections.', + mayMutateSourceDomain: false, + implementationPhase: 'Forecast enhancement epic' + } +]; + +export function assertConsumerMatrixDoesNotMutateSourceDomains(): void { + const invalidRule = BUSINESS_EVENT_CONSUMER_MATRIX.find((rule) => rule.mayMutateSourceDomain); + + if (invalidRule) { + throw new Error(`${invalidRule.consumer} may not mutate source domains`); + } +} + +export function assertSequenceStartsWith(action: string, eventType: string): void { + const rule = BUSINESS_EVENT_SEQUENCE_MATRIX.find((item) => item.businessAction === action); + + if (!rule) { + throw new Error(`Unknown business action: ${action}`); + } + + if (rule.requiredEventSequence[0] !== eventType) { + throw new Error(`${action} must start with ${rule.requiredEventSequence[0]}`); + } +} diff --git a/src/features/foundation/business-events/publisher.test.ts b/src/features/foundation/business-events/publisher.test.ts new file mode 100644 index 0000000..d98187d --- /dev/null +++ b/src/features/foundation/business-events/publisher.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { InProcessBusinessEventDispatcher } from './dispatcher.ts'; +import { createBusinessEvent, DefaultBusinessEventPublisher } from './publisher.ts'; + +test('publisher creates canonical event with correlation propagation', () => { + const event = createBusinessEvent({ + eventType: 'activity.created', + organizationId: 'org-1', + entityType: 'activity', + entityId: 'activity-1', + primaryRecord: { type: 'activity', id: 'activity-1' }, + actorUserId: 'user-1', + correlationId: 'corr-1', + payload: { activityId: 'activity-1' } + }); + + assert.equal(event.eventType, 'activity.created'); + assert.equal(event.schemaVersion, 1); + assert.equal(event.correlationId, 'corr-1'); + assert.equal(event.metadata.owner, 'Activity'); +}); + +test('publisher rejects unregistered events and unsafe binary payloads', () => { + assert.throws( + () => + createBusinessEvent({ + eventType: 'missing.event', + organizationId: 'org-1', + entityType: 'activity', + entityId: 'activity-1', + primaryRecord: { type: 'activity', id: 'activity-1' }, + payload: {} + }), + /Unregistered/ + ); + + assert.throws( + () => + createBusinessEvent({ + eventType: 'activity.created', + organizationId: 'org-1', + entityType: 'activity', + entityId: 'activity-1', + primaryRecord: { type: 'activity', id: 'activity-1' }, + payload: Buffer.from('nope') as unknown as Record + }), + /binary/ + ); +}); + +test('publisher publishes one and many events through dispatcher', async () => { + const dispatcher = new InProcessBusinessEventDispatcher(); + const publisher = new DefaultBusinessEventPublisher(dispatcher); + let count = 0; + + dispatcher.subscribe('activity.created', { + consumerName: 'counter', + supportedEventTypes: ['activity.created'], + async handle() { + count += 1; + } + }); + + const first = createBusinessEvent({ + eventType: 'activity.created', + organizationId: 'org-1', + entityType: 'activity', + entityId: 'activity-1', + primaryRecord: { type: 'activity', id: 'activity-1' }, + payload: {} + }); + const second = createBusinessEvent({ + eventType: 'activity.created', + organizationId: 'org-1', + entityType: 'activity', + entityId: 'activity-2', + primaryRecord: { type: 'activity', id: 'activity-2' }, + payload: {} + }); + + await publisher.publish(first); + await publisher.publishMany([second]); + + assert.equal(count, 2); +}); diff --git a/src/features/foundation/business-events/publisher.ts b/src/features/foundation/business-events/publisher.ts new file mode 100644 index 0000000..895c1e1 --- /dev/null +++ b/src/features/foundation/business-events/publisher.ts @@ -0,0 +1,111 @@ +import { + assertBusinessEventPayload, + assertRegisteredBusinessEvent, + resolveBusinessEventType +} from './registry.ts'; +import { inProcessBusinessEventDispatcher } from './dispatcher.ts'; +import type { + BusinessEvent, + BusinessEventCreateInput, + BusinessEventDispatchResult, + BusinessEventDispatcher, + BusinessEventPublisher +} from './types.ts'; + +function assertSafePayload(payload: unknown) { + if (payload instanceof Buffer) { + throw new Error('Business event payload must not contain binary content'); + } +} + +function normalizeOccurredAt(value?: string): string { + const date = value ? new Date(value) : new Date(); + + if (Number.isNaN(date.getTime())) { + throw new Error('Business event occurredAt must be a valid ISO date'); + } + + return date.toISOString(); +} + +export function createBusinessEvent>( + input: BusinessEventCreateInput +): BusinessEvent { + const eventType = resolveBusinessEventType(input.eventType); + const definition = assertRegisteredBusinessEvent(eventType); + assertSafePayload(input.payload); + assertBusinessEventPayload(eventType, input.payload); + + return { + eventId: crypto.randomUUID(), + eventType, + schemaVersion: definition.schemaVersion, + organizationId: input.organizationId, + branchId: input.branchId ?? null, + entityType: input.entityType, + entityId: input.entityId, + primaryRecord: input.primaryRecord, + relatedRecords: input.relatedRecords ?? [], + actorUserId: input.actorUserId ?? null, + occurredAt: normalizeOccurredAt(input.occurredAt), + correlationId: input.correlationId ?? crypto.randomUUID(), + causationId: input.causationId ?? null, + visibility: { + productType: input.visibility?.productType ?? null, + pricingSensitive: input.visibility?.pricingSensitive ?? false, + internalOnly: input.visibility?.internalOnly ?? false + }, + payload: input.payload, + metadata: { + owner: definition.owner, + ...input.metadata + } + }; +} + +export class DefaultBusinessEventPublisher implements BusinessEventPublisher { + private readonly dispatcher: BusinessEventDispatcher; + + constructor(dispatcher: BusinessEventDispatcher) { + this.dispatcher = dispatcher; + } + + async publish(event: BusinessEvent): Promise { + assertRegisteredBusinessEvent(event.eventType); + assertBusinessEventPayload(event.eventType, event.payload); + + const result = await this.dispatcher.dispatch(event); + + if (result.status === 'failed') { + throw new Error(`Business event dispatch failed: ${event.eventType}`); + } + + return result; + } + + async publishMany(events: BusinessEvent[]): Promise { + const results: BusinessEventDispatchResult[] = []; + + for (const event of events) { + results.push(await this.publish(event)); + } + + return results; + } +} + +export const businessEventPublisher = new DefaultBusinessEventPublisher( + inProcessBusinessEventDispatcher +); + +export async function publishBusinessEvent( + event: BusinessEvent +): Promise { + return businessEventPublisher.publish(event); +} + +export async function publishBusinessEvents( + events: BusinessEvent[] +): Promise { + return businessEventPublisher.publishMany(events); +} diff --git a/src/features/foundation/business-events/registry.test.ts b/src/features/foundation/business-events/registry.test.ts new file mode 100644 index 0000000..a546cff --- /dev/null +++ b/src/features/foundation/business-events/registry.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + assertBusinessEventPayload, + assertRegisteredBusinessEvent, + getBusinessEventDefinition, + listBusinessEventDefinitions, + resolveBusinessEventType +} from './registry.ts'; + +test('business event registry resolves canonical definitions and aliases', () => { + assert.ok(getBusinessEventDefinition('activity.created')); + assert.equal(resolveBusinessEventType('approval.step.approved'), 'approval.step-approved'); + assert.equal(assertRegisteredBusinessEvent('approval.step.rejected').eventType, 'approval.step-rejected'); +}); + +test('business event registry rejects unknown events', () => { + assert.throws(() => assertRegisteredBusinessEvent('unknown.created'), /Unregistered/); +}); + +test('registered event payloads require object payloads', () => { + assert.doesNotThrow(() => assertBusinessEventPayload('activity.created', { activityId: 'a1' })); + assert.throws(() => assertBusinessEventPayload('activity.created', null), /Invalid payload/); +}); + +test('all registered event names use lowercase domain fact convention', () => { + for (const definition of listBusinessEventDefinitions()) { + assert.match(definition.eventType, /^[a-z]+(\.[a-z0-9-]+)+$/); + assert.equal(definition.schemaVersion > 0, true); + assert.equal(definition.deprecated === true ? Boolean(definition.replacementEventType) : true, true); + } +}); diff --git a/src/features/foundation/business-events/registry.ts b/src/features/foundation/business-events/registry.ts new file mode 100644 index 0000000..606a829 --- /dev/null +++ b/src/features/foundation/business-events/registry.ts @@ -0,0 +1,508 @@ +import type { BusinessEventDefinition } from './types.ts'; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +const anyRecordPayload = (payload: unknown): payload is Record => isRecord(payload); + +const EVENT_DEFINITIONS = [ + { + eventType: 'activity.created', + owner: 'Activity', + schemaVersion: 1, + description: 'Activity was created successfully.', + trigger: 'Activity create mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive', 'internalOnly'], + ordering: 'First event in Create Activity sequence.', + consumers: ['Timeline', 'Calendar', 'Dashboard', 'Notification', 'My Day', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'activity.assigned', + owner: 'Activity', + schemaVersion: 1, + description: 'Activity was assigned for the first time or at creation.', + trigger: 'Activity assigned user is set where no prior assignee exists.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive', 'internalOnly'], + ordering: 'May follow activity.created in the same correlation.', + consumers: ['Timeline', 'Notification', 'My Day', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'activity.reassigned', + owner: 'Activity', + schemaVersion: 1, + description: 'Existing activity assignee changed.', + trigger: 'Activity reassignment mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive', 'internalOnly'], + ordering: 'Single assignment event for a reassignment operation.', + consumers: ['Timeline', 'Notification', 'My Day', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'activity.started', + owner: 'Activity', + schemaVersion: 1, + description: 'Activity entered in-progress status.', + trigger: 'Activity lifecycle update persisted with status in_progress.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive', 'internalOnly'], + ordering: 'Occurs before completion/cancellation when explicit start is used.', + consumers: ['Timeline', 'My Day', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'activity.rescheduled', + owner: 'Activity', + schemaVersion: 1, + description: 'Activity schedule or due date changed.', + trigger: 'Activity reschedule mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive', 'internalOnly'], + ordering: 'May occur after create or assignment.', + consumers: ['Timeline', 'Calendar', 'Notification', 'My Day', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'activity.completed', + owner: 'Activity', + schemaVersion: 1, + description: 'Activity was completed.', + trigger: 'Activity completion mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive', 'internalOnly'], + ordering: 'Terminal lifecycle event.', + consumers: ['Timeline', 'Dashboard', 'My Day', 'Relationship Health', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'activity.cancelled', + owner: 'Activity', + schemaVersion: 1, + description: 'Activity was cancelled.', + trigger: 'Activity cancellation mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive', 'internalOnly'], + ordering: 'Terminal lifecycle event.', + consumers: ['Timeline', 'Calendar', 'Notification', 'My Day', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'activity.deleted', + owner: 'Activity', + schemaVersion: 1, + description: 'Activity was soft-deleted.', + trigger: 'Activity delete mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive', 'internalOnly'], + ordering: 'Final cleanup signal when governed deletion is valid.', + consumers: ['Timeline', 'Calendar', 'Dashboard', 'My Day'], + compatibilityAliases: [] + }, + { + eventType: 'lead.created', + owner: 'Lead', + schemaVersion: 1, + description: 'Lead was created.', + trigger: 'Lead create mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType'], + ordering: 'First event in lead lifecycle.', + consumers: ['Timeline', 'Dashboard', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'lead.assigned', + owner: 'Lead', + schemaVersion: 1, + description: 'Lead owner or sales assignment changed.', + trigger: 'Lead assignment mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType'], + ordering: 'May follow lead.created.', + consumers: ['Timeline', 'Notification', 'My Day', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'lead.handoff-ready', + owner: 'Lead', + schemaVersion: 1, + description: 'Lead is ready for sales handoff.', + trigger: 'Lead handoff readiness persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType'], + ordering: 'Precedes conversion when conversion occurs.', + consumers: ['Timeline', 'Notification', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'lead.converted', + owner: 'Lead', + schemaVersion: 1, + description: 'Lead was converted to an opportunity.', + trigger: 'Lead conversion operation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType'], + ordering: 'Precedes opportunity.created in coordinated conversion.', + consumers: ['Timeline', 'Dashboard', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'opportunity.created', + owner: 'Opportunity', + schemaVersion: 1, + description: 'Opportunity was created.', + trigger: 'Opportunity create mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType'], + ordering: 'First opportunity event.', + consumers: ['Timeline', 'Dashboard', 'Forecast', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'opportunity.reassigned', + owner: 'Opportunity', + schemaVersion: 1, + description: 'Opportunity assignment changed.', + trigger: 'Opportunity assignment mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType'], + ordering: 'Assignment event within opportunity lifecycle.', + consumers: ['Timeline', 'Notification', 'My Day', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'opportunity.expected-award-changed', + owner: 'Opportunity', + schemaVersion: 1, + description: 'Expected award or close timing changed.', + trigger: 'Opportunity expected date mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Forecast signal after opportunity exists.', + consumers: ['Timeline', 'Calendar', 'Forecast', 'Dashboard', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'opportunity.hot-project-flagged', + owner: 'Opportunity', + schemaVersion: 1, + description: 'Opportunity became a hot project.', + trigger: 'Hot project flag persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType'], + ordering: 'Operational priority signal.', + consumers: ['Timeline', 'Dashboard', 'My Day', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'opportunity.hot-project-cleared', + owner: 'Opportunity', + schemaVersion: 1, + description: 'Opportunity hot project flag was cleared.', + trigger: 'Hot project clear mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType'], + ordering: 'Operational priority signal.', + consumers: ['Timeline', 'Dashboard', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'opportunity.won', + owner: 'Opportunity', + schemaVersion: 1, + description: 'Opportunity was won.', + trigger: 'Opportunity won outcome persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Terminal positive opportunity outcome.', + consumers: ['Timeline', 'Dashboard', 'Forecast', 'Relationship Health', 'Executive Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'opportunity.lost', + owner: 'Opportunity', + schemaVersion: 1, + description: 'Opportunity was lost.', + trigger: 'Opportunity lost outcome persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Terminal negative opportunity outcome.', + consumers: ['Timeline', 'Dashboard', 'Forecast', 'Relationship Health', 'Executive Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'opportunity.reopened', + owner: 'Opportunity', + schemaVersion: 1, + description: 'Opportunity was reopened.', + trigger: 'Opportunity reopen mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType'], + ordering: 'Reopens terminal outcome into active lifecycle.', + consumers: ['Timeline', 'Dashboard', 'Forecast', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'quotation.created', + owner: 'Quotation', + schemaVersion: 1, + description: 'Quotation was created.', + trigger: 'Quotation create mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'First quotation event.', + consumers: ['Timeline', 'Dashboard', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'quotation.revised', + owner: 'Quotation', + schemaVersion: 1, + description: 'Quotation revision was created.', + trigger: 'Quotation revision mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Follows original quotation creation.', + consumers: ['Timeline', 'Dashboard', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'quotation.sent', + owner: 'Quotation', + schemaVersion: 1, + description: 'Quotation was sent to customer.', + trigger: 'Quotation send state persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Occurs after quotation exists.', + consumers: ['Timeline', 'Notification', 'Dashboard', 'My Day'], + compatibilityAliases: [] + }, + { + eventType: 'quotation.pending-approval', + owner: 'Quotation', + schemaVersion: 1, + description: 'Quotation entered pending approval state.', + trigger: 'Approval submission updated quotation state.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Precedes approval.requested in approval submission correlation.', + consumers: ['Timeline', 'Notification', 'Dashboard', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'quotation.approved', + owner: 'Quotation', + schemaVersion: 1, + description: 'Quotation was approved.', + trigger: 'Approval completed and quotation state synced.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Follows approval.completed.', + consumers: ['Timeline', 'Notification', 'Dashboard', 'My Day'], + compatibilityAliases: [] + }, + { + eventType: 'quotation.rejected', + owner: 'Quotation', + schemaVersion: 1, + description: 'Quotation approval was rejected.', + trigger: 'Approval rejection and quotation state sync persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Follows approval rejection event.', + consumers: ['Timeline', 'Notification', 'Dashboard', 'My Day'], + compatibilityAliases: [] + }, + { + eventType: 'quotation.returned', + owner: 'Quotation', + schemaVersion: 1, + description: 'Quotation approval was returned for revision.', + trigger: 'Approval return and quotation state sync persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Follows approval.returned.', + consumers: ['Timeline', 'Notification', 'Dashboard', 'My Day'], + compatibilityAliases: [] + }, + { + eventType: 'quotation.cancelled', + owner: 'Quotation', + schemaVersion: 1, + description: 'Quotation was cancelled.', + trigger: 'Quotation cancel mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Terminal quotation event.', + consumers: ['Timeline', 'Dashboard', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'approval.requested', + owner: 'Approval', + schemaVersion: 1, + description: 'Approval request was created.', + trigger: 'Approval request persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['pricingSensitive'], + ordering: 'Follows source entity pending-approval event when applicable.', + consumers: ['Timeline', 'Notification', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'approval.step-approved', + owner: 'Approval', + schemaVersion: 1, + description: 'An approval step was approved.', + trigger: 'Approval action persisted with approve.', + payloadSchema: anyRecordPayload, + visibilityFields: ['pricingSensitive'], + ordering: 'Part of approval action chain.', + consumers: ['Timeline', 'Notification', 'Manager Workspace'], + compatibilityAliases: ['approval.step.approved'] + }, + { + eventType: 'approval.step-rejected', + owner: 'Approval', + schemaVersion: 1, + description: 'An approval step was rejected.', + trigger: 'Approval action persisted with reject.', + payloadSchema: anyRecordPayload, + visibilityFields: ['pricingSensitive'], + ordering: 'Terminal approval rejection chain.', + consumers: ['Timeline', 'Notification', 'Manager Workspace'], + compatibilityAliases: ['approval.step.rejected'] + }, + { + eventType: 'approval.returned', + owner: 'Approval', + schemaVersion: 1, + description: 'Approval was returned for revision.', + trigger: 'Approval return action persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['pricingSensitive'], + ordering: 'Terminal approval returned chain.', + consumers: ['Timeline', 'Notification', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'approval.completed', + owner: 'Approval', + schemaVersion: 1, + description: 'Approval workflow completed successfully.', + trigger: 'Final approval step persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['pricingSensitive'], + ordering: 'Precedes source entity approved event.', + consumers: ['Timeline', 'Notification', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'approval.escalated', + owner: 'Approval', + schemaVersion: 1, + description: 'Approval was escalated.', + trigger: 'Approval automation escalation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['pricingSensitive'], + ordering: 'Operational alert event.', + consumers: ['Timeline', 'Notification', 'Manager Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'po.received', + owner: 'Opportunity', + schemaVersion: 1, + description: 'Purchase order evidence was received.', + trigger: 'PO receive mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: ['productType', 'pricingSensitive'], + ordering: 'Precedes opportunity.won in PO win flow.', + consumers: ['Timeline', 'Dashboard', 'Forecast', 'Executive Workspace'], + compatibilityAliases: [] + }, + { + eventType: 'customer.created', + owner: 'Customer', + schemaVersion: 1, + description: 'Customer was created.', + trigger: 'Customer create mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: [], + ordering: 'First customer event.', + consumers: ['Timeline', 'Relationship Health', 'Dashboard'], + compatibilityAliases: [] + }, + { + eventType: 'customer.updated', + owner: 'Customer', + schemaVersion: 1, + description: 'Customer core profile changed.', + trigger: 'Customer update mutation persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: [], + ordering: 'Customer profile lifecycle event.', + consumers: ['Timeline', 'Relationship Health'], + compatibilityAliases: [] + }, + { + eventType: 'customer.owner-changed', + owner: 'Customer', + schemaVersion: 1, + description: 'Customer owner changed.', + trigger: 'Customer owner assignment persisted.', + payloadSchema: anyRecordPayload, + visibilityFields: [], + ordering: 'Ownership event.', + consumers: ['Timeline', 'Notification', 'Manager Workspace'], + compatibilityAliases: [] + } +] satisfies BusinessEventDefinition>[]; + +export const BUSINESS_EVENT_REGISTRY = new Map( + EVENT_DEFINITIONS.map((definition) => [definition.eventType, definition]) +); + +export const BUSINESS_EVENT_ALIAS_REGISTRY = new Map( + EVENT_DEFINITIONS.flatMap((definition) => + definition.compatibilityAliases.map((alias) => [alias, definition.eventType] as const) + ) +); + +export function listBusinessEventDefinitions(): BusinessEventDefinition[] { + return [...BUSINESS_EVENT_REGISTRY.values()]; +} + +export function resolveBusinessEventType(eventType: string): string { + return BUSINESS_EVENT_ALIAS_REGISTRY.get(eventType) ?? eventType; +} + +export function getBusinessEventDefinition( + eventType: string +): BusinessEventDefinition | undefined { + return BUSINESS_EVENT_REGISTRY.get(resolveBusinessEventType(eventType)); +} + +export function assertRegisteredBusinessEvent(eventType: string): BusinessEventDefinition { + const definition = getBusinessEventDefinition(eventType); + + if (!definition) { + throw new Error(`Unregistered business event type: ${eventType}`); + } + + return definition; +} + +export function assertBusinessEventPayload(eventType: string, payload: unknown): void { + const definition = assertRegisteredBusinessEvent(eventType); + + if (!definition.payloadSchema(payload)) { + throw new Error(`Invalid payload for business event type: ${eventType}`); + } +} diff --git a/src/features/foundation/business-events/replay.test.ts b/src/features/foundation/business-events/replay.test.ts new file mode 100644 index 0000000..348f5af --- /dev/null +++ b/src/features/foundation/business-events/replay.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { assertReplayRequestIsScoped, createReplayPlan } from './replay.ts'; +import type { ReplaySource } from './types.ts'; + +const source: ReplaySource = { + sourceName: 'test-source', + async *listEvents() {} +}; + +test('replay request requires organization scope and valid dates', () => { + assert.doesNotThrow(() => + assertReplayRequestIsScoped({ + organizationId: 'org-1', + occurredFrom: '2026-07-13T00:00:00.000Z' + }) + ); + assert.throws(() => assertReplayRequestIsScoped({ organizationId: '' }), /organizationId/); + assert.throws( + () => assertReplayRequestIsScoped({ organizationId: 'org-1', occurredTo: 'bad-date' }), + /valid ISO/ + ); +}); + +test('replay plan is dry-run and idempotent by default', () => { + const plan = createReplayPlan({ organizationId: 'org-1' }, source); + + assert.equal(plan.dryRun, true); + assert.equal(plan.idempotencyRequired, true); + assert.equal(plan.ordering, 'occurred_at_then_event_id'); +}); diff --git a/src/features/foundation/business-events/replay.ts b/src/features/foundation/business-events/replay.ts new file mode 100644 index 0000000..754153c --- /dev/null +++ b/src/features/foundation/business-events/replay.ts @@ -0,0 +1,25 @@ +import type { ReplayPlan, ReplayRequest, ReplaySource } from './types.ts'; + +export function createReplayPlan(request: ReplayRequest, source: ReplaySource): ReplayPlan { + return { + request, + sourceName: source.sourceName, + ordering: 'occurred_at_then_event_id', + dryRun: request.dryRun ?? true, + idempotencyRequired: true + }; +} + +export function assertReplayRequestIsScoped(request: ReplayRequest): void { + if (!request.organizationId) { + throw new Error('Replay requires organizationId'); + } + + if (request.occurredFrom && Number.isNaN(new Date(request.occurredFrom).getTime())) { + throw new Error('Replay occurredFrom must be a valid ISO date'); + } + + if (request.occurredTo && Number.isNaN(new Date(request.occurredTo).getTime())) { + throw new Error('Replay occurredTo must be a valid ISO date'); + } +} diff --git a/src/features/foundation/business-events/types.ts b/src/features/foundation/business-events/types.ts new file mode 100644 index 0000000..44589a7 --- /dev/null +++ b/src/features/foundation/business-events/types.ts @@ -0,0 +1,118 @@ +export interface BusinessEventRecordRef { + type: string; + id: string; +} + +export interface BusinessEventVisibility { + productType?: string | null; + pricingSensitive?: boolean; + internalOnly?: boolean; +} + +export interface BusinessEvent> { + eventId: string; + eventType: string; + schemaVersion: number; + organizationId: string; + branchId?: string | null; + entityType: string; + entityId: string; + primaryRecord: BusinessEventRecordRef; + relatedRecords: BusinessEventRecordRef[]; + actorUserId: string | null; + occurredAt: string; + correlationId: string; + causationId?: string | null; + visibility: BusinessEventVisibility; + payload: TPayload; + metadata: Record; +} + +export interface BusinessEventDefinition { + eventType: string; + owner: string; + schemaVersion: number; + description: string; + trigger: string; + payloadSchema: (payload: unknown) => payload is TPayload; + visibilityFields: Array; + ordering: string; + consumers: string[]; + compatibilityAliases: string[]; + deprecated?: boolean; + replacementEventType?: string; +} + +export interface BusinessEventConsumer { + readonly consumerName: string; + readonly supportedEventTypes: string[]; + readonly supportedSchemaVersions?: Record; + handle(event: TEvent): Promise; +} + +export interface BusinessEventSubscriberResult { + consumerName: string; + eventType: string; + status: 'processed' | 'duplicate' | 'failed' | 'unsupported_version'; + durationMs: number; + error?: string; +} + +export interface BusinessEventDispatchResult { + eventId: string; + eventType: string; + status: 'dispatched' | 'no_subscribers' | 'failed'; + durationMs: number; + subscriberResults: BusinessEventSubscriberResult[]; +} + +export interface BusinessEventDispatcher { + subscribe(eventType: string, consumer: BusinessEventConsumer): () => void; + unsubscribe(eventType: string, consumerName: string): void; + dispatch(event: BusinessEvent): Promise; +} + +export interface BusinessEventPublisher { + publish(event: BusinessEvent): Promise; + publishMany(events: BusinessEvent[]): Promise; +} + +export interface BusinessEventCreateInput> { + eventType: string; + organizationId: string; + branchId?: string | null; + entityType: string; + entityId: string; + primaryRecord: BusinessEventRecordRef; + relatedRecords?: BusinessEventRecordRef[]; + actorUserId?: string | null; + occurredAt?: string; + correlationId?: string; + causationId?: string | null; + visibility?: BusinessEventVisibility; + payload: TPayload; + metadata?: Record; +} + +export type ReplayRequest = { + organizationId: string; + eventTypes?: string[]; + entityType?: string; + entityId?: string; + occurredFrom?: string; + occurredTo?: string; + dryRun?: boolean; +}; + +export interface ReplaySource { + sourceName: string; + listEvents(request: ReplayRequest): AsyncIterable; +} + +export interface ReplayPlan { + request: ReplayRequest; + sourceName: string; + ordering: 'occurred_at_then_event_id'; + dryRun: boolean; + idempotencyRequired: true; +}