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