task-ep.1.1

This commit is contained in:
phaichayon
2026-07-10 10:33:45 +07:00
parent f47fea5d6f
commit 9ae4f31f28
33 changed files with 9680 additions and 22 deletions

View File

@@ -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

View 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
);

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1783350585715, "when": 1783350585715,
"tag": "0001_yellow_wasp", "tag": "0001_yellow_wasp",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1783647090217,
"tag": "0002_empty_jean_grey",
"breakpoints": true
} }
] ]
} }

445
plans/task-ep1.1.md Normal file
View 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.

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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>
);
}

View File

@@ -674,6 +674,48 @@ export const crmOpportunityFollowups = pgTable('crm_opportunity_followups', {
updatedBy: text('updated_by').notNull() updatedBy: text('updated_by').notNull()
}); });
export const crmActivities = pgTable('crm_activities', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
primaryEntityType: text('primary_entity_type').notNull(),
primaryEntityId: text('primary_entity_id'),
primaryRecordLabel: text('primary_record_label'),
relatedRecords: jsonb('related_records').default([]).notNull(),
customerId: text('customer_id'),
contactId: text('contact_id'),
leadId: text('lead_id'),
opportunityId: text('opportunity_id'),
quotationId: text('quotation_id'),
activityType: text('activity_type').notNull(),
subject: text('subject').notNull(),
description: text('description'),
ownerId: text('owner_id').notNull(),
assignedToId: text('assigned_to_id'),
scheduledStartAt: timestamp('scheduled_start_at', { withTimezone: true }),
scheduledEndAt: timestamp('scheduled_end_at', { withTimezone: true }),
dueAt: timestamp('due_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
skippedAt: timestamp('skipped_at', { withTimezone: true }),
status: text('status').notNull(),
priority: text('priority').notNull(),
outcomeSummary: text('outcome_summary'),
cancellationReason: text('cancellation_reason'),
skipReason: text('skip_reason'),
nextAction: text('next_action'),
branchId: text('branch_id'),
productType: text('product_type'),
isInternalOnly: boolean('is_internal_only').default(false).notNull(),
isPricingSensitive: boolean('is_pricing_sensitive').default(false).notNull(),
parentActivityId: text('parent_activity_id'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull()
});
export const crmOpportunityCustomers = pgTable('crm_opportunity_customers', { export const crmOpportunityCustomers = pgTable('crm_opportunity_customers', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(), organizationId: text('organization_id').notNull(),

View 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);
}
}
});

View 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()
});

View 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)
});
}

View 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;
}

View File

@@ -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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View 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>;

View File

@@ -0,0 +1,6 @@
import type { LeadFollowupSummary } from '@/features/crm/leads/types';
export interface LeadFollowupAdapterCandidate {
source: 'lead_followup';
followup: LeadFollowupSummary;
}

View File

@@ -0,0 +1,6 @@
import type { OpportunityFollowupRecord } from '@/features/crm/opportunities/api/types';
export interface OpportunityFollowupAdapterCandidate {
source: 'opportunity_followup';
followup: OpportunityFollowupRecord;
}

View File

@@ -0,0 +1,6 @@
import type { QuotationFollowupRecord } from '@/features/crm/quotations/api/types';
export interface QuotationFollowupAdapterCandidate {
source: 'quotation_followup';
followup: QuotationFollowupRecord;
}

View 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>;
}

View 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
};
});
}

View 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));
}

View 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
});
}

View File

@@ -1,60 +1,100 @@
import { PERMISSIONS } from '@/lib/auth/rbac';
export const CRM_ACTIVITY_ENTITY_TYPES = [ export const CRM_ACTIVITY_ENTITY_TYPES = [
'customer',
'contact',
'lead', 'lead',
'opportunity', 'opportunity',
'quotation', 'quotation',
'customer',
'contact',
'purchase_order', 'purchase_order',
'service_report' 'internal'
] as const; ] as const;
export const CRM_ACTIVITY_TYPES = [ export const CRM_ACTIVITY_TYPES = [
'site_visit', 'follow_up',
'meeting', 'meeting',
'visit',
'phone_call', 'phone_call',
'email_follow_up', 'email_follow_up',
'quotation_follow_up',
'site_survey', 'site_survey',
'requirement_discussion', 'presentation',
'internal_task', 'internal_task',
'reminder_action',
'other' 'other'
] as const; ] as const;
export const CRM_ACTIVITY_STATUSES = [ export const CRM_ACTIVITY_STATUSES = [
'planned', 'draft',
'scheduled',
'in_progress', 'in_progress',
'completed', 'completed',
'cancelled', 'cancelled',
'skipped',
'overdue' 'overdue'
] as const; ] as const;
export const CRM_ACTIVITY_PRIORITIES = ['low', 'medium', 'high', 'urgent'] as const; export const CRM_ACTIVITY_STORED_STATUSES = [
'draft',
'scheduled',
'in_progress',
'completed',
'cancelled',
'skipped'
] as const;
export const CRM_ACTIVITY_PRIORITIES = ['low', 'normal', 'high', 'critical'] as const;
export type CrmActivityEntityType = (typeof CRM_ACTIVITY_ENTITY_TYPES)[number]; export type CrmActivityEntityType = (typeof CRM_ACTIVITY_ENTITY_TYPES)[number];
export type CrmActivityType = (typeof CRM_ACTIVITY_TYPES)[number]; export type CrmActivityType = (typeof CRM_ACTIVITY_TYPES)[number];
export type CrmActivityStatus = (typeof CRM_ACTIVITY_STATUSES)[number]; export type CrmActivityStatus = (typeof CRM_ACTIVITY_STATUSES)[number];
export type CrmActivityPriority = (typeof CRM_ACTIVITY_PRIORITIES)[number]; export type CrmActivityPriority = (typeof CRM_ACTIVITY_PRIORITIES)[number];
export type CrmActivityStoredStatus = (typeof CRM_ACTIVITY_STORED_STATUSES)[number];
export interface CrmActivityRelatedRecord {
entityType: CrmActivityEntityType;
entityId: string;
}
export interface CrmActivityRecord { export interface CrmActivityRecord {
id: string; id: string;
organizationId: string; organizationId: string;
entityType: CrmActivityEntityType; primaryEntityType: CrmActivityEntityType;
entityId: string; primaryEntityId: string | null;
primaryRecordLabel: string | null;
relatedRecords: CrmActivityRelatedRecord[];
customerId: string | null;
contactId: string | null;
leadId: string | null;
opportunityId: string | null;
quotationId: string | null;
activityType: CrmActivityType; activityType: CrmActivityType;
subject: string; subject: string;
description: string | null; description: string | null;
ownerId: string | null; ownerId: string;
assignedToId: string | null; assignedToId: string | null;
dueDate: string | null; scheduledStartAt: string | null;
dueTime: string | null; scheduledEndAt: string | null;
dueAt: string | null;
completedAt: string | null; completedAt: string | null;
status: Exclude<CrmActivityStatus, 'overdue'>; cancelledAt: string | null;
skippedAt: string | null;
status: CrmActivityStoredStatus;
priority: CrmActivityPriority; priority: CrmActivityPriority;
outcome: string | null; outcomeSummary: string | null;
cancellationReason: string | null;
skipReason: string | null;
nextAction: string | null; nextAction: string | null;
branchId: string | null;
productType: string | null;
isInternalOnly: boolean;
isPricingSensitive: boolean;
parentActivityId: string | null;
metadata: Record<string, unknown> | null;
createdBy: string; createdBy: string;
updatedBy: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null;
} }
export const CRM_ACTIVITY_PERMISSIONS = { export const CRM_ACTIVITY_PERMISSIONS = {
@@ -62,25 +102,52 @@ export const CRM_ACTIVITY_PERMISSIONS = {
create: PERMISSIONS.crmActivityCreate, create: PERMISSIONS.crmActivityCreate,
update: PERMISSIONS.crmActivityUpdate, update: PERMISSIONS.crmActivityUpdate,
complete: PERMISSIONS.crmActivityComplete, complete: PERMISSIONS.crmActivityComplete,
cancel: PERMISSIONS.crmActivityUpdate,
reassign: PERMISSIONS.crmActivityUpdate,
delete: PERMISSIONS.crmActivityDelete, delete: PERMISSIONS.crmActivityDelete,
auditLogRead: PERMISSIONS.crmAuditLogRead auditLogRead: PERMISSIONS.crmAuditLogRead
} as const; } as const;
function parseDate(value?: string | null): number | null {
if (!value) {
return null;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
}
export function resolveCrmActivityStatus(input: { export function resolveCrmActivityStatus(input: {
status: Exclude<CrmActivityStatus, 'overdue'>; status: CrmActivityStoredStatus;
dueDate?: string | null; scheduledStartAt?: string | null;
dueAt?: string | null;
completedAt?: string | null; completedAt?: string | null;
cancelledAt?: string | null;
skippedAt?: string | null;
now?: Date; now?: Date;
}): CrmActivityStatus { }): CrmActivityStatus {
if (input.completedAt || input.status === 'completed' || input.status === 'cancelled') { if (
input.completedAt ||
input.cancelledAt ||
input.skippedAt ||
input.status === 'completed' ||
input.status === 'cancelled' ||
input.status === 'skipped'
) {
return input.status; return input.status;
} }
if (!input.dueDate) { const compareAt = parseDate(input.dueAt) ?? parseDate(input.scheduledStartAt);
if (compareAt === null) {
return input.status; return input.status;
} }
const today = (input.now ?? new Date()).toISOString().slice(0, 10); return compareAt < (input.now ?? new Date()).getTime() ? 'overdue' : input.status;
return input.dueDate < today ? 'overdue' : input.status; }
export function formatCrmActivityLabel(value: string): string {
return value
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
} }
import { PERMISSIONS } from '@/lib/auth/rbac';