task-ep.1.1
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
# Task EP.1.1 Activity Domain Foundation - 2026-07-10
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- introduce the first additive `crm_activities` write model
|
||||||
|
- add activity API, service, lightweight repository, and read-model shaping
|
||||||
|
- add foundational UI building blocks for sheet, form, detail, and badges
|
||||||
|
- preserve legacy lead, opportunity, and quotation follow-up implementations
|
||||||
|
|
||||||
|
## Review Summary
|
||||||
|
Reviewed before implementation:
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `plans/task-ep1.1.md`
|
||||||
|
- `docs/standards/task-contract-template.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/business/relationship-sales-workspace-blueprint-v1.md`
|
||||||
|
- `docs/implementation/task-bu-r.0.1-workspace-activity-business-blueprint-2026-07-07.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`
|
||||||
|
- `plans/task-eng.0.md`
|
||||||
|
- existing follow-up services and route handlers under `src/features/crm/leads/**`, `src/features/crm/opportunities/**`, and `src/features/crm/quotations/**`
|
||||||
|
- existing CRM security, audit, and customer detail foundations
|
||||||
|
|
||||||
|
## Foundations Reused
|
||||||
|
- `src/lib/auth/session.ts`
|
||||||
|
- `src/features/crm/security/server/service.ts`
|
||||||
|
- `src/features/foundation/audit-log/service.ts`
|
||||||
|
- `src/features/foundation/display/server/display-resolver.ts`
|
||||||
|
- existing customer, lead, opportunity, and quotation detail services for primary-record access validation
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
- `crm_activities` is additive and does not replace existing follow-up tables or audit-backed lead follow-up behavior.
|
||||||
|
- activity visibility reuses resolved CRM scope plus owner/assignee visibility and an internal-only rule for manager/admin style access.
|
||||||
|
- pricing-sensitive activity content is redacted when tied to quotation or PO context without quotation pricing visibility.
|
||||||
|
- follow-up adapters are documented as candidate contracts only in this phase. No data migration or dual-write was introduced.
|
||||||
|
|
||||||
|
## Follow-up Gap Analysis
|
||||||
|
1. Lead follow-up is still audit-log-backed rather than row-backed, so migration needs an adapter that can translate immutable audit entries into activity candidates without rewriting history.
|
||||||
|
2. Opportunity follow-up is row-backed and is the cleanest EP.1.2 consolidation seam.
|
||||||
|
3. Quotation follow-up is row-backed and can follow the same adapter path as opportunity follow-up, but pricing visibility rules must stay active when notes expose commercial values.
|
||||||
|
4. Dashboard and report consumers still read legacy follow-up data today, so EP.1.2 needs continuity checks before any shared activity projection becomes source input.
|
||||||
|
|
||||||
|
## Migration Preparation Report
|
||||||
|
- prepared:
|
||||||
|
- shared activity write model
|
||||||
|
- route-handler boundary
|
||||||
|
- service-owned lifecycle validation
|
||||||
|
- repository/read-model split
|
||||||
|
- projection contract interfaces
|
||||||
|
- basic activity UI foundation
|
||||||
|
- deferred intentionally:
|
||||||
|
- timeline projection
|
||||||
|
- calendar projection
|
||||||
|
- notification fan-out
|
||||||
|
- legacy follow-up storage migration
|
||||||
|
- dashboard/report dataset swap
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
- run `npm run typecheck`
|
||||||
|
- run `npm run db:generate`
|
||||||
|
- run targeted manual API smoke checks for create, update, assign, complete, cancel, reschedule, delete
|
||||||
|
|
||||||
|
## Residual Risks
|
||||||
|
- current activity creation UI uses manual primary record id entry because source-record pickers are intentionally deferred to later workspace integration work
|
||||||
|
- contact primary-record validation remains lightweight and should be revisited when customer workspace activity embedding begins
|
||||||
41
drizzle/0002_empty_jean_grey.sql
Normal file
41
drizzle/0002_empty_jean_grey.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
CREATE TABLE "crm_activities" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"primary_entity_type" text NOT NULL,
|
||||||
|
"primary_entity_id" text,
|
||||||
|
"primary_record_label" text,
|
||||||
|
"related_records" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"customer_id" text,
|
||||||
|
"contact_id" text,
|
||||||
|
"lead_id" text,
|
||||||
|
"opportunity_id" text,
|
||||||
|
"quotation_id" text,
|
||||||
|
"activity_type" text NOT NULL,
|
||||||
|
"subject" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"owner_id" text NOT NULL,
|
||||||
|
"assigned_to_id" text,
|
||||||
|
"scheduled_start_at" timestamp with time zone,
|
||||||
|
"scheduled_end_at" timestamp with time zone,
|
||||||
|
"due_at" timestamp with time zone,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"cancelled_at" timestamp with time zone,
|
||||||
|
"skipped_at" timestamp with time zone,
|
||||||
|
"status" text NOT NULL,
|
||||||
|
"priority" text NOT NULL,
|
||||||
|
"outcome_summary" text,
|
||||||
|
"cancellation_reason" text,
|
||||||
|
"skip_reason" text,
|
||||||
|
"next_action" text,
|
||||||
|
"branch_id" text,
|
||||||
|
"product_type" text,
|
||||||
|
"is_internal_only" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_pricing_sensitive" boolean DEFAULT false NOT NULL,
|
||||||
|
"parent_activity_id" text,
|
||||||
|
"metadata" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
"created_by" text NOT NULL,
|
||||||
|
"updated_by" text NOT NULL
|
||||||
|
);
|
||||||
6662
drizzle/meta/0002_snapshot.json
Normal file
6662
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
|||||||
"when": 1783350585715,
|
"when": 1783350585715,
|
||||||
"tag": "0001_yellow_wasp",
|
"tag": "0001_yellow_wasp",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783647090217,
|
||||||
|
"tag": "0002_empty_jean_grey",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
445
plans/task-ep1.1.md
Normal file
445
plans/task-ep1.1.md
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
# EP.1.1 – Activity Domain Foundation
|
||||||
|
|
||||||
|
Status: Planning
|
||||||
|
|
||||||
|
Priority: Critical
|
||||||
|
|
||||||
|
Type: Feature Foundation
|
||||||
|
|
||||||
|
Depends On
|
||||||
|
|
||||||
|
- BU-R.0 Business Constitution
|
||||||
|
- 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 shared Activity Domain as the operational foundation of ALLA OS.
|
||||||
|
|
||||||
|
This epic establishes the Activity aggregate, ownership model, business rules, service boundaries, and integration points while preserving all existing CRM modules.
|
||||||
|
|
||||||
|
This phase delivers only the Activity foundation.
|
||||||
|
|
||||||
|
Timeline, Calendar, My Day, Dashboard, and Notification are intentionally excluded and will consume Activity in later epics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Background
|
||||||
|
|
||||||
|
Business Constitution defines Activity as the shared operational model.
|
||||||
|
|
||||||
|
Architecture Constitution defines Activity as the source domain for future projections.
|
||||||
|
|
||||||
|
Engineering Constitution requires:
|
||||||
|
|
||||||
|
- Preserve existing CRM domains
|
||||||
|
- Extend rather than replace
|
||||||
|
- Keep Activity as the single operational write model
|
||||||
|
|
||||||
|
Current implementation already contains several activity-like concepts:
|
||||||
|
|
||||||
|
- Follow-up
|
||||||
|
- Reminder
|
||||||
|
- Site Visit
|
||||||
|
- Meeting
|
||||||
|
- Internal Task
|
||||||
|
|
||||||
|
These must be audited and unified without breaking existing production behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Review Required
|
||||||
|
|
||||||
|
Review before implementation:
|
||||||
|
|
||||||
|
Business
|
||||||
|
|
||||||
|
- Relationship & Sales Workspace Blueprint
|
||||||
|
- Activity Blueprint
|
||||||
|
|
||||||
|
Architecture
|
||||||
|
|
||||||
|
- AR.1 Architecture Transition Plan
|
||||||
|
- AR.2 Epic Technical Design
|
||||||
|
|
||||||
|
Engineering
|
||||||
|
|
||||||
|
- Engineering Constitution
|
||||||
|
|
||||||
|
UI
|
||||||
|
|
||||||
|
- Workspace UI/UX Design Note
|
||||||
|
- layout.md
|
||||||
|
- ui-ux-rules.md
|
||||||
|
- ui-ux-pro-max
|
||||||
|
|
||||||
|
Implementation
|
||||||
|
|
||||||
|
- Existing Follow-up modules
|
||||||
|
- Existing Customer detail
|
||||||
|
- Existing Opportunity detail
|
||||||
|
- Existing Quotation detail
|
||||||
|
- Existing Notification logic
|
||||||
|
- Existing Permission model
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope
|
||||||
|
|
||||||
|
## Part 1 — Activity Domain Model
|
||||||
|
|
||||||
|
Create
|
||||||
|
|
||||||
|
Activity Aggregate
|
||||||
|
|
||||||
|
Define
|
||||||
|
|
||||||
|
- Activity
|
||||||
|
- Activity Owner
|
||||||
|
- Activity Assignee
|
||||||
|
- Activity Type
|
||||||
|
- Activity Status
|
||||||
|
- Activity Priority
|
||||||
|
|
||||||
|
Freeze ownership.
|
||||||
|
|
||||||
|
Activity becomes the only operational work entity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Activity Lifecycle
|
||||||
|
|
||||||
|
Define
|
||||||
|
|
||||||
|
Draft
|
||||||
|
|
||||||
|
Scheduled
|
||||||
|
|
||||||
|
In Progress
|
||||||
|
|
||||||
|
Completed
|
||||||
|
|
||||||
|
Cancelled
|
||||||
|
|
||||||
|
Skipped
|
||||||
|
|
||||||
|
Overdue
|
||||||
|
|
||||||
|
Define
|
||||||
|
|
||||||
|
Allowed transitions
|
||||||
|
|
||||||
|
Completion rules
|
||||||
|
|
||||||
|
Reschedule rules
|
||||||
|
|
||||||
|
Assignment rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Activity Relationship Model
|
||||||
|
|
||||||
|
Activity may reference
|
||||||
|
|
||||||
|
Customer
|
||||||
|
|
||||||
|
Contact
|
||||||
|
|
||||||
|
Lead
|
||||||
|
|
||||||
|
Opportunity
|
||||||
|
|
||||||
|
Quotation
|
||||||
|
|
||||||
|
PO
|
||||||
|
|
||||||
|
Internal Only
|
||||||
|
|
||||||
|
Exactly one source context is required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4 — Activity Business Rules
|
||||||
|
|
||||||
|
Freeze
|
||||||
|
|
||||||
|
Assignment
|
||||||
|
|
||||||
|
Ownership
|
||||||
|
|
||||||
|
Visibility
|
||||||
|
|
||||||
|
Permission
|
||||||
|
|
||||||
|
Completion
|
||||||
|
|
||||||
|
Deletion
|
||||||
|
|
||||||
|
Editing
|
||||||
|
|
||||||
|
History
|
||||||
|
|
||||||
|
Determine
|
||||||
|
|
||||||
|
Recurring activities
|
||||||
|
|
||||||
|
Parent / Child activities
|
||||||
|
|
||||||
|
Future dependency support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5 — Activity Service
|
||||||
|
|
||||||
|
Introduce
|
||||||
|
|
||||||
|
Activity Service
|
||||||
|
|
||||||
|
Responsibilities
|
||||||
|
|
||||||
|
Create
|
||||||
|
|
||||||
|
Update
|
||||||
|
|
||||||
|
Assign
|
||||||
|
|
||||||
|
Complete
|
||||||
|
|
||||||
|
Cancel
|
||||||
|
|
||||||
|
Reschedule
|
||||||
|
|
||||||
|
Permission validation
|
||||||
|
|
||||||
|
Audit logging
|
||||||
|
|
||||||
|
Business validation
|
||||||
|
|
||||||
|
Route Handlers remain thin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 6 — Activity Repository
|
||||||
|
|
||||||
|
Create repository abstraction.
|
||||||
|
|
||||||
|
Separate
|
||||||
|
|
||||||
|
Write model
|
||||||
|
|
||||||
|
Read model
|
||||||
|
|
||||||
|
Prepare for future projections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 7 — Activity API
|
||||||
|
|
||||||
|
Introduce REST endpoints
|
||||||
|
|
||||||
|
Create
|
||||||
|
|
||||||
|
Update
|
||||||
|
|
||||||
|
Delete
|
||||||
|
|
||||||
|
Assign
|
||||||
|
|
||||||
|
Complete
|
||||||
|
|
||||||
|
Cancel
|
||||||
|
|
||||||
|
Reschedule
|
||||||
|
|
||||||
|
List
|
||||||
|
|
||||||
|
Get Detail
|
||||||
|
|
||||||
|
API must follow existing CRM conventions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 8 — Permission Model
|
||||||
|
|
||||||
|
Integrate with existing CRM authorization.
|
||||||
|
|
||||||
|
Support
|
||||||
|
|
||||||
|
Owner
|
||||||
|
|
||||||
|
Assignee
|
||||||
|
|
||||||
|
Manager
|
||||||
|
|
||||||
|
Administrator
|
||||||
|
|
||||||
|
Respect
|
||||||
|
|
||||||
|
Organization
|
||||||
|
|
||||||
|
Branch
|
||||||
|
|
||||||
|
Product Type
|
||||||
|
|
||||||
|
Pricing visibility boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 9 — Existing Module Integration
|
||||||
|
|
||||||
|
Integrate Activity with
|
||||||
|
|
||||||
|
Customer
|
||||||
|
|
||||||
|
Lead
|
||||||
|
|
||||||
|
Opportunity
|
||||||
|
|
||||||
|
Quotation
|
||||||
|
|
||||||
|
without changing their ownership.
|
||||||
|
|
||||||
|
Do not migrate Follow-up yet.
|
||||||
|
|
||||||
|
Only establish extension points.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 10 — Future Projection Preparation
|
||||||
|
|
||||||
|
Publish integration contracts for
|
||||||
|
|
||||||
|
Timeline
|
||||||
|
|
||||||
|
Calendar
|
||||||
|
|
||||||
|
Notification
|
||||||
|
|
||||||
|
My Day
|
||||||
|
|
||||||
|
Dashboard
|
||||||
|
|
||||||
|
Do NOT implement projections.
|
||||||
|
|
||||||
|
Only define extension interfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI Scope
|
||||||
|
|
||||||
|
Implement only foundational UI.
|
||||||
|
|
||||||
|
Review
|
||||||
|
|
||||||
|
- layout.md
|
||||||
|
- ui-ux-rules.md
|
||||||
|
- ui-ux-pro-max
|
||||||
|
|
||||||
|
Deliver
|
||||||
|
|
||||||
|
- Activity Sheet
|
||||||
|
- Activity Form
|
||||||
|
- Activity Detail
|
||||||
|
- Activity Badge
|
||||||
|
- Activity Status Badge
|
||||||
|
- Activity Priority Badge
|
||||||
|
|
||||||
|
Do NOT build
|
||||||
|
|
||||||
|
- Calendar
|
||||||
|
- Timeline
|
||||||
|
- My Day
|
||||||
|
- Manager Workspace
|
||||||
|
|
||||||
|
UI must extend existing CRM patterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deliverables
|
||||||
|
|
||||||
|
1. Activity Domain Model
|
||||||
|
2. Activity Lifecycle
|
||||||
|
3. Activity Service
|
||||||
|
4. Activity Repository
|
||||||
|
5. Activity API
|
||||||
|
6. Activity Permission Integration
|
||||||
|
7. Activity UI Foundation
|
||||||
|
8. Activity Integration Contracts
|
||||||
|
9. Existing Follow-up Gap Analysis
|
||||||
|
10. Migration Preparation Report
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
|
||||||
|
Must preserve
|
||||||
|
|
||||||
|
- Customer
|
||||||
|
- Lead
|
||||||
|
- Opportunity
|
||||||
|
- Quotation
|
||||||
|
- Approval
|
||||||
|
- RBAC
|
||||||
|
- Organization
|
||||||
|
- Existing APIs
|
||||||
|
|
||||||
|
No Calendar.
|
||||||
|
|
||||||
|
No Timeline.
|
||||||
|
|
||||||
|
No Dashboard.
|
||||||
|
|
||||||
|
No Notification Expansion.
|
||||||
|
|
||||||
|
No projection implementation.
|
||||||
|
|
||||||
|
No migration of existing Follow-up records.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
- Activity becomes the official operational domain.
|
||||||
|
- Existing CRM behavior remains unchanged.
|
||||||
|
- Activity is reusable by all future workspaces.
|
||||||
|
- Existing Follow-up implementation continues working.
|
||||||
|
- Projection interfaces are prepared without implementation.
|
||||||
|
- Thin Route Handler pattern is preserved.
|
||||||
|
- Service-owned business logic is preserved.
|
||||||
|
- All new UI follows layout.md, ui-ux-rules.md, and ui-ux-pro-max.
|
||||||
|
- Existing shadcn/ui patterns are reused.
|
||||||
|
- No breaking changes are introduced.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Success Criteria
|
||||||
|
|
||||||
|
ALLA OS now has a production-ready Activity Foundation.
|
||||||
|
|
||||||
|
Future epics can consume Activity without redesign.
|
||||||
|
|
||||||
|
Ready for
|
||||||
|
|
||||||
|
EP.1.2 – Activity Integration & Follow-up Consolidation.
|
||||||
|
|
||||||
|
## Compatibility Strategy
|
||||||
|
|
||||||
|
During EP.1.1, existing Follow-up implementations remain operational.
|
||||||
|
|
||||||
|
The new Activity Domain must coexist with legacy Follow-up structures.
|
||||||
|
|
||||||
|
No existing API contracts may be removed.
|
||||||
|
|
||||||
|
No database records are migrated.
|
||||||
|
|
||||||
|
No UI flow changes are introduced.
|
||||||
|
|
||||||
|
Activity is introduced as an additive capability only.
|
||||||
|
|
||||||
|
Legacy Follow-up consolidation is deferred to EP.1.2 after Activity Foundation has been validated.
|
||||||
47
src/app/api/crm/activities/[id]/assign/route.ts
Normal file
47
src/app/api/crm/activities/[id]/assign/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import { assignActivity } from '@/features/crm/activities/server/service';
|
||||||
|
import { activityAssignSchema } from '@/features/crm/activities/schemas/activity.schema';
|
||||||
|
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.reassign
|
||||||
|
});
|
||||||
|
const payload = activityAssignSchema.parse(await request.json());
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const activity = await assignActivity(id, organization.id, session.user.id, payload, accessContext);
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: activity.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_activity',
|
||||||
|
entityId: id,
|
||||||
|
action: 'assign',
|
||||||
|
afterData: activity
|
||||||
|
});
|
||||||
|
return NextResponse.json(activity);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to assign activity' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/crm/activities/[id]/cancel/route.ts
Normal file
47
src/app/api/crm/activities/[id]/cancel/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import { cancelActivity } from '@/features/crm/activities/server/service';
|
||||||
|
import { activityCancelSchema } from '@/features/crm/activities/schemas/activity.schema';
|
||||||
|
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.cancel
|
||||||
|
});
|
||||||
|
const payload = activityCancelSchema.parse(await request.json());
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const activity = await cancelActivity(id, organization.id, session.user.id, payload, accessContext);
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: activity.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_activity',
|
||||||
|
entityId: id,
|
||||||
|
action: 'cancel',
|
||||||
|
afterData: activity
|
||||||
|
});
|
||||||
|
return NextResponse.json(activity);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to cancel activity' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/api/crm/activities/[id]/complete/route.ts
Normal file
53
src/app/api/crm/activities/[id]/complete/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import { completeActivity } from '@/features/crm/activities/server/service';
|
||||||
|
import { activityCompleteSchema } from '@/features/crm/activities/schemas/activity.schema';
|
||||||
|
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.complete
|
||||||
|
});
|
||||||
|
const payload = activityCompleteSchema.parse(await request.json());
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const activity = await completeActivity(
|
||||||
|
id,
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: activity.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_activity',
|
||||||
|
entityId: id,
|
||||||
|
action: 'complete',
|
||||||
|
afterData: activity
|
||||||
|
});
|
||||||
|
return NextResponse.json(activity);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to complete activity' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/api/crm/activities/[id]/reschedule/route.ts
Normal file
53
src/app/api/crm/activities/[id]/reschedule/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import { rescheduleActivity } from '@/features/crm/activities/server/service';
|
||||||
|
import { activityRescheduleSchema } from '@/features/crm/activities/schemas/activity.schema';
|
||||||
|
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.update
|
||||||
|
});
|
||||||
|
const payload = activityRescheduleSchema.parse(await request.json());
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const activity = await rescheduleActivity(
|
||||||
|
id,
|
||||||
|
organization.id,
|
||||||
|
session.user.id,
|
||||||
|
payload,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
await auditAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: activity.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_activity',
|
||||||
|
entityId: id,
|
||||||
|
action: 'reschedule',
|
||||||
|
afterData: activity
|
||||||
|
});
|
||||||
|
return NextResponse.json(activity);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to reschedule activity' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/app/api/crm/activities/[id]/route.ts
Normal file
104
src/app/api/crm/activities/[id]/route.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import {
|
||||||
|
deleteActivity,
|
||||||
|
getActivityById,
|
||||||
|
updateActivity
|
||||||
|
} from '@/features/crm/activities/server/service';
|
||||||
|
import { activityUpdateSchema } from '@/features/crm/activities/schemas/activity.schema';
|
||||||
|
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.read
|
||||||
|
});
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const activity = await getActivityById(id, organization.id, accessContext);
|
||||||
|
return NextResponse.json(activity);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to load activity' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.update
|
||||||
|
});
|
||||||
|
const payload = activityUpdateSchema.parse(await request.json());
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const before = await getActivityById(id, organization.id, accessContext);
|
||||||
|
const updated = await updateActivity(id, organization.id, session.user.id, payload, accessContext);
|
||||||
|
await auditUpdate({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: updated.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_activity',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: updated
|
||||||
|
});
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to update activity' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.delete
|
||||||
|
});
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const before = await getActivityById(id, organization.id, accessContext);
|
||||||
|
await deleteActivity(id, organization.id, session.user.id, accessContext);
|
||||||
|
await auditDelete({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: before.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_activity',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: { deletedAt: new Date().toISOString() }
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to delete activity' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/api/crm/activities/reference/route.ts
Normal file
19
src/app/api/crm/activities/reference/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getActivityReferenceData } from '@/features/crm/activities/server/service';
|
||||||
|
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.read
|
||||||
|
});
|
||||||
|
const data = await getActivityReferenceData(organization.id);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to load activity reference data' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/app/api/crm/activities/route.ts
Normal file
90
src/app/api/crm/activities/route.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import {
|
||||||
|
createActivity,
|
||||||
|
listActivities
|
||||||
|
} from '@/features/crm/activities/server/service';
|
||||||
|
import { activityMutationSchema } from '@/features/crm/activities/schemas/activity.schema';
|
||||||
|
import {
|
||||||
|
CRM_ACTIVITY_ENTITY_TYPES,
|
||||||
|
CRM_ACTIVITY_PERMISSIONS,
|
||||||
|
CRM_ACTIVITY_STATUSES,
|
||||||
|
CRM_ACTIVITY_TYPES
|
||||||
|
} from '@/features/crm/activity/types';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
const activityListQuerySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
primaryEntityType: z.enum(CRM_ACTIVITY_ENTITY_TYPES).optional(),
|
||||||
|
primaryEntityId: z.string().optional(),
|
||||||
|
activityType: z.enum(CRM_ACTIVITY_TYPES).optional(),
|
||||||
|
status: z.enum(CRM_ACTIVITY_STATUSES).optional(),
|
||||||
|
ownerId: z.string().optional(),
|
||||||
|
assignedToId: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.read
|
||||||
|
});
|
||||||
|
const filters = activityListQuerySchema.parse({
|
||||||
|
search: request.nextUrl.searchParams.get('search') ?? undefined,
|
||||||
|
primaryEntityType: request.nextUrl.searchParams.get('primaryEntityType') ?? undefined,
|
||||||
|
primaryEntityId: request.nextUrl.searchParams.get('primaryEntityId') ?? undefined,
|
||||||
|
activityType: request.nextUrl.searchParams.get('activityType') ?? undefined,
|
||||||
|
status: request.nextUrl.searchParams.get('status') ?? undefined,
|
||||||
|
ownerId: request.nextUrl.searchParams.get('ownerId') ?? undefined,
|
||||||
|
assignedToId: request.nextUrl.searchParams.get('assignedToId') ?? undefined
|
||||||
|
});
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const response = await listActivities(organization.id, filters, accessContext);
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to load activities' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: CRM_ACTIVITY_PERMISSIONS.create
|
||||||
|
});
|
||||||
|
const payload = activityMutationSchema.parse(await request.json());
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
|
const created = await createActivity(organization.id, session.user.id, payload, accessContext);
|
||||||
|
await auditCreate({
|
||||||
|
organizationId: organization.id,
|
||||||
|
branchId: created.branchId,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'crm_activity',
|
||||||
|
entityId: created.id,
|
||||||
|
afterData: created
|
||||||
|
});
|
||||||
|
return NextResponse.json(created);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ message: 'Unable to create activity' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/app/dashboard/crm/activities/page.tsx
Normal file
27
src/app/dashboard/crm/activities/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
|
import PageContainer from '@/components/layout/page-container';
|
||||||
|
import { ActivityListing } from '@/features/crm/activities/components/activity-listing';
|
||||||
|
import {
|
||||||
|
activitiesQueryOptions,
|
||||||
|
activityReferenceDataOptions
|
||||||
|
} from '@/features/crm/activities/api/queries';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
|
export default function ActivitiesPage() {
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
const filters = {};
|
||||||
|
|
||||||
|
void queryClient.prefetchQuery(activitiesQueryOptions(filters));
|
||||||
|
void queryClient.prefetchQuery(activityReferenceDataOptions());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Activities'
|
||||||
|
pageDescription='Shared operational activity foundation across CRM entities.'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<ActivityListing initialFilters={filters} />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -674,6 +674,48 @@ export const crmOpportunityFollowups = pgTable('crm_opportunity_followups', {
|
|||||||
updatedBy: text('updated_by').notNull()
|
updatedBy: text('updated_by').notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const crmActivities = pgTable('crm_activities', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
primaryEntityType: text('primary_entity_type').notNull(),
|
||||||
|
primaryEntityId: text('primary_entity_id'),
|
||||||
|
primaryRecordLabel: text('primary_record_label'),
|
||||||
|
relatedRecords: jsonb('related_records').default([]).notNull(),
|
||||||
|
customerId: text('customer_id'),
|
||||||
|
contactId: text('contact_id'),
|
||||||
|
leadId: text('lead_id'),
|
||||||
|
opportunityId: text('opportunity_id'),
|
||||||
|
quotationId: text('quotation_id'),
|
||||||
|
activityType: text('activity_type').notNull(),
|
||||||
|
subject: text('subject').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
ownerId: text('owner_id').notNull(),
|
||||||
|
assignedToId: text('assigned_to_id'),
|
||||||
|
scheduledStartAt: timestamp('scheduled_start_at', { withTimezone: true }),
|
||||||
|
scheduledEndAt: timestamp('scheduled_end_at', { withTimezone: true }),
|
||||||
|
dueAt: timestamp('due_at', { withTimezone: true }),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
|
||||||
|
skippedAt: timestamp('skipped_at', { withTimezone: true }),
|
||||||
|
status: text('status').notNull(),
|
||||||
|
priority: text('priority').notNull(),
|
||||||
|
outcomeSummary: text('outcome_summary'),
|
||||||
|
cancellationReason: text('cancellation_reason'),
|
||||||
|
skipReason: text('skip_reason'),
|
||||||
|
nextAction: text('next_action'),
|
||||||
|
branchId: text('branch_id'),
|
||||||
|
productType: text('product_type'),
|
||||||
|
isInternalOnly: boolean('is_internal_only').default(false).notNull(),
|
||||||
|
isPricingSensitive: boolean('is_pricing_sensitive').default(false).notNull(),
|
||||||
|
parentActivityId: text('parent_activity_id'),
|
||||||
|
metadata: jsonb('metadata'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
|
createdBy: text('created_by').notNull(),
|
||||||
|
updatedBy: text('updated_by').notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export const crmOpportunityCustomers = pgTable('crm_opportunity_customers', {
|
export const crmOpportunityCustomers = pgTable('crm_opportunity_customers', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
|
|||||||
104
src/features/crm/activities/api/mutations.ts
Normal file
104
src/features/crm/activities/api/mutations.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
import {
|
||||||
|
assignActivity,
|
||||||
|
cancelActivity,
|
||||||
|
completeActivity,
|
||||||
|
createActivity,
|
||||||
|
deleteActivity,
|
||||||
|
rescheduleActivity,
|
||||||
|
updateActivity
|
||||||
|
} from './service';
|
||||||
|
import { activityKeys } from './queries';
|
||||||
|
import type {
|
||||||
|
ActivityAssignPayload,
|
||||||
|
ActivityCancelPayload,
|
||||||
|
ActivityCompletePayload,
|
||||||
|
ActivityMutationPayload,
|
||||||
|
ActivityReschedulePayload,
|
||||||
|
ActivityUpdatePayload
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
async function invalidateActivityLists() {
|
||||||
|
await getQueryClient().invalidateQueries({ queryKey: activityKeys.lists() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invalidateActivityDetail(id: string) {
|
||||||
|
await getQueryClient().invalidateQueries({ queryKey: activityKeys.detail(id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invalidateActivityMutationQueries(id: string) {
|
||||||
|
await Promise.all([invalidateActivityLists(), invalidateActivityDetail(id)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createActivityMutation = mutationOptions({
|
||||||
|
mutationFn: (payload: ActivityMutationPayload) => createActivity(payload),
|
||||||
|
onSettled: async (data, error) => {
|
||||||
|
if (!error) {
|
||||||
|
await Promise.all([
|
||||||
|
invalidateActivityLists(),
|
||||||
|
data ? invalidateActivityDetail(data.id) : Promise.resolve()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateActivityMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: ActivityUpdatePayload }) =>
|
||||||
|
updateActivity(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateActivityMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteActivityMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id }: { id: string }) => deleteActivity(id),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateActivityLists();
|
||||||
|
getQueryClient().removeQueries({ queryKey: activityKeys.detail(variables.id) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const completeActivityMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: ActivityCompletePayload }) =>
|
||||||
|
completeActivity(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateActivityMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelActivityMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: ActivityCancelPayload }) =>
|
||||||
|
cancelActivity(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateActivityMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const assignActivityMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: ActivityAssignPayload }) =>
|
||||||
|
assignActivity(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateActivityMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rescheduleActivityMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: ActivityReschedulePayload }) =>
|
||||||
|
rescheduleActivity(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateActivityMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
30
src/features/crm/activities/api/queries.ts
Normal file
30
src/features/crm/activities/api/queries.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
import { getActivities, getActivityById, getActivityReferenceData } from './service';
|
||||||
|
import type { ActivityListFilters } from './types';
|
||||||
|
|
||||||
|
export const activityKeys = {
|
||||||
|
all: ['crm-activities'] as const,
|
||||||
|
lists: () => [...activityKeys.all, 'list'] as const,
|
||||||
|
list: (filters: ActivityListFilters) => [...activityKeys.lists(), filters] as const,
|
||||||
|
details: () => [...activityKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...activityKeys.details(), id] as const,
|
||||||
|
reference: () => [...activityKeys.all, 'reference'] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activitiesQueryOptions = (filters: ActivityListFilters) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: activityKeys.list(filters),
|
||||||
|
queryFn: () => getActivities(filters)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activityByIdOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: activityKeys.detail(id),
|
||||||
|
queryFn: () => getActivityById(id)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activityReferenceDataOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: activityKeys.reference(),
|
||||||
|
queryFn: () => getActivityReferenceData()
|
||||||
|
});
|
||||||
103
src/features/crm/activities/api/service.ts
Normal file
103
src/features/crm/activities/api/service.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import type {
|
||||||
|
ActivityAssignPayload,
|
||||||
|
ActivityCancelPayload,
|
||||||
|
ActivityCompletePayload,
|
||||||
|
ActivityListFilters,
|
||||||
|
ActivityListResponse,
|
||||||
|
ActivityMutationPayload,
|
||||||
|
ActivityRecord,
|
||||||
|
ActivityReferenceData,
|
||||||
|
ActivityReschedulePayload,
|
||||||
|
ActivityUpdatePayload
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
function buildSearchParams(filters: ActivityListFilters): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters.search) params.set('search', filters.search);
|
||||||
|
if (filters.primaryEntityType) params.set('primaryEntityType', filters.primaryEntityType);
|
||||||
|
if (filters.primaryEntityId) params.set('primaryEntityId', filters.primaryEntityId);
|
||||||
|
if (filters.activityType) params.set('activityType', filters.activityType);
|
||||||
|
if (filters.status) params.set('status', filters.status);
|
||||||
|
if (filters.ownerId) params.set('ownerId', filters.ownerId);
|
||||||
|
if (filters.assignedToId) params.set('assignedToId', filters.assignedToId);
|
||||||
|
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivities(filters: ActivityListFilters): Promise<ActivityListResponse> {
|
||||||
|
const search = buildSearchParams(filters);
|
||||||
|
return apiClient<ActivityListResponse>(`/crm/activities${search ? `?${search}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivityById(id: string): Promise<ActivityRecord> {
|
||||||
|
return apiClient<ActivityRecord>(`/crm/activities/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivityReferenceData(): Promise<ActivityReferenceData> {
|
||||||
|
return apiClient<ActivityReferenceData>('/crm/activities/reference');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createActivity(payload: ActivityMutationPayload): Promise<ActivityRecord> {
|
||||||
|
return apiClient<ActivityRecord>('/crm/activities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateActivity(
|
||||||
|
id: string,
|
||||||
|
payload: ActivityUpdatePayload
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
return apiClient<ActivityRecord>(`/crm/activities/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteActivity(id: string): Promise<{ success: true }> {
|
||||||
|
return apiClient<{ success: true }>(`/crm/activities/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeActivity(
|
||||||
|
id: string,
|
||||||
|
payload: ActivityCompletePayload
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
return apiClient<ActivityRecord>(`/crm/activities/${id}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelActivity(
|
||||||
|
id: string,
|
||||||
|
payload: ActivityCancelPayload
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
return apiClient<ActivityRecord>(`/crm/activities/${id}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignActivity(
|
||||||
|
id: string,
|
||||||
|
payload: ActivityAssignPayload
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
return apiClient<ActivityRecord>(`/crm/activities/${id}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rescheduleActivity(
|
||||||
|
id: string,
|
||||||
|
payload: ActivityReschedulePayload
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
return apiClient<ActivityRecord>(`/crm/activities/${id}/reschedule`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
99
src/features/crm/activities/api/types.ts
Normal file
99
src/features/crm/activities/api/types.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type {
|
||||||
|
CrmActivityEntityType,
|
||||||
|
CrmActivityPriority,
|
||||||
|
CrmActivityRecord,
|
||||||
|
CrmActivityRelatedRecord,
|
||||||
|
CrmActivityStatus,
|
||||||
|
CrmActivityStoredStatus,
|
||||||
|
CrmActivityType
|
||||||
|
} from '@/features/crm/activity/types';
|
||||||
|
|
||||||
|
export interface ActivityOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityAssignableUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
membershipRole: 'admin' | 'user';
|
||||||
|
businessRole: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityRecord extends CrmActivityRecord {
|
||||||
|
effectiveStatus: CrmActivityStatus;
|
||||||
|
ownerName: string | null;
|
||||||
|
assignedToName: string | null;
|
||||||
|
createdByName: string | null;
|
||||||
|
updatedByName: string | null;
|
||||||
|
canViewPricingSensitiveContent: boolean;
|
||||||
|
isContentRedacted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityListFilters {
|
||||||
|
search?: string;
|
||||||
|
primaryEntityType?: CrmActivityEntityType;
|
||||||
|
primaryEntityId?: string;
|
||||||
|
activityType?: CrmActivityType;
|
||||||
|
status?: CrmActivityStatus;
|
||||||
|
ownerId?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityListResponse {
|
||||||
|
items: ActivityRecord[];
|
||||||
|
totalItems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityReferenceData {
|
||||||
|
entityTypes: ActivityOption[];
|
||||||
|
activityTypes: ActivityOption[];
|
||||||
|
statuses: ActivityOption[];
|
||||||
|
priorities: ActivityOption[];
|
||||||
|
assignableUsers: ActivityAssignableUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityMutationPayload {
|
||||||
|
primaryEntityType: CrmActivityEntityType;
|
||||||
|
primaryEntityId?: string | null;
|
||||||
|
relatedRecords?: CrmActivityRelatedRecord[];
|
||||||
|
activityType: CrmActivityType;
|
||||||
|
subject: string;
|
||||||
|
description?: string | null;
|
||||||
|
ownerId: string;
|
||||||
|
assignedToId?: string | null;
|
||||||
|
scheduledStartAt?: string | null;
|
||||||
|
scheduledEndAt?: string | null;
|
||||||
|
dueAt?: string | null;
|
||||||
|
status: CrmActivityStoredStatus;
|
||||||
|
priority: CrmActivityPriority;
|
||||||
|
nextAction?: string | null;
|
||||||
|
isInternalOnly?: boolean;
|
||||||
|
isPricingSensitive?: boolean;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityUpdatePayload extends Partial<ActivityMutationPayload> {
|
||||||
|
outcomeSummary?: string | null;
|
||||||
|
cancellationReason?: string | null;
|
||||||
|
skipReason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityCompletePayload {
|
||||||
|
outcomeSummary: string;
|
||||||
|
nextAction?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityCancelPayload {
|
||||||
|
cancellationReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityAssignPayload {
|
||||||
|
assignedToId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityReschedulePayload {
|
||||||
|
scheduledStartAt?: string | null;
|
||||||
|
scheduledEndAt?: string | null;
|
||||||
|
dueAt?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { formatCrmActivityLabel, type CrmActivityType } from '@/features/crm/activity/types';
|
||||||
|
|
||||||
|
export function ActivityBadge({ activityType }: { activityType: CrmActivityType }) {
|
||||||
|
return <Badge variant='outline'>{formatCrmActivityLabel(activityType)}</Badge>;
|
||||||
|
}
|
||||||
64
src/features/crm/activities/components/activity-detail.tsx
Normal file
64
src/features/crm/activities/components/activity-detail.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
|
import type { ActivityRecord } from '../api/types';
|
||||||
|
import { ActivityBadge } from './activity-badge';
|
||||||
|
import { ActivityPriorityBadge } from './activity-priority-badge';
|
||||||
|
import { ActivityStatusBadge } from './activity-status-badge';
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||||
|
<div className='text-sm'>{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityDetail({ activity }: { activity: ActivityRecord | null }) {
|
||||||
|
if (!activity) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||||
|
Select an activity to review its detail.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='space-y-3'>
|
||||||
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<CardTitle>{activity.subject}</CardTitle>
|
||||||
|
<CardDescription>{activity.primaryRecordLabel ?? 'Unlinked activity'}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<ActivityStatusBadge status={activity.effectiveStatus} />
|
||||||
|
<ActivityPriorityBadge priority={activity.priority} />
|
||||||
|
<ActivityBadge activityType={activity.activityType} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<Field label='Owner' value={activity.ownerName} />
|
||||||
|
<Field label='Assignee' value={activity.assignedToName} />
|
||||||
|
<Field label='Primary Entity' value={activity.primaryEntityType} />
|
||||||
|
<Field label='Primary Record Id' value={activity.primaryEntityId} />
|
||||||
|
<Field label='Scheduled' value={formatDateTime(activity.scheduledStartAt)} />
|
||||||
|
<Field label='Due' value={formatDateTime(activity.dueAt)} />
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className='grid gap-4'>
|
||||||
|
<Field label='Description' value={activity.description} />
|
||||||
|
<Field label='Outcome Summary' value={activity.outcomeSummary} />
|
||||||
|
<Field label='Next Action' value={activity.nextAction} />
|
||||||
|
<Field label='Cancellation Reason' value={activity.cancellationReason} />
|
||||||
|
<Field label='Skip Reason' value={activity.skipReason} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
src/features/crm/activities/components/activity-form-sheet.tsx
Normal file
249
src/features/crm/activities/components/activity-form-sheet.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||||
|
import { createActivityMutation, updateActivityMutation } from '../api/mutations';
|
||||||
|
import type {
|
||||||
|
ActivityMutationPayload,
|
||||||
|
ActivityRecord,
|
||||||
|
ActivityReferenceData
|
||||||
|
} from '../api/types';
|
||||||
|
import { activityFormSchema, type ActivityFormValues } from '../schemas/activity.schema';
|
||||||
|
|
||||||
|
function dateToIso(value?: string): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(`${value}T00:00:00.000Z`).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDefaultValues(activity?: ActivityRecord): ActivityFormValues {
|
||||||
|
return {
|
||||||
|
primaryEntityType: activity?.primaryEntityType ?? 'internal',
|
||||||
|
primaryEntityId: activity?.primaryEntityId ?? '',
|
||||||
|
activityType: activity?.activityType ?? 'follow_up',
|
||||||
|
subject: activity?.subject ?? '',
|
||||||
|
description: activity?.description ?? '',
|
||||||
|
ownerId: activity?.ownerId ?? '',
|
||||||
|
assignedToId: activity?.assignedToId ?? '',
|
||||||
|
scheduledDate: activity?.scheduledStartAt?.slice(0, 10) ?? '',
|
||||||
|
dueDate: activity?.dueAt?.slice(0, 10) ?? '',
|
||||||
|
status: activity?.status ?? 'draft',
|
||||||
|
priority: activity?.priority ?? 'normal',
|
||||||
|
nextAction: activity?.nextAction ?? '',
|
||||||
|
isInternalOnly: activity?.isInternalOnly ?? false,
|
||||||
|
isPricingSensitive: activity?.isPricingSensitive ?? false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(values: ActivityFormValues): ActivityMutationPayload {
|
||||||
|
return {
|
||||||
|
primaryEntityType: values.primaryEntityType,
|
||||||
|
primaryEntityId: values.primaryEntityId?.trim() ? values.primaryEntityId.trim() : null,
|
||||||
|
activityType: values.activityType,
|
||||||
|
subject: values.subject.trim(),
|
||||||
|
description: values.description?.trim() || null,
|
||||||
|
ownerId: values.ownerId,
|
||||||
|
assignedToId: values.assignedToId?.trim() ? values.assignedToId : null,
|
||||||
|
scheduledStartAt: dateToIso(values.scheduledDate),
|
||||||
|
dueAt: dateToIso(values.dueDate),
|
||||||
|
status: values.status,
|
||||||
|
priority: values.priority,
|
||||||
|
nextAction: values.nextAction?.trim() || null,
|
||||||
|
isInternalOnly: values.isInternalOnly,
|
||||||
|
isPricingSensitive: values.isPricingSensitive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityFormSheet({
|
||||||
|
activity,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
referenceData,
|
||||||
|
defaultPrimaryEntityType,
|
||||||
|
defaultPrimaryEntityId
|
||||||
|
}: {
|
||||||
|
activity?: ActivityRecord;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
referenceData: ActivityReferenceData;
|
||||||
|
defaultPrimaryEntityType?: ActivityFormValues['primaryEntityType'];
|
||||||
|
defaultPrimaryEntityId?: string;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(activity);
|
||||||
|
const defaultValues = useMemo(() => {
|
||||||
|
const values = toDefaultValues(activity);
|
||||||
|
if (!activity && defaultPrimaryEntityType) {
|
||||||
|
values.primaryEntityType = defaultPrimaryEntityType;
|
||||||
|
}
|
||||||
|
if (!activity && defaultPrimaryEntityId) {
|
||||||
|
values.primaryEntityId = defaultPrimaryEntityId;
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}, [activity, defaultPrimaryEntityId, defaultPrimaryEntityType]);
|
||||||
|
|
||||||
|
const { FormTextField, FormTextareaField, FormDatePickerField, FormSelectField, FormCheckboxField } =
|
||||||
|
useFormFields<ActivityFormValues>();
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
...createActivityMutation,
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Activity created');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unable to create activity');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
...updateActivityMutation,
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Activity updated');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unable to update activity');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const form = useAppForm({
|
||||||
|
defaultValues,
|
||||||
|
validators: {
|
||||||
|
onSubmit: activityFormSchema
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
const payload = toPayload(value);
|
||||||
|
if (isEdit && activity) {
|
||||||
|
await updateMutation.mutateAsync({ id: activity.id, values: payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(defaultValues);
|
||||||
|
}, [defaultValues, form, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className='flex flex-col sm:max-w-2xl'>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{isEdit ? 'Edit Activity' : 'Create Activity'}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Foundation form for shared operational activity records.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
|
<form.AppForm>
|
||||||
|
<form.Form id='crm-activity-form'>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<FormTextField name='subject' label='Subject' required placeholder='Follow up on revised quotation' />
|
||||||
|
<FormSelectField
|
||||||
|
name='activityType'
|
||||||
|
label='Activity Type'
|
||||||
|
required
|
||||||
|
options={referenceData.activityTypes.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='primaryEntityType'
|
||||||
|
label='Primary Entity'
|
||||||
|
required
|
||||||
|
options={referenceData.entityTypes.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
disabled={Boolean(defaultPrimaryEntityType)}
|
||||||
|
/>
|
||||||
|
<FormTextField
|
||||||
|
name='primaryEntityId'
|
||||||
|
label='Primary Record Id'
|
||||||
|
placeholder='Existing CRM record id'
|
||||||
|
disabled={Boolean(defaultPrimaryEntityId)}
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='ownerId'
|
||||||
|
label='Owner'
|
||||||
|
required
|
||||||
|
options={referenceData.assignableUsers.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='assignedToId'
|
||||||
|
label='Assignee'
|
||||||
|
options={referenceData.assignableUsers.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.name
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='status'
|
||||||
|
label='Status'
|
||||||
|
required
|
||||||
|
options={referenceData.statuses.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='priority'
|
||||||
|
label='Priority'
|
||||||
|
required
|
||||||
|
options={referenceData.priorities.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<FormDatePickerField name='scheduledDate' label='Scheduled Date' />
|
||||||
|
<FormDatePickerField name='dueDate' label='Due Date' />
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormTextField name='nextAction' label='Next Action' placeholder='Book follow-up meeting' />
|
||||||
|
</div>
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormTextareaField
|
||||||
|
name='description'
|
||||||
|
size='md'
|
||||||
|
label='Description'
|
||||||
|
placeholder='Business context and execution notes'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormCheckboxField name='isInternalOnly' label='Internal only' />
|
||||||
|
<FormCheckboxField name='isPricingSensitive' label='Pricing sensitive' />
|
||||||
|
</div>
|
||||||
|
</form.Form>
|
||||||
|
</form.AppForm>
|
||||||
|
</div>
|
||||||
|
<SheetFooter>
|
||||||
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' form='crm-activity-form' isLoading={isPending}>
|
||||||
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
|
{isEdit ? 'Update Activity' : 'Create Activity'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/features/crm/activities/components/activity-listing.tsx
Normal file
88
src/features/crm/activities/components/activity-listing.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import type { ActivityListFilters } from '../api/types';
|
||||||
|
import { activitiesQueryOptions, activityReferenceDataOptions } from '../api/queries';
|
||||||
|
import { ActivityBadge } from './activity-badge';
|
||||||
|
import { ActivityDetail } from './activity-detail';
|
||||||
|
import { ActivityFormSheet } from './activity-form-sheet';
|
||||||
|
import { ActivityPriorityBadge } from './activity-priority-badge';
|
||||||
|
import { ActivityStatusBadge } from './activity-status-badge';
|
||||||
|
|
||||||
|
export function ActivityListing({ initialFilters }: { initialFilters: ActivityListFilters }) {
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const { data } = useSuspenseQuery(activitiesQueryOptions(initialFilters));
|
||||||
|
const { data: referenceData } = useSuspenseQuery(activityReferenceDataOptions());
|
||||||
|
|
||||||
|
const selectedActivity = useMemo(
|
||||||
|
() => data.items.find((item) => item.id === (selectedId ?? data.items[0]?.id)) ?? null,
|
||||||
|
[data.items, selectedId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-end'>
|
||||||
|
<Button onClick={() => setSheetOpen(true)}>
|
||||||
|
<Icons.add className='mr-2 h-4 w-4' />
|
||||||
|
Create Activity
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-4 lg:grid-cols-[1.2fr_0.8fr]'>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{!data.items.length ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||||
|
No activities created yet.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
data.items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type='button'
|
||||||
|
className='w-full text-left'
|
||||||
|
aria-label={`View activity ${item.subject}`}
|
||||||
|
onClick={() => setSelectedId(item.id)}
|
||||||
|
>
|
||||||
|
<Card className='transition-colors hover:bg-muted/40'>
|
||||||
|
<CardContent className='space-y-3 p-4'>
|
||||||
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='font-medium'>{item.subject}</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
{item.primaryRecordLabel ?? 'Unlinked activity'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<ActivityStatusBadge status={item.effectiveStatus} />
|
||||||
|
<ActivityPriorityBadge priority={item.priority} />
|
||||||
|
<ActivityBadge activityType={item.activityType} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Owner: {item.ownerName ?? '-'} | Assignee: {item.assignedToName ?? '-'}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ActivityDetail activity={selectedActivity ?? null} />
|
||||||
|
</div>
|
||||||
|
<ActivityFormSheet
|
||||||
|
activity={undefined}
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setSheetOpen(open);
|
||||||
|
}}
|
||||||
|
referenceData={referenceData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { formatCrmActivityLabel, type CrmActivityPriority } from '@/features/crm/activity/types';
|
||||||
|
|
||||||
|
const PRIORITY_VARIANTS: Record<
|
||||||
|
CrmActivityPriority,
|
||||||
|
'secondary' | 'outline' | 'default' | 'destructive'
|
||||||
|
> = {
|
||||||
|
low: 'outline',
|
||||||
|
normal: 'secondary',
|
||||||
|
high: 'default',
|
||||||
|
critical: 'destructive'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActivityPriorityBadge({ priority }: { priority: CrmActivityPriority }) {
|
||||||
|
return <Badge variant={PRIORITY_VARIANTS[priority]}>{formatCrmActivityLabel(priority)}</Badge>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { formatCrmActivityLabel, type CrmActivityStatus } from '@/features/crm/activity/types';
|
||||||
|
|
||||||
|
const STATUS_VARIANTS: Record<CrmActivityStatus, 'secondary' | 'outline' | 'default' | 'destructive'> = {
|
||||||
|
draft: 'outline',
|
||||||
|
scheduled: 'secondary',
|
||||||
|
in_progress: 'default',
|
||||||
|
completed: 'secondary',
|
||||||
|
cancelled: 'destructive',
|
||||||
|
skipped: 'outline',
|
||||||
|
overdue: 'destructive'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActivityStatusBadge({ status }: { status: CrmActivityStatus }) {
|
||||||
|
return <Badge variant={STATUS_VARIANTS[status]}>{formatCrmActivityLabel(status)}</Badge>;
|
||||||
|
}
|
||||||
86
src/features/crm/activities/schemas/activity.schema.ts
Normal file
86
src/features/crm/activities/schemas/activity.schema.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
CRM_ACTIVITY_ENTITY_TYPES,
|
||||||
|
CRM_ACTIVITY_PRIORITIES,
|
||||||
|
CRM_ACTIVITY_STORED_STATUSES,
|
||||||
|
CRM_ACTIVITY_TYPES
|
||||||
|
} from '@/features/crm/activity/types';
|
||||||
|
|
||||||
|
const isoDateTimeSchema = z
|
||||||
|
.string()
|
||||||
|
.datetime({ offset: true })
|
||||||
|
.or(z.literal(''))
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => (value ? value : null));
|
||||||
|
|
||||||
|
export const activityRelatedRecordSchema = z.object({
|
||||||
|
entityType: z.enum(CRM_ACTIVITY_ENTITY_TYPES),
|
||||||
|
entityId: z.string().min(1, 'Related record id is required')
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activityMutationSchema = z.object({
|
||||||
|
primaryEntityType: z.enum(CRM_ACTIVITY_ENTITY_TYPES),
|
||||||
|
primaryEntityId: z.string().nullable().optional(),
|
||||||
|
relatedRecords: z.array(activityRelatedRecordSchema).optional(),
|
||||||
|
activityType: z.enum(CRM_ACTIVITY_TYPES),
|
||||||
|
subject: z.string().trim().min(1, 'Subject is required'),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
ownerId: z.string().min(1, 'Owner is required'),
|
||||||
|
assignedToId: z.string().nullable().optional(),
|
||||||
|
scheduledStartAt: isoDateTimeSchema,
|
||||||
|
scheduledEndAt: isoDateTimeSchema,
|
||||||
|
dueAt: isoDateTimeSchema,
|
||||||
|
status: z.enum(CRM_ACTIVITY_STORED_STATUSES),
|
||||||
|
priority: z.enum(CRM_ACTIVITY_PRIORITIES),
|
||||||
|
nextAction: z.string().nullable().optional(),
|
||||||
|
isInternalOnly: z.boolean().optional(),
|
||||||
|
isPricingSensitive: z.boolean().optional(),
|
||||||
|
metadata: z.record(z.string(), z.unknown()).nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activityUpdateSchema = activityMutationSchema
|
||||||
|
.partial()
|
||||||
|
.extend({
|
||||||
|
outcomeSummary: z.string().nullable().optional(),
|
||||||
|
cancellationReason: z.string().nullable().optional(),
|
||||||
|
skipReason: z.string().nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activityCompleteSchema = z.object({
|
||||||
|
outcomeSummary: z.string().trim().min(1, 'Outcome summary is required'),
|
||||||
|
nextAction: z.string().nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activityCancelSchema = z.object({
|
||||||
|
cancellationReason: z.string().trim().min(1, 'Cancellation reason is required')
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activityAssignSchema = z.object({
|
||||||
|
assignedToId: z.string().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activityRescheduleSchema = z.object({
|
||||||
|
scheduledStartAt: isoDateTimeSchema,
|
||||||
|
scheduledEndAt: isoDateTimeSchema,
|
||||||
|
dueAt: isoDateTimeSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
export const activityFormSchema = z.object({
|
||||||
|
primaryEntityType: z.enum(CRM_ACTIVITY_ENTITY_TYPES),
|
||||||
|
primaryEntityId: z.string().optional(),
|
||||||
|
activityType: z.enum(CRM_ACTIVITY_TYPES),
|
||||||
|
subject: z.string().trim().min(1, 'Subject is required'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
ownerId: z.string().min(1, 'Owner is required'),
|
||||||
|
assignedToId: z.string().optional(),
|
||||||
|
scheduledDate: z.string().optional(),
|
||||||
|
dueDate: z.string().optional(),
|
||||||
|
status: z.enum(CRM_ACTIVITY_STORED_STATUSES),
|
||||||
|
priority: z.enum(CRM_ACTIVITY_PRIORITIES),
|
||||||
|
nextAction: z.string().optional(),
|
||||||
|
isInternalOnly: z.boolean(),
|
||||||
|
isPricingSensitive: z.boolean()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ActivityFormValues = z.infer<typeof activityFormSchema>;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { LeadFollowupSummary } from '@/features/crm/leads/types';
|
||||||
|
|
||||||
|
export interface LeadFollowupAdapterCandidate {
|
||||||
|
source: 'lead_followup';
|
||||||
|
followup: LeadFollowupSummary;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { OpportunityFollowupRecord } from '@/features/crm/opportunities/api/types';
|
||||||
|
|
||||||
|
export interface OpportunityFollowupAdapterCandidate {
|
||||||
|
source: 'opportunity_followup';
|
||||||
|
followup: OpportunityFollowupRecord;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { QuotationFollowupRecord } from '@/features/crm/quotations/api/types';
|
||||||
|
|
||||||
|
export interface QuotationFollowupAdapterCandidate {
|
||||||
|
source: 'quotation_followup';
|
||||||
|
followup: QuotationFollowupRecord;
|
||||||
|
}
|
||||||
32
src/features/crm/activities/server/integration-contracts.ts
Normal file
32
src/features/crm/activities/server/integration-contracts.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { CrmActivityEntityType, CrmActivityRecord, CrmActivityStatus } from '@/features/crm/activity/types';
|
||||||
|
|
||||||
|
export type ActivityIntegrationEventType =
|
||||||
|
| 'activity.created'
|
||||||
|
| 'activity.updated'
|
||||||
|
| 'activity.assigned'
|
||||||
|
| 'activity.completed'
|
||||||
|
| 'activity.cancelled'
|
||||||
|
| 'activity.rescheduled'
|
||||||
|
| 'activity.deleted';
|
||||||
|
|
||||||
|
export interface ActivityProjectionEnvelope {
|
||||||
|
eventType: ActivityIntegrationEventType;
|
||||||
|
organizationId: string;
|
||||||
|
activityId: string;
|
||||||
|
primaryRecord: {
|
||||||
|
entityType: CrmActivityEntityType;
|
||||||
|
entityId: string | null;
|
||||||
|
};
|
||||||
|
effectiveStatus: CrmActivityStatus;
|
||||||
|
occurredAt: string;
|
||||||
|
actorUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityProjectionContract {
|
||||||
|
activity: CrmActivityRecord;
|
||||||
|
envelope: ActivityProjectionEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityProjectionPublisher {
|
||||||
|
publish(contract: ActivityProjectionContract): Promise<void>;
|
||||||
|
}
|
||||||
98
src/features/crm/activities/server/read-model.ts
Normal file
98
src/features/crm/activities/server/read-model.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { ActivityRow } from './repository';
|
||||||
|
import { resolveUserDisplays } from '@/features/foundation/display/server/display-resolver';
|
||||||
|
import { canViewQuotationPricing, type CrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
|
import { resolveCrmActivityStatus } from '@/features/crm/activity/types';
|
||||||
|
import type { ActivityRecord } from '../api/types';
|
||||||
|
|
||||||
|
function toRecordMetadata(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canViewPricingForRow(context: CrmSecurityContext, row: ActivityRow): boolean {
|
||||||
|
if (!row.isPricingSensitive) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.quotationId && row.primaryEntityType !== 'quotation' && row.primaryEntityType !== 'purchase_order') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return canViewQuotationPricing(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hydrateActivityRecords(
|
||||||
|
rows: ActivityRow[],
|
||||||
|
context: CrmSecurityContext
|
||||||
|
): Promise<ActivityRecord[]> {
|
||||||
|
const userIds = [...new Set(rows.flatMap((row) => [row.ownerId, row.assignedToId, row.createdBy, row.updatedBy]).filter((value): value is string => Boolean(value)))];
|
||||||
|
const userMap = await resolveUserDisplays(userIds);
|
||||||
|
|
||||||
|
return rows.map<ActivityRecord>((row) => {
|
||||||
|
const effectiveStatus = resolveCrmActivityStatus({
|
||||||
|
status: row.status as ActivityRecord['status'],
|
||||||
|
scheduledStartAt: row.scheduledStartAt?.toISOString() ?? null,
|
||||||
|
dueAt: row.dueAt?.toISOString() ?? null,
|
||||||
|
completedAt: row.completedAt?.toISOString() ?? null,
|
||||||
|
cancelledAt: row.cancelledAt?.toISOString() ?? null,
|
||||||
|
skippedAt: row.skippedAt?.toISOString() ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
const canViewPricingSensitiveContent = canViewPricingForRow(context, row);
|
||||||
|
const shouldRedact = row.isPricingSensitive && !canViewPricingSensitiveContent;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
primaryEntityType: row.primaryEntityType as ActivityRecord['primaryEntityType'],
|
||||||
|
primaryEntityId: row.primaryEntityId ?? null,
|
||||||
|
primaryRecordLabel: row.primaryRecordLabel ?? null,
|
||||||
|
relatedRecords: Array.isArray(row.relatedRecords)
|
||||||
|
? (row.relatedRecords as ActivityRecord['relatedRecords'])
|
||||||
|
: [],
|
||||||
|
customerId: row.customerId ?? null,
|
||||||
|
contactId: row.contactId ?? null,
|
||||||
|
leadId: row.leadId ?? null,
|
||||||
|
opportunityId: row.opportunityId ?? null,
|
||||||
|
quotationId: row.quotationId ?? null,
|
||||||
|
activityType: row.activityType as ActivityRecord['activityType'],
|
||||||
|
subject: row.subject,
|
||||||
|
description: shouldRedact ? null : row.description ?? null,
|
||||||
|
ownerId: row.ownerId,
|
||||||
|
assignedToId: row.assignedToId ?? null,
|
||||||
|
scheduledStartAt: row.scheduledStartAt?.toISOString() ?? null,
|
||||||
|
scheduledEndAt: row.scheduledEndAt?.toISOString() ?? null,
|
||||||
|
dueAt: row.dueAt?.toISOString() ?? null,
|
||||||
|
completedAt: row.completedAt?.toISOString() ?? null,
|
||||||
|
cancelledAt: row.cancelledAt?.toISOString() ?? null,
|
||||||
|
skippedAt: row.skippedAt?.toISOString() ?? null,
|
||||||
|
status: row.status as ActivityRecord['status'],
|
||||||
|
effectiveStatus,
|
||||||
|
priority: row.priority as ActivityRecord['priority'],
|
||||||
|
outcomeSummary: shouldRedact ? null : row.outcomeSummary ?? null,
|
||||||
|
cancellationReason: row.cancellationReason ?? null,
|
||||||
|
skipReason: row.skipReason ?? null,
|
||||||
|
nextAction: shouldRedact ? null : row.nextAction ?? null,
|
||||||
|
branchId: row.branchId ?? null,
|
||||||
|
productType: row.productType ?? null,
|
||||||
|
isInternalOnly: row.isInternalOnly,
|
||||||
|
isPricingSensitive: row.isPricingSensitive,
|
||||||
|
parentActivityId: row.parentActivityId ?? null,
|
||||||
|
metadata: toRecordMetadata(row.metadata),
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||||
|
ownerName: userMap.get(row.ownerId)?.name ?? null,
|
||||||
|
assignedToName: row.assignedToId ? (userMap.get(row.assignedToId)?.name ?? null) : null,
|
||||||
|
createdByName: userMap.get(row.createdBy)?.name ?? null,
|
||||||
|
updatedByName: userMap.get(row.updatedBy)?.name ?? null,
|
||||||
|
canViewPricingSensitiveContent,
|
||||||
|
isContentRedacted: shouldRedact
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
93
src/features/crm/activities/server/repository.ts
Normal file
93
src/features/crm/activities/server/repository.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { and, desc, eq, ilike, isNull, or, type SQL } from 'drizzle-orm';
|
||||||
|
import { crmActivities } from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export type ActivityRow = typeof crmActivities.$inferSelect;
|
||||||
|
export type InsertActivityRow = typeof crmActivities.$inferInsert;
|
||||||
|
|
||||||
|
export interface ActivityListQuery {
|
||||||
|
organizationId: string;
|
||||||
|
search?: string;
|
||||||
|
primaryEntityType?: string;
|
||||||
|
primaryEntityId?: string;
|
||||||
|
activityType?: string;
|
||||||
|
ownerId?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertActivity(values: InsertActivityRow): Promise<ActivityRow> {
|
||||||
|
const [created] = await db.insert(crmActivities).values(values).returning();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateActivityRow(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
values: Partial<InsertActivityRow>
|
||||||
|
): Promise<ActivityRow> {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmActivities)
|
||||||
|
.set(values)
|
||||||
|
.where(and(eq(crmActivities.id, id), eq(crmActivities.organizationId, organizationId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivityRow(id: string, organizationId: string): Promise<ActivityRow | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmActivities)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmActivities.id, id),
|
||||||
|
eq(crmActivities.organizationId, organizationId),
|
||||||
|
isNull(crmActivities.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActivityRows(query: ActivityListQuery): Promise<ActivityRow[]> {
|
||||||
|
const filters: SQL[] = [
|
||||||
|
eq(crmActivities.organizationId, query.organizationId),
|
||||||
|
isNull(crmActivities.deletedAt)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (query.search) {
|
||||||
|
filters.push(
|
||||||
|
or(
|
||||||
|
ilike(crmActivities.subject, `%${query.search}%`),
|
||||||
|
ilike(crmActivities.description, `%${query.search}%`)
|
||||||
|
)!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.primaryEntityType) {
|
||||||
|
filters.push(eq(crmActivities.primaryEntityType, query.primaryEntityType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.primaryEntityId) {
|
||||||
|
filters.push(eq(crmActivities.primaryEntityId, query.primaryEntityId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.activityType) {
|
||||||
|
filters.push(eq(crmActivities.activityType, query.activityType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.ownerId) {
|
||||||
|
filters.push(eq(crmActivities.ownerId, query.ownerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.assignedToId) {
|
||||||
|
filters.push(eq(crmActivities.assignedToId, query.assignedToId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(crmActivities)
|
||||||
|
.where(and(...filters))
|
||||||
|
.orderBy(desc(crmActivities.updatedAt), desc(crmActivities.createdAt));
|
||||||
|
}
|
||||||
782
src/features/crm/activities/server/service.ts
Normal file
782
src/features/crm/activities/server/service.ts
Normal file
@@ -0,0 +1,782 @@
|
|||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import { memberships, users, crmContactShares, crmCustomerContacts, crmCustomers } from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import {
|
||||||
|
CRM_ACTIVITY_ENTITY_TYPES,
|
||||||
|
CRM_ACTIVITY_PERMISSIONS,
|
||||||
|
CRM_ACTIVITY_PRIORITIES,
|
||||||
|
CRM_ACTIVITY_STATUSES,
|
||||||
|
CRM_ACTIVITY_TYPES,
|
||||||
|
formatCrmActivityLabel,
|
||||||
|
type CrmActivityEntityType,
|
||||||
|
type CrmActivityRelatedRecord,
|
||||||
|
type CrmActivityStoredStatus
|
||||||
|
} from '@/features/crm/activity/types';
|
||||||
|
import { getCustomerDetail, type CustomerAccessContext } from '@/features/crm/customers/server/service';
|
||||||
|
import { getLeadById, type LeadAccessContext } from '@/features/crm/leads/server/service';
|
||||||
|
import {
|
||||||
|
getOpportunityDetail,
|
||||||
|
type OpportunityAccessContext
|
||||||
|
} from '@/features/crm/opportunities/server/service';
|
||||||
|
import {
|
||||||
|
getQuotationDetail,
|
||||||
|
type QuotationAccessContext
|
||||||
|
} from '@/features/crm/quotations/server/service';
|
||||||
|
import {
|
||||||
|
canAccessContact,
|
||||||
|
canAccessScopedCrmRecord,
|
||||||
|
type CrmSecurityContext
|
||||||
|
} from '@/features/crm/security/server/service';
|
||||||
|
import type {
|
||||||
|
ActivityAssignPayload,
|
||||||
|
ActivityAssignableUser,
|
||||||
|
ActivityCancelPayload,
|
||||||
|
ActivityCompletePayload,
|
||||||
|
ActivityListFilters,
|
||||||
|
ActivityListResponse,
|
||||||
|
ActivityMutationPayload,
|
||||||
|
ActivityOption,
|
||||||
|
ActivityRecord,
|
||||||
|
ActivityReferenceData,
|
||||||
|
ActivityReschedulePayload,
|
||||||
|
ActivityUpdatePayload
|
||||||
|
} from '../api/types';
|
||||||
|
import { getActivityRow, insertActivity, listActivityRows, updateActivityRow } from './repository';
|
||||||
|
import { hydrateActivityRecords } from './read-model';
|
||||||
|
|
||||||
|
type ActivityAccessContext = CrmSecurityContext;
|
||||||
|
|
||||||
|
interface PrimaryRecordContext {
|
||||||
|
primaryEntityId: string | null;
|
||||||
|
primaryRecordLabel: string | null;
|
||||||
|
branchId: string | null;
|
||||||
|
productType: string | null;
|
||||||
|
customerId: string | null;
|
||||||
|
contactId: string | null;
|
||||||
|
leadId: string | null;
|
||||||
|
opportunityId: string | null;
|
||||||
|
quotationId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isManagerLike(context: ActivityAccessContext): 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 assertActivityPermission(
|
||||||
|
context: ActivityAccessContext,
|
||||||
|
permission: string,
|
||||||
|
message = 'Forbidden'
|
||||||
|
) {
|
||||||
|
if (!context.permissions.includes(permission)) {
|
||||||
|
throw new AuthError(message, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOrNull(value?: string | null): Date | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueRelatedRecords(
|
||||||
|
records: CrmActivityRelatedRecord[] | undefined
|
||||||
|
): CrmActivityRelatedRecord[] {
|
||||||
|
const map = new Map<string, CrmActivityRelatedRecord>();
|
||||||
|
for (const record of records ?? []) {
|
||||||
|
map.set(`${record.entityType}:${record.entityId}`, record);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRelatedRecords(
|
||||||
|
primaryEntityType: CrmActivityEntityType,
|
||||||
|
primaryEntityId: string | null,
|
||||||
|
relatedRecords: CrmActivityRelatedRecord[]
|
||||||
|
) {
|
||||||
|
for (const record of relatedRecords) {
|
||||||
|
if (record.entityType === primaryEntityType && record.entityId === primaryEntityId) {
|
||||||
|
throw new AuthError('Related records must not duplicate the primary record', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTransitionAllowed(
|
||||||
|
currentStatus: CrmActivityStoredStatus,
|
||||||
|
nextStatus: CrmActivityStoredStatus
|
||||||
|
) {
|
||||||
|
const allowed: Record<CrmActivityStoredStatus, CrmActivityStoredStatus[]> = {
|
||||||
|
draft: ['draft', 'scheduled', 'cancelled'],
|
||||||
|
scheduled: ['scheduled', 'in_progress', 'completed', 'cancelled', 'skipped'],
|
||||||
|
in_progress: ['in_progress', 'completed', 'cancelled', 'skipped'],
|
||||||
|
completed: ['completed'],
|
||||||
|
cancelled: ['cancelled'],
|
||||||
|
skipped: ['skipped']
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!allowed[currentStatus].includes(nextStatus)) {
|
||||||
|
throw new AuthError(`Activity cannot transition from ${currentStatus} to ${nextStatus}`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertMembershipUserBelongsToOrganization(userId: string, organizationId: string) {
|
||||||
|
const [membership] = await db
|
||||||
|
.select({
|
||||||
|
userId: memberships.userId,
|
||||||
|
role: memberships.role,
|
||||||
|
businessRole: memberships.businessRole,
|
||||||
|
name: users.name
|
||||||
|
})
|
||||||
|
.from(memberships)
|
||||||
|
.innerJoin(users, eq(users.id, memberships.userId))
|
||||||
|
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new AuthError('User not found in organization', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAssignableUsers(organizationId: string): Promise<ActivityAssignableUser[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
membershipRole: memberships.role,
|
||||||
|
businessRole: memberships.businessRole
|
||||||
|
})
|
||||||
|
.from(memberships)
|
||||||
|
.innerJoin(users, eq(users.id, memberships.userId))
|
||||||
|
.where(eq(memberships.organizationId, organizationId));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user',
|
||||||
|
businessRole: row.businessRole
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePrimaryRecordContext(input: {
|
||||||
|
organizationId: string;
|
||||||
|
context: ActivityAccessContext;
|
||||||
|
primaryEntityType: CrmActivityEntityType;
|
||||||
|
primaryEntityId?: string | null;
|
||||||
|
}): Promise<PrimaryRecordContext> {
|
||||||
|
const { organizationId, context, primaryEntityType, primaryEntityId } = input;
|
||||||
|
|
||||||
|
if (primaryEntityType === 'internal') {
|
||||||
|
return {
|
||||||
|
primaryEntityId: null,
|
||||||
|
primaryRecordLabel: 'Internal Only',
|
||||||
|
branchId: null,
|
||||||
|
productType: null,
|
||||||
|
customerId: null,
|
||||||
|
contactId: null,
|
||||||
|
leadId: null,
|
||||||
|
opportunityId: null,
|
||||||
|
quotationId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!primaryEntityId) {
|
||||||
|
throw new AuthError('Primary record id is required for this activity type', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryEntityType === 'customer') {
|
||||||
|
const customer = await getCustomerDetail(primaryEntityId, organizationId, context as CustomerAccessContext);
|
||||||
|
return {
|
||||||
|
primaryEntityId,
|
||||||
|
primaryRecordLabel: customer.name,
|
||||||
|
branchId: customer.branchId,
|
||||||
|
productType: null,
|
||||||
|
customerId: customer.id,
|
||||||
|
contactId: null,
|
||||||
|
leadId: null,
|
||||||
|
opportunityId: null,
|
||||||
|
quotationId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryEntityType === 'contact') {
|
||||||
|
const [contact] = await db
|
||||||
|
.select({
|
||||||
|
id: crmCustomerContacts.id,
|
||||||
|
name: crmCustomerContacts.name,
|
||||||
|
customerId: crmCustomerContacts.customerId,
|
||||||
|
createdBy: crmCustomerContacts.createdBy,
|
||||||
|
ownerUserId: crmCustomers.ownerUserId,
|
||||||
|
sharedToUserIds: crmContactShares.sharedToUserId,
|
||||||
|
branchId: crmCustomers.branchId
|
||||||
|
})
|
||||||
|
.from(crmCustomerContacts)
|
||||||
|
.innerJoin(crmCustomers, eq(crmCustomers.id, crmCustomerContacts.customerId))
|
||||||
|
.leftJoin(
|
||||||
|
crmContactShares,
|
||||||
|
and(
|
||||||
|
eq(crmContactShares.contactId, crmCustomerContacts.id),
|
||||||
|
isNull(crmContactShares.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomerContacts.id, primaryEntityId),
|
||||||
|
eq(crmCustomerContacts.organizationId, organizationId),
|
||||||
|
isNull(crmCustomerContacts.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
throw new AuthError('Contact not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!canAccessContact(context, {
|
||||||
|
branchId: contact.branchId,
|
||||||
|
createdBy: contact.createdBy,
|
||||||
|
ownerUserId: contact.ownerUserId,
|
||||||
|
sharedToUserIds: contact.sharedToUserIds ? [contact.sharedToUserIds] : []
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
primaryEntityId,
|
||||||
|
primaryRecordLabel: contact.name,
|
||||||
|
branchId: contact.branchId ?? null,
|
||||||
|
productType: null,
|
||||||
|
customerId: contact.customerId,
|
||||||
|
contactId: contact.id,
|
||||||
|
leadId: null,
|
||||||
|
opportunityId: null,
|
||||||
|
quotationId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryEntityType === 'lead') {
|
||||||
|
const lead = await getLeadById(primaryEntityId, organizationId, context as LeadAccessContext);
|
||||||
|
return {
|
||||||
|
primaryEntityId,
|
||||||
|
primaryRecordLabel: lead.code,
|
||||||
|
branchId: lead.branchId,
|
||||||
|
productType: lead.productType,
|
||||||
|
customerId: lead.customerId,
|
||||||
|
contactId: lead.contactId,
|
||||||
|
leadId: lead.id,
|
||||||
|
opportunityId: null,
|
||||||
|
quotationId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryEntityType === 'opportunity') {
|
||||||
|
const opportunity = await getOpportunityDetail(
|
||||||
|
primaryEntityId,
|
||||||
|
organizationId,
|
||||||
|
context as OpportunityAccessContext
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
primaryEntityId,
|
||||||
|
primaryRecordLabel: `${opportunity.code} ${opportunity.title}`.trim(),
|
||||||
|
branchId: opportunity.branchId,
|
||||||
|
productType: opportunity.productType,
|
||||||
|
customerId: opportunity.customerId,
|
||||||
|
contactId: null,
|
||||||
|
leadId: null,
|
||||||
|
opportunityId: opportunity.id,
|
||||||
|
quotationId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryEntityType === 'quotation') {
|
||||||
|
const quotation = await getQuotationDetail(
|
||||||
|
primaryEntityId,
|
||||||
|
organizationId,
|
||||||
|
context as QuotationAccessContext
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
primaryEntityId,
|
||||||
|
primaryRecordLabel: quotation.code,
|
||||||
|
branchId: quotation.branchId,
|
||||||
|
productType: quotation.quotationType,
|
||||||
|
customerId: quotation.customerId,
|
||||||
|
contactId: quotation.contactId,
|
||||||
|
leadId: null,
|
||||||
|
opportunityId: quotation.opportunityId,
|
||||||
|
quotationId: quotation.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const opportunity = await getOpportunityDetail(
|
||||||
|
primaryEntityId,
|
||||||
|
organizationId,
|
||||||
|
context as OpportunityAccessContext
|
||||||
|
);
|
||||||
|
if (!opportunity.poNumber) {
|
||||||
|
throw new AuthError('Purchase order activity requires an opportunity with a PO number', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
primaryEntityId,
|
||||||
|
primaryRecordLabel: opportunity.poNumber,
|
||||||
|
branchId: opportunity.branchId,
|
||||||
|
productType: opportunity.productType,
|
||||||
|
customerId: opportunity.customerId,
|
||||||
|
contactId: null,
|
||||||
|
leadId: null,
|
||||||
|
opportunityId: opportunity.id,
|
||||||
|
quotationId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertActivityVisibility(context: ActivityAccessContext, activity: ActivityRecord) {
|
||||||
|
const isDirectUser =
|
||||||
|
activity.createdBy === context.userId ||
|
||||||
|
activity.ownerId === context.userId ||
|
||||||
|
activity.assignedToId === context.userId;
|
||||||
|
|
||||||
|
if (activity.isInternalOnly) {
|
||||||
|
if (!isDirectUser && !isManagerLike(context)) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isDirectUser ||
|
||||||
|
canAccessScopedCrmRecord(context, {
|
||||||
|
branchId: activity.branchId,
|
||||||
|
productType: activity.productType,
|
||||||
|
createdBy: activity.createdBy,
|
||||||
|
ownerUserId: activity.ownerId
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getHydratedActivityOrThrow(id: string, organizationId: string, context: ActivityAccessContext) {
|
||||||
|
const row = await getActivityRow(id, organizationId);
|
||||||
|
if (!row) {
|
||||||
|
throw new AuthError('Activity not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [activity] = await hydrateActivityRecords([row], context);
|
||||||
|
if (!activity) {
|
||||||
|
throw new AuthError('Activity not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertActivityVisibility(context, activity);
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOption(value: string): ActivityOption {
|
||||||
|
return { value, label: formatCrmActivityLabel(value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByEffectiveStatus(items: ActivityRecord[], status?: string) {
|
||||||
|
if (!status) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter((item) => item.effectiveStatus === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildActivityWriteValues(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
context: ActivityAccessContext,
|
||||||
|
payload: ActivityMutationPayload | ActivityUpdatePayload,
|
||||||
|
current?: ActivityRecord
|
||||||
|
) {
|
||||||
|
const primaryEntityType = payload.primaryEntityType ?? current?.primaryEntityType;
|
||||||
|
if (!primaryEntityType || !CRM_ACTIVITY_ENTITY_TYPES.includes(primaryEntityType)) {
|
||||||
|
throw new AuthError('Invalid primary entity type', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryContext = await resolvePrimaryRecordContext({
|
||||||
|
organizationId,
|
||||||
|
context,
|
||||||
|
primaryEntityType,
|
||||||
|
primaryEntityId:
|
||||||
|
payload.primaryEntityId === undefined ? current?.primaryEntityId ?? null : payload.primaryEntityId
|
||||||
|
});
|
||||||
|
|
||||||
|
const relatedRecords = uniqueRelatedRecords(
|
||||||
|
payload.relatedRecords ?? current?.relatedRecords ?? []
|
||||||
|
);
|
||||||
|
validateRelatedRecords(primaryEntityType, primaryContext.primaryEntityId, relatedRecords);
|
||||||
|
|
||||||
|
const ownerId = payload.ownerId ?? current?.ownerId;
|
||||||
|
if (!ownerId) {
|
||||||
|
throw new AuthError('Owner is required', 400);
|
||||||
|
}
|
||||||
|
await assertMembershipUserBelongsToOrganization(ownerId, organizationId);
|
||||||
|
|
||||||
|
const assignedToId =
|
||||||
|
payload.assignedToId === undefined ? current?.assignedToId ?? null : payload.assignedToId;
|
||||||
|
if (assignedToId) {
|
||||||
|
await assertMembershipUserBelongsToOrganization(assignedToId, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = (payload.status ?? current?.status ?? 'draft') as CrmActivityStoredStatus;
|
||||||
|
if (!CRM_ACTIVITY_STATUSES.includes(status)) {
|
||||||
|
throw new AuthError('Invalid activity status', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CRM_ACTIVITY_PRIORITIES.includes((payload.priority ?? current?.priority ?? 'normal') as typeof CRM_ACTIVITY_PRIORITIES[number])) {
|
||||||
|
throw new AuthError('Invalid activity priority', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationId,
|
||||||
|
primaryEntityType,
|
||||||
|
primaryEntityId: primaryContext.primaryEntityId,
|
||||||
|
primaryRecordLabel: primaryContext.primaryRecordLabel,
|
||||||
|
relatedRecords,
|
||||||
|
customerId: primaryContext.customerId,
|
||||||
|
contactId: primaryContext.contactId,
|
||||||
|
leadId: primaryContext.leadId,
|
||||||
|
opportunityId: primaryContext.opportunityId,
|
||||||
|
quotationId: primaryContext.quotationId,
|
||||||
|
activityType: (payload.activityType ?? current?.activityType ?? 'follow_up') as ActivityRecord['activityType'],
|
||||||
|
subject: (payload.subject ?? current?.subject ?? '').trim(),
|
||||||
|
description:
|
||||||
|
payload.description === undefined ? current?.description ?? null : payload.description?.trim() || null,
|
||||||
|
ownerId,
|
||||||
|
assignedToId,
|
||||||
|
scheduledStartAt: toDateOrNull(
|
||||||
|
payload.scheduledStartAt === undefined
|
||||||
|
? current?.scheduledStartAt ?? null
|
||||||
|
: payload.scheduledStartAt
|
||||||
|
),
|
||||||
|
scheduledEndAt: toDateOrNull(
|
||||||
|
payload.scheduledEndAt === undefined ? current?.scheduledEndAt ?? null : payload.scheduledEndAt
|
||||||
|
),
|
||||||
|
dueAt: toDateOrNull(payload.dueAt === undefined ? current?.dueAt ?? null : payload.dueAt),
|
||||||
|
status,
|
||||||
|
priority: (payload.priority ?? current?.priority ?? 'normal') as ActivityRecord['priority'],
|
||||||
|
nextAction:
|
||||||
|
payload.nextAction === undefined ? current?.nextAction ?? null : payload.nextAction?.trim() || null,
|
||||||
|
branchId: primaryContext.branchId,
|
||||||
|
productType: primaryContext.productType,
|
||||||
|
isInternalOnly:
|
||||||
|
payload.isInternalOnly === undefined ? current?.isInternalOnly ?? false : payload.isInternalOnly,
|
||||||
|
isPricingSensitive:
|
||||||
|
payload.isPricingSensitive === undefined
|
||||||
|
? current?.isPricingSensitive ?? false
|
||||||
|
: payload.isPricingSensitive,
|
||||||
|
metadata:
|
||||||
|
payload.metadata === undefined ? current?.metadata ?? null : payload.metadata,
|
||||||
|
updatedBy: userId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivityReferenceData(
|
||||||
|
organizationId: string
|
||||||
|
): Promise<ActivityReferenceData> {
|
||||||
|
const assignableUsers = await listAssignableUsers(organizationId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entityTypes: CRM_ACTIVITY_ENTITY_TYPES.map(mapOption),
|
||||||
|
activityTypes: CRM_ACTIVITY_TYPES.map(mapOption),
|
||||||
|
statuses: CRM_ACTIVITY_STATUSES.filter((status) => status !== 'overdue').map(mapOption),
|
||||||
|
priorities: CRM_ACTIVITY_PRIORITIES.map(mapOption),
|
||||||
|
assignableUsers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listActivities(
|
||||||
|
organizationId: string,
|
||||||
|
filters: ActivityListFilters,
|
||||||
|
context: ActivityAccessContext
|
||||||
|
): Promise<ActivityListResponse> {
|
||||||
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.read);
|
||||||
|
|
||||||
|
const rows = await listActivityRows({
|
||||||
|
organizationId,
|
||||||
|
search: filters.search,
|
||||||
|
primaryEntityType: filters.primaryEntityType,
|
||||||
|
primaryEntityId: filters.primaryEntityId,
|
||||||
|
activityType: filters.activityType,
|
||||||
|
ownerId: filters.ownerId,
|
||||||
|
assignedToId: filters.assignedToId
|
||||||
|
});
|
||||||
|
|
||||||
|
const hydrated = await hydrateActivityRecords(rows, context);
|
||||||
|
const visible = hydrated.filter((item) => {
|
||||||
|
try {
|
||||||
|
assertActivityVisibility(context, item);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const filtered = filterByEffectiveStatus(visible, filters.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: filtered,
|
||||||
|
totalItems: filtered.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivityById(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
context: ActivityAccessContext
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.read);
|
||||||
|
return getHydratedActivityOrThrow(id, organizationId, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createActivity(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: ActivityMutationPayload,
|
||||||
|
context: ActivityAccessContext
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.create);
|
||||||
|
|
||||||
|
const values = await buildActivityWriteValues(organizationId, userId, context, payload);
|
||||||
|
if (!values.subject) {
|
||||||
|
throw new AuthError('Subject is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await insertActivity({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
primaryEntityType: values.primaryEntityType,
|
||||||
|
primaryEntityId: values.primaryEntityId,
|
||||||
|
primaryRecordLabel: values.primaryRecordLabel,
|
||||||
|
relatedRecords: values.relatedRecords,
|
||||||
|
customerId: values.customerId,
|
||||||
|
contactId: values.contactId,
|
||||||
|
leadId: values.leadId,
|
||||||
|
opportunityId: values.opportunityId,
|
||||||
|
quotationId: values.quotationId,
|
||||||
|
activityType: values.activityType,
|
||||||
|
subject: values.subject,
|
||||||
|
description: values.description,
|
||||||
|
ownerId: values.ownerId,
|
||||||
|
assignedToId: values.assignedToId,
|
||||||
|
scheduledStartAt: values.scheduledStartAt,
|
||||||
|
scheduledEndAt: values.scheduledEndAt,
|
||||||
|
dueAt: values.dueAt,
|
||||||
|
completedAt: values.status === 'completed' ? new Date() : null,
|
||||||
|
cancelledAt: values.status === 'cancelled' ? new Date() : null,
|
||||||
|
skippedAt: values.status === 'skipped' ? new Date() : null,
|
||||||
|
status: values.status,
|
||||||
|
priority: values.priority,
|
||||||
|
outcomeSummary: null,
|
||||||
|
cancellationReason: null,
|
||||||
|
skipReason: null,
|
||||||
|
nextAction: values.nextAction,
|
||||||
|
branchId: values.branchId,
|
||||||
|
productType: values.productType,
|
||||||
|
isInternalOnly: values.isInternalOnly,
|
||||||
|
isPricingSensitive: values.isPricingSensitive,
|
||||||
|
parentActivityId: null,
|
||||||
|
metadata: values.metadata,
|
||||||
|
createdBy: userId,
|
||||||
|
updatedBy: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return getActivityById(created.id, organizationId, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateActivity(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: ActivityUpdatePayload,
|
||||||
|
context: ActivityAccessContext
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.update);
|
||||||
|
|
||||||
|
const current = await getHydratedActivityOrThrow(id, organizationId, context);
|
||||||
|
assertTransitionAllowed(
|
||||||
|
current.status,
|
||||||
|
(payload.status ?? current.status) as CrmActivityStoredStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
const values = await buildActivityWriteValues(organizationId, userId, context, payload, current);
|
||||||
|
const nextStatus = values.status;
|
||||||
|
|
||||||
|
await updateActivityRow(id, organizationId, {
|
||||||
|
primaryEntityType: values.primaryEntityType,
|
||||||
|
primaryEntityId: values.primaryEntityId,
|
||||||
|
primaryRecordLabel: values.primaryRecordLabel,
|
||||||
|
relatedRecords: values.relatedRecords,
|
||||||
|
customerId: values.customerId,
|
||||||
|
contactId: values.contactId,
|
||||||
|
leadId: values.leadId,
|
||||||
|
opportunityId: values.opportunityId,
|
||||||
|
quotationId: values.quotationId,
|
||||||
|
activityType: values.activityType,
|
||||||
|
subject: values.subject,
|
||||||
|
description: values.description,
|
||||||
|
ownerId: values.ownerId,
|
||||||
|
assignedToId: values.assignedToId,
|
||||||
|
scheduledStartAt: values.scheduledStartAt,
|
||||||
|
scheduledEndAt: values.scheduledEndAt,
|
||||||
|
dueAt: values.dueAt,
|
||||||
|
status: nextStatus,
|
||||||
|
priority: values.priority,
|
||||||
|
outcomeSummary:
|
||||||
|
payload.outcomeSummary === undefined
|
||||||
|
? current.outcomeSummary
|
||||||
|
: payload.outcomeSummary?.trim() || null,
|
||||||
|
cancellationReason:
|
||||||
|
payload.cancellationReason === undefined
|
||||||
|
? current.cancellationReason
|
||||||
|
: payload.cancellationReason?.trim() || null,
|
||||||
|
skipReason:
|
||||||
|
payload.skipReason === undefined ? current.skipReason : payload.skipReason?.trim() || null,
|
||||||
|
nextAction: values.nextAction,
|
||||||
|
branchId: values.branchId,
|
||||||
|
productType: values.productType,
|
||||||
|
isInternalOnly: values.isInternalOnly,
|
||||||
|
isPricingSensitive: values.isPricingSensitive,
|
||||||
|
metadata: values.metadata,
|
||||||
|
completedAt: nextStatus === 'completed' ? new Date() : null,
|
||||||
|
cancelledAt: nextStatus === 'cancelled' ? new Date() : null,
|
||||||
|
skippedAt: nextStatus === 'skipped' ? new Date() : null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return getActivityById(id, organizationId, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeActivity(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: ActivityCompletePayload,
|
||||||
|
context: ActivityAccessContext
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.complete);
|
||||||
|
const current = await getHydratedActivityOrThrow(id, organizationId, context);
|
||||||
|
assertTransitionAllowed(current.status, 'completed');
|
||||||
|
|
||||||
|
await updateActivityRow(id, organizationId, {
|
||||||
|
status: 'completed',
|
||||||
|
outcomeSummary: payload.outcomeSummary.trim(),
|
||||||
|
nextAction: payload.nextAction?.trim() || null,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return getActivityById(id, organizationId, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelActivity(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: ActivityCancelPayload,
|
||||||
|
context: ActivityAccessContext
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.cancel);
|
||||||
|
const current = await getHydratedActivityOrThrow(id, organizationId, context);
|
||||||
|
assertTransitionAllowed(current.status, 'cancelled');
|
||||||
|
|
||||||
|
await updateActivityRow(id, organizationId, {
|
||||||
|
status: 'cancelled',
|
||||||
|
cancellationReason: payload.cancellationReason.trim(),
|
||||||
|
cancelledAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return getActivityById(id, organizationId, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignActivity(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: ActivityAssignPayload,
|
||||||
|
context: ActivityAccessContext
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.reassign);
|
||||||
|
await getHydratedActivityOrThrow(id, organizationId, context);
|
||||||
|
|
||||||
|
if (payload.assignedToId) {
|
||||||
|
await assertMembershipUserBelongsToOrganization(payload.assignedToId, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateActivityRow(id, organizationId, {
|
||||||
|
assignedToId: payload.assignedToId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return getActivityById(id, organizationId, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rescheduleActivity(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: ActivityReschedulePayload,
|
||||||
|
context: ActivityAccessContext
|
||||||
|
): Promise<ActivityRecord> {
|
||||||
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.update);
|
||||||
|
const current = await getHydratedActivityOrThrow(id, organizationId, context);
|
||||||
|
|
||||||
|
if (!payload.scheduledStartAt && !payload.dueAt) {
|
||||||
|
throw new AuthError('Reschedule requires at least one date value', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatus = current.status === 'draft' ? 'scheduled' : current.status;
|
||||||
|
assertTransitionAllowed(current.status, nextStatus);
|
||||||
|
|
||||||
|
await updateActivityRow(id, organizationId, {
|
||||||
|
scheduledStartAt:
|
||||||
|
payload.scheduledStartAt === undefined
|
||||||
|
? toDateOrNull(current.scheduledStartAt)
|
||||||
|
: toDateOrNull(payload.scheduledStartAt),
|
||||||
|
scheduledEndAt:
|
||||||
|
payload.scheduledEndAt === undefined
|
||||||
|
? toDateOrNull(current.scheduledEndAt)
|
||||||
|
: toDateOrNull(payload.scheduledEndAt),
|
||||||
|
dueAt: payload.dueAt === undefined ? toDateOrNull(current.dueAt) : toDateOrNull(payload.dueAt),
|
||||||
|
status: nextStatus,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
});
|
||||||
|
|
||||||
|
return getActivityById(id, organizationId, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteActivity(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
context: ActivityAccessContext
|
||||||
|
) {
|
||||||
|
assertActivityPermission(context, CRM_ACTIVITY_PERMISSIONS.delete);
|
||||||
|
await getHydratedActivityOrThrow(id, organizationId, context);
|
||||||
|
|
||||||
|
await updateActivityRow(id, organizationId, {
|
||||||
|
deletedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,60 +1,100 @@
|
|||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
|
||||||
export const CRM_ACTIVITY_ENTITY_TYPES = [
|
export const CRM_ACTIVITY_ENTITY_TYPES = [
|
||||||
|
'customer',
|
||||||
|
'contact',
|
||||||
'lead',
|
'lead',
|
||||||
'opportunity',
|
'opportunity',
|
||||||
'quotation',
|
'quotation',
|
||||||
'customer',
|
|
||||||
'contact',
|
|
||||||
'purchase_order',
|
'purchase_order',
|
||||||
'service_report'
|
'internal'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CRM_ACTIVITY_TYPES = [
|
export const CRM_ACTIVITY_TYPES = [
|
||||||
'site_visit',
|
'follow_up',
|
||||||
'meeting',
|
'meeting',
|
||||||
|
'visit',
|
||||||
'phone_call',
|
'phone_call',
|
||||||
'email_follow_up',
|
'email_follow_up',
|
||||||
'quotation_follow_up',
|
|
||||||
'site_survey',
|
'site_survey',
|
||||||
'requirement_discussion',
|
'presentation',
|
||||||
'internal_task',
|
'internal_task',
|
||||||
|
'reminder_action',
|
||||||
'other'
|
'other'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CRM_ACTIVITY_STATUSES = [
|
export const CRM_ACTIVITY_STATUSES = [
|
||||||
'planned',
|
'draft',
|
||||||
|
'scheduled',
|
||||||
'in_progress',
|
'in_progress',
|
||||||
'completed',
|
'completed',
|
||||||
'cancelled',
|
'cancelled',
|
||||||
|
'skipped',
|
||||||
'overdue'
|
'overdue'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CRM_ACTIVITY_PRIORITIES = ['low', 'medium', 'high', 'urgent'] as const;
|
export const CRM_ACTIVITY_STORED_STATUSES = [
|
||||||
|
'draft',
|
||||||
|
'scheduled',
|
||||||
|
'in_progress',
|
||||||
|
'completed',
|
||||||
|
'cancelled',
|
||||||
|
'skipped'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const CRM_ACTIVITY_PRIORITIES = ['low', 'normal', 'high', 'critical'] as const;
|
||||||
|
|
||||||
export type CrmActivityEntityType = (typeof CRM_ACTIVITY_ENTITY_TYPES)[number];
|
export type CrmActivityEntityType = (typeof CRM_ACTIVITY_ENTITY_TYPES)[number];
|
||||||
export type CrmActivityType = (typeof CRM_ACTIVITY_TYPES)[number];
|
export type CrmActivityType = (typeof CRM_ACTIVITY_TYPES)[number];
|
||||||
export type CrmActivityStatus = (typeof CRM_ACTIVITY_STATUSES)[number];
|
export type CrmActivityStatus = (typeof CRM_ACTIVITY_STATUSES)[number];
|
||||||
export type CrmActivityPriority = (typeof CRM_ACTIVITY_PRIORITIES)[number];
|
export type CrmActivityPriority = (typeof CRM_ACTIVITY_PRIORITIES)[number];
|
||||||
|
export type CrmActivityStoredStatus = (typeof CRM_ACTIVITY_STORED_STATUSES)[number];
|
||||||
|
|
||||||
|
export interface CrmActivityRelatedRecord {
|
||||||
|
entityType: CrmActivityEntityType;
|
||||||
|
entityId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CrmActivityRecord {
|
export interface CrmActivityRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
entityType: CrmActivityEntityType;
|
primaryEntityType: CrmActivityEntityType;
|
||||||
entityId: string;
|
primaryEntityId: string | null;
|
||||||
|
primaryRecordLabel: string | null;
|
||||||
|
relatedRecords: CrmActivityRelatedRecord[];
|
||||||
|
customerId: string | null;
|
||||||
|
contactId: string | null;
|
||||||
|
leadId: string | null;
|
||||||
|
opportunityId: string | null;
|
||||||
|
quotationId: string | null;
|
||||||
activityType: CrmActivityType;
|
activityType: CrmActivityType;
|
||||||
subject: string;
|
subject: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
ownerId: string | null;
|
ownerId: string;
|
||||||
assignedToId: string | null;
|
assignedToId: string | null;
|
||||||
dueDate: string | null;
|
scheduledStartAt: string | null;
|
||||||
dueTime: string | null;
|
scheduledEndAt: string | null;
|
||||||
|
dueAt: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
status: Exclude<CrmActivityStatus, 'overdue'>;
|
cancelledAt: string | null;
|
||||||
|
skippedAt: string | null;
|
||||||
|
status: CrmActivityStoredStatus;
|
||||||
priority: CrmActivityPriority;
|
priority: CrmActivityPriority;
|
||||||
outcome: string | null;
|
outcomeSummary: string | null;
|
||||||
|
cancellationReason: string | null;
|
||||||
|
skipReason: string | null;
|
||||||
nextAction: string | null;
|
nextAction: string | null;
|
||||||
|
branchId: string | null;
|
||||||
|
productType: string | null;
|
||||||
|
isInternalOnly: boolean;
|
||||||
|
isPricingSensitive: boolean;
|
||||||
|
parentActivityId: string | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CRM_ACTIVITY_PERMISSIONS = {
|
export const CRM_ACTIVITY_PERMISSIONS = {
|
||||||
@@ -62,25 +102,52 @@ export const CRM_ACTIVITY_PERMISSIONS = {
|
|||||||
create: PERMISSIONS.crmActivityCreate,
|
create: PERMISSIONS.crmActivityCreate,
|
||||||
update: PERMISSIONS.crmActivityUpdate,
|
update: PERMISSIONS.crmActivityUpdate,
|
||||||
complete: PERMISSIONS.crmActivityComplete,
|
complete: PERMISSIONS.crmActivityComplete,
|
||||||
|
cancel: PERMISSIONS.crmActivityUpdate,
|
||||||
|
reassign: PERMISSIONS.crmActivityUpdate,
|
||||||
delete: PERMISSIONS.crmActivityDelete,
|
delete: PERMISSIONS.crmActivityDelete,
|
||||||
auditLogRead: PERMISSIONS.crmAuditLogRead
|
auditLogRead: PERMISSIONS.crmAuditLogRead
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function parseDate(value?: string | null): number | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveCrmActivityStatus(input: {
|
export function resolveCrmActivityStatus(input: {
|
||||||
status: Exclude<CrmActivityStatus, 'overdue'>;
|
status: CrmActivityStoredStatus;
|
||||||
dueDate?: string | null;
|
scheduledStartAt?: string | null;
|
||||||
|
dueAt?: string | null;
|
||||||
completedAt?: string | null;
|
completedAt?: string | null;
|
||||||
|
cancelledAt?: string | null;
|
||||||
|
skippedAt?: string | null;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
}): CrmActivityStatus {
|
}): CrmActivityStatus {
|
||||||
if (input.completedAt || input.status === 'completed' || input.status === 'cancelled') {
|
if (
|
||||||
|
input.completedAt ||
|
||||||
|
input.cancelledAt ||
|
||||||
|
input.skippedAt ||
|
||||||
|
input.status === 'completed' ||
|
||||||
|
input.status === 'cancelled' ||
|
||||||
|
input.status === 'skipped'
|
||||||
|
) {
|
||||||
return input.status;
|
return input.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input.dueDate) {
|
const compareAt = parseDate(input.dueAt) ?? parseDate(input.scheduledStartAt);
|
||||||
|
if (compareAt === null) {
|
||||||
return input.status;
|
return input.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = (input.now ?? new Date()).toISOString().slice(0, 10);
|
return compareAt < (input.now ?? new Date()).getTime() ? 'overdue' : input.status;
|
||||||
return input.dueDate < today ? 'overdue' : input.status;
|
}
|
||||||
|
|
||||||
|
export function formatCrmActivityLabel(value: string): string {
|
||||||
|
return value
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
}
|
}
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
|
||||||
|
|||||||
Reference in New Issue
Block a user