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
+1 -1
View File
@@ -12,7 +12,7 @@
"prisma:seed": "tsx prisma/seed.ts", "prisma:seed": "tsx prisma/seed.ts",
"lint": "echo \"api lint placeholder\"", "lint": "echo \"api lint placeholder\"",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p test/tsconfig.json",
"test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts" "test": "tsx --test test/tahesab-methods.test.ts test/dates.test.ts test/birthdays.test.ts test/message-automation.test.ts test/record-scope.test.ts test/gamification-engine.test.ts test/automation-branches.test.ts test/pipeline-merge.test.ts test/ask-count.test.ts test/prior-archive.test.ts"
}, },
"dependencies": { "dependencies": {
"@mento/shared": "*", "@mento/shared": "*",
@@ -0,0 +1,19 @@
-- بایگانی اطلاعات قبل: مرز محیط، مجوز عضو، و منبع فعالیت
ALTER TABLE "Organization" ADD COLUMN "priorArchiveEnabledAt" TIMESTAMP(3);
ALTER TABLE "Membership" ADD COLUMN "priorArchiveEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Membership" ADD COLUMN "priorArchiveLens" TEXT NOT NULL DEFAULT 'current';
ALTER TABLE "Activity" ADD COLUMN "importSource" TEXT NOT NULL DEFAULT 'manual';
CREATE INDEX "Activity_organizationId_importSource_idx" ON "Activity"("organizationId", "importSource");
-- فعالیت وصل به معاملهٔ دیدار از همین حالا قبلی است. بقیه بعد از روشن شدن ماژول و با تاریخ مرز پر می‌شوند.
UPDATE "Activity" AS a
SET "importSource" = 'didar'
WHERE 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'
);
+9
View File
@@ -150,6 +150,8 @@ model Organization {
enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط /// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([]) enabledModules String[] @default([])
/// اولین لحظهٔ روشن شدن «بایگانی اطلاعات قبل»؛ مرز فعالیت‌های قبلی
priorArchiveEnabledAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -348,6 +350,10 @@ model Membership {
dataVisibility String @default("own") dataVisibility String @default("own")
/// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخش‌های بدون مقدار = dataVisibility /// سطح دید تفکیکی هر بخش، مثلاً {"contacts":"org","deals":"own"}؛ بخش‌های بدون مقدار = dataVisibility
dataVisibilityScopes Json @default("{}") dataVisibilityScopes Json @default("{}")
/// سوئیچ «بایگانی اطلاعات قبل» برای این عضو؛ فقط وقتی ماژول محیط روشن است اثر دارد
priorArchiveEnabled Boolean @default(false)
/// current | archive
priorArchiveLens String @default("current")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -695,6 +701,8 @@ model Activity {
status ActivityStatus @default(open) status ActivityStatus @default(open)
priority ActivityPriority @default(medium) priority ActivityPriority @default(medium)
title String title String
/// manual | didar — سابقهٔ منتقل‌شده از اطلاعات قبل
importSource String @default("manual")
description String? description String?
dueAt DateTime? dueAt DateTime?
durationMinutes Int? durationMinutes Int?
@@ -741,6 +749,7 @@ model Activity {
@@index([typeDefId]) @@index([typeDefId])
@@index([leadId]) @@index([leadId])
@@index([organizationId, status, dueAt]) @@index([organizationId, status, dueAt])
@@index([organizationId, importSource])
@@index([organizationId, assigneeId]) @@index([organizationId, assigneeId])
@@index([organizationId, createdById]) @@index([organizationId, createdById])
@@index([reminderAt, reminderSentAt]) @@index([reminderAt, reminderSentAt])
+52 -21
View File
@@ -28,6 +28,7 @@ import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates'; import { tehranDayKey } from '../routines/routine-dates';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { activityPriorWhere } from '../common/prior-archive';
import { ActivityStagesService, type StageDefaults } from './activity-stages.service'; import { ActivityStagesService, type StageDefaults } from './activity-stages.service';
import { import {
ActivityTypesService, ActivityTypesService,
@@ -37,7 +38,7 @@ import {
type DtoContext = { stages?: StageDefaults; types?: SystemTypes }; type DtoContext = { stages?: StageDefaults; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role'>; type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens'>;
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */ /** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string }; type StageScope = { stageId: string };
@@ -63,6 +64,7 @@ type ActivityRow = {
reminderNotifySms: boolean; reminderNotifySms: boolean;
reminderSentAt: Date | null; reminderSentAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
importSource?: string;
tags: string[]; tags: string[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -117,31 +119,47 @@ export class ActivitiesService {
): Promise<ActivitySummaryDto> { ): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope = activityAccessWhere(actor); const scope = activityAccessWhere(actor);
const prior = activityPriorWhere(actor.priorLens);
const [todayCount, overdueCount] = await Promise.all([ const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({ this.prisma.activity.count({
where: { where: {
organizationId, AND: [
...scope, {
status: 'open', organizationId,
dueAt: { gte: start, lt: end }, ...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
prior,
],
}, },
}), }),
this.prisma.activity.count({ this.prisma.activity.count({
where: { where: {
organizationId, AND: [
...scope, {
status: 'open', organizationId,
dueAt: { lt: start }, ...scope,
status: 'open',
dueAt: { lt: start },
},
prior,
],
}, },
}), }),
]); ]);
const [today, overdue, upcoming] = await Promise.all([ const [today, overdue, upcoming] = await Promise.all([
this.prisma.activity.findMany({ this.prisma.activity.findMany({
where: { where: {
organizationId, AND: [
...scope, {
status: 'open', organizationId,
dueAt: { gte: start, lt: end }, ...scope,
status: 'open',
dueAt: { gte: start, lt: end },
},
prior,
],
}, },
include, include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
@@ -149,10 +167,15 @@ export class ActivitiesService {
}), }),
this.prisma.activity.findMany({ this.prisma.activity.findMany({
where: { where: {
organizationId, AND: [
...scope, {
status: 'open', organizationId,
dueAt: { lt: start }, ...scope,
status: 'open',
dueAt: { lt: start },
},
prior,
],
}, },
include, include,
orderBy: [{ dueAt: 'asc' }], orderBy: [{ dueAt: 'asc' }],
@@ -160,10 +183,15 @@ export class ActivitiesService {
}), }),
this.prisma.activity.findMany({ this.prisma.activity.findMany({
where: { where: {
organizationId, AND: [
...scope, {
status: 'open', organizationId,
OR: [{ dueAt: { gte: end } }, { dueAt: null }], ...scope,
status: 'open',
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
},
prior,
],
}, },
include, include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }], orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
@@ -671,6 +699,8 @@ export class ActivitiesService {
...this.types.searchByName(typeRows, t), ...this.types.searchByName(typeRows, t),
]); ]);
if (search) andParts.push(search); 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 = { const base: Prisma.ActivityWhereInput = {
organizationId, organizationId,
AND: andParts, AND: andParts,
@@ -868,6 +898,7 @@ export class ActivitiesService {
reminderNotifySms: row.reminderNotifySms, reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null, reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null,
importSource: row.importSource ?? 'manual',
tags: row.tags ?? [], tags: row.tags ?? [],
person: row.person person: row.person
? { ? {
+91 -24
View File
@@ -32,13 +32,26 @@ import {
type PersonImportSource, type PersonImportSource,
type UserRole, type UserRole,
} from '@mento/shared'; } 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 { AiService } from './ai.service';
import { import {
classifyCountQuestion, classifyCountQuestion,
personSourceKeys, personSourceKeys,
type CountIntent, type CountIntent,
} from './ask-count'; } from './ask-count';
import {
activityPriorWhere,
companyPriorWhere,
dealPriorWhere,
personPriorWhere,
} from '../common/prior-archive';
type CatalogItem = AskMentoSourceDto; type CatalogItem = AskMentoSourceDto;
@@ -51,6 +64,10 @@ type PeriodCounts = {
month: number; 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) { function clip(value: string | null | undefined, max = 280) {
const text = (value ?? '').replace(/\s+/g, ' ').trim(); const text = (value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return ''; if (!text) return '';
@@ -116,6 +133,14 @@ function tehranPeriods(now = new Date()) {
start: dayBoundsFromKey(weekStartKey).start, start: dayBoundsFromKey(weekStartKey).start,
end: dayBoundsFromKey(weekEndKey).end, 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 { jy, jm } = (() => {
const [gy, gm, gd] = dayKey.split('-').map(Number); const [gy, gm, gd] = dayKey.split('-').map(Number);
return toJalali(gy!, gm!, gd!); return toJalali(gy!, gm!, gd!);
@@ -127,7 +152,21 @@ function tehranPeriods(now = new Date()) {
start: monthStart, start: monthStart,
end: now, 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) { 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. "مشتری" 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. 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. 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. 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. 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. 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 === 'today') return periods.today;
if (key === 'yesterday') return periods.yesterday; if (key === 'yesterday') return periods.yesterday;
if (key === 'week') return periods.week; if (key === 'week') return periods.week;
if (key === 'last_week') return periods.lastWeek;
if (key === 'last_month') return periods.lastMonth;
return periods.month; return periods.month;
} }
@@ -261,24 +302,38 @@ Rules:
? 'امروز' ? 'امروز'
: intent.period === 'yesterday' : intent.period === 'yesterday'
? 'دیروز' ? 'دیروز'
: intent.period === 'week' : intent.period === 'week'
? 'این هفته' ? 'این هفته'
: intent.period === 'month' : intent.period === 'last_week'
? 'این ماه' ? 'هفته گذشته'
: intent.period === 'month'
? 'این ماه'
: intent.period === 'last_month'
? 'ماه گذشته'
: ''; : '';
if (intent.entity === 'person' || intent.entity === 'company') { if (intent.entity === 'person' || intent.entity === 'company') {
const where = { const shared = {
organizationId: orgId, organizationId: orgId,
...(intent.entity === 'person' ? ownerWhere(actor) : ownerWhere(actor)), ...ownerWhere(actor),
...(intent.personSource ? { importSource: intent.personSource } : {}), ...(intent.personSource ? { importSource: intent.personSource } : {}),
...(intent.entity === 'person' && intent.mood ? { mood: intent.mood } : {}),
...created, ...created,
}; };
const n = const n =
intent.entity === 'person' intent.entity === 'person'
? await this.prisma.person.count({ where }) ? await this.prisma.person.count({
: await this.prisma.company.count({ where }); 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 who = intent.entity === 'person' ? 'مشتری' : 'شرکت';
const source = intent.personSource const source = intent.personSource
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource] ? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
@@ -305,15 +360,20 @@ Rules:
if (intent.entity === 'deal') { if (intent.entity === 'deal') {
const scope = dealAccessWhere(actor); const scope = dealAccessWhere(actor);
const where = { const where = {
organizationId: orgId, AND: [
...scope, {
...created, organizationId: orgId,
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}), ...scope,
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}), ...created,
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}), ...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
...(intent.dealBucket === 'stale' ...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] } ...(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 n = await this.prisma.deal.count({ where });
const name = const name =
@@ -342,7 +402,12 @@ Rules:
this.prisma.product.count({ where: { organizationId: orgId, ...created } }), this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
activity: () => activity: () =>
this.prisma.activity.count({ this.prisma.activity.count({
where: { organizationId: orgId, ...assigneeWhere(actor), ...created }, where: {
organizationId: orgId,
...assigneeWhere(actor),
...created,
...activityPriorWhere(actor.priorLens),
},
}), }),
ticket: () => ticket: () =>
this.prisma.ticket.count({ this.prisma.ticket.count({
@@ -432,8 +497,10 @@ Rules:
}), }),
this.prisma.lead.count({ where: base }), this.prisma.lead.count({ where: base }),
]); ]);
const when = periodNote ? ` ${periodNote}` : ''; if (periodNote) {
return `لیدهای باز${when}: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`; 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 = { const where = {
+15 -4
View File
@@ -19,7 +19,7 @@ export type CountEntity =
| 'routine' | 'routine'
| 'project'; | '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 LeadBucket = 'summary' | 'open' | 'archived' | 'converted';
export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale'; 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(/‌/g, ' ') .replace(/‌/g, ' ')
.replace(/[؟?!.,،؛:]+/g, ' ')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim() .trim()
.toLowerCase(); .toLowerCase();
@@ -64,7 +65,7 @@ function detectEntity(q: string): CountEntity | null {
if (/لید|سرنخ/.test(q)) return 'lead'; if (/لید|سرنخ/.test(q)) return 'lead';
if (/پیش\s*فاکتور/.test(q)) return 'quote'; if (/پیش\s*فاکتور/.test(q)) return 'quote';
if (/فاکتور/.test(q)) return 'invoice'; if (/فاکتور/.test(q)) return 'invoice';
if (/معامله/.test(q)) return 'deal'; if (/معاملات|معامله/.test(q)) return 'deal';
if (/چک/.test(q)) return 'cheque'; if (/چک/.test(q)) return 'cheque';
if (/رزرو|نوبت/.test(q)) return 'reservation'; if (/رزرو|نوبت/.test(q)) return 'reservation';
if (/روتین/.test(q)) return 'routine'; if (/روتین/.test(q)) return 'routine';
@@ -81,7 +82,9 @@ function detectEntity(q: string): CountEntity | null {
function detectPeriod(q: string): CountPeriod | undefined { function detectPeriod(q: string): CountPeriod | undefined {
if (/دیروز/.test(q)) return 'yesterday'; if (/دیروز/.test(q)) return 'yesterday';
if (/امروز/.test(q)) return 'today'; 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 'week';
if (/ماه\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_month';
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month'; if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
return undefined; return undefined;
} }
@@ -152,6 +155,9 @@ const HINT_STOP = [
'مرحله', 'مرحله',
'وضعیت', 'وضعیت',
'هفته', 'هفته',
'گذشته',
'پیش',
'قبل',
'لطفا', 'لطفا',
'الان', 'الان',
'شده', 'شده',
@@ -219,6 +225,11 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
const entity = detectEntity(q); const entity = detectEntity(q);
if (!entity) return null; if (!entity) return null;
const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source; 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 { return {
entity, entity,
personSource: personSource:
@@ -226,8 +237,8 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
mood: entity === 'person' ? detectMood(q) : undefined, mood: entity === 'person' ? detectMood(q) : undefined,
leadBucket: detectLeadBucket(q), leadBucket: detectLeadBucket(q),
dealBucket: detectDealBucket(q), dealBucket: detectDealBucket(q),
period: detectPeriod(q), period,
hint: leftoverHint(q, entity), hint,
}; };
} }
+13
View File
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
@@ -92,6 +93,18 @@ export class AuthController {
return this.authService.updateMe(user.userId, user.organizationId, dto); 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) @UseGuards(JwtAuthGuard)
@Get('me/avatar') @Get('me/avatar')
+40
View File
@@ -1,6 +1,7 @@
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
ForbiddenException,
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
@@ -412,6 +413,29 @@ export class AuthService {
return this.toAuthUserDto(user, organizationId); 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> { async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
const fullName = dto.fullName.trim(); const fullName = dto.fullName.trim();
if (fullName.length < 2) { if (fullName.length < 2) {
@@ -653,9 +677,23 @@ export class AuthService {
), ),
subscription: sub, subscription: sub,
isOrgOwner: role === 'owner', 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( private async throttleChallenges(
kind: string, kind: string,
email: string | null | undefined, email: string | null | undefined,
@@ -740,6 +778,7 @@ export class AuthService {
enabledModules?: OrgModuleKey[]; enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null; subscription?: SubscriptionSummaryDto | null;
isOrgOwner?: boolean; isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive'];
}): AuthUserDto { }): AuthUserDto {
return { return {
id: user.id, id: user.id,
@@ -753,6 +792,7 @@ export class AuthService {
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}), ...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
...(user.subscription ? { subscription: user.subscription } : {}), ...(user.subscription ? { subscription: user.subscription } : {}),
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}), ...(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, role: true,
dataVisibility: true, dataVisibility: true,
dataVisibilityScopes: true, dataVisibilityScopes: true,
priorArchiveEnabled: true,
priorArchiveLens: true,
organization: { select: { enabledModules: true } },
}, },
}); });
if (!membership) { 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 { return {
userId: user.id, userId: user.id,
organizationId: membership.organizationId, organizationId: membership.organizationId,
@@ -98,6 +106,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
dataVisibility, dataVisibility,
visibleUserIds, visibleUserIds,
scopes, scopes,
priorLens: priorGranted ? priorLens : 'off',
}; };
} }
+47 -1
View File
@@ -4,6 +4,7 @@ import {
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { import type {
ApplyBillingPlanRequest, ApplyBillingPlanRequest,
BillingAccountDto, BillingAccountDto,
@@ -251,13 +252,58 @@ export class BillingService {
modules.filter(isOrgModuleKey), modules.filter(isOrgModuleKey),
), ),
] as OrgModuleKey[]; ] 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({ await this.prisma.organization.update({
where: { id: organizationId }, where: { id: organizationId },
data: { enabledModules: cleaned }, data: {
enabledModules: cleaned,
...(enablePrior && !prev?.priorArchiveEnabledAt
? { priorArchiveEnabledAt: cutoff }
: {}),
},
}); });
if (enablePrior) await this.backfillPriorActivities(organizationId, cutoff);
return cleaned; 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[] { normalizeModules(raw: string[] | null | undefined): OrgModuleKey[] {
if (!raw?.length) return defaultEnabledModules(); if (!raw?.length) return defaultEnabledModules();
const cleaned = raw.filter(isOrgModuleKey) as OrgModuleKey[]; 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; visibleUserIds?: string[] | null;
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */ /** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
scopes?: Partial<Record<DataScope, ScopeVisibility>>; scopes?: Partial<Record<DataScope, ScopeVisibility>>;
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
priorLens?: 'off' | 'current' | 'archive';
}; };
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>; export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
@@ -26,6 +26,7 @@ import {
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source'; import { normalizePersonImportSource } from './person-source';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { companyPriorWhere, listPriorLens } from '../common/prior-archive';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
function parseDateOrNull(raw?: string | null): Date | null { function parseDateOrNull(raw?: string | null): Date | null {
@@ -97,6 +98,7 @@ export class CompaniesService {
const where: Prisma.CompanyWhereInput = { const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId, organizationId: actor.organizationId,
...ownerWhere(actor), ...ownerWhere(actor),
...companyPriorWhere(listPriorLens(actor.priorLens, q, false)),
...(search ? { AND: [search] } : {}), ...(search ? { AND: [search] } : {}),
}; };
+2
View File
@@ -43,6 +43,7 @@ import { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text'; import { renderSmsText } from '../common/sms-text';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { listPriorLens, personPriorWhere } from '../common/prior-archive';
import { import {
normalizePersonImportSource, normalizePersonImportSource,
personImportSourceLabel, personImportSourceLabel,
@@ -137,6 +138,7 @@ export class PeopleService implements OnModuleInit {
...ownerWhere(actor), ...ownerWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}), ...(opts?.mood ? { mood: opts.mood } : {}),
...(sourceFilter ? { importSource: sourceFilter } : {}), ...(sourceFilter ? { importSource: sourceFilter } : {}),
...personPriorWhere(listPriorLens(actor.priorLens, q, Boolean(sourceFilter))),
...(search ? { AND: [search] } : {}), ...(search ? { AND: [search] } : {}),
}; };
@@ -12,6 +12,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { dealAccessWhere, resolveOwnerId } 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 { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import { normalizePhone } from '../contacts/normalize'; import { normalizePhone } from '../contacts/normalize';
import { import {
@@ -110,7 +111,11 @@ export class DealsExcelService {
async export(actor: Actor): Promise<Buffer> { async export(actor: Actor): Promise<Buffer> {
const [rows, defs] = await Promise.all([ const [rows, defs] = await Promise.all([
this.prisma.deal.findMany({ this.prisma.deal.findMany({
where: { organizationId: actor.organizationId, ...dealAccessWhere(actor) }, where: {
organizationId: actor.organizationId,
...dealAccessWhere(actor),
...dealPriorWhere(actor.priorLens),
},
include: { include: {
person: { person: {
select: { select: {
@@ -37,8 +37,9 @@ export class PipelinesController {
board( board(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@Query('pipelineId') pipelineId?: string, @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() @Post()
+5 -2
View File
@@ -15,6 +15,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { asCustomFields } from '../common/custom-fields'; import { asCustomFields } from '../common/custom-fields';
import { type Actor, dealAccessWhere } from '../common/record-scope'; import { type Actor, dealAccessWhere } from '../common/record-scope';
import { dealPriorWhere } from '../common/prior-archive';
import { decimalToBase, loadOrgRates } from '../common/currency'; import { decimalToBase, loadOrgRates } from '../common/currency';
import { assertStageMap, dealStatusForStage } from './pipeline-merge.util'; import { assertStageMap, dealStatusForStage } from './pipeline-merge.util';
@@ -294,8 +295,9 @@ export class PipelinesService {
pipelineId?: string, pipelineId?: string,
actor?: Pick< actor?: Pick<
Actor, Actor,
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'priorLens'
>, >,
includePrior = false,
): Promise<PipelineBoardDto> { ): Promise<PipelineBoardDto> {
const pipeline = pipelineId const pipeline = pipelineId
? await this.prisma.pipeline.findFirst({ ? await this.prisma.pipeline.findFirst({
@@ -313,7 +315,7 @@ export class PipelinesService {
if (!resolved) { if (!resolved) {
const created = await this.ensureDefaultPipeline(organizationId); 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({ const deals = await this.prisma.deal.findMany({
@@ -321,6 +323,7 @@ export class PipelinesService {
organizationId, organizationId,
pipelineId: resolved.id, pipelineId: resolved.id,
...(actor ? dealAccessWhere(actor) : {}), ...(actor ? dealAccessWhere(actor) : {}),
...(includePrior ? {} : dealPriorWhere(actor?.priorLens)),
}, },
include: { include: {
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
+45 -15
View File
@@ -26,6 +26,11 @@ import {
ticketAccessWhere, ticketAccessWhere,
} from '../common/record-scope'; } from '../common/record-scope';
import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency'; import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency';
import {
activityPriorWhere,
dealPriorWhere,
personPriorWhere,
} from '../common/prior-archive';
import { import {
addJalaliMonths, addJalaliMonths,
dateToJalali, dateToJalali,
@@ -57,6 +62,9 @@ export class ReportsService {
const prevStart = new Date(prevEnd.getTime() - durationMs); const prevStart = new Date(prevEnd.getTime() - durationMs);
const now = new Date(); 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 { jy, jm } = dateToJalali(now);
const sixAgo = addJalaliMonths(jy, jm, -5); const sixAgo = addJalaliMonths(jy, jm, -5);
const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm); const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm);
@@ -76,15 +84,20 @@ export class ReportsService {
await Promise.all([ await Promise.all([
this.prisma.deal.findMany({ this.prisma.deal.findMany({
where: { where: {
organizationId: orgId, AND: [
...dealScope,
OR: [
{ status: 'open' },
{ {
status: { in: ['won', 'lost'] }, organizationId: orgId,
updatedAt: { gte: dealEarliest }, ...dealScope,
OR: [
{ status: 'open' },
{
status: { in: ['won', 'lost'] },
updatedAt: { gte: dealEarliest },
},
{ createdAt: { gte: dealEarliest } },
],
}, },
{ createdAt: { gte: dealEarliest } }, dealsPrior,
], ],
}, },
select: { select: {
@@ -118,6 +131,7 @@ export class ReportsService {
where: { where: {
organizationId: orgId, organizationId: orgId,
...activityScope, ...activityScope,
...activitiesPrior,
createdAt: { gte: prevStart, lte: end }, createdAt: { gte: prevStart, lte: end },
}, },
select: { select: {
@@ -146,6 +160,7 @@ export class ReportsService {
where: { where: {
organizationId: orgId, organizationId: orgId,
...personScope, ...personScope,
...peoplePrior,
}, },
select: { mood: true, createdAt: true }, select: { mood: true, createdAt: true },
}), }),
@@ -1536,6 +1551,9 @@ export class ReportsService {
const yesterdayEnd = endOfDay(yesterday); const yesterdayEnd = endOfDay(yesterday);
const weekAgo = new Date(now); const weekAgo = new Date(now);
weekAgo.setDate(weekAgo.getDate() - 7); weekAgo.setDate(weekAgo.getDate() - 7);
const peoplePrior = personPriorWhere(actor.priorLens);
const dealsPrior = dealPriorWhere(actor.priorLens, now);
const activitiesPrior = activityPriorWhere(actor.priorLens, now);
const [ const [
wonDeals, wonDeals,
@@ -1554,6 +1572,7 @@ export class ReportsService {
organizationId: orgId, organizationId: orgId,
status: 'won', status: 'won',
...dealScope, ...dealScope,
...dealsPrior,
updatedAt: { gte: addMonthsStart(now, -5) }, updatedAt: { gte: addMonthsStart(now, -5) },
}, },
select: { select: {
@@ -1568,17 +1587,22 @@ export class ReportsService {
}, },
}), }),
this.prisma.deal.count({ this.prisma.deal.count({
where: { organizationId: orgId, status: 'open', ...dealScope }, where: { organizationId: orgId, status: 'open', ...dealScope, ...dealsPrior },
}), }),
this.prisma.deal.findMany({ this.prisma.deal.findMany({
where: { where: {
organizationId: orgId, AND: [
status: 'open', {
...dealScope, organizationId: orgId,
OR: [ status: 'open',
{ isStale: true }, ...dealScope,
{ isRotten: true }, OR: [
{ updatedAt: { lt: daysAgo(5) } }, { isStale: true },
{ isRotten: true },
{ updatedAt: { lt: daysAgo(5) } },
],
},
dealsPrior,
], ],
}, },
select: { select: {
@@ -1594,6 +1618,7 @@ export class ReportsService {
where: { where: {
organizationId: orgId, organizationId: orgId,
...personScope, ...personScope,
...peoplePrior,
createdAt: { gte: curMonthStart, lte: curMonthEnd }, createdAt: { gte: curMonthStart, lte: curMonthEnd },
}, },
}), }),
@@ -1601,6 +1626,7 @@ export class ReportsService {
where: { where: {
organizationId: orgId, organizationId: orgId,
...personScope, ...personScope,
...peoplePrior,
createdAt: { gte: prevMonthStart, lte: prevMonthEnd }, createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
}, },
}), }),
@@ -1608,6 +1634,7 @@ export class ReportsService {
where: { where: {
organizationId: orgId, organizationId: orgId,
...activityScope, ...activityScope,
...activitiesPrior,
status: 'open', status: 'open',
dueAt: { gte: todayStart, lte: todayEnd }, dueAt: { gte: todayStart, lte: todayEnd },
}, },
@@ -1616,6 +1643,7 @@ export class ReportsService {
where: { where: {
organizationId: orgId, organizationId: orgId,
...activityScope, ...activityScope,
...activitiesPrior,
dueAt: { gte: yesterdayStart, lte: yesterdayEnd }, dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
}, },
}), }),
@@ -1624,6 +1652,7 @@ export class ReportsService {
organizationId: orgId, organizationId: orgId,
status: 'open', status: 'open',
...dealScope, ...dealScope,
...dealsPrior,
updatedAt: { lte: weekAgo }, updatedAt: { lte: weekAgo },
}, },
}), }),
@@ -1639,6 +1668,7 @@ export class ReportsService {
where: { where: {
organizationId: orgId, organizationId: orgId,
...activityScope, ...activityScope,
...activitiesPrior,
status: 'open', status: 'open',
dueAt: { lt: now }, 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') @Patch(':id/visibility')
@Roles('owner', 'admin') @Roles('owner', 'admin')
updateVisibility( updateVisibility(
+28
View File
@@ -44,6 +44,7 @@ type MembershipRow = {
commissionPercent: { toString(): string } | number | null; commissionPercent: { toString(): string } | number | null;
dataVisibility?: string | null; dataVisibility?: string | null;
dataVisibilityScopes?: unknown; dataVisibilityScopes?: unknown;
priorArchiveEnabled?: boolean;
createdAt: Date; createdAt: Date;
orgRole: { id: string; name: string; color: string } | null; orgRole: { id: string; name: string; color: string } | null;
user: { user: {
@@ -241,6 +242,32 @@ export class UsersService {
return this.dtoOf(organizationId, targetId); 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( async updateVisibility(
organizationId: string, organizationId: string,
actorRole: UserRole, actorRole: UserRole,
@@ -478,6 +505,7 @@ export class UsersService {
row.role === 'owner' || row.role === 'admin' row.role === 'owner' || row.role === 'admin'
? {} ? {}
: parseVisibilityScopes(row.dataVisibilityScopes), : parseVisibilityScopes(row.dataVisibilityScopes),
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
}; };
} }
+38
View File
@@ -0,0 +1,38 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { classifyCountQuestion } from '../src/ai/ask-count';
describe('سؤال‌های شمارشی دستیار', () => {
it('لید جدید هفته گذشته را ساختن حساب نمی‌کند', () => {
const intent = classifyCountQuestion('تعداد لید جدید در هفته گذشته؟');
assert.ok(intent);
assert.equal(intent.entity, 'lead');
assert.equal(intent.period, 'last_week');
assert.equal(intent.leadBucket, 'summary');
assert.equal(intent.hint, '');
});
it('هفته‌ی گذشته و ماه قبل را هم می‌فهمد', () => {
assert.equal(classifyCountQuestion('چند مشتری جدید هفته‌ی گذشته داشتیم؟')?.period, 'last_week');
assert.equal(classifyCountQuestion('تعداد معاملات ماه قبل')?.period, 'last_month');
assert.equal(classifyCountQuestion('تعداد فعالیت جدید این هفته')?.period, 'week');
});
it('مرحلهٔ جدید را با بازه قاطی نمی‌کند', () => {
const intent = classifyCountQuestion('چند لید در مرحله جدید هفته گذشته داریم؟');
assert.ok(intent);
assert.equal(intent.period, 'last_week');
assert.equal(intent.hint, 'جدید');
});
it('بدون بازه، لید جدید یعنی مرحلهٔ جدید', () => {
const intent = classifyCountQuestion('تعداد لید جدید');
assert.ok(intent);
assert.equal(intent.period, undefined);
assert.equal(intent.hint, 'جدید');
});
it('دستور ساخت را سؤال شمارشی نمی‌گیرد', () => {
assert.equal(classifyCountQuestion('یک لید جدید به نام علی بساز'), null);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
activityPriorWhere,
dealPriorWhere,
listPriorLens,
personPriorWhere,
} from '../src/common/prior-archive';
const now = new Date('2026-10-15T08:00:00.000Z');
describe('بایگانی اطلاعات قبل', () => {
it('بدون مجوز فیلتر نمی‌گذارد', () => {
assert.deepEqual(personPriorWhere('off'), {});
assert.deepEqual(dealPriorWhere(undefined, now), {});
});
it('کار جاری شخص قبلی را کنار می‌گذارد و معاملهٔ بازِ موعددار را نگه می‌دارد', () => {
assert.deepEqual(personPriorWhere('current'), { importSource: { not: 'didar' } });
const where = dealPriorWhere('current', now);
assert.equal(where.OR?.length, 2);
assert.deepEqual(where.OR?.[1], {
importSource: 'didar',
status: 'open',
expectedCloseAt: { gt: now },
});
});
it('بایگانی فقط قبلی‌ها را می‌آورد و استثنای باز را برمی‌دارد', () => {
const where = activityPriorWhere('archive', now);
assert.equal(where.importSource, 'didar');
assert.deepEqual(where.NOT, {
importSource: 'didar',
status: 'open',
dueAt: { gt: now },
});
});
it('جستجوی کار جاری شخص قبلی را پیدا می‌کند', () => {
assert.equal(listPriorLens('current', 'علی', false), 'off');
assert.equal(listPriorLens('current', ' ', false), 'current');
assert.equal(listPriorLens('archive', 'علی', false), 'archive');
assert.equal(listPriorLens('current', '', true), 'off');
});
});
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import type { import type {
OrgUserDto, OrgUserDto,
TeamDto, TeamDto,
@@ -23,6 +24,15 @@ const props = defineProps<{
canEdit: boolean; canEdit: boolean;
}>(); }>();
const auth = useAuthStore();
const showPriorArchive = computed(
() =>
auth.user?.role === 'owner' &&
Boolean(auth.user.enabledModules?.includes('prior_data_archive')),
);
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
const priorSavingId = ref('');
const emit = defineEmits<{ const emit = defineEmits<{
refreshUsers: []; refreshUsers: [];
}>(); }>();
@@ -45,6 +55,25 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'), props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
); );
async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId;
error.value = '';
ok.value = '';
try {
await api.patch(`/users/${userId}/prior-archive`, { enabled });
ok.value = enabled
? 'بایگانی اطلاعات قبل برای این کاربر روشن شد.'
: 'بایگانی اطلاعات قبل برای این کاربر خاموش شد.';
emit('refreshUsers');
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
: 'ذخیره ناموفق بود.';
} finally {
priorSavingId.value = '';
}
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
@@ -296,6 +325,23 @@ onMounted(() => {
</div> </div>
</section> </section>
<section v-if="showPriorArchive" class="card">
<h3>بایگانی اطلاعات قبل</h3>
<p class="hint">
فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ
رکوردها را مطابق سطح دسترسی خودشان می‌بینند.
</p>
<label v-for="u in priorUsers" :key="u.id" class="vis-row">
<input
type="checkbox"
:checked="Boolean(u.priorArchiveEnabled)"
:disabled="priorSavingId === u.id"
@change="setPriorArchive(u.id, ($event.target as HTMLInputElement).checked)"
/>
<span>{{ u.fullName }}</span>
</label>
</section>
<section class="card"> <section class="card">
<h3>سطح دسترسی به پرونده‌ها</h3> <h3>سطح دسترسی به پرونده‌ها</h3>
<p class="hint"> <p class="hint">
@@ -71,6 +71,9 @@ function onVoiceTranscript(text: string) {
} }
function looksLikeAction(text: string) { function looksLikeAction(text: string) {
if (/[؟?]/.test(text) || /چند|تعداد|چقدر|چنتا|چیست|چیه|کدام|کدوم|چرا|چطور|چگونه/.test(text)) {
return false;
}
return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید|یک لید|لید جدید/.test( return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید|یک لید|لید جدید/.test(
text, text,
); );
+66
View File
@@ -13,6 +13,7 @@ import GamificationToaster from '@/components/gamification/GamificationToaster.v
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs'; import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
import type { import type {
ActivitySummaryDto, ActivitySummaryDto,
AuthUserDto,
NotificationDto, NotificationDto,
NotificationsListDto, NotificationsListDto,
} from '@mento/shared'; } from '@mento/shared';
@@ -40,6 +41,20 @@ const isMobileNav = ref(false);
const globalSearch = ref(''); const globalSearch = ref('');
const activityBadge = ref(0); const activityBadge = ref(0);
const routineBadge = ref(0); const routineBadge = ref(0);
const priorSaving = ref(false);
const priorLens = computed(() => auth.user?.priorArchive?.lens ?? 'current');
async function setPriorLens(lens: 'current' | 'archive') {
if (!auth.user?.priorArchive || priorLens.value === lens || priorSaving.value) return;
priorSaving.value = true;
try {
const { data } = await api.patch<AuthUserDto>('/auth/me/prior-archive', { lens });
auth.setUser(data);
window.location.reload();
} finally {
priorSaving.value = false;
}
}
function pathIn(prefixes: string[]) { function pathIn(prefixes: string[]) {
return prefixes.some( return prefixes.some(
@@ -665,6 +680,24 @@ onUnmounted(() => {
</p> </p>
<div class="sidebar-foot"> <div class="sidebar-foot">
<div v-if="auth.user?.priorArchive" class="prior-switch" role="group" aria-label="بایگانی اطلاعات قبل">
<button
type="button"
:class="{ on: priorLens === 'current' }"
:disabled="priorSaving"
@click="setPriorLens('current')"
>
کار جاری
</button>
<button
type="button"
:class="{ on: priorLens === 'archive' }"
:disabled="priorSaving"
@click="setPriorLens('archive')"
>
بایگانی اطلاعات قبل
</button>
</div>
<div class="foot-actions"> <div class="foot-actions">
<RouterLink <RouterLink
class="profile-card profile-foot" class="profile-card profile-foot"
@@ -1312,6 +1345,35 @@ onUnmounted(() => {
gap: 0.55rem; gap: 0.55rem;
} }
.prior-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.3rem;
}
.prior-switch button {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
border-radius: 8px;
padding: 0.35rem 0.3rem;
font: inherit;
font-size: 0.68rem;
font-weight: 700;
cursor: pointer;
}
.prior-switch button.on {
background: #f0fdfa;
border-color: #99f6e4;
color: #0f766e;
}
.prior-switch button:disabled {
opacity: 0.6;
cursor: wait;
}
.foot-meta { .foot-meta {
display: grid; display: grid;
gap: 0.15rem; gap: 0.15rem;
@@ -1769,6 +1831,10 @@ onUnmounted(() => {
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar.collapsed .prior-switch {
display: none;
}
.sidebar.collapsed .logout-link { .sidebar.collapsed .logout-link {
display: grid; display: grid;
place-items: center; place-items: center;
+74 -4
View File
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router'; import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { formatCompactMoney, formatMoney } from '@/lib/money'; import { formatCompactMoney, formatMoney } from '@/lib/money';
import { import {
calendarDaysBetween, calendarDaysBetween,
@@ -60,6 +61,7 @@ type TimelineRow = {
tone: TimelineTone; tone: TimelineTone;
href: string; href: string;
at: number; at: number;
prior?: boolean;
}; };
type AttachmentRow = { type AttachmentRow = {
@@ -180,6 +182,27 @@ const previousSourceLabel = computed(() => {
return SOURCE_LABELS[src] || src; return SOURCE_LABELS[src] || src;
}); });
const auth = useAuthStore();
const priorArchiveOn = computed(() => Boolean(auth.user?.priorArchive));
function isPriorActivity(row: ActivityDto) {
if (row.importSource !== 'didar') return false;
if (row.status === 'open' && row.dueAt && new Date(row.dueAt).getTime() > Date.now()) return false;
return true;
}
function isPriorDeal(row: DealDto) {
if (row.importSource !== 'didar') return false;
if (
row.status === 'open' &&
row.expectedCloseAt &&
new Date(row.expectedCloseAt).getTime() > Date.now()
) {
return false;
}
return true;
}
const lastCallAt = computed(() => { const lastCallAt = computed(() => {
const calls = activities.value const calls = activities.value
.filter((a) => a.type === 'call') .filter((a) => a.type === 'call')
@@ -210,6 +233,12 @@ const nextActivity = computed(() => {
}); });
const openDeals = computed(() => deals.value.filter((d) => d.status === 'open')); const openDeals = computed(() => deals.value.filter((d) => d.status === 'open'));
const currentDeals = computed(() =>
priorArchiveOn.value ? deals.value.filter((d) => !isPriorDeal(d)) : deals.value,
);
const priorDeals = computed(() =>
priorArchiveOn.value ? deals.value.filter((d) => isPriorDeal(d)) : [],
);
const lastQuote = computed( const lastQuote = computed(
() => () =>
@@ -308,6 +337,7 @@ const timeline = computed<TimelineRow[]>(() => {
tone: activityTone(a), tone: activityTone(a),
href: `/activities/${a.id}`, href: `/activities/${a.id}`,
at, at,
prior: isPriorActivity(a),
}); });
} }
for (const n of notes.value) { for (const n of notes.value) {
@@ -340,7 +370,17 @@ const visibleTimeline = computed(() => {
historyFilter.value === 'all' historyFilter.value === 'all'
? timeline.value ? timeline.value
: timeline.value.filter((row) => row.tone === historyFilter.value); : timeline.value.filter((row) => row.tone === historyFilter.value);
return rows.slice(0, 8); const current = priorArchiveOn.value ? rows.filter((row) => !row.prior) : rows;
return current.slice(0, 8);
});
const priorTimeline = computed(() => {
if (!priorArchiveOn.value) return [];
const rows =
historyFilter.value === 'all'
? timeline.value
: timeline.value.filter((row) => row.tone === historyFilter.value);
return rows.filter((row) => row.prior).slice(0, 8);
}); });
function fileKind(name: string, mime: string): AttachmentRow['kind'] { function fileKind(name: string, mime: string): AttachmentRow['kind'] {
@@ -442,7 +482,7 @@ async function load() {
api.get<PaginatedResponse<MemoryItemDto>>('/memory', { api.get<PaginatedResponse<MemoryItemDto>>('/memory', {
params: { personId: id.value, limit: 50 }, params: { personId: id.value, limit: 50 },
}), }),
api.get<PipelineBoardDto>('/pipelines/board'), api.get<PipelineBoardDto>('/pipelines/board', { params: { prior: 'all' } }),
]); ]);
person.value = personRes.data; person.value = personRes.data;
activities.value = actRes.data.items; activities.value = actRes.data.items;
@@ -832,7 +872,24 @@ watch(() => route.query.tab, readTab, { immediate: true });
</RouterLink> </RouterLink>
</li> </li>
</ol> </ol>
<p v-else class="empty">موردی با این فیلتر نیست.</p> <p v-else-if="!priorTimeline.length" class="empty">موردی با این فیلتر نیست.</p>
<template v-if="priorTimeline.length">
<h3 class="prior-head">بایگانی اطلاعات قبل</h3>
<ol class="tl">
<li v-for="row in priorTimeline" :key="row.key">
<RouterLink :to="row.href">
<span class="dot" :class="row.tone">
<UiIcon :name="timelineIcon(row.tone)" :size="14" />
</span>
<div>
<time>{{ row.when }}</time>
<strong>{{ row.title }}</strong>
<p v-if="row.body">{{ row.body }}</p>
</div>
</RouterLink>
</li>
</ol>
</template>
<RouterLink class="see-all" :to="{ name: 'activities', query: { filter: 'all' } }"> <RouterLink class="see-all" :to="{ name: 'activities', query: { filter: 'all' } }">
مشاهده همه فعالیت‌ها مشاهده همه فعالیت‌ها
</RouterLink> </RouterLink>
@@ -896,7 +953,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
/> />
<div v-if="!dealsEmpty"> <div v-if="!dealsEmpty">
<RouterLink <RouterLink
v-for="deal in deals" v-for="deal in currentDeals"
:key="deal.id" :key="deal.id"
class="row-card" class="row-card"
:to="{ name: 'deal-detail', params: { id: deal.id } }" :to="{ name: 'deal-detail', params: { id: deal.id } }"
@@ -905,6 +962,19 @@ watch(() => route.query.tab, readTab, { immediate: true });
<span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span> <span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span>
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em> <em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
</RouterLink> </RouterLink>
<div v-if="priorDeals.length" class="subblock">
<h3>بایگانی اطلاعات قبل</h3>
<RouterLink
v-for="deal in priorDeals"
:key="deal.id"
class="row-card"
:to="{ name: 'deal-detail', params: { id: deal.id } }"
>
<strong>{{ deal.title }}</strong>
<span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span>
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
</RouterLink>
</div>
<div v-if="invoices.length" class="subblock"> <div v-if="invoices.length" class="subblock">
<h3>فاکتورها</h3> <h3>فاکتورها</h3>
<RouterLink <RouterLink
@@ -584,6 +584,19 @@ onMounted(() => {
حرفه‌ای (چندشاخه، شرط روی پاسخ فرم، تقسیم نوبتی) حرفه‌ای (چندشاخه، شرط روی پاسخ فرم، تقسیم نوبتی)
</label> </label>
</div> </div>
<div class="mod-group">
<div class="mod-group-head">
<span>بایگانی اطلاعات قبل</span>
</div>
<label class="mod-item">
<input
type="checkbox"
:checked="moduleDraft.includes('prior_data_archive')"
@change="toggleModule('prior_data_archive')"
/>
بایگانی اطلاعات قبل
</label>
</div>
</div> </div>
<div class="chat-settings"> <div class="chat-settings">
@@ -1,7 +1,7 @@
# بایگانی اطلاعات قبل # بایگانی اطلاعات قبل
تاریخ: ۲۰۲۶-۱۰-۱۴ تاریخ: ۲۰۲۶-۱۰-۱۴
وضعیت: قفل‌شده توسط کاربر؛ پیاده‌سازی شروع نشده است. تا وقتی کاربر صریح بگوید «شروع کن»، کدی برای این قابلیت نوشته نمی‌شود. وضعیت: قفل‌شده توسط کاربر و پیاده‌سازی شروع شده است.
## هدف ## هدف
+14 -2
View File
@@ -42,6 +42,8 @@ export interface AuthUserDto {
enabledModules?: OrgModuleKey[]; enabledModules?: OrgModuleKey[];
/** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */ /** خلاصه اشتراک مالک محیط (برای اعضا فقط جهت نمایش/گیت ورود) */
subscription?: SubscriptionSummaryDto | null; subscription?: SubscriptionSummaryDto | null;
/** اگر باشد، این کاربر سوئیچ «بایگانی اطلاعات قبل» را دارد */
priorArchive?: { lens: "current" | "archive" } | null;
} }
export type BillingPlanCode = export type BillingPlanCode =
@@ -141,7 +143,8 @@ export type OrgModuleKey =
| "leads" | "leads"
| "conversations" | "conversations"
| "routines" | "routines"
| "gamification"; | "gamification"
| "prior_data_archive";
export const ORG_MODULE_KEYS: OrgModuleKey[] = [ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"customers", "customers",
@@ -182,6 +185,7 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
"conversations", "conversations",
"routines", "routines",
"gamification", "gamification",
"prior_data_archive",
]; ];
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = { export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
@@ -223,6 +227,7 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
conversations: "گفتگو", conversations: "گفتگو",
routines: "کارهای روتین", routines: "کارهای روتین",
gamification: "باشگاه امتیازات", gamification: "باشگاه امتیازات",
prior_data_archive: "بایگانی اطلاعات قبل",
}; };
export const ORG_MODULE_GROUPS: Array<{ export const ORG_MODULE_GROUPS: Array<{
@@ -287,7 +292,10 @@ export function isOrgModuleKey(value: string): value is OrgModuleKey {
} }
/** ماژول‌هایی که باید صراحتاً توسط سوپرادمین فعال شوند */ /** ماژول‌هایی که باید صراحتاً توسط سوپرادمین فعال شوند */
export const OPT_IN_ORG_MODULES: OrgModuleKey[] = ["workflow_automations_advanced"]; export const OPT_IN_ORG_MODULES: OrgModuleKey[] = [
"workflow_automations_advanced",
"prior_data_archive",
];
export function defaultEnabledModules(): OrgModuleKey[] { export function defaultEnabledModules(): OrgModuleKey[] {
return ORG_MODULE_KEYS.filter((k) => !OPT_IN_ORG_MODULES.includes(k)); return ORG_MODULE_KEYS.filter((k) => !OPT_IN_ORG_MODULES.includes(k));
@@ -346,6 +354,8 @@ export interface OrgUserDto {
dataVisibility?: DataVisibility; dataVisibility?: DataVisibility;
/** سطح دسترسی تفکیکی هر بخش (فقط بخش‌هایی که با پیش‌فرض فرق دارند) */ /** سطح دسترسی تفکیکی هر بخش (فقط بخش‌هایی که با پیش‌فرض فرق دارند) */
dataVisibilityScopes?: DataVisibilityScopes; dataVisibilityScopes?: DataVisibilityScopes;
/** تیک مالک: این کاربر سوئیچ بایگانی اطلاعات قبل را ببیند */
priorArchiveEnabled?: boolean;
createdAt: string; createdAt: string;
} }
@@ -1465,6 +1475,8 @@ export interface ActivityDto {
/** زمان ارسال یادآور (اگر ارسال شده باشد) */ /** زمان ارسال یادآور (اگر ارسال شده باشد) */
reminderSentAt: string | null; reminderSentAt: string | null;
completedAt: string | null; completedAt: string | null;
/** manual یا didar؛ برای جدا کردن سابقهٔ قبلی */
importSource?: string;
tags: string[]; tags: string[];
person: PersonSummaryDto | null; person: PersonSummaryDto | null;
company: CompanySummaryDto | null; company: CompanySummaryDto | null;