fix(leads): keep a future follow-up as an activity on the lead

A scheduled follow-up only stored a date, and activities opened from a lead were not linked back, so they disappeared from the lead.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-03 16:19:32 +03:30
co-authored by Cursor
parent c26353624e
commit c337b0b4c7
6 changed files with 89 additions and 20 deletions
+20 -2
View File
@@ -36,6 +36,7 @@ import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates';
import { buildSearchWhere, ci } from '../common/search';
import { activityPriorWhere } from '../common/prior-archive';
import { leadsDeepWhere } from '../common/data-access';
import { ActivityStagesService, type BoardStageContext } from './activity-stages.service';
import {
ActivityTypesService,
@@ -45,7 +46,10 @@ import {
type DtoContext = { stages?: BoardStageContext; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
type ActivityActor = Pick<
Actor,
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
>;
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
@@ -324,7 +328,10 @@ export class ActivitiesService {
this.prisma.activity.findMany({
where,
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
// روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
orderBy: entity?.leadId
? [{ createdAt: 'desc' }]
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take,
take,
}),
@@ -628,6 +635,7 @@ export class ActivitiesService {
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
leadId: emptyToNull(dto.leadId ?? null),
assigneeId,
assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) }
@@ -767,6 +775,7 @@ export class ActivitiesService {
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined;
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({
@@ -1283,11 +1292,13 @@ export class ActivitiesService {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
leadId?: string | null;
},
) {
const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null);
const leadId = emptyToNull(dto.leadId ?? null);
const scope = ownerWhere(actor);
if (personId) {
@@ -1311,6 +1322,13 @@ export class ActivitiesService {
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
if (leadId) {
const lead = await this.prisma.lead.findFirst({
where: { id: leadId, organizationId, ...leadsDeepWhere(actor) },
select: { id: true },
});
if (!lead) throw new BadRequestException('لید انتخاب‌شده معتبر نیست');
}
}
private async notifyParticipants(
+10
View File
@@ -364,6 +364,11 @@ export class CreateActivityDto {
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
leadId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -459,6 +464,11 @@ export class UpdateActivityDto {
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
leadId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
+42 -7
View File
@@ -810,14 +810,14 @@ export class LeadsService {
? `تماس ثبت شد — ${dto.outcome}`
: 'تماس ثبت شد';
await this.addEvent(actor, id, 'call', title, dto.body ?? null);
const followAt = this.parseFollowUpAt(dto.nextFollowUpAt);
if (followAt) {
await this.createLeadFollowUpActivity(actor, before, followAt, dto.body);
}
const data: Prisma.LeadUpdateInput = {
lastContactAt: new Date(),
...(dto.nextFollowUpAt !== undefined
? {
nextFollowUpAt: dto.nextFollowUpAt
? new Date(dto.nextFollowUpAt)
: null,
}
? { nextFollowUpAt: followAt }
: {}),
};
if (dto.stageId) data.stage = { connect: { id: dto.stageId } };
@@ -856,8 +856,10 @@ export class LeadsService {
nextFollowUpAt: string,
body?: string,
): Promise<LeadDto> {
await this.findAccessible(actor, id);
const at = new Date(nextFollowUpAt);
const lead = await this.findAccessible(actor, id);
const at = this.parseFollowUpAt(nextFollowUpAt);
if (!at) throw new BadRequestException('زمان پیگیری نامعتبر است');
await this.createLeadFollowUpActivity(actor, lead, at, body);
await this.addEvent(
actor,
id,
@@ -878,6 +880,39 @@ export class LeadsService {
return this.toDto(row);
}
/** پیگیری آینده باید خودش یک فعالیت باز و متصل به لید باشد، نه فقط یک تاریخ. */
private parseFollowUpAt(value?: string | null): Date | null {
if (!value) return null;
const at = new Date(value);
if (Number.isNaN(at.getTime())) {
throw new BadRequestException('زمان پیگیری نامعتبر است');
}
return at;
}
private async createLeadFollowUpActivity(
actor: Actor,
lead: { id: string; title: string; assigneeId: string | null },
dueAt: Date,
body?: string | null,
) {
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'activities');
const assigneeId = allowOthers && lead.assigneeId ? lead.assigneeId : actor.userId;
await this.prisma.activity.create({
data: {
organizationId: actor.organizationId,
type: 'task',
title: `پیگیری: ${lead.title}`.slice(0, 200),
description: body?.trim() || null,
dueAt,
leadId: lead.id,
assigneeId,
assignees: { connect: [{ id: assigneeId }] },
createdById: actor.userId,
},
});
}
async archive(actor: Actor, ids: string[]): Promise<number> {
if (!elevated(actor.role) && ids.length > 1) {
throw new BadRequestException('حذف گروهی فقط برای مدیر');