task ep.1.3.md

This commit is contained in:
phaichayon
2026-07-13 11:00:49 +07:00
parent e96320a24d
commit d78c56148e
18 changed files with 2944 additions and 8 deletions

View 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');
});

View 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;
}

View File

@@ -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<ActivityRecord> {
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
});
}

View 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.'
}
];

View 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');
});

View 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();

View 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';

View 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);
});

View 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]}`);
}
}

View 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);
});

View 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);
}

View 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);
}
});

View 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}`);
}
}

View 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');
});

View 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');
}
}

View 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;
}