task ep.1.3.md
This commit is contained in:
@@ -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<TPayload>` 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 `<entity>.<past-tense-action>` 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.
|
||||||
1034
plans/task-ep.1.3.md
Normal file
1034
plans/task-ep.1.3.md
Normal file
File diff suppressed because it is too large
Load Diff
84
src/features/crm/activities/server/business-events.test.ts
Normal file
84
src/features/crm/activities/server/business-events.test.ts
Normal file
@@ -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> = {}): 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');
|
||||||
|
});
|
||||||
114
src/features/crm/activities/server/business-events.ts
Normal file
114
src/features/crm/activities/server/business-events.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>
|
||||||
|
) {
|
||||||
|
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<BusinessEvent> {
|
||||||
|
const event = createActivityBusinessEvent(input);
|
||||||
|
await publishBusinessEvent(event);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ import type {
|
|||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
import { getActivityRow, insertActivity, listActivityRows, updateActivityRow } from './repository';
|
import { getActivityRow, insertActivity, listActivityRows, updateActivityRow } from './repository';
|
||||||
import { hydrateActivityRecords } from './read-model';
|
import { hydrateActivityRecords } from './read-model';
|
||||||
|
import { publishActivityBusinessEvent } from './business-events';
|
||||||
|
|
||||||
type ActivityAccessContext = CrmSecurityContext;
|
type ActivityAccessContext = CrmSecurityContext;
|
||||||
|
|
||||||
@@ -646,7 +647,26 @@ export async function createActivity(
|
|||||||
updatedBy: userId
|
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(
|
export async function updateActivity(
|
||||||
@@ -711,7 +731,64 @@ export async function updateActivity(
|
|||||||
updatedBy: userId
|
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(
|
export async function completeActivity(
|
||||||
@@ -734,7 +811,15 @@ export async function completeActivity(
|
|||||||
updatedBy: userId
|
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(
|
export async function cancelActivity(
|
||||||
@@ -756,7 +841,15 @@ export async function cancelActivity(
|
|||||||
updatedBy: userId
|
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(
|
export async function assignActivity(
|
||||||
@@ -767,7 +860,7 @@ export async function assignActivity(
|
|||||||
context: ActivityAccessContext
|
context: ActivityAccessContext
|
||||||
): Promise<ActivityRecord> {
|
): Promise<ActivityRecord> {
|
||||||
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.reassign);
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.reassign);
|
||||||
await getHydratedActivityOrThrow(id, organizationId, context);
|
const current = await getHydratedActivityOrThrow(id, organizationId, context);
|
||||||
|
|
||||||
if (payload.assignedToId) {
|
if (payload.assignedToId) {
|
||||||
await assertMembershipUserBelongsToOrganization(payload.assignedToId, organizationId);
|
await assertMembershipUserBelongsToOrganization(payload.assignedToId, organizationId);
|
||||||
@@ -779,7 +872,17 @@ export async function assignActivity(
|
|||||||
updatedBy: userId
|
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(
|
export async function rescheduleActivity(
|
||||||
@@ -814,7 +917,15 @@ export async function rescheduleActivity(
|
|||||||
updatedBy: userId
|
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(
|
export async function deleteActivity(
|
||||||
@@ -824,11 +935,17 @@ export async function deleteActivity(
|
|||||||
context: ActivityAccessContext
|
context: ActivityAccessContext
|
||||||
) {
|
) {
|
||||||
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.delete);
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.delete);
|
||||||
await getHydratedActivityOrThrow(id, organizationId, context);
|
const current = await getHydratedActivityOrThrow(id, organizationId, context);
|
||||||
|
|
||||||
await updateActivityRow(id, organizationId, {
|
await updateActivityRow(id, organizationId, {
|
||||||
deletedAt: new Date(),
|
deletedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
updatedBy: userId
|
updatedBy: userId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await publishActivityBusinessEvent({
|
||||||
|
eventType: 'activity.deleted',
|
||||||
|
activity: current,
|
||||||
|
actorUserId: userId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/features/foundation/business-events/compatibility.ts
Normal file
66
src/features/foundation/business-events/compatibility.ts
Normal file
@@ -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.'
|
||||||
|
}
|
||||||
|
];
|
||||||
94
src/features/foundation/business-events/dispatcher.test.ts
Normal file
94
src/features/foundation/business-events/dispatcher.test.ts
Normal file
@@ -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> = {}): 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');
|
||||||
|
});
|
||||||
121
src/features/foundation/business-events/dispatcher.ts
Normal file
121
src/features/foundation/business-events/dispatcher.ts
Normal file
@@ -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<string, Map<string, BusinessEventConsumer>>();
|
||||||
|
private readonly processed = new Set<string>();
|
||||||
|
|
||||||
|
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<BusinessEventDispatchResult> {
|
||||||
|
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();
|
||||||
7
src/features/foundation/business-events/index.ts
Normal file
7
src/features/foundation/business-events/index.ts
Normal file
@@ -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';
|
||||||
24
src/features/foundation/business-events/matrices.test.ts
Normal file
24
src/features/foundation/business-events/matrices.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
153
src/features/foundation/business-events/matrices.ts
Normal file
153
src/features/foundation/business-events/matrices.ts
Normal file
@@ -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]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/features/foundation/business-events/publisher.test.ts
Normal file
86
src/features/foundation/business-events/publisher.test.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
}),
|
||||||
|
/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);
|
||||||
|
});
|
||||||
111
src/features/foundation/business-events/publisher.ts
Normal file
111
src/features/foundation/business-events/publisher.ts
Normal file
@@ -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<TPayload extends Record<string, unknown>>(
|
||||||
|
input: BusinessEventCreateInput<TPayload>
|
||||||
|
): BusinessEvent<TPayload> {
|
||||||
|
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<BusinessEventDispatchResult> {
|
||||||
|
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<BusinessEventDispatchResult[]> {
|
||||||
|
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<BusinessEventDispatchResult> {
|
||||||
|
return businessEventPublisher.publish(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishBusinessEvents(
|
||||||
|
events: BusinessEvent[]
|
||||||
|
): Promise<BusinessEventDispatchResult[]> {
|
||||||
|
return businessEventPublisher.publishMany(events);
|
||||||
|
}
|
||||||
32
src/features/foundation/business-events/registry.test.ts
Normal file
32
src/features/foundation/business-events/registry.test.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
508
src/features/foundation/business-events/registry.ts
Normal file
508
src/features/foundation/business-events/registry.ts
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
import type { BusinessEventDefinition } from './types.ts';
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anyRecordPayload = (payload: unknown): payload is Record<string, unknown> => 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<Record<string, unknown>>[];
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/features/foundation/business-events/replay.test.ts
Normal file
31
src/features/foundation/business-events/replay.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
25
src/features/foundation/business-events/replay.ts
Normal file
25
src/features/foundation/business-events/replay.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/features/foundation/business-events/types.ts
Normal file
118
src/features/foundation/business-events/types.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
export interface BusinessEventRecordRef {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessEventVisibility {
|
||||||
|
productType?: string | null;
|
||||||
|
pricingSensitive?: boolean;
|
||||||
|
internalOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessEvent<TPayload = Record<string, unknown>> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessEventDefinition<TPayload = unknown> {
|
||||||
|
eventType: string;
|
||||||
|
owner: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
description: string;
|
||||||
|
trigger: string;
|
||||||
|
payloadSchema: (payload: unknown) => payload is TPayload;
|
||||||
|
visibilityFields: Array<keyof BusinessEventVisibility>;
|
||||||
|
ordering: string;
|
||||||
|
consumers: string[];
|
||||||
|
compatibilityAliases: string[];
|
||||||
|
deprecated?: boolean;
|
||||||
|
replacementEventType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessEventConsumer<TEvent extends BusinessEvent = BusinessEvent> {
|
||||||
|
readonly consumerName: string;
|
||||||
|
readonly supportedEventTypes: string[];
|
||||||
|
readonly supportedSchemaVersions?: Record<string, number[]>;
|
||||||
|
handle(event: TEvent): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<BusinessEventDispatchResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessEventPublisher {
|
||||||
|
publish(event: BusinessEvent): Promise<BusinessEventDispatchResult>;
|
||||||
|
publishMany(events: BusinessEvent[]): Promise<BusinessEventDispatchResult[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessEventCreateInput<TPayload = Record<string, unknown>> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<BusinessEvent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayPlan {
|
||||||
|
request: ReplayRequest;
|
||||||
|
sourceName: string;
|
||||||
|
ordering: 'occurred_at_then_event_id';
|
||||||
|
dryRun: boolean;
|
||||||
|
idempotencyRequired: true;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user