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,
|
||||
"tag": "0001_yellow_wasp",
|
||||
"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()
|
||||
});
|
||||
|
||||
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', {
|
||||
id: text('id').primaryKey(),
|
||||
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 = [
|
||||
'customer',
|
||||
'contact',
|
||||
'lead',
|
||||
'opportunity',
|
||||
'quotation',
|
||||
'customer',
|
||||
'contact',
|
||||
'purchase_order',
|
||||
'service_report'
|
||||
'internal'
|
||||
] as const;
|
||||
|
||||
export const CRM_ACTIVITY_TYPES = [
|
||||
'site_visit',
|
||||
'follow_up',
|
||||
'meeting',
|
||||
'visit',
|
||||
'phone_call',
|
||||
'email_follow_up',
|
||||
'quotation_follow_up',
|
||||
'site_survey',
|
||||
'requirement_discussion',
|
||||
'presentation',
|
||||
'internal_task',
|
||||
'reminder_action',
|
||||
'other'
|
||||
] as const;
|
||||
|
||||
export const CRM_ACTIVITY_STATUSES = [
|
||||
'planned',
|
||||
'draft',
|
||||
'scheduled',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'cancelled',
|
||||
'skipped',
|
||||
'overdue'
|
||||
] 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 CrmActivityType = (typeof CRM_ACTIVITY_TYPES)[number];
|
||||
export type CrmActivityStatus = (typeof CRM_ACTIVITY_STATUSES)[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 {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
entityType: CrmActivityEntityType;
|
||||
entityId: string;
|
||||
primaryEntityType: CrmActivityEntityType;
|
||||
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;
|
||||
subject: string;
|
||||
description: string | null;
|
||||
ownerId: string | null;
|
||||
ownerId: string;
|
||||
assignedToId: string | null;
|
||||
dueDate: string | null;
|
||||
dueTime: string | null;
|
||||
scheduledStartAt: string | null;
|
||||
scheduledEndAt: string | null;
|
||||
dueAt: string | null;
|
||||
completedAt: string | null;
|
||||
status: Exclude<CrmActivityStatus, 'overdue'>;
|
||||
cancelledAt: string | null;
|
||||
skippedAt: string | null;
|
||||
status: CrmActivityStoredStatus;
|
||||
priority: CrmActivityPriority;
|
||||
outcome: string | null;
|
||||
outcomeSummary: string | null;
|
||||
cancellationReason: string | null;
|
||||
skipReason: 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;
|
||||
updatedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export const CRM_ACTIVITY_PERMISSIONS = {
|
||||
@@ -62,25 +102,52 @@ export const CRM_ACTIVITY_PERMISSIONS = {
|
||||
create: PERMISSIONS.crmActivityCreate,
|
||||
update: PERMISSIONS.crmActivityUpdate,
|
||||
complete: PERMISSIONS.crmActivityComplete,
|
||||
cancel: PERMISSIONS.crmActivityUpdate,
|
||||
reassign: PERMISSIONS.crmActivityUpdate,
|
||||
delete: PERMISSIONS.crmActivityDelete,
|
||||
auditLogRead: PERMISSIONS.crmAuditLogRead
|
||||
} 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: {
|
||||
status: Exclude<CrmActivityStatus, 'overdue'>;
|
||||
dueDate?: string | null;
|
||||
status: CrmActivityStoredStatus;
|
||||
scheduledStartAt?: string | null;
|
||||
dueAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
skippedAt?: string | null;
|
||||
now?: Date;
|
||||
}): 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;
|
||||
}
|
||||
|
||||
if (!input.dueDate) {
|
||||
const compareAt = parseDate(input.dueAt) ?? parseDate(input.scheduledStartAt);
|
||||
if (compareAt === null) {
|
||||
return input.status;
|
||||
}
|
||||
|
||||
const today = (input.now ?? new Date()).toISOString().slice(0, 10);
|
||||
return input.dueDate < today ? 'overdue' : input.status;
|
||||
return compareAt < (input.now ?? new Date()).getTime() ? '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