Skip to main content

Phase 3: Timeline & Notes

Activity timeline and customer notes.


Deliverables

  • Activity service with CRUD
  • Timeline API with filters
  • Customer notes CRUD
  • Activity metrics denormalization

1. Activity Service

// libs/crm/core/src/services/activity.service.ts

import { Injectable } from '@nestjs/common';
import { PrismaService } from '@digiwedge/crm-client';
import { Prisma } from '@prisma/crm';

@Injectable()
export class ActivityService {
constructor(private readonly prisma: PrismaService) {}

async create(dto: CreateActivityDto): Promise<Activity> {
// Check for duplicate (idempotency)
if (dto.sourceEventId) {
const existing = await this.prisma.activity.findUnique({
where: {
tenantId_sourceService_sourceEventId: {
tenantId: dto.tenantId,
sourceService: dto.sourceService,
sourceEventId: dto.sourceEventId,
},
},
});
if (existing) return existing;
}

const activity = await this.prisma.activity.create({ data: dto });

// Update profile activity metrics
await this.updateProfileMetrics(dto.customerId, dto);

return activity;
}

private async updateProfileMetrics(customerId: string, activity: CreateActivityDto) {
const updates: Prisma.CustomerProfileUpdateInput = {
lastActivityAt: activity.occurredAt,
lastActivityType: activity.type,
};

// Check if activity is within 30/90 days
const now = new Date();
const daysSince = Math.floor((now.getTime() - activity.occurredAt.getTime()) / (1000 * 60 * 60 * 24));

if (daysSince <= 30) {
updates.activityCount30d = { increment: 1 };
}
if (daysSince <= 90) {
updates.activityCount90d = { increment: 1 };
}

// Engagement-specific updates
if (activity.type === 'EMAIL_OPENED') {
updates.lastEmailOpenAt = activity.occurredAt;
}
if (activity.type === 'EMAIL_CLICKED') {
updates.lastEmailClickAt = activity.occurredAt;
}

await this.prisma.customerProfile.update({
where: { id: customerId },
data: updates,
});
}

async getTimeline(
customerId: string,
filters: TimelineFilters = {},
pagination: { skip?: number; take?: number } = {},
): Promise<{ activities: Activity[]; total: number }> {
const where: Prisma.ActivityWhereInput = { customerId };

if (filters.categories?.length) {
where.category = { in: filters.categories };
}
if (filters.types?.length) {
where.type = { in: filters.types };
}
if (filters.startDate || filters.endDate) {
where.occurredAt = {};
if (filters.startDate) where.occurredAt.gte = filters.startDate;
if (filters.endDate) where.occurredAt.lte = filters.endDate;
}
if (filters.sourceService) {
where.sourceService = filters.sourceService;
}

const [activities, total] = await Promise.all([
this.prisma.activity.findMany({
where,
orderBy: { occurredAt: 'desc' },
skip: pagination.skip ?? 0,
take: pagination.take ?? 50,
}),
this.prisma.activity.count({ where }),
]);

return { activities, total };
}

async getActivityStats(customerId: string): Promise<ActivityStats> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

const [byCategory, byType] = await Promise.all([
this.prisma.activity.groupBy({
by: ['category'],
where: { customerId, occurredAt: { gte: thirtyDaysAgo } },
_count: true,
}),
this.prisma.activity.groupBy({
by: ['type'],
where: { customerId, occurredAt: { gte: thirtyDaysAgo } },
_count: true,
}),
]);

return {
byCategory: Object.fromEntries(byCategory.map(c => [c.category, c._count])),
byType: Object.fromEntries(byType.map(t => [t.type, t._count])),
};
}
}

2. Notes Service

// libs/crm/core/src/services/customer-notes.service.ts

@Injectable()
export class CustomerNotesService {
constructor(
private readonly prisma: PrismaService,
private readonly activityService: ActivityService,
) {}

async create(dto: CreateNoteDto, userId: string): Promise<CustomerNote> {
const note = await this.prisma.customerNote.create({
data: {
tenantId: dto.tenantId,
customerId: dto.customerId,
content: dto.content,
type: dto.type ?? 'GENERAL',
isPinned: dto.isPinned ?? false,
isPrivate: dto.isPrivate ?? false,
createdBy: userId,
},
});

// Create activity
await this.activityService.create({
tenantId: dto.tenantId,
customerId: dto.customerId,
type: 'NOTE_ADDED',
category: 'SUPPORT',
title: `Note added: ${dto.type || 'General'}`,
sourceService: 'crm',
sourceId: note.id,
occurredAt: new Date(),
});

return note;
}

async list(customerId: string, options: ListNotesOptions = {}) {
const { includePrivate = false, userId, type } = options;

const where: Prisma.CustomerNoteWhereInput = { customerId };

// Privacy filter
if (!includePrivate) {
where.OR = [
{ isPrivate: false },
{ isPrivate: true, createdBy: userId },
];
}

if (type) {
where.type = type;
}

return this.prisma.customerNote.findMany({
where,
orderBy: [{ isPinned: 'desc' }, { createdAt: 'desc' }],
});
}

async update(id: string, dto: UpdateNoteDto, userId: string): Promise<CustomerNote> {
const note = await this.prisma.customerNote.findUnique({ where: { id } });

if (!note) throw new NotFoundException('Note not found');

// Only creator can update private notes
if (note.isPrivate && note.createdBy !== userId) {
throw new ForbiddenException('Cannot update private note');
}

return this.prisma.customerNote.update({
where: { id },
data: {
content: dto.content,
type: dto.type,
isPinned: dto.isPinned,
updatedBy: userId,
},
});
}

async delete(id: string, userId: string): Promise<void> {
const note = await this.prisma.customerNote.findUnique({ where: { id } });

if (!note) throw new NotFoundException('Note not found');

if (note.isPrivate && note.createdBy !== userId) {
throw new ForbiddenException('Cannot delete private note');
}

await this.prisma.customerNote.delete({ where: { id } });
}

async pin(id: string, userId: string): Promise<CustomerNote> {
return this.prisma.customerNote.update({
where: { id },
data: { isPinned: true, updatedBy: userId },
});
}

async unpin(id: string, userId: string): Promise<CustomerNote> {
return this.prisma.customerNote.update({
where: { id },
data: { isPinned: false, updatedBy: userId },
});
}
}

3. Timeline Controller

// apps/crm/crm-backend/src/app/customer/timeline.controller.ts

@ApiTags('customer-timeline')
@Controller('api/v1/customers/:customerId/activities')
export class TimelineController {
constructor(private readonly activityService: ActivityService) {}

@Get()
@ApiOperation({ summary: 'Get customer timeline' })
async getTimeline(
@Param('customerId') customerId: string,
@Query() filters: TimelineFiltersDto,
) {
return this.activityService.getTimeline(customerId, filters, {
skip: filters.skip,
take: filters.take,
});
}

@Get('stats')
@ApiOperation({ summary: 'Get activity statistics' })
async getStats(@Param('customerId') customerId: string) {
return this.activityService.getActivityStats(customerId);
}

@Post()
@ApiOperation({ summary: 'Add custom activity' })
async addActivity(
@Param('customerId') customerId: string,
@Body() dto: CreateActivityDto,
@CurrentUser() user: AuthUser,
) {
return this.activityService.create({
...dto,
customerId,
sourceService: 'crm',
occurredAt: dto.occurredAt || new Date(),
});
}
}

4. Notes Controller

// apps/crm/crm-backend/src/app/customer/notes.controller.ts

@ApiTags('customer-notes')
@Controller('api/v1/customers/:customerId/notes')
export class NotesController {
constructor(private readonly notesService: CustomerNotesService) {}

@Get()
@ApiOperation({ summary: 'List customer notes' })
async list(
@Param('customerId') customerId: string,
@Query('type') type?: NoteType,
@CurrentUser() user: AuthUser,
) {
return this.notesService.list(customerId, {
userId: user.id,
type,
});
}

@Post()
@ApiOperation({ summary: 'Create note' })
async create(
@Param('customerId') customerId: string,
@Body() dto: CreateNoteDto,
@CurrentUser() user: AuthUser,
) {
return this.notesService.create(
{ ...dto, customerId },
user.id,
);
}

@Patch(':noteId')
@ApiOperation({ summary: 'Update note' })
async update(
@Param('noteId') noteId: string,
@Body() dto: UpdateNoteDto,
@CurrentUser() user: AuthUser,
) {
return this.notesService.update(noteId, dto, user.id);
}

@Delete(':noteId')
@ApiOperation({ summary: 'Delete note' })
async delete(
@Param('noteId') noteId: string,
@CurrentUser() user: AuthUser,
) {
return this.notesService.delete(noteId, user.id);
}

@Post(':noteId/pin')
@ApiOperation({ summary: 'Pin note' })
async pin(
@Param('noteId') noteId: string,
@CurrentUser() user: AuthUser,
) {
return this.notesService.pin(noteId, user.id);
}

@Delete(':noteId/pin')
@ApiOperation({ summary: 'Unpin note' })
async unpin(
@Param('noteId') noteId: string,
@CurrentUser() user: AuthUser,
) {
return this.notesService.unpin(noteId, user.id);
}
}

5. Activity Metrics Refresh Job

// libs/crm/core/src/jobs/activity-metrics.job.ts

@Injectable()
export class ActivityMetricsJob {
constructor(private readonly prisma: PrismaService) {}

@Cron('0 1 * * *') // Run at 1 AM daily
async refreshActivityCounts(): Promise<void> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);

// Batch update in chunks to avoid lock contention
let cursor: string | undefined;

while (true) {
const profiles = await this.prisma.customerProfile.findMany({
where: { status: 'ACTIVE', deletedAt: null },
take: 100,
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { id: 'asc' },
select: { id: true },
});

if (profiles.length === 0) break;

for (const profile of profiles) {
const [count30d, count90d, bookingCount30d, bookingCount90d] = await Promise.all([
this.prisma.activity.count({
where: { customerId: profile.id, occurredAt: { gte: thirtyDaysAgo } },
}),
this.prisma.activity.count({
where: { customerId: profile.id, occurredAt: { gte: ninetyDaysAgo } },
}),
this.prisma.activity.count({
where: {
customerId: profile.id,
type: 'TEETIME_BOOKED',
occurredAt: { gte: thirtyDaysAgo },
},
}),
this.prisma.activity.count({
where: {
customerId: profile.id,
type: 'TEETIME_BOOKED',
occurredAt: { gte: ninetyDaysAgo },
},
}),
]);

await this.prisma.customerProfile.update({
where: { id: profile.id },
data: {
activityCount30d: count30d,
activityCount90d: count90d,
bookingCount30d: bookingCount30d,
bookingCount90d: bookingCount90d,
},
});
}

cursor = profiles[profiles.length - 1].id;
}
}
}

Next Steps

After completing Phase 3, proceed to Phase 4: Marketing Core.