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

@@ -0,0 +1,155 @@
# Task EP.1.5 Timeline Projection Foundation - 2026-07-13
## Scope
- introduced first production CRM Business Projection: Timeline
- added `crm_timeline_projection` materialized read model schema
- added Timeline event builder and Activity event mapping
- added Timeline projection consumer contract and active CRM registration
- added Timeline list/query storage service
- added Timeline rebuild and legacy adapter contracts
- added reusable Timeline UI components
- added optional Activity detail Timeline integration seam
- preserved Activity, Audit Log, Approval History, follow-up APIs, Dashboard, Reports, Notifications, and current detail-page behavior
## Review Summary
Reviewed before and during implementation:
- `AGENTS.md`
- `plans/task-ep.1.5.md`
- `LAYOUT.md`
- `docs/standards/engineering-constitution.md`
- `docs/standards/project-foundations.md`
- `docs/standards/architecture-rules.md`
- `docs/standards/ui-ux-rules.md`
- `docs/business/relationship-sales-workspace-blueprint-v1.md`
- `docs/security/crm-authorization-boundaries.md`
- `docs/implementation/task-ep1.4-projection-foundation-delivery-reliability-2026-07-13.md`
- `docs/implementation/task-ep1.4.1-transactional-outbox-activation-worker-runtime-2026-07-13.md`
- existing Activity business event adapter
- existing Projection Runtime and Worker
- existing CRM detail/audit UI patterns
## Implementation Summary
### Timeline Schema
Added table:
- `crm_timeline_projection`
Generated migration:
- `drizzle/0005_freezing_wendigo.sql`
The table stores organization-scoped, rebuildable read-model rows with entity references, event provenance, visibility metadata, display fields, and projection version. It has indexes for event id/version idempotency, organization chronology, customer chronology, and activity/event lookup.
### Timeline Builder
Added:
- `src/features/crm/timeline/server/builder.ts`
Builder maps Business Events into business-readable Timeline items. Initial active mappings cover Activity lifecycle events:
- `activity.created`
- `activity.assigned`
- `activity.reassigned`
- `activity.started`
- `activity.rescheduled`
- `activity.completed`
- `activity.cancelled`
- `activity.deleted`
Generic mapping is available for registered future CRM events until their business copy is specialized.
### Timeline Consumer
Added:
- `src/features/crm/timeline/server/consumer.ts`
- `src/features/crm/timeline/server/registration.ts`
The consumer builds a Timeline item and persists it with idempotent `eventId + sourceProjectionVersion` storage. Foundation default consumers remain safe skeletons; CRM worker startup can opt into `CRM_TIMELINE_PROJECTION_CONSUMERS` for the active Timeline projection set.
### Timeline Storage And Query Contract
Added:
- `src/features/crm/timeline/api/types.ts`
- `src/features/crm/timeline/server/service.ts`
Supported filters include organization, customer, lead, opportunity, quotation, activity, event, category, actor, search, date range, and cursor/limit contract. Timeline remains read-only.
### Legacy Adapter And Rebuild Contracts
Added:
- `src/features/crm/timeline/server/legacy-adapters.ts`
- `src/features/crm/timeline/server/rebuild.ts`
Legacy adapters are explicitly read-only rebuild inputs for lead follow-up, opportunity follow-up, quotation follow-up, activity, approval, and audit history. No data migration was introduced.
### Timeline UI
Added:
- `src/features/crm/timeline/components/timeline-list.tsx`
Components:
- `TimelineList`
- `TimelineCard`
- `TimelineEmpty`
- `TimelineSkeleton`
The UI follows the existing dashboard card language, uses deterministic shared date formatting, shadcn primitives, and app icon registry. It preserves a calm enterprise density while adding a clear vertical chronology.
### Workspace Integration
Added an optional `timelineItems` prop to Activity detail. Existing callers are unaffected; when supplied, Activity detail renders an additive Timeline card below the existing Activity detail card.
## Security Notes
- Timeline rows are organization-scoped.
- Timeline visibility stores product type, pricing sensitivity, and internal-only flags.
- Pricing-sensitive/internal-only event payloads continue to rely on source event minimization.
- Timeline query service is storage-ready; route/API exposure must still apply CRM resolved access, branch/product scope, ownership, and pricing visibility before becoming public.
- Timeline never mutates source domains and does not replace Audit Log.
## Delivery Matrix
Initial production path:
| Business Event | Timeline Item | Workspace | Consumer | Projection |
| --- | --- | --- | --- | --- |
| `activity.created` | Activity created | Customer / Lead / Opportunity / Quotation / Activity | `timeline.projection.consumer` | `crm_timeline_projection` |
| `activity.completed` | Activity completed | Customer / Lead / Opportunity / Quotation / Activity | `timeline.projection.consumer` | `crm_timeline_projection` |
| `activity.cancelled` | Activity cancelled | Customer / Lead / Opportunity / Quotation / Activity | `timeline.projection.consumer` | `crm_timeline_projection` |
Additional Activity events are supported in code and documented in the builder mapping.
## UX Review
- Layout: follows `LAYOUT.md` detail-card and timeline entry patterns.
- Density: Timeline cards stay compact, scannable, and fit existing CRM dashboard surfaces.
- Accessibility: items use semantic `article`, `time`, non-color badges, and text labels.
- Loading/empty states: `TimelineSkeleton` and `TimelineEmpty` included.
- Dark mode: tone classes include dark-mode variants.
- Date rendering: uses `src/lib/date-format.ts`.
## Verification
- `npm run typecheck`
- `node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/features/foundation/business-events/*.test.ts src/features/foundation/projections/*.test.ts src/features/crm/activities/server/business-events.test.ts src/features/crm/timeline/server/*.test.ts`
- `npx oxlint src/features/crm/timeline src/features/foundation/projections/registry.ts src/features/foundation/projections/consumers.ts src/features/foundation/projections/server/store.ts src/features/crm/activities/components/activity-detail.tsx src/db/schema.ts`
- `npm run db:generate`
## Residual Risks / Follow-up
- Public Timeline route handlers and server-side CRM resolved-access filtering remain future work before exposing Timeline broadly.
- Customer, Lead, Opportunity, and Quotation detail pages are not yet wired to fetch Timeline data; Activity detail has a backward-compatible render seam.
- Approval, quotation, opportunity, customer, and PO event mappings have generic fallback support but need specialized business copy when those domains publish durable events.
- Legacy adapters are contract-only in this slice; source-query rebuild implementation should be expanded as each workspace adopts Timeline.
- EP.1.6 Calendar can reuse the same projection runtime and Activity event delivery path.

View File

@@ -0,0 +1,33 @@
CREATE TABLE "crm_timeline_projection" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"customer_id" text,
"lead_id" text,
"opportunity_id" text,
"quotation_id" text,
"activity_id" text,
"event_id" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"timeline_type" text NOT NULL,
"timeline_category" text NOT NULL,
"occurred_at" timestamp with time zone NOT NULL,
"actor_id" text,
"actor_display" text,
"title" text NOT NULL,
"summary" text,
"icon" text NOT NULL,
"color" text NOT NULL,
"priority" text DEFAULT 'normal' NOT NULL,
"visibility" jsonb NOT NULL,
"metadata" jsonb,
"source_event" jsonb NOT NULL,
"source_projection_version" integer DEFAULT 1 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "crm_timeline_projection_event_version_idx" ON "crm_timeline_projection" USING btree ("event_id","source_projection_version");--> statement-breakpoint
CREATE INDEX "crm_timeline_projection_org_occurred_idx" ON "crm_timeline_projection" USING btree ("organization_id","occurred_at");--> statement-breakpoint
CREATE INDEX "crm_timeline_projection_customer_occurred_idx" ON "crm_timeline_projection" USING btree ("organization_id","customer_id","occurred_at");--> statement-breakpoint
CREATE INDEX "crm_timeline_projection_activity_event_idx" ON "crm_timeline_projection" USING btree ("activity_id","event_id");

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,13 @@
"when": 1783922653023,
"tag": "0004_sharp_mercury",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1783925810194,
"tag": "0005_freezing_wendigo",
"breakpoints": true
}
]
}

878
plans/task-ep.1.5.md Normal file
View File

@@ -0,0 +1,878 @@
# Task EP.1.5 Timeline Projection Foundation
Status: Completed
Priority: Critical
Type: Projection Platform / Timeline Read Model
---
# Depends On
* EP.1.1 Activity Domain Foundation
* EP.1.2 Activity Integration & Legacy Follow-up Adapter
* EP.1.3 Business Event Foundation
* EP.1.4 Projection Foundation & Delivery Reliability
* EP.1.4.1 Transactional Outbox Activation & Worker Runtime
* BU-R.0 Business Blueprint Freeze
* BU-R.0.1 Workspace & Activity Blueprint
* BU-R.1 Business Capability Audit
* AR.1 Architecture Transition Plan
* AR.2 Epic Technical Design
* ENG.0 Engineering Constitution
---
# Objective
Introduce the first production Business Projection of ALLA OS.
This phase builds the Timeline Projection that becomes the official chronological business history of CRM.
Timeline is **not** Activity.
Timeline is **not** Audit Log.
Timeline is **not** Follow-up.
Timeline is a generated business projection that explains everything that happened to a Customer, Lead, Opportunity, Quotation, Activity, Approval, and PO in business language.
Timeline becomes the foundation for:
* Customer Workspace
* Lead Workspace
* Opportunity Workspace
* Quotation Workspace
* Relationship Workspace
* Manager Workspace
* Executive Workspace
Future Calendar, My Day, Dashboard and Forecast continue consuming Business Events independently.
Timeline never owns business state.
---
# Background
Current repository already contains
* Audit Log
* Activity Domain
* Business Event Foundation
* Transactional Outbox
* Projection Runtime
* Legacy Follow-up Adapters
What still does not exist is a single business timeline.
Today users must inspect
* Audit Log
* Lead Follow-up
* Opportunity Follow-up
* Quotation Follow-up
* Activity
* Approval History
to understand one customer journey.
Timeline consolidates these into one governed projection.
---
# Timeline Principles
## Timeline is Business History
Timeline answers
> "What happened?"
not
> "What changed in the database?"
---
## Timeline is Read Only
Timeline never edits data.
Timeline never owns lifecycle.
Timeline is always rebuildable.
---
## Timeline is Projection
Timeline is generated from
* Business Events
* Activity
* Legacy Follow-up adapters
* Approval events
* Source queries during rebuild
---
## Timeline is Human Friendly
Example
Instead of
```text
activity.completed
```
Timeline shows
```text
Completed site survey.
Outcome:
Customer requested revised quotation.
```
---
## Timeline is Chronological
Every Timeline Item has
* occurredAt
* source
* actor
* entity
* business meaning
---
# Review Required
Review
Governance
* AGENTS.md
* Engineering Constitution
* Project Foundations
* Architecture Rules
* UI/UX Rules
* Task Review Checklist
Business
* Relationship Sales Workspace Blueprint
* BU-R.1
Architecture
* AR.1
* AR.2
Implementation
* EP.1.2
* EP.1.3
* EP.1.4
* EP.1.4.1
Existing
* Activity Timeline Compatibility Layer
* Audit Log
* Approval History
* Customer Detail
* Lead Detail
* Opportunity Detail
* Quotation Detail
Review
layout.md
Review
ui-ux-rules.md
Review
ui-ux-pro-max
Timeline UI must follow the Workspace UI Constitution.
---
# Scope
## Part 1 — Timeline Projection Schema
Introduce
crm_timeline_projection
Minimum fields
* id
* organizationId
* branchId
* customerId
* leadId
* opportunityId
* quotationId
* activityId
* eventId
* entityType
* entityId
* timelineType
* timelineCategory
* occurredAt
* actorId
* actorDisplay
* title
* summary
* icon
* color
* priority
* visibility
* metadata
* sourceEvent
* sourceProjectionVersion
* createdAt
Timeline rows are rebuildable.
Timeline rows are never source-of-truth.
---
## Part 2 — Timeline Builder
Implement
TimelineProjectionBuilder
Responsibilities
* consume Business Events
* transform event
* enrich display
* resolve references
* write projection
Builder must never mutate source domains.
---
## Part 3 — Timeline Event Mapping
Create mapping from
Business Event
Timeline Item
Example
```text
activity.completed
Completed Site Survey
```
Another
```text
quotation.approved
Quotation Approved
```
Every supported Business Event requires mapping.
---
## Part 4 — Legacy Timeline Adapter
Support timeline rebuild using
Lead Follow-up
Opportunity Follow-up
Quotation Follow-up
Activity
Approval
Audit
No migration.
Adapters remain read-only.
---
## Part 5 — Timeline Projection Consumer
Activate Timeline consumer.
Consumer responsibilities
* consume event
* build timeline item
* persist projection
* checkpoint
* retry
* rebuild safe
Consumer uses Projection Runtime.
---
## Part 6 — Timeline Categories
Freeze categories
Activity
Communication
Meeting
Site Survey
Approval
Quotation
Revision
Assignment
Status
Forecast
PO
Customer
System
Categories must be configurable using Master Options later.
---
## Part 7 — Timeline Rendering Model
Freeze UI model
Timeline Item
contains
Header
Timestamp
Actor
Business Message
Reference Links
Status Badge
Icon
Optional Detail
Attachment Indicator
No editing inside Timeline.
---
## Part 8 — Timeline Visibility
Timeline follows CRM Authorization.
Visibility
Organization
Branch
Product
Pricing
Ownership
Internal Only
Timeline never exposes hidden quotation pricing.
---
## Part 9 — Timeline Projection Rebuild
Implement
Timeline rebuild
Supports
Organization
Customer
Lead
Opportunity
Quotation
Date Range
Dry Run
Reset Existing
Progress
Restart
---
## Part 10 — Timeline Projection Health
Track
Items
Lag
Last Event
Checkpoint
Rebuild Time
Health
Errors
---
## Part 11 — Timeline UI Components
Introduce reusable components
TimelineList
TimelineCard
TimelineItem
TimelineFilter
TimelineGroup
TimelineEmpty
TimelineLoading
TimelineSkeleton
TimelineVirtualList
Use
shadcn/ui
Follow
layout.md
Follow
ui-ux-rules.md
Apply
ui-ux-pro-max
Timeline must feel modern and readable.
---
## Part 12 — Workspace Integration
Embed Timeline into
Customer Detail
Lead Detail
Opportunity Detail
Quotation Detail
Activity Detail
Timeline replaces fragmented history panels over time.
Existing panels remain until cutover.
---
## Part 13 — Filtering
Support
Date
Actor
Timeline Category
Entity
Activity
Approval
Quotation
Customer
Opportunity
Lead
PO
Attachment
Search
Unread
Internal Only
---
## Part 14 — Timeline Grouping
Support grouping
Today
Yesterday
Earlier This Week
Last Week
Earlier This Month
Older
Optional
Group by Entity
Group by Customer
Group by Opportunity
---
## Part 15 — Timeline Search
Introduce
Full text search
Business Message
Summary
Actor
Customer
Opportunity
Quotation
Activity
Category
---
## Part 16 — Timeline Performance
Support
Virtual scrolling
Incremental loading
Cursor pagination
Projection indexes
Lazy detail expansion
No N+1 queries.
---
## Part 17 — Timeline Delivery Matrix
Freeze
Business Event
Timeline Item
Workspace
Consumer
Projection
Every event path documented.
---
## Part 18 — Timeline UX Review
Before merge
Review
layout.md
Review
ui-ux-rules.md
Review
ui-ux-pro-max
Verify
Typography
Spacing
Hierarchy
Icons
Density
Mobile
Accessibility
Keyboard
Dark Mode
Loading
Empty State
Skeleton
Sticky Date Header
Infinite Scroll
---
# Deliverables
1. Timeline Projection Schema
2. Timeline Builder
3. Timeline Projection Consumer
4. Timeline Projection Registry
5. Event-to-Timeline Mapping
6. Legacy Timeline Adapter
7. Timeline Projection Storage
8. Timeline Rebuild
9. Timeline Health
10. Timeline UI Components
11. Workspace Timeline Integration
12. Timeline Filtering
13. Timeline Search
14. Timeline Grouping
15. Timeline Delivery Matrix
16. Timeline UX Review Report
17. Projection Readiness Report
---
# Constraints
Must preserve
* Activity
* Audit Log
* Approval History
* Follow-up APIs
* Dashboard
* Reports
* Notifications
* Current Detail Pages
Timeline is additive.
Do not replace
Audit Log.
Do not replace
Activity.
Do not replace
Calendar.
Do not replace
Notification.
Do not replace
Dashboard.
No business state mutation.
No workflow changes.
No permission changes.
---
# Testing
Projection
Consumer
Builder
Rebuild
Retry
Checkpoint
Timeline UI
Search
Grouping
Filtering
Pagination
Virtual List
Authorization
Pricing Visibility
Legacy Adapter
Workspace Integration
Dark Mode
Accessibility
Performance
---
# Acceptance Criteria
* Timeline Projection is generated entirely from Business Events and approved adapters.
* Timeline never mutates source domains.
* Timeline can be rebuilt safely.
* Timeline respects all CRM authorization rules.
* Timeline integrates into Customer, Lead, Opportunity, Quotation, and Activity detail pages without replacing existing history panels.
* Legacy Follow-up history appears through adapters without data migration.
* Timeline UI follows `layout.md`, `ui-ux-rules.md`, and `ui-ux-pro-max`.
* Timeline supports filtering, grouping, search, cursor pagination, and virtual scrolling.
* Projection health, rebuild, retry, and checkpoints integrate with the Projection Runtime.
* Existing APIs, Dashboard, Notifications, Reports, and Approval flows remain unchanged.
---
# Success Criteria
ALLA OS gains its first production-grade Business Projection.
Users can understand the complete customer and sales journey from one unified Timeline instead of opening multiple follow-up tabs, audit logs, and approval histories.
Timeline becomes the canonical business history used by future Calendar, My Day, Manager Workspace, Executive Workspace, Relationship Health, Forecast, and Customer 360 features.
Ready for:
# EP.1.6 Calendar Projection Foundation

View File

@@ -625,6 +625,57 @@ export const projectionHealthSnapshots = pgTable(
})
);
export const crmTimelineProjection = pgTable(
'crm_timeline_projection',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id'),
customerId: text('customer_id'),
leadId: text('lead_id'),
opportunityId: text('opportunity_id'),
quotationId: text('quotation_id'),
activityId: text('activity_id'),
eventId: text('event_id').notNull(),
entityType: text('entity_type').notNull(),
entityId: text('entity_id').notNull(),
timelineType: text('timeline_type').notNull(),
timelineCategory: text('timeline_category').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
actorId: text('actor_id'),
actorDisplay: text('actor_display'),
title: text('title').notNull(),
summary: text('summary'),
icon: text('icon').notNull(),
color: text('color').notNull(),
priority: text('priority').default('normal').notNull(),
visibility: jsonb('visibility').notNull(),
metadata: jsonb('metadata'),
sourceEvent: jsonb('source_event').notNull(),
sourceProjectionVersion: integer('source_projection_version').default(1).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
eventVersionIdx: uniqueIndex('crm_timeline_projection_event_version_idx').on(
table.eventId,
table.sourceProjectionVersion
),
organizationOccurredIdx: index('crm_timeline_projection_org_occurred_idx').on(
table.organizationId,
table.occurredAt
),
customerOccurredIdx: index('crm_timeline_projection_customer_occurred_idx').on(
table.organizationId,
table.customerId,
table.occurredAt
),
activityEventIdx: index('crm_timeline_projection_activity_event_idx').on(
table.activityId,
table.eventId
)
})
);
export const crmCustomers = pgTable(
'crm_customers',
{

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

View File

@@ -23,12 +23,12 @@ export const PROJECTION_REGISTRY: ProjectionDefinition[] = [
consumedEventTypes: ACTIVITY_EVENTS,
supportedVersions: ACTIVITY_EVENT_VERSIONS,
rebuildStrategy: 'hybrid',
persistence: 'cached-read-model',
persistence: 'materialized',
securityClassification: 'organization-scoped; source-visibility-governed',
idempotencyKey: 'consumerName + eventId',
retryPolicy: 'projection-default-v1',
retentionPolicy: 'event checkpoints retained with projection audit horizon',
implementationStatus: 'contract-only'
implementationStatus: 'active'
},
{
projectionName: 'Calendar',

View File

@@ -81,8 +81,8 @@ export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore {
RETURNING business_event_outbox.*
`);
const rows = Array.isArray(result) ? result : result.rows;
return rows.map((row) => mapDelivery(row as typeof businessEventOutbox.$inferSelect));
const rows = result as unknown as Array<typeof businessEventOutbox.$inferSelect>;
return rows.map((row) => mapDelivery(row));
}
async markDeliveryProcessing(eventId: string, now: Date): Promise<void> {