task-ep.1.5

This commit is contained in:
phaichayon
2026-07-13 15:03:17 +07:00
parent bba4c8d97d
commit 2bc8dd184c
19 changed files with 9722 additions and 5 deletions

View File

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

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

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

View File

@@ -0,0 +1,2 @@
export * from './api/types';
export * from './components/timeline-list';

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

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

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

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

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

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

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