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,
|
"when": 1783922653023,
|
||||||
"tag": "0004_sharp_mercury",
|
"tag": "0004_sharp_mercury",
|
||||||
"breakpoints": true
|
"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(
|
export const crmCustomers = pgTable(
|
||||||
'crm_customers',
|
'crm_customers',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { formatDateTime } from '@/lib/date-format';
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
import type { ActivityRecord } from '../api/types';
|
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 { ActivityBadge } from './activity-badge';
|
||||||
import { ActivityPriorityBadge } from './activity-priority-badge';
|
import { ActivityPriorityBadge } from './activity-priority-badge';
|
||||||
import { ActivityStatusBadge } from './activity-status-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) {
|
if (!activity) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -27,6 +35,7 @@ export function ActivityDetail({ activity }: { activity: ActivityRecord | null }
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className='space-y-3'>
|
<CardHeader className='space-y-3'>
|
||||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||||
@@ -61,5 +70,14 @@ export function ActivityDetail({ activity }: { activity: ActivityRecord | null }
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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,
|
consumedEventTypes: ACTIVITY_EVENTS,
|
||||||
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
supportedVersions: ACTIVITY_EVENT_VERSIONS,
|
||||||
rebuildStrategy: 'hybrid',
|
rebuildStrategy: 'hybrid',
|
||||||
persistence: 'cached-read-model',
|
persistence: 'materialized',
|
||||||
securityClassification: 'organization-scoped; source-visibility-governed',
|
securityClassification: 'organization-scoped; source-visibility-governed',
|
||||||
idempotencyKey: 'consumerName + eventId',
|
idempotencyKey: 'consumerName + eventId',
|
||||||
retryPolicy: 'projection-default-v1',
|
retryPolicy: 'projection-default-v1',
|
||||||
retentionPolicy: 'event checkpoints retained with projection audit horizon',
|
retentionPolicy: 'event checkpoints retained with projection audit horizon',
|
||||||
implementationStatus: 'contract-only'
|
implementationStatus: 'active'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
projectionName: 'Calendar',
|
projectionName: 'Calendar',
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export class DrizzleProjectionRuntimeStore implements ProjectionRuntimeStore {
|
|||||||
RETURNING business_event_outbox.*
|
RETURNING business_event_outbox.*
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const rows = Array.isArray(result) ? result : result.rows;
|
const rows = result as unknown as Array<typeof businessEventOutbox.$inferSelect>;
|
||||||
return rows.map((row) => mapDelivery(row as typeof businessEventOutbox.$inferSelect));
|
return rows.map((row) => mapDelivery(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
async markDeliveryProcessing(eventId: string, now: Date): Promise<void> {
|
async markDeliveryProcessing(eventId: string, now: Date): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user