From 6bff9d2888452adf0ee6a2bbc6a8f9454eda65f5 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 15 Oct 2026 12:34:40 +0330 Subject: [PATCH] fix: keep imported follow-ups out of current work They stayed on the board when a non-prior deal was attached, even though the person was prior. Co-authored-by: Cursor --- apps/api/src/activities/activities.service.ts | 8 ++-- apps/api/src/ai/ai-ask.service.ts | 2 +- apps/api/src/auth/jwt.strategy.ts | 3 +- apps/api/src/billing/billing.service.ts | 13 +++-- apps/api/src/common/prior-archive.ts | 48 +++++++++++++++++-- apps/api/src/common/record-scope.ts | 2 + apps/api/src/reports/reports.service.ts | 4 +- apps/api/test/prior-archive.test.ts | 36 ++++++++++++++ .../2026-10-14-prior-data-archive-design.md | 2 +- 9 files changed, 102 insertions(+), 16 deletions(-) diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index a76f5a6..2fe2378 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -38,7 +38,7 @@ import { type DtoContext = { stages?: StageDefaults; types?: SystemTypes }; -type ActivityActor = Pick; +type ActivityActor = Pick; /** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */ type StageScope = { stageId: string }; @@ -119,7 +119,7 @@ export class ActivitiesService { ): Promise { const { start, end } = dayBounds(); const scope = activityAccessWhere(actor); - const prior = activityPriorWhere(actor.priorLens); + const prior = activityPriorWhere(actor.priorLens, new Date(), actor.priorCutoff); const [todayCount, overdueCount] = await Promise.all([ this.prisma.activity.count({ where: { @@ -700,7 +700,9 @@ export class ActivitiesService { ]); if (search) andParts.push(search); const onRecord = Boolean(entity?.personId || entity?.companyId || entity?.dealId); - if (!onRecord) andParts.push(activityPriorWhere(actor.priorLens)); + if (!onRecord) { + andParts.push(activityPriorWhere(actor.priorLens, new Date(), actor.priorCutoff)); + } const base: Prisma.ActivityWhereInput = { organizationId, AND: andParts, diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index b310339..1e47c91 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -406,7 +406,7 @@ Rules: organizationId: orgId, ...assigneeWhere(actor), ...created, - ...activityPriorWhere(actor.priorLens), + ...activityPriorWhere(actor.priorLens, new Date(), actor.priorCutoff), }, }), ticket: () => diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 99f11e9..52c13c2 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -64,7 +64,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { dataVisibilityScopes: true, priorArchiveEnabled: true, priorArchiveLens: true, - organization: { select: { enabledModules: true } }, + organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } }, }, }); if (!membership) { @@ -107,6 +107,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { visibleUserIds, scopes, priorLens: priorGranted ? priorLens : 'off', + priorCutoff: priorGranted ? membership.organization.priorArchiveEnabledAt : null, }; } diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index 3c8d98d..a9d0f14 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -287,17 +287,24 @@ export class BillingService { ) ) OR ( - a."dealId" IS NULL - AND a."createdAt" < ${cutoff} + a."createdAt" < ${cutoff} AND ( EXISTS ( SELECT 1 FROM "Person" AS p - WHERE p."id" = a."personId" AND p."importSource" = 'didar' + WHERE p."id" = a."personId" + AND ( + p."importSource" = 'didar' + OR p."previousImportSource" = 'didar' + ) ) OR EXISTS ( SELECT 1 FROM "Company" AS c WHERE c."id" = a."companyId" AND c."importSource" = 'didar' ) + OR EXISTS ( + SELECT 1 FROM "Deal" AS d2 + WHERE d2."id" = a."dealId" AND d2."importSource" = 'didar' + ) ) ) ) diff --git a/apps/api/src/common/prior-archive.ts b/apps/api/src/common/prior-archive.ts index bd917f5..fce7164 100644 --- a/apps/api/src/common/prior-archive.ts +++ b/apps/api/src/common/prior-archive.ts @@ -25,6 +25,39 @@ function liveActivity(now: Date): Prisma.ActivityWhereInput { }; } +/** فعالیت باز با موعد آینده، حتی اگر قبلی باشد، در کار جاری می‌ماند. */ +function stillOpenAhead(now: Date): Prisma.ActivityWhereInput { + return { status: 'open', dueAt: { gt: now } }; +} + +/** + * فعالیت قبلی است اگر منبعش didar باشد، + * یا قبل از روشن شدن ماژول ساخته شده و به شخص/شرکت/معاملهٔ قبلی وصل باشد. + * معاملهٔ غیرقبلی مانع نمی‌شود؛ شخص قبلی (حتی اگر بعداً منبعش عوض شده) کافی است. + */ +function historicalActivity(cutoff: Date): Prisma.ActivityWhereInput { + return { + OR: [ + { importSource: PRIOR_IMPORT_SOURCE }, + { + createdAt: { lt: cutoff }, + OR: [ + { + person: { + OR: [ + { importSource: PRIOR_IMPORT_SOURCE }, + { previousImportSource: PRIOR_IMPORT_SOURCE }, + ], + }, + }, + { company: { importSource: PRIOR_IMPORT_SOURCE } }, + { deal: { importSource: PRIOR_IMPORT_SOURCE } }, + ], + }, + ], + }; +} + export function personPriorWhere(lens: PriorLens | undefined): Prisma.PersonWhereInput { if (lens === 'archive') return { importSource: PRIOR_IMPORT_SOURCE }; if (lens === 'current') return { importSource: { not: PRIOR_IMPORT_SOURCE } }; @@ -50,14 +83,19 @@ export function dealPriorWhere(lens: PriorLens | undefined, now = new Date()): P export function activityPriorWhere( lens: PriorLens | undefined, now = new Date(), + cutoff?: Date | null, ): Prisma.ActivityWhereInput { - if (lens === 'current') { - return { OR: [{ importSource: { not: PRIOR_IMPORT_SOURCE } }, liveActivity(now)] }; - } - if (lens === 'archive') { + if (lens !== 'current' && lens !== 'archive') return {}; + if (!cutoff) { + if (lens === 'current') { + return { OR: [{ importSource: { not: PRIOR_IMPORT_SOURCE } }, liveActivity(now)] }; + } return { importSource: PRIOR_IMPORT_SOURCE, NOT: liveActivity(now) }; } - return {}; + const prior = historicalActivity(cutoff); + const live = stillOpenAhead(now); + if (lens === 'current') return { OR: [{ NOT: prior }, live] }; + return { AND: [prior, { NOT: live }] }; } /** فهرست شخص/شرکت: در کار جاری، جستجو و فیلتر صریح منبع، قبلی‌ها را هم پیدا می‌کند. */ diff --git a/apps/api/src/common/record-scope.ts b/apps/api/src/common/record-scope.ts index bc0dadd..6eb9022 100644 --- a/apps/api/src/common/record-scope.ts +++ b/apps/api/src/common/record-scope.ts @@ -22,6 +22,8 @@ export type Actor = { scopes?: Partial>; /** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */ priorLens?: 'off' | 'current' | 'archive'; + /** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */ + priorCutoff?: Date | null; }; export type ScopedActor = Pick; diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index 8c4095c..78d0c63 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -64,7 +64,7 @@ export class ReportsService { const now = new Date(); const peoplePrior = personPriorWhere(actor.priorLens); const dealsPrior = dealPriorWhere(actor.priorLens, now); - const activitiesPrior = activityPriorWhere(actor.priorLens, now); + const activitiesPrior = activityPriorWhere(actor.priorLens, now, actor.priorCutoff); const { jy, jm } = dateToJalali(now); const sixAgo = addJalaliMonths(jy, jm, -5); const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm); @@ -1553,7 +1553,7 @@ export class ReportsService { weekAgo.setDate(weekAgo.getDate() - 7); const peoplePrior = personPriorWhere(actor.priorLens); const dealsPrior = dealPriorWhere(actor.priorLens, now); - const activitiesPrior = activityPriorWhere(actor.priorLens, now); + const activitiesPrior = activityPriorWhere(actor.priorLens, now, actor.priorCutoff); const [ wonDeals, diff --git a/apps/api/test/prior-archive.test.ts b/apps/api/test/prior-archive.test.ts index 688f4c3..f595fc0 100644 --- a/apps/api/test/prior-archive.test.ts +++ b/apps/api/test/prior-archive.test.ts @@ -36,6 +36,42 @@ describe('بایگانی اطلاعات قبل', () => { }); }); + it('فعالیت قدیمی روی شخص قبلی از کار جاری کنار می‌رود', () => { + const cutoff = new Date('2026-10-01T00:00:00.000Z'); + const current = JSON.parse(JSON.stringify(activityPriorWhere('current', now, cutoff))) as { + OR: [ + { + NOT: { + OR: [ + { importSource: string }, + { + createdAt: { lt: string }; + OR: Array<{ person?: { OR: Array> } }>; + }, + ]; + }; + }, + { status: string; dueAt: { gt: string } }, + ]; + }; + const linked = current.OR[0].NOT.OR[1]; + assert.equal(linked.createdAt.lt, cutoff.toISOString()); + assert.deepEqual(linked.OR[0], { + person: { + OR: [{ importSource: 'didar' }, { previousImportSource: 'didar' }], + }, + }); + assert.deepEqual(current.OR[1], { status: 'open', dueAt: { gt: now.toISOString() } }); + + const archive = JSON.parse(JSON.stringify(activityPriorWhere('archive', now, cutoff))) as { + AND: [unknown, { NOT: { status: string; dueAt: { gt: string } } }]; + }; + assert.deepEqual(archive.AND[0], current.OR[0].NOT); + assert.deepEqual(archive.AND[1], { + NOT: { status: 'open', dueAt: { gt: now.toISOString() } }, + }); + }); + it('جستجوی کار جاری شخص قبلی را پیدا می‌کند', () => { assert.equal(listPriorLens('current', 'علی', false), 'off'); assert.equal(listPriorLens('current', ' ', false), 'current'); diff --git a/docs/superpowers/specs/2026-10-14-prior-data-archive-design.md b/docs/superpowers/specs/2026-10-14-prior-data-archive-design.md index d65df09..ae41a9d 100644 --- a/docs/superpowers/specs/2026-10-14-prior-data-archive-design.md +++ b/docs/superpowers/specs/2026-10-14-prior-data-archive-design.md @@ -26,7 +26,7 @@ رکورد قبلی یعنی: - شخص، شرکت یا معامله‌ای که منبع ورودش `didar` است. -- فعالیتی که منبع ورودش `didar` است. فعالیت امروز این فیلد را ندارد؛ در پیاده‌سازی همان فیلد با پیش‌فرض `manual` اضافه می‌شود. پر کردن اولیه: اگر معاملهٔ مرتبط `didar` باشد، یا معامله نداشته باشد و شخص یا شرکت مرتبط `didar` باشد و تاریخ ساخت فعالیت قبل از لحظهٔ روشن شدن ماژول برای آن محیط باشد. فعالیتی که بعد از آن لحظه در منتکت ساخته شود جاری است، حتی اگر روی شخص قبلی باشد. +- فعالیتی که منبع ورودش `didar` است. فعالیت امروز این فیلد را ندارد؛ در پیاده‌سازی همان فیلد با پیش‌فرض `manual` اضافه می‌شود. پر کردن اولیه: اگر معاملهٔ مرتبط `didar` باشد، یا تاریخ ساخت فعالیت قبل از لحظهٔ روشن شدن ماژول باشد و شخص (حتی با منبع قبلی `didar`)، شرکت یا معاملهٔ مرتبط `didar` باشد. وجود معاملهٔ غیرقبلی این را لغو نمی‌کند. فعالیتی که بعد از آن لحظه در منتکت ساخته شود جاری است، حتی اگر روی شخص قبلی باشد. استثنا فقط در حالت کار جاری: معامله یا فعالیت قبلی که هنوز باز است و موعدش در آینده است، در کار جاری می‌آید و در بایگانی نمی‌آید. معامله یا فعالیت باز بدون موعد، یا با موعد گذشته، بایگانی می‌ماند. با بسته شدن یا گذشتن موعد، از کار جاری خارج و فقط در بایگانی دیده می‌شود.