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