task-ep.1.6

This commit is contained in:
phaichayon
2026-07-13 16:29:40 +07:00
parent 2bc8dd184c
commit ab56852c47
23 changed files with 10693 additions and 4 deletions

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
import { calendarProjectionConsumer } from './consumer.ts';
export const CRM_CALENDAR_PROJECTION_CONSUMERS = [calendarProjectionConsumer];

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

View File

@@ -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 = [