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('حذف گروهی فقط برای مدیر');
+4
View File
@@ -126,6 +126,7 @@ const dueDate = ref('');
const dueTime = ref('10:30');
const durationMinutes = ref(30);
const personId = ref('');
const leadId = ref('');
const dealId = ref('');
const assigneeIds = ref<string[]>([]);
const isDone = ref(false);
@@ -519,6 +520,7 @@ async function load() {
if (data.person?.id) await ensurePersonLoaded(data.person.id);
personId.value = data.person?.id ?? '';
dealId.value = data.deal?.id ?? '';
leadId.value = data.lead?.id ?? '';
assigneeIds.value = data.assignees?.length
? data.assignees.map((a) => a.id)
: data.assignee?.id
@@ -538,6 +540,7 @@ async function load() {
await ensurePersonLoaded(incomingPerson);
personId.value = incomingPerson;
}
leadId.value = typeof route.query.leadId === 'string' ? route.query.leadId : '';
const incomingDef =
typeof route.query.typeDefId === 'string'
? activityTypes.byId.value.get(route.query.typeDefId)
@@ -599,6 +602,7 @@ function payload(): CreateActivityRequest {
personId: personId.value || null,
companyId: selectedPerson.value?.company?.id ?? null,
dealId: dealId.value || null,
leadId: leadId.value || null,
assigneeIds: [...assigneeIds.value],
assigneeId: assigneeIds.value[0] || null,
};
+10 -11
View File
@@ -845,6 +845,16 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
</template>
</a-list>
<a-empty v-else description="فعالیت مرتبطی نیست." />
<RouterLink
:to="{ name: 'activity-new', query: { leadId: lead.id } }"
custom
v-slot="{ navigate }"
>
<a-button type="dashed" block @click="navigate">
<template #icon><PlusOutlined /></template>
ایجاد فعالیت
</a-button>
</RouterLink>
<a-divider orientation="right">جدول زمانی</a-divider>
<a-timeline v-if="lead.events.length">
@@ -889,17 +899,6 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
مشاهده همه فعالیت‌ها
</a-button>
</RouterLink>
<RouterLink
v-if="narrow"
:to="{ name: 'activity-new' }"
custom
v-slot="{ navigate }"
>
<a-button type="dashed" block @click="navigate">
<template #icon><PlusOutlined /></template>
ایجاد فعالیت
</a-button>
</RouterLink>
</template>
</a-card>
+3
View File
@@ -1982,6 +1982,8 @@ export interface CreateActivityRequest {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
/** لید مرتبط؛ فعالیت در پروندهٔ همان لید می‌ماند */
leadId?: string | null;
/** سازگاری — اگر assigneeIds نباشد از این استفاده می‌شود */
assigneeId?: string | null;
/** چند مسئول؛ اولی به‌عنوان assigneeId ذخیره می‌شود */
@@ -2007,6 +2009,7 @@ export interface UpdateActivityRequest {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
leadId?: string | null;
assigneeId?: string | null;
assigneeIds?: string[];
}