task-ep.1.2
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
# Task EP.1.2 Activity Integration & Legacy Follow-up Adapter - 2026-07-13
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- introduced adapter-based Activity timeline integration for legacy lead, opportunity, and quotation follow-ups
|
||||||
|
- added additive `recentActivities` detail API data for customer, lead, opportunity, and quotation workspaces
|
||||||
|
- kept existing follow-up APIs, dashboard consumers, report datasets, and audit history unchanged
|
||||||
|
- added Activity numbering through the Document Sequence Foundation using `documentType = activity`
|
||||||
|
- added Activity attachment foundation service on top of Storage and Document Artifact foundations
|
||||||
|
- documented migration readiness without executing migration, dual-write, projections, calendar, timeline, notifications, or dashboard swaps
|
||||||
|
|
||||||
|
## Review Summary
|
||||||
|
|
||||||
|
Reviewed before and during implementation:
|
||||||
|
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `plans/task-ep.1.2.md`
|
||||||
|
- `docs/standards/task-catalog.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/implementation/task-ep1.1-activity-domain-foundation-2026-07-10.md`
|
||||||
|
- `docs/implementation/task-ar.1-architecture-transition-plan-2026-07-07.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`
|
||||||
|
- existing lead, opportunity, quotation follow-up services and APIs
|
||||||
|
- existing dashboard and report follow-up consumers
|
||||||
|
- existing audit, storage, document artifact, and document sequence foundations
|
||||||
|
|
||||||
|
## Implementation Summary
|
||||||
|
|
||||||
|
### Legacy Follow-up Adapters
|
||||||
|
|
||||||
|
Created Activity-compatible timeline adapters:
|
||||||
|
|
||||||
|
- `src/features/crm/activities/server/adapters/lead-followup.adapter.ts`
|
||||||
|
- `src/features/crm/activities/server/adapters/opportunity-followup.adapter.ts`
|
||||||
|
- `src/features/crm/activities/server/adapters/quotation-followup.adapter.ts`
|
||||||
|
|
||||||
|
The adapters only read and translate legacy records. They do not migrate data, dual-write, or remove existing follow-up behavior.
|
||||||
|
|
||||||
|
### Compatibility Layer
|
||||||
|
|
||||||
|
Added `src/features/crm/activities/server/timeline-service.ts` as the single Activity compatibility read layer for detail-page recent activity views.
|
||||||
|
|
||||||
|
Current composition:
|
||||||
|
|
||||||
|
- new `crm_activities` rows queried by context columns such as `customerId`, `leadId`, `opportunityId`, and `quotationId`
|
||||||
|
- legacy lead follow-ups adapted from audit-log-backed records
|
||||||
|
- legacy opportunity follow-ups adapted from row-backed records
|
||||||
|
- legacy quotation follow-ups adapted from row-backed records
|
||||||
|
|
||||||
|
Dashboard and report datasets remain on their existing legacy sources for EP.1.2.
|
||||||
|
|
||||||
|
### Activity Context Integration
|
||||||
|
|
||||||
|
Integrated recent activity reads into:
|
||||||
|
|
||||||
|
- customer detail API and UI
|
||||||
|
- lead detail API and UI
|
||||||
|
- opportunity detail API and UI
|
||||||
|
- quotation detail API and UI
|
||||||
|
|
||||||
|
The UI uses `RecentActivitiesPanel` and keeps existing follow-up tabs and audit log tabs in place.
|
||||||
|
|
||||||
|
### Activity Type Governance
|
||||||
|
|
||||||
|
Expanded the official fallback Activity catalog to cover the EP.1.2 minimum types and kept backward-compatible aliases.
|
||||||
|
|
||||||
|
Reference data now prefers `crm_activity_type` master options when configured, with static catalog fallback for environments that have not seeded the new category yet.
|
||||||
|
|
||||||
|
### Activity Numbering Strategy
|
||||||
|
|
||||||
|
Activity creation now calls `generateNextDocumentCode()` from the Document Sequence Foundation using:
|
||||||
|
|
||||||
|
- `documentType = activity`
|
||||||
|
- branch scope from the resolved Activity context
|
||||||
|
- product type scope from the resolved Activity context
|
||||||
|
- default prefix `ACT`
|
||||||
|
- period-based sequence from the existing foundation
|
||||||
|
|
||||||
|
The generated code is stored in `crm_activities.metadata.activityCode` to avoid a schema migration in EP.1.2.
|
||||||
|
|
||||||
|
### Activity Attachment Foundation
|
||||||
|
|
||||||
|
Added `src/features/crm/activities/server/attachment-service.ts`.
|
||||||
|
|
||||||
|
The service supports Activity attachment metadata and binary storage through:
|
||||||
|
|
||||||
|
- Storage Foundation object writes
|
||||||
|
- Document Artifact Foundation metadata records
|
||||||
|
- `entityType = crm_activity`
|
||||||
|
- `artifactType = attachment`
|
||||||
|
|
||||||
|
Supported attachment kinds are `image`, `pdf`, `office_document`, `voice_recording`, `drawing`, and `other`.
|
||||||
|
|
||||||
|
No separate storage engine or attachment table was introduced.
|
||||||
|
|
||||||
|
## Adapter Validation
|
||||||
|
|
||||||
|
| Source | Legacy Storage | Adapter Output | Validation Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Lead follow-up | audit log payload | `ActivityTimelineItem` | implemented |
|
||||||
|
| Opportunity follow-up | `crm_opportunity_followups` | `ActivityTimelineItem` | implemented |
|
||||||
|
| Quotation follow-up | `crm_quotation_followups` | `ActivityTimelineItem` | implemented |
|
||||||
|
| New Activity | `crm_activities` | `ActivityTimelineItem` | implemented |
|
||||||
|
|
||||||
|
## Legacy Usage Inventory
|
||||||
|
|
||||||
|
Existing consumers intentionally remain unchanged:
|
||||||
|
|
||||||
|
- lead follow-up API and panel
|
||||||
|
- opportunity follow-up API and tab
|
||||||
|
- quotation follow-up API and tab
|
||||||
|
- CRM dashboard service follow-up queries
|
||||||
|
- pipeline/report datasets reading legacy follow-up state
|
||||||
|
- audit log views for customer, lead, opportunity, and quotation details
|
||||||
|
|
||||||
|
## Projection Readiness
|
||||||
|
|
||||||
|
EP.1.3 can consume the following stable contracts:
|
||||||
|
|
||||||
|
- `ActivityTimelineItem`
|
||||||
|
- legacy follow-up adapters
|
||||||
|
- `listRecentActivitiesForContext()`
|
||||||
|
- Activity numbering metadata under `metadata.activityCode`
|
||||||
|
- Activity attachment artifacts under `entityType = crm_activity`
|
||||||
|
|
||||||
|
Projection consumers should still wait for EP.1.3 event and projection contracts before replacing dashboard, report, calendar, or timeline behavior.
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
- Keep legacy follow-up writes active until adapter parity is validated with production-like data.
|
||||||
|
- Backfill or project legacy follow-ups only after a separate migration task approves the source-of-truth switch.
|
||||||
|
- Decide whether `metadata.activityCode` should become a first-class column in a future schema task.
|
||||||
|
- Add upload/download route handlers for Activity attachments when the UI workflow is scheduled.
|
||||||
|
- Add seeded `crm_activity_type` master options before disabling fallback catalog behavior.
|
||||||
|
- Run dataset regression checks before dashboard/report consumers switch from legacy follow-ups to Activity projections.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm run typecheck`
|
||||||
|
- targeted `npx oxlint` for Activity adapter, timeline, route, and detail UI files
|
||||||
|
|
||||||
|
## Residual Risks
|
||||||
|
|
||||||
|
- Activity attachment upload/download routes are intentionally deferred; the foundation service is ready for those routes.
|
||||||
|
- Activity numbering is metadata-backed in EP.1.2 to honor the no-migration constraint.
|
||||||
|
- Detail pages show recent activity read integration, but create-activity shortcuts from each detail page are still deferred to the next UI workflow task.
|
||||||
382
plans/task-ep.1.2.md
Normal file
382
plans/task-ep.1.2.md
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
# EP.1.2 – Activity Integration & Legacy Follow-up Adapter
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
|
Priority: Critical
|
||||||
|
|
||||||
|
Type: Feature Integration
|
||||||
|
|
||||||
|
Depends On
|
||||||
|
|
||||||
|
- EP.1.1 Activity Domain Foundation
|
||||||
|
- ENG.0 Engineering Constitution
|
||||||
|
- AR.1 Architecture Transition Plan
|
||||||
|
- AR.2 Epic Technical Design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Objective
|
||||||
|
|
||||||
|
Introduce the Activity Integration Layer that connects the new Activity Domain with existing CRM modules while preserving all legacy follow-up implementations.
|
||||||
|
|
||||||
|
This phase establishes adapter-based integration without introducing data migration, dual-write, or projection consumers.
|
||||||
|
|
||||||
|
The objective is to prepare existing modules to consume Activity incrementally while maintaining production stability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Background
|
||||||
|
|
||||||
|
EP.1.1 introduced the additive `crm_activities` write model.
|
||||||
|
|
||||||
|
Current CRM still contains multiple follow-up implementations:
|
||||||
|
|
||||||
|
- Lead (audit-log-backed)
|
||||||
|
- Opportunity (row-backed)
|
||||||
|
- Quotation (row-backed)
|
||||||
|
|
||||||
|
Business Constitution defines Activity as the long-term operational model.
|
||||||
|
|
||||||
|
Architecture Constitution requires:
|
||||||
|
|
||||||
|
- Preserve existing production behavior.
|
||||||
|
- Introduce Activity through adapters.
|
||||||
|
- Delay consolidation until adapters are validated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Review Required
|
||||||
|
|
||||||
|
Review
|
||||||
|
|
||||||
|
Business
|
||||||
|
|
||||||
|
- Relationship & Sales Workspace Blueprint
|
||||||
|
- Activity Blueprint
|
||||||
|
|
||||||
|
Architecture
|
||||||
|
|
||||||
|
- AR.1 Architecture Transition Plan
|
||||||
|
- AR.2 Epic Technical Design
|
||||||
|
|
||||||
|
Engineering
|
||||||
|
|
||||||
|
- Engineering Constitution
|
||||||
|
|
||||||
|
Implementation
|
||||||
|
|
||||||
|
- EP.1.1 Activity Foundation
|
||||||
|
- Existing Lead Follow-up
|
||||||
|
- Existing Opportunity Follow-up
|
||||||
|
- Existing Quotation Follow-up
|
||||||
|
- Existing Dashboard queries
|
||||||
|
- Existing Reports
|
||||||
|
- Existing Audit Log
|
||||||
|
|
||||||
|
UI
|
||||||
|
|
||||||
|
- Workspace UI/UX Design Note
|
||||||
|
- layout.md
|
||||||
|
- ui-ux-rules.md
|
||||||
|
- ui-ux-pro-max
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope
|
||||||
|
|
||||||
|
## Part 1 — Legacy Follow-up Adapter Layer
|
||||||
|
|
||||||
|
Introduce adapter interfaces.
|
||||||
|
|
||||||
|
Create
|
||||||
|
|
||||||
|
LeadFollowUpAdapter
|
||||||
|
|
||||||
|
OpportunityFollowUpAdapter
|
||||||
|
|
||||||
|
QuotationFollowUpAdapter
|
||||||
|
|
||||||
|
Responsibilities
|
||||||
|
|
||||||
|
Read legacy follow-up
|
||||||
|
|
||||||
|
Translate legacy model
|
||||||
|
|
||||||
|
Produce Activity-compatible DTO
|
||||||
|
|
||||||
|
No database migration.
|
||||||
|
|
||||||
|
No dual write.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Activity Context Integration
|
||||||
|
|
||||||
|
Integrate Activity with
|
||||||
|
|
||||||
|
Customer
|
||||||
|
|
||||||
|
Contact
|
||||||
|
|
||||||
|
Lead
|
||||||
|
|
||||||
|
Opportunity
|
||||||
|
|
||||||
|
Quotation
|
||||||
|
|
||||||
|
PO
|
||||||
|
|
||||||
|
Activity must resolve context without changing ownership of those domains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Activity Type Governance
|
||||||
|
|
||||||
|
Freeze the official activity catalog.
|
||||||
|
|
||||||
|
Minimum types
|
||||||
|
|
||||||
|
- Meeting
|
||||||
|
- Visit
|
||||||
|
- Phone Call
|
||||||
|
- Email
|
||||||
|
- Site Survey
|
||||||
|
- Presentation
|
||||||
|
- Reminder
|
||||||
|
- Internal Task
|
||||||
|
- Follow-up
|
||||||
|
- Delivery Coordination
|
||||||
|
- Approval Action
|
||||||
|
|
||||||
|
Introduce `crm_activity_type` master options.
|
||||||
|
|
||||||
|
No hard-coded types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4 — Activity Context Strategy
|
||||||
|
|
||||||
|
Freeze
|
||||||
|
|
||||||
|
contextType
|
||||||
|
|
||||||
|
contextId
|
||||||
|
|
||||||
|
supported contexts
|
||||||
|
|
||||||
|
Exactly one primary context.
|
||||||
|
|
||||||
|
Optional related references.
|
||||||
|
|
||||||
|
Prepare Timeline and Calendar compatibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5 — Activity Number Strategy
|
||||||
|
|
||||||
|
Introduce configurable document numbering.
|
||||||
|
|
||||||
|
Example
|
||||||
|
|
||||||
|
ACT2607-0001
|
||||||
|
|
||||||
|
Support
|
||||||
|
|
||||||
|
Organization
|
||||||
|
|
||||||
|
Branch
|
||||||
|
|
||||||
|
Period
|
||||||
|
|
||||||
|
Sequence
|
||||||
|
|
||||||
|
Reuse existing Document Sequence Foundation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 6 — Activity Attachment Foundation
|
||||||
|
|
||||||
|
Integrate Activity with existing Storage Foundation.
|
||||||
|
|
||||||
|
Support
|
||||||
|
|
||||||
|
Images
|
||||||
|
|
||||||
|
PDF
|
||||||
|
|
||||||
|
Office Documents
|
||||||
|
|
||||||
|
Voice Recording
|
||||||
|
|
||||||
|
Drawing
|
||||||
|
|
||||||
|
No new storage engine.
|
||||||
|
|
||||||
|
Reuse current abstraction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 7 — Activity UI Integration
|
||||||
|
|
||||||
|
Extend existing pages.
|
||||||
|
|
||||||
|
Customer Detail
|
||||||
|
|
||||||
|
Opportunity Detail
|
||||||
|
|
||||||
|
Quotation Detail
|
||||||
|
|
||||||
|
Lead Detail
|
||||||
|
|
||||||
|
Add
|
||||||
|
|
||||||
|
Recent Activities
|
||||||
|
|
||||||
|
Create Activity
|
||||||
|
|
||||||
|
Activity Summary
|
||||||
|
|
||||||
|
Do NOT replace Follow-up UI.
|
||||||
|
|
||||||
|
Run both in parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 8 — Permission Integration
|
||||||
|
|
||||||
|
Reuse existing CRM permission model.
|
||||||
|
|
||||||
|
Support
|
||||||
|
|
||||||
|
Owner
|
||||||
|
|
||||||
|
Assignee
|
||||||
|
|
||||||
|
Manager
|
||||||
|
|
||||||
|
Administrator
|
||||||
|
|
||||||
|
Organization
|
||||||
|
|
||||||
|
Branch
|
||||||
|
|
||||||
|
Product Type
|
||||||
|
|
||||||
|
Pricing visibility
|
||||||
|
|
||||||
|
Internal-only Activity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 9 — Compatibility Layer
|
||||||
|
|
||||||
|
Introduce compatibility interfaces.
|
||||||
|
|
||||||
|
Legacy Follow-up
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Activity Adapter
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Activity DTO
|
||||||
|
|
||||||
|
Current consumers remain unchanged.
|
||||||
|
|
||||||
|
No existing API removed.
|
||||||
|
|
||||||
|
No schema migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 10 — Migration Readiness
|
||||||
|
|
||||||
|
Produce
|
||||||
|
|
||||||
|
Adapter validation
|
||||||
|
|
||||||
|
Compatibility report
|
||||||
|
|
||||||
|
Legacy usage inventory
|
||||||
|
|
||||||
|
Projection readiness report
|
||||||
|
|
||||||
|
Migration checklist
|
||||||
|
|
||||||
|
No migration execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deliverables
|
||||||
|
|
||||||
|
1. Lead Follow-up Adapter
|
||||||
|
2. Opportunity Follow-up Adapter
|
||||||
|
3. Quotation Follow-up Adapter
|
||||||
|
4. Activity Context Integration
|
||||||
|
5. Activity Type Governance
|
||||||
|
6. Activity Numbering Strategy
|
||||||
|
7. Activity Attachment Foundation
|
||||||
|
8. Activity UI Integration
|
||||||
|
9. Compatibility Layer
|
||||||
|
10. Migration Readiness Report
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
|
||||||
|
Must preserve
|
||||||
|
|
||||||
|
- Existing Follow-up
|
||||||
|
- Existing APIs
|
||||||
|
- Existing Reports
|
||||||
|
- Existing Dashboard
|
||||||
|
- Existing Audit History
|
||||||
|
- Existing Permissions
|
||||||
|
|
||||||
|
No Timeline.
|
||||||
|
|
||||||
|
No Calendar.
|
||||||
|
|
||||||
|
No Notification.
|
||||||
|
|
||||||
|
No My Day.
|
||||||
|
|
||||||
|
No Dashboard Projection.
|
||||||
|
|
||||||
|
No Event Publishing.
|
||||||
|
|
||||||
|
No data migration.
|
||||||
|
|
||||||
|
No dual write.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
- Activity integrates with all major CRM modules.
|
||||||
|
- Existing Follow-up continues to function.
|
||||||
|
- Adapter Layer becomes the only integration point.
|
||||||
|
- Activity Types are centrally governed.
|
||||||
|
- Activity Numbering follows Document Sequence Foundation.
|
||||||
|
- Activity Attachments reuse existing Storage Foundation.
|
||||||
|
- No production behavior changes.
|
||||||
|
- No breaking API changes.
|
||||||
|
- No duplicated business logic.
|
||||||
|
- No duplicated lifecycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Success Criteria
|
||||||
|
|
||||||
|
Activity is fully integrated into the CRM foundation.
|
||||||
|
|
||||||
|
Legacy Follow-up remains operational.
|
||||||
|
|
||||||
|
All future projection epics can consume Activity through stable interfaces.
|
||||||
|
|
||||||
|
Repository remains backward compatible.
|
||||||
|
|
||||||
|
Ready for
|
||||||
|
|
||||||
|
EP.1.3 – Business Event Foundation & Projection Contracts.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { listRecentActivitiesForContext } from '@/features/crm/activities/server/timeline-service';
|
||||||
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
import {
|
import {
|
||||||
auditCrmSecurityEvent,
|
auditCrmSecurityEvent,
|
||||||
@@ -38,10 +39,17 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membership
|
membership
|
||||||
});
|
});
|
||||||
const [customer, activity, ownerHistory] = await Promise.all([
|
const [customer, activity, ownerHistory, recentActivities] = await Promise.all([
|
||||||
getCustomerDetail(id, organization.id, accessContext),
|
getCustomerDetail(id, organization.id, accessContext),
|
||||||
getCustomerActivity(id, organization.id),
|
getCustomerActivity(id, organization.id),
|
||||||
getCustomerOwnerHistory(id, organization.id, accessContext)
|
getCustomerOwnerHistory(id, organization.id, accessContext),
|
||||||
|
listRecentActivitiesForContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
entityType: 'customer',
|
||||||
|
entityId: id,
|
||||||
|
context: accessContext,
|
||||||
|
limit: 6
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -50,6 +58,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
message: 'Customer loaded successfully',
|
message: 'Customer loaded successfully',
|
||||||
customer,
|
customer,
|
||||||
activity,
|
activity,
|
||||||
|
recentActivities,
|
||||||
ownerHistory
|
ownerHistory
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { listRecentActivitiesForContext } from '@/features/crm/activities/server/timeline-service';
|
||||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
import { updateLeadSchema } from '@/features/crm/leads/schemas/lead.schema';
|
import { updateLeadSchema } from '@/features/crm/leads/schemas/lead.schema';
|
||||||
import {
|
import {
|
||||||
@@ -28,10 +29,17 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membership
|
membership
|
||||||
});
|
});
|
||||||
const [lead, followups, referenceData] = await Promise.all([
|
const [lead, followups, referenceData, recentActivities] = await Promise.all([
|
||||||
getLeadById(id, organization.id, accessContext),
|
getLeadById(id, organization.id, accessContext),
|
||||||
listLeadFollowups(id, organization.id, accessContext),
|
listLeadFollowups(id, organization.id, accessContext),
|
||||||
getLeadReferenceData(organization.id)
|
getLeadReferenceData(organization.id),
|
||||||
|
listRecentActivitiesForContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
entityType: 'lead',
|
||||||
|
entityId: id,
|
||||||
|
context: accessContext,
|
||||||
|
limit: 6
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await auditAction({
|
await auditAction({
|
||||||
@@ -52,7 +60,8 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
message: 'Lead loaded successfully',
|
message: 'Lead loaded successfully',
|
||||||
lead,
|
lead,
|
||||||
followups,
|
followups,
|
||||||
referenceData
|
referenceData,
|
||||||
|
recentActivities
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { listRecentActivitiesForContext } from '@/features/crm/activities/server/timeline-service';
|
||||||
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
import {
|
import {
|
||||||
getOpportunityActivity,
|
getOpportunityActivity,
|
||||||
@@ -42,9 +43,16 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const [opportunity, activity] = await Promise.all([
|
const [opportunity, activity, recentActivities] = await Promise.all([
|
||||||
getOpportunityDetail(id, organization.id, accessContext),
|
getOpportunityDetail(id, organization.id, accessContext),
|
||||||
getOpportunityActivity(id, organization.id, accessContext)
|
getOpportunityActivity(id, organization.id, accessContext),
|
||||||
|
listRecentActivitiesForContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
entityType: 'opportunity',
|
||||||
|
entityId: id,
|
||||||
|
context: accessContext,
|
||||||
|
limit: 6
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -52,7 +60,8 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Opportunity loaded successfully',
|
message: 'Opportunity loaded successfully',
|
||||||
opportunity,
|
opportunity,
|
||||||
activity
|
activity,
|
||||||
|
recentActivities
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { listRecentActivitiesForContext } from '@/features/crm/activities/server/timeline-service';
|
||||||
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
import {
|
import {
|
||||||
@@ -27,9 +28,16 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membership
|
membership
|
||||||
});
|
});
|
||||||
const [quotation, activity] = await Promise.all([
|
const [quotation, activity, recentActivities] = await Promise.all([
|
||||||
getQuotationDetail(id, organization.id, accessContext),
|
getQuotationDetail(id, organization.id, accessContext),
|
||||||
getQuotationActivity(id, organization.id)
|
getQuotationActivity(id, organization.id),
|
||||||
|
listRecentActivitiesForContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
entityType: 'quotation',
|
||||||
|
entityId: id,
|
||||||
|
context: accessContext,
|
||||||
|
limit: 6
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -37,7 +45,8 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Quotation loaded successfully',
|
message: 'Quotation loaded successfully',
|
||||||
quotation,
|
quotation,
|
||||||
activity
|
activity,
|
||||||
|
recentActivities
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface ActivityAssignableUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityRecord extends CrmActivityRecord {
|
export interface ActivityRecord extends CrmActivityRecord {
|
||||||
|
activityCode: string | null;
|
||||||
effectiveStatus: CrmActivityStatus;
|
effectiveStatus: CrmActivityStatus;
|
||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
assignedToName: string | null;
|
assignedToName: string | null;
|
||||||
@@ -30,6 +31,37 @@ export interface ActivityRecord extends CrmActivityRecord {
|
|||||||
isContentRedacted: boolean;
|
isContentRedacted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ActivityTimelineItemSource =
|
||||||
|
| 'activity'
|
||||||
|
| 'lead_followup'
|
||||||
|
| 'opportunity_followup'
|
||||||
|
| 'quotation_followup';
|
||||||
|
|
||||||
|
export interface ActivityTimelineItem {
|
||||||
|
id: string;
|
||||||
|
source: ActivityTimelineItemSource;
|
||||||
|
sourceLabel: string;
|
||||||
|
isLegacy: boolean;
|
||||||
|
activityId: string | null;
|
||||||
|
activityCode: string | null;
|
||||||
|
primaryEntityType: CrmActivityEntityType;
|
||||||
|
primaryEntityId: string | null;
|
||||||
|
primaryRecordLabel: string | null;
|
||||||
|
activityType: string;
|
||||||
|
activityTypeLabel: string;
|
||||||
|
subject: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
statusLabel: string;
|
||||||
|
occurredAt: string;
|
||||||
|
scheduledAt: string | null;
|
||||||
|
dueAt: string | null;
|
||||||
|
nextAction: string | null;
|
||||||
|
ownerName: string | null;
|
||||||
|
assignedToName: string | null;
|
||||||
|
createdByName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActivityListFilters {
|
export interface ActivityListFilters {
|
||||||
search?: string;
|
search?: string;
|
||||||
primaryEntityType?: CrmActivityEntityType;
|
primaryEntityType?: CrmActivityEntityType;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function ActivityDetail({ activity }: { activity: ActivityRecord | null }
|
|||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<Field label='Owner' value={activity.ownerName} />
|
<Field label='Owner' value={activity.ownerName} />
|
||||||
|
<Field label='Activity Code' value={activity.activityCode} />
|
||||||
<Field label='Assignee' value={activity.assignedToName} />
|
<Field label='Assignee' value={activity.assignedToName} />
|
||||||
<Field label='Primary Entity' value={activity.primaryEntityType} />
|
<Field label='Primary Entity' value={activity.primaryEntityType} />
|
||||||
<Field label='Primary Record Id' value={activity.primaryEntityId} />
|
<Field label='Primary Record Id' value={activity.primaryEntityId} />
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
|
import type { ActivityTimelineItem } from '../api/types';
|
||||||
|
|
||||||
|
interface RecentActivitiesPanelProps {
|
||||||
|
items: ActivityTimelineItem[];
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaItem({ label, value }: { label: string; value: string | null }) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{label}: {value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentActivitiesPanel({
|
||||||
|
items,
|
||||||
|
title = 'Recent Activities',
|
||||||
|
description = 'Unified view of new activities and legacy follow-up records.',
|
||||||
|
emptyMessage = 'No recent activities yet.'
|
||||||
|
}: RecentActivitiesPanelProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3'>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<div key={`${item.source}-${item.id}`} className='rounded-lg border p-4'>
|
||||||
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<div className='font-medium'>{item.subject}</div>
|
||||||
|
{item.activityCode ? <Badge variant='outline'>{item.activityCode}</Badge> : null}
|
||||||
|
<Badge variant={item.isLegacy ? 'secondary' : 'default'}>
|
||||||
|
{item.sourceLabel}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='outline'>{item.statusLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
{item.activityTypeLabel}
|
||||||
|
{item.primaryRecordLabel ? ` • ${item.primaryRecordLabel}` : ''}
|
||||||
|
</div>
|
||||||
|
{item.description ? <div className='text-sm'>{item.description}</div> : null}
|
||||||
|
<div className='text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs'>
|
||||||
|
<MetaItem label='When' value={formatDateTime(item.occurredAt)} />
|
||||||
|
<MetaItem label='Owner' value={item.ownerName} />
|
||||||
|
<MetaItem label='Created by' value={item.createdByName} />
|
||||||
|
<MetaItem label='Next action' value={item.nextAction} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,41 @@
|
|||||||
|
import { formatCrmActivityLabel } from '@/features/crm/activity/types';
|
||||||
import type { LeadFollowupSummary } from '@/features/crm/leads/types';
|
import type { LeadFollowupSummary } from '@/features/crm/leads/types';
|
||||||
|
import type { ActivityTimelineItem } from '../../api/types';
|
||||||
|
|
||||||
export interface LeadFollowupAdapterCandidate {
|
export interface LeadFollowupAdapterCandidate {
|
||||||
source: 'lead_followup';
|
source: 'lead_followup';
|
||||||
followup: LeadFollowupSummary;
|
followup: LeadFollowupSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adaptLeadFollowupCandidate(
|
||||||
|
candidate: LeadFollowupAdapterCandidate
|
||||||
|
): ActivityTimelineItem {
|
||||||
|
const statusLabel =
|
||||||
|
candidate.followup.followupStatusLabel ??
|
||||||
|
formatCrmActivityLabel(candidate.followup.followupStatus);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: candidate.followup.id,
|
||||||
|
source: 'lead_followup',
|
||||||
|
sourceLabel: 'Lead Follow-up',
|
||||||
|
isLegacy: true,
|
||||||
|
activityId: null,
|
||||||
|
activityCode: null,
|
||||||
|
primaryEntityType: 'lead',
|
||||||
|
primaryEntityId: candidate.followup.leadId,
|
||||||
|
primaryRecordLabel: null,
|
||||||
|
activityType: 'follow_up',
|
||||||
|
activityTypeLabel: 'Follow Up',
|
||||||
|
subject: candidate.followup.note?.trim() || 'Lead follow-up',
|
||||||
|
description: candidate.followup.note,
|
||||||
|
status: candidate.followup.followupStatus,
|
||||||
|
statusLabel,
|
||||||
|
occurredAt: candidate.followup.followupDate,
|
||||||
|
scheduledAt: candidate.followup.followupDate,
|
||||||
|
dueAt: candidate.followup.nextFollowupDate,
|
||||||
|
nextAction: null,
|
||||||
|
ownerName: null,
|
||||||
|
assignedToName: null,
|
||||||
|
createdByName: candidate.followup.createdByName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
|
import { formatCrmActivityLabel } from '@/features/crm/activity/types';
|
||||||
|
import type { ActivityTimelineItem } from '../../api/types';
|
||||||
import type { OpportunityFollowupRecord } from '@/features/crm/opportunities/api/types';
|
import type { OpportunityFollowupRecord } from '@/features/crm/opportunities/api/types';
|
||||||
|
|
||||||
export interface OpportunityFollowupAdapterCandidate {
|
export interface OpportunityFollowupAdapterCandidate {
|
||||||
source: 'opportunity_followup';
|
source: 'opportunity_followup';
|
||||||
followup: OpportunityFollowupRecord;
|
followup: OpportunityFollowupRecord;
|
||||||
|
followupTypeLabel?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adaptOpportunityFollowupCandidate(
|
||||||
|
candidate: OpportunityFollowupAdapterCandidate
|
||||||
|
): ActivityTimelineItem {
|
||||||
|
const typeLabel =
|
||||||
|
candidate.followupTypeLabel ?? formatCrmActivityLabel(candidate.followup.followupType);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: candidate.followup.id,
|
||||||
|
source: 'opportunity_followup',
|
||||||
|
sourceLabel: 'Opportunity Follow-up',
|
||||||
|
isLegacy: true,
|
||||||
|
activityId: null,
|
||||||
|
activityCode: null,
|
||||||
|
primaryEntityType: 'opportunity',
|
||||||
|
primaryEntityId: candidate.followup.opportunityId,
|
||||||
|
primaryRecordLabel: null,
|
||||||
|
activityType: 'follow_up',
|
||||||
|
activityTypeLabel: typeLabel,
|
||||||
|
subject: typeLabel,
|
||||||
|
description: candidate.followup.outcome ?? candidate.followup.notes,
|
||||||
|
status: 'completed',
|
||||||
|
statusLabel: 'Completed',
|
||||||
|
occurredAt: candidate.followup.followupDate,
|
||||||
|
scheduledAt: candidate.followup.followupDate,
|
||||||
|
dueAt: candidate.followup.nextFollowupDate,
|
||||||
|
nextAction: candidate.followup.nextAction,
|
||||||
|
ownerName: null,
|
||||||
|
assignedToName: null,
|
||||||
|
createdByName: candidate.createdByName ?? null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
|
import { formatCrmActivityLabel } from '@/features/crm/activity/types';
|
||||||
|
import type { ActivityTimelineItem } from '../../api/types';
|
||||||
import type { QuotationFollowupRecord } from '@/features/crm/quotations/api/types';
|
import type { QuotationFollowupRecord } from '@/features/crm/quotations/api/types';
|
||||||
|
|
||||||
export interface QuotationFollowupAdapterCandidate {
|
export interface QuotationFollowupAdapterCandidate {
|
||||||
source: 'quotation_followup';
|
source: 'quotation_followup';
|
||||||
followup: QuotationFollowupRecord;
|
followup: QuotationFollowupRecord;
|
||||||
|
followupTypeLabel?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adaptQuotationFollowupCandidate(
|
||||||
|
candidate: QuotationFollowupAdapterCandidate
|
||||||
|
): ActivityTimelineItem {
|
||||||
|
const typeLabel =
|
||||||
|
candidate.followupTypeLabel ?? formatCrmActivityLabel(candidate.followup.followupType);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: candidate.followup.id,
|
||||||
|
source: 'quotation_followup',
|
||||||
|
sourceLabel: 'Quotation Follow-up',
|
||||||
|
isLegacy: true,
|
||||||
|
activityId: null,
|
||||||
|
activityCode: null,
|
||||||
|
primaryEntityType: 'quotation',
|
||||||
|
primaryEntityId: candidate.followup.quotationId,
|
||||||
|
primaryRecordLabel: null,
|
||||||
|
activityType: 'follow_up',
|
||||||
|
activityTypeLabel: typeLabel,
|
||||||
|
subject: typeLabel,
|
||||||
|
description: candidate.followup.outcome ?? candidate.followup.notes,
|
||||||
|
status: 'completed',
|
||||||
|
statusLabel: 'Completed',
|
||||||
|
occurredAt: candidate.followup.followupDate,
|
||||||
|
scheduledAt: candidate.followup.followupDate,
|
||||||
|
dueAt: candidate.followup.nextFollowupDate,
|
||||||
|
nextAction: candidate.followup.nextAction,
|
||||||
|
ownerName: null,
|
||||||
|
assignedToName: null,
|
||||||
|
createdByName: candidate.createdByName ?? null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/features/crm/activities/server/attachment-service.ts
Normal file
128
src/features/crm/activities/server/attachment-service.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
|
||||||
|
import {
|
||||||
|
createArtifact,
|
||||||
|
downloadArtifact,
|
||||||
|
listArtifactsForEntity,
|
||||||
|
type DocumentArtifactRecord
|
||||||
|
} from '@/features/foundation/document-artifact/server/service';
|
||||||
|
import {
|
||||||
|
buildOrganizationStorageKey,
|
||||||
|
getStorageProvider
|
||||||
|
} from '@/features/foundation/storage/service';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import type { StoredObjectStream } from '@/features/foundation/storage/types';
|
||||||
|
import type { CrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import { getActivityById } from './service';
|
||||||
|
|
||||||
|
const ACTIVITY_ATTACHMENT_ENTITY_TYPE = 'crm_activity';
|
||||||
|
const ACTIVITY_ATTACHMENT_DOCUMENT_TYPE = 'activity';
|
||||||
|
const ACTIVITY_ATTACHMENT_ARTIFACT_TYPE = 'attachment';
|
||||||
|
|
||||||
|
export type ActivityAttachmentKind =
|
||||||
|
| 'image'
|
||||||
|
| 'pdf'
|
||||||
|
| 'office_document'
|
||||||
|
| 'voice_recording'
|
||||||
|
| 'drawing'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
export interface UploadActivityAttachmentInput {
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
activityId: string;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
body: Buffer;
|
||||||
|
attachmentKind?: ActivityAttachmentKind;
|
||||||
|
context: CrmSecurityContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertAttachmentWritePermission(context: CrmSecurityContext) {
|
||||||
|
if (
|
||||||
|
!context.permissions.includes(CRM_ACTIVITY_PERMISSIONS.create) &&
|
||||||
|
!context.permissions.includes(CRM_ACTIVITY_PERMISSIONS.update)
|
||||||
|
) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachmentStorageKey(input: {
|
||||||
|
organizationId: string;
|
||||||
|
activityId: string;
|
||||||
|
fileName: string;
|
||||||
|
}) {
|
||||||
|
return buildOrganizationStorageKey(input.organizationId, [
|
||||||
|
'crm',
|
||||||
|
'activities',
|
||||||
|
input.activityId,
|
||||||
|
crypto.randomUUID(),
|
||||||
|
input.fileName
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActivityAttachments(input: {
|
||||||
|
organizationId: string;
|
||||||
|
activityId: string;
|
||||||
|
context: CrmSecurityContext;
|
||||||
|
}): Promise<DocumentArtifactRecord[]> {
|
||||||
|
await getActivityById(input.activityId, input.organizationId, input.context);
|
||||||
|
|
||||||
|
return listArtifactsForEntity({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
entityType: ACTIVITY_ATTACHMENT_ENTITY_TYPE,
|
||||||
|
entityId: input.activityId,
|
||||||
|
artifactType: ACTIVITY_ATTACHMENT_ARTIFACT_TYPE
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadActivityAttachment(
|
||||||
|
input: UploadActivityAttachmentInput
|
||||||
|
): Promise<DocumentArtifactRecord> {
|
||||||
|
assertAttachmentWritePermission(input.context);
|
||||||
|
await getActivityById(input.activityId, input.organizationId, input.context);
|
||||||
|
|
||||||
|
const storedObject = await getStorageProvider().putObject({
|
||||||
|
key: buildAttachmentStorageKey(input),
|
||||||
|
body: input.body,
|
||||||
|
contentType: input.contentType,
|
||||||
|
fileName: input.fileName
|
||||||
|
});
|
||||||
|
|
||||||
|
return createArtifact({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
userId: input.userId,
|
||||||
|
entityType: ACTIVITY_ATTACHMENT_ENTITY_TYPE,
|
||||||
|
entityId: input.activityId,
|
||||||
|
documentType: ACTIVITY_ATTACHMENT_DOCUMENT_TYPE,
|
||||||
|
artifactType: ACTIVITY_ATTACHMENT_ARTIFACT_TYPE,
|
||||||
|
storageProvider: storedObject.provider,
|
||||||
|
storageKey: storedObject.key,
|
||||||
|
fileName: storedObject.fileName ?? input.fileName,
|
||||||
|
contentType: storedObject.contentType,
|
||||||
|
fileSize: storedObject.size,
|
||||||
|
checksum: storedObject.checksum ?? null,
|
||||||
|
metadata: {
|
||||||
|
attachmentKind: input.attachmentKind ?? 'other'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadActivityAttachment(input: {
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
activityId: string;
|
||||||
|
artifactId: string;
|
||||||
|
context: CrmSecurityContext;
|
||||||
|
}): Promise<{
|
||||||
|
artifact: DocumentArtifactRecord;
|
||||||
|
object: StoredObjectStream;
|
||||||
|
}> {
|
||||||
|
await getActivityById(input.activityId, input.organizationId, input.context);
|
||||||
|
|
||||||
|
return downloadArtifact({
|
||||||
|
artifactId: input.artifactId,
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
userId: input.userId,
|
||||||
|
auditDownload: true
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ function toRecordMetadata(value: unknown): Record<string, unknown> | null {
|
|||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActivityCode(metadata: Record<string, unknown> | null): string | null {
|
||||||
|
return typeof metadata?.activityCode === 'string' ? metadata.activityCode : null;
|
||||||
|
}
|
||||||
|
|
||||||
function canViewPricingForRow(context: CrmSecurityContext, row: ActivityRow): boolean {
|
function canViewPricingForRow(context: CrmSecurityContext, row: ActivityRow): boolean {
|
||||||
if (!row.isPricingSensitive) {
|
if (!row.isPricingSensitive) {
|
||||||
return true;
|
return true;
|
||||||
@@ -43,9 +47,11 @@ export async function hydrateActivityRecords(
|
|||||||
|
|
||||||
const canViewPricingSensitiveContent = canViewPricingForRow(context, row);
|
const canViewPricingSensitiveContent = canViewPricingForRow(context, row);
|
||||||
const shouldRedact = row.isPricingSensitive && !canViewPricingSensitiveContent;
|
const shouldRedact = row.isPricingSensitive && !canViewPricingSensitiveContent;
|
||||||
|
const metadata = toRecordMetadata(row.metadata);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
activityCode: getActivityCode(metadata),
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
primaryEntityType: row.primaryEntityType as ActivityRecord['primaryEntityType'],
|
primaryEntityType: row.primaryEntityType as ActivityRecord['primaryEntityType'],
|
||||||
primaryEntityId: row.primaryEntityId ?? null,
|
primaryEntityId: row.primaryEntityId ?? null,
|
||||||
@@ -81,7 +87,7 @@ export async function hydrateActivityRecords(
|
|||||||
isInternalOnly: row.isInternalOnly,
|
isInternalOnly: row.isInternalOnly,
|
||||||
isPricingSensitive: row.isPricingSensitive,
|
isPricingSensitive: row.isPricingSensitive,
|
||||||
parentActivityId: row.parentActivityId ?? null,
|
parentActivityId: row.parentActivityId ?? null,
|
||||||
metadata: toRecordMetadata(row.metadata),
|
metadata,
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy,
|
updatedBy: row.updatedBy,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, desc, eq, ilike, isNull, or, type SQL } from 'drizzle-orm';
|
import { and, desc, eq, ilike, isNull, or, type SQL } from 'drizzle-orm';
|
||||||
import { crmActivities } from '@/db/schema';
|
import { crmActivities } from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import type { CrmActivityEntityType } from '@/features/crm/activity/types';
|
||||||
|
|
||||||
export type ActivityRow = typeof crmActivities.$inferSelect;
|
export type ActivityRow = typeof crmActivities.$inferSelect;
|
||||||
export type InsertActivityRow = typeof crmActivities.$inferInsert;
|
export type InsertActivityRow = typeof crmActivities.$inferInsert;
|
||||||
@@ -13,10 +14,19 @@ export interface ActivityListQuery {
|
|||||||
activityType?: string;
|
activityType?: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
assignedToId?: string;
|
assignedToId?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityContextQuery {
|
||||||
|
organizationId: string;
|
||||||
|
contextEntityType: Exclude<CrmActivityEntityType, 'internal'>;
|
||||||
|
contextEntityId: string;
|
||||||
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function insertActivity(values: InsertActivityRow): Promise<ActivityRow> {
|
export async function insertActivity(values: InsertActivityRow): Promise<ActivityRow> {
|
||||||
const [created] = await db.insert(crmActivities).values(values).returning();
|
const [created] = await db.insert(crmActivities).values(values).returning();
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +44,10 @@ export async function updateActivityRow(
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActivityRow(id: string, organizationId: string): Promise<ActivityRow | undefined> {
|
export async function getActivityRow(
|
||||||
|
id: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<ActivityRow | undefined> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(crmActivities)
|
.from(crmActivities)
|
||||||
@@ -85,9 +98,59 @@ export async function listActivityRows(query: ActivityListQuery): Promise<Activi
|
|||||||
filters.push(eq(crmActivities.assignedToId, query.assignedToId));
|
filters.push(eq(crmActivities.assignedToId, query.assignedToId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
let statement = db
|
||||||
.select()
|
.select()
|
||||||
.from(crmActivities)
|
.from(crmActivities)
|
||||||
.where(and(...filters))
|
.where(and(...filters))
|
||||||
.orderBy(desc(crmActivities.updatedAt), desc(crmActivities.createdAt));
|
.orderBy(desc(crmActivities.scheduledStartAt), desc(crmActivities.createdAt))
|
||||||
|
.$dynamic();
|
||||||
|
|
||||||
|
if (query.limit) {
|
||||||
|
statement = statement.limit(query.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return statement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContextFilter(query: ActivityContextQuery): SQL {
|
||||||
|
switch (query.contextEntityType) {
|
||||||
|
case 'customer':
|
||||||
|
return eq(crmActivities.customerId, query.contextEntityId);
|
||||||
|
case 'contact':
|
||||||
|
return eq(crmActivities.contactId, query.contextEntityId);
|
||||||
|
case 'lead':
|
||||||
|
return eq(crmActivities.leadId, query.contextEntityId);
|
||||||
|
case 'opportunity':
|
||||||
|
return eq(crmActivities.opportunityId, query.contextEntityId);
|
||||||
|
case 'quotation':
|
||||||
|
return eq(crmActivities.quotationId, query.contextEntityId);
|
||||||
|
case 'purchase_order':
|
||||||
|
return and(
|
||||||
|
eq(crmActivities.primaryEntityType, 'purchase_order'),
|
||||||
|
eq(crmActivities.primaryEntityId, query.contextEntityId)
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActivityRowsByContext(
|
||||||
|
query: ActivityContextQuery
|
||||||
|
): Promise<ActivityRow[]> {
|
||||||
|
const filters: SQL[] = [
|
||||||
|
eq(crmActivities.organizationId, query.organizationId),
|
||||||
|
isNull(crmActivities.deletedAt),
|
||||||
|
buildContextFilter(query)
|
||||||
|
];
|
||||||
|
|
||||||
|
let statement = db
|
||||||
|
.select()
|
||||||
|
.from(crmActivities)
|
||||||
|
.where(and(...filters))
|
||||||
|
.orderBy(desc(crmActivities.scheduledStartAt), desc(crmActivities.createdAt))
|
||||||
|
.$dynamic();
|
||||||
|
|
||||||
|
if (query.limit) {
|
||||||
|
statement = statement.limit(query.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return statement;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { and, eq, isNull } from 'drizzle-orm';
|
|||||||
import { memberships, users, crmContactShares, crmCustomerContacts, crmCustomers } from '@/db/schema';
|
import { memberships, users, crmContactShares, crmCustomerContacts, crmCustomers } from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
import {
|
import {
|
||||||
CRM_ACTIVITY_ENTITY_TYPES,
|
CRM_ACTIVITY_ENTITY_TYPES,
|
||||||
CRM_ACTIVITY_PERMISSIONS,
|
CRM_ACTIVITY_PERMISSIONS,
|
||||||
@@ -47,6 +49,9 @@ import { hydrateActivityRecords } from './read-model';
|
|||||||
|
|
||||||
type ActivityAccessContext = CrmSecurityContext;
|
type ActivityAccessContext = CrmSecurityContext;
|
||||||
|
|
||||||
|
const ACTIVITY_DOCUMENT_TYPE = 'activity';
|
||||||
|
const ACTIVITY_TYPE_CATEGORY = 'crm_activity_type';
|
||||||
|
|
||||||
interface PrimaryRecordContext {
|
interface PrimaryRecordContext {
|
||||||
primaryEntityId: string | null;
|
primaryEntityId: string | null;
|
||||||
primaryRecordLabel: string | null;
|
primaryRecordLabel: string | null;
|
||||||
@@ -388,6 +393,30 @@ function mapOption(value: string): ActivityOption {
|
|||||||
return { value, label: formatCrmActivityLabel(value) };
|
return { value, label: formatCrmActivityLabel(value) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listGovernedActivityTypeOptions(organizationId: string): Promise<ActivityOption[]> {
|
||||||
|
const options = await getActiveOptionsByCategory(ACTIVITY_TYPE_CATEGORY, { organizationId });
|
||||||
|
|
||||||
|
if (!options.length) {
|
||||||
|
return CRM_ACTIVITY_TYPES.map(mapOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.map((option) => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.label
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertActivityTypeValue(organizationId: string, activityType: string) {
|
||||||
|
const options = await getActiveOptionsByCategory(ACTIVITY_TYPE_CATEGORY, { organizationId });
|
||||||
|
const allowedValues = options.length
|
||||||
|
? new Set(options.flatMap((option) => [option.id, option.code]))
|
||||||
|
: new Set<string>(CRM_ACTIVITY_TYPES);
|
||||||
|
|
||||||
|
if (!allowedValues.has(activityType)) {
|
||||||
|
throw new AuthError('Invalid activity type', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function filterByEffectiveStatus(items: ActivityRecord[], status?: string) {
|
function filterByEffectiveStatus(items: ActivityRecord[], status?: string) {
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return items;
|
return items;
|
||||||
@@ -489,11 +518,14 @@ async function buildActivityWriteValues(
|
|||||||
export async function getActivityReferenceData(
|
export async function getActivityReferenceData(
|
||||||
organizationId: string
|
organizationId: string
|
||||||
): Promise<ActivityReferenceData> {
|
): Promise<ActivityReferenceData> {
|
||||||
const assignableUsers = await listAssignableUsers(organizationId);
|
const [assignableUsers, activityTypes] = await Promise.all([
|
||||||
|
listAssignableUsers(organizationId),
|
||||||
|
listGovernedActivityTypeOptions(organizationId)
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entityTypes: CRM_ACTIVITY_ENTITY_TYPES.map(mapOption),
|
entityTypes: CRM_ACTIVITY_ENTITY_TYPES.map(mapOption),
|
||||||
activityTypes: CRM_ACTIVITY_TYPES.map(mapOption),
|
activityTypes,
|
||||||
statuses: CRM_ACTIVITY_STATUSES.filter((status) => status !== 'overdue').map(mapOption),
|
statuses: CRM_ACTIVITY_STATUSES.filter((status) => status !== 'overdue').map(mapOption),
|
||||||
priorities: CRM_ACTIVITY_PRIORITIES.map(mapOption),
|
priorities: CRM_ACTIVITY_PRIORITIES.map(mapOption),
|
||||||
assignableUsers
|
assignableUsers
|
||||||
@@ -555,6 +587,25 @@ export async function createActivity(
|
|||||||
if (!values.subject) {
|
if (!values.subject) {
|
||||||
throw new AuthError('Subject is required', 400);
|
throw new AuthError('Subject is required', 400);
|
||||||
}
|
}
|
||||||
|
await assertActivityTypeValue(organizationId, values.activityType);
|
||||||
|
|
||||||
|
const sequence = await generateNextDocumentCode({
|
||||||
|
organizationId,
|
||||||
|
documentType: ACTIVITY_DOCUMENT_TYPE,
|
||||||
|
branchId: values.branchId,
|
||||||
|
productType: values.productType
|
||||||
|
});
|
||||||
|
const metadata = {
|
||||||
|
...values.metadata,
|
||||||
|
activityCode: sequence.code,
|
||||||
|
documentSequence: {
|
||||||
|
documentType: sequence.documentType,
|
||||||
|
branchId: sequence.branchId,
|
||||||
|
productType: sequence.productType,
|
||||||
|
period: sequence.period,
|
||||||
|
currentNumber: sequence.currentNumber
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const created = await insertActivity({
|
const created = await insertActivity({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -590,7 +641,7 @@ export async function createActivity(
|
|||||||
isInternalOnly: values.isInternalOnly,
|
isInternalOnly: values.isInternalOnly,
|
||||||
isPricingSensitive: values.isPricingSensitive,
|
isPricingSensitive: values.isPricingSensitive,
|
||||||
parentActivityId: null,
|
parentActivityId: null,
|
||||||
metadata: values.metadata,
|
metadata,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
updatedBy: userId
|
updatedBy: userId
|
||||||
});
|
});
|
||||||
@@ -614,6 +665,7 @@ export async function updateActivity(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const values = await buildActivityWriteValues(organizationId, userId, context, payload, current);
|
const values = await buildActivityWriteValues(organizationId, userId, context, payload, current);
|
||||||
|
await assertActivityTypeValue(organizationId, values.activityType);
|
||||||
const nextStatus = values.status;
|
const nextStatus = values.status;
|
||||||
|
|
||||||
await updateActivityRow(id, organizationId, {
|
await updateActivityRow(id, organizationId, {
|
||||||
|
|||||||
217
src/features/crm/activities/server/timeline-service.ts
Normal file
217
src/features/crm/activities/server/timeline-service.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import {
|
||||||
|
CRM_ACTIVITY_PERMISSIONS,
|
||||||
|
formatCrmActivityLabel,
|
||||||
|
type CrmActivityEntityType
|
||||||
|
} from '@/features/crm/activity/types';
|
||||||
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
import { resolveUserDisplays } from '@/features/foundation/display/server/display-resolver';
|
||||||
|
import {
|
||||||
|
canAccessScopedCrmRecord,
|
||||||
|
type CrmSecurityContext
|
||||||
|
} from '@/features/crm/security/server/service';
|
||||||
|
import type { ActivityRecord, ActivityTimelineItem } from '../api/types';
|
||||||
|
import { hydrateActivityRecords } from './read-model';
|
||||||
|
import { listActivityRowsByContext } from './repository';
|
||||||
|
import {
|
||||||
|
adaptLeadFollowupCandidate
|
||||||
|
} from './adapters/lead-followup.adapter';
|
||||||
|
import {
|
||||||
|
adaptOpportunityFollowupCandidate
|
||||||
|
} from './adapters/opportunity-followup.adapter';
|
||||||
|
import {
|
||||||
|
adaptQuotationFollowupCandidate
|
||||||
|
} from './adapters/quotation-followup.adapter';
|
||||||
|
import { listLeadFollowups } from '@/features/crm/leads/server/service';
|
||||||
|
import { listOpportunityFollowups } from '@/features/crm/opportunities/server/service';
|
||||||
|
import { listQuotationFollowups } from '@/features/crm/quotations/server/service';
|
||||||
|
|
||||||
|
const FOLLOWUP_TYPE_CATEGORY = 'crm_followup_type';
|
||||||
|
|
||||||
|
type TimelineEntityType = Exclude<CrmActivityEntityType, 'internal' | 'purchase_order'>;
|
||||||
|
|
||||||
|
interface TimelineContextInput {
|
||||||
|
organizationId: string;
|
||||||
|
entityType: TimelineEntityType;
|
||||||
|
entityId: string;
|
||||||
|
context: CrmSecurityContext;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasActivityReadPermission(context: CrmSecurityContext): boolean {
|
||||||
|
return context.permissions.includes(CRM_ACTIVITY_PERMISSIONS.read);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isManagerLike(context: CrmSecurityContext): boolean {
|
||||||
|
const roles = context.businessRoles ?? [context.businessRole];
|
||||||
|
|
||||||
|
return (
|
||||||
|
context.membershipRole === 'admin' ||
|
||||||
|
context.ownershipScope === 'organization' ||
|
||||||
|
context.ownershipScope === 'team' ||
|
||||||
|
roles.some((role) => role.includes('manager') || role === 'crm_admin')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canViewActivityTimelineItem(context: CrmSecurityContext, activity: ActivityRecord): boolean {
|
||||||
|
const isDirectUser =
|
||||||
|
activity.createdBy === context.userId ||
|
||||||
|
activity.ownerId === context.userId ||
|
||||||
|
activity.assignedToId === context.userId;
|
||||||
|
|
||||||
|
if (activity.isInternalOnly) {
|
||||||
|
return isDirectUser || isManagerLike(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
isDirectUser ||
|
||||||
|
canAccessScopedCrmRecord(context, {
|
||||||
|
branchId: activity.branchId,
|
||||||
|
productType: activity.productType,
|
||||||
|
ownerUserId: activity.ownerId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActivityOccurredAt(activity: ActivityRecord): string {
|
||||||
|
return (
|
||||||
|
activity.completedAt ??
|
||||||
|
activity.cancelledAt ??
|
||||||
|
activity.skippedAt ??
|
||||||
|
activity.scheduledStartAt ??
|
||||||
|
activity.dueAt ??
|
||||||
|
activity.createdAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapActivityToTimelineItem(activity: ActivityRecord): ActivityTimelineItem {
|
||||||
|
return {
|
||||||
|
id: activity.id,
|
||||||
|
source: 'activity',
|
||||||
|
sourceLabel: 'Activity',
|
||||||
|
isLegacy: false,
|
||||||
|
activityId: activity.id,
|
||||||
|
activityCode: activity.activityCode,
|
||||||
|
primaryEntityType: activity.primaryEntityType,
|
||||||
|
primaryEntityId: activity.primaryEntityId,
|
||||||
|
primaryRecordLabel: activity.primaryRecordLabel,
|
||||||
|
activityType: activity.activityType,
|
||||||
|
activityTypeLabel: formatCrmActivityLabel(activity.activityType),
|
||||||
|
subject: activity.subject,
|
||||||
|
description: activity.description,
|
||||||
|
status: activity.effectiveStatus,
|
||||||
|
statusLabel: formatCrmActivityLabel(activity.effectiveStatus),
|
||||||
|
occurredAt: resolveActivityOccurredAt(activity),
|
||||||
|
scheduledAt: activity.scheduledStartAt,
|
||||||
|
dueAt: activity.dueAt,
|
||||||
|
nextAction: activity.nextAction,
|
||||||
|
ownerName: activity.ownerName,
|
||||||
|
assignedToName: activity.assignedToName,
|
||||||
|
createdByName: activity.createdByName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTimelineItems(items: ActivityTimelineItem[]): ActivityTimelineItem[] {
|
||||||
|
return items.toSorted((left, right) => {
|
||||||
|
return new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildFollowupTypeLabelMap(organizationId: string): Promise<Map<string, string>> {
|
||||||
|
const options = await getActiveOptionsByCategory(FOLLOWUP_TYPE_CATEGORY, { organizationId });
|
||||||
|
|
||||||
|
return new Map(options.map((option) => [option.id, option.label]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUserDisplayMap(items: Array<{ createdBy: string }>): Promise<Map<string, string>> {
|
||||||
|
const userIds = [...new Set(items.map((item) => item.createdBy).filter(Boolean))];
|
||||||
|
const displayMap = await resolveUserDisplays(userIds);
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
[...displayMap.entries()].map(([userId, record]) => [userId, record.displayName])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLegacyTimelineItems(
|
||||||
|
input: TimelineContextInput
|
||||||
|
): Promise<ActivityTimelineItem[]> {
|
||||||
|
if (input.entityType === 'customer' || input.entityType === 'contact') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.entityType === 'lead') {
|
||||||
|
const followups = await listLeadFollowups(input.entityId, input.organizationId, input.context);
|
||||||
|
|
||||||
|
return followups.map((followup) =>
|
||||||
|
adaptLeadFollowupCandidate({
|
||||||
|
source: 'lead_followup',
|
||||||
|
followup
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.entityType === 'opportunity') {
|
||||||
|
const [followupTypeLabelMap, followups] = await Promise.all([
|
||||||
|
buildFollowupTypeLabelMap(input.organizationId),
|
||||||
|
listOpportunityFollowups(input.entityId, input.organizationId, input.context)
|
||||||
|
]);
|
||||||
|
const userDisplayMap = await buildUserDisplayMap(followups);
|
||||||
|
|
||||||
|
return followups.map((followup) =>
|
||||||
|
adaptOpportunityFollowupCandidate({
|
||||||
|
source: 'opportunity_followup',
|
||||||
|
followup,
|
||||||
|
followupTypeLabel: followupTypeLabelMap.get(followup.followupType),
|
||||||
|
createdByName: userDisplayMap.get(followup.createdBy) ?? null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [followupTypeLabelMap, followups] = await Promise.all([
|
||||||
|
buildFollowupTypeLabelMap(input.organizationId),
|
||||||
|
listQuotationFollowups(input.entityId, input.organizationId)
|
||||||
|
]);
|
||||||
|
const userDisplayMap = await buildUserDisplayMap(followups);
|
||||||
|
|
||||||
|
return followups.map((followup) =>
|
||||||
|
adaptQuotationFollowupCandidate({
|
||||||
|
source: 'quotation_followup',
|
||||||
|
followup,
|
||||||
|
followupTypeLabel: followupTypeLabelMap.get(followup.followupType),
|
||||||
|
createdByName: userDisplayMap.get(followup.createdBy) ?? null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCurrentTimelineItems(input: TimelineContextInput): Promise<ActivityTimelineItem[]> {
|
||||||
|
const rows = await listActivityRowsByContext({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
contextEntityType: input.entityType,
|
||||||
|
contextEntityId: input.entityId,
|
||||||
|
limit: input.limit ? input.limit * 2 : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const hydrated = await hydrateActivityRecords(rows, input.context);
|
||||||
|
|
||||||
|
return hydrated.filter((activity) => canViewActivityTimelineItem(input.context, activity)).map(mapActivityToTimelineItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRecentActivitiesForContext(
|
||||||
|
input: TimelineContextInput
|
||||||
|
): Promise<ActivityTimelineItem[]> {
|
||||||
|
if (!hasActivityReadPermission(input.context)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [currentItems, legacyItems] = await Promise.all([
|
||||||
|
listCurrentTimelineItems(input),
|
||||||
|
listLegacyTimelineItems(input)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged = sortTimelineItems([...currentItems, ...legacyItems]);
|
||||||
|
|
||||||
|
if (!input.limit) {
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged.slice(0, input.limit);
|
||||||
|
}
|
||||||
@@ -11,14 +11,19 @@ export const CRM_ACTIVITY_ENTITY_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CRM_ACTIVITY_TYPES = [
|
export const CRM_ACTIVITY_TYPES = [
|
||||||
'follow_up',
|
|
||||||
'meeting',
|
'meeting',
|
||||||
'visit',
|
'visit',
|
||||||
'phone_call',
|
'phone_call',
|
||||||
'email_follow_up',
|
'email',
|
||||||
'site_survey',
|
'site_survey',
|
||||||
'presentation',
|
'presentation',
|
||||||
|
'reminder',
|
||||||
'internal_task',
|
'internal_task',
|
||||||
|
'follow_up',
|
||||||
|
'delivery_coordination',
|
||||||
|
'approval_action',
|
||||||
|
// Backward-compatible aliases kept while master options become the source of truth.
|
||||||
|
'email_follow_up',
|
||||||
'reminder_action',
|
'reminder_action',
|
||||||
'other'
|
'other'
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export interface CustomerDetailResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
customer: CustomerRecord;
|
customer: CustomerRecord;
|
||||||
activity: CustomerActivityRecord[];
|
activity: CustomerActivityRecord[];
|
||||||
|
recentActivities: ActivityTimelineItem[];
|
||||||
ownerHistory: CustomerOwnerHistoryRecord[];
|
ownerHistory: CustomerOwnerHistoryRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,3 +228,4 @@ export interface MutationSuccessResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||||
|
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
|
||||||
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
import { formatNumber } from '@/lib/number-format';
|
import { formatNumber } from '@/lib/number-format';
|
||||||
@@ -126,6 +127,12 @@ export function CustomerDetail({
|
|||||||
<div className='space-y-6 lg:col-span-2'>
|
<div className='space-y-6 lg:col-span-2'>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className='pt-6'>
|
<CardContent className='pt-6'>
|
||||||
|
<RecentActivitiesPanel
|
||||||
|
items={data.recentActivities}
|
||||||
|
description='Recent customer-linked activity records shown alongside existing audit history.'
|
||||||
|
emptyMessage='No recent customer activity yet.'
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue='overview' className='gap-4'>
|
<Tabs defaultValue='overview' className='gap-4'>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value='overview'>ภาพรวม</TabsTrigger>
|
<TabsTrigger value='overview'>ภาพรวม</TabsTrigger>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
|
||||||
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||||
import { formatDateTime } from '@/lib/date-format';
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
import { formatNumber } from '@/lib/number-format';
|
import { formatNumber } from '@/lib/number-format';
|
||||||
@@ -112,6 +113,12 @@ export function LeadDetail({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<RecentActivitiesPanel
|
||||||
|
items={data.recentActivities}
|
||||||
|
description='New activity records and legacy lead follow-ups shown together.'
|
||||||
|
emptyMessage='No recent activity or lead follow-up yet.'
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue='followups' className='space-y-4'>
|
<Tabs defaultValue='followups' className='space-y-4'>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export interface LeadDetailResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
lead: LeadDetail;
|
lead: LeadDetail;
|
||||||
followups: LeadFollowupSummary[];
|
followups: LeadFollowupSummary[];
|
||||||
|
recentActivities: ActivityTimelineItem[];
|
||||||
referenceData: LeadReferenceData;
|
referenceData: LeadReferenceData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,3 +224,4 @@ export interface LeadMutationSuccessResponse {
|
|||||||
followup?: LeadFollowupSummary;
|
followup?: LeadFollowupSummary;
|
||||||
}
|
}
|
||||||
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||||
|
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export interface OpportunityDetailResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
opportunity: OpportunityRecord;
|
opportunity: OpportunityRecord;
|
||||||
activity: OpportunityActivityRecord[];
|
activity: OpportunityActivityRecord[];
|
||||||
|
recentActivities: ActivityTimelineItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpportunityProjectPartiesResponse {
|
export interface OpportunityProjectPartiesResponse {
|
||||||
@@ -338,3 +339,4 @@ export interface MutationSuccessResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||||
|
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
|
||||||
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||||
import { formatDate, formatDateTime } from '@/lib/date-format';
|
import { formatDate, formatDateTime } from '@/lib/date-format';
|
||||||
import { formatNumber } from '@/lib/number-format';
|
import { formatNumber } from '@/lib/number-format';
|
||||||
@@ -317,6 +318,12 @@ remark: item.remark ?? ''
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<RecentActivitiesPanel
|
||||||
|
items={data.recentActivities}
|
||||||
|
description='New activity records and legacy opportunity follow-ups shown together.'
|
||||||
|
emptyMessage='No recent activity or opportunity follow-up yet.'
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue='followups' className='space-y-4'>
|
<Tabs defaultValue='followups' className='space-y-4'>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value='followups'>Follow-up Timeline</TabsTrigger>
|
<TabsTrigger value='followups'>Follow-up Timeline</TabsTrigger>
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export interface QuotationDetailResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
quotation: QuotationRecord;
|
quotation: QuotationRecord;
|
||||||
activity: QuotationActivityRecord[];
|
activity: QuotationActivityRecord[];
|
||||||
|
recentActivities: ActivityTimelineItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotationItemsResponse {
|
export interface QuotationItemsResponse {
|
||||||
@@ -488,3 +489,4 @@ export interface QuotationCustomerPackageGenerateResponse {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||||
|
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { RecentActivitiesPanel } from "@/features/crm/activities/components/recent-activities-panel";
|
||||||
import { AuditLogTab } from "@/features/crm/components/audit-log-tab";
|
import { AuditLogTab } from "@/features/crm/components/audit-log-tab";
|
||||||
import { formatDate, formatDateTime } from "@/lib/date-format";
|
import { formatDate, formatDateTime } from "@/lib/date-format";
|
||||||
import { formatNumber } from "@/lib/number-format";
|
import { formatNumber } from "@/lib/number-format";
|
||||||
@@ -1380,6 +1381,12 @@ export function QuotationDetail({
|
|||||||
<div className="space-y-6 lg:col-span-2">
|
<div className="space-y-6 lg:col-span-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
<RecentActivitiesPanel
|
||||||
|
items={data.recentActivities}
|
||||||
|
description="New activity records and legacy quotation follow-ups shown together."
|
||||||
|
emptyMessage="No recent activity or quotation follow-up yet."
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="gap-4">
|
<Tabs defaultValue="overview" className="gap-4">
|
||||||
<TabsList className="flex flex-wrap">
|
<TabsList className="flex flex-wrap">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export async function createArtifact(input: {
|
|||||||
entityType: string;
|
entityType: string;
|
||||||
entityId: string;
|
entityId: string;
|
||||||
documentType: string;
|
documentType: string;
|
||||||
artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export';
|
artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export' | 'attachment';
|
||||||
templateVersionId?: string | null;
|
templateVersionId?: string | null;
|
||||||
storageProvider: string;
|
storageProvider: string;
|
||||||
storageKey: string;
|
storageKey: string;
|
||||||
@@ -189,6 +189,29 @@ export async function getActiveArtifactForEntity(input: {
|
|||||||
return enrichArtifact(artifact);
|
return enrichArtifact(artifact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listArtifactsForEntity(input: {
|
||||||
|
organizationId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
artifactType?: string;
|
||||||
|
}) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(crmDocumentArtifacts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmDocumentArtifacts.organizationId, input.organizationId),
|
||||||
|
eq(crmDocumentArtifacts.entityType, input.entityType),
|
||||||
|
eq(crmDocumentArtifacts.entityId, input.entityId),
|
||||||
|
...(input.artifactType ? [eq(crmDocumentArtifacts.artifactType, input.artifactType)] : []),
|
||||||
|
isNull(crmDocumentArtifacts.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(crmDocumentArtifacts.createdAt));
|
||||||
|
|
||||||
|
return Promise.all(rows.map((row) => enrichArtifact(row)));
|
||||||
|
}
|
||||||
|
|
||||||
export async function lockArtifact(input: {
|
export async function lockArtifact(input: {
|
||||||
artifactId: string;
|
artifactId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
|
|||||||
crm_opportunity: "EN",
|
crm_opportunity: "EN",
|
||||||
quotation: "QT",
|
quotation: "QT",
|
||||||
approval: "APV",
|
approval: "APV",
|
||||||
|
activity: "ACT",
|
||||||
};
|
};
|
||||||
|
|
||||||
type OrganizationRecord = {
|
type OrganizationRecord = {
|
||||||
|
|||||||
Reference in New Issue
Block a user