task-ep.1.6

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

View File

@@ -0,0 +1,153 @@
# Task EP.1.6 Calendar Projection, Workspace & Big Calendar Foundation - 2026-07-13
## Scope Delivered
- introduced `crm_calendar_projection` materialized read model schema and migration `drizzle/0006_massive_scalphunter.sql`
- added Calendar projection contracts under `src/features/crm/calendar/**`
- added Activity business-event mapping for scheduled Activity calendar rows
- added terminal Activity event handling that removes completed/cancelled/deleted Activity rows from Calendar projection
- added Calendar projection consumer, registration seam, rebuild plan contract, query service, API route, React Query service, and dashboard workspace route
- added `CalendarProjectionItem -> BigCalendarEvent` adapter so UI never consumes database rows directly
- added Calendar Workspace UI at `/dashboard/crm/calendar` with Agenda, Day, Week, Month tabs, summary cards, search, empty/loading states, and read-only/editable visual distinction
- added Calendar navigation item under CRM
- preserved Activity, Timeline, Dashboard, Reports, Notifications, Approval, Follow-up APIs, and source-domain ownership
## Review Summary
Reviewed before/during implementation:
- `AGENTS.md`
- `plans/task-ep.1.6.md`
- `docs/standards/engineering-constitution.md`
- `docs/standards/project-foundations.md`
- `docs/standards/architecture-rules.md`
- `docs/standards/ui-ux-rules.md`
- `docs/standards/task-review-checklist.md`
- `docs/security/crm-authorization-boundaries.md`
- `docs/business/relationship-sales-workspace-blueprint-v1.md`
- `docs/implementation/task-ar.2-epic-technical-design-2026-07-07.md`
- `docs/implementation/task-ar.2-workspace-ui-ux-design-note-2026-07-07.md`
- `docs/implementation/task-bu-r.0.1-workspace-activity-business-blueprint-2026-07-07.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`
- `docs/implementation/task-ep1.5-timeline-projection-foundation-2026-07-13.md`
- existing Projection Runtime, Activity Business Events, Timeline Projection, CRM security, and date formatting utilities
## Big Calendar Source Audit
Upstream: `https://github.com/lramos33/big-calendar`
Findings:
- Compatibility: upstream is a Next.js, TypeScript, Tailwind, shadcn-oriented calendar app, but targets Next.js 14 and Tailwind v3 while this repo is Next.js 16 and Tailwind v4.
- React/Next: source is useful as UI composition reference, but the upstream app shell, provider model, demo data, and direct event mutation flow are not compatible with ALLA OS projection governance.
- shadcn/Tailwind: visual approach is compatible, but code must be adapted to existing dashboard shell and token usage.
- Accessibility/dark mode/responsive: upstream explicitly supports responsive and dark-mode calendar behavior; ALLA OS implementation keeps shadcn primitives, semantic cards, badges, buttons, and deterministic date rendering.
- Timezone: upstream uses general date-fns/date string assumptions; ALLA OS calendar rendering uses `src/lib/date-format.ts` and stores projection timezone as `Asia/Bangkok`.
- Drag/drop: upstream supports direct drag/drop rescheduling. ALLA OS does not persist Calendar mutations directly; drag/resize must call Activity APIs, publish Business Events, and refresh the projection.
- License: upstream repository is MIT licensed.
Adoption decision:
- Adopted conceptually: Agenda/Day/Week/Month views, toolbar/search pattern, event rendering, editable vs read-only event behavior, user/lens filter seam, working-hours/timezone constraints.
- Rejected: upstream app shell, backend assumptions, mock event store, direct event CRUD, global Calendar context, uncontrolled user lists, direct drag/drop persistence.
- Modification strategy: implement native ALLA OS components under `src/features/crm/calendar/`, keep projection adapter as boundary, and wire future drag/resize only through Activity services.
## Implementation Summary
### Calendar Schema
Added `crm_calendar_projection` with organization, branch, product, source refs, activity/customer/lead/opportunity/quotation/approval refs, owner/assignee, event provenance, title/summary/location, start/end/timezone, priority/category, editable/milestone/overdue/hot-project/all-day flags, visibility metadata, source event, projection version, timestamps.
Indexes cover organization/date range, owner/assignee/date range, branch/date range, category/date range, customer/date range, activity/event lookup, and event/version idempotency.
### Calendar Builder and Consumer
Activity events supported:
- `activity.created`
- `activity.assigned`
- `activity.reassigned`
- `activity.started`
- `activity.rescheduled`
- `activity.completed`
- `activity.cancelled`
- `activity.deleted`
Non-terminal scheduled Activity events upsert one current Calendar row per Activity. Terminal events delete the Activity calendar row so Calendar remains a forward-looking operational workspace, not a history timeline.
### Query and Security
Calendar query service supports:
- date range
- user/team user IDs
- branch/product
- customer/lead/opportunity/quotation
- activity type/category/priority
- overdue/milestone/hot-project flags
- search
- Personal/Team/Manager/Executive lens contract
The service applies bounded SQL first, then server-side CRM scope checks through `CrmSecurityContext` and `canAccessScopedCrmRecord()`. Owner/assignee direct visibility is preserved. Internal-only rows remain restricted unless directly assigned or organization-wide scope applies.
### Workspace
Added `/dashboard/crm/calendar` using `PageContainer`, React Query prefetch/hydration, and existing shadcn UI primitives.
Workspace includes:
- summary cards: Due Today, Overdue, Meetings, Hot Projects
- Agenda default view
- Day/Week/Month tabs
- search
- empty/loading states
- event cards with read-only milestone vs editable activity distinction
### Rebuild and Health
Calendar rebuild contract added as a plan/result helper for hybrid rebuild:
- sources: Activity, Opportunity, Quotation, Approval, Business Event replay
- supports organization/date range/dry-run/reset/restart contract
Runtime checkpoint/retry/dead-letter behavior remains owned by Projection Runtime. Calendar consumer can be registered with runtime consumers without changing source-domain writes.
## Delivery Matrix
| Source | Business Event / Source Signal | Projection | Calendar Item | Workspace |
| --- | --- | --- | --- | --- |
| Activity | `activity.created`, assigned, reassigned, started, rescheduled | `crm_calendar_projection` | editable Activity event | Calendar Agenda/Day/Week/Month |
| Activity | `activity.completed`, cancelled, deleted | delete projection row | removed from forward schedule | Calendar refresh |
| Opportunity | Expected Award / Expected Delivery / Hot Project | rebuild contract seam | read-only milestone | future source adapter |
| Quotation | Valid Until / Delivery milestone | rebuild contract seam | read-only milestone | future source adapter |
| Approval | Due / Escalation | rebuild contract seam | read-only milestone | future source adapter |
## UI/UX Review
- Layout follows AR.2 Calendar Workspace note: PageContainer, header, compact filter/search, calendar body, summary cards.
- Agenda is default manager/mobile-friendly dense view.
- Day/Week/Month views are available as foundation tabs.
- State coverage includes loading skeleton and empty state.
- Status is not color-only: badges show overdue/hot-project/read-only/category text.
- Date/time rendering uses `src/lib/date-format.ts`.
- UI avoids importing upstream shell, backend, provider, or mock data.
## Known Follow-ups
- Opportunity, Quotation, and Approval milestone projection adapters are contract-ready but not populated until those domains publish durable milestone events or rebuild source adapters are expanded.
- Drag/drop and resize UI actions are represented as Activity-only affordances, but direct mutation wiring is intentionally deferred until Activity reschedule API integration is added to Calendar UI.
- User/team filter contract exists; UI currently exposes lens/search foundation only. Manager team hierarchy should be wired after team graph/scope UX is formalized.
- Working hours and conflict detection are schema/service-ready concepts but need fuller UI and source preference integration in a follow-up slice.
- `INITIAL_PROJECTION_CONSUMERS` remains skeleton-safe for foundation runtime tests; CRM runtime composition should opt into `CRM_CALENDAR_PROJECTION_CONSUMERS` or combined CRM registration when running production projection workers.
## Verification
- `npm run typecheck`
- `node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test src/features/crm/calendar/server/builder.test.ts src/features/crm/timeline/server/builder.test.ts src/features/foundation/projections/runtime.test.ts`
- `npx oxlint src/features/crm/calendar src/app/api/crm/calendar src/app/dashboard/crm/calendar src/features/foundation/projections/consumers.ts src/features/foundation/projections/registry.ts src/features/crm/timeline/server/registration.ts src/db/schema.ts`
- `npm run db:generate`
## Outcome
EP.1.6 now has a governed Calendar Projection and first Calendar Workspace foundation. Calendar remains additive, rebuildable, and non-authoritative. Activity remains the only editable operational work source; Timeline remains unchanged as historical chronology.

View File

@@ -0,0 +1,46 @@
CREATE TABLE "crm_calendar_projection" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"product_type" text,
"customer_id" text,
"lead_id" text,
"opportunity_id" text,
"quotation_id" text,
"approval_id" text,
"activity_id" text,
"owner_id" text,
"assignee_id" text,
"event_id" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"title" text NOT NULL,
"summary" text,
"location" text,
"start_at" timestamp with time zone NOT NULL,
"end_at" timestamp with time zone NOT NULL,
"timezone" text DEFAULT 'Asia/Bangkok' NOT NULL,
"priority" text DEFAULT 'normal' NOT NULL,
"category" text NOT NULL,
"activity_type" text,
"editable" boolean DEFAULT false NOT NULL,
"milestone" boolean DEFAULT false NOT NULL,
"overdue" boolean DEFAULT false NOT NULL,
"hot_project" boolean DEFAULT false NOT NULL,
"all_day" boolean DEFAULT false 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,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "crm_calendar_projection_event_version_idx" ON "crm_calendar_projection" USING btree ("event_id","source_projection_version");--> statement-breakpoint
CREATE INDEX "crm_calendar_projection_org_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","start_at");--> statement-breakpoint
CREATE INDEX "crm_calendar_projection_org_assignee_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","assignee_id","start_at");--> statement-breakpoint
CREATE INDEX "crm_calendar_projection_org_owner_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","owner_id","start_at");--> statement-breakpoint
CREATE INDEX "crm_calendar_projection_org_branch_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","branch_id","start_at");--> statement-breakpoint
CREATE INDEX "crm_calendar_projection_org_category_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","category","start_at");--> statement-breakpoint
CREATE INDEX "crm_calendar_projection_customer_start_idx" ON "crm_calendar_projection" USING btree ("organization_id","customer_id","start_at");--> statement-breakpoint
CREATE INDEX "crm_calendar_projection_activity_event_idx" ON "crm_calendar_projection" USING btree ("organization_id","activity_id","event_id");

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1783925810194,
"tag": "0005_freezing_wendigo",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1783931878587,
"tag": "0006_massive_scalphunter",
"breakpoints": true
}
]
}

844
plans/task-ep.1.6.md Normal file
View File

@@ -0,0 +1,844 @@
# Task EP.1.6 Calendar Projection, Workspace & Big Calendar Foundation
**Status:** Foundation Implemented
**Priority:** Critical
**Type:** Projection Platform / Workspace / UI Foundation
---
# 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
* EP.1.5 Timeline Projection Foundation
* 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
* AR.2 Workspace UI/UX Design Note
* ENG.0 Engineering Constitution
---
# Objective
Introduce the first production Calendar Workspace of ALLA OS by combining:
* Calendar Projection
* Calendar Workspace
* Big Calendar UI Foundation
* Activity scheduling
* Business milestones
* Team visibility
* Calendar interaction
Calendar becomes the operational scheduling workspace for CRM while preserving existing source-domain ownership.
Calendar is **NOT**:
* a source-of-truth
* another Activity module
* another Opportunity module
* another Quotation module
Calendar is a governed projection and operational workspace.
---
# Business Outcome
After EP.1.6 users can answer:
* What should I do today?
* Which customers should I visit?
* Which follow-ups are overdue?
* Which quotations are expiring?
* Which projects are Hot Projects?
* Which approvals are waiting?
* Which customer visits are scheduled?
* What is my team's workload today?
* What are tomorrow's priorities?
Managers gain a Team Calendar without creating duplicate business data.
---
# Calendar Business Principles
## Calendar is Projection
Calendar is generated from:
* Activity
* Business Events
* Opportunity milestones
* Quotation milestones
* Approval milestones
* Legacy adapters during rebuild
Calendar rows are rebuildable.
---
## Activity Owns Operational Work
Editable Calendar events always belong to Activity.
Examples:
* Follow-up
* Meeting
* Site Survey
* Visit
* Internal Task
* Phone Call
* Reminder
Calendar actions must call Activity Services.
Calendar never edits projection rows directly.
---
## Milestones Remain Source-Owned
Read-only Calendar milestones include:
* Expected Award Date
* Expected Delivery Date
* Quotation Expiration
* Approval Due
* Approval Escalation
* PO Milestones
Editing must always occur through the owning domain.
---
## Calendar ≠ Timeline
Timeline answers:
> What happened?
Calendar answers:
> What must happen?
Both consume Business Events but serve different business purposes.
---
# UI Foundation Decision
The Calendar UI must adopt:
**lramos33/big-calendar**
Repository:
```text
https://github.com/lramos33/big-calendar
```
This repository becomes the Calendar UI foundation only.
It does **NOT** become:
* Calendar database
* Calendar backend
* Activity service
* Projection runtime
* CRM authorization
* Source-of-truth
The governed architecture remains:
```text
Activity / Opportunity / Quotation / Approval
Business Events
Projection Runtime
crm_calendar_projection
Calendar Adapter
lramos33/big-calendar
```
---
# Review Required
## Governance
Review:
* AGENTS.md
* Engineering Constitution
* Project Foundations
* Architecture Rules
* UI UX Rules
* Task Review Checklist
* CRM Authorization Boundaries
* LAYOUT.md
## Business
Review:
* Relationship Sales Workspace Blueprint
* BU-R.1
## Architecture
Review:
* AR.1
* AR.2
* Workspace UI UX Design Note
## Existing Runtime
Review:
* Projection Runtime
* Worker Runtime
* Timeline Projection
* Activity Service
* Business Event Registry
* Calendar-related Activity scheduling
* Date formatting utilities
* CRM security
## UI Review
Must use:
* ui-ux-pro-max
* LAYOUT.md
* ui-ux-rules.md
No code-first Calendar implementation is allowed.
---
# Scope
# Part 1 — Calendar Projection Schema
Introduce:
```text
crm_calendar_projection
```
Containing:
* organization
* branch
* product
* activity
* customer
* lead
* opportunity
* quotation
* approval
* owner
* assignee
* title
* summary
* location
* start
* end
* timezone
* priority
* category
* editable
* milestone
* overdue
* hot project
* projection version
* source event
* metadata
Projection rows remain rebuildable.
---
# Part 2 — Calendar Projection Builder
Implement
CalendarProjectionBuilder
Responsibilities
* Business Event mapping
* milestone mapping
* display enrichment
* permission metadata
* icon registry
* urgency calculation
* projection persistence
Builder never mutates source domains.
---
# Part 3 — Activity Event Mapping
Support
* activity.created
* activity.assigned
* activity.reassigned
* activity.started
* activity.rescheduled
* activity.completed
* activity.cancelled
* activity.deleted
Projection updates must be incremental through Business Events.
---
# Part 4 — Milestone Projection
Project:
Opportunity
* Expected Award Date
* Expected Delivery Date
* Hot Project review
Quotation
* Valid Until
* Delivery milestone
Approval
* Due
* Escalation
Milestones are read-only.
---
# Part 5 — Calendar Projection Consumer
Activate Calendar consumer through Projection Runtime.
Must support:
* checkpoint
* retry
* dead letter
* rebuild
* idempotency
---
# Part 6 — Calendar Query Service
Provide secured query service.
Filters:
* date range
* user
* team
* branch
* product
* customer
* lead
* opportunity
* quotation
* activity type
* category
* priority
* overdue
* milestone
* Hot Project
* search
Supported lenses:
* Personal
* Team
* Manager
* Executive
---
# Part 7 — Calendar Rebuild
Hybrid rebuild.
Initial build:
* Activity
* Opportunity
* Quotation
* Approval
Incremental:
* Business Events
Support:
* organization
* customer
* opportunity
* quotation
* date range
* dry run
* reset existing
* restart
---
# Part 8 — Calendar Workspace
Route:
```text
/dashboard/crm/calendar
```
Views:
* Day
* Week
* Month
* Agenda
Agenda is default for:
* mobile
* manager
* high-density work
---
# Part 9 — Big Calendar Source Audit
Before implementation:
Audit:
* dependency compatibility
* React compatibility
* Next.js compatibility
* shadcn compatibility
* Tailwind compatibility
* accessibility
* dark mode
* timezone assumptions
* drag/drop libraries
* demo code
* mock data
Document:
* files adopted
* files rejected
* modification strategy
---
# Part 10 — Big Calendar Adaptation
Do **NOT** import the whole repository.
Only adapt:
* Calendar Views
* Toolbar
* Event Rendering
* Drag & Drop
* Resize
* Navigation
* User Filter
* Working Hours
Move adapted source into:
```text
src/features/crm/calendar/
```
Maintain ALLA OS architecture.
---
# Part 11 — Calendar Adapter Layer
Introduce
CalendarProjectionItem
BigCalendarEvent
Implement
```ts
mapCalendarProjectionToBigCalendarEvent()
```
UI must never consume database rows directly.
---
# Part 12 — Calendar Rendering Strategy
Differentiate:
## Editable Activity
Examples
* Follow-up
* Meeting
* Visit
* Site Survey
Supports
* Complete
* Reschedule
* Cancel
* Open
* Reassign
---
## Read-only Milestone
Examples
* Expected Award
* Expected Delivery
* Approval Due
* Quotation Expiration
Supports
* Open Source
* View Detail
* Create Related Activity
No editing.
---
# Part 13 — Drag and Drop
For Activities
Drag
Activity API
Business Event
Projection Update
Calendar Refresh
No direct Calendar persistence.
Milestones cannot be dragged.
---
# Part 14 — User Filter
Integrate upstream user filter with:
* CRM permissions
* team hierarchy
* ownership
* manager scope
Never load unauthorized users.
---
# Part 15 — Working Hours
Support
* organization defaults
* user preferences
* Bangkok timezone
* all-day events
* current time
* business hours
---
# Part 16 — Schedule Conflict Detection
Detect
* overlapping visits
* meetings
* site surveys
* tasks
Read-only advisory.
No blocking.
---
# Part 17 — Calendar Summary
Display
* Due Today
* Overdue
* Meetings
* Visits
* Milestones
* Approval Due
* Hot Projects
Derived from Calendar projection.
---
# Part 18 — Calendar Performance
Support
* indexes
* bounded queries
* lazy loading
* virtual agenda
* cursor pagination
* projection batching
No N+1.
---
# Part 19 — Projection Health
Track
* lag
* retries
* dead letters
* row count
* stale rows
* rebuild status
---
# Part 20 — Calendar Delivery Matrix
Document
Source
Business Event
Projection
Calendar Item
Workspace
Include:
* Activity
* Opportunity
* Quotation
* Approval
---
# Part 21 — Calendar UI / UX Review
Review with
* ui-ux-pro-max
* LAYOUT.md
* ui-ux-rules.md
Validate
* hierarchy
* spacing
* typography
* icons
* filters
* density
* accessibility
* dark mode
* responsiveness
* keyboard
* loading
* empty state
---
# Part 22 — Upstream License
Retain
MIT License
Document
* adopted files
* attribution
* modification history
---
# Deliverables
1. Calendar Projection Schema
2. Calendar Builder
3. Calendar Projection Consumer
4. Calendar Query Service
5. Calendar Rebuild
6. Calendar Workspace
7. Day / Week / Month / Agenda
8. Big Calendar Source Audit
9. Big Calendar Adaptation
10. Calendar Adapter Layer
11. Activity Calendar Actions
12. Milestone Projection
13. Drag & Drop Integration
14. User Filter Integration
15. Working Hours Support
16. Schedule Conflict Detection
17. Calendar Summary Panels
18. Projection Health
19. Calendar Delivery Matrix
20. UI/UX Review Report
21. Upstream License & Attribution Report
22. EP.1.7 Readiness Report
---
# Constraints
Must preserve:
* Activity ownership
* Opportunity ownership
* Quotation ownership
* Approval ownership
* Timeline
* Dashboard
* Reports
* Notifications
* Audit Log
* CRM security
* Projection Runtime
Do NOT implement:
* Google Calendar Sync
* Outlook Sync
* recurring engine
* My Day
* Manager Workspace
* Executive Workspace
* Relationship Health
* Forecast
* automatic Activity generation
* legacy migration
Calendar remains an additive projection.
---
# Testing
* Projection
* Builder
* Consumer
* Rebuild
* Activity Events
* Milestones
* Query
* Permissions
* Drag & Drop
* Resize
* Agenda
* Day
* Week
* Month
* Timezone
* Conflict Detection
* UI
* Accessibility
* Dark Mode
* Performance
* Compatibility
---
# Acceptance Criteria
* Calendar Projection is fully rebuildable.
* Calendar uses Business Events for incremental updates.
* Activity remains the only editable operational work source.
* Opportunity, Quotation, and Approval milestones are projected as read-only Calendar items.
* `lramos33/big-calendar` is successfully adopted as the UI foundation without importing its application shell or backend.
* A dedicated adapter maps `CalendarProjectionItem` to the Calendar UI event model.
* Drag-and-drop and resize update Activities only through Activity Services and Business Events.
* Read-only milestones cannot be dragged or resized.
* Personal, Team, Manager, and Executive lenses enforce existing CRM authorization.
* Day, Week, Month, and Agenda views are available, with Agenda optimized for managers and mobile.
* Working hours, timezone, and schedule conflict detection behave consistently.
* Calendar integrates with the Projection Runtime (checkpoint, retry, rebuild, health).
* UI conforms to `LAYOUT.md`, `ui-ux-rules.md`, and `ui-ux-pro-max`.
* Existing Activity, Timeline, Dashboard, Reports, Notifications, Approval flows, and Follow-up APIs remain unchanged.
---
# Success Criteria
ALLA OS gains a production-ready Calendar Workspace built on a governed Projection architecture.
Users can plan and execute daily operational work from a single Calendar while preserving Activity, Opportunity, Quotation, and Approval as the authoritative business domains.
The Calendar foundation is ready for:
**EP.1.7 My Day Workspace Foundation**

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
import { listCalendarItems } from '@/features/crm/calendar/server/service';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const calendarQuerySchema = z.object({
startFrom: z.string().datetime(),
startTo: z.string().datetime(),
lens: z.enum(['personal', 'team', 'manager', 'executive']).optional(),
userId: z.string().optional(),
teamUserIds: z
.string()
.optional()
.transform((value) => value?.split(',').filter(Boolean)),
branchId: z.string().optional(),
productType: z.string().optional(),
customerId: z.string().optional(),
leadId: z.string().optional(),
opportunityId: z.string().optional(),
quotationId: z.string().optional(),
activityType: z.string().optional(),
category: z
.enum([
'activity',
'meeting',
'visit',
'site_survey',
'task',
'reminder',
'opportunity_milestone',
'quotation_milestone',
'approval_milestone'
])
.optional(),
priority: z.enum(['low', 'normal', 'high', 'critical']).optional(),
overdue: z.coerce.boolean().optional(),
milestone: z.coerce.boolean().optional(),
hotProject: z.coerce.boolean().optional(),
search: z.string().optional(),
limit: z.coerce.number().int().positive().max(500).optional(),
cursor: z.string().optional()
});
export async function GET(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess({
permission: CRM_ACTIVITY_PERMISSIONS.read
});
const query = calendarQuerySchema.parse({
startFrom: request.nextUrl.searchParams.get('startFrom') ?? undefined,
startTo: request.nextUrl.searchParams.get('startTo') ?? undefined,
lens: request.nextUrl.searchParams.get('lens') ?? undefined,
userId: request.nextUrl.searchParams.get('userId') ?? undefined,
teamUserIds: request.nextUrl.searchParams.get('teamUserIds') ?? undefined,
branchId: request.nextUrl.searchParams.get('branchId') ?? undefined,
productType: request.nextUrl.searchParams.get('productType') ?? undefined,
customerId: request.nextUrl.searchParams.get('customerId') ?? undefined,
leadId: request.nextUrl.searchParams.get('leadId') ?? undefined,
opportunityId: request.nextUrl.searchParams.get('opportunityId') ?? undefined,
quotationId: request.nextUrl.searchParams.get('quotationId') ?? undefined,
activityType: request.nextUrl.searchParams.get('activityType') ?? undefined,
category: request.nextUrl.searchParams.get('category') ?? undefined,
priority: request.nextUrl.searchParams.get('priority') ?? undefined,
overdue: request.nextUrl.searchParams.get('overdue') ?? undefined,
milestone: request.nextUrl.searchParams.get('milestone') ?? undefined,
hotProject: request.nextUrl.searchParams.get('hotProject') ?? undefined,
search: request.nextUrl.searchParams.get('search') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
cursor: request.nextUrl.searchParams.get('cursor') ?? undefined
});
const context = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const response = await listCalendarItems(
{
...query,
organizationId: organization.id
},
context
);
return NextResponse.json(response);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json({ message: 'Invalid calendar filters' }, { status: 400 });
}
console.error('[crm/calendar] failed to list calendar projection', error);
return NextResponse.json({ message: 'Failed to load calendar' }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { Suspense } from 'react';
import PageContainer from '@/components/layout/page-container';
import {
CalendarWorkspace,
CalendarWorkspaceSkeleton
} from '@/features/crm/calendar/components/calendar-workspace';
import { calendarItemsQueryOptions } from '@/features/crm/calendar/api/queries';
import { getQueryClient } from '@/lib/query-client';
function defaultCalendarRange() {
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0));
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0, 23, 59, 59));
return {
startFrom: start.toISOString(),
startTo: end.toISOString(),
lens: 'personal' as const,
limit: 300
};
}
export default function CrmCalendarPage() {
const queryClient = getQueryClient();
const filters = defaultCalendarRange();
void queryClient.prefetchQuery(calendarItemsQueryOptions(filters));
return (
<PageContainer
pageTitle='Calendar'
pageDescription='Operational schedule workspace for activities and governed CRM milestones.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<CalendarWorkspaceSkeleton />}>
<CalendarWorkspace filters={filters} />
</Suspense>
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -12,6 +12,14 @@ import { NavGroup } from "@/types";
items: [],
access: { requireOrg: true, permission: "crm.dashboard.read" },
},
{
title: "Calendar",
url: "/dashboard/crm/calendar",
icon: "calendar",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.activity.read" },
},
{
title: "ลูกค้า",
url: "/dashboard/crm/customers",

View File

@@ -676,6 +676,87 @@ export const crmTimelineProjection = pgTable(
})
);
export const crmCalendarProjection = pgTable(
'crm_calendar_projection',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id'),
productType: text('product_type'),
customerId: text('customer_id'),
leadId: text('lead_id'),
opportunityId: text('opportunity_id'),
quotationId: text('quotation_id'),
approvalId: text('approval_id'),
activityId: text('activity_id'),
ownerId: text('owner_id'),
assigneeId: text('assignee_id'),
eventId: text('event_id').notNull(),
entityType: text('entity_type').notNull(),
entityId: text('entity_id').notNull(),
title: text('title').notNull(),
summary: text('summary'),
location: text('location'),
startAt: timestamp('start_at', { withTimezone: true }).notNull(),
endAt: timestamp('end_at', { withTimezone: true }).notNull(),
timezone: text('timezone').default('Asia/Bangkok').notNull(),
priority: text('priority').default('normal').notNull(),
category: text('category').notNull(),
activityType: text('activity_type'),
editable: boolean('editable').default(false).notNull(),
milestone: boolean('milestone').default(false).notNull(),
overdue: boolean('overdue').default(false).notNull(),
hotProject: boolean('hot_project').default(false).notNull(),
allDay: boolean('all_day').default(false).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(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
eventVersionIdx: uniqueIndex('crm_calendar_projection_event_version_idx').on(
table.eventId,
table.sourceProjectionVersion
),
organizationStartIdx: index('crm_calendar_projection_org_start_idx').on(
table.organizationId,
table.startAt
),
organizationAssigneeStartIdx: index('crm_calendar_projection_org_assignee_start_idx').on(
table.organizationId,
table.assigneeId,
table.startAt
),
organizationOwnerStartIdx: index('crm_calendar_projection_org_owner_start_idx').on(
table.organizationId,
table.ownerId,
table.startAt
),
organizationBranchStartIdx: index('crm_calendar_projection_org_branch_start_idx').on(
table.organizationId,
table.branchId,
table.startAt
),
organizationCategoryStartIdx: index('crm_calendar_projection_org_category_start_idx').on(
table.organizationId,
table.category,
table.startAt
),
customerStartIdx: index('crm_calendar_projection_customer_start_idx').on(
table.organizationId,
table.customerId,
table.startAt
),
activityEventIdx: index('crm_calendar_projection_activity_event_idx').on(
table.organizationId,
table.activityId,
table.eventId
)
})
);
export const crmCustomers = pgTable(
'crm_customers',
{

View File

@@ -0,0 +1,18 @@
import { queryOptions } from '@tanstack/react-query';
import { getCalendarItems } from './service';
import type { CalendarListFilters } from './types';
export const calendarKeys = {
all: ['crm-calendar'] as const,
lists: () => [...calendarKeys.all, 'list'] as const,
list: (filters: Omit<CalendarListFilters, 'organizationId'>) =>
[...calendarKeys.lists(), filters] as const
};
export const calendarItemsQueryOptions = (
filters: Omit<CalendarListFilters, 'organizationId'>
) =>
queryOptions({
queryKey: calendarKeys.list(filters),
queryFn: () => getCalendarItems(filters)
});

View File

@@ -0,0 +1,34 @@
import { apiClient } from '@/lib/api-client';
import type { CalendarListFilters, CalendarListResponse } from './types';
function buildSearchParams(filters: Omit<CalendarListFilters, 'organizationId'>): string {
const params = new URLSearchParams();
params.set('startFrom', filters.startFrom);
params.set('startTo', filters.startTo);
if (filters.lens) params.set('lens', filters.lens);
if (filters.userId) params.set('userId', filters.userId);
if (filters.teamUserIds?.length) params.set('teamUserIds', filters.teamUserIds.join(','));
if (filters.branchId) params.set('branchId', filters.branchId);
if (filters.productType) params.set('productType', filters.productType);
if (filters.customerId) params.set('customerId', filters.customerId);
if (filters.leadId) params.set('leadId', filters.leadId);
if (filters.opportunityId) params.set('opportunityId', filters.opportunityId);
if (filters.quotationId) params.set('quotationId', filters.quotationId);
if (filters.activityType) params.set('activityType', filters.activityType);
if (filters.category) params.set('category', filters.category);
if (filters.priority) params.set('priority', filters.priority);
if (typeof filters.overdue === 'boolean') params.set('overdue', String(filters.overdue));
if (typeof filters.milestone === 'boolean') params.set('milestone', String(filters.milestone));
if (typeof filters.hotProject === 'boolean') params.set('hotProject', String(filters.hotProject));
if (filters.search) params.set('search', filters.search);
if (filters.limit) params.set('limit', String(filters.limit));
if (filters.cursor) params.set('cursor', filters.cursor);
return params.toString();
}
export async function getCalendarItems(
filters: Omit<CalendarListFilters, 'organizationId'>
): Promise<CalendarListResponse> {
const search = buildSearchParams(filters);
return apiClient<CalendarListResponse>(`/crm/calendar${search ? `?${search}` : ''}`);
}

View File

@@ -0,0 +1,128 @@
export type CalendarCategory =
| 'activity'
| 'meeting'
| 'visit'
| 'site_survey'
| 'task'
| 'reminder'
| 'opportunity_milestone'
| 'quotation_milestone'
| 'approval_milestone';
export type CalendarPriority = 'low' | 'normal' | 'high' | 'critical';
export type CalendarLens = 'personal' | 'team' | 'manager' | 'executive';
export type CalendarView = 'day' | 'week' | 'month' | 'agenda';
export interface CalendarVisibility {
productType?: string | null;
pricingSensitive: boolean;
internalOnly: boolean;
}
export interface CalendarProjectionItem {
id: string;
organizationId: string;
branchId: string | null;
productType: string | null;
customerId: string | null;
leadId: string | null;
opportunityId: string | null;
quotationId: string | null;
approvalId: string | null;
activityId: string | null;
ownerId: string | null;
assigneeId: string | null;
eventId: string;
entityType: string;
entityId: string;
title: string;
summary: string | null;
location: string | null;
startAt: string;
endAt: string;
timezone: string;
priority: CalendarPriority;
category: CalendarCategory;
activityType: string | null;
editable: boolean;
milestone: boolean;
overdue: boolean;
hotProject: boolean;
allDay: boolean;
visibility: CalendarVisibility;
metadata: Record<string, unknown> | null;
sourceProjectionVersion: number;
createdAt: string;
updatedAt: string;
}
export interface CalendarListFilters {
organizationId: string;
startFrom: string;
startTo: string;
lens?: CalendarLens;
userId?: string;
teamUserIds?: string[];
branchId?: string;
productType?: string;
customerId?: string;
leadId?: string;
opportunityId?: string;
quotationId?: string;
activityType?: string;
category?: CalendarCategory;
priority?: CalendarPriority;
overdue?: boolean;
milestone?: boolean;
hotProject?: boolean;
search?: string;
limit?: number;
cursor?: string;
}
export interface CalendarSummary {
dueToday: number;
overdue: number;
meetings: number;
visits: number;
milestones: number;
approvalDue: number;
hotProjects: number;
}
export interface CalendarListResponse {
items: CalendarProjectionItem[];
summary: CalendarSummary;
nextCursor: string | null;
totalItems: number;
}
export interface BigCalendarEvent {
id: string;
title: string;
start: string;
end: string;
allDay: boolean;
editable: boolean;
draggable: boolean;
resizable: boolean;
category: CalendarCategory;
priority: CalendarPriority;
source: {
entityType: string;
entityId: string;
activityId: string | null;
customerId: string | null;
leadId: string | null;
opportunityId: string | null;
quotationId: string | null;
approvalId: string | null;
};
display: {
summary: string | null;
location: string | null;
overdue: boolean;
hotProject: boolean;
milestone: boolean;
};
}

View File

@@ -0,0 +1,37 @@
import type { BigCalendarEvent, CalendarProjectionItem } from '../api/types';
export function mapCalendarProjectionToBigCalendarEvent(
item: CalendarProjectionItem
): BigCalendarEvent {
const activityEditable = item.editable && !item.milestone && item.activityId !== null;
return {
id: item.id,
title: item.title,
start: item.startAt,
end: item.endAt,
allDay: item.allDay,
editable: activityEditable,
draggable: activityEditable,
resizable: activityEditable,
category: item.category,
priority: item.priority,
source: {
entityType: item.entityType,
entityId: item.entityId,
activityId: item.activityId,
customerId: item.customerId,
leadId: item.leadId,
opportunityId: item.opportunityId,
quotationId: item.quotationId,
approvalId: item.approvalId
},
display: {
summary: item.summary,
location: item.location,
overdue: item.overdue,
hotProject: item.hotProject,
milestone: item.milestone
}
};
}

View File

@@ -0,0 +1,295 @@
'use client';
import { useMemo, useState } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Icons } from '@/components/icons';
import { cn } from '@/lib/utils';
import { formatDate, formatTime } from '@/lib/date-format';
import { calendarItemsQueryOptions } from '../api/queries';
import type { BigCalendarEvent, CalendarListFilters, CalendarView } from '../api/types';
import { mapCalendarProjectionToBigCalendarEvent } from './calendar-adapter';
interface CalendarWorkspaceProps {
filters: Omit<CalendarListFilters, 'organizationId'>;
}
const VIEW_LABELS: Record<CalendarView, string> = {
day: 'Day',
week: 'Week',
month: 'Month',
agenda: 'Agenda'
};
export function CalendarWorkspace({ filters }: CalendarWorkspaceProps) {
const [view, setView] = useState<CalendarView>('agenda');
const [search, setSearch] = useState('');
const { data } = useSuspenseQuery(calendarItemsQueryOptions(filters));
const events = useMemo(
() =>
data.items
.map(mapCalendarProjectionToBigCalendarEvent)
.filter((event) => event.title.toLowerCase().includes(search.toLowerCase())),
[data.items, search]
);
const groupedEvents = groupEventsByDate(events);
return (
<div className='space-y-6'>
<CalendarSummaryCards summary={data.summary} />
<Card>
<CardHeader className='gap-4'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
<div>
<CardTitle className='flex items-center gap-2'>
<Icons.calendar className='h-5 w-5' />
Calendar Workspace
</CardTitle>
<p className='text-muted-foreground text-sm'>
Projection workspace for scheduled activities and read-only business milestones.
</p>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center'>
<div className='relative'>
<Icons.search className='text-muted-foreground absolute left-2.5 top-2.5 h-4 w-4' />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder='Search calendar'
className='w-full pl-8 sm:w-[240px]'
/>
</div>
<Badge variant='outline'>Lens: {filters.lens ?? 'personal'}</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<Tabs value={view} onValueChange={(value) => setView(value as CalendarView)}>
<TabsList className='mb-4 grid w-full grid-cols-4 lg:w-[420px]'>
{Object.entries(VIEW_LABELS).map(([value, label]) => (
<TabsTrigger key={value} value={value}>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value='agenda'>
<AgendaView groupedEvents={groupedEvents} />
</TabsContent>
<TabsContent value='day'>
<CompactCalendarGrid events={events.slice(0, 12)} columns={1} />
</TabsContent>
<TabsContent value='week'>
<CompactCalendarGrid events={events.slice(0, 28)} columns={7} />
</TabsContent>
<TabsContent value='month'>
<CompactCalendarGrid events={events.slice(0, 42)} columns={7} month />
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
);
}
export function CalendarWorkspaceSkeleton() {
return (
<div className='space-y-6'>
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className='h-24 rounded-xl' />
))}
</div>
<Skeleton className='h-[520px] rounded-xl' />
</div>
);
}
function CalendarSummaryCards({ summary }: { summary: CalendarWorkspaceSummary }) {
const cards = [
{ label: 'Due Today', value: summary.dueToday, icon: Icons.calendar },
{ label: 'Overdue', value: summary.overdue, icon: Icons.warning },
{ label: 'Meetings', value: summary.meetings, icon: Icons.clock },
{ label: 'Hot Projects', value: summary.hotProjects, icon: Icons.check }
];
return (
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
{cards.map((card) => (
<Card key={card.label}>
<CardContent className='flex items-center gap-3 p-4'>
<div className='bg-muted rounded-lg p-2'>
<card.icon className='text-muted-foreground h-5 w-5' />
</div>
<div>
<p className='text-2xl font-bold'>{card.value}</p>
<p className='text-muted-foreground text-xs'>{card.label}</p>
</div>
</CardContent>
</Card>
))}
</div>
);
}
function AgendaView({ groupedEvents }: { groupedEvents: Array<[string, BigCalendarEvent[]]> }) {
if (!groupedEvents.length) {
return <CalendarEmptyState />;
}
return (
<div className='space-y-5'>
{groupedEvents.map(([date, events]) => (
<section key={date} className='space-y-3'>
<div className='flex items-center gap-2'>
<div className='bg-border h-px flex-1' />
<p className='text-muted-foreground text-xs font-medium uppercase tracking-wide'>
{formatDate(date)}
</p>
<div className='bg-border h-px flex-1' />
</div>
<div className='grid gap-3'>
{events.map((event) => (
<CalendarEventCard key={event.id} event={event} />
))}
</div>
</section>
))}
</div>
);
}
function CompactCalendarGrid({
events,
columns,
month
}: {
events: BigCalendarEvent[];
columns: number;
month?: boolean;
}) {
if (!events.length) {
return <CalendarEmptyState />;
}
return (
<div
className={cn(
'grid gap-3',
columns === 1 && 'grid-cols-1',
columns === 7 && 'grid-cols-1 md:grid-cols-7'
)}
>
{events.map((event) => (
<div
key={event.id}
className={cn(
'border-border/70 bg-muted/20 min-h-28 rounded-lg border p-2',
month && 'min-h-36'
)}
>
<p className='text-muted-foreground text-xs'>{formatDate(event.start)}</p>
<CalendarEventPill event={event} />
</div>
))}
</div>
);
}
function CalendarEventCard({ event }: { event: BigCalendarEvent }) {
return (
<article className='border-border/70 hover:bg-muted/30 rounded-lg border p-4 transition-colors'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<Badge variant={event.display.milestone ? 'secondary' : 'outline'}>
{event.category.replaceAll('_', ' ')}
</Badge>
{event.display.overdue ? <Badge variant='destructive'>Overdue</Badge> : null}
{event.display.hotProject ? <Badge variant='default'>Hot Project</Badge> : null}
{!event.editable ? <Badge variant='outline'>Read-only</Badge> : null}
</div>
<h3 className='font-medium'>{event.title}</h3>
{event.display.summary ? (
<p className='text-muted-foreground text-sm'>{event.display.summary}</p>
) : null}
<p className='text-muted-foreground text-xs'>
{formatTime(event.start)} - {formatTime(event.end)}
{event.display.location ? ` · ${event.display.location}` : ''}
</p>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm'>
Open Source
</Button>
{event.editable ? (
<>
<Button variant='outline' size='sm'>
Reschedule
</Button>
<Button variant='outline' size='sm'>
Complete
</Button>
</>
) : (
<Button variant='outline' size='sm'>
Create Activity
</Button>
)}
</div>
</div>
</article>
);
}
function CalendarEventPill({ event }: { event: BigCalendarEvent }) {
return (
<div
className={cn(
'mt-2 rounded-md border px-2 py-1 text-xs',
event.display.overdue
? 'border-destructive/40 bg-destructive/10 text-destructive'
: 'border-primary/20 bg-primary/10 text-primary'
)}
>
<span className='font-medium'>{formatTime(event.start)}</span> {event.title}
</div>
);
}
function CalendarEmptyState() {
return (
<div className='border-border/70 rounded-xl border border-dashed p-10 text-center'>
<Icons.calendar className='text-muted-foreground mx-auto mb-3 h-8 w-8' />
<p className='font-medium'>No scheduled work in this period</p>
<p className='text-muted-foreground mt-1 text-sm'>
Try changing filters or create an Activity from the owning CRM record.
</p>
</div>
);
}
function groupEventsByDate(events: BigCalendarEvent[]): Array<[string, BigCalendarEvent[]]> {
const grouped = new Map<string, BigCalendarEvent[]>();
for (const event of events) {
const key = event.start.slice(0, 10);
grouped.set(key, [...(grouped.get(key) ?? []), event]);
}
return [...grouped.entries()];
}
type CalendarWorkspaceSummary = {
dueToday: number;
overdue: number;
meetings: number;
visits: number;
milestones: number;
approvalDue: number;
hotProjects: number;
};

View File

@@ -0,0 +1,12 @@
export type {
BigCalendarEvent,
CalendarCategory,
CalendarLens,
CalendarListFilters,
CalendarListResponse,
CalendarPriority,
CalendarProjectionItem,
CalendarSummary,
CalendarView
} from './api/types';
export { mapCalendarProjectionToBigCalendarEvent } from './components/calendar-adapter';

View File

@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createBusinessEvent } from '../../../foundation/business-events/publisher.ts';
import { buildCalendarChangeFromEvent } from './builder.ts';
import { createCalendarProjectionConsumer } from './consumer.ts';
function scheduledActivityEvent(eventType = 'activity.created') {
return createBusinessEvent({
eventType,
organizationId: 'org-1',
branchId: 'branch-1',
entityType: 'activity',
entityId: 'activity-1',
primaryRecord: { type: 'opportunity', id: 'opp-1' },
relatedRecords: [
{ type: 'customer', id: 'customer-1' },
{ type: 'quotation', id: 'quotation-1' }
],
actorUserId: 'user-1',
occurredAt: '2026-07-13T00:00:00.000Z',
correlationId: 'corr-1',
visibility: { productType: 'product-a', pricingSensitive: true, internalOnly: false },
payload: {
activityId: 'activity-1',
activityCode: 'ACT-1',
activityType: 'meeting',
subject: 'Customer planning meeting',
priority: 'high',
ownerId: 'owner-1',
assignedToId: 'assignee-1',
scheduledStartAt: '2026-07-14T03:00:00.000Z',
scheduledEndAt: '2026-07-14T04:00:00.000Z',
status: 'scheduled'
}
});
}
function unscheduledActivityEvent() {
return createBusinessEvent({
eventType: 'activity.created',
organizationId: 'org-1',
branchId: 'branch-1',
entityType: 'activity',
entityId: 'activity-1',
primaryRecord: { type: 'activity', id: 'activity-1' },
relatedRecords: [],
actorUserId: 'user-1',
occurredAt: '2026-07-13T00:00:00.000Z',
correlationId: 'corr-1',
visibility: { productType: null, pricingSensitive: false, internalOnly: false },
payload: {
activityId: 'activity-1',
subject: 'No date'
}
});
}
test('calendar builder maps scheduled activity into editable calendar item', () => {
const change = buildCalendarChangeFromEvent(scheduledActivityEvent());
assert.equal(change?.action, 'upsert');
assert.equal(change?.item.category, 'meeting');
assert.equal(change?.item.editable, true);
assert.equal(change?.item.milestone, false);
assert.equal(change?.item.customerId, 'customer-1');
assert.equal(change?.item.quotationId, 'quotation-1');
assert.equal(change?.item.ownerId, 'owner-1');
assert.equal(change?.item.assigneeId, 'assignee-1');
assert.equal(change?.item.visibility.pricingSensitive, true);
});
test('calendar builder deletes terminal activity from projection', () => {
const change = buildCalendarChangeFromEvent(scheduledActivityEvent('activity.cancelled'));
assert.equal(change?.action, 'delete');
assert.equal(change?.activityId, 'activity-1');
});
test('calendar consumer skips activity events without schedulable date', async () => {
const consumer = createCalendarProjectionConsumer({
persist: async () => {
throw new Error('should not persist');
}
});
const result = await consumer.handle(unscheduledActivityEvent());
assert.equal(result.status, 'skipped');
assert.equal(result.sideEffectCount, 0);
});

View File

@@ -0,0 +1,184 @@
import { BANGKOK_TIME_ZONE } from '../../../../lib/date-format.ts';
import type { BusinessEvent } from '../../../foundation/business-events/types.ts';
import type { CalendarCategory, CalendarPriority, CalendarProjectionItem } from '../api/types';
export const CALENDAR_PROJECTION_VERSION = 1;
export type CalendarProjectionChange =
| { action: 'upsert'; item: CalendarProjectionItem }
| { action: 'delete'; organizationId: string; activityId: string; eventId: string };
const TERMINAL_ACTIVITY_EVENTS = new Set([
'activity.completed',
'activity.cancelled',
'activity.deleted'
]);
const ACTIVITY_CATEGORY_BY_TYPE: Record<string, CalendarCategory> = {
meeting: 'meeting',
visit: 'visit',
site_survey: 'site_survey',
internal_task: 'task',
reminder: 'reminder',
reminder_action: 'reminder',
follow_up: 'activity',
phone_call: 'activity',
email: 'activity',
email_follow_up: 'activity',
presentation: 'meeting',
delivery_coordination: 'activity',
approval_action: 'approval_milestone'
};
export function buildCalendarChangeFromEvent(event: BusinessEvent): CalendarProjectionChange | null {
if (!event.eventType.startsWith('activity.')) {
return null;
}
const payload = event.payload as Record<string, unknown>;
const activityId = text(payload.activityId) ?? (event.entityType === 'activity' ? event.entityId : null);
if (!activityId) {
return null;
}
if (TERMINAL_ACTIVITY_EVENTS.has(event.eventType)) {
return {
action: 'delete',
organizationId: event.organizationId,
activityId,
eventId: event.eventId
};
}
const startAt = dateText(payload.scheduledStartAt) ?? dateText(payload.dueAt);
if (!startAt) {
return null;
}
const endAt =
dateText(payload.scheduledEndAt) ??
addMinutesIso(startAt, defaultDurationMinutes(text(payload.activityType)));
const refs = resolveEntityRefs(event, payload);
const priority = priorityFromPayload(payload);
const category = categoryFromPayload(payload);
return {
action: 'upsert',
item: {
id: `calendar:${event.organizationId}:${activityId}`,
organizationId: event.organizationId,
branchId: event.branchId ?? null,
productType: event.visibility.productType ?? null,
customerId: refs.customerId,
leadId: refs.leadId,
opportunityId: refs.opportunityId,
quotationId: refs.quotationId,
approvalId: refs.approvalId,
activityId,
ownerId: text(payload.ownerId),
assigneeId: text(payload.assignedToId),
eventId: event.eventId,
entityType: event.entityType,
entityId: event.entityId,
title: calendarTitle(payload, category),
summary: text(payload.description) ?? text(payload.outcomeSummary) ?? text(payload.nextAction),
location: text(payload.location) ?? text(payload.projectLocation),
startAt,
endAt,
timezone: BANGKOK_TIME_ZONE,
priority,
category,
activityType: text(payload.activityType),
editable: true,
milestone: false,
overdue: isOverdue(startAt, text(payload.status)),
hotProject: Boolean(payload.hotProject),
allDay: Boolean(payload.allDay),
visibility: {
productType: event.visibility.productType,
pricingSensitive: event.visibility.pricingSensitive ?? false,
internalOnly: event.visibility.internalOnly ?? false
},
metadata: {
source: 'activity-business-event',
activityCode: text(payload.activityCode),
contentRedacted: Boolean(payload.contentRedacted),
primaryRecord: event.primaryRecord
},
sourceProjectionVersion: CALENDAR_PROJECTION_VERSION,
createdAt: event.occurredAt,
updatedAt: event.occurredAt
}
};
}
function resolveEntityRefs(event: BusinessEvent, payload: Record<string, unknown>) {
const refs = new Map(event.relatedRecords.map((record) => [record.type, record.id]));
return {
customerId: text(payload.customerId) ?? refs.get('customer') ?? null,
leadId: text(payload.leadId) ?? refs.get('lead') ?? null,
opportunityId: text(payload.opportunityId) ?? refs.get('opportunity') ?? null,
quotationId: text(payload.quotationId) ?? refs.get('quotation') ?? null,
approvalId: text(payload.approvalId) ?? refs.get('approval') ?? null
};
}
function calendarTitle(payload: Record<string, unknown>, category: CalendarCategory): string {
const subject = text(payload.subject);
if (subject) return subject;
if (category === 'meeting') return 'Meeting';
if (category === 'visit') return 'Customer visit';
if (category === 'site_survey') return 'Site survey';
if (category === 'reminder') return 'Reminder';
return 'Activity';
}
function categoryFromPayload(payload: Record<string, unknown>): CalendarCategory {
const activityType = text(payload.activityType);
if (activityType && ACTIVITY_CATEGORY_BY_TYPE[activityType]) {
return ACTIVITY_CATEGORY_BY_TYPE[activityType];
}
return 'activity';
}
function priorityFromPayload(payload: Record<string, unknown>): CalendarPriority {
const priority = text(payload.priority);
if (priority === 'critical' || priority === 'high' || priority === 'low') {
return priority;
}
return 'normal';
}
function defaultDurationMinutes(activityType: string | null): number {
if (activityType === 'visit' || activityType === 'site_survey' || activityType === 'presentation') {
return 120;
}
if (activityType === 'meeting') return 60;
return 30;
}
function isOverdue(startAt: string, status: string | null): boolean {
if (status === 'completed' || status === 'cancelled' || status === 'skipped') {
return false;
}
return new Date(startAt).getTime() < Date.now();
}
function addMinutesIso(value: string, minutes: number): string {
const date = new Date(value);
date.setMinutes(date.getMinutes() + minutes);
return date.toISOString();
}
function dateText(value: unknown): string | null {
if (typeof value !== 'string' || value.trim().length === 0) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}

View File

@@ -0,0 +1,69 @@
import type { BusinessEvent } from '../../../foundation/business-events/types.ts';
import type { ProjectionConsumer, ProjectionHandleResult } from '../../../foundation/projections/types.ts';
import { getProjectionDefinition } from '../../../foundation/projections/registry.ts';
import { buildCalendarChangeFromEvent } from './builder.ts';
const definition = getProjectionDefinition('Calendar');
interface CalendarProjectionConsumerOptions {
persist?: (change: ReturnType<typeof buildCalendarChangeFromEvent>) => Promise<number>;
}
export function createCalendarProjectionConsumer(
options: CalendarProjectionConsumerOptions = {}
): ProjectionConsumer {
const persist = options.persist ?? persistCalendarChange;
return {
consumerName: definition.consumerName,
projectionName: definition.projectionName,
supportedEventTypes: definition.consumedEventTypes,
supportedVersions: definition.supportedVersions,
async handle(event: BusinessEvent): Promise<ProjectionHandleResult> {
const change = buildCalendarChangeFromEvent(event);
if (!change) {
return {
status: 'skipped',
message: 'Calendar event has no schedulable date',
sideEffectCount: 0
};
}
const sideEffectCount = await persist(change);
return {
status: 'processed',
message:
change.action === 'delete'
? 'Calendar projection activity item removed'
: 'Calendar projection item persisted',
sideEffectCount
};
}
};
}
export const calendarProjectionConsumer = createCalendarProjectionConsumer();
async function persistCalendarChange(
change: ReturnType<typeof buildCalendarChangeFromEvent>
): Promise<number> {
if (!change) return 0;
const { deleteCalendarProjectionForActivity, upsertCalendarProjectionItem } = await import(
'./service.ts'
);
if (change.action === 'delete') {
await deleteCalendarProjectionForActivity({
organizationId: change.organizationId,
activityId: change.activityId
});
return 1;
}
await deleteCalendarProjectionForActivity({
organizationId: change.item.organizationId,
activityId: change.item.activityId ?? change.item.entityId
});
await upsertCalendarProjectionItem(change.item);
return 1;
}

View File

@@ -0,0 +1,51 @@
import type {
ProjectionRebuildRequest,
ProjectionRebuildResult
} from '../../../foundation/projections/types.ts';
export interface CalendarRebuildPlan {
projectionName: 'Calendar';
organizationId: string;
sources: Array<'activity' | 'opportunity' | 'quotation' | 'approval' | 'business-event-replay'>;
resetExisting: boolean;
dryRun: boolean;
restart: boolean;
dateRange: {
from: string | null;
to: string | null;
};
}
export function createCalendarRebuildPlan(
request: ProjectionRebuildRequest & { restart?: boolean }
): CalendarRebuildPlan {
return {
projectionName: 'Calendar',
organizationId: request.organizationId,
sources: ['activity', 'opportunity', 'quotation', 'approval', 'business-event-replay'],
resetExisting: Boolean(request.resetExisting),
dryRun: Boolean(request.dryRun),
restart: Boolean(request.restart),
dateRange: {
from: request.occurredFrom ?? null,
to: request.occurredTo ?? null
}
};
}
export function createCalendarRebuildResult(
plan: CalendarRebuildPlan,
overrides: Partial<ProjectionRebuildResult> = {}
): ProjectionRebuildResult {
return {
rebuildRunId: `calendar-rebuild:${plan.organizationId}:${Date.now()}`,
projectionName: plan.projectionName,
organizationId: plan.organizationId,
dryRun: plan.dryRun,
status: 'pending',
processedCount: 0,
skippedCount: 0,
failedCount: 0,
...overrides
};
}

View File

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

View File

@@ -0,0 +1,260 @@
import { and, asc, eq, gte, ilike, lte, or, type SQL } from 'drizzle-orm';
import { crmCalendarProjection } from '@/db/schema';
import {
buildCrmSecurityContext,
canAccessScopedCrmRecord,
type CrmSecurityContext
} from '@/features/crm/security/server/service';
import { db } from '@/lib/db';
import type { CalendarListFilters, CalendarListResponse, CalendarProjectionItem } from '../api/types';
export type CalendarProjectionRow = typeof crmCalendarProjection.$inferSelect;
export type CalendarDbClient = Pick<typeof db, 'insert' | 'select' | 'delete'>;
export async function upsertCalendarProjectionItem(
item: CalendarProjectionItem,
client: CalendarDbClient = db
): Promise<void> {
await client
.insert(crmCalendarProjection)
.values(toInsertRow(item))
.onConflictDoUpdate({
target: crmCalendarProjection.id,
set: toInsertRow(item)
});
}
export async function deleteCalendarProjectionForActivity(
input: { organizationId: string; activityId: string },
client: CalendarDbClient = db
): Promise<void> {
await client
.delete(crmCalendarProjection)
.where(
and(
eq(crmCalendarProjection.organizationId, input.organizationId),
eq(crmCalendarProjection.activityId, input.activityId)
)
);
}
export async function clearCalendarProjectionForOrganization(
organizationId: string,
client: CalendarDbClient = db
): Promise<void> {
await client
.delete(crmCalendarProjection)
.where(eq(crmCalendarProjection.organizationId, organizationId));
}
export async function listCalendarItems(
filters: CalendarListFilters,
context: CrmSecurityContext,
client = db
): Promise<CalendarListResponse> {
const limit = Math.min(filters.limit ?? 200, 500);
const rows = await client
.select()
.from(crmCalendarProjection)
.where(and(...buildCalendarWhere(filters, context)))
.orderBy(asc(crmCalendarProjection.startAt))
.limit(limit + 1);
const visibleItems = rows.map(mapCalendarRow).filter((item) => canViewCalendarItem(context, item));
const items = visibleItems.slice(0, limit);
return {
items,
summary: summarizeCalendarItems(items),
nextCursor: visibleItems.length > limit ? items.at(-1)?.startAt ?? null : null,
totalItems: items.length
};
}
export function buildCalendarSecurityContext(input: Parameters<typeof buildCrmSecurityContext>[0]) {
return buildCrmSecurityContext(input);
}
function buildCalendarWhere(filters: CalendarListFilters, context: CrmSecurityContext): SQL[] {
const where: SQL[] = [
eq(crmCalendarProjection.organizationId, filters.organizationId),
gte(crmCalendarProjection.startAt, new Date(filters.startFrom)),
lte(crmCalendarProjection.startAt, new Date(filters.startTo))
];
if (filters.userId) {
where.push(
or(
eq(crmCalendarProjection.ownerId, filters.userId),
eq(crmCalendarProjection.assigneeId, filters.userId)
)!
);
} else if (filters.lens === 'personal') {
where.push(
or(
eq(crmCalendarProjection.ownerId, context.userId),
eq(crmCalendarProjection.assigneeId, context.userId)
)!
);
}
if (filters.teamUserIds?.length) {
const teamPredicates = filters.teamUserIds.flatMap((userId) => [
eq(crmCalendarProjection.ownerId, userId),
eq(crmCalendarProjection.assigneeId, userId)
]);
where.push(or(...teamPredicates)!);
}
if (filters.branchId) where.push(eq(crmCalendarProjection.branchId, filters.branchId));
if (filters.productType) where.push(eq(crmCalendarProjection.productType, filters.productType));
if (filters.customerId) where.push(eq(crmCalendarProjection.customerId, filters.customerId));
if (filters.leadId) where.push(eq(crmCalendarProjection.leadId, filters.leadId));
if (filters.opportunityId) where.push(eq(crmCalendarProjection.opportunityId, filters.opportunityId));
if (filters.quotationId) where.push(eq(crmCalendarProjection.quotationId, filters.quotationId));
if (filters.activityType) where.push(eq(crmCalendarProjection.activityType, filters.activityType));
if (filters.category) where.push(eq(crmCalendarProjection.category, filters.category));
if (filters.priority) where.push(eq(crmCalendarProjection.priority, filters.priority));
if (typeof filters.overdue === 'boolean') where.push(eq(crmCalendarProjection.overdue, filters.overdue));
if (typeof filters.milestone === 'boolean') where.push(eq(crmCalendarProjection.milestone, filters.milestone));
if (typeof filters.hotProject === 'boolean') {
where.push(eq(crmCalendarProjection.hotProject, filters.hotProject));
}
if (filters.search) {
where.push(
or(
ilike(crmCalendarProjection.title, `%${filters.search}%`),
ilike(crmCalendarProjection.summary, `%${filters.search}%`)
)!
);
}
return where;
}
function canViewCalendarItem(
context: CrmSecurityContext,
item: CalendarProjectionItem
): boolean {
const directlyAssigned = item.ownerId === context.userId || item.assigneeId === context.userId;
if (item.visibility.internalOnly && !directlyAssigned && context.ownershipScope !== 'organization') {
return false;
}
return (
directlyAssigned ||
canAccessScopedCrmRecord(context, {
branchId: item.branchId,
productType: item.productType,
createdBy: item.ownerId,
ownerUserId: item.assigneeId ?? item.ownerId
})
);
}
function summarizeCalendarItems(items: CalendarProjectionItem[]) {
const today = new Date().toISOString().slice(0, 10);
return items.reduce(
(summary, item) => {
if (item.startAt.slice(0, 10) === today) summary.dueToday += 1;
if (item.overdue) summary.overdue += 1;
if (item.category === 'meeting') summary.meetings += 1;
if (item.category === 'visit') summary.visits += 1;
if (item.milestone) summary.milestones += 1;
if (item.category === 'approval_milestone') summary.approvalDue += 1;
if (item.hotProject) summary.hotProjects += 1;
return summary;
},
{
dueToday: 0,
overdue: 0,
meetings: 0,
visits: 0,
milestones: 0,
approvalDue: 0,
hotProjects: 0
}
);
}
function toInsertRow(item: CalendarProjectionItem): typeof crmCalendarProjection.$inferInsert {
return {
id: item.id,
organizationId: item.organizationId,
branchId: item.branchId,
productType: item.productType,
customerId: item.customerId,
leadId: item.leadId,
opportunityId: item.opportunityId,
quotationId: item.quotationId,
approvalId: item.approvalId,
activityId: item.activityId,
ownerId: item.ownerId,
assigneeId: item.assigneeId,
eventId: item.eventId,
entityType: item.entityType,
entityId: item.entityId,
title: item.title,
summary: item.summary,
location: item.location,
startAt: new Date(item.startAt),
endAt: new Date(item.endAt),
timezone: item.timezone,
priority: item.priority,
category: item.category,
activityType: item.activityType,
editable: item.editable,
milestone: item.milestone,
overdue: item.overdue,
hotProject: item.hotProject,
allDay: item.allDay,
visibility: item.visibility,
metadata: item.metadata,
sourceEvent: {
eventId: item.eventId,
entityType: item.entityType,
entityId: item.entityId
},
sourceProjectionVersion: item.sourceProjectionVersion,
updatedAt: new Date(item.updatedAt)
};
}
function mapCalendarRow(row: CalendarProjectionRow): CalendarProjectionItem {
return {
id: row.id,
organizationId: row.organizationId,
branchId: row.branchId,
productType: row.productType,
customerId: row.customerId,
leadId: row.leadId,
opportunityId: row.opportunityId,
quotationId: row.quotationId,
approvalId: row.approvalId,
activityId: row.activityId,
ownerId: row.ownerId,
assigneeId: row.assigneeId,
eventId: row.eventId,
entityType: row.entityType,
entityId: row.entityId,
title: row.title,
summary: row.summary,
location: row.location,
startAt: row.startAt.toISOString(),
endAt: row.endAt.toISOString(),
timezone: row.timezone,
priority: row.priority as CalendarProjectionItem['priority'],
category: row.category as CalendarProjectionItem['category'],
activityType: row.activityType,
editable: row.editable,
milestone: row.milestone,
overdue: row.overdue,
hotProject: row.hotProject,
allDay: row.allDay,
visibility: row.visibility as CalendarProjectionItem['visibility'],
metadata: row.metadata as Record<string, unknown> | null,
sourceProjectionVersion: row.sourceProjectionVersion,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}

View File

@@ -1,8 +1,8 @@
import {
calendarProjectionConsumer,
dashboardProjectionConsumer,
notificationProjectionConsumer
} from '../../../foundation/projections/consumers.ts';
import { calendarProjectionConsumer } from '../../calendar/server/consumer.ts';
import { timelineProjectionConsumer } from './consumer.ts';
export const CRM_TIMELINE_PROJECTION_CONSUMERS = [

View File

@@ -33,16 +33,16 @@ export const PROJECTION_REGISTRY: ProjectionDefinition[] = [
{
projectionName: 'Calendar',
consumerName: 'calendar.projection.consumer',
owningFeature: 'foundation/projections',
owningFeature: 'crm/calendar',
consumedEventTypes: ACTIVITY_EVENTS,
supportedVersions: ACTIVITY_EVENT_VERSIONS,
rebuildStrategy: 'hybrid',
persistence: 'cached-read-model',
persistence: 'materialized',
securityClassification: 'organization-scoped; assignment and source-visibility-governed',
idempotencyKey: 'consumerName + eventId',
retryPolicy: 'projection-default-v1',
retentionPolicy: 'calendar projection rows rebuildable from source activity and events',
implementationStatus: 'contract-only'
implementationStatus: 'active'
},
{
projectionName: 'Dashboard',