From c337b0b4c70771f59688b986c90d060ddf6f11f4 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 3 Nov 2026 16:19:32 +0330 Subject: [PATCH] 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 --- apps/api/src/activities/activities.service.ts | 22 ++++++++- apps/api/src/activities/dto.ts | 10 ++++ apps/api/src/leads/leads.service.ts | 49 ++++++++++++++++--- apps/web/src/views/ActivityFormView.vue | 4 ++ apps/web/src/views/LeadDetailView.vue | 21 ++++---- packages/shared/src/index.ts | 3 ++ 6 files changed, 89 insertions(+), 20 deletions(-) diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 916beae..31c8850 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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; +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( diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 6bda448..e39b27d 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -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() diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index 6a1d0f4..fff017e 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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 { - 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 { if (!elevated(actor.role) && ids.length > 1) { throw new BadRequestException('حذف گروهی فقط برای مدیر'); diff --git a/apps/web/src/views/ActivityFormView.vue b/apps/web/src/views/ActivityFormView.vue index c9f5f2a..e87b1a7 100644 --- a/apps/web/src/views/ActivityFormView.vue +++ b/apps/web/src/views/ActivityFormView.vue @@ -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([]); 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, }; diff --git a/apps/web/src/views/LeadDetailView.vue b/apps/web/src/views/LeadDetailView.vue index 34af557..79d70ef 100644 --- a/apps/web/src/views/LeadDetailView.vue +++ b/apps/web/src/views/LeadDetailView.vue @@ -845,6 +845,16 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow)); + + + + ایجاد فعالیت + + جدول زمانی @@ -889,17 +899,6 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow)); مشاهده همه فعالیت‌ها - - - - ایجاد فعالیت - - diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 96b069f..40e20ba 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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[]; }