task ep.1.4.1

This commit is contained in:
phaichayon
2026-07-13 13:08:00 +07:00
parent 06af36156f
commit bba4c8d97d
18 changed files with 9388 additions and 192 deletions

View File

@@ -1,9 +1,10 @@
import {
createBusinessEvent,
publishBusinessEvent,
type BusinessEvent
createBusinessEvent,
publishBusinessEvent,
type BusinessEvent
} from '../../../foundation/business-events/index.ts';
import type { ActivityRecord } from '../api/types.ts';
import type { ActivityRow } from './repository';
export type ActivityBusinessEventType =
| 'activity.created'
@@ -16,13 +17,90 @@ export type ActivityBusinessEventType =
| 'activity.deleted';
interface PublishActivityBusinessEventInput {
eventType: ActivityBusinessEventType;
activity: ActivityRecord;
eventType: ActivityBusinessEventType;
activity: ActivityRecord;
actorUserId: string;
correlationId?: string;
causationId?: string | null;
payload?: Record<string, unknown>;
previousActivity?: ActivityRecord | null;
previousActivity?: ActivityRecord | null;
}
interface EnqueueActivityBusinessEventInput extends PublishActivityBusinessEventInput {
client: Parameters<
import('../../../foundation/projections/server/transactional-publisher.ts').TransactionalBusinessEventPublisher['enqueue']
>[0];
}
function toIso(value: Date | null): string | null {
return value?.toISOString() ?? null;
}
function toMetadata(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function activityRowToBusinessEventRecord(row: ActivityRow): ActivityRecord {
const metadata = toMetadata(row.metadata);
const scheduledStartAt = toIso(row.scheduledStartAt);
const dueAt = toIso(row.dueAt);
const completedAt = toIso(row.completedAt);
const cancelledAt = toIso(row.cancelledAt);
const skippedAt = toIso(row.skippedAt);
return {
id: row.id,
organizationId: row.organizationId,
primaryEntityType: row.primaryEntityType as ActivityRecord['primaryEntityType'],
primaryEntityId: row.primaryEntityId,
primaryRecordLabel: row.primaryRecordLabel,
relatedRecords: Array.isArray(row.relatedRecords)
? (row.relatedRecords as ActivityRecord['relatedRecords'])
: [],
customerId: row.customerId,
contactId: row.contactId,
leadId: row.leadId,
opportunityId: row.opportunityId,
quotationId: row.quotationId,
activityType: row.activityType as ActivityRecord['activityType'],
subject: row.subject,
description: row.description,
ownerId: row.ownerId,
assignedToId: row.assignedToId,
scheduledStartAt,
scheduledEndAt: toIso(row.scheduledEndAt),
dueAt,
completedAt,
cancelledAt,
skippedAt,
status: row.status as ActivityRecord['status'],
priority: row.priority as ActivityRecord['priority'],
outcomeSummary: row.outcomeSummary,
cancellationReason: row.cancellationReason,
skipReason: row.skipReason,
nextAction: row.nextAction,
branchId: row.branchId,
productType: row.productType,
isInternalOnly: row.isInternalOnly,
isPricingSensitive: row.isPricingSensitive,
parentActivityId: row.parentActivityId,
metadata,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: toIso(row.deletedAt),
activityCode: typeof metadata?.activityCode === 'string' ? metadata.activityCode : null,
effectiveStatus: row.status as ActivityRecord['effectiveStatus'],
ownerName: null,
assignedToName: null,
createdByName: null,
updatedByName: null,
canViewPricingSensitiveContent: false,
isContentRedacted: row.isPricingSensitive || row.isInternalOnly
};
}
function compactRelatedRecords(activity: ActivityRecord) {
@@ -112,3 +190,15 @@ export async function publishActivityBusinessEvent(
return event;
}
export async function enqueueActivityBusinessEvent(
input: EnqueueActivityBusinessEventInput
): Promise<BusinessEvent> {
const { transactionalBusinessEventPublisher } = await import(
'../../../foundation/projections/server/transactional-publisher.ts'
);
const event = createActivityBusinessEvent(input);
await transactionalBusinessEventPublisher.enqueue(input.client, event);
return event;
}

View File

@@ -5,6 +5,7 @@ import type { CrmActivityEntityType } from '@/features/crm/activity/types';
export type ActivityRow = typeof crmActivities.$inferSelect;
export type InsertActivityRow = typeof crmActivities.$inferInsert;
type ActivityDbClient = Pick<typeof db, 'insert' | 'update' | 'select'>;
export interface ActivityListQuery {
organizationId: string;
@@ -24,8 +25,11 @@ export interface ActivityContextQuery {
limit?: number;
}
export async function insertActivity(values: InsertActivityRow): Promise<ActivityRow> {
const [created] = await db.insert(crmActivities).values(values).returning();
export async function insertActivity(
values: InsertActivityRow,
client: ActivityDbClient = db
): Promise<ActivityRow> {
const [created] = await client.insert(crmActivities).values(values).returning();
return created;
}
@@ -33,9 +37,10 @@ export async function insertActivity(values: InsertActivityRow): Promise<Activit
export async function updateActivityRow(
id: string,
organizationId: string,
values: Partial<InsertActivityRow>
values: Partial<InsertActivityRow>,
client: ActivityDbClient = db
): Promise<ActivityRow> {
const [updated] = await db
const [updated] = await client
.update(crmActivities)
.set(values)
.where(and(eq(crmActivities.id, id), eq(crmActivities.organizationId, organizationId)))

View File

@@ -46,7 +46,10 @@ import type {
} from '../api/types';
import { getActivityRow, insertActivity, listActivityRows, updateActivityRow } from './repository';
import { hydrateActivityRecords } from './read-model';
import { publishActivityBusinessEvent } from './business-events';
import {
activityRowToBusinessEventRecord,
enqueueActivityBusinessEvent
} from './business-events';
type ActivityAccessContext = CrmSecurityContext;
@@ -608,63 +611,73 @@ export async function createActivity(
}
};
const created = await insertActivity({
id: crypto.randomUUID(),
organizationId,
primaryEntityType: values.primaryEntityType,
primaryEntityId: values.primaryEntityId,
primaryRecordLabel: values.primaryRecordLabel,
relatedRecords: values.relatedRecords,
customerId: values.customerId,
contactId: values.contactId,
leadId: values.leadId,
opportunityId: values.opportunityId,
quotationId: values.quotationId,
activityType: values.activityType,
subject: values.subject,
description: values.description,
ownerId: values.ownerId,
assignedToId: values.assignedToId,
scheduledStartAt: values.scheduledStartAt,
scheduledEndAt: values.scheduledEndAt,
dueAt: values.dueAt,
completedAt: values.status === 'completed' ? new Date() : null,
cancelledAt: values.status === 'cancelled' ? new Date() : null,
skippedAt: values.status === 'skipped' ? new Date() : null,
status: values.status,
priority: values.priority,
outcomeSummary: null,
cancellationReason: null,
skipReason: null,
nextAction: values.nextAction,
branchId: values.branchId,
productType: values.productType,
isInternalOnly: values.isInternalOnly,
isPricingSensitive: values.isPricingSensitive,
parentActivityId: null,
metadata,
createdBy: userId,
updatedBy: userId
const created = await db.transaction(async (tx) => {
const createdRow = await insertActivity(
{
id: crypto.randomUUID(),
organizationId,
primaryEntityType: values.primaryEntityType,
primaryEntityId: values.primaryEntityId,
primaryRecordLabel: values.primaryRecordLabel,
relatedRecords: values.relatedRecords,
customerId: values.customerId,
contactId: values.contactId,
leadId: values.leadId,
opportunityId: values.opportunityId,
quotationId: values.quotationId,
activityType: values.activityType,
subject: values.subject,
description: values.description,
ownerId: values.ownerId,
assignedToId: values.assignedToId,
scheduledStartAt: values.scheduledStartAt,
scheduledEndAt: values.scheduledEndAt,
dueAt: values.dueAt,
completedAt: values.status === 'completed' ? new Date() : null,
cancelledAt: values.status === 'cancelled' ? new Date() : null,
skippedAt: values.status === 'skipped' ? new Date() : null,
status: values.status,
priority: values.priority,
outcomeSummary: null,
cancellationReason: null,
skipReason: null,
nextAction: values.nextAction,
branchId: values.branchId,
productType: values.productType,
isInternalOnly: values.isInternalOnly,
isPricingSensitive: values.isPricingSensitive,
parentActivityId: null,
metadata,
createdBy: userId,
updatedBy: userId
},
tx
);
const eventActivity = activityRowToBusinessEventRecord(createdRow);
const correlationId = crypto.randomUUID();
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.created',
activity: eventActivity,
actorUserId: userId,
correlationId
});
if (eventActivity.assignedToId && eventActivity.assignedToId !== eventActivity.ownerId) {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.assigned',
activity: eventActivity,
actorUserId: userId,
correlationId
});
}
return createdRow;
});
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;
}
@@ -688,8 +701,9 @@ export async function updateActivity(
await assertActivityTypeValue(organizationId, values.activityType);
const nextStatus = values.status;
await updateActivityRow(id, organizationId, {
primaryEntityType: values.primaryEntityType,
const activityEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
primaryEntityType: values.primaryEntityType,
primaryEntityId: values.primaryEntityId,
primaryRecordLabel: values.primaryRecordLabel,
relatedRecords: values.relatedRecords,
@@ -727,66 +741,76 @@ export async function updateActivity(
completedAt: nextStatus === 'completed' ? new Date() : null,
cancelledAt: nextStatus === 'cancelled' ? new Date() : null,
skippedAt: nextStatus === 'skipped' ? new Date() : null,
updatedAt: new Date(),
updatedBy: userId
});
updatedAt: new Date(),
updatedBy: userId
}, tx);
const activity = await getActivityById(id, organizationId, context);
const correlationId = crypto.randomUUID();
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
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.assignedToId !== nextActivity.assignedToId) {
await enqueueActivityBusinessEvent({
client: tx,
eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned',
activity: nextActivity,
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
current.scheduledStartAt !== nextActivity.scheduledStartAt ||
current.scheduledEndAt !== nextActivity.scheduledEndAt ||
current.dueAt !== nextActivity.dueAt
) {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.rescheduled',
activity: nextActivity,
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 !== 'in_progress' && nextActivity.status === 'in_progress') {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.started',
activity: nextActivity,
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 !== 'completed' && nextActivity.status === 'completed') {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.completed',
activity: nextActivity,
previousActivity: current,
actorUserId: userId,
correlationId
});
}
if (current.status !== 'cancelled' && activity.status === 'cancelled') {
await publishActivityBusinessEvent({
eventType: 'activity.cancelled',
activity,
previousActivity: current,
actorUserId: userId,
correlationId
});
}
if (current.status !== 'cancelled' && nextActivity.status === 'cancelled') {
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.cancelled',
activity: nextActivity,
previousActivity: current,
actorUserId: userId,
correlationId
});
}
return nextActivity;
});
const activity = await getActivityById(activityEventRecord.id, organizationId, context);
return activity;
}
@@ -802,24 +826,28 @@ export async function completeActivity(
const current = await getHydratedActivityOrThrow(id, organizationId, context);
assertTransitionAllowed(current.status, 'completed');
await updateActivityRow(id, organizationId, {
status: 'completed',
outcomeSummary: payload.outcomeSummary.trim(),
nextAction: payload.nextAction?.trim() || null,
completedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
});
const completedEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
status: 'completed',
outcomeSummary: payload.outcomeSummary.trim(),
nextAction: payload.nextAction?.trim() || null,
completedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
}, tx);
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.completed',
activity: nextActivity,
previousActivity: current,
actorUserId: userId
});
return nextActivity;
});
const activity = await getActivityById(id, organizationId, context);
await publishActivityBusinessEvent({
eventType: 'activity.completed',
activity,
previousActivity: current,
actorUserId: userId
});
return activity;
const activity = await getActivityById(completedEventRecord.id, organizationId, context);
return activity;
}
export async function cancelActivity(
@@ -833,23 +861,27 @@ export async function cancelActivity(
const current = await getHydratedActivityOrThrow(id, organizationId, context);
assertTransitionAllowed(current.status, 'cancelled');
await updateActivityRow(id, organizationId, {
status: 'cancelled',
cancellationReason: payload.cancellationReason.trim(),
cancelledAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
});
const cancelledEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
status: 'cancelled',
cancellationReason: payload.cancellationReason.trim(),
cancelledAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
}, tx);
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.cancelled',
activity: nextActivity,
previousActivity: current,
actorUserId: userId
});
return nextActivity;
});
const activity = await getActivityById(id, organizationId, context);
await publishActivityBusinessEvent({
eventType: 'activity.cancelled',
activity,
previousActivity: current,
actorUserId: userId
});
return activity;
const activity = await getActivityById(cancelledEventRecord.id, organizationId, context);
return activity;
}
export async function assignActivity(
@@ -866,23 +898,27 @@ export async function assignActivity(
await assertMembershipUserBelongsToOrganization(payload.assignedToId, organizationId);
}
await updateActivityRow(id, organizationId, {
assignedToId: payload.assignedToId,
updatedAt: new Date(),
updatedBy: userId
});
const assignedEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
assignedToId: payload.assignedToId,
updatedAt: new Date(),
updatedBy: userId
}, tx);
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
if (current.assignedToId !== nextActivity.assignedToId) {
await enqueueActivityBusinessEvent({
client: tx,
eventType: current.assignedToId ? 'activity.reassigned' : 'activity.assigned',
activity: nextActivity,
previousActivity: current,
actorUserId: userId
});
}
return nextActivity;
});
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;
const activity = await getActivityById(assignedEventRecord.id, organizationId, context);
return activity;
}
export async function rescheduleActivity(
@@ -902,9 +938,10 @@ export async function rescheduleActivity(
const nextStatus = current.status === 'draft' ? 'scheduled' : current.status;
assertTransitionAllowed(current.status, nextStatus);
await updateActivityRow(id, organizationId, {
scheduledStartAt:
payload.scheduledStartAt === undefined
const rescheduledEventRecord = await db.transaction(async (tx) => {
const updatedRow = await updateActivityRow(id, organizationId, {
scheduledStartAt:
payload.scheduledStartAt === undefined
? toDateOrNull(current.scheduledStartAt)
: toDateOrNull(payload.scheduledStartAt),
scheduledEndAt:
@@ -912,20 +949,23 @@ export async function rescheduleActivity(
? toDateOrNull(current.scheduledEndAt)
: toDateOrNull(payload.scheduledEndAt),
dueAt: payload.dueAt === undefined ? toDateOrNull(current.dueAt) : toDateOrNull(payload.dueAt),
status: nextStatus,
updatedAt: new Date(),
updatedBy: userId
});
status: nextStatus,
updatedAt: new Date(),
updatedBy: userId
}, tx);
const nextActivity = activityRowToBusinessEventRecord(updatedRow);
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.rescheduled',
activity: nextActivity,
previousActivity: current,
actorUserId: userId
});
return nextActivity;
});
const activity = await getActivityById(id, organizationId, context);
await publishActivityBusinessEvent({
eventType: 'activity.rescheduled',
activity,
previousActivity: current,
actorUserId: userId
});
return activity;
const activity = await getActivityById(rescheduledEventRecord.id, organizationId, context);
return activity;
}
export async function deleteActivity(
@@ -937,15 +977,18 @@ export async function deleteActivity(
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.delete);
const current = await getHydratedActivityOrThrow(id, organizationId, context);
await updateActivityRow(id, organizationId, {
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
});
await db.transaction(async (tx) => {
await updateActivityRow(id, organizationId, {
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
}, tx);
await publishActivityBusinessEvent({
eventType: 'activity.deleted',
activity: current,
actorUserId: userId
});
await enqueueActivityBusinessEvent({
client: tx,
eventType: 'activity.deleted',
activity: current,
actorUserId: userId
});
});
}