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

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 = [
'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';