task-ep.1.6
This commit is contained in:
18
src/features/crm/calendar/api/queries.ts
Normal file
18
src/features/crm/calendar/api/queries.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getCalendarItems } from './service';
|
||||
import type { CalendarListFilters } from './types';
|
||||
|
||||
export const calendarKeys = {
|
||||
all: ['crm-calendar'] as const,
|
||||
lists: () => [...calendarKeys.all, 'list'] as const,
|
||||
list: (filters: Omit<CalendarListFilters, 'organizationId'>) =>
|
||||
[...calendarKeys.lists(), filters] as const
|
||||
};
|
||||
|
||||
export const calendarItemsQueryOptions = (
|
||||
filters: Omit<CalendarListFilters, 'organizationId'>
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: calendarKeys.list(filters),
|
||||
queryFn: () => getCalendarItems(filters)
|
||||
});
|
||||
34
src/features/crm/calendar/api/service.ts
Normal file
34
src/features/crm/calendar/api/service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { CalendarListFilters, CalendarListResponse } from './types';
|
||||
|
||||
function buildSearchParams(filters: Omit<CalendarListFilters, 'organizationId'>): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set('startFrom', filters.startFrom);
|
||||
params.set('startTo', filters.startTo);
|
||||
if (filters.lens) params.set('lens', filters.lens);
|
||||
if (filters.userId) params.set('userId', filters.userId);
|
||||
if (filters.teamUserIds?.length) params.set('teamUserIds', filters.teamUserIds.join(','));
|
||||
if (filters.branchId) params.set('branchId', filters.branchId);
|
||||
if (filters.productType) params.set('productType', filters.productType);
|
||||
if (filters.customerId) params.set('customerId', filters.customerId);
|
||||
if (filters.leadId) params.set('leadId', filters.leadId);
|
||||
if (filters.opportunityId) params.set('opportunityId', filters.opportunityId);
|
||||
if (filters.quotationId) params.set('quotationId', filters.quotationId);
|
||||
if (filters.activityType) params.set('activityType', filters.activityType);
|
||||
if (filters.category) params.set('category', filters.category);
|
||||
if (filters.priority) params.set('priority', filters.priority);
|
||||
if (typeof filters.overdue === 'boolean') params.set('overdue', String(filters.overdue));
|
||||
if (typeof filters.milestone === 'boolean') params.set('milestone', String(filters.milestone));
|
||||
if (typeof filters.hotProject === 'boolean') params.set('hotProject', String(filters.hotProject));
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.limit) params.set('limit', String(filters.limit));
|
||||
if (filters.cursor) params.set('cursor', filters.cursor);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function getCalendarItems(
|
||||
filters: Omit<CalendarListFilters, 'organizationId'>
|
||||
): Promise<CalendarListResponse> {
|
||||
const search = buildSearchParams(filters);
|
||||
return apiClient<CalendarListResponse>(`/crm/calendar${search ? `?${search}` : ''}`);
|
||||
}
|
||||
128
src/features/crm/calendar/api/types.ts
Normal file
128
src/features/crm/calendar/api/types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export type CalendarCategory =
|
||||
| 'activity'
|
||||
| 'meeting'
|
||||
| 'visit'
|
||||
| 'site_survey'
|
||||
| 'task'
|
||||
| 'reminder'
|
||||
| 'opportunity_milestone'
|
||||
| 'quotation_milestone'
|
||||
| 'approval_milestone';
|
||||
|
||||
export type CalendarPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
export type CalendarLens = 'personal' | 'team' | 'manager' | 'executive';
|
||||
export type CalendarView = 'day' | 'week' | 'month' | 'agenda';
|
||||
|
||||
export interface CalendarVisibility {
|
||||
productType?: string | null;
|
||||
pricingSensitive: boolean;
|
||||
internalOnly: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarProjectionItem {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
branchId: string | null;
|
||||
productType: string | null;
|
||||
customerId: string | null;
|
||||
leadId: string | null;
|
||||
opportunityId: string | null;
|
||||
quotationId: string | null;
|
||||
approvalId: string | null;
|
||||
activityId: string | null;
|
||||
ownerId: string | null;
|
||||
assigneeId: string | null;
|
||||
eventId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
location: string | null;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
timezone: string;
|
||||
priority: CalendarPriority;
|
||||
category: CalendarCategory;
|
||||
activityType: string | null;
|
||||
editable: boolean;
|
||||
milestone: boolean;
|
||||
overdue: boolean;
|
||||
hotProject: boolean;
|
||||
allDay: boolean;
|
||||
visibility: CalendarVisibility;
|
||||
metadata: Record<string, unknown> | null;
|
||||
sourceProjectionVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CalendarListFilters {
|
||||
organizationId: string;
|
||||
startFrom: string;
|
||||
startTo: string;
|
||||
lens?: CalendarLens;
|
||||
userId?: string;
|
||||
teamUserIds?: string[];
|
||||
branchId?: string;
|
||||
productType?: string;
|
||||
customerId?: string;
|
||||
leadId?: string;
|
||||
opportunityId?: string;
|
||||
quotationId?: string;
|
||||
activityType?: string;
|
||||
category?: CalendarCategory;
|
||||
priority?: CalendarPriority;
|
||||
overdue?: boolean;
|
||||
milestone?: boolean;
|
||||
hotProject?: boolean;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface CalendarSummary {
|
||||
dueToday: number;
|
||||
overdue: number;
|
||||
meetings: number;
|
||||
visits: number;
|
||||
milestones: number;
|
||||
approvalDue: number;
|
||||
hotProjects: number;
|
||||
}
|
||||
|
||||
export interface CalendarListResponse {
|
||||
items: CalendarProjectionItem[];
|
||||
summary: CalendarSummary;
|
||||
nextCursor: string | null;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface BigCalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
allDay: boolean;
|
||||
editable: boolean;
|
||||
draggable: boolean;
|
||||
resizable: boolean;
|
||||
category: CalendarCategory;
|
||||
priority: CalendarPriority;
|
||||
source: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
activityId: string | null;
|
||||
customerId: string | null;
|
||||
leadId: string | null;
|
||||
opportunityId: string | null;
|
||||
quotationId: string | null;
|
||||
approvalId: string | null;
|
||||
};
|
||||
display: {
|
||||
summary: string | null;
|
||||
location: string | null;
|
||||
overdue: boolean;
|
||||
hotProject: boolean;
|
||||
milestone: boolean;
|
||||
};
|
||||
}
|
||||
37
src/features/crm/calendar/components/calendar-adapter.ts
Normal file
37
src/features/crm/calendar/components/calendar-adapter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { BigCalendarEvent, CalendarProjectionItem } from '../api/types';
|
||||
|
||||
export function mapCalendarProjectionToBigCalendarEvent(
|
||||
item: CalendarProjectionItem
|
||||
): BigCalendarEvent {
|
||||
const activityEditable = item.editable && !item.milestone && item.activityId !== null;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
start: item.startAt,
|
||||
end: item.endAt,
|
||||
allDay: item.allDay,
|
||||
editable: activityEditable,
|
||||
draggable: activityEditable,
|
||||
resizable: activityEditable,
|
||||
category: item.category,
|
||||
priority: item.priority,
|
||||
source: {
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
activityId: item.activityId,
|
||||
customerId: item.customerId,
|
||||
leadId: item.leadId,
|
||||
opportunityId: item.opportunityId,
|
||||
quotationId: item.quotationId,
|
||||
approvalId: item.approvalId
|
||||
},
|
||||
display: {
|
||||
summary: item.summary,
|
||||
location: item.location,
|
||||
overdue: item.overdue,
|
||||
hotProject: item.hotProject,
|
||||
milestone: item.milestone
|
||||
}
|
||||
};
|
||||
}
|
||||
295
src/features/crm/calendar/components/calendar-workspace.tsx
Normal file
295
src/features/crm/calendar/components/calendar-workspace.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDate, formatTime } from '@/lib/date-format';
|
||||
import { calendarItemsQueryOptions } from '../api/queries';
|
||||
import type { BigCalendarEvent, CalendarListFilters, CalendarView } from '../api/types';
|
||||
import { mapCalendarProjectionToBigCalendarEvent } from './calendar-adapter';
|
||||
|
||||
interface CalendarWorkspaceProps {
|
||||
filters: Omit<CalendarListFilters, 'organizationId'>;
|
||||
}
|
||||
|
||||
const VIEW_LABELS: Record<CalendarView, string> = {
|
||||
day: 'Day',
|
||||
week: 'Week',
|
||||
month: 'Month',
|
||||
agenda: 'Agenda'
|
||||
};
|
||||
|
||||
export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
|
||||
const [view, setView] = useState<CalendarView>('agenda');
|
||||
const [search, setSearch] = useState('');
|
||||
const { data } = useSuspenseQuery(calendarItemsQueryOptions(filters));
|
||||
const events = useMemo(
|
||||
() =>
|
||||
data.items
|
||||
.map(mapCalendarProjectionToBigCalendarEvent)
|
||||
.filter((event) => event.title.toLowerCase().includes(search.toLowerCase())),
|
||||
[data.items, search]
|
||||
);
|
||||
const groupedEvents = groupEventsByDate(events);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<CalendarSummaryCards summary={data.summary} />
|
||||
|
||||
<Card>
|
||||
<CardHeader className='gap-4'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
<Icons.calendar className='h-5 w-5' />
|
||||
Calendar Workspace
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Projection workspace for scheduled activities and read-only business milestones.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
|
||||
<div className='relative'>
|
||||
<Icons.search className='text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4' />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder='Search calendar'
|
||||
className='w-full pl-8 sm:w-[240px]'
|
||||
/>
|
||||
</div>
|
||||
<Badge variant='outline'>Lens: {filters.lens ?? 'personal'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={view} onValueChange={(value) => setView(value as CalendarView)}>
|
||||
<TabsList className='mb-4 grid w-full grid-cols-4 lg:w-[420px]'>
|
||||
{Object.entries(VIEW_LABELS).map(([value, label]) => (
|
||||
<TabsTrigger key={value} value={value}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='agenda'>
|
||||
<AgendaView groupedEvents={groupedEvents} />
|
||||
</TabsContent>
|
||||
<TabsContent value='day'>
|
||||
<CompactCalendarGrid events={events.slice(0, 12)} columns={1} />
|
||||
</TabsContent>
|
||||
<TabsContent value='week'>
|
||||
<CompactCalendarGrid events={events.slice(0, 28)} columns={7} />
|
||||
</TabsContent>
|
||||
<TabsContent value='month'>
|
||||
<CompactCalendarGrid events={events.slice(0, 42)} columns={7} month />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarWorkspaceSkeleton() {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className='h-24 rounded-xl' />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className='h-[520px] rounded-xl' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarSummaryCards({ summary }: { summary: CalendarWorkspaceSummary }) {
|
||||
const cards = [
|
||||
{ label: 'Due Today', value: summary.dueToday, icon: Icons.calendar },
|
||||
{ label: 'Overdue', value: summary.overdue, icon: Icons.warning },
|
||||
{ label: 'Meetings', value: summary.meetings, icon: Icons.clock },
|
||||
{ label: 'Hot Projects', value: summary.hotProjects, icon: Icons.check }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<CardContent className='flex items-center gap-3 p-4'>
|
||||
<div className='bg-muted rounded-lg p-2'>
|
||||
<card.icon className='text-muted-foreground h-5 w-5' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-2xl font-bold'>{card.value}</p>
|
||||
<p className='text-muted-foreground text-xs'>{card.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgendaView({ groupedEvents }: { groupedEvents: Array<[string, BigCalendarEvent[]]> }) {
|
||||
if (!groupedEvents.length) {
|
||||
return <CalendarEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-5'>
|
||||
{groupedEvents.map(([date, events]) => (
|
||||
<section key={date} className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='bg-border h-px flex-1' />
|
||||
<p className='text-muted-foreground text-xs font-medium uppercase tracking-wide'>
|
||||
{formatDate(date)}
|
||||
</p>
|
||||
<div className='bg-border h-px flex-1' />
|
||||
</div>
|
||||
<div className='grid gap-3'>
|
||||
{events.map((event) => (
|
||||
<CalendarEventCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactCalendarGrid({
|
||||
events,
|
||||
columns,
|
||||
month
|
||||
}: {
|
||||
events: BigCalendarEvent[];
|
||||
columns: number;
|
||||
month?: boolean;
|
||||
}) {
|
||||
if (!events.length) {
|
||||
return <CalendarEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-3',
|
||||
columns === 1 && 'grid-cols-1',
|
||||
columns === 7 && 'grid-cols-1 md:grid-cols-7'
|
||||
)}
|
||||
>
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'border-border/70 bg-muted/20 min-h-28 rounded-lg border p-2',
|
||||
month && 'min-h-36'
|
||||
)}
|
||||
>
|
||||
<p className='text-muted-foreground text-xs'>{formatDate(event.start)}</p>
|
||||
<CalendarEventPill event={event} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEventCard({ event }: { event: BigCalendarEvent }) {
|
||||
return (
|
||||
<article className='border-border/70 hover:bg-muted/30 rounded-lg border p-4 transition-colors'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant={event.display.milestone ? 'secondary' : 'outline'}>
|
||||
{event.category.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
{event.display.overdue ? <Badge variant='destructive'>Overdue</Badge> : null}
|
||||
{event.display.hotProject ? <Badge variant='default'>Hot Project</Badge> : null}
|
||||
{!event.editable ? <Badge variant='outline'>Read-only</Badge> : null}
|
||||
</div>
|
||||
<h3 className='font-medium'>{event.title}</h3>
|
||||
{event.display.summary ? (
|
||||
<p className='text-muted-foreground text-sm'>{event.display.summary}</p>
|
||||
) : null}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatTime(event.start)} - {formatTime(event.end)}
|
||||
{event.display.location ? ` · ${event.display.location}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' size='sm'>
|
||||
Open Source
|
||||
</Button>
|
||||
{event.editable ? (
|
||||
<>
|
||||
<Button variant='outline' size='sm'>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button variant='outline' size='sm'>
|
||||
Complete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant='outline' size='sm'>
|
||||
Create Activity
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEventPill({ event }: { event: BigCalendarEvent }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 rounded-md border px-2 py-1 text-xs',
|
||||
event.display.overdue
|
||||
? 'border-destructive/40 bg-destructive/10 text-destructive'
|
||||
: 'border-primary/20 bg-primary/10 text-primary'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium'>{formatTime(event.start)}</span> {event.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEmptyState() {
|
||||
return (
|
||||
<div className='border-border/70 rounded-xl border border-dashed p-10 text-center'>
|
||||
<Icons.calendar className='text-muted-foreground mx-auto mb-3 h-8 w-8' />
|
||||
<p className='font-medium'>No scheduled work in this period</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Try changing filters or create an Activity from the owning CRM record.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupEventsByDate(events: BigCalendarEvent[]): Array<[string, BigCalendarEvent[]]> {
|
||||
const grouped = new Map<string, BigCalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = event.start.slice(0, 10);
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), event]);
|
||||
}
|
||||
return [...grouped.entries()];
|
||||
}
|
||||
|
||||
type CalendarWorkspaceSummary = {
|
||||
dueToday: number;
|
||||
overdue: number;
|
||||
meetings: number;
|
||||
visits: number;
|
||||
milestones: number;
|
||||
approvalDue: number;
|
||||
hotProjects: number;
|
||||
};
|
||||
12
src/features/crm/calendar/index.ts
Normal file
12
src/features/crm/calendar/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
BigCalendarEvent,
|
||||
CalendarCategory,
|
||||
CalendarLens,
|
||||
CalendarListFilters,
|
||||
CalendarListResponse,
|
||||
CalendarPriority,
|
||||
CalendarProjectionItem,
|
||||
CalendarSummary,
|
||||
CalendarView
|
||||
} from './api/types';
|
||||
export { mapCalendarProjectionToBigCalendarEvent } from './components/calendar-adapter';
|
||||
86
src/features/crm/calendar/server/builder.test.ts
Normal file
86
src/features/crm/calendar/server/builder.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createBusinessEvent } from '../../../foundation/business-events/publisher.ts';
|
||||
import { buildCalendarChangeFromEvent } from './builder.ts';
|
||||
import { createCalendarProjectionConsumer } from './consumer.ts';
|
||||
|
||||
function scheduledActivityEvent(eventType = 'activity.created') {
|
||||
return createBusinessEvent({
|
||||
eventType,
|
||||
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',
|
||||
activityCode: 'ACT-1',
|
||||
activityType: 'meeting',
|
||||
subject: 'Customer planning meeting',
|
||||
priority: 'high',
|
||||
ownerId: 'owner-1',
|
||||
assignedToId: 'assignee-1',
|
||||
scheduledStartAt: '2026-07-14T03:00:00.000Z',
|
||||
scheduledEndAt: '2026-07-14T04:00:00.000Z',
|
||||
status: 'scheduled'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unscheduledActivityEvent() {
|
||||
return createBusinessEvent({
|
||||
eventType: 'activity.created',
|
||||
organizationId: 'org-1',
|
||||
branchId: 'branch-1',
|
||||
entityType: 'activity',
|
||||
entityId: 'activity-1',
|
||||
primaryRecord: { type: 'activity', id: 'activity-1' },
|
||||
relatedRecords: [],
|
||||
actorUserId: 'user-1',
|
||||
occurredAt: '2026-07-13T00:00:00.000Z',
|
||||
correlationId: 'corr-1',
|
||||
visibility: { productType: null, pricingSensitive: false, internalOnly: false },
|
||||
payload: {
|
||||
activityId: 'activity-1',
|
||||
subject: 'No date'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('calendar builder maps scheduled activity into editable calendar item', () => {
|
||||
const change = buildCalendarChangeFromEvent(scheduledActivityEvent());
|
||||
assert.equal(change?.action, 'upsert');
|
||||
assert.equal(change?.item.category, 'meeting');
|
||||
assert.equal(change?.item.editable, true);
|
||||
assert.equal(change?.item.milestone, false);
|
||||
assert.equal(change?.item.customerId, 'customer-1');
|
||||
assert.equal(change?.item.quotationId, 'quotation-1');
|
||||
assert.equal(change?.item.ownerId, 'owner-1');
|
||||
assert.equal(change?.item.assigneeId, 'assignee-1');
|
||||
assert.equal(change?.item.visibility.pricingSensitive, true);
|
||||
});
|
||||
|
||||
test('calendar builder deletes terminal activity from projection', () => {
|
||||
const change = buildCalendarChangeFromEvent(scheduledActivityEvent('activity.cancelled'));
|
||||
assert.equal(change?.action, 'delete');
|
||||
assert.equal(change?.activityId, 'activity-1');
|
||||
});
|
||||
|
||||
test('calendar consumer skips activity events without schedulable date', async () => {
|
||||
const consumer = createCalendarProjectionConsumer({
|
||||
persist: async () => {
|
||||
throw new Error('should not persist');
|
||||
}
|
||||
});
|
||||
const result = await consumer.handle(unscheduledActivityEvent());
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.equal(result.sideEffectCount, 0);
|
||||
});
|
||||
184
src/features/crm/calendar/server/builder.ts
Normal file
184
src/features/crm/calendar/server/builder.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { BANGKOK_TIME_ZONE } from '../../../../lib/date-format.ts';
|
||||
import type { BusinessEvent } from '../../../foundation/business-events/types.ts';
|
||||
import type { CalendarCategory, CalendarPriority, CalendarProjectionItem } from '../api/types';
|
||||
|
||||
export const CALENDAR_PROJECTION_VERSION = 1;
|
||||
|
||||
export type CalendarProjectionChange =
|
||||
| { action: 'upsert'; item: CalendarProjectionItem }
|
||||
| { action: 'delete'; organizationId: string; activityId: string; eventId: string };
|
||||
|
||||
const TERMINAL_ACTIVITY_EVENTS = new Set([
|
||||
'activity.completed',
|
||||
'activity.cancelled',
|
||||
'activity.deleted'
|
||||
]);
|
||||
|
||||
const ACTIVITY_CATEGORY_BY_TYPE: Record<string, CalendarCategory> = {
|
||||
meeting: 'meeting',
|
||||
visit: 'visit',
|
||||
site_survey: 'site_survey',
|
||||
internal_task: 'task',
|
||||
reminder: 'reminder',
|
||||
reminder_action: 'reminder',
|
||||
follow_up: 'activity',
|
||||
phone_call: 'activity',
|
||||
email: 'activity',
|
||||
email_follow_up: 'activity',
|
||||
presentation: 'meeting',
|
||||
delivery_coordination: 'activity',
|
||||
approval_action: 'approval_milestone'
|
||||
};
|
||||
|
||||
export function buildCalendarChangeFromEvent(event: BusinessEvent): CalendarProjectionChange | null {
|
||||
if (!event.eventType.startsWith('activity.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
const activityId = text(payload.activityId) ?? (event.entityType === 'activity' ? event.entityId : null);
|
||||
|
||||
if (!activityId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TERMINAL_ACTIVITY_EVENTS.has(event.eventType)) {
|
||||
return {
|
||||
action: 'delete',
|
||||
organizationId: event.organizationId,
|
||||
activityId,
|
||||
eventId: event.eventId
|
||||
};
|
||||
}
|
||||
|
||||
const startAt = dateText(payload.scheduledStartAt) ?? dateText(payload.dueAt);
|
||||
if (!startAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endAt =
|
||||
dateText(payload.scheduledEndAt) ??
|
||||
addMinutesIso(startAt, defaultDurationMinutes(text(payload.activityType)));
|
||||
const refs = resolveEntityRefs(event, payload);
|
||||
const priority = priorityFromPayload(payload);
|
||||
const category = categoryFromPayload(payload);
|
||||
|
||||
return {
|
||||
action: 'upsert',
|
||||
item: {
|
||||
id: `calendar:${event.organizationId}:${activityId}`,
|
||||
organizationId: event.organizationId,
|
||||
branchId: event.branchId ?? null,
|
||||
productType: event.visibility.productType ?? null,
|
||||
customerId: refs.customerId,
|
||||
leadId: refs.leadId,
|
||||
opportunityId: refs.opportunityId,
|
||||
quotationId: refs.quotationId,
|
||||
approvalId: refs.approvalId,
|
||||
activityId,
|
||||
ownerId: text(payload.ownerId),
|
||||
assigneeId: text(payload.assignedToId),
|
||||
eventId: event.eventId,
|
||||
entityType: event.entityType,
|
||||
entityId: event.entityId,
|
||||
title: calendarTitle(payload, category),
|
||||
summary: text(payload.description) ?? text(payload.outcomeSummary) ?? text(payload.nextAction),
|
||||
location: text(payload.location) ?? text(payload.projectLocation),
|
||||
startAt,
|
||||
endAt,
|
||||
timezone: BANGKOK_TIME_ZONE,
|
||||
priority,
|
||||
category,
|
||||
activityType: text(payload.activityType),
|
||||
editable: true,
|
||||
milestone: false,
|
||||
overdue: isOverdue(startAt, text(payload.status)),
|
||||
hotProject: Boolean(payload.hotProject),
|
||||
allDay: Boolean(payload.allDay),
|
||||
visibility: {
|
||||
productType: event.visibility.productType,
|
||||
pricingSensitive: event.visibility.pricingSensitive ?? false,
|
||||
internalOnly: event.visibility.internalOnly ?? false
|
||||
},
|
||||
metadata: {
|
||||
source: 'activity-business-event',
|
||||
activityCode: text(payload.activityCode),
|
||||
contentRedacted: Boolean(payload.contentRedacted),
|
||||
primaryRecord: event.primaryRecord
|
||||
},
|
||||
sourceProjectionVersion: CALENDAR_PROJECTION_VERSION,
|
||||
createdAt: event.occurredAt,
|
||||
updatedAt: event.occurredAt
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
approvalId: text(payload.approvalId) ?? refs.get('approval') ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function calendarTitle(payload: Record<string, unknown>, category: CalendarCategory): string {
|
||||
const subject = text(payload.subject);
|
||||
if (subject) return subject;
|
||||
if (category === 'meeting') return 'Meeting';
|
||||
if (category === 'visit') return 'Customer visit';
|
||||
if (category === 'site_survey') return 'Site survey';
|
||||
if (category === 'reminder') return 'Reminder';
|
||||
return 'Activity';
|
||||
}
|
||||
|
||||
function categoryFromPayload(payload: Record<string, unknown>): CalendarCategory {
|
||||
const activityType = text(payload.activityType);
|
||||
if (activityType && ACTIVITY_CATEGORY_BY_TYPE[activityType]) {
|
||||
return ACTIVITY_CATEGORY_BY_TYPE[activityType];
|
||||
}
|
||||
return 'activity';
|
||||
}
|
||||
|
||||
function priorityFromPayload(payload: Record<string, unknown>): CalendarPriority {
|
||||
const priority = text(payload.priority);
|
||||
if (priority === 'critical' || priority === 'high' || priority === 'low') {
|
||||
return priority;
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function defaultDurationMinutes(activityType: string | null): number {
|
||||
if (activityType === 'visit' || activityType === 'site_survey' || activityType === 'presentation') {
|
||||
return 120;
|
||||
}
|
||||
if (activityType === 'meeting') return 60;
|
||||
return 30;
|
||||
}
|
||||
|
||||
function isOverdue(startAt: string, status: string | null): boolean {
|
||||
if (status === 'completed' || status === 'cancelled' || status === 'skipped') {
|
||||
return false;
|
||||
}
|
||||
return new Date(startAt).getTime() < Date.now();
|
||||
}
|
||||
|
||||
function addMinutesIso(value: string, minutes: number): string {
|
||||
const date = new Date(value);
|
||||
date.setMinutes(date.getMinutes() + minutes);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function dateText(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
69
src/features/crm/calendar/server/consumer.ts
Normal file
69
src/features/crm/calendar/server/consumer.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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 { buildCalendarChangeFromEvent } from './builder.ts';
|
||||
|
||||
const definition = getProjectionDefinition('Calendar');
|
||||
|
||||
interface CalendarProjectionConsumerOptions {
|
||||
persist?: (change: ReturnType<typeof buildCalendarChangeFromEvent>) => Promise<number>;
|
||||
}
|
||||
|
||||
export function createCalendarProjectionConsumer(
|
||||
options: CalendarProjectionConsumerOptions = {}
|
||||
): ProjectionConsumer {
|
||||
const persist = options.persist ?? persistCalendarChange;
|
||||
|
||||
return {
|
||||
consumerName: definition.consumerName,
|
||||
projectionName: definition.projectionName,
|
||||
supportedEventTypes: definition.consumedEventTypes,
|
||||
supportedVersions: definition.supportedVersions,
|
||||
async handle(event: BusinessEvent): Promise<ProjectionHandleResult> {
|
||||
const change = buildCalendarChangeFromEvent(event);
|
||||
if (!change) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
message: 'Calendar event has no schedulable date',
|
||||
sideEffectCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
const sideEffectCount = await persist(change);
|
||||
return {
|
||||
status: 'processed',
|
||||
message:
|
||||
change.action === 'delete'
|
||||
? 'Calendar projection activity item removed'
|
||||
: 'Calendar projection item persisted',
|
||||
sideEffectCount
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const calendarProjectionConsumer = createCalendarProjectionConsumer();
|
||||
|
||||
async function persistCalendarChange(
|
||||
change: ReturnType<typeof buildCalendarChangeFromEvent>
|
||||
): Promise<number> {
|
||||
if (!change) return 0;
|
||||
const { deleteCalendarProjectionForActivity, upsertCalendarProjectionItem } = await import(
|
||||
'./service.ts'
|
||||
);
|
||||
|
||||
if (change.action === 'delete') {
|
||||
await deleteCalendarProjectionForActivity({
|
||||
organizationId: change.organizationId,
|
||||
activityId: change.activityId
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
await deleteCalendarProjectionForActivity({
|
||||
organizationId: change.item.organizationId,
|
||||
activityId: change.item.activityId ?? change.item.entityId
|
||||
});
|
||||
await upsertCalendarProjectionItem(change.item);
|
||||
return 1;
|
||||
}
|
||||
51
src/features/crm/calendar/server/rebuild.ts
Normal file
51
src/features/crm/calendar/server/rebuild.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
ProjectionRebuildRequest,
|
||||
ProjectionRebuildResult
|
||||
} from '../../../foundation/projections/types.ts';
|
||||
|
||||
export interface CalendarRebuildPlan {
|
||||
projectionName: 'Calendar';
|
||||
organizationId: string;
|
||||
sources: Array<'activity' | 'opportunity' | 'quotation' | 'approval' | 'business-event-replay'>;
|
||||
resetExisting: boolean;
|
||||
dryRun: boolean;
|
||||
restart: boolean;
|
||||
dateRange: {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function createCalendarRebuildPlan(
|
||||
request: ProjectionRebuildRequest & { restart?: boolean }
|
||||
): CalendarRebuildPlan {
|
||||
return {
|
||||
projectionName: 'Calendar',
|
||||
organizationId: request.organizationId,
|
||||
sources: ['activity', 'opportunity', 'quotation', 'approval', 'business-event-replay'],
|
||||
resetExisting: Boolean(request.resetExisting),
|
||||
dryRun: Boolean(request.dryRun),
|
||||
restart: Boolean(request.restart),
|
||||
dateRange: {
|
||||
from: request.occurredFrom ?? null,
|
||||
to: request.occurredTo ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createCalendarRebuildResult(
|
||||
plan: CalendarRebuildPlan,
|
||||
overrides: Partial<ProjectionRebuildResult> = {}
|
||||
): ProjectionRebuildResult {
|
||||
return {
|
||||
rebuildRunId: `calendar-rebuild:${plan.organizationId}:${Date.now()}`,
|
||||
projectionName: plan.projectionName,
|
||||
organizationId: plan.organizationId,
|
||||
dryRun: plan.dryRun,
|
||||
status: 'pending',
|
||||
processedCount: 0,
|
||||
skippedCount: 0,
|
||||
failedCount: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
3
src/features/crm/calendar/server/registration.ts
Normal file
3
src/features/crm/calendar/server/registration.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { calendarProjectionConsumer } from './consumer.ts';
|
||||
|
||||
export const CRM_CALENDAR_PROJECTION_CONSUMERS = [calendarProjectionConsumer];
|
||||
260
src/features/crm/calendar/server/service.ts
Normal file
260
src/features/crm/calendar/server/service.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { and, asc, eq, gte, ilike, lte, or, type SQL } from 'drizzle-orm';
|
||||
import { crmCalendarProjection } from '@/db/schema';
|
||||
import {
|
||||
buildCrmSecurityContext,
|
||||
canAccessScopedCrmRecord,
|
||||
type CrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { db } from '@/lib/db';
|
||||
import type { CalendarListFilters, CalendarListResponse, CalendarProjectionItem } from '../api/types';
|
||||
|
||||
export type CalendarProjectionRow = typeof crmCalendarProjection.$inferSelect;
|
||||
export type CalendarDbClient = Pick<typeof db, 'insert' | 'select' | 'delete'>;
|
||||
|
||||
export async function upsertCalendarProjectionItem(
|
||||
item: CalendarProjectionItem,
|
||||
client: CalendarDbClient = db
|
||||
): Promise<void> {
|
||||
await client
|
||||
.insert(crmCalendarProjection)
|
||||
.values(toInsertRow(item))
|
||||
.onConflictDoUpdate({
|
||||
target: crmCalendarProjection.id,
|
||||
set: toInsertRow(item)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCalendarProjectionForActivity(
|
||||
input: { organizationId: string; activityId: string },
|
||||
client: CalendarDbClient = db
|
||||
): Promise<void> {
|
||||
await client
|
||||
.delete(crmCalendarProjection)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCalendarProjection.organizationId, input.organizationId),
|
||||
eq(crmCalendarProjection.activityId, input.activityId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearCalendarProjectionForOrganization(
|
||||
organizationId: string,
|
||||
client: CalendarDbClient = db
|
||||
): Promise<void> {
|
||||
await client
|
||||
.delete(crmCalendarProjection)
|
||||
.where(eq(crmCalendarProjection.organizationId, organizationId));
|
||||
}
|
||||
|
||||
export async function listCalendarItems(
|
||||
filters: CalendarListFilters,
|
||||
context: CrmSecurityContext,
|
||||
client = db
|
||||
): Promise<CalendarListResponse> {
|
||||
const limit = Math.min(filters.limit ?? 200, 500);
|
||||
const rows = await client
|
||||
.select()
|
||||
.from(crmCalendarProjection)
|
||||
.where(and(...buildCalendarWhere(filters, context)))
|
||||
.orderBy(asc(crmCalendarProjection.startAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
const visibleItems = rows.map(mapCalendarRow).filter((item) => canViewCalendarItem(context, item));
|
||||
const items = visibleItems.slice(0, limit);
|
||||
|
||||
return {
|
||||
items,
|
||||
summary: summarizeCalendarItems(items),
|
||||
nextCursor: visibleItems.length > limit ? items.at(-1)?.startAt ?? null : null,
|
||||
totalItems: items.length
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCalendarSecurityContext(input: Parameters<typeof buildCrmSecurityContext>[0]) {
|
||||
return buildCrmSecurityContext(input);
|
||||
}
|
||||
|
||||
function buildCalendarWhere(filters: CalendarListFilters, context: CrmSecurityContext): SQL[] {
|
||||
const where: SQL[] = [
|
||||
eq(crmCalendarProjection.organizationId, filters.organizationId),
|
||||
gte(crmCalendarProjection.startAt, new Date(filters.startFrom)),
|
||||
lte(crmCalendarProjection.startAt, new Date(filters.startTo))
|
||||
];
|
||||
|
||||
if (filters.userId) {
|
||||
where.push(
|
||||
or(
|
||||
eq(crmCalendarProjection.ownerId, filters.userId),
|
||||
eq(crmCalendarProjection.assigneeId, filters.userId)
|
||||
)!
|
||||
);
|
||||
} else if (filters.lens === 'personal') {
|
||||
where.push(
|
||||
or(
|
||||
eq(crmCalendarProjection.ownerId, context.userId),
|
||||
eq(crmCalendarProjection.assigneeId, context.userId)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.teamUserIds?.length) {
|
||||
const teamPredicates = filters.teamUserIds.flatMap((userId) => [
|
||||
eq(crmCalendarProjection.ownerId, userId),
|
||||
eq(crmCalendarProjection.assigneeId, userId)
|
||||
]);
|
||||
where.push(or(...teamPredicates)!);
|
||||
}
|
||||
|
||||
if (filters.branchId) where.push(eq(crmCalendarProjection.branchId, filters.branchId));
|
||||
if (filters.productType) where.push(eq(crmCalendarProjection.productType, filters.productType));
|
||||
if (filters.customerId) where.push(eq(crmCalendarProjection.customerId, filters.customerId));
|
||||
if (filters.leadId) where.push(eq(crmCalendarProjection.leadId, filters.leadId));
|
||||
if (filters.opportunityId) where.push(eq(crmCalendarProjection.opportunityId, filters.opportunityId));
|
||||
if (filters.quotationId) where.push(eq(crmCalendarProjection.quotationId, filters.quotationId));
|
||||
if (filters.activityType) where.push(eq(crmCalendarProjection.activityType, filters.activityType));
|
||||
if (filters.category) where.push(eq(crmCalendarProjection.category, filters.category));
|
||||
if (filters.priority) where.push(eq(crmCalendarProjection.priority, filters.priority));
|
||||
if (typeof filters.overdue === 'boolean') where.push(eq(crmCalendarProjection.overdue, filters.overdue));
|
||||
if (typeof filters.milestone === 'boolean') where.push(eq(crmCalendarProjection.milestone, filters.milestone));
|
||||
if (typeof filters.hotProject === 'boolean') {
|
||||
where.push(eq(crmCalendarProjection.hotProject, filters.hotProject));
|
||||
}
|
||||
if (filters.search) {
|
||||
where.push(
|
||||
or(
|
||||
ilike(crmCalendarProjection.title, `%${filters.search}%`),
|
||||
ilike(crmCalendarProjection.summary, `%${filters.search}%`)
|
||||
)!
|
||||
);
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
function canViewCalendarItem(
|
||||
context: CrmSecurityContext,
|
||||
item: CalendarProjectionItem
|
||||
): boolean {
|
||||
const directlyAssigned = item.ownerId === context.userId || item.assigneeId === context.userId;
|
||||
if (item.visibility.internalOnly && !directlyAssigned && context.ownershipScope !== 'organization') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
directlyAssigned ||
|
||||
canAccessScopedCrmRecord(context, {
|
||||
branchId: item.branchId,
|
||||
productType: item.productType,
|
||||
createdBy: item.ownerId,
|
||||
ownerUserId: item.assigneeId ?? item.ownerId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeCalendarItems(items: CalendarProjectionItem[]) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return items.reduce(
|
||||
(summary, item) => {
|
||||
if (item.startAt.slice(0, 10) === today) summary.dueToday += 1;
|
||||
if (item.overdue) summary.overdue += 1;
|
||||
if (item.category === 'meeting') summary.meetings += 1;
|
||||
if (item.category === 'visit') summary.visits += 1;
|
||||
if (item.milestone) summary.milestones += 1;
|
||||
if (item.category === 'approval_milestone') summary.approvalDue += 1;
|
||||
if (item.hotProject) summary.hotProjects += 1;
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
dueToday: 0,
|
||||
overdue: 0,
|
||||
meetings: 0,
|
||||
visits: 0,
|
||||
milestones: 0,
|
||||
approvalDue: 0,
|
||||
hotProjects: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function toInsertRow(item: CalendarProjectionItem): typeof crmCalendarProjection.$inferInsert {
|
||||
return {
|
||||
id: item.id,
|
||||
organizationId: item.organizationId,
|
||||
branchId: item.branchId,
|
||||
productType: item.productType,
|
||||
customerId: item.customerId,
|
||||
leadId: item.leadId,
|
||||
opportunityId: item.opportunityId,
|
||||
quotationId: item.quotationId,
|
||||
approvalId: item.approvalId,
|
||||
activityId: item.activityId,
|
||||
ownerId: item.ownerId,
|
||||
assigneeId: item.assigneeId,
|
||||
eventId: item.eventId,
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
location: item.location,
|
||||
startAt: new Date(item.startAt),
|
||||
endAt: new Date(item.endAt),
|
||||
timezone: item.timezone,
|
||||
priority: item.priority,
|
||||
category: item.category,
|
||||
activityType: item.activityType,
|
||||
editable: item.editable,
|
||||
milestone: item.milestone,
|
||||
overdue: item.overdue,
|
||||
hotProject: item.hotProject,
|
||||
allDay: item.allDay,
|
||||
visibility: item.visibility,
|
||||
metadata: item.metadata,
|
||||
sourceEvent: {
|
||||
eventId: item.eventId,
|
||||
entityType: item.entityType,
|
||||
entityId: item.entityId
|
||||
},
|
||||
sourceProjectionVersion: item.sourceProjectionVersion,
|
||||
updatedAt: new Date(item.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function mapCalendarRow(row: CalendarProjectionRow): CalendarProjectionItem {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
branchId: row.branchId,
|
||||
productType: row.productType,
|
||||
customerId: row.customerId,
|
||||
leadId: row.leadId,
|
||||
opportunityId: row.opportunityId,
|
||||
quotationId: row.quotationId,
|
||||
approvalId: row.approvalId,
|
||||
activityId: row.activityId,
|
||||
ownerId: row.ownerId,
|
||||
assigneeId: row.assigneeId,
|
||||
eventId: row.eventId,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
title: row.title,
|
||||
summary: row.summary,
|
||||
location: row.location,
|
||||
startAt: row.startAt.toISOString(),
|
||||
endAt: row.endAt.toISOString(),
|
||||
timezone: row.timezone,
|
||||
priority: row.priority as CalendarProjectionItem['priority'],
|
||||
category: row.category as CalendarProjectionItem['category'],
|
||||
activityType: row.activityType,
|
||||
editable: row.editable,
|
||||
milestone: row.milestone,
|
||||
overdue: row.overdue,
|
||||
hotProject: row.hotProject,
|
||||
allDay: row.allDay,
|
||||
visibility: row.visibility as CalendarProjectionItem['visibility'],
|
||||
metadata: row.metadata as Record<string, unknown> | null,
|
||||
sourceProjectionVersion: row.sourceProjectionVersion,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
calendarProjectionConsumer,
|
||||
dashboardProjectionConsumer,
|
||||
notificationProjectionConsumer
|
||||
} from '../../../foundation/projections/consumers.ts';
|
||||
import { calendarProjectionConsumer } from '../../calendar/server/consumer.ts';
|
||||
import { timelineProjectionConsumer } from './consumer.ts';
|
||||
|
||||
export const CRM_TIMELINE_PROJECTION_CONSUMERS = [
|
||||
|
||||
Reference in New Issue
Block a user