task-ep.1.2
This commit is contained in:
@@ -21,6 +21,7 @@ export interface ActivityAssignableUser {
|
||||
}
|
||||
|
||||
export interface ActivityRecord extends CrmActivityRecord {
|
||||
activityCode: string | null;
|
||||
effectiveStatus: CrmActivityStatus;
|
||||
ownerName: string | null;
|
||||
assignedToName: string | null;
|
||||
@@ -30,6 +31,37 @@ export interface ActivityRecord extends CrmActivityRecord {
|
||||
isContentRedacted: boolean;
|
||||
}
|
||||
|
||||
export type ActivityTimelineItemSource =
|
||||
| 'activity'
|
||||
| 'lead_followup'
|
||||
| 'opportunity_followup'
|
||||
| 'quotation_followup';
|
||||
|
||||
export interface ActivityTimelineItem {
|
||||
id: string;
|
||||
source: ActivityTimelineItemSource;
|
||||
sourceLabel: string;
|
||||
isLegacy: boolean;
|
||||
activityId: string | null;
|
||||
activityCode: string | null;
|
||||
primaryEntityType: CrmActivityEntityType;
|
||||
primaryEntityId: string | null;
|
||||
primaryRecordLabel: string | null;
|
||||
activityType: string;
|
||||
activityTypeLabel: string;
|
||||
subject: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
statusLabel: string;
|
||||
occurredAt: string;
|
||||
scheduledAt: string | null;
|
||||
dueAt: string | null;
|
||||
nextAction: string | null;
|
||||
ownerName: string | null;
|
||||
assignedToName: string | null;
|
||||
createdByName: string | null;
|
||||
}
|
||||
|
||||
export interface ActivityListFilters {
|
||||
search?: string;
|
||||
primaryEntityType?: CrmActivityEntityType;
|
||||
|
||||
@@ -44,6 +44,7 @@ export function ActivityDetail({ activity }: { activity: ActivityRecord | null }
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Field label='Owner' value={activity.ownerName} />
|
||||
<Field label='Activity Code' value={activity.activityCode} />
|
||||
<Field label='Assignee' value={activity.assignedToName} />
|
||||
<Field label='Primary Entity' value={activity.primaryEntityType} />
|
||||
<Field label='Primary Record Id' value={activity.primaryEntityId} />
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { formatDateTime } from '@/lib/date-format';
|
||||
import type { ActivityTimelineItem } from '../api/types';
|
||||
|
||||
interface RecentActivitiesPanelProps {
|
||||
items: ActivityTimelineItem[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value: string | null }) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{label}: {value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentActivitiesPanel({
|
||||
items,
|
||||
title = 'Recent Activities',
|
||||
description = 'Unified view of new activities and legacy follow-up records.',
|
||||
emptyMessage = 'No recent activities yet.'
|
||||
}: RecentActivitiesPanelProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{items.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={`${item.source}-${item.id}`} className='rounded-lg border p-4'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className='font-medium'>{item.subject}</div>
|
||||
{item.activityCode ? <Badge variant='outline'>{item.activityCode}</Badge> : null}
|
||||
<Badge variant={item.isLegacy ? 'secondary' : 'default'}>
|
||||
{item.sourceLabel}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{item.statusLabel}</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{item.activityTypeLabel}
|
||||
{item.primaryRecordLabel ? ` • ${item.primaryRecordLabel}` : ''}
|
||||
</div>
|
||||
{item.description ? <div className='text-sm'>{item.description}</div> : null}
|
||||
<div className='text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-xs'>
|
||||
<MetaItem label='When' value={formatDateTime(item.occurredAt)} />
|
||||
<MetaItem label='Owner' value={item.ownerName} />
|
||||
<MetaItem label='Created by' value={item.createdByName} />
|
||||
<MetaItem label='Next action' value={item.nextAction} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,41 @@
|
||||
import { formatCrmActivityLabel } from '@/features/crm/activity/types';
|
||||
import type { LeadFollowupSummary } from '@/features/crm/leads/types';
|
||||
import type { ActivityTimelineItem } from '../../api/types';
|
||||
|
||||
export interface LeadFollowupAdapterCandidate {
|
||||
source: 'lead_followup';
|
||||
followup: LeadFollowupSummary;
|
||||
}
|
||||
|
||||
export function adaptLeadFollowupCandidate(
|
||||
candidate: LeadFollowupAdapterCandidate
|
||||
): ActivityTimelineItem {
|
||||
const statusLabel =
|
||||
candidate.followup.followupStatusLabel ??
|
||||
formatCrmActivityLabel(candidate.followup.followupStatus);
|
||||
|
||||
return {
|
||||
id: candidate.followup.id,
|
||||
source: 'lead_followup',
|
||||
sourceLabel: 'Lead Follow-up',
|
||||
isLegacy: true,
|
||||
activityId: null,
|
||||
activityCode: null,
|
||||
primaryEntityType: 'lead',
|
||||
primaryEntityId: candidate.followup.leadId,
|
||||
primaryRecordLabel: null,
|
||||
activityType: 'follow_up',
|
||||
activityTypeLabel: 'Follow Up',
|
||||
subject: candidate.followup.note?.trim() || 'Lead follow-up',
|
||||
description: candidate.followup.note,
|
||||
status: candidate.followup.followupStatus,
|
||||
statusLabel,
|
||||
occurredAt: candidate.followup.followupDate,
|
||||
scheduledAt: candidate.followup.followupDate,
|
||||
dueAt: candidate.followup.nextFollowupDate,
|
||||
nextAction: null,
|
||||
ownerName: null,
|
||||
assignedToName: null,
|
||||
createdByName: candidate.followup.createdByName
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
import { formatCrmActivityLabel } from '@/features/crm/activity/types';
|
||||
import type { ActivityTimelineItem } from '../../api/types';
|
||||
import type { OpportunityFollowupRecord } from '@/features/crm/opportunities/api/types';
|
||||
|
||||
export interface OpportunityFollowupAdapterCandidate {
|
||||
source: 'opportunity_followup';
|
||||
followup: OpportunityFollowupRecord;
|
||||
followupTypeLabel?: string | null;
|
||||
createdByName?: string | null;
|
||||
}
|
||||
|
||||
export function adaptOpportunityFollowupCandidate(
|
||||
candidate: OpportunityFollowupAdapterCandidate
|
||||
): ActivityTimelineItem {
|
||||
const typeLabel =
|
||||
candidate.followupTypeLabel ?? formatCrmActivityLabel(candidate.followup.followupType);
|
||||
|
||||
return {
|
||||
id: candidate.followup.id,
|
||||
source: 'opportunity_followup',
|
||||
sourceLabel: 'Opportunity Follow-up',
|
||||
isLegacy: true,
|
||||
activityId: null,
|
||||
activityCode: null,
|
||||
primaryEntityType: 'opportunity',
|
||||
primaryEntityId: candidate.followup.opportunityId,
|
||||
primaryRecordLabel: null,
|
||||
activityType: 'follow_up',
|
||||
activityTypeLabel: typeLabel,
|
||||
subject: typeLabel,
|
||||
description: candidate.followup.outcome ?? candidate.followup.notes,
|
||||
status: 'completed',
|
||||
statusLabel: 'Completed',
|
||||
occurredAt: candidate.followup.followupDate,
|
||||
scheduledAt: candidate.followup.followupDate,
|
||||
dueAt: candidate.followup.nextFollowupDate,
|
||||
nextAction: candidate.followup.nextAction,
|
||||
ownerName: null,
|
||||
assignedToName: null,
|
||||
createdByName: candidate.createdByName ?? null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
import { formatCrmActivityLabel } from '@/features/crm/activity/types';
|
||||
import type { ActivityTimelineItem } from '../../api/types';
|
||||
import type { QuotationFollowupRecord } from '@/features/crm/quotations/api/types';
|
||||
|
||||
export interface QuotationFollowupAdapterCandidate {
|
||||
source: 'quotation_followup';
|
||||
followup: QuotationFollowupRecord;
|
||||
followupTypeLabel?: string | null;
|
||||
createdByName?: string | null;
|
||||
}
|
||||
|
||||
export function adaptQuotationFollowupCandidate(
|
||||
candidate: QuotationFollowupAdapterCandidate
|
||||
): ActivityTimelineItem {
|
||||
const typeLabel =
|
||||
candidate.followupTypeLabel ?? formatCrmActivityLabel(candidate.followup.followupType);
|
||||
|
||||
return {
|
||||
id: candidate.followup.id,
|
||||
source: 'quotation_followup',
|
||||
sourceLabel: 'Quotation Follow-up',
|
||||
isLegacy: true,
|
||||
activityId: null,
|
||||
activityCode: null,
|
||||
primaryEntityType: 'quotation',
|
||||
primaryEntityId: candidate.followup.quotationId,
|
||||
primaryRecordLabel: null,
|
||||
activityType: 'follow_up',
|
||||
activityTypeLabel: typeLabel,
|
||||
subject: typeLabel,
|
||||
description: candidate.followup.outcome ?? candidate.followup.notes,
|
||||
status: 'completed',
|
||||
statusLabel: 'Completed',
|
||||
occurredAt: candidate.followup.followupDate,
|
||||
scheduledAt: candidate.followup.followupDate,
|
||||
dueAt: candidate.followup.nextFollowupDate,
|
||||
nextAction: candidate.followup.nextAction,
|
||||
ownerName: null,
|
||||
assignedToName: null,
|
||||
createdByName: candidate.createdByName ?? null
|
||||
};
|
||||
}
|
||||
|
||||
128
src/features/crm/activities/server/attachment-service.ts
Normal file
128
src/features/crm/activities/server/attachment-service.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { CRM_ACTIVITY_PERMISSIONS } from '@/features/crm/activity/types';
|
||||
import {
|
||||
createArtifact,
|
||||
downloadArtifact,
|
||||
listArtifactsForEntity,
|
||||
type DocumentArtifactRecord
|
||||
} from '@/features/foundation/document-artifact/server/service';
|
||||
import {
|
||||
buildOrganizationStorageKey,
|
||||
getStorageProvider
|
||||
} from '@/features/foundation/storage/service';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import type { StoredObjectStream } from '@/features/foundation/storage/types';
|
||||
import type { CrmSecurityContext } from '@/features/crm/security/server/service';
|
||||
import { getActivityById } from './service';
|
||||
|
||||
const ACTIVITY_ATTACHMENT_ENTITY_TYPE = 'crm_activity';
|
||||
const ACTIVITY_ATTACHMENT_DOCUMENT_TYPE = 'activity';
|
||||
const ACTIVITY_ATTACHMENT_ARTIFACT_TYPE = 'attachment';
|
||||
|
||||
export type ActivityAttachmentKind =
|
||||
| 'image'
|
||||
| 'pdf'
|
||||
| 'office_document'
|
||||
| 'voice_recording'
|
||||
| 'drawing'
|
||||
| 'other';
|
||||
|
||||
export interface UploadActivityAttachmentInput {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
activityId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
attachmentKind?: ActivityAttachmentKind;
|
||||
context: CrmSecurityContext;
|
||||
}
|
||||
|
||||
function assertAttachmentWritePermission(context: CrmSecurityContext) {
|
||||
if (
|
||||
!context.permissions.includes(CRM_ACTIVITY_PERMISSIONS.create) &&
|
||||
!context.permissions.includes(CRM_ACTIVITY_PERMISSIONS.update)
|
||||
) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAttachmentStorageKey(input: {
|
||||
organizationId: string;
|
||||
activityId: string;
|
||||
fileName: string;
|
||||
}) {
|
||||
return buildOrganizationStorageKey(input.organizationId, [
|
||||
'crm',
|
||||
'activities',
|
||||
input.activityId,
|
||||
crypto.randomUUID(),
|
||||
input.fileName
|
||||
]);
|
||||
}
|
||||
|
||||
export async function listActivityAttachments(input: {
|
||||
organizationId: string;
|
||||
activityId: string;
|
||||
context: CrmSecurityContext;
|
||||
}): Promise<DocumentArtifactRecord[]> {
|
||||
await getActivityById(input.activityId, input.organizationId, input.context);
|
||||
|
||||
return listArtifactsForEntity({
|
||||
organizationId: input.organizationId,
|
||||
entityType: ACTIVITY_ATTACHMENT_ENTITY_TYPE,
|
||||
entityId: input.activityId,
|
||||
artifactType: ACTIVITY_ATTACHMENT_ARTIFACT_TYPE
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadActivityAttachment(
|
||||
input: UploadActivityAttachmentInput
|
||||
): Promise<DocumentArtifactRecord> {
|
||||
assertAttachmentWritePermission(input.context);
|
||||
await getActivityById(input.activityId, input.organizationId, input.context);
|
||||
|
||||
const storedObject = await getStorageProvider().putObject({
|
||||
key: buildAttachmentStorageKey(input),
|
||||
body: input.body,
|
||||
contentType: input.contentType,
|
||||
fileName: input.fileName
|
||||
});
|
||||
|
||||
return createArtifact({
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
entityType: ACTIVITY_ATTACHMENT_ENTITY_TYPE,
|
||||
entityId: input.activityId,
|
||||
documentType: ACTIVITY_ATTACHMENT_DOCUMENT_TYPE,
|
||||
artifactType: ACTIVITY_ATTACHMENT_ARTIFACT_TYPE,
|
||||
storageProvider: storedObject.provider,
|
||||
storageKey: storedObject.key,
|
||||
fileName: storedObject.fileName ?? input.fileName,
|
||||
contentType: storedObject.contentType,
|
||||
fileSize: storedObject.size,
|
||||
checksum: storedObject.checksum ?? null,
|
||||
metadata: {
|
||||
attachmentKind: input.attachmentKind ?? 'other'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadActivityAttachment(input: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
activityId: string;
|
||||
artifactId: string;
|
||||
context: CrmSecurityContext;
|
||||
}): Promise<{
|
||||
artifact: DocumentArtifactRecord;
|
||||
object: StoredObjectStream;
|
||||
}> {
|
||||
await getActivityById(input.activityId, input.organizationId, input.context);
|
||||
|
||||
return downloadArtifact({
|
||||
artifactId: input.artifactId,
|
||||
organizationId: input.organizationId,
|
||||
userId: input.userId,
|
||||
auditDownload: true
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,10 @@ function toRecordMetadata(value: unknown): Record<string, unknown> | null {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getActivityCode(metadata: Record<string, unknown> | null): string | null {
|
||||
return typeof metadata?.activityCode === 'string' ? metadata.activityCode : null;
|
||||
}
|
||||
|
||||
function canViewPricingForRow(context: CrmSecurityContext, row: ActivityRow): boolean {
|
||||
if (!row.isPricingSensitive) {
|
||||
return true;
|
||||
@@ -43,9 +47,11 @@ export async function hydrateActivityRecords(
|
||||
|
||||
const canViewPricingSensitiveContent = canViewPricingForRow(context, row);
|
||||
const shouldRedact = row.isPricingSensitive && !canViewPricingSensitiveContent;
|
||||
const metadata = toRecordMetadata(row.metadata);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
activityCode: getActivityCode(metadata),
|
||||
organizationId: row.organizationId,
|
||||
primaryEntityType: row.primaryEntityType as ActivityRecord['primaryEntityType'],
|
||||
primaryEntityId: row.primaryEntityId ?? null,
|
||||
@@ -81,7 +87,7 @@ export async function hydrateActivityRecords(
|
||||
isInternalOnly: row.isInternalOnly,
|
||||
isPricingSensitive: row.isPricingSensitive,
|
||||
parentActivityId: row.parentActivityId ?? null,
|
||||
metadata: toRecordMetadata(row.metadata),
|
||||
metadata,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, desc, eq, ilike, isNull, or, type SQL } from 'drizzle-orm';
|
||||
import { crmActivities } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import type { CrmActivityEntityType } from '@/features/crm/activity/types';
|
||||
|
||||
export type ActivityRow = typeof crmActivities.$inferSelect;
|
||||
export type InsertActivityRow = typeof crmActivities.$inferInsert;
|
||||
@@ -13,10 +14,19 @@ export interface ActivityListQuery {
|
||||
activityType?: string;
|
||||
ownerId?: string;
|
||||
assignedToId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ActivityContextQuery {
|
||||
organizationId: string;
|
||||
contextEntityType: Exclude<CrmActivityEntityType, 'internal'>;
|
||||
contextEntityId: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function insertActivity(values: InsertActivityRow): Promise<ActivityRow> {
|
||||
const [created] = await db.insert(crmActivities).values(values).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -34,7 +44,10 @@ export async function updateActivityRow(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function getActivityRow(id: string, organizationId: string): Promise<ActivityRow | undefined> {
|
||||
export async function getActivityRow(
|
||||
id: string,
|
||||
organizationId: string
|
||||
): Promise<ActivityRow | undefined> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(crmActivities)
|
||||
@@ -85,9 +98,59 @@ export async function listActivityRows(query: ActivityListQuery): Promise<Activi
|
||||
filters.push(eq(crmActivities.assignedToId, query.assignedToId));
|
||||
}
|
||||
|
||||
return db
|
||||
let statement = db
|
||||
.select()
|
||||
.from(crmActivities)
|
||||
.where(and(...filters))
|
||||
.orderBy(desc(crmActivities.updatedAt), desc(crmActivities.createdAt));
|
||||
.orderBy(desc(crmActivities.scheduledStartAt), desc(crmActivities.createdAt))
|
||||
.$dynamic();
|
||||
|
||||
if (query.limit) {
|
||||
statement = statement.limit(query.limit);
|
||||
}
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
function buildContextFilter(query: ActivityContextQuery): SQL {
|
||||
switch (query.contextEntityType) {
|
||||
case 'customer':
|
||||
return eq(crmActivities.customerId, query.contextEntityId);
|
||||
case 'contact':
|
||||
return eq(crmActivities.contactId, query.contextEntityId);
|
||||
case 'lead':
|
||||
return eq(crmActivities.leadId, query.contextEntityId);
|
||||
case 'opportunity':
|
||||
return eq(crmActivities.opportunityId, query.contextEntityId);
|
||||
case 'quotation':
|
||||
return eq(crmActivities.quotationId, query.contextEntityId);
|
||||
case 'purchase_order':
|
||||
return and(
|
||||
eq(crmActivities.primaryEntityType, 'purchase_order'),
|
||||
eq(crmActivities.primaryEntityId, query.contextEntityId)
|
||||
)!;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listActivityRowsByContext(
|
||||
query: ActivityContextQuery
|
||||
): Promise<ActivityRow[]> {
|
||||
const filters: SQL[] = [
|
||||
eq(crmActivities.organizationId, query.organizationId),
|
||||
isNull(crmActivities.deletedAt),
|
||||
buildContextFilter(query)
|
||||
];
|
||||
|
||||
let statement = db
|
||||
.select()
|
||||
.from(crmActivities)
|
||||
.where(and(...filters))
|
||||
.orderBy(desc(crmActivities.scheduledStartAt), desc(crmActivities.createdAt))
|
||||
.$dynamic();
|
||||
|
||||
if (query.limit) {
|
||||
statement = statement.limit(query.limit);
|
||||
}
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { memberships, users, crmContactShares, crmCustomerContacts, crmCustomers } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import {
|
||||
CRM_ACTIVITY_ENTITY_TYPES,
|
||||
CRM_ACTIVITY_PERMISSIONS,
|
||||
@@ -47,6 +49,9 @@ import { hydrateActivityRecords } from './read-model';
|
||||
|
||||
type ActivityAccessContext = CrmSecurityContext;
|
||||
|
||||
const ACTIVITY_DOCUMENT_TYPE = 'activity';
|
||||
const ACTIVITY_TYPE_CATEGORY = 'crm_activity_type';
|
||||
|
||||
interface PrimaryRecordContext {
|
||||
primaryEntityId: string | null;
|
||||
primaryRecordLabel: string | null;
|
||||
@@ -388,6 +393,30 @@ function mapOption(value: string): ActivityOption {
|
||||
return { value, label: formatCrmActivityLabel(value) };
|
||||
}
|
||||
|
||||
async function listGovernedActivityTypeOptions(organizationId: string): Promise<ActivityOption[]> {
|
||||
const options = await getActiveOptionsByCategory(ACTIVITY_TYPE_CATEGORY, { organizationId });
|
||||
|
||||
if (!options.length) {
|
||||
return CRM_ACTIVITY_TYPES.map(mapOption);
|
||||
}
|
||||
|
||||
return options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label
|
||||
}));
|
||||
}
|
||||
|
||||
async function assertActivityTypeValue(organizationId: string, activityType: string) {
|
||||
const options = await getActiveOptionsByCategory(ACTIVITY_TYPE_CATEGORY, { organizationId });
|
||||
const allowedValues = options.length
|
||||
? new Set(options.flatMap((option) => [option.id, option.code]))
|
||||
: new Set<string>(CRM_ACTIVITY_TYPES);
|
||||
|
||||
if (!allowedValues.has(activityType)) {
|
||||
throw new AuthError('Invalid activity type', 400);
|
||||
}
|
||||
}
|
||||
|
||||
function filterByEffectiveStatus(items: ActivityRecord[], status?: string) {
|
||||
if (!status) {
|
||||
return items;
|
||||
@@ -489,11 +518,14 @@ async function buildActivityWriteValues(
|
||||
export async function getActivityReferenceData(
|
||||
organizationId: string
|
||||
): Promise<ActivityReferenceData> {
|
||||
const assignableUsers = await listAssignableUsers(organizationId);
|
||||
const [assignableUsers, activityTypes] = await Promise.all([
|
||||
listAssignableUsers(organizationId),
|
||||
listGovernedActivityTypeOptions(organizationId)
|
||||
]);
|
||||
|
||||
return {
|
||||
entityTypes: CRM_ACTIVITY_ENTITY_TYPES.map(mapOption),
|
||||
activityTypes: CRM_ACTIVITY_TYPES.map(mapOption),
|
||||
activityTypes,
|
||||
statuses: CRM_ACTIVITY_STATUSES.filter((status) => status !== 'overdue').map(mapOption),
|
||||
priorities: CRM_ACTIVITY_PRIORITIES.map(mapOption),
|
||||
assignableUsers
|
||||
@@ -555,6 +587,25 @@ export async function createActivity(
|
||||
if (!values.subject) {
|
||||
throw new AuthError('Subject is required', 400);
|
||||
}
|
||||
await assertActivityTypeValue(organizationId, values.activityType);
|
||||
|
||||
const sequence = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
documentType: ACTIVITY_DOCUMENT_TYPE,
|
||||
branchId: values.branchId,
|
||||
productType: values.productType
|
||||
});
|
||||
const metadata = {
|
||||
...values.metadata,
|
||||
activityCode: sequence.code,
|
||||
documentSequence: {
|
||||
documentType: sequence.documentType,
|
||||
branchId: sequence.branchId,
|
||||
productType: sequence.productType,
|
||||
period: sequence.period,
|
||||
currentNumber: sequence.currentNumber
|
||||
}
|
||||
};
|
||||
|
||||
const created = await insertActivity({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -590,7 +641,7 @@ export async function createActivity(
|
||||
isInternalOnly: values.isInternalOnly,
|
||||
isPricingSensitive: values.isPricingSensitive,
|
||||
parentActivityId: null,
|
||||
metadata: values.metadata,
|
||||
metadata,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
});
|
||||
@@ -614,6 +665,7 @@ export async function updateActivity(
|
||||
);
|
||||
|
||||
const values = await buildActivityWriteValues(organizationId, userId, context, payload, current);
|
||||
await assertActivityTypeValue(organizationId, values.activityType);
|
||||
const nextStatus = values.status;
|
||||
|
||||
await updateActivityRow(id, organizationId, {
|
||||
|
||||
217
src/features/crm/activities/server/timeline-service.ts
Normal file
217
src/features/crm/activities/server/timeline-service.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
CRM_ACTIVITY_PERMISSIONS,
|
||||
formatCrmActivityLabel,
|
||||
type CrmActivityEntityType
|
||||
} from '@/features/crm/activity/types';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { resolveUserDisplays } from '@/features/foundation/display/server/display-resolver';
|
||||
import {
|
||||
canAccessScopedCrmRecord,
|
||||
type CrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import type { ActivityRecord, ActivityTimelineItem } from '../api/types';
|
||||
import { hydrateActivityRecords } from './read-model';
|
||||
import { listActivityRowsByContext } from './repository';
|
||||
import {
|
||||
adaptLeadFollowupCandidate
|
||||
} from './adapters/lead-followup.adapter';
|
||||
import {
|
||||
adaptOpportunityFollowupCandidate
|
||||
} from './adapters/opportunity-followup.adapter';
|
||||
import {
|
||||
adaptQuotationFollowupCandidate
|
||||
} from './adapters/quotation-followup.adapter';
|
||||
import { listLeadFollowups } from '@/features/crm/leads/server/service';
|
||||
import { listOpportunityFollowups } from '@/features/crm/opportunities/server/service';
|
||||
import { listQuotationFollowups } from '@/features/crm/quotations/server/service';
|
||||
|
||||
const FOLLOWUP_TYPE_CATEGORY = 'crm_followup_type';
|
||||
|
||||
type TimelineEntityType = Exclude<CrmActivityEntityType, 'internal' | 'purchase_order'>;
|
||||
|
||||
interface TimelineContextInput {
|
||||
organizationId: string;
|
||||
entityType: TimelineEntityType;
|
||||
entityId: string;
|
||||
context: CrmSecurityContext;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function hasActivityReadPermission(context: CrmSecurityContext): boolean {
|
||||
return context.permissions.includes(CRM_ACTIVITY_PERMISSIONS.read);
|
||||
}
|
||||
|
||||
function isManagerLike(context: CrmSecurityContext): boolean {
|
||||
const roles = context.businessRoles ?? [context.businessRole];
|
||||
|
||||
return (
|
||||
context.membershipRole === 'admin' ||
|
||||
context.ownershipScope === 'organization' ||
|
||||
context.ownershipScope === 'team' ||
|
||||
roles.some((role) => role.includes('manager') || role === 'crm_admin')
|
||||
);
|
||||
}
|
||||
|
||||
function canViewActivityTimelineItem(context: CrmSecurityContext, activity: ActivityRecord): boolean {
|
||||
const isDirectUser =
|
||||
activity.createdBy === context.userId ||
|
||||
activity.ownerId === context.userId ||
|
||||
activity.assignedToId === context.userId;
|
||||
|
||||
if (activity.isInternalOnly) {
|
||||
return isDirectUser || isManagerLike(context);
|
||||
}
|
||||
|
||||
return (
|
||||
isDirectUser ||
|
||||
canAccessScopedCrmRecord(context, {
|
||||
branchId: activity.branchId,
|
||||
productType: activity.productType,
|
||||
ownerUserId: activity.ownerId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function resolveActivityOccurredAt(activity: ActivityRecord): string {
|
||||
return (
|
||||
activity.completedAt ??
|
||||
activity.cancelledAt ??
|
||||
activity.skippedAt ??
|
||||
activity.scheduledStartAt ??
|
||||
activity.dueAt ??
|
||||
activity.createdAt
|
||||
);
|
||||
}
|
||||
|
||||
function mapActivityToTimelineItem(activity: ActivityRecord): ActivityTimelineItem {
|
||||
return {
|
||||
id: activity.id,
|
||||
source: 'activity',
|
||||
sourceLabel: 'Activity',
|
||||
isLegacy: false,
|
||||
activityId: activity.id,
|
||||
activityCode: activity.activityCode,
|
||||
primaryEntityType: activity.primaryEntityType,
|
||||
primaryEntityId: activity.primaryEntityId,
|
||||
primaryRecordLabel: activity.primaryRecordLabel,
|
||||
activityType: activity.activityType,
|
||||
activityTypeLabel: formatCrmActivityLabel(activity.activityType),
|
||||
subject: activity.subject,
|
||||
description: activity.description,
|
||||
status: activity.effectiveStatus,
|
||||
statusLabel: formatCrmActivityLabel(activity.effectiveStatus),
|
||||
occurredAt: resolveActivityOccurredAt(activity),
|
||||
scheduledAt: activity.scheduledStartAt,
|
||||
dueAt: activity.dueAt,
|
||||
nextAction: activity.nextAction,
|
||||
ownerName: activity.ownerName,
|
||||
assignedToName: activity.assignedToName,
|
||||
createdByName: activity.createdByName
|
||||
};
|
||||
}
|
||||
|
||||
function sortTimelineItems(items: ActivityTimelineItem[]): ActivityTimelineItem[] {
|
||||
return items.toSorted((left, right) => {
|
||||
return new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
async function buildFollowupTypeLabelMap(organizationId: string): Promise<Map<string, string>> {
|
||||
const options = await getActiveOptionsByCategory(FOLLOWUP_TYPE_CATEGORY, { organizationId });
|
||||
|
||||
return new Map(options.map((option) => [option.id, option.label]));
|
||||
}
|
||||
|
||||
async function buildUserDisplayMap(items: Array<{ createdBy: string }>): Promise<Map<string, string>> {
|
||||
const userIds = [...new Set(items.map((item) => item.createdBy).filter(Boolean))];
|
||||
const displayMap = await resolveUserDisplays(userIds);
|
||||
|
||||
return new Map(
|
||||
[...displayMap.entries()].map(([userId, record]) => [userId, record.displayName])
|
||||
);
|
||||
}
|
||||
|
||||
async function listLegacyTimelineItems(
|
||||
input: TimelineContextInput
|
||||
): Promise<ActivityTimelineItem[]> {
|
||||
if (input.entityType === 'customer' || input.entityType === 'contact') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (input.entityType === 'lead') {
|
||||
const followups = await listLeadFollowups(input.entityId, input.organizationId, input.context);
|
||||
|
||||
return followups.map((followup) =>
|
||||
adaptLeadFollowupCandidate({
|
||||
source: 'lead_followup',
|
||||
followup
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (input.entityType === 'opportunity') {
|
||||
const [followupTypeLabelMap, followups] = await Promise.all([
|
||||
buildFollowupTypeLabelMap(input.organizationId),
|
||||
listOpportunityFollowups(input.entityId, input.organizationId, input.context)
|
||||
]);
|
||||
const userDisplayMap = await buildUserDisplayMap(followups);
|
||||
|
||||
return followups.map((followup) =>
|
||||
adaptOpportunityFollowupCandidate({
|
||||
source: 'opportunity_followup',
|
||||
followup,
|
||||
followupTypeLabel: followupTypeLabelMap.get(followup.followupType),
|
||||
createdByName: userDisplayMap.get(followup.createdBy) ?? null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const [followupTypeLabelMap, followups] = await Promise.all([
|
||||
buildFollowupTypeLabelMap(input.organizationId),
|
||||
listQuotationFollowups(input.entityId, input.organizationId)
|
||||
]);
|
||||
const userDisplayMap = await buildUserDisplayMap(followups);
|
||||
|
||||
return followups.map((followup) =>
|
||||
adaptQuotationFollowupCandidate({
|
||||
source: 'quotation_followup',
|
||||
followup,
|
||||
followupTypeLabel: followupTypeLabelMap.get(followup.followupType),
|
||||
createdByName: userDisplayMap.get(followup.createdBy) ?? null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function listCurrentTimelineItems(input: TimelineContextInput): Promise<ActivityTimelineItem[]> {
|
||||
const rows = await listActivityRowsByContext({
|
||||
organizationId: input.organizationId,
|
||||
contextEntityType: input.entityType,
|
||||
contextEntityId: input.entityId,
|
||||
limit: input.limit ? input.limit * 2 : undefined
|
||||
});
|
||||
|
||||
const hydrated = await hydrateActivityRecords(rows, input.context);
|
||||
|
||||
return hydrated.filter((activity) => canViewActivityTimelineItem(input.context, activity)).map(mapActivityToTimelineItem);
|
||||
}
|
||||
|
||||
export async function listRecentActivitiesForContext(
|
||||
input: TimelineContextInput
|
||||
): Promise<ActivityTimelineItem[]> {
|
||||
if (!hasActivityReadPermission(input.context)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [currentItems, legacyItems] = await Promise.all([
|
||||
listCurrentTimelineItems(input),
|
||||
listLegacyTimelineItems(input)
|
||||
]);
|
||||
|
||||
const merged = sortTimelineItems([...currentItems, ...legacyItems]);
|
||||
|
||||
if (!input.limit) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
return merged.slice(0, input.limit);
|
||||
}
|
||||
@@ -11,14 +11,19 @@ export const CRM_ACTIVITY_ENTITY_TYPES = [
|
||||
] as const;
|
||||
|
||||
export const CRM_ACTIVITY_TYPES = [
|
||||
'follow_up',
|
||||
'meeting',
|
||||
'visit',
|
||||
'phone_call',
|
||||
'email_follow_up',
|
||||
'email',
|
||||
'site_survey',
|
||||
'presentation',
|
||||
'reminder',
|
||||
'internal_task',
|
||||
'follow_up',
|
||||
'delivery_coordination',
|
||||
'approval_action',
|
||||
// Backward-compatible aliases kept while master options become the source of truth.
|
||||
'email_follow_up',
|
||||
'reminder_action',
|
||||
'other'
|
||||
] as const;
|
||||
|
||||
@@ -160,6 +160,7 @@ export interface CustomerDetailResponse {
|
||||
message: string;
|
||||
customer: CustomerRecord;
|
||||
activity: CustomerActivityRecord[];
|
||||
recentActivities: ActivityTimelineItem[];
|
||||
ownerHistory: CustomerOwnerHistoryRecord[];
|
||||
}
|
||||
|
||||
@@ -227,3 +228,4 @@ export interface MutationSuccessResponse {
|
||||
message: string;
|
||||
}
|
||||
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
|
||||
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { formatNumber } from '@/lib/number-format';
|
||||
@@ -126,6 +127,12 @@ export function CustomerDetail({
|
||||
<div className='space-y-6 lg:col-span-2'>
|
||||
<Card>
|
||||
<CardContent className='pt-6'>
|
||||
<RecentActivitiesPanel
|
||||
items={data.recentActivities}
|
||||
description='Recent customer-linked activity records shown alongside existing audit history.'
|
||||
emptyMessage='No recent customer activity yet.'
|
||||
/>
|
||||
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>ภาพรวม</TabsTrigger>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
|
||||
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||
import { formatDateTime } from '@/lib/date-format';
|
||||
import { formatNumber } from '@/lib/number-format';
|
||||
@@ -112,6 +113,12 @@ export function LeadDetail({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RecentActivitiesPanel
|
||||
items={data.recentActivities}
|
||||
description='New activity records and legacy lead follow-ups shown together.'
|
||||
emptyMessage='No recent activity or lead follow-up yet.'
|
||||
/>
|
||||
|
||||
<Tabs defaultValue='followups' className='space-y-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||
|
||||
@@ -213,6 +213,7 @@ export interface LeadDetailResponse {
|
||||
message: string;
|
||||
lead: LeadDetail;
|
||||
followups: LeadFollowupSummary[];
|
||||
recentActivities: ActivityTimelineItem[];
|
||||
referenceData: LeadReferenceData;
|
||||
}
|
||||
|
||||
@@ -223,3 +224,4 @@ export interface LeadMutationSuccessResponse {
|
||||
followup?: LeadFollowupSummary;
|
||||
}
|
||||
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';
|
||||
|
||||
@@ -224,6 +224,7 @@ export interface OpportunityDetailResponse {
|
||||
message: string;
|
||||
opportunity: OpportunityRecord;
|
||||
activity: OpportunityActivityRecord[];
|
||||
recentActivities: ActivityTimelineItem[];
|
||||
}
|
||||
|
||||
export interface OpportunityProjectPartiesResponse {
|
||||
@@ -338,3 +339,4 @@ export interface MutationSuccessResponse {
|
||||
message: string;
|
||||
}
|
||||
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { RecentActivitiesPanel } from '@/features/crm/activities/components/recent-activities-panel';
|
||||
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||
import { formatDate, formatDateTime } from '@/lib/date-format';
|
||||
import { formatNumber } from '@/lib/number-format';
|
||||
@@ -317,6 +318,12 @@ remark: item.remark ?? ''
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RecentActivitiesPanel
|
||||
items={data.recentActivities}
|
||||
description='New activity records and legacy opportunity follow-ups shown together.'
|
||||
emptyMessage='No recent activity or opportunity follow-up yet.'
|
||||
/>
|
||||
|
||||
<Tabs defaultValue='followups' className='space-y-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='followups'>Follow-up Timeline</TabsTrigger>
|
||||
|
||||
@@ -332,6 +332,7 @@ export interface QuotationDetailResponse {
|
||||
message: string;
|
||||
quotation: QuotationRecord;
|
||||
activity: QuotationActivityRecord[];
|
||||
recentActivities: ActivityTimelineItem[];
|
||||
}
|
||||
|
||||
export interface QuotationItemsResponse {
|
||||
@@ -488,3 +489,4 @@ export interface QuotationCustomerPackageGenerateResponse {
|
||||
} | null;
|
||||
}
|
||||
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||
import type { ActivityTimelineItem } from '@/features/crm/activities/api/types';
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { RecentActivitiesPanel } from "@/features/crm/activities/components/recent-activities-panel";
|
||||
import { AuditLogTab } from "@/features/crm/components/audit-log-tab";
|
||||
import { formatDate, formatDateTime } from "@/lib/date-format";
|
||||
import { formatNumber } from "@/lib/number-format";
|
||||
@@ -1380,6 +1381,12 @@ export function QuotationDetail({
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<RecentActivitiesPanel
|
||||
items={data.recentActivities}
|
||||
description="New activity records and legacy quotation follow-ups shown together."
|
||||
emptyMessage="No recent activity or quotation follow-up yet."
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview" className="gap-4">
|
||||
<TabsList className="flex flex-wrap">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function createArtifact(input: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
documentType: string;
|
||||
artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export';
|
||||
artifactType: 'preview' | 'approved_pdf' | 'assembled_pdf' | 'export' | 'attachment';
|
||||
templateVersionId?: string | null;
|
||||
storageProvider: string;
|
||||
storageKey: string;
|
||||
@@ -189,6 +189,29 @@ export async function getActiveArtifactForEntity(input: {
|
||||
return enrichArtifact(artifact);
|
||||
}
|
||||
|
||||
export async function listArtifactsForEntity(input: {
|
||||
organizationId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
artifactType?: string;
|
||||
}) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmDocumentArtifacts)
|
||||
.where(
|
||||
and(
|
||||
eq(crmDocumentArtifacts.organizationId, input.organizationId),
|
||||
eq(crmDocumentArtifacts.entityType, input.entityType),
|
||||
eq(crmDocumentArtifacts.entityId, input.entityId),
|
||||
...(input.artifactType ? [eq(crmDocumentArtifacts.artifactType, input.artifactType)] : []),
|
||||
isNull(crmDocumentArtifacts.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmDocumentArtifacts.createdAt));
|
||||
|
||||
return Promise.all(rows.map((row) => enrichArtifact(row)));
|
||||
}
|
||||
|
||||
export async function lockArtifact(input: {
|
||||
artifactId: string;
|
||||
organizationId: string;
|
||||
|
||||
@@ -38,6 +38,7 @@ const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
|
||||
crm_opportunity: "EN",
|
||||
quotation: "QT",
|
||||
approval: "APV",
|
||||
activity: "ACT",
|
||||
};
|
||||
|
||||
type OrganizationRecord = {
|
||||
|
||||
Reference in New Issue
Block a user