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:
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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] } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user