feat: separate prior records behind an owner switch and count last-period questions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-15 10:48:30 +03:30
co-authored by Cursor
parent 3a8a684aa4
commit 6bc5e1bec7
29 changed files with 775 additions and 77 deletions
+52 -21
View File
@@ -28,6 +28,7 @@ import { normalizeTags } from '../common/tags';
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 { ActivityStagesService, type StageDefaults } from './activity-stages.service';
import {
ActivityTypesService,
@@ -37,7 +38,7 @@ import {
type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens'>;
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
@@ -63,6 +64,7 @@ type ActivityRow = {
reminderNotifySms: boolean;
reminderSentAt: Date | null;
completedAt: Date | null;
importSource?: string;
tags: string[];
createdAt: Date;
updatedAt: Date;
@@ -117,31 +119,47 @@ export class ActivitiesService {
): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds();
const scope = activityAccessWhere(actor);
const prior = activityPriorWhere(actor.priorLens);
const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
prior,
],
},
}),
this.prisma.activity.count({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
},
prior,
],
},
}),
]);
const [today, overdue, upcoming] = await Promise.all([
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
prior,
],
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
@@ -149,10 +167,15 @@ export class ActivitiesService {
}),
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
AND: [
{
organizationId,
...scope,
status: 'open',
dueAt: { lt: start },
},
prior,
],
},
include,
orderBy: [{ dueAt: 'asc' }],
@@ -160,10 +183,15 @@ export class ActivitiesService {
}),
this.prisma.activity.findMany({
where: {
organizationId,
...scope,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
AND: [
{
organizationId,
...scope,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
},
prior,
],
},
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
@@ -671,6 +699,8 @@ export class ActivitiesService {
...this.types.searchByName(typeRows, t),
]);
if (search) andParts.push(search);
const onRecord = Boolean(entity?.personId || entity?.companyId || entity?.dealId);
if (!onRecord) andParts.push(activityPriorWhere(actor.priorLens));
const base: Prisma.ActivityWhereInput = {
organizationId,
AND: andParts,
@@ -868,6 +898,7 @@ export class ActivitiesService {
reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
importSource: row.importSource ?? 'manual',
tags: row.tags ?? [],
person: row.person
? {
+91 -24
View File
@@ -32,13 +32,26 @@ import {
type PersonImportSource,
type UserRole,
} from '@mento/shared';
import { formatTehranJalali, rewriteGregorianDates, startOfJalaliMonth, toJalali } from '../common/jalali';
import {
addJalaliMonths,
formatTehranJalali,
rewriteGregorianDates,
startOfJalaliMonth,
toGregorian,
toJalali,
} from '../common/jalali';
import { AiService } from './ai.service';
import {
classifyCountQuestion,
personSourceKeys,
type CountIntent,
} from './ask-count';
import {
activityPriorWhere,
companyPriorWhere,
dealPriorWhere,
personPriorWhere,
} from '../common/prior-archive';
type CatalogItem = AskMentoSourceDto;
@@ -51,6 +64,10 @@ type PeriodCounts = {
month: number;
};
function gregorianKey(gy: number, gm: number, gd: number) {
return `${gy}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`;
}
function clip(value: string | null | undefined, max = 280) {
const text = (value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return '';
@@ -116,6 +133,14 @@ function tehranPeriods(now = new Date()) {
start: dayBoundsFromKey(weekStartKey).start,
end: dayBoundsFromKey(weekEndKey).end,
};
const lastWeekStartKey = addDaysToDayKey(weekStartKey, -7);
const lastWeekEndKey = addDaysToDayKey(weekStartKey, -1);
const lastWeek: PeriodRange = {
key: `${lastWeekStartKey}..${lastWeekEndKey}`,
label: 'last_week',
start: dayBoundsFromKey(lastWeekStartKey).start,
end: dayBoundsFromKey(lastWeekEndKey).end,
};
const { jy, jm } = (() => {
const [gy, gm, gd] = dayKey.split('-').map(Number);
return toJalali(gy!, gm!, gd!);
@@ -127,7 +152,21 @@ function tehranPeriods(now = new Date()) {
start: monthStart,
end: now,
};
return { now, dayKey, today, yesterday, week, month };
const prev = addJalaliMonths(jy, jm, -1);
const prevStart = toGregorian(prev.jy, prev.jm, 1);
const nextAfterPrev = addJalaliMonths(prev.jy, prev.jm, 1);
const nextStart = toGregorian(nextAfterPrev.jy, nextAfterPrev.jm, 1);
const lastMonthEndKey = addDaysToDayKey(
gregorianKey(nextStart.gy, nextStart.gm, nextStart.gd),
-1,
);
const lastMonth: PeriodRange = {
key: `${prev.jy}-${String(prev.jm).padStart(2, '0')}`,
label: 'last_jalali_month',
start: dayBoundsFromKey(gregorianKey(prevStart.gy, prevStart.gm, prevStart.gd)).start,
end: dayBoundsFromKey(lastMonthEndKey).end,
};
return { now, dayKey, today, yesterday, week, lastWeek, month, lastMonth };
}
function formatPeriodCounts(labelFa: string, counts: PeriodCounts) {
@@ -163,7 +202,7 @@ export class AiAskService {
"مشتری" means a Person record. "لید" means a Lead record. They are different. Never answer a lead question with the people/customer total.
Person and company "by source" lines are authoritative. If the user names a source (دستی، اکسل، دیدار، ته‌حساب، گوشی، فرم، مونتیفای), use that source count only, never the grand total.
Lead stage and lead source lines are authoritative the same way.
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month).
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), last_week (the previous Saturday–Friday), this_month (current Jalali month), last_month (the previous Jalali month).
Every date you write must be Shamsi/Jalali, copied from jalali dates in the context, like ۱۴۰۴/۰۷/۲۱. Never use Gregorian month names (ژانویه، سپتامبر، September) or ISO dates.
Workspace facts and Period stats are complete and authoritative for counts.
You cannot create, update, or delete records. Never say a customer, lead, or activity was created.
@@ -240,6 +279,8 @@ Rules:
if (key === 'today') return periods.today;
if (key === 'yesterday') return periods.yesterday;
if (key === 'week') return periods.week;
if (key === 'last_week') return periods.lastWeek;
if (key === 'last_month') return periods.lastMonth;
return periods.month;
}
@@ -261,24 +302,38 @@ Rules:
? 'امروز'
: intent.period === 'yesterday'
? 'دیروز'
: intent.period === 'week'
? 'این هفته'
: intent.period === 'month'
? 'این ماه'
: intent.period === 'week'
? 'این هفته'
: intent.period === 'last_week'
? 'هفته گذشته'
: intent.period === 'month'
? 'این ماه'
: intent.period === 'last_month'
? 'ماه گذشته'
: '';
if (intent.entity === 'person' || intent.entity === 'company') {
const where = {
const shared = {
organizationId: orgId,
...(intent.entity === 'person' ? ownerWhere(actor) : ownerWhere(actor)),
...ownerWhere(actor),
...(intent.personSource ? { importSource: intent.personSource } : {}),
...(intent.entity === 'person' && intent.mood ? { mood: intent.mood } : {}),
...created,
};
const n =
intent.entity === 'person'
? await this.prisma.person.count({ where })
: await this.prisma.company.count({ where });
? await this.prisma.person.count({
where: {
...shared,
...(intent.mood ? { mood: intent.mood } : {}),
...(intent.personSource ? {} : personPriorWhere(actor.priorLens)),
},
})
: await this.prisma.company.count({
where: {
...shared,
...(intent.personSource ? {} : companyPriorWhere(actor.priorLens)),
},
});
const who = intent.entity === 'person' ? 'مشتری' : 'شرکت';
const source = intent.personSource
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
@@ -305,15 +360,20 @@ Rules:
if (intent.entity === 'deal') {
const scope = dealAccessWhere(actor);
const where = {
organizationId: orgId,
...scope,
...created,
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}),
...(intent.dealBucket === 'stale'
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] }
: {}),
AND: [
{
organizationId: orgId,
...scope,
...created,
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}),
...(intent.dealBucket === 'stale'
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] }
: {}),
},
dealPriorWhere(actor.priorLens),
],
};
const n = await this.prisma.deal.count({ where });
const name =
@@ -342,7 +402,12 @@ Rules:
this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
activity: () =>
this.prisma.activity.count({
where: { organizationId: orgId, ...assigneeWhere(actor), ...created },
where: {
organizationId: orgId,
...assigneeWhere(actor),
...created,
...activityPriorWhere(actor.priorLens),
},
}),
ticket: () =>
this.prisma.ticket.count({
@@ -432,8 +497,10 @@ Rules:
}),
this.prisma.lead.count({ where: base }),
]);
const when = periodNote ? ` ${periodNote}` : '';
return `لیدهای باز${when}: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`;
if (periodNote) {
return `لیدهای ${periodNote}: ${this.fa(total)} تا. ${this.fa(open)} تا هنوز باز، ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`;
}
return `لیدهای باز: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`;
}
const where = {
+15 -4
View File
@@ -19,7 +19,7 @@ export type CountEntity =
| 'routine'
| 'project';
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'month';
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'last_week' | 'month' | 'last_month';
export type LeadBucket = 'summary' | 'open' | 'archived' | 'converted';
export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale';
@@ -51,6 +51,7 @@ export function normalizeAskText(raw: string) {
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/‌/g, ' ')
.replace(/[؟?!.,،؛:]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
@@ -64,7 +65,7 @@ function detectEntity(q: string): CountEntity | null {
if (/لید|سرنخ/.test(q)) return 'lead';
if (/پیش\s*فاکتور/.test(q)) return 'quote';
if (/فاکتور/.test(q)) return 'invoice';
if (/معامله/.test(q)) return 'deal';
if (/معاملات|معامله/.test(q)) return 'deal';
if (/چک/.test(q)) return 'cheque';
if (/رزرو|نوبت/.test(q)) return 'reservation';
if (/روتین/.test(q)) return 'routine';
@@ -81,7 +82,9 @@ function detectEntity(q: string): CountEntity | null {
function detectPeriod(q: string): CountPeriod | undefined {
if (/دیروز/.test(q)) return 'yesterday';
if (/امروز/.test(q)) return 'today';
if (/هفته\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_week';
if (/این\s*هفته|هفته\s*جاری/.test(q)) return 'week';
if (/ماه\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_month';
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
return undefined;
}
@@ -152,6 +155,9 @@ const HINT_STOP = [
'مرحله',
'وضعیت',
'هفته',
'گذشته',
'پیش',
'قبل',
'لطفا',
'الان',
'شده',
@@ -219,6 +225,11 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
const entity = detectEntity(q);
if (!entity) return null;
const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source;
const period = detectPeriod(q);
let hint = leftoverHint(q, entity);
if (period && entity === 'lead' && !/مرحله|وضعیت|ستون/.test(q)) {
hint = hint.replace(/(^|\s)جدید(?=\s|$)/g, ' ').replace(/\s+/g, ' ').trim();
}
return {
entity,
personSource:
@@ -226,8 +237,8 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
mood: entity === 'person' ? detectMood(q) : undefined,
leadBucket: detectLeadBucket(q),
dealBucket: detectDealBucket(q),
period: detectPeriod(q),
hint: leftoverHint(q, entity),
period,
hint,
};
}
+13
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -92,6 +93,18 @@ export class AuthController {
return this.authService.updateMe(user.userId, user.organizationId, dto);
}
@UseGuards(JwtAuthGuard)
@Patch('me/prior-archive')
setPriorLens(
@CurrentUser() user: AuthActor,
@Body() body: { lens?: string },
) {
if (body.lens !== 'archive' && body.lens !== 'current') {
throw new BadRequestException('حالت بایگانی نامعتبر است');
}
return this.authService.setPriorLens(user.userId, user.organizationId, body.lens);
}
/** عکس پروفایل کاربر جاری */
@UseGuards(JwtAuthGuard)
@Get('me/avatar')
+40
View File
@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
@@ -412,6 +413,29 @@ export class AuthService {
return this.toAuthUserDto(user, organizationId);
}
async setPriorLens(
userId: string,
organizationId: string,
lens: 'current' | 'archive',
): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
include: { organization: { select: { enabledModules: true } } },
});
if (!membership?.priorArchiveEnabled) {
throw new ForbiddenException('بایگانی اطلاعات قبل برای شما فعال نیست');
}
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
if (!modules.includes('prior_data_archive')) {
throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست');
}
await this.prisma.membership.update({
where: { id: membership.id },
data: { priorArchiveLens: lens },
});
return this.me(userId, organizationId);
}
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
@@ -653,9 +677,23 @@ export class AuthService {
),
subscription: sub,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
});
}
private priorArchiveFor(
membership: {
priorArchiveEnabled?: boolean;
priorArchiveLens?: string;
organization?: { enabledModules?: string[] };
} | null,
): AuthUserDto['priorArchive'] {
if (!membership?.priorArchiveEnabled) return undefined;
const modules = this.billing.normalizeModules(membership.organization?.enabledModules);
if (!modules.includes('prior_data_archive')) return undefined;
return { lens: membership.priorArchiveLens === 'archive' ? 'archive' : 'current' };
}
private async throttleChallenges(
kind: string,
email: string | null | undefined,
@@ -740,6 +778,7 @@ export class AuthService {
enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null;
isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive'];
}): AuthUserDto {
return {
id: user.id,
@@ -753,6 +792,7 @@ export class AuthService {
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
...(user.subscription ? { subscription: user.subscription } : {}),
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
};
}
}
+9
View File
@@ -62,6 +62,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
role: true,
dataVisibility: true,
dataVisibilityScopes: true,
priorArchiveEnabled: true,
priorArchiveLens: true,
organization: { select: { enabledModules: true } },
},
});
if (!membership) {
@@ -91,6 +94,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
}
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
const priorGranted =
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
const priorLens = membership.priorArchiveLens === 'archive' ? 'archive' : 'current';
return {
userId: user.id,
organizationId: membership.organizationId,
@@ -98,6 +106,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
dataVisibility,
visibleUserIds,
scopes,
priorLens: priorGranted ? priorLens : 'off',
};
}
+47 -1
View File
@@ -4,6 +4,7 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
ApplyBillingPlanRequest,
BillingAccountDto,
@@ -251,13 +252,58 @@ export class BillingService {
modules.filter(isOrgModuleKey),
),
] as OrgModuleKey[];
const prev = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { priorArchiveEnabledAt: true },
});
const enablePrior = cleaned.includes('prior_data_archive');
const cutoff = prev?.priorArchiveEnabledAt ?? new Date();
await this.prisma.organization.update({
where: { id: organizationId },
data: { enabledModules: cleaned },
data: {
enabledModules: cleaned,
...(enablePrior && !prev?.priorArchiveEnabledAt
? { priorArchiveEnabledAt: cutoff }
: {}),
},
});
if (enablePrior) await this.backfillPriorActivities(organizationId, cutoff);
return cleaned;
}
/** فعالیت‌های منتقل‌شده را یک‌بار (و تکرار بی‌اثر) به‌عنوان اطلاعات قبل علامت می‌زند. */
private async backfillPriorActivities(organizationId: string, cutoff: Date) {
await this.prisma.$executeRaw(Prisma.sql`
UPDATE "Activity" AS a
SET "importSource" = 'didar'
WHERE a."organizationId" = ${organizationId}
AND a."importSource" = 'manual'
AND (
(
a."dealId" IS NOT NULL
AND EXISTS (
SELECT 1 FROM "Deal" AS d
WHERE d."id" = a."dealId" AND d."importSource" = 'didar'
)
)
OR (
a."dealId" IS NULL
AND a."createdAt" < ${cutoff}
AND (
EXISTS (
SELECT 1 FROM "Person" AS p
WHERE p."id" = a."personId" AND p."importSource" = 'didar'
)
OR EXISTS (
SELECT 1 FROM "Company" AS c
WHERE c."id" = a."companyId" AND c."importSource" = 'didar'
)
)
)
)
`);
}
normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
if (!raw?.length) return defaultEnabledModules();
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[];
+72
View File
@@ -0,0 +1,72 @@
import type { Prisma } from '@prisma/client';
import type { Actor } from './record-scope';
export const PRIOR_IMPORT_SOURCE = 'didar';
export type PriorLens = 'off' | 'current' | 'archive';
export function actorPriorLens(actor: Pick<Actor, 'priorLens'> | undefined): PriorLens {
return actor?.priorLens ?? 'off';
}
function liveDeal(now: Date): Prisma.DealWhereInput {
return {
importSource: PRIOR_IMPORT_SOURCE,
status: 'open',
expectedCloseAt: { gt: now },
};
}
function liveActivity(now: Date): Prisma.ActivityWhereInput {
return {
importSource: PRIOR_IMPORT_SOURCE,
status: 'open',
dueAt: { gt: now },
};
}
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 } };
return {};
}
export function companyPriorWhere(lens: PriorLens | undefined): Prisma.CompanyWhereInput {
if (lens === 'archive') return { importSource: PRIOR_IMPORT_SOURCE };
if (lens === 'current') return { importSource: { not: PRIOR_IMPORT_SOURCE } };
return {};
}
export function dealPriorWhere(lens: PriorLens | undefined, now = new Date()): Prisma.DealWhereInput {
if (lens === 'current') {
return { OR: [{ importSource: { not: PRIOR_IMPORT_SOURCE } }, liveDeal(now)] };
}
if (lens === 'archive') {
return { importSource: PRIOR_IMPORT_SOURCE, NOT: liveDeal(now) };
}
return {};
}
export function activityPriorWhere(
lens: PriorLens | undefined,
now = new Date(),
): Prisma.ActivityWhereInput {
if (lens === 'current') {
return { OR: [{ importSource: { not: PRIOR_IMPORT_SOURCE } }, liveActivity(now)] };
}
if (lens === 'archive') {
return { importSource: PRIOR_IMPORT_SOURCE, NOT: liveActivity(now) };
}
return {};
}
/** فهرست شخص/شرکت: در کار جاری، جستجو و فیلتر صریح منبع، قبلی‌ها را هم پیدا می‌کند. */
export function listPriorLens(
lens: PriorLens | undefined,
q: string | undefined,
explicitSource: boolean,
): PriorLens {
if (explicitSource) return 'off';
if (lens === 'current' && q?.trim()) return 'off';
return lens ?? 'off';
}
+2
View File
@@ -20,6 +20,8 @@ export type Actor = {
visibleUserIds?: string[] | null;
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
priorLens?: 'off' | 'current' | 'archive';
};
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
@@ -26,6 +26,7 @@ import {
import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source';
import { buildSearchWhere, ci } from '../common/search';
import { companyPriorWhere, listPriorLens } from '../common/prior-archive';
import { GamificationService } from '../gamification/gamification.service';
function parseDateOrNull(raw?: string | null): Date | null {
@@ -97,6 +98,7 @@ export class CompaniesService {
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...companyPriorWhere(listPriorLens(actor.priorLens, q, false)),
...(search ? { AND: [search] } : {}),
};
+2
View File
@@ -43,6 +43,7 @@ import { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search';
import { listPriorLens, personPriorWhere } from '../common/prior-archive';
import {
normalizePersonImportSource,
personImportSourceLabel,
@@ -137,6 +138,7 @@ export class PeopleService implements OnModuleInit {
...ownerWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}),
...(sourceFilter ? { importSource: sourceFilter } : {}),
...personPriorWhere(listPriorLens(actor.priorLens, q, Boolean(sourceFilter))),
...(search ? { AND: [search] } : {}),
};
@@ -12,6 +12,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { dealAccessWhere, resolveOwnerId } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize';
import {
@@ -110,7 +111,11 @@ export class DealsExcelService {
async export(actor: Actor): Promise<Buffer> {
const [rows, defs] = await Promise.all([
this.prisma.deal.findMany({
where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) },
where: {
organizationId: actor.organizationId,
...dealAccessWhere(actor),
...dealPriorWhere(actor.priorLens),
},
include: {
person: {
select: {
@@ -37,8 +37,9 @@ export class PipelinesController {
board(
@CurrentUser() user: Actor,
@Query('pipelineId') pipelineId?: string,
@Query('prior') prior?: string,
) {
return this.pipelinesService.getBoard(user.organizationId, pipelineId, user);
return this.pipelinesService.getBoard(user.organizationId, pipelineId, user, prior === 'all');
}
@Post()
+5 -2
View File
@@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { asCustomFields } from '../common/custom-fields';
import { type Actor, dealAccessWhere } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive';
import { decimalToBase, loadOrgRates } from '../common/currency';
import { assertStageMap, dealStatusForStage } from './pipeline-merge.util';
@@ -294,8 +295,9 @@ export class PipelinesService {
pipelineId?: string,
actor?: Pick<
Actor,
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'priorLens'
>,
includePrior = false,
): Promise<PipelineBoardDto> {
const pipeline = pipelineId
? await this.prisma.pipeline.findFirst({
@@ -313,7 +315,7 @@ export class PipelinesService {
if (!resolved) {
const created = await this.ensureDefaultPipeline(organizationId);
return this.getBoard(organizationId, created.id, actor);
return this.getBoard(organizationId, created.id, actor, includePrior);
}
const deals = await this.prisma.deal.findMany({
@@ -321,6 +323,7 @@ export class PipelinesService {
organizationId,
pipelineId: resolved.id,
...(actor ? dealAccessWhere(actor) : {}),
...(includePrior ? {} : dealPriorWhere(actor?.priorLens)),
},
include: {
person: { select: { id: true, firstName: true, lastName: true } },
+45 -15
View File
@@ -26,6 +26,11 @@ import {
ticketAccessWhere,
} from '../common/record-scope';
import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency';
import {
activityPriorWhere,
dealPriorWhere,
personPriorWhere,
} from '../common/prior-archive';
import {
addJalaliMonths,
dateToJalali,
@@ -57,6 +62,9 @@ export class ReportsService {
const prevStart = new Date(prevEnd.getTime() - durationMs);
const now = new Date();
const peoplePrior = personPriorWhere(actor.priorLens);
const dealsPrior = dealPriorWhere(actor.priorLens, now);
const activitiesPrior = activityPriorWhere(actor.priorLens, now);
const { jy, jm } = dateToJalali(now);
const sixAgo = addJalaliMonths(jy, jm, -5);
const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm);
@@ -76,15 +84,20 @@ export class ReportsService {
await Promise.all([
this.prisma.deal.findMany({
where: {
organizationId: orgId,
...dealScope,
OR: [
{ status: 'open' },
AND: [
{
status: { in: ['won', 'lost'] },
updatedAt: { gte: dealEarliest },
organizationId: orgId,
...dealScope,
OR: [
{ status: 'open' },
{
status: { in: ['won', 'lost'] },
updatedAt: { gte: dealEarliest },
},
{ createdAt: { gte: dealEarliest } },
],
},
{ createdAt: { gte: dealEarliest } },
dealsPrior,
],
},
select: {
@@ -118,6 +131,7 @@ export class ReportsService {
where: {
organizationId: orgId,
...activityScope,
...activitiesPrior,
createdAt: { gte: prevStart, lte: end },
},
select: {
@@ -146,6 +160,7 @@ export class ReportsService {
where: {
organizationId: orgId,
...personScope,
...peoplePrior,
},
select: { mood: true, createdAt: true },
}),
@@ -1536,6 +1551,9 @@ export class ReportsService {
const yesterdayEnd = endOfDay(yesterday);
const weekAgo = new Date(now);
weekAgo.setDate(weekAgo.getDate() - 7);
const peoplePrior = personPriorWhere(actor.priorLens);
const dealsPrior = dealPriorWhere(actor.priorLens, now);
const activitiesPrior = activityPriorWhere(actor.priorLens, now);
const [
wonDeals,
@@ -1554,6 +1572,7 @@ export class ReportsService {
organizationId: orgId,
status: 'won',
...dealScope,
...dealsPrior,
updatedAt: { gte: addMonthsStart(now, -5) },
},
select: {
@@ -1568,17 +1587,22 @@ export class ReportsService {
},
}),
this.prisma.deal.count({
where: { organizationId: orgId, status: 'open', ...dealScope },
where: { organizationId: orgId, status: 'open', ...dealScope, ...dealsPrior },
}),
this.prisma.deal.findMany({
where: {
organizationId: orgId,
status: 'open',
...dealScope,
OR: [
{ isStale: true },
{ isRotten: true },
{ updatedAt: { lt: daysAgo(5) } },
AND: [
{
organizationId: orgId,
status: 'open',
...dealScope,
OR: [
{ isStale: true },
{ isRotten: true },
{ updatedAt: { lt: daysAgo(5) } },
],
},
dealsPrior,
],
},
select: {
@@ -1594,6 +1618,7 @@ export class ReportsService {
where: {
organizationId: orgId,
...personScope,
...peoplePrior,
createdAt: { gte: curMonthStart, lte: curMonthEnd },
},
}),
@@ -1601,6 +1626,7 @@ export class ReportsService {
where: {
organizationId: orgId,
...personScope,
...peoplePrior,
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
},
}),
@@ -1608,6 +1634,7 @@ export class ReportsService {
where: {
organizationId: orgId,
...activityScope,
...activitiesPrior,
status: 'open',
dueAt: { gte: todayStart, lte: todayEnd },
},
@@ -1616,6 +1643,7 @@ export class ReportsService {
where: {
organizationId: orgId,
...activityScope,
...activitiesPrior,
dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
},
}),
@@ -1624,6 +1652,7 @@ export class ReportsService {
organizationId: orgId,
status: 'open',
...dealScope,
...dealsPrior,
updatedAt: { lte: weekAgo },
},
}),
@@ -1639,6 +1668,7 @@ export class ReportsService {
where: {
organizationId: orgId,
...activityScope,
...activitiesPrior,
status: 'open',
dueAt: { lt: now },
},
+15
View File
@@ -96,6 +96,21 @@ export class UsersController {
);
}
@Patch(':id/prior-archive')
@Roles('owner')
updatePriorArchive(
@CurrentUser() user: { organizationId: string; role: UserRole },
@Param('id') id: string,
@Body() body: { enabled?: boolean },
) {
return this.usersService.updatePriorArchive(
user.organizationId,
user.role,
id,
body.enabled === true,
);
}
@Patch(':id/visibility')
@Roles('owner', 'admin')
updateVisibility(
+28
View File
@@ -44,6 +44,7 @@ type MembershipRow = {
commissionPercent: { toString(): string } | number | null;
dataVisibility?: string | null;
dataVisibilityScopes?: unknown;
priorArchiveEnabled?: boolean;
createdAt: Date;
orgRole: { id: string; name: string; color: string } | null;
user: {
@@ -241,6 +242,32 @@ export class UsersService {
return this.dtoOf(organizationId, targetId);
}
async updatePriorArchive(
organizationId: string,
actorRole: UserRole,
targetId: string,
enabled: boolean,
): Promise<OrgUserDto> {
if (actorRole !== 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند بایگانی اطلاعات قبل را برای کاربران روشن کند');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (enabled) {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { enabledModules: true },
});
if (!org?.enabledModules?.includes('prior_data_archive')) {
throw new ForbiddenException('بایگانی اطلاعات قبل برای این محیط فعال نیست');
}
}
await this.prisma.membership.update({
where: { id: target.id },
data: { priorArchiveEnabled: enabled },
});
return this.dtoOf(organizationId, targetId);
}
async updateVisibility(
organizationId: string,
actorRole: UserRole,
@@ -478,6 +505,7 @@ export class UsersService {
row.role === 'owner' || row.role === 'admin'
? {}
: parseVisibilityScopes(row.dataVisibilityScopes),
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
createdAt: row.createdAt.toISOString(),
};
}