task ep.1.3.md
This commit is contained in:
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';
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user