task-ep.1.5
This commit is contained in:
@@ -625,6 +625,57 @@ export const projectionHealthSnapshots = pgTable(
|
||||
})
|
||||
);
|
||||
|
||||
export const crmTimelineProjection = pgTable(
|
||||
'crm_timeline_projection',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
branchId: text('branch_id'),
|
||||
customerId: text('customer_id'),
|
||||
leadId: text('lead_id'),
|
||||
opportunityId: text('opportunity_id'),
|
||||
quotationId: text('quotation_id'),
|
||||
activityId: text('activity_id'),
|
||||
eventId: text('event_id').notNull(),
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
timelineType: text('timeline_type').notNull(),
|
||||
timelineCategory: text('timeline_category').notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
actorId: text('actor_id'),
|
||||
actorDisplay: text('actor_display'),
|
||||
title: text('title').notNull(),
|
||||
summary: text('summary'),
|
||||
icon: text('icon').notNull(),
|
||||
color: text('color').notNull(),
|
||||
priority: text('priority').default('normal').notNull(),
|
||||
visibility: jsonb('visibility').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
sourceEvent: jsonb('source_event').notNull(),
|
||||
sourceProjectionVersion: integer('source_projection_version').default(1).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
},
|
||||
(table) => ({
|
||||
eventVersionIdx: uniqueIndex('crm_timeline_projection_event_version_idx').on(
|
||||
table.eventId,
|
||||
table.sourceProjectionVersion
|
||||
),
|
||||
organizationOccurredIdx: index('crm_timeline_projection_org_occurred_idx').on(
|
||||
table.organizationId,
|
||||
table.occurredAt
|
||||
),
|
||||
customerOccurredIdx: index('crm_timeline_projection_customer_occurred_idx').on(
|
||||
table.organizationId,
|
||||
table.customerId,
|
||||
table.occurredAt
|
||||
),
|
||||
activityEventIdx: index('crm_timeline_projection_activity_event_idx').on(
|
||||
table.activityId,
|
||||
table.eventId
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const crmCustomers = pgTable(
|
||||
'crm_customers',
|
||||
{
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { formatDateTime } from '@/lib/date-format';
|
||||
import type { ActivityRecord } from '../api/types';
|
||||
import { TimelineList } from '@/features/crm/timeline/components/timeline-list';
|
||||
import type { TimelineItem } from '@/features/crm/timeline/api/types';
|
||||
import { ActivityBadge } from './activity-badge';
|
||||
import { ActivityPriorityBadge } from './activity-priority-badge';
|
||||
import { ActivityStatusBadge } from './activity-status-badge';
|
||||
@@ -15,7 +17,13 @@ function Field({ label, value }: { label: string; value: string | null | undefin
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityDetail({ activity }: { activity: ActivityRecord | null }) {
|
||||
export function ActivityDetail({
|
||||
activity,
|
||||
timelineItems
|
||||
}: {
|
||||
activity: ActivityRecord | null;
|
||||
timelineItems?: TimelineItem[];
|
||||
}) {
|
||||
if (!activity) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -27,6 +35,7 @@ export function ActivityDetail({ activity }: { activity: ActivityRecord | null }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Card>
|
||||
<CardHeader className='space-y-3'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
@@ -61,5 +70,14 @@ export function ActivityDetail({ activity }: { activity: ActivityRecord | null }
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{timelineItems ? (
|
||||
<TimelineList
|
||||
items={timelineItems}
|
||||
title='Activity Timeline'
|
||||
description='Business history generated from Activity events.'
|
||||
emptyMessage='No timeline entries recorded for this activity yet.'
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
74
src/features/crm/timeline/api/types.ts
Normal file
74
src/features/crm/timeline/api/types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type TimelineCategory =
|
||||
| 'activity'
|
||||
| 'communication'
|
||||
| 'meeting'
|
||||
| 'site_survey'
|
||||
| 'approval'
|
||||
| 'quotation'
|
||||
| 'revision'
|
||||
| 'assignment'
|
||||
| 'status'
|
||||
| 'forecast'
|
||||
| 'po'
|
||||
| 'customer'
|
||||
| 'system';
|
||||
|
||||
export type TimelinePriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
|
||||
export interface TimelineVisibility {
|
||||
productType?: string | null;
|
||||
pricingSensitive: boolean;
|
||||
internalOnly: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineItem {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
branchId: string | null;
|
||||
customerId: string | null;
|
||||
leadId: string | null;
|
||||
opportunityId: string | null;
|
||||
quotationId: string | null;
|
||||
activityId: string | null;
|
||||
eventId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
timelineType: string;
|
||||
timelineCategory: TimelineCategory;
|
||||
occurredAt: string;
|
||||
actorId: string | null;
|
||||
actorDisplay: string | null;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
icon: string;
|
||||
color: string;
|
||||
priority: TimelinePriority;
|
||||
visibility: TimelineVisibility;
|
||||
metadata: Record<string, unknown> | null;
|
||||
sourceProjectionVersion: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TimelineListFilters {
|
||||
organizationId: string;
|
||||
customerId?: string;
|
||||
leadId?: string;
|
||||
opportunityId?: string;
|
||||
quotationId?: string;
|
||||
activityId?: string;
|
||||
eventId?: string;
|
||||
category?: TimelineCategory;
|
||||
actorId?: string;
|
||||
search?: string;
|
||||
occurredFrom?: string;
|
||||
occurredTo?: string;
|
||||
includeInternalOnly?: boolean;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface TimelineListResponse {
|
||||
items: TimelineItem[];
|
||||
nextCursor: string | null;
|
||||
totalItems: number;
|
||||
}
|
||||
162
src/features/crm/timeline/components/timeline-list.tsx
Normal file
162
src/features/crm/timeline/components/timeline-list.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { formatDateTime } from '@/lib/date-format';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TimelineItem } from '../api/types';
|
||||
|
||||
interface TimelineListProps {
|
||||
items: TimelineItem[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
activity: 'Activity',
|
||||
communication: 'Communication',
|
||||
meeting: 'Meeting',
|
||||
site_survey: 'Site Survey',
|
||||
approval: 'Approval',
|
||||
quotation: 'Quotation',
|
||||
revision: 'Revision',
|
||||
assignment: 'Assignment',
|
||||
status: 'Status',
|
||||
forecast: 'Forecast',
|
||||
po: 'PO',
|
||||
customer: 'Customer',
|
||||
system: 'System'
|
||||
};
|
||||
|
||||
const toneClassNames: Record<string, string> = {
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300',
|
||||
warning: 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300',
|
||||
danger: 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300',
|
||||
info: 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-300',
|
||||
muted: 'border-muted bg-muted/40 text-muted-foreground',
|
||||
neutral: 'border-border bg-background text-foreground'
|
||||
};
|
||||
|
||||
export function TimelineList({
|
||||
items,
|
||||
title = 'Timeline',
|
||||
description = 'Business-readable history generated from events and approved adapters.',
|
||||
emptyMessage = 'No timeline items yet.',
|
||||
className
|
||||
}: TimelineListProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</div>
|
||||
<Badge variant='outline'>{items.length} items</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!items.length ? (
|
||||
<TimelineEmpty message={emptyMessage} />
|
||||
) : (
|
||||
<div className='relative space-y-0'>
|
||||
<div className='bg-border absolute bottom-3 left-4 top-3 w-px' aria-hidden='true' />
|
||||
{items.map((item) => (
|
||||
<TimelineCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineCard({ item }: { item: TimelineItem }) {
|
||||
const Icon = iconForItem(item.icon);
|
||||
const toneClassName = toneClassNames[item.color] ?? toneClassNames.neutral;
|
||||
|
||||
return (
|
||||
<article className='relative flex gap-4 pb-5 last:pb-0'>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border shadow-sm',
|
||||
toneClassName
|
||||
)}
|
||||
aria-hidden='true'
|
||||
>
|
||||
<Icon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0 flex-1 rounded-xl border bg-card/80 p-4 shadow-sm transition-colors hover:bg-muted/30'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='min-w-0 space-y-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h3 className='text-sm font-semibold'>{item.title}</h3>
|
||||
<Badge variant='secondary'>
|
||||
{categoryLabels[item.timelineCategory] ?? item.timelineCategory}
|
||||
</Badge>
|
||||
{item.visibility.internalOnly ? <Badge variant='outline'>Internal</Badge> : null}
|
||||
{item.visibility.pricingSensitive ? <Badge variant='outline'>Pricing guarded</Badge> : null}
|
||||
</div>
|
||||
{item.summary ? (
|
||||
<p className='text-muted-foreground text-sm leading-6'>{item.summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<time className='text-muted-foreground shrink-0 text-xs' dateTime={item.occurredAt}>
|
||||
{formatDateTime(item.occurredAt)}
|
||||
</time>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-3 flex flex-wrap gap-3 text-xs'>
|
||||
<span>{item.actorDisplay ?? item.actorId ?? 'System'}</span>
|
||||
<span>{item.entityType}:{item.entityId}</span>
|
||||
<span>{item.timelineType}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineEmpty({ message }: { message: string }) {
|
||||
return (
|
||||
<div className='text-muted-foreground rounded-xl border border-dashed p-8 text-center text-sm'>
|
||||
<Icons.clock className='mx-auto mb-3 h-8 w-8 opacity-50' />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className='h-5 w-40' />
|
||||
<Skeleton className='h-4 w-72' />
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className='flex gap-4'>
|
||||
<Skeleton className='h-8 w-8 rounded-full' />
|
||||
<div className='flex-1 space-y-2 rounded-xl border p-4'>
|
||||
<Skeleton className='h-4 w-44' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
<Skeleton className='h-3 w-60' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForItem(icon: string) {
|
||||
if (icon === 'check') return Icons.check;
|
||||
if (icon === 'close') return Icons.close;
|
||||
if (icon === 'calendar') return Icons.calendar;
|
||||
if (icon === 'user') return Icons.user;
|
||||
if (icon === 'trash') return Icons.trash;
|
||||
if (icon === 'clock') return Icons.clock;
|
||||
return Icons.info;
|
||||
}
|
||||
2
src/features/crm/timeline/index.ts
Normal file
2
src/features/crm/timeline/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api/types';
|
||||
export * from './components/timeline-list';
|
||||
67
src/features/crm/timeline/server/builder.test.ts
Normal file
67
src/features/crm/timeline/server/builder.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBusinessEvent } from '../../../foundation/business-events/publisher.ts';
|
||||
import { createTimelineProjectionConsumer } from './consumer.ts';
|
||||
import { buildTimelineItemFromEvent } from './builder.ts';
|
||||
import type { TimelineItem } from '../api/types.ts';
|
||||
|
||||
function activityCompletedEvent() {
|
||||
return createBusinessEvent({
|
||||
eventType: 'activity.completed',
|
||||
organizationId: 'org-1',
|
||||
branchId: 'branch-1',
|
||||
entityType: 'activity',
|
||||
entityId: 'activity-1',
|
||||
primaryRecord: { type: 'opportunity', id: 'opp-1' },
|
||||
relatedRecords: [
|
||||
{ type: 'customer', id: 'customer-1' },
|
||||
{ type: 'quotation', id: 'quotation-1' }
|
||||
],
|
||||
actorUserId: 'user-1',
|
||||
occurredAt: '2026-07-13T00:00:00.000Z',
|
||||
correlationId: 'corr-1',
|
||||
visibility: {
|
||||
productType: 'product-a',
|
||||
pricingSensitive: true,
|
||||
internalOnly: false
|
||||
},
|
||||
payload: {
|
||||
activityId: 'activity-1',
|
||||
subject: 'Completed site survey',
|
||||
outcomeSummary: 'Customer requested revised quotation.',
|
||||
nextAction: 'Prepare revision',
|
||||
priority: 'high',
|
||||
contentRedacted: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('timeline builder maps activity completed into business history item', () => {
|
||||
const item = buildTimelineItemFromEvent(activityCompletedEvent());
|
||||
|
||||
assert.equal(item.title, 'Activity completed');
|
||||
assert.equal(item.timelineCategory, 'activity');
|
||||
assert.equal(item.customerId, 'customer-1');
|
||||
assert.equal(item.opportunityId, null);
|
||||
assert.equal(item.quotationId, 'quotation-1');
|
||||
assert.equal(item.activityId, 'activity-1');
|
||||
assert.equal(item.priority, 'high');
|
||||
assert.equal(item.visibility.pricingSensitive, true);
|
||||
assert.match(item.summary ?? '', /Customer requested revised quotation/);
|
||||
});
|
||||
|
||||
test('timeline consumer persists built item through injected writer', async () => {
|
||||
const persisted: TimelineItem[] = [];
|
||||
const consumer = createTimelineProjectionConsumer({
|
||||
async persist(item) {
|
||||
persisted.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await consumer.handle(activityCompletedEvent());
|
||||
|
||||
assert.equal(result.status, 'processed');
|
||||
assert.equal(result.sideEffectCount, 1);
|
||||
assert.equal(persisted.length, 1);
|
||||
assert.equal(persisted[0]?.timelineType, 'activity.completed');
|
||||
});
|
||||
207
src/features/crm/timeline/server/builder.ts
Normal file
207
src/features/crm/timeline/server/builder.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import type { BusinessEvent } from '../../../foundation/business-events/types.ts';
|
||||
import type { TimelineCategory, TimelineItem, TimelinePriority } from '../api/types';
|
||||
|
||||
export const TIMELINE_PROJECTION_VERSION = 1;
|
||||
|
||||
interface TimelineEventMapping {
|
||||
title: string;
|
||||
category: TimelineCategory;
|
||||
icon: string;
|
||||
color: string;
|
||||
priority?: TimelinePriority;
|
||||
summary?: (payload: Record<string, unknown>) => string | null;
|
||||
}
|
||||
|
||||
const ACTIVITY_EVENT_MAPPINGS: Record<string, TimelineEventMapping> = {
|
||||
'activity.created': {
|
||||
title: 'Activity created',
|
||||
category: 'activity',
|
||||
icon: 'calendar',
|
||||
color: 'neutral',
|
||||
summary: (payload) => readableJoin([text(payload.subject), text(payload.description)])
|
||||
},
|
||||
'activity.assigned': {
|
||||
title: 'Activity assigned',
|
||||
category: 'assignment',
|
||||
icon: 'user',
|
||||
color: 'info',
|
||||
summary: (payload) => assignedSummary(payload)
|
||||
},
|
||||
'activity.reassigned': {
|
||||
title: 'Activity reassigned',
|
||||
category: 'assignment',
|
||||
icon: 'user',
|
||||
color: 'warning',
|
||||
summary: (payload) => assignedSummary(payload)
|
||||
},
|
||||
'activity.started': {
|
||||
title: 'Activity started',
|
||||
category: 'status',
|
||||
icon: 'clock',
|
||||
color: 'info',
|
||||
summary: (payload) => text(payload.subject)
|
||||
},
|
||||
'activity.rescheduled': {
|
||||
title: 'Activity rescheduled',
|
||||
category: 'activity',
|
||||
icon: 'calendar',
|
||||
color: 'warning',
|
||||
summary: (payload) =>
|
||||
readableJoin([text(payload.subject), scheduleSummary(payload)])
|
||||
},
|
||||
'activity.completed': {
|
||||
title: 'Activity completed',
|
||||
category: 'activity',
|
||||
icon: 'check',
|
||||
color: 'success',
|
||||
priority: 'high',
|
||||
summary: (payload) =>
|
||||
readableJoin([text(payload.subject), text(payload.outcomeSummary), text(payload.nextAction)])
|
||||
},
|
||||
'activity.cancelled': {
|
||||
title: 'Activity cancelled',
|
||||
category: 'activity',
|
||||
icon: 'close',
|
||||
color: 'danger',
|
||||
summary: (payload) => readableJoin([text(payload.subject), text(payload.cancellationReason)])
|
||||
},
|
||||
'activity.deleted': {
|
||||
title: 'Activity deleted',
|
||||
category: 'system',
|
||||
icon: 'trash',
|
||||
color: 'muted',
|
||||
summary: (payload) => text(payload.subject)
|
||||
}
|
||||
};
|
||||
|
||||
const GENERIC_EVENT_MAPPING: TimelineEventMapping = {
|
||||
title: 'Business event recorded',
|
||||
category: 'system',
|
||||
icon: 'info',
|
||||
color: 'neutral'
|
||||
};
|
||||
|
||||
export function buildTimelineItemFromEvent(event: BusinessEvent): TimelineItem {
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
const mapping = ACTIVITY_EVENT_MAPPINGS[event.eventType] ?? genericMapping(event.eventType);
|
||||
const entityRefs = resolveEntityRefs(event, payload);
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: event.organizationId,
|
||||
branchId: event.branchId ?? null,
|
||||
customerId: entityRefs.customerId,
|
||||
leadId: entityRefs.leadId,
|
||||
opportunityId: entityRefs.opportunityId,
|
||||
quotationId: entityRefs.quotationId,
|
||||
activityId: entityRefs.activityId,
|
||||
eventId: event.eventId,
|
||||
entityType: event.entityType,
|
||||
entityId: event.entityId,
|
||||
timelineType: event.eventType,
|
||||
timelineCategory: mapping.category,
|
||||
occurredAt: event.occurredAt,
|
||||
actorId: event.actorUserId,
|
||||
actorDisplay: null,
|
||||
title: mapping.title,
|
||||
summary: mapping.summary?.(payload) ?? text(payload.subject) ?? null,
|
||||
icon: mapping.icon,
|
||||
color: mapping.color,
|
||||
priority: mapping.priority ?? priorityFromPayload(payload),
|
||||
visibility: {
|
||||
productType: event.visibility.productType ?? null,
|
||||
pricingSensitive: event.visibility.pricingSensitive ?? false,
|
||||
internalOnly: event.visibility.internalOnly ?? false
|
||||
},
|
||||
metadata: {
|
||||
primaryRecord: event.primaryRecord,
|
||||
relatedRecords: event.relatedRecords,
|
||||
correlationId: event.correlationId,
|
||||
causationId: event.causationId ?? null,
|
||||
contentRedacted: Boolean(payload.contentRedacted)
|
||||
},
|
||||
sourceProjectionVersion: TIMELINE_PROJECTION_VERSION,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export const TIMELINE_EVENT_DELIVERY_MATRIX = [
|
||||
{
|
||||
eventType: 'activity.created',
|
||||
timelineItem: 'Activity created',
|
||||
workspace: 'Customer, Lead, Opportunity, Quotation, Activity',
|
||||
consumer: 'timeline.projection.consumer',
|
||||
projection: 'crm_timeline_projection'
|
||||
},
|
||||
{
|
||||
eventType: 'activity.completed',
|
||||
timelineItem: 'Activity completed',
|
||||
workspace: 'Customer, Lead, Opportunity, Quotation, Activity',
|
||||
consumer: 'timeline.projection.consumer',
|
||||
projection: 'crm_timeline_projection'
|
||||
},
|
||||
{
|
||||
eventType: 'activity.cancelled',
|
||||
timelineItem: 'Activity cancelled',
|
||||
workspace: 'Customer, Lead, Opportunity, Quotation, Activity',
|
||||
consumer: 'timeline.projection.consumer',
|
||||
projection: 'crm_timeline_projection'
|
||||
}
|
||||
] as const;
|
||||
|
||||
function genericMapping(eventType: string): TimelineEventMapping {
|
||||
return {
|
||||
...GENERIC_EVENT_MAPPING,
|
||||
title: eventType
|
||||
.split('.')
|
||||
.map((part) => part.replaceAll('-', ' '))
|
||||
.join(' ')
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEntityRefs(event: BusinessEvent, payload: Record<string, unknown>) {
|
||||
const refs = new Map(event.relatedRecords.map((record) => [record.type, record.id]));
|
||||
return {
|
||||
customerId: text(payload.customerId) ?? refs.get('customer') ?? null,
|
||||
leadId: text(payload.leadId) ?? refs.get('lead') ?? null,
|
||||
opportunityId: text(payload.opportunityId) ?? refs.get('opportunity') ?? null,
|
||||
quotationId: text(payload.quotationId) ?? refs.get('quotation') ?? null,
|
||||
activityId:
|
||||
text(payload.activityId) ??
|
||||
(event.entityType === 'activity' ? event.entityId : refs.get('activity') ?? null)
|
||||
};
|
||||
}
|
||||
|
||||
function priorityFromPayload(payload: Record<string, unknown>): TimelinePriority {
|
||||
const priority = text(payload.priority);
|
||||
if (priority === 'critical' || priority === 'high' || priority === 'low') {
|
||||
return priority;
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function assignedSummary(payload: Record<string, unknown>): string | null {
|
||||
return readableJoin([
|
||||
text(payload.subject),
|
||||
text(payload.previousAssignedToId)
|
||||
? `Previous assignee: ${text(payload.previousAssignedToId)}`
|
||||
: null,
|
||||
text(payload.assignedToId) ? `Assignee: ${text(payload.assignedToId)}` : null
|
||||
]);
|
||||
}
|
||||
|
||||
function scheduleSummary(payload: Record<string, unknown>): string | null {
|
||||
return readableJoin([
|
||||
text(payload.scheduledStartAt) ? `Scheduled: ${text(payload.scheduledStartAt)}` : null,
|
||||
text(payload.dueAt) ? `Due: ${text(payload.dueAt)}` : null
|
||||
]);
|
||||
}
|
||||
|
||||
function readableJoin(values: Array<string | null>): string | null {
|
||||
const parts = values.filter((value): value is string => Boolean(value));
|
||||
return parts.length ? parts.join(' · ') : null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
45
src/features/crm/timeline/server/consumer.ts
Normal file
45
src/features/crm/timeline/server/consumer.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { BusinessEvent } from '../../../foundation/business-events/types.ts';
|
||||
import type {
|
||||
ProjectionConsumer,
|
||||
ProjectionHandleResult
|
||||
} from '../../../foundation/projections/types.ts';
|
||||
import { getProjectionDefinition } from '../../../foundation/projections/registry.ts';
|
||||
import { buildTimelineItemFromEvent } from './builder.ts';
|
||||
|
||||
const definition = getProjectionDefinition('Timeline');
|
||||
|
||||
interface TimelineProjectionConsumerOptions {
|
||||
persist?: (item: ReturnType<typeof buildTimelineItemFromEvent>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function createTimelineProjectionConsumer(
|
||||
options: TimelineProjectionConsumerOptions = {}
|
||||
): ProjectionConsumer {
|
||||
const persist = options.persist ?? persistTimelineItem;
|
||||
|
||||
return {
|
||||
consumerName: definition.consumerName,
|
||||
projectionName: definition.projectionName,
|
||||
supportedEventTypes: definition.consumedEventTypes,
|
||||
supportedVersions: definition.supportedVersions,
|
||||
async handle(event: BusinessEvent): Promise<ProjectionHandleResult> {
|
||||
const item = buildTimelineItemFromEvent(event);
|
||||
await persist(item);
|
||||
|
||||
return {
|
||||
status: 'processed',
|
||||
message: 'Timeline projection item persisted',
|
||||
sideEffectCount: 1
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const timelineProjectionConsumer = createTimelineProjectionConsumer();
|
||||
|
||||
async function persistTimelineItem(
|
||||
item: ReturnType<typeof buildTimelineItemFromEvent>
|
||||
): Promise<void> {
|
||||
const { upsertTimelineProjectionItem } = await import('./service.ts');
|
||||
await upsertTimelineProjectionItem(item);
|
||||
}
|
||||
35
src/features/crm/timeline/server/legacy-adapters.ts
Normal file
35
src/features/crm/timeline/server/legacy-adapters.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { TimelineItem } from '../api/types.ts';
|
||||
|
||||
export interface TimelineLegacyAdapterRequest {
|
||||
organizationId: string;
|
||||
customerId?: string;
|
||||
leadId?: string;
|
||||
opportunityId?: string;
|
||||
quotationId?: string;
|
||||
occurredFrom?: string;
|
||||
occurredTo?: string;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface TimelineLegacyAdapter {
|
||||
readonly adapterName: string;
|
||||
readonly source: 'lead_followup' | 'opportunity_followup' | 'quotation_followup' | 'activity' | 'approval' | 'audit';
|
||||
collect(request: TimelineLegacyAdapterRequest): Promise<TimelineItem[]>;
|
||||
}
|
||||
|
||||
export const TIMELINE_LEGACY_ADAPTER_SOURCES = [
|
||||
'lead_followup',
|
||||
'opportunity_followup',
|
||||
'quotation_followup',
|
||||
'activity',
|
||||
'approval',
|
||||
'audit'
|
||||
] as const;
|
||||
|
||||
export const timelineLegacyAdapterContract = {
|
||||
rebuildOnly: true,
|
||||
migrationRequired: false,
|
||||
sourceMutationsAllowed: false,
|
||||
notes:
|
||||
'Legacy adapters are read-only rebuild inputs. They must not migrate, update, or delete source records.'
|
||||
} as const;
|
||||
38
src/features/crm/timeline/server/rebuild.ts
Normal file
38
src/features/crm/timeline/server/rebuild.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createProjectionRebuildPlan } from '../../../foundation/projections/rebuild.ts';
|
||||
import type { ProjectionRebuildRequest } from '../../../foundation/projections/types.ts';
|
||||
import type { TimelineListResponse } from '../api/types.ts';
|
||||
import { listTimelineItems, resetTimelineProjectionForOrganization } from './service.ts';
|
||||
|
||||
export interface TimelineRebuildRequest extends ProjectionRebuildRequest {
|
||||
projectionName: 'Timeline';
|
||||
}
|
||||
|
||||
export async function planTimelineRebuild(request: TimelineRebuildRequest) {
|
||||
return createProjectionRebuildPlan({
|
||||
...request,
|
||||
projectionName: 'Timeline',
|
||||
dryRun: request.dryRun ?? true
|
||||
});
|
||||
}
|
||||
|
||||
export async function dryRunTimelineRebuild(
|
||||
request: TimelineRebuildRequest
|
||||
): Promise<TimelineListResponse> {
|
||||
return listTimelineItems({
|
||||
organizationId: request.organizationId,
|
||||
customerId: request.entityType === 'customer' ? request.entityId : undefined,
|
||||
leadId: request.entityType === 'lead' ? request.entityId : undefined,
|
||||
opportunityId: request.entityType === 'opportunity' ? request.entityId : undefined,
|
||||
quotationId: request.entityType === 'quotation' ? request.entityId : undefined,
|
||||
occurredFrom: request.occurredFrom,
|
||||
occurredTo: request.occurredTo,
|
||||
limit: 100
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetTimelineProjection(request: TimelineRebuildRequest): Promise<void> {
|
||||
if (!request.resetExisting) {
|
||||
throw new Error('Timeline projection reset requires resetExisting=true');
|
||||
}
|
||||
await resetTimelineProjectionForOrganization(request.organizationId);
|
||||
}
|
||||
13
src/features/crm/timeline/server/registration.ts
Normal file
13
src/features/crm/timeline/server/registration.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
calendarProjectionConsumer,
|
||||
dashboardProjectionConsumer,
|
||||
notificationProjectionConsumer
|
||||
} from '../../../foundation/projections/consumers.ts';
|
||||
import { timelineProjectionConsumer } from './consumer.ts';
|
||||
|
||||
export const CRM_TIMELINE_PROJECTION_CONSUMERS = [
|
||||
timelineProjectionConsumer,
|
||||
calendarProjectionConsumer,
|
||||
dashboardProjectionConsumer,
|
||||
notificationProjectionConsumer
|
||||
];
|
||||
137
src/features/crm/timeline/server/service.ts
Normal file
137
src/features/crm/timeline/server/service.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { and, desc, eq, ilike, lte, gte, type SQL } from 'drizzle-orm';
|
||||
import { crmTimelineProjection } from '../../../../db/schema.ts';
|
||||
import { db } from '../../../../lib/db.ts';
|
||||
import type { TimelineItem, TimelineListFilters, TimelineListResponse } from '../api/types.ts';
|
||||
|
||||
export type TimelineProjectionRow = typeof crmTimelineProjection.$inferSelect;
|
||||
export type InsertTimelineProjectionRow = typeof crmTimelineProjection.$inferInsert;
|
||||
type TimelineDbClient = Pick<typeof db, 'insert' | 'select' | 'delete'>;
|
||||
|
||||
export async function upsertTimelineProjectionItem(
|
||||
item: TimelineItem,
|
||||
client: TimelineDbClient = db
|
||||
): Promise<void> {
|
||||
await client
|
||||
.insert(crmTimelineProjection)
|
||||
.values({
|
||||
id: item.id,
|
||||
organizationId: item.organizationId,
|
||||
branchId: item.branchId,
|
||||
customerId: item.customerId,
|
||||
leadId: item.leadId,
|
||||
opportunityId: item.opportunityId,
|
||||
quotationId: item.quotationId,
|
||||
activityId: item.activityId,
|
||||
eventId: item.eventId,
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
timelineType: item.timelineType,
|
||||
timelineCategory: item.timelineCategory,
|
||||
occurredAt: new Date(item.occurredAt),
|
||||
actorId: item.actorId,
|
||||
actorDisplay: item.actorDisplay,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
icon: item.icon,
|
||||
color: item.color,
|
||||
priority: item.priority,
|
||||
visibility: item.visibility,
|
||||
metadata: item.metadata,
|
||||
sourceEvent: {
|
||||
eventId: item.eventId,
|
||||
timelineType: item.timelineType,
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId
|
||||
},
|
||||
sourceProjectionVersion: item.sourceProjectionVersion
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
export async function listTimelineItems(
|
||||
filters: TimelineListFilters,
|
||||
client: TimelineDbClient = db
|
||||
): Promise<TimelineListResponse> {
|
||||
const limit = Math.min(filters.limit ?? 25, 100);
|
||||
const where = buildTimelineWhere(filters);
|
||||
|
||||
const rows = await client
|
||||
.select()
|
||||
.from(crmTimelineProjection)
|
||||
.where(and(...where))
|
||||
.orderBy(desc(crmTimelineProjection.occurredAt), desc(crmTimelineProjection.id))
|
||||
.limit(limit + 1);
|
||||
|
||||
const visibleRows = rows.slice(0, limit);
|
||||
|
||||
return {
|
||||
items: visibleRows.map(mapTimelineRow),
|
||||
nextCursor: rows.length > limit ? visibleRows.at(-1)?.id ?? null : null,
|
||||
totalItems: visibleRows.length
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetTimelineProjectionForOrganization(
|
||||
organizationId: string,
|
||||
client: TimelineDbClient = db
|
||||
): Promise<void> {
|
||||
await client
|
||||
.delete(crmTimelineProjection)
|
||||
.where(eq(crmTimelineProjection.organizationId, organizationId));
|
||||
}
|
||||
|
||||
function buildTimelineWhere(filters: TimelineListFilters): SQL[] {
|
||||
const where: SQL[] = [eq(crmTimelineProjection.organizationId, filters.organizationId)];
|
||||
|
||||
if (filters.customerId) where.push(eq(crmTimelineProjection.customerId, filters.customerId));
|
||||
if (filters.leadId) where.push(eq(crmTimelineProjection.leadId, filters.leadId));
|
||||
if (filters.opportunityId) {
|
||||
where.push(eq(crmTimelineProjection.opportunityId, filters.opportunityId));
|
||||
}
|
||||
if (filters.quotationId) where.push(eq(crmTimelineProjection.quotationId, filters.quotationId));
|
||||
if (filters.activityId) where.push(eq(crmTimelineProjection.activityId, filters.activityId));
|
||||
if (filters.eventId) where.push(eq(crmTimelineProjection.eventId, filters.eventId));
|
||||
if (filters.category) where.push(eq(crmTimelineProjection.timelineCategory, filters.category));
|
||||
if (filters.actorId) where.push(eq(crmTimelineProjection.actorId, filters.actorId));
|
||||
if (filters.search) {
|
||||
where.push(ilike(crmTimelineProjection.title, `%${filters.search}%`));
|
||||
}
|
||||
if (filters.occurredFrom) {
|
||||
where.push(gte(crmTimelineProjection.occurredAt, new Date(filters.occurredFrom)));
|
||||
}
|
||||
if (filters.occurredTo) {
|
||||
where.push(lte(crmTimelineProjection.occurredAt, new Date(filters.occurredTo)));
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
function mapTimelineRow(row: TimelineProjectionRow): TimelineItem {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
branchId: row.branchId,
|
||||
customerId: row.customerId,
|
||||
leadId: row.leadId,
|
||||
opportunityId: row.opportunityId,
|
||||
quotationId: row.quotationId,
|
||||
activityId: row.activityId,
|
||||
eventId: row.eventId,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
timelineType: row.timelineType,
|
||||
timelineCategory: row.timelineCategory as TimelineItem['timelineCategory'],
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
actorId: row.actorId,
|
||||
actorDisplay: row.actorDisplay,
|
||||
title: row.title,
|
||||
summary: row.summary,
|
||||
icon: row.icon,
|
||||
color: row.color,
|
||||
priority: row.priority as TimelineItem['priority'],
|
||||
visibility: row.visibility as TimelineItem['visibility'],
|
||||
metadata: row.metadata as Record<string, unknown> | null,
|
||||
sourceProjectionVersion: row.sourceProjectionVersion,
|
||||
createdAt: row.createdAt.toISOString()
|
||||
};
|
||||
}
|
||||
@@ -23,12 +23,12 @@ export const PROJECTION_REGISTRY: ProjectionDefinition[] = [
|
||||
consumedEventTypes: ACTIVITY_EVENTS,
|
||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||
rebuildStrategy: 'hybrid',
|
||||
persistence: 'cached-read-model',
|
||||
persistence: 'materialized',
|
||||
securityClassification: 'organization-scoped; source-visibility-governed',
|
||||
idempotencyKey: 'consumerName + eventId',
|
||||
retryPolicy: 'projection-default-v1',
|
||||
retentionPolicy: 'event checkpoints retained with projection audit horizon',
|
||||
implementationStatus: 'contract-only'
|
||||
implementationStatus: 'active'
|
||||
},
|
||||
{
|
||||
projectionName: 'Calendar',
|
||||
|
||||
@@ -81,8 +81,8 @@ export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore {
|
||||
RETURNING business_event_outbox.*
|
||||
`);
|
||||
|
||||
const rows = Array.isArray(result) ? result : result.rows;
|
||||
return rows.map((row) => mapDelivery(row as typeof businessEventOutbox.$inferSelect));
|
||||
const rows = result as unknown as Array<typeof businessEventOutbox.$inferSelect>;
|
||||
return rows.map((row) => mapDelivery(row));
|
||||
}
|
||||
|
||||
async markDeliveryProcessing(eventId: string, now: Date): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user