merge: bring latest origin/main into dev (keep local UI)

Resolved UI conflicts in favor of local Ant Design screens; kept non-UI changes from main.
This commit is contained in:
2026-11-03 16:55:42 +03:30
201 changed files with 19202 additions and 1607 deletions
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -50,7 +51,8 @@ const excelUpload = FileInterceptor('file', {
});
@Controller('activities')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('activities')
export class ActivitiesController {
constructor(
private readonly activitiesService: ActivitiesService,
@@ -70,14 +72,14 @@ export class ActivitiesController {
@Get('boards')
listBoards(@CurrentUser() user: Actor) {
return this.stagesService.listBoards(user.organizationId);
return this.stagesService.listBoards(user.organizationId, user);
}
@Post('boards')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) {
return this.stagesService.createBoard(user.organizationId, dto.name);
return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds);
}
@Patch('boards/:id')
@@ -101,7 +103,7 @@ export class ActivitiesController {
@Get('stages')
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) {
return this.stagesService.list(user.organizationId, query.boardId);
return this.stagesService.list(user.organizationId, query.boardId, user);
}
@Post('stages')
@@ -191,6 +193,9 @@ export class ActivitiesController {
typeDefId: query.typeDefId,
status: query.status,
assigneeId: query.assigneeId,
title: query.title,
customer: query.customer,
due: query.due,
boardId: query.boardId,
},
);
@@ -224,6 +229,42 @@ export class ActivitiesController {
typeDefId: query.typeDefId,
status: query.status,
assigneeId: query.assigneeId,
title: query.title,
customer: query.customer,
due: query.due,
boardId: query.boardId,
},
query.stageId ? { stageId: query.stageId } : undefined,
);
}
/** ترتیب همان لیست میز، برای فعالیت قبلی/بعدی در جزئیات */
@Get('nav-ids')
navIds(@CurrentUser() user: Actor, @Query() query: ListActivitiesQueryDto) {
return this.activitiesService.orderedIds(
user.organizationId,
user,
query.filter ?? 'today',
query.q,
{
personId: query.personId,
companyId: query.companyId,
dealId: query.dealId,
leadId: query.leadId,
},
{
from: query.from,
to: query.to,
},
query.desk,
{
type: query.type,
typeDefId: query.typeDefId,
status: query.status,
assigneeId: query.assigneeId,
title: query.title,
customer: query.customer,
due: query.due,
boardId: query.boardId,
},
query.stageId ? { stageId: query.stageId } : undefined,
@@ -307,6 +348,56 @@ export class ActivitiesController {
return this.activitiesService.get(user.organizationId, id, user);
}
@Post(':id/images')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
}),
)
addImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.activitiesService.addImage(
user.organizationId,
user.userId,
id,
file,
user,
);
}
@Get(':id/images/:imageId')
async downloadImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('imageId') imageId: string,
): Promise<StreamableFile> {
const { stream, fileName, mimeType } = await this.activitiesService.openImage(
user.organizationId,
id,
imageId,
user,
);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: mimeType,
disposition: `inline; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
});
}
@Delete(':id/images/:imageId')
async removeImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('imageId') imageId: string,
) {
await this.activitiesService.removeImage(user.organizationId, id, imageId, user);
return { ok: true };
}
@Post(':id/promote-from-prior')
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.promoteFromPrior(user, id);
+2 -1
View File
@@ -7,9 +7,10 @@ import { ActivityTypesService } from './activity-types.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
controllers: [ActivitiesController],
providers: [
ActivitiesService,
+469 -58
View File
@@ -8,6 +8,7 @@ import type {
ActivityDto,
ActivityFieldValues,
ActivityFilter,
ActivityImageDto,
ActivitySummaryDto,
ActivityStageCountsDto,
CreateActivityRequest,
@@ -15,6 +16,10 @@ import type {
UpdateActivityRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import * as path from 'node:path';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
@@ -25,11 +30,13 @@ import {
dealAccessWhere,
ownerWhere,
} from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access';
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 { leadsDeepWhere } from '../common/data-access';
import { ActivityStagesService, type BoardStageContext } from './activity-stages.service';
import {
ActivityTypesService,
@@ -39,7 +46,10 @@ import {
type DtoContext = { stages?: BoardStageContext; types?: SystemTypes };
type ActivityActor = Pick<Actor, 'userId' | 'role' | 'priorLens' | 'priorCutoff'>;
type ActivityActor = Pick<
Actor,
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
>;
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string };
@@ -49,6 +59,10 @@ type ListFacets = {
typeDefId?: string;
status?: ActivityDto['status'];
assigneeId?: string;
title?: string;
customer?: string;
/** set = زمان شروع دارد، unset = ندارد */
due?: 'set' | 'unset';
/** گروه فعالیت؛ خالی = همهٔ گروه‌ها */
boardId?: string;
};
@@ -84,6 +98,57 @@ type ActivityRow = {
typeDef?: { id: string; name: string; icon: string; color: string } | null;
fields?: Prisma.JsonValue | null;
participantIds?: string[];
images?: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
}>;
};
const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024;
const ACTIVITY_IMAGE_MAX_COUNT = 8;
const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']);
const ACTIVITY_VOICE_MAX_BYTES = 2 * 1024 * 1024;
const ACTIVITY_VOICE_MAX_COUNT = 5;
const ACTIVITY_VOICE_MIME = new Set(['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/mp3', 'audio/aac', 'audio/x-m4a', 'audio/m4a', 'audio/wav', 'audio/x-wav']);
const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024;
const ACTIVITY_FILE_MAX_COUNT = 8;
const ACTIVITY_FILE_MIME = new Set([
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/zip',
'application/x-zip-compressed',
]);
const ACTIVITY_EXT_MIME: Record<string, string> = {
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
webm: 'audio/webm',
ogg: 'audio/ogg',
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
wav: 'audio/wav',
};
const include = {
@@ -108,6 +173,8 @@ export class ActivitiesService {
private readonly stages: ActivityStagesService,
private readonly types: ActivityTypesService,
private readonly game: GamificationService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
) {}
private async ctx(organizationId: string): Promise<DtoContext> {
@@ -245,6 +312,105 @@ export class ActivitiesService {
facets?: ListFacets,
stageScope?: StageScope,
): Promise<PaginatedResponse<ActivityDto>> {
const { where, ctx } = await this.listScope(
organizationId,
actor,
filter,
q,
entity,
range,
desk,
facets,
stageScope,
);
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
include,
// روی پروندهٔ لید، تازه‌ترین (از جمله پیگیری آینده) اول بیاید تا از سقف صفحه نیفتد
orderBy: entity?.leadId
? [{ createdAt: 'desc' }]
: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take,
take,
}),
this.prisma.activity.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row, ctx)),
total,
page,
limit: take,
};
}
/**
* شناسه‌ها با همان فیلتر و ترتیب لیست (زمان شروع صعودی).
* برای ورق‌زدن جزئیات؛ سقف دارد تا یک زبانهٔ خیلی شلوغ کل پاسخ را بزرگ نکند.
*/
async orderedIds(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter = 'today',
q?: string,
entity?: {
personId?: string;
companyId?: string;
dealId?: string;
leadId?: string;
},
range?: {
from?: string;
to?: string;
},
desk?: 'mine' | 'team',
facets?: ListFacets,
stageScope?: StageScope,
): Promise<{ ids: string[]; total: number; truncated: boolean }> {
const cap = 2000;
const { where } = await this.listScope(
organizationId,
actor,
filter,
q,
entity,
range,
desk,
facets,
stageScope,
);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
select: { id: true },
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
take: cap,
}),
this.prisma.activity.count({ where }),
]);
return { ids: rows.map((row) => row.id), total, truncated: total > rows.length };
}
private async listScope(
organizationId: string,
actor: ActivityActor,
filter: ActivityFilter,
q: string | undefined,
entity:
| {
personId?: string;
companyId?: string;
dealId?: string;
leadId?: string;
}
| undefined,
range: { from?: string; to?: string } | undefined,
desk: 'mine' | 'team' | undefined,
facets: ListFacets | undefined,
stageScope: StageScope | undefined,
): Promise<{ where: Prisma.ActivityWhereInput; ctx: DtoContext }> {
const typeRows = await this.types.rows(organizationId);
const where = this.buildWhere(
organizationId,
@@ -261,6 +427,7 @@ export class ActivitiesService {
const boardCtx = await this.stages.boardContext(organizationId);
if (facets?.boardId) {
const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId);
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
(where.AND as Prisma.ActivityWhereInput[]).push(
this.stages.boardWhere(boardId, boardCtx.defaultBoardId),
);
@@ -270,27 +437,9 @@ export class ActivitiesService {
await this.stages.whereForStage(organizationId, stageScope.stageId),
);
}
const ctx: DtoContext = {
stages: boardCtx,
types: this.types.systemOf(typeRows),
};
const take = Math.min(Math.max(limit, 1), 200);
const [rows, total] = await this.prisma.$transaction([
this.prisma.activity.findMany({
where,
include,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'desc' }],
skip: (page - 1) * take,
take,
}),
this.prisma.activity.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row, ctx)),
total,
page,
limit: take,
where,
ctx: { stages: boardCtx, types: this.types.systemOf(typeRows) },
};
}
@@ -304,6 +453,7 @@ export class ActivitiesService {
facets?: ListFacets,
): Promise<ActivityStageCountsDto> {
const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId);
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
const rows = await this.stages.rows(organizationId, boardId);
const defaults = this.stages.defaultsOf(rows);
@@ -382,7 +532,19 @@ export class ActivitiesService {
): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...activityAccessWhere(actor) },
include,
include: {
...include,
images: {
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
},
},
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
const dto = this.toDto(row, await this.ctx(organizationId));
@@ -412,6 +574,12 @@ export class ActivitiesService {
? [dto.assigneeId]
: [userId],
);
await assertAssigneeSet(
this.prisma,
{ userId, role: linkActor.role, organizationId },
'activities',
assigneeIds,
);
const assigneeId = assigneeIds[0] ?? userId;
const initialStageId = emptyToNull(dto.stageId ?? null);
const initialStage = initialStageId
@@ -423,6 +591,7 @@ export class ActivitiesService {
: requestedBoardId
? await this.stages.resolveBoardId(organizationId, requestedBoardId)
: null;
if (boardId) await this.stages.assertCanAccessBoard(organizationId, boardId, linkActor);
const resolved = await this.types.resolve(organizationId, {
typeDefId: dto.typeDefId,
type: dto.type,
@@ -432,6 +601,12 @@ export class ActivitiesService {
resolved.fields,
dto.fields,
);
await assertAssigneeSet(
this.prisma,
{ userId, role: linkActor.role, organizationId },
'activities',
participantIds,
);
const row = await this.prisma.activity.create({
data: {
@@ -460,6 +635,7 @@ export class ActivitiesService {
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
leadId: emptyToNull(dto.leadId ?? null),
assigneeId,
assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) }
@@ -543,6 +719,7 @@ export class ActivitiesService {
select: { boardId: true },
});
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
await this.stages.assertCanAccessBoard(organizationId, nextBoardId, actor);
if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) {
// ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود
data.boardId = nextBoardId;
@@ -567,6 +744,13 @@ export class ActivitiesService {
data.typeDefId = resolved.typeDefId;
data.fields = fields as Prisma.InputJsonValue;
data.participantIds = participantIds;
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'activities',
participantIds,
existing?.participantIds ?? [],
);
const before = new Set(existing?.participantIds ?? []);
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
}
@@ -591,23 +775,49 @@ export class ActivitiesService {
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined;
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
if (!assigneeIds.length) {
throw new BadRequestException('حداقل یک مسئول لازم است');
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { assigneeId: true, assignees: { select: { id: true } } },
});
const previous = current?.assignees.length
? current.assignees.map((item) => item.id)
: current?.assigneeId
? [current.assigneeId]
: [];
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
if (!assigneeIds.length) {
throw new BadRequestException('حداقل یک مسئول لازم است');
}
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'activities',
assigneeIds,
previous,
);
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
} else {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'activities',
[assigneeId],
previous,
);
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
}
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
} else if ('assigneeId' in dto) {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
}
if (dto.status !== undefined) {
data.status = dto.status;
@@ -701,7 +911,145 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId },
select: { storageKey: true, sizeBytes: true },
});
const bytes = images.reduce((sum, image) => sum + image.sizeBytes, 0);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
await this.prisma.activity.delete({ where: { id } });
for (const image of images) {
await this.files.remove(image.storageKey);
}
}
async addImage(
organizationId: string,
userId: string,
activityId: string,
file: Express.Multer.File,
actor: ActivityActor,
): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor);
if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است');
}
const namedExt = path.extname(file.originalname || '').slice(1).toLowerCase();
let mimeType = (file.mimetype || '').toLowerCase().split(';')[0]?.trim() || '';
if (!mimeType || mimeType === 'application/octet-stream') {
mimeType = ACTIVITY_EXT_MIME[namedExt] || mimeType;
}
const isVoice = mimeType.startsWith('audio/') || ACTIVITY_VOICE_MIME.has(mimeType);
const isImage = ACTIVITY_IMAGE_MIME.has(mimeType);
const isFile = ACTIVITY_FILE_MIME.has(mimeType);
if (!isVoice && !isImage && !isFile) {
throw new BadRequestException('این نوع فایل مجاز نیست.');
}
if (isVoice) {
if (file.size > ACTIVITY_VOICE_MAX_BYTES) {
throw new BadRequestException('حجم صوت باید زیر ۲ مگابایت باشد');
}
const voiceCount = await this.prisma.activityImage.count({
where: { activityId, mimeType: { startsWith: 'audio/' } },
});
if (voiceCount >= ACTIVITY_VOICE_MAX_COUNT) {
throw new BadRequestException(`حداکثر ${ACTIVITY_VOICE_MAX_COUNT} صوت برای هر فعالیت مجاز است`);
}
} else if (isImage) {
if (file.size > ACTIVITY_IMAGE_MAX_BYTES) {
throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد');
}
const count = await this.prisma.activityImage.count({
where: { activityId, mimeType: { startsWith: 'image/' } },
});
if (count >= ACTIVITY_IMAGE_MAX_COUNT) {
throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`);
}
} else {
if (file.size > ACTIVITY_FILE_MAX_BYTES) {
throw new BadRequestException('حجم فایل باید زیر ۱۰ مگابایت باشد');
}
const fileCount = await this.prisma.activityImage.count({
where: {
activityId,
NOT: {
OR: [{ mimeType: { startsWith: 'image/' } }, { mimeType: { startsWith: 'audio/' } }],
},
},
});
if (fileCount >= ACTIVITY_FILE_MAX_COUNT) {
throw new BadRequestException(`حداکثر ${ACTIVITY_FILE_MAX_COUNT} فایل برای هر فعالیت مجاز است`);
}
}
const fileName = sanitizeActivityImageName(file.originalname);
const ext =
path.extname(fileName).slice(0, 12) ||
(mimeType === 'audio/ogg' ? '.ogg' : mimeType === 'audio/mp4' ? '.m4a' : isVoice ? '.webm' : '.jpg');
const imageId = randomUUID().replace(/-/g, '').slice(0, 24);
const storageKey = path
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
.replace(/\\/g, '/');
await this.storage.assertCanAdd(organizationId, file.size);
await this.files.save(storageKey, file.buffer);
try {
const row = await this.prisma.activityImage.create({
data: {
id: imageId,
organizationId,
activityId,
fileName,
mimeType: mimeType === 'image/jpg' ? 'image/jpeg' : mimeType,
sizeBytes: file.size,
storageKey,
uploadedById: userId,
},
});
await this.storage.addLocal(organizationId, file.size);
return {
id: row.id,
fileName: row.fileName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt.toISOString(),
};
} catch (err) {
await this.files.remove(storageKey);
throw err;
}
}
async removeImage(
organizationId: string,
activityId: string,
imageId: string,
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, activityId, actor);
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('فایل پیدا نشد');
await this.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes);
}
async openImage(
organizationId: string,
activityId: string,
imageId: string,
actor: ActivityActor,
): Promise<{ stream: ReturnType<LocalFileStorage['openReadStream']>; fileName: string; mimeType: string }> {
await this.ensureExists(organizationId, activityId, actor);
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('فایل پیدا نشد');
return {
stream: this.files.openReadStream(row.storageKey),
fileName: row.fileName,
mimeType: row.mimeType,
};
}
private buildWhere(
@@ -737,11 +1085,15 @@ export class ActivitiesService {
: activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim();
const facetCustomer = facets?.customer?.trim();
const andParts: Prisma.ActivityWhereInput[] = [
scope,
...(facets?.type ? [{ type: facets.type }] : []),
...(facetTypeDef ? [this.types.whereForType(typeRows, facetTypeDef)] : []),
...(facets?.status ? [{ status: facets.status }] : []),
...(facetTitle ? [{ title: ci(facetTitle) }] : []),
...(facetCustomer ? [this.customerWhere(facetCustomer)] : []),
...(facetAssignee
? [
{
@@ -788,15 +1140,18 @@ export class ActivitiesService {
const fromDate = range?.from ? new Date(range.from) : null;
const toDate = range?.to ? new Date(range.to) : null;
if (fromDate || toDate) {
return {
...base,
dueAt: {
...(fromDate && !Number.isNaN(fromDate.getTime())
? { gte: fromDate }
: {}),
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
return this.withDue(
{
...base,
dueAt: {
...(fromDate && !Number.isNaN(fromDate.getTime())
? { gte: fromDate }
: {}),
...(toDate && !Number.isNaN(toDate.getTime()) ? { lt: toDate } : {}),
},
},
};
facets?.due,
);
}
// Explicit status facet overrides the status implied by time tabs.
@@ -810,28 +1165,62 @@ export class ActivitiesService {
switch (filter) {
case 'today':
return { ...base, ...impliedOpen, dueAt: { gte: start, lt: end } };
return this.withDue(
{ ...base, ...impliedOpen, dueAt: { gte: start, lt: end } },
facets?.due,
);
case 'overdue':
// عقب‌افتاده ذاتاً یعنی باز و موعدگذشته
return {
...base,
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
dueAt: { lt: start },
};
return this.withDue(
{
...base,
...(facets?.status ? { status: facets.status } : { status: 'open' as const }),
dueAt: { lt: start },
},
facets?.due,
);
case 'upcoming':
return {
...base,
...impliedOpen,
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
};
return this.withDue(
{
...base,
...impliedOpen,
OR: [{ dueAt: { gte: end } }, { dueAt: null }],
},
facets?.due,
);
case 'done':
return { ...base, ...impliedDone };
return this.withDue({ ...base, ...impliedDone }, facets?.due);
case 'all':
default:
return base;
return this.withDue(base, facets?.due);
}
}
/** نام شخص (چندکلمه‌ای) یا نام شرکت */
private customerWhere(term: string): Prisma.ActivityWhereInput {
const parts = term.split(/\s+/).filter(Boolean);
const person: Prisma.PersonWhereInput =
parts.length <= 1
? { OR: [{ firstName: ci(term) }, { lastName: ci(term) }] }
: {
AND: parts.map((part) => ({
OR: [{ firstName: ci(part) }, { lastName: ci(part) }],
})),
};
return {
OR: [{ person }, { company: { name: ci(term) } }],
};
}
/** وجود یا نبود زمان شروع، روی هر فیلتر زمانی */
private withDue(
where: Prisma.ActivityWhereInput,
due?: 'set' | 'unset',
): Prisma.ActivityWhereInput {
if (due !== 'set' && due !== 'unset') return where;
return { AND: [where, due === 'set' ? { dueAt: { not: null } } : { dueAt: null }] };
}
private async fireDone(
organizationId: string,
row: {
@@ -903,11 +1292,13 @@ export class ActivitiesService {
personId?: string | null;
companyId?: string | null;
dealId?: string | null;
leadId?: string | null;
},
) {
const personId = emptyToNull(dto.personId ?? null);
const companyId = emptyToNull(dto.companyId ?? null);
const dealId = emptyToNull(dto.dealId ?? null);
const leadId = emptyToNull(dto.leadId ?? null);
const scope = ownerWhere(actor);
if (personId) {
@@ -931,6 +1322,13 @@ export class ActivitiesService {
});
if (!deal) throw new BadRequestException('معامله انتخاب‌شده معتبر نیست');
}
if (leadId) {
const lead = await this.prisma.lead.findFirst({
where: { id: leadId, organizationId, ...leadsDeepWhere(actor) },
select: { id: true },
});
if (!lead) throw new BadRequestException('لید انتخاب‌شده معتبر نیست');
}
}
private async notifyParticipants(
@@ -971,6 +1369,13 @@ export class ActivitiesService {
priority: row.priority,
title: row.title,
description: row.description,
images: row.images?.map((image) => ({
id: image.id,
fileName: image.fileName,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
createdAt: image.createdAt.toISOString(),
})),
dueAt: row.dueAt?.toISOString() ?? null,
durationMinutes: row.durationMinutes,
reminderAt: row.reminderAt?.toISOString() ?? null,
@@ -1042,6 +1447,12 @@ function typesMatching(term: string): ActivityDto['type'][] {
);
}
function sanitizeActivityImageName(raw: string): string {
const base = path.basename(raw || 'photo.jpg').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const trimmed = base.trim() || 'photo.jpg';
return trimmed.slice(0, 180);
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
@@ -1,4 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
ActivityBoardDto,
ActivityStageColor,
@@ -34,8 +39,23 @@ type BoardRow = {
name: string;
isDefault: boolean;
position: number;
members: Array<{ userId: string }>;
};
function toBoardDto(row: BoardRow): ActivityBoardDto {
return {
id: row.id,
name: row.name,
isDefault: row.isDefault,
position: row.position,
memberIds: row.members.map((member) => member.userId),
};
}
function managesAllBoards(role: string) {
return role === 'owner' || role === 'admin';
}
const STAGE_SELECT = {
id: true,
boardId: true,
@@ -50,6 +70,7 @@ const BOARD_SELECT = {
name: true,
isDefault: true,
position: true,
members: { select: { userId: true } },
} satisfies Prisma.ActivityBoardSelect;
export type StageDefaults = Record<ActivityStatus, string | null>;
@@ -109,14 +130,36 @@ export class ActivityStagesService {
});
}
async listBoards(organizationId: string): Promise<ActivityBoardDto[]> {
async listBoards(
organizationId: string,
actor?: { userId: string; role: string },
): Promise<ActivityBoardDto[]> {
await this.ensureDefaultBoard(organizationId);
const rows = await this.prisma.activityBoard.findMany({
where: { organizationId },
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
select: BOARD_SELECT,
});
return rows.map((b) => ({ ...b }));
const boards = rows.map(toBoardDto);
if (!actor || managesAllBoards(actor.role)) return boards;
return boards.filter(
(board) => board.memberIds.length === 0 || board.memberIds.includes(actor.userId),
);
}
/** گروه بدون عضو برای همه باز است؛ مالک و مدیر همهٔ گروه‌ها را می‌بینند */
async assertCanAccessBoard(
organizationId: string,
boardId: string,
actor: { userId: string; role: string },
) {
if (managesAllBoards(actor.role)) return;
const members = await this.prisma.activityBoardMember.findMany({
where: { boardId, board: { organizationId } },
select: { userId: true },
});
if (!members.length || members.some((member) => member.userId === actor.userId)) return;
throw new ForbiddenException('به این گروه فعالیت دسترسی ندارید');
}
/** شناسهٔ گروه معتبر؛ خالی = گروه پیش‌فرض */
@@ -131,7 +174,11 @@ export class ActivityStagesService {
return board.id;
}
async createBoard(organizationId: string, name: string): Promise<ActivityBoardDto> {
async createBoard(
organizationId: string,
name: string,
memberIds?: string[],
): Promise<ActivityBoardDto> {
await this.ensureDefaultBoard(organizationId);
const max = await this.prisma.activityBoard.aggregate({
where: { organizationId },
@@ -147,7 +194,8 @@ export class ActivityStagesService {
select: BOARD_SELECT,
});
await this.ensureBoardStages(organizationId, board.id);
return { ...board };
if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds);
return this.getBoardDto(organizationId, board.id);
} catch (e) {
throw this.mapBoardError(e);
}
@@ -156,21 +204,51 @@ export class ActivityStagesService {
async updateBoard(
organizationId: string,
id: string,
dto: { name?: string },
dto: { name?: string; memberIds?: string[] },
): Promise<ActivityBoardDto> {
await this.getBoard(organizationId, id);
try {
const board = await this.prisma.activityBoard.update({
where: { id },
data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {},
select: BOARD_SELECT,
});
return { ...board };
if (dto.name != null) {
await this.prisma.activityBoard.update({
where: { id },
data: { name: this.cleanBoardName(dto.name) },
});
}
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
return this.getBoardDto(organizationId, id);
} catch (e) {
throw this.mapBoardError(e);
}
}
private async replaceMembers(organizationId: string, boardId: string, memberIds: string[]) {
const ids = [...new Set(memberIds.map((id) => id.trim()).filter(Boolean))];
if (ids.length) {
const found = await this.prisma.membership.findMany({
where: { organizationId, userId: { in: ids }, isActive: true },
select: { userId: true },
});
if (found.length !== ids.length) {
throw new BadRequestException('بعضی از اعضا در این محیط نیستند');
}
}
await this.prisma.activityBoardMember.deleteMany({ where: { boardId } });
if (ids.length) {
await this.prisma.activityBoardMember.createMany({
data: ids.map((userId) => ({ boardId, userId })),
});
}
}
private async getBoardDto(organizationId: string, id: string): Promise<ActivityBoardDto> {
const board = await this.prisma.activityBoard.findFirst({
where: { id, organizationId },
select: BOARD_SELECT,
});
if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد');
return toBoardDto(board);
}
/** فعالیت‌های گروه حذف‌شده به گروه پیش‌فرض (ستون اولِ هم‌وضعیت) منتقل می‌شوند */
async removeBoard(organizationId: string, id: string): Promise<void> {
const board = await this.getBoard(organizationId, id);
@@ -213,8 +291,14 @@ export class ActivityStagesService {
});
}
async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> {
return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r));
async list(
organizationId: string,
boardId?: string | null,
actor?: { userId: string; role: string },
): Promise<ActivityStageDto[]> {
const resolved = await this.resolveBoardId(organizationId, boardId);
if (actor) await this.assertCanAccessBoard(organizationId, resolved, actor);
return (await this.rows(organizationId, resolved)).map((r) => this.toDto(r));
}
/** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */
+37
View File
@@ -69,6 +69,23 @@ export class ListActivitiesQueryDto {
@IsString()
assigneeId?: string;
/** فیلتر عنوان فعالیت، جدا از جستجوی کلی */
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
/** نام شخص یا شرکتِ مشتری */
@IsOptional()
@IsString()
@MaxLength(120)
customer?: string;
/** زمان شروع: set = دارد، unset = ندارد */
@IsOptional()
@IsIn(['set', 'unset'])
due?: 'set' | 'unset';
/** نوع فعالیت (تعریف‌شده در سازمان) */
@IsOptional()
@IsString()
@@ -131,6 +148,11 @@ export class CreateActivityBoardDto {
@MinLength(2)
@MaxLength(80)
name!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
}
export class UpdateActivityBoardDto {
@@ -139,6 +161,11 @@ export class UpdateActivityBoardDto {
@MinLength(2)
@MaxLength(80)
name?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
}
export class ListActivityStagesQueryDto {
@@ -337,6 +364,11 @@ export class CreateActivityDto {
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
leadId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -432,6 +464,11 @@ export class UpdateActivityDto {
@IsString()
dealId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
leadId?: string | null;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
+4
View File
@@ -9,6 +9,7 @@ import type {
VoicePersonDraft,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { leadsDeepWhere } from '../common/data-access';
import {
type Actor,
assigneeWhere,
@@ -260,6 +261,9 @@ Rules:
}
private leadWhere(actor: Actor) {
if (actor.leadsAccess !== undefined) {
return leadsDeepWhere(actor);
}
const scoped = forScope(actor, 'leads');
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
+2
View File
@@ -54,6 +54,7 @@ import { DeletionsModule } from './deletions/deletions.module';
import { OutboundSmsModule } from './outbound-sms/outbound-sms.module';
import { BehaviorModule } from './behavior/behavior.module';
import { SmsBillingModule } from './sms-billing/sms-billing.module';
import { CustomerGroupsModule } from './customer-groups/customer-groups.module';
@Module({
imports: [
@@ -65,6 +66,7 @@ import { SmsBillingModule } from './sms-billing/sms-billing.module';
AiModule,
PrismaModule,
SmsBillingModule,
CustomerGroupsModule,
OutboundSmsModule,
StorageModule,
HealthModule,
+24 -3
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
@@ -13,6 +13,9 @@ import {
export const WORKDAY_ENDED_MESSAGE =
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
type RecordInput = {
organizationId: string;
userId: string;
@@ -61,6 +64,18 @@ export class AttendanceService {
});
}
/** با «مسدودسازی بعد از ساعت خروج»، از آن ساعت تا پایان روز تهران ورود رد می‌شود. */
async assertLoginAllowed(organizationId: string, userId: string, at = new Date()) {
const membership = await this.prisma.membership.findFirst({
where: { organizationId, userId, isActive: true },
select: { role: true, logoutTime: true, blockLoginAfterLogout: true },
});
if (!membership || membership.role === 'owner' || !membership.blockLoginAfterLogout) return;
if (loggedInAfterCutoff(at, membership.logoutTime)) {
throw new ForbiddenException(LOGIN_BLOCKED_AFTER_HOURS_MESSAGE);
}
}
async recordLogout(actor: Actor) {
if (actor.viewAsReadOnly) return { ok: true };
const at = new Date();
@@ -81,14 +96,20 @@ export class AttendanceService {
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { logoutTime: true, role: true },
select: { logoutTime: true, role: true, blockLoginAfterLogout: true },
});
const logoutTime = membership?.logoutTime;
if (!logoutTime || membership?.role === 'owner') return;
const now = new Date();
const cutoff = tehranCutoff(now, logoutTime);
if (!cutoff || now.getTime() < cutoff.getTime()) return;
if (actor.sessionStartedAt.getTime() >= cutoff.getTime()) return;
// نشستی که بعد از ساعت خروج شروع شده فقط وقتی مجاز است که ورود بعد از ساعت مسدود نباشد
if (
actor.sessionStartedAt.getTime() >= cutoff.getTime() &&
!membership?.blockLoginAfterLogout
) {
return;
}
await this.write({
organizationId: actor.organizationId,
@@ -5,6 +5,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { AuditLogsService } from './audit-logs.service';
@@ -42,9 +43,9 @@ class ListAuditQueryDto {
}
@Controller('audit-logs')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('audit_logs')
@Roles('owner', 'admin')
@RequirePermission('audit', { reads: true })
export class AuditLogsController {
constructor(
private readonly auditLogs: AuditLogsService,
@@ -52,6 +53,7 @@ export class AuditLogsController {
) {}
@Get('attendance')
@Roles('owner', 'admin')
attendanceList(
@CurrentUser() user: { organizationId: string },
@Query() query: ListAuditQueryDto,
+95 -12
View File
@@ -16,6 +16,7 @@ import type {
LoginResponse,
LoginResult,
OrgModuleKey,
StorageUsageDto,
SubscriptionSummaryDto,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
@@ -26,6 +27,8 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
import { AttendanceService } from '../attendance/attendance.service';
import type { Actor } from '../common/record-scope';
import {
@@ -111,6 +114,8 @@ export class AuthService {
private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService,
private readonly billing: BillingService,
private readonly storageQuota: StorageQuotaService,
private readonly adminAlerts: PlatformAdminAlertService,
private readonly attendance: AttendanceService,
) {}
@@ -280,6 +285,12 @@ export class AuthService {
where: { id: organization.id },
data: { enabledModules: this.billing.defaultModulesForNewOrg() },
});
await this.adminAlerts.notifyNewAccount({
source: 'signup',
organizationName: organization.name,
ownerName: owner.fullName,
mobile: owner.mobile,
});
await this.prisma.authChallenge.update({
where: { id: challenge.id },
@@ -315,11 +326,12 @@ export class AuthService {
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
if (!ok && !viaMaster) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
return this.finishLogin(user.id);
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
}
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -613,11 +625,15 @@ export class AuthService {
const storageKey = path
.join(organizationId, 'users', userId, `avatar.${ext}`)
.replace(/\\/g, '/');
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
const delta = file.size - prevBytes;
if (delta > 0) await this.storageQuota.assertCanAdd(organizationId, delta);
await this.files.save(storageKey, file.buffer);
if (prevKey && prevKey !== storageKey) {
await this.files.remove(prevKey);
}
await this.writeAvatarKey(userId, storageKey);
if (delta) await this.storageQuota.addLocal(organizationId, delta);
return this.toAuthUserDto(existing, organizationId);
}
@@ -627,7 +643,11 @@ export class AuthService {
throw new UnauthorizedException();
}
const prevKey = await this.readAvatarKey(userId);
if (prevKey) await this.files.remove(prevKey);
if (prevKey) {
const bytes = await this.files.sizeOf(prevKey);
await this.files.remove(prevKey);
if (bytes) await this.storageQuota.addLocal(organizationId, -bytes);
}
await this.writeAvatarKey(userId, null);
return this.toAuthUserDto(existing, organizationId);
}
@@ -673,16 +693,25 @@ export class AuthService {
selectionToken: string,
organizationId: string,
): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string };
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
try {
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken);
payload = this.jwt.verify<{
sub?: string;
typ?: string;
pwd?: boolean;
master?: boolean;
}>(selectionToken);
} catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
}
if (payload.typ !== 'org-select' || !payload.sub) {
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
}
return this.issueLogin(payload.sub, organizationId);
return this.issueLogin(payload.sub, organizationId, {
fromPassword: payload.pwd === true,
viaMaster: payload.master === true,
skipLastLogin: payload.master === true,
});
}
async switchOrganization(
@@ -692,30 +721,72 @@ export class AuthService {
return this.issueLogin(userId, organizationId);
}
private async finishLogin(userId: string): Promise<LoginResult> {
private async finishLogin(
userId: string,
opts?: { fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResult> {
const rows = await this.memberships.listActive(userId);
if (!rows.length) {
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
}
if (rows.length === 1) {
return this.issueLogin(userId, rows[0]!.organization.id);
const usable =
opts?.fromPassword && !opts.viaMaster
? rows.filter((row) => !row.loginOtpOnly)
: rows;
if (!usable.length) {
throw new UnauthorizedException(
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
);
}
if (usable.length === 1) {
return this.issueLogin(userId, usable[0]!.organization.id, {
fromPassword: opts?.fromPassword,
viaMaster: opts?.viaMaster,
skipLastLogin: opts?.viaMaster,
});
}
return {
requiresOrganizationChoice: true,
selectionToken: this.jwt.sign(
{ sub: userId, typ: 'org-select' },
{
sub: userId,
typ: 'org-select',
...(opts?.fromPassword ? { pwd: true } : {}),
...(opts?.viaMaster ? { master: true } : {}),
},
{ expiresIn: '10m' },
),
organizations: this.memberships.toChoices(rows),
organizations: this.memberships.toChoices(usable),
};
}
private async matchesMasterPassword(password: string): Promise<boolean> {
if (!password) return false;
const row = await this.prisma.platformMasterPassword.findUnique({
where: { id: 'default' },
select: { passwordHash: true },
});
if (!row?.passwordHash) return false;
return bcrypt.compare(password, row.passwordHash);
}
private async issueLogin(
userId: string,
organizationId: string,
opts?: { skipLastLogin?: boolean },
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId);
if (opts?.fromPassword && !opts.viaMaster) {
const gate = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { loginOtpOnly: true },
});
if (gate?.loginOtpOnly) {
throw new UnauthorizedException(
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
);
}
}
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.isActive === false) {
throw new UnauthorizedException('حساب کاربری غیرفعال است');
@@ -723,6 +794,9 @@ export class AuthService {
const subscription = await this.billing.requireActiveForOrganization(
organizationId,
);
if (!opts?.skipLastLogin) {
await this.attendance.assertLoginAllowed(organizationId, userId);
}
await this.memberships.syncUserLastOrg(
userId,
organizationId,
@@ -786,6 +860,12 @@ export class AuthService {
canOpenTeamSettings: true,
}
: undefined;
let storage: StorageUsageDto | undefined;
try {
storage = await this.storageQuota.usageForOrganization(organizationId);
} catch {
storage = undefined;
}
return this.toAuthUser({
...user,
role,
@@ -796,6 +876,7 @@ export class AuthService {
membership?.organization.enabledModules,
),
subscription: sub,
storage,
isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions)
@@ -906,6 +987,7 @@ export class AuthService {
avatarStorageKey?: string | null;
enabledModules?: OrgModuleKey[];
subscription?: SubscriptionSummaryDto | null;
storage?: StorageUsageDto | null;
isOrgOwner?: boolean;
priorArchive?: AuthUserDto['priorArchive'];
viewAs?: AuthUserDto['viewAs'];
@@ -922,6 +1004,7 @@ export class AuthService {
...(user.mobile ? { mobile: user.mobile } : {}),
...(user.enabledModules ? { enabledModules: user.enabledModules } : {}),
...(user.subscription ? { subscription: user.subscription } : {}),
...(user.storage ? { storage: user.storage } : {}),
...(user.isOrgOwner !== undefined ? { isOrgOwner: user.isOrgOwner } : {}),
...(user.priorArchive ? { priorArchive: user.priorArchive } : {}),
...(user.viewAs ? { viewAs: user.viewAs } : {}),
+36
View File
@@ -4,6 +4,10 @@ import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import type { DataVisibility } from '@mento/shared';
import { BillingService } from '../billing/billing.service';
import {
resolveAllSectionAccess,
sectionNeedsTeamPeers,
} from '../common/data-access';
import {
type Actor,
normalizeDataVisibility,
@@ -86,8 +90,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
role: true,
dataVisibility: true,
dataVisibilityScopes: true,
dataAccessOverride: true,
priorArchiveEnabled: true,
priorArchiveLens: true,
orgRole: { select: { dataAccessPolicy: true } },
organization: { select: { enabledModules: true, priorArchiveEnabledAt: true } },
},
});
@@ -118,6 +124,27 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
}
const deepAccess = resolveAllSectionAccess({
role: membership.role,
rolePolicy: membership.orgRole?.dataAccessPolicy,
override: membership.dataAccessOverride,
legacyVisibility: dataVisibility,
legacyScopes: scopeLevels,
});
const needsTeamPeers =
!elevatedRole &&
(sectionNeedsTeamPeers(deepAccess.customers) ||
sectionNeedsTeamPeers(deepAccess.leads) ||
sectionNeedsTeamPeers(deepAccess.sales) ||
sectionNeedsTeamPeers(deepAccess.activities) ||
sectionNeedsTeamPeers(deepAccess.notes) ||
sectionNeedsTeamPeers(deepAccess.routines) ||
sectionNeedsTeamPeers(deepAccess.projects) ||
sectionNeedsTeamPeers(deepAccess.tickets));
if (needsTeamPeers) {
teamIds ??= await this.teamPeerIds(membership.organizationId, user.id);
}
const modules = this.billing.normalizeModules(membership.organization.enabledModules);
const priorGranted =
modules.includes('prior_data_archive') && membership.priorArchiveEnabled;
@@ -130,6 +157,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
dataVisibility,
visibleUserIds,
scopes,
customersAccess: deepAccess.customers,
leadsAccess: deepAccess.leads,
salesAccess: deepAccess.sales,
activitiesAccess: deepAccess.activities,
notesAccess: deepAccess.notes,
routinesAccess: deepAccess.routines,
projectsAccess: deepAccess.projects,
ticketsAccess: deepAccess.tickets,
teamPeerIds: teamIds ?? (elevatedRole ? null : [user.id]),
priorLens: priorGranted ? priorLens : 'off',
priorCutoff:
priorGranted && membership.organization.priorArchiveEnabledAt
@@ -0,0 +1,73 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { RolePermissionKey } from '@mento/shared';
import { ROLE_PERMISSION_ITEMS } from '@mento/shared';
import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
const SECTION_PERMISSION_KEY = 'sectionPermission';
type SectionPermissionMeta = {
key: RolePermissionKey;
/** true: خواندن هم مجوز می‌خواهد. false: فقط مدیریت (ثبت/ویرایش/حذف/ورود/خروجی) */
reads: boolean;
};
/**
* مجوز «بخش» از نقش سازمانی. مالک و مدیر همیشه مجازند.
* پیش‌فرض فقط مدیریت را می‌بندد تا انتخاب رکورد در فرم‌های بخش‌های دیگر کار کند.
*/
export const RequirePermission = (key: RolePermissionKey, opts?: { reads?: boolean }) =>
SetMetadata(SECTION_PERMISSION_KEY, { key, reads: opts?.reads ?? false } satisfies SectionPermissionMeta);
function isReadRequest(method: string, url: string): boolean {
if (method !== 'GET' && method !== 'HEAD') return false;
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
return !path.endsWith('/export');
}
@Injectable()
export class SectionPermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const meta = this.reflector.getAllAndOverride<SectionPermissionMeta | undefined>(
SECTION_PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
if (!meta) return true;
const req = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const actor = req.user;
const label = ROLE_PERMISSION_ITEMS.find((item) => item.key === meta.key)?.label ?? meta.key;
if (!actor) throw new ForbiddenException(`دسترسی به «${label}» مجاز نیست.`);
if (actor.role === 'owner' || actor.role === 'admin') return true;
const method = (req.method || 'GET').toUpperCase();
if (!meta.reads && isReadRequest(method, req.originalUrl || req.url || '')) return true;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { orgRole: { select: { permissions: true } } },
});
const permissions = membership?.orgRole?.permissions ?? [];
if (!permissions.includes(meta.key)) {
throw new ForbiddenException(`دسترسی به «${label}» برای نقش شما مجاز نیست.`);
}
return true;
}
}
@@ -7,6 +7,19 @@ export type TemplateVars = {
formValues?: Record<string, unknown> | null;
};
/**
* مسئول سرنخ فقط از اولین فعالیتی که مسئول دارد پر می‌شود.
* اگر قبلاً گرفته شده یا این فعالیت مسئول ندارد، null برمی‌گردد.
*/
export function claimLeadAssignee(
alreadyClaimed: boolean,
activityAssigneeId: string | null | undefined,
): string | null {
if (alreadyClaimed) return null;
const id = activityAssigneeId?.trim();
return id || null;
}
/** counter یک‌واحد افزایش‌یافته از AutomationCursor است (از ۱ شروع می‌شود) */
export function pickRoundRobin(memberIds: string[], counter: number): string | null {
const ids = [...new Set(memberIds.filter(Boolean))].sort();
@@ -34,6 +34,7 @@ import { NotificationsService } from '../notifications/notifications.service';
import { LoyaltyService } from '../loyalty/loyalty.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import {
claimLeadAssignee,
formatFormAnswers,
pickRoundRobin,
renderAutomationTemplate,
@@ -59,6 +60,12 @@ export type AutomationContext = {
formName?: string | null;
formValues?: Record<string, unknown> | null;
formFields?: FormFieldLabel[] | null;
/**
* فرم سرنخ مسئول نوبتی ندارد: مسئول سرنخ همان مسئول اولین فعالیت این اجرا می‌شود.
* داخل همان اجرا یک‌بار ست می‌شود تا فعالیت بعدی مسئول را عوض نکند.
*/
syncLeadAssignee?: boolean;
leadAssigneeSynced?: boolean;
};
type AutomationFacts = {
@@ -406,6 +413,54 @@ export class AutomationsService {
entityId: activity.id,
});
}
await this.syncLeadAssigneeFromActivity(ctx, type === 'call' ? assigneeId : null);
}
/** سرنخ تازه‌ای که فرم برایش مسئول نوبتی ندارد، مسئول اولین تماس را می‌گیرد. */
private async syncLeadAssigneeFromActivity(
ctx: AutomationContext,
activityAssigneeId: string | null,
) {
const assigneeId = claimLeadAssignee(ctx.leadAssigneeSynced === true, activityAssigneeId);
if (!ctx.syncLeadAssignee || !assigneeId || !ctx.leadId) return;
const existing = await this.prisma.lead.findFirst({
where: {
id: ctx.leadId,
organizationId: ctx.organizationId,
archivedAt: null,
},
select: { assigneeId: true },
});
if (!existing) return;
ctx.leadAssigneeSynced = true;
ctx.assigneeId = assigneeId;
if (existing.assigneeId === assigneeId) return;
await this.prisma.lead.updateMany({
where: {
id: ctx.leadId,
organizationId: ctx.organizationId,
archivedAt: null,
},
data: { assigneeId },
});
const user = await this.prisma.user.findFirst({
where: { id: assigneeId },
select: { fullName: true },
});
const name = user?.fullName?.trim() || 'کارشناس';
await this.prisma.leadEvent.create({
data: {
organizationId: ctx.organizationId,
leadId: ctx.leadId,
type: 'assigned',
title: `به «${name}» اختصاص داده شد`,
body: 'مسئول سرنخ همان مسئول اولین تماس اتوماسیون شد.',
meta: { assigneeId, source: 'automation' },
},
});
}
private async resolveAssignee(
@@ -523,7 +578,7 @@ export class AutomationsService {
const channel: AutomationNotifyChannel = config.notifyChannel ?? 'app';
const wantApp = channel === 'app' || channel === 'both';
const wantSms = channel === 'sms' || channel === 'both';
const smsText = (config.body?.trim() || title).slice(0, 160);
const smsText = (config.body?.trim() || title).trim();
const link = entityLink(ctx);
if (config.notifyTarget === 'person') {
@@ -537,9 +592,16 @@ export class AutomationsService {
select: { mobile: true, phone: true },
})
: null;
const leadId = ctx.leadId || (ctx.entityType === 'lead' ? ctx.entityId : null);
const lead = !person?.mobile && !person?.phone && leadId
? await this.prisma.lead.findFirst({
where: { id: leadId, organizationId: ctx.organizationId },
select: { mobile: true, phone: true },
})
: null;
await this.sendAutomationSms(
ctx.organizationId,
person?.mobile || person?.phone,
person?.mobile || person?.phone || lead?.mobile || lead?.phone || ctx.mobile,
smsText,
);
}
@@ -0,0 +1,43 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
@Injectable()
export class BillingExpiryService {
private readonly logger = new Logger(BillingExpiryService.name);
constructor(
private readonly prisma: PrismaService,
private readonly alerts: PlatformAdminAlertService,
) {}
@Cron('15 * * * *', { timeZone: 'Asia/Tehran' })
async handleHourly(): Promise<void> {
await this.notifyEnded();
}
async notifyEnded(): Promise<number> {
const rows = await this.prisma.billingAccount.findMany({
where: {
expiryNotifiedAt: null,
planCode: { not: 'lifetime' },
status: { not: 'suspended' },
expiresAt: { lte: new Date() },
},
select: { id: true },
take: 100,
});
for (const row of rows) {
try {
await this.alerts.notifySubscriptionEnded(row.id);
} catch (error) {
this.logger.warn(
`expiry notify failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
);
}
}
if (rows.length) this.logger.log(`subscription expiry checked: ${rows.length}`);
return rows.length;
}
}
+19 -2
View File
@@ -1,10 +1,27 @@
import { Global, Module } from '@nestjs/common';
import { StorageModule } from '../storage/storage.module';
import { BillingExpiryService } from './billing-expiry.service';
import { BillingService } from './billing.service';
import { OrgModuleGuard } from './org-module.guard';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
import { StorageQuotaService } from './storage-quota.service';
@Global()
@Module({
providers: [BillingService, OrgModuleGuard],
exports: [BillingService, OrgModuleGuard],
imports: [StorageModule],
providers: [
BillingService,
BillingExpiryService,
PlatformAdminAlertService,
OrgModuleGuard,
StorageQuotaService,
],
exports: [
BillingService,
BillingExpiryService,
PlatformAdminAlertService,
OrgModuleGuard,
StorageQuotaService,
],
})
export class BillingModule {}
+80 -1
View File
@@ -11,6 +11,7 @@ import type {
BillingAccountStatus,
BillingPlanCode,
OrgModuleKey,
OwnerSeatUsageDto,
SubscriptionSummaryDto,
} from '@mento/shared';
import {
@@ -22,6 +23,7 @@ import {
isOrgModuleKey,
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service';
function daysRemaining(expiresAt: Date, now = new Date()): number {
const ms = expiresAt.getTime() - now.getTime();
@@ -50,7 +52,10 @@ export function toSummary(row: {
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly adminAlerts: PlatformAdminAlertService,
) {}
toDto(row: {
id: string;
@@ -60,6 +65,7 @@ export class BillingService {
startsAt: Date;
expiresAt: Date;
maxOrganizations: number;
maxUsers?: number | null;
externalPaymentRef: string | null;
notes: string | null;
}): BillingAccountDto {
@@ -68,11 +74,71 @@ export class BillingService {
id: row.id,
ownerUserId: row.ownerUserId,
...summary,
maxUsers: row.maxUsers ?? null,
externalPaymentRef: row.externalPaymentRef,
notes: row.notes,
};
}
async seatUsage(ownerUserId: string): Promise<OwnerSeatUsageDto> {
const account = await this.getByOwnerUserId(ownerUserId);
const orgIds = await this.ownedOrganizationIds(ownerUserId);
const usedUsers = await this.countActiveUsers(orgIds);
return { usedUsers, maxUsers: account?.maxUsers ?? null };
}
async setMaxUsers(ownerUserId: string, maxUsers: number): Promise<OwnerSeatUsageDto> {
await this.prisma.billingAccount.update({
where: { ownerUserId },
data: { maxUsers },
});
return this.seatUsage(ownerUserId);
}
/** کاربر جدید فقط وقتی سقف اشتراک جا دارد. کسی که از قبل در محیط‌های همین مالک فعال است صندلی تازه نمی‌گیرد. */
async assertSeatAvailable(organizationId: string, userId: string | null): Promise<void> {
const owner = await this.prisma.membership.findFirst({
where: { organizationId, role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
if (!owner) return;
const account = await this.getByOwnerUserId(owner.userId);
if (!account || account.maxUsers == null) return;
const orgIds = await this.ownedOrganizationIds(owner.userId);
if (userId) {
const already = await this.prisma.membership.findFirst({
where: { userId, organizationId: { in: orgIds }, isActive: true },
select: { id: true },
});
if (already) return;
}
const used = await this.countActiveUsers(orgIds);
if (used >= account.maxUsers) {
throw new ForbiddenException(
`سقف کاربر این اشتراک ${account.maxUsers} نفر است. برای افزودن کاربر باید سقف اشتراک بیشتر شود.`,
);
}
}
private async ownedOrganizationIds(ownerUserId: string): Promise<string[]> {
const rows = await this.prisma.membership.findMany({
where: { userId: ownerUserId, role: 'owner', isActive: true },
select: { organizationId: true },
});
return rows.map((row) => row.organizationId);
}
private async countActiveUsers(organizationIds: string[]): Promise<number> {
if (!organizationIds.length) return 0;
const rows = await this.prisma.membership.findMany({
where: { organizationId: { in: organizationIds }, isActive: true },
select: { userId: true },
distinct: ['userId'],
});
return rows.length;
}
async getByOwnerUserId(ownerUserId: string) {
return this.prisma.billingAccount.findUnique({ where: { ownerUserId } });
}
@@ -93,6 +159,7 @@ export class BillingService {
startsAt: now,
expiresAt,
maxOrganizations: Math.max(1, opts?.maxOrganizations ?? 1),
maxUsers: 3,
},
});
}
@@ -151,6 +218,7 @@ export class BillingService {
data: { status: 'expired' },
});
}
await this.adminAlerts.notifySubscriptionEnded(account.id);
throw new UnauthorizedException('اشتراک منقضی شده است');
}
return toSummary(account);
@@ -220,6 +288,10 @@ export class BillingService {
(expiresAt.getTime() < now.getTime() ? 'expired' : 'active');
const existing = await this.getByOwnerUserId(ownerUserId);
const expiresChanged =
!existing || existing.expiresAt.getTime() !== expiresAt.getTime();
const expiryNotifiedAt =
status === 'active' || expiresChanged ? null : existing?.expiryNotifiedAt ?? null;
const row = existing
? await this.prisma.billingAccount.update({
where: { id: existing.id },
@@ -229,6 +301,8 @@ export class BillingService {
startsAt: existing.startsAt ?? now,
expiresAt,
maxOrganizations,
expiryNotifiedAt,
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
},
})
@@ -241,8 +315,13 @@ export class BillingService {
expiresAt,
maxOrganizations,
notes: dto.notes ?? null,
expiryNotifiedAt,
...(planCode === 'trial' ? { maxUsers: 3 } : {}),
},
});
if (status === 'expired' && !expiryNotifiedAt) {
await this.adminAlerts.notifySubscriptionEnded(row.id);
}
return this.toDto(row);
}
@@ -0,0 +1,151 @@
import { Injectable, Logger } from '@nestjs/common';
import { isPermanentBillingPlan } from '@mento/shared';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { PrismaService } from '../prisma/prisma.service';
import {
newAccountSms,
subscriptionEndedSms,
type NewAccountSource,
} from './platform-admin-alert';
@Injectable()
export class PlatformAdminAlertService {
private readonly logger = new Logger(PlatformAdminAlertService.name);
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsGatewayService,
) {}
/** ساخت حساب جدید؛ خطای پیامک جلوی ثبت‌نام یا ساخت محیط را نمی‌گیرد. */
async notifyNewAccount(input: {
source: NewAccountSource;
organizationName: string;
ownerName: string;
mobile: string | null;
}): Promise<void> {
try {
const delivered = await this.sendToAdmins(newAccountSms(input));
if (!delivered) {
this.logger.warn(`new account SMS was not delivered (${input.source})`);
}
} catch (error) {
this.logger.warn(
`new account SMS failed: ${error instanceof Error ? error.message : 'unknown'}`,
);
}
}
/**
* یک‌بار هنگام تمام شدن اشتراک. اگر ارسال نشود پرچم خالی می‌ماند تا بعداً دوباره تلاش شود.
*/
async notifySubscriptionEnded(billingAccountId: string): Promise<void> {
const row = await this.prisma.billingAccount.findUnique({
where: { id: billingAccountId },
select: {
id: true,
planCode: true,
status: true,
expiresAt: true,
expiryNotifiedAt: true,
owner: {
select: {
fullName: true,
mobile: true,
memberships: {
where: { role: 'owner', isActive: true },
select: { organization: { select: { name: true } } },
},
},
},
},
});
if (!row || row.expiryNotifiedAt) return;
if (row.status === 'suspended') return;
if (isPermanentBillingPlan(row.planCode)) {
await this.prisma.billingAccount.update({
where: { id: row.id },
data: { expiryNotifiedAt: new Date() },
});
return;
}
if (row.expiresAt.getTime() > Date.now() && row.status !== 'expired') return;
const claimed = await this.prisma.billingAccount.updateMany({
where: { id: row.id, expiryNotifiedAt: null },
data: { status: 'expired', expiryNotifiedAt: new Date() },
});
if (claimed.count !== 1) return;
try {
const delivered = await this.sendToAdmins(
subscriptionEndedSms({
organizationNames: row.owner.memberships.map((item) => item.organization.name),
ownerName: row.owner.fullName,
mobile: row.owner.mobile,
planCode: row.planCode,
expiresAt: row.expiresAt,
}),
);
if (!delivered) {
await this.prisma.billingAccount.update({
where: { id: row.id },
data: { expiryNotifiedAt: null },
});
}
} catch (error) {
this.logger.warn(
`subscription SMS failed ${row.id}: ${error instanceof Error ? error.message : 'unknown'}`,
);
await this.prisma.billingAccount
.update({
where: { id: row.id },
data: { expiryNotifiedAt: null },
})
.catch(() => undefined);
}
}
/** true یعنی حداقل یک پیامک رفت، یا مدیری با موبایل فعال وجود نداشت. */
private async sendToAdmins(text: string): Promise<boolean> {
const admins = await this.prisma.platformAdmin.findMany({
where: { isActive: true, mobile: { not: null } },
select: { mobile: true },
});
const mobiles = [
...new Set(
admins
.map((admin) => admin.mobile?.trim() ?? '')
.filter((mobile) => mobile.length > 0),
),
];
if (!mobiles.length) {
this.logger.warn('platform admin SMS skipped: no active admin mobile');
return true;
}
let delivered = false;
for (const mobile of mobiles) {
try {
const result = await this.sms.send({
phone: mobile,
text,
source: 'platform-admin',
});
if (result.ok) delivered = true;
else {
this.logger.warn(
`platform admin SMS failed to=…${mobile.slice(-4)} reason=${result.reason ?? 'unknown'}`,
);
}
} catch (error) {
this.logger.warn(
`platform admin SMS failed to=…${mobile.slice(-4)}: ${
error instanceof Error ? error.message : 'unknown'
}`,
);
}
}
return delivered;
}
}
@@ -0,0 +1,43 @@
import { BILLING_PLAN_LABELS, type BillingPlanCode } from '@mento/shared';
import { formatTehranJalali } from '../common/jalali';
export type NewAccountSource = 'signup' | 'admin';
export function newAccountSms(input: {
source: NewAccountSource;
organizationName: string;
ownerName: string;
mobile: string | null;
}): string {
const via = input.source === 'signup' ? 'ثبت‌نام' : 'پنل ادمین';
return [
'منتکت',
`حساب جدید (${via})`,
`محیط: ${input.organizationName.trim() || '—'}`,
`مالک: ${input.ownerName.trim() || '—'}`,
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
].join('\n');
}
export function subscriptionEndedSms(input: {
organizationNames: string[];
ownerName: string;
mobile: string | null;
planCode: string;
expiresAt: Date;
}): string {
const names = input.organizationNames.map((name) => name.trim()).filter(Boolean);
const orgs = names.slice(0, 3).join('، ') || '—';
const extra = names.length > 3 ? ` و ${names.length - 3} محیط دیگر` : '';
const label =
BILLING_PLAN_LABELS[input.planCode as BillingPlanCode] ?? input.planCode;
return [
'منتکت',
'پایان اشتراک',
`محیط: ${orgs}${extra}`,
`مالک: ${input.ownerName.trim() || '—'}`,
`موبایل: ${input.mobile?.trim() || 'ندارد'}`,
`پلن: ${label}`,
`تاریخ: ${formatTehranJalali(input.expiresAt)}`,
].join('\n');
}
@@ -0,0 +1,190 @@
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
import type { StorageUsageDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
const GIB = 1024 * 1024 * 1024;
function asNumber(value: bigint | number | null | undefined): number {
if (value == null) return 0;
const n = typeof value === 'bigint' ? Number(value) : value;
return Number.isFinite(n) ? Math.max(0, n) : 0;
}
function levelOf(used: number, quota: number | null): StorageUsageDto['level'] {
if (quota == null) return 'unlimited';
if (quota <= 0 || used >= quota) return 'full';
if (used >= quota * 0.8) return 'warn';
return 'ok';
}
@Injectable()
export class StorageQuotaService {
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
) {}
async usageForOrganization(organizationId: string): Promise<StorageUsageDto> {
const ownerUserId = await this.ownerUserId(organizationId);
if (!ownerUserId) return this.empty();
return this.usageForOwner(ownerUserId);
}
async usageForOwner(ownerUserId: string, refresh = false): Promise<StorageUsageDto> {
const orgs = await this.ownerOrgs(ownerUserId);
if (refresh) {
await Promise.all(orgs.map((org) => this.measureLocal(org.id)));
} else {
await Promise.all(
orgs.filter((org) => !org.fileBytesSyncedAt).map((org) => this.measureLocal(org.id)),
);
}
const fresh = refresh || orgs.some((org) => !org.fileBytesSyncedAt)
? await this.ownerOrgs(ownerUserId)
: orgs;
const account = await this.prisma.billingAccount.findUnique({
where: { ownerUserId },
select: { storageQuotaBytes: true },
});
const quota = account?.storageQuotaBytes == null ? null : asNumber(account.storageQuotaBytes);
const byOrganization = fresh.map((org) => ({
id: org.id,
name: org.name,
usedBytes: asNumber(org.fileBytes) + asNumber(org.chatFileBytes),
}));
const used = byOrganization.reduce((sum, org) => sum + org.usedBytes, 0);
const percent = quota == null || quota <= 0 ? null : Math.min(100, Math.round((used / quota) * 100));
return {
usedBytes: used,
quotaBytes: quota,
percent,
level: levelOf(used, quota),
byOrganization,
};
}
async assertCanAdd(organizationId: string, bytes: number): Promise<void> {
if (bytes <= 0) return;
const ownerUserId = await this.ownerUserId(organizationId);
if (!ownerUserId) return;
const usage = await this.usageForOwner(ownerUserId);
if (usage.quotaBytes == null) return;
if (usage.usedBytes + bytes > usage.quotaBytes) {
throw new PayloadTooLargeException(
'حجم فایل این اشتراک پر شده است. تا شارژ حجم، آپلود جدید ممکن نیست.',
);
}
}
async addLocal(organizationId: string, delta: number): Promise<void> {
await this.add(organizationId, 'fileBytes', delta);
}
async addChat(organizationId: string, bytes: number): Promise<StorageUsageDto> {
if (bytes > 0) await this.assertCanAdd(organizationId, bytes);
if (bytes > 0) await this.add(organizationId, 'chatFileBytes', bytes);
return this.usageForOrganization(organizationId);
}
async setQuotaGb(ownerUserId: string, quotaGb: number | null): Promise<StorageUsageDto> {
const bytes = quotaGb == null ? null : BigInt(Math.round(quotaGb * GIB));
await this.prisma.billingAccount.update({
where: { ownerUserId },
data: { storageQuotaBytes: bytes },
});
return this.usageForOwner(ownerUserId, true);
}
private async add(
organizationId: string,
field: 'fileBytes' | 'chatFileBytes',
delta: number,
): Promise<void> {
if (!delta) return;
const row = await this.prisma.organization.update({
where: { id: organizationId },
data: { [field]: { increment: BigInt(Math.round(delta)) } },
select: { fileBytes: true, chatFileBytes: true },
});
const value = field === 'fileBytes' ? row.fileBytes : row.chatFileBytes;
if (value < 0n) {
await this.prisma.organization.update({
where: { id: organizationId },
data: { [field]: 0n },
});
}
}
private async ownerUserId(organizationId: string): Promise<string | null> {
const owner = await this.prisma.membership.findFirst({
where: { organizationId, role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
return owner?.userId ?? null;
}
private ownerOrgs(ownerUserId: string) {
return this.prisma.membership.findMany({
where: { userId: ownerUserId, role: 'owner', isActive: true },
select: {
organization: {
select: {
id: true,
name: true,
fileBytes: true,
chatFileBytes: true,
fileBytesSyncedAt: true,
},
},
},
}).then((rows) => rows.map((row) => row.organization));
}
private async measureLocal(organizationId: string): Promise<void> {
const [imageSum, noteSum, treasury, leads, forms, users] = await Promise.all([
this.prisma.activityImage.aggregate({
where: { organizationId },
_sum: { sizeBytes: true },
}),
this.prisma.noteAttachment.aggregate({
where: { organizationId },
_sum: { sizeBytes: true },
}),
this.prisma.financialAttachment.findMany({
where: { organizationId },
select: { storageKey: true },
}),
this.prisma.lead.findMany({
where: { organizationId, logoStorageKey: { not: null } },
select: { logoStorageKey: true },
}),
this.prisma.leadForm.findMany({
where: { organizationId, coverStorageKey: { not: null } },
select: { coverStorageKey: true },
}),
this.prisma.user.findMany({
where: { avatarStorageKey: { startsWith: `${organizationId}/` } },
select: { avatarStorageKey: true },
}),
]);
const keys = [
...treasury.map((row) => row.storageKey),
...leads.map((row) => row.logoStorageKey).filter((key): key is string => Boolean(key)),
...forms.map((row) => row.coverStorageKey).filter((key): key is string => Boolean(key)),
...users.map((row) => row.avatarStorageKey).filter((key): key is string => Boolean(key)),
];
let disk = 0;
for (const key of keys) disk += await this.files.sizeOf(key);
const bytes = (imageSum._sum.sizeBytes ?? 0) + (noteSum._sum.sizeBytes ?? 0) + disk;
await this.prisma.organization.update({
where: { id: organizationId },
data: { fileBytes: BigInt(bytes), fileBytesSyncedAt: new Date() },
});
}
private empty(): StorageUsageDto {
return { usedBytes: 0, quotaBytes: null, percent: null, level: 'unlimited' };
}
}
+62
View File
@@ -0,0 +1,62 @@
import { ForbiddenException } from '@nestjs/common';
import {
ASSIGN_SECTION_PERMISSION,
type AssignSection,
} from '@mento/shared';
import type { PrismaService } from '../prisma/prisma.service';
import { elevated } from './record-scope';
const ASSIGN_NOUN: Record<AssignSection, string> = {
customers: 'مشتری',
leads: 'لید',
sales: 'معامله',
activities: 'فعالیت',
routines: 'کار روتین',
projects: 'کارت پروژه',
tickets: 'تیکت',
};
type AssignActor = { userId: string; role: string; organizationId: string };
export function assignOthersDeniedMessage(section: AssignSection): string {
return `تخصیص ${ASSIGN_NOUN[section]} به دیگران برای نقش شما مجاز نیست. فقط می‌توانید برای خودتان ثبت کنید.`;
}
export async function actorCanAssignToOthers(
prisma: PrismaService,
actor: AssignActor,
section: AssignSection,
): Promise<boolean> {
if (elevated(actor.role)) return true;
const membership = await prisma.membership.findFirst({
where: {
userId: actor.userId,
organizationId: actor.organizationId,
isActive: true,
},
select: { orgRole: { select: { permissions: true } } },
});
const permissions = membership?.orgRole?.permissions;
const key = ASSIGN_SECTION_PERMISSION[section];
return Array.isArray(permissions) && permissions.includes(key);
}
/**
* بدون مجوز همان بخش، مجموعهٔ مسئولانِ غیر از خود کاربر نباید عوض شود.
* ثبت تازه یعنی قبلی خالی است، پس فقط خود کاربر مجاز است.
*/
export async function assertAssigneeSet(
prisma: PrismaService,
actor: AssignActor,
section: AssignSection,
nextIds: Array<string | null | undefined>,
previousIds: Array<string | null | undefined> = [],
): Promise<void> {
if (await actorCanAssignToOthers(prisma, actor, section)) return;
const prev = new Set(
previousIds.filter((id): id is string => Boolean(id) && id !== actor.userId),
);
const next = nextIds.filter((id): id is string => Boolean(id) && id !== actor.userId);
const changed = next.length !== prev.size || next.some((id) => !prev.has(id));
if (changed) throw new ForbiddenException(assignOthersDeniedMessage(section));
}
+470
View File
@@ -0,0 +1,470 @@
import type {
DataAccessPolicy,
DataAccessRule,
DataAccessSection,
DataVisibility,
DataVisibilityScopes,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_LEGACY_SCOPE,
DATA_ACCESS_SECTIONS,
migrateVisibilityToSectionAccess,
parseDataAccessPolicy,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import type { Actor } from './record-scope';
function elevated(role: string | null | undefined): boolean {
return canViewAllRecords(role);
}
export type SectionAccessContext = {
section: SectionDataAccess;
source: 'role' | 'override' | 'default';
};
function ownerIdList(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): string[] {
const ownerIds = new Set<string>();
if (!rule.owners?.length) return [];
if (rule.owners.includes('self')) ownerIds.add(ctx.userId);
if (rule.owners.includes('team')) {
const peers = ctx.teamPeerIds?.length ? ctx.teamPeerIds : [ctx.userId];
for (const id of peers) ownerIds.add(id);
}
return [...ownerIds];
}
/** Resolve یک بخش: override کاربر بر نقش؛ وگرنه مهاجرت از visibility قدیمی. */
export function resolveSectionAccess(
sectionKey: DataAccessSection,
input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
},
): SectionAccessContext {
if (elevated(input.role)) {
return { section: { mode: 'all', rules: [] }, source: 'default' };
}
const override = parseDataAccessPolicy(input.override);
if (override[sectionKey]) {
return { section: override[sectionKey]!, source: 'override' };
}
const rolePolicy = parseDataAccessPolicy(input.rolePolicy);
if (rolePolicy[sectionKey]) {
return { section: rolePolicy[sectionKey]!, source: 'role' };
}
// فرم محدودهٔ نقش، بخشِ ننوشته را «بدون محدودیت» نشان می‌دهد.
// اگر نقش حتی یک بخش ذخیره کرده باشد، بقیه نباید به دید قدیمی own/team برگردند.
if (Object.keys(rolePolicy).length > 0) {
return { section: { mode: 'all', rules: [] }, source: 'role' };
}
if (input.legacyVisibility != null || input.legacyScopes) {
return {
section: migrateVisibilityToSectionAccess(
input.legacyVisibility,
input.legacyScopes,
DATA_ACCESS_LEGACY_SCOPE[sectionKey],
),
source: 'default',
};
}
return { section: { mode: 'all', rules: [] }, source: 'default' };
}
/** @deprecated */
export function resolveCustomersAccess(input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
}): SectionAccessContext {
return resolveSectionAccess('customers', input);
}
export function resolveAllSectionAccess(input: {
role: string;
rolePolicy?: unknown;
override?: unknown;
legacyVisibility?: DataVisibility | string | null;
legacyScopes?: DataVisibilityScopes | null;
}): DataAccessPolicy {
const out: DataAccessPolicy = {};
for (const key of DATA_ACCESS_SECTIONS) {
out[key] = resolveSectionAccess(key, input).section;
}
return out;
}
export function buildContactRuleWhere(
rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null },
): Prisma.PersonWhereInput | null {
const and: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
if (rule.owners?.length) {
if (!ids.length) return null;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
}
if (rule.sources?.length) {
and.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (rule.moods?.length) {
and.push({ mood: { in: rule.moods } });
}
if (!and.length) return null;
return and.length === 1 ? and[0]! : { AND: and };
}
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
if (!parts.length) return { id: '__no_access__' } as T & { id: string };
return parts.length === 1 ? parts[0]! : { OR: parts };
}
export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput {
if (elevated(actor.role)) return {};
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.PersonWhereInput[] = [];
for (const rule of section.rules ?? []) {
const w = buildContactRuleWhere(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (w) parts.push(w);
}
return orParts(parts);
}
export function customersCompanyWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.CompanyWhereInput {
if (elevated(actor.role)) return {};
const section = actor.customersAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.CompanyWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.CompanyWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
}
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
}
if (rule.sources?.length) {
and.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
});
}
if (
rule.moods?.length &&
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function leadsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'leadsAccess' | 'teamPeerIds'>,
): Prisma.LeadWhereInput {
if (elevated(actor.role)) return {};
const section = actor.leadsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.LeadWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.LeadWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (rule.sources?.length) {
and.push({
OR: rule.sources.map((s) => ({
source: { equals: s, mode: 'insensitive' as const },
})),
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function dealsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'salesAccess' | 'teamPeerIds'>,
): Prisma.DealWhereInput {
if (elevated(actor.role)) return {};
const section = actor.salesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.DealWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.DealWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
if (rule.pipelineIds?.length) {
and.push({ pipelineId: { in: rule.pipelineIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function activitiesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'activitiesAccess' | 'teamPeerIds'>,
): Prisma.ActivityWhereInput {
if (elevated(actor.role)) return {};
const section = actor.activitiesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ActivityWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ActivityWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
{ participantIds: { hasSome: ids } },
],
});
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (rule.typeIds?.length) {
and.push({ typeDefId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function notesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'notesAccess' | 'teamPeerIds'>,
): Prisma.NoteWhereInput {
const personalGate: Prisma.NoteWhereInput = {
OR: [
{ visibility: { not: 'personal' } },
{ visibility: 'personal', authorId: actor.userId },
],
};
if (elevated(actor.role)) return personalGate;
const section = actor.notesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return personalGate;
const parts: Prisma.NoteWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.NoteWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ authorId: { in: ids } },
{
deal: {
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
},
},
{ person: { ownerId: { in: ids } } },
{ company: { ownerId: { in: ids } } },
],
});
}
if (rule.sources?.length) {
and.push({ noteType: { in: rule.sources } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return {
AND: [personalGate, orParts(parts)],
};
}
export function ticketsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'ticketsAccess' | 'teamPeerIds'>,
): Prisma.TicketWhereInput {
if (elevated(actor.role)) return {};
const section = actor.ticketsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.TicketWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.TicketWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
});
}
if (rule.sources?.length) {
and.push({
status: {
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
},
});
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function projectsDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'projectsAccess' | 'teamPeerIds'>,
): Prisma.ProjectCardWhereInput {
if (elevated(actor.role)) return {};
const section = actor.projectsAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ProjectCardWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.ProjectCardWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push({
OR: [
{ ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } },
],
});
}
if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } });
}
if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
export function routinesDeepWhere(
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
): Prisma.RoutineTaskWhereInput {
if (elevated(actor.role)) return {};
const section = actor.routinesAccess ?? { mode: 'all' as const, rules: [] };
if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.RoutineTaskWhereInput[] = [];
for (const rule of section.rules ?? []) {
const and: Prisma.RoutineTaskWhereInput[] = [];
if (rule.owners?.length) {
const ids = ownerIdList(rule, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (!ids.length) continue;
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
if (rule.typeIds?.length) {
and.push({ categoryId: { in: rule.typeIds } });
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
}
return orParts(parts);
}
/** اتحاد شناسهٔ owners در قوانین؛ null = بدون محدودیت مالکی (یا mode=all) */
export function sectionOwnerIdUnion(
section: SectionDataAccess | null | undefined,
ctx: { userId: string; teamPeerIds: string[] | null },
): string[] | null {
if (!sectionHasDataAccessLimits(section)) return null;
const ids = new Set<string>();
let sawOwnerRule = false;
let sawNonOwnerRule = false;
for (const rule of section?.rules ?? []) {
if (rule.owners?.length) {
sawOwnerRule = true;
for (const id of ownerIdList(rule, ctx)) ids.add(id);
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length ||
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length ||
rule.typeIds?.length) {
sawNonOwnerRule = true;
}
}
if (sawNonOwnerRule && !sawOwnerRule) return null;
if (!sawOwnerRule) return null;
return [...ids];
}
export function sectionNeedsTeamPeers(section: SectionDataAccess | null | undefined): boolean {
if (!sectionHasDataAccessLimits(section)) return false;
return (section?.rules ?? []).some((r) => r.owners?.includes('team'));
}
export function sanitizeDataAccessPolicyInput(raw: unknown): DataAccessPolicy {
return parseDataAccessPolicy(raw);
}
+62 -12
View File
@@ -1,6 +1,20 @@
import type { DataScope, DataVisibility, DataVisibilityScopes, UserRole } from '@mento/shared';
import type {
DataScope,
DataVisibility,
DataVisibilityScopes,
SectionDataAccess,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared';
import type { Prisma } from '@prisma/client';
import {
activitiesDeepWhere,
dealsDeepWhere,
notesDeepWhere,
projectsDeepWhere,
routinesDeepWhere,
ticketsDeepWhere,
} from './data-access';
export type ScopeVisibility = {
dataVisibility: DataVisibility;
@@ -20,6 +34,17 @@ export type Actor = {
visibleUserIds?: string[] | null;
/** سطح دید تفکیکی هر بخش (از Membership.dataVisibilityScopes) */
scopes?: Partial<Record<DataScope, ScopeVisibility>>;
/** عمق دادهٔ بخش‌ها (resolve شده از نقش + override) */
customersAccess?: SectionDataAccess;
leadsAccess?: SectionDataAccess;
salesAccess?: SectionDataAccess;
activitiesAccess?: SectionDataAccess;
notesAccess?: SectionDataAccess;
routinesAccess?: SectionDataAccess;
projectsAccess?: SectionDataAccess;
ticketsAccess?: SectionDataAccess;
/** هم‌تیمی‌ها برای قوانین owners:team (شامل خود کاربر) */
teamPeerIds?: string[] | null;
/** off = فیلتر خاموش؛ current = کار جاری؛ archive = بایگانی اطلاعات قبل */
priorLens?: 'off' | 'current' | 'archive';
/** لحظهٔ روشن شدن ماژول برای این محیط؛ فعالیت‌های ساخته‌شده قبل از آن می‌توانند قبلی باشند */
@@ -93,10 +118,13 @@ export function ownerWhere(
return { ownerId: { in: ids } };
}
/** معامله: مالک اصلی یا یکی از مسئولان مشترک در محدوده دید */
/** معامله: عمق دادهٔ sales (JWT) یا مدل قدیمی own/team/org */
export function dealAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'salesAccess' | 'teamPeerIds'>,
): Prisma.DealWhereInput {
if (actor.salesAccess !== undefined) {
return dealsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'deals');
if (!ids) return {};
return {
@@ -107,10 +135,13 @@ export function dealAccessWhere(
};
}
/** کارت پروژه: مالک یا یکی از مسئولان در محدوده دید */
/** کارت پروژه: عمق داده یا مدل قدیمی own/team/org */
export function projectCardAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'projectsAccess' | 'teamPeerIds'>,
): Prisma.ProjectCardWhereInput {
if (actor.projectsAccess !== undefined) {
return projectsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'projects');
if (!ids) return {};
return {
@@ -135,10 +166,13 @@ export function assigneeWhere(
};
}
/** دسترسی به فعالیت: مسئولان یا شرکت‌کنندگان (فیلد شرکت‌کنندگان) در محدوده دید */
/** دسترسی به فعالیت: عمق دادهٔ activities (JWT) یا مدل قدیمی */
export function activityAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'activitiesAccess' | 'teamPeerIds'>,
): Prisma.ActivityWhereInput {
if (actor.activitiesAccess !== undefined) {
return activitiesDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'activities');
if (!ids) return {};
return {
@@ -150,10 +184,13 @@ export function activityAccessWhere(
};
}
/** یادداشت: نویسنده یا پرونده‌های تحت مالکیت در محدوده دید + محرمانگی شخصی */
/** یادداشت: عمق داده یا مدل قدیمی + محرمانگی شخصی */
export function noteAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'notesAccess' | 'teamPeerIds'>,
): Prisma.NoteWhereInput {
if (actor.notesAccess !== undefined) {
return notesDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'notes');
const personalGate: Prisma.NoteWhereInput = {
OR: [
@@ -188,17 +225,22 @@ export function noteAccessWhere(
export function resolveOwnerId(
actor: Pick<Actor, 'userId' | 'role'>,
requested?: string | null,
allowOthers?: boolean,
): string {
if (elevated(actor.role) && requested?.trim()) {
const allowed = allowOthers ?? elevated(actor.role);
if (allowed && requested?.trim()) {
return requested.trim();
}
return actor.userId;
}
/** تیکت: مسئول یا ثبت‌کننده در محدوده دید */
/** تیکت: عمق داده یا مدل قدیمی */
export function ticketAccessWhere(
actor: ScopedActor,
actor: ScopedActor & Pick<Actor, 'ticketsAccess' | 'teamPeerIds'>,
): Prisma.TicketWhereInput {
if (actor.ticketsAccess !== undefined) {
return ticketsDeepWhere(actor as Actor);
}
const ids = scopedOwnerIds(actor, 'tickets');
if (!ids) return {};
return {
@@ -206,6 +248,14 @@ export function ticketAccessWhere(
};
}
/** کار روتین: عمق داده (وقتی JWT ست کرده) */
export function routineTaskAccessWhere(
actor: Pick<Actor, 'userId' | 'role' | 'routinesAccess' | 'teamPeerIds'>,
): Prisma.RoutineTaskWhereInput {
if (actor.routinesAccess === undefined) return {};
return routinesDeepWhere(actor);
}
export function canReassignOwner(role: string | null | undefined): boolean {
return elevated(role);
}
+13
View File
@@ -79,6 +79,19 @@ export function buildSearchWhere<W extends object>(
return (parts.length === 1 ? parts[0] : { AND: parts }) as unknown as W;
}
/** پارامتر چندتایی کوئری: «a,b,c» → ['a','b','c'] */
export function splitCsv(raw: string | null | undefined, max = 50): string[] {
if (!raw) return [];
return [
...new Set(
String(raw)
.split(',')
.map((v) => v.trim())
.filter(Boolean),
),
].slice(0, max);
}
/** کمک برای فیلدهای متنی ساده: `{ field: { contains, mode: 'insensitive' } }` */
export function ci(term: string) {
return { contains: term, mode: 'insensitive' as const };
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { CompaniesService } from './companies.service';
@@ -31,7 +32,8 @@ const excelUpload = FileInterceptor('file', {
});
@Controller('companies')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('customers')
export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
@@ -45,9 +47,15 @@ export class CompaniesController {
query.q,
query.page ?? 1,
query.limit ?? 20,
{ groupId: query.groupId },
);
}
@Get('group-stats')
groupStats(@CurrentUser() user: Actor) {
return this.companiesService.groupStats(user);
}
@Get('export')
async export(
@CurrentUser() user: Actor,
+82 -10
View File
@@ -6,6 +6,7 @@ import {
import type {
CompanyDto,
CreateCompanyRequest,
CustomerGroupDto,
PaginatedResponse,
UpdateCompanyRequest,
} from '@mento/shared';
@@ -16,17 +17,21 @@ import {
asCustomFields,
toJsonCustomFields,
} from '../common/custom-fields';
import { customersCompanyWhere } from '../common/data-access';
import {
type Actor,
canReassignOwner,
elevated,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source';
import { buildSearchWhere, ci } from '../common/search';
import { buildSearchWhere, ci, splitCsv } from '../common/search';
import { GamificationService } from '../gamification/gamification.service';
import {
CustomerGroupsService,
groupSummarySelect,
} from '../customer-groups/customer-groups.service';
function parseDateOrNull(raw?: string | null): Date | null {
if (!raw?.trim()) return null;
@@ -37,6 +42,10 @@ function parseDateOrNull(raw?: string | null): Date | null {
const companyInclude = {
_count: { select: { people: true } },
owner: { select: { id: true, fullName: true } },
groupLinks: {
select: { group: { select: groupSummarySelect } },
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
},
} satisfies Prisma.CompanyInclude;
type CompanyRow = {
@@ -67,6 +76,7 @@ type CompanyRow = {
updatedAt: Date;
_count: { people: number };
owner: { id: string; fullName: string } | null;
groupLinks: Array<{ group: { id: string; name: string; color: string } }>;
};
@Injectable()
@@ -74,6 +84,7 @@ export class CompaniesService {
constructor(
private readonly prisma: PrismaService,
private readonly game: GamificationService,
private readonly groups: CustomerGroupsService,
) {}
async list(
@@ -81,6 +92,7 @@ export class CompaniesService {
q?: string,
page = 1,
limit = 20,
opts?: { groupId?: string },
): Promise<PaginatedResponse<CompanyDto>> {
const search = buildSearchWhere<Prisma.CompanyWhereInput>(q, (t) => [
{ name: ci(t) },
@@ -94,10 +106,15 @@ export class CompaniesService {
{ city: ci(t) },
{ province: ci(t) },
]);
const groupIds = splitCsv(opts?.groupId);
const and: Prisma.CompanyWhereInput[] = [
...(search ? [search] : []),
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
];
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(search ? { AND: [search] } : {}),
...customersCompanyWhere(actor),
...(and.length ? { AND: and } : {}),
};
const [rows, total] = await this.prisma.$transaction([
@@ -119,9 +136,41 @@ export class CompaniesService {
};
}
/** آمار گروه برای فیلتر شرکت‌ها — فقط شرکت‌های در دسترس کاربر */
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
const counts = await this.prisma.companyGroupLink.groupBy({
by: ['groupId'],
where: {
company: {
organizationId: actor.organizationId,
...customersCompanyWhere(actor),
},
},
_count: { _all: true },
});
if (!counts.length) return [];
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
const groups = await this.prisma.customerGroup.findMany({
where: {
organizationId: actor.organizationId,
id: { in: counts.map((c) => c.groupId) },
},
orderBy: [{ position: 'asc' }, { name: 'asc' }],
});
return groups.map((g) => ({
id: g.id,
name: g.name,
color: g.color,
position: g.position,
fromTahesab: Boolean(g.tahesabGid),
personCount: 0,
companyCount: countMap.get(g.id) ?? 0,
}));
}
async get(actor: Actor, id: string): Promise<CompanyDto> {
const row = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
where: { id, organizationId: actor.organizationId, ...customersCompanyWhere(actor) },
include: companyInclude,
});
if (!row) throw new NotFoundException('شرکت پیدا نشد');
@@ -152,15 +201,27 @@ export class CompaniesService {
notes: emptyToNull(dto.notes),
registeredAt: parseDateOrNull(dto.registeredAt),
tags: normalizeTags(dto.tags),
ownerId: resolveOwnerId(actor, dto.ownerId),
ownerId: resolveOwnerId(
actor,
dto.ownerId,
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
),
customFields: toJsonCustomFields(dto.customFields),
};
await this.assertNoDuplicate(actor.organizationId, data);
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
const row = await this.prisma.company.create({
let row = await this.prisma.company.create({
data,
include: companyInclude,
});
if (groupIds.length) {
await this.groups.addCompanyGroups(row.id, groupIds);
row = await this.prisma.company.findUniqueOrThrow({
where: { id: row.id },
include: companyInclude,
});
}
if (row.importSource === 'manual') {
this.game.emit({
organizationId: actor.organizationId,
@@ -209,11 +270,21 @@ export class CompaniesService {
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
if (dto.ownerId !== undefined) {
const current = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId },
select: { ownerId: true },
});
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
data.ownerId = nextOwner ?? actor.userId;
}
await this.assertNoDuplicate(actor.organizationId, data, id);
if (dto.groupIds !== undefined) {
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
await this.groups.setCompanyGroups(id, groupIds);
}
const row = await this.prisma.company.update({
where: { id },
data,
@@ -315,6 +386,7 @@ export class CompaniesService {
registeredAt: row.registeredAt ? row.registeredAt.toISOString() : null,
tags: row.tags ?? [],
customFields: asCustomFields(row.customFields),
groups: (row.groupLinks ?? []).map((l) => l.group),
peopleCount: row._count.people,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
@@ -2,8 +2,9 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm
import type { ImportContactsResult, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { customersCompanyWhere } from '../common/data-access';
import type { Actor } from '../common/record-scope';
import { ownerWhere, resolveOwnerId } from '../common/record-scope';
import { resolveOwnerId } from '../common/record-scope';
import {
collectAddresses,
} from '../common/custom-fields';
@@ -25,6 +26,10 @@ import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
import { normalizeEmail, normalizePhone } from './normalize';
import { normalizePersonImportSource } from './person-source';
import {
CustomerGroupsService,
splitGroupNames,
} from '../customer-groups/customer-groups.service';
type ImportChunkOpts = {
sessionId?: string;
@@ -39,6 +44,16 @@ function parseMood(raw?: string): PersonMood | undefined {
return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value);
}
/** ستون‌های گروه اکسل → نام گروه‌ها (برچسب‌های وضعیت مشتری گروه حساب نمی‌شوند) */
function groupNamesFrom(...raws: Array<string | undefined>): string[] {
const names = raws.flatMap((raw) => splitGroupNames(raw));
return [...new Set(names.filter((name) => !parseMood(name)))];
}
function groupNamesText(groups: Array<{ name: string }> | undefined, fallback?: string) {
return groups?.length ? groups.map((g) => g.name).join('، ') : (fallback ?? '');
}
function splitDidarName(firstName: string, lastName: string, title?: string) {
const first = firstName.trim();
const last = lastName.trim();
@@ -369,6 +384,7 @@ export class ContactsExcelService {
private readonly prisma: PrismaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
private readonly groups: CustomerGroupsService,
) {}
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
@@ -378,6 +394,7 @@ export class ContactsExcelService {
CONTACT_COLUMNS,
items.map((p) => {
const extra = (p.customFields ?? {}) as Record<string, string>;
const groupText = groupNamesText(p.groups, extra.group);
return {
_type: 'Contact',
didarId: extra.didarId ?? p.id,
@@ -402,7 +419,7 @@ export class ContactsExcelService {
nationalId: extra.nationalId ?? '',
registeredAt: formatJalaliDate(p.createdAt),
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'],
otherPhones: extra.otherPhones ?? '',
address: p.addresses[0] ?? extra.address ?? '',
website: p.website ?? extra.website ?? '',
@@ -410,7 +427,7 @@ export class ContactsExcelService {
position2: p.position ?? extra.position ?? '',
city2: extra.city ?? '',
code2: extra.customerCode ?? '',
group2: extra.group ?? '',
group2: groupText,
address2: p.addresses[1] ?? extra.address ?? '',
phone2: extra.phone2 ?? p.phone ?? '',
postalCode2: extra.postalCode ?? '',
@@ -418,7 +435,7 @@ export class ContactsExcelService {
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
instagram: p.instagram ?? extra.instagram ?? '',
group: extra.group ?? '',
group: groupText,
colleagueContact: extra.colleagueContact ?? '',
previousCompany: extra.previousCompany ?? '',
customerType: extra.customerType ?? '',
@@ -456,7 +473,7 @@ export class ContactsExcelService {
registeredAt: formatJalaliDate(
c.registeredAt ? new Date(c.registeredAt) : new Date(c.createdAt),
),
groupName: c.groupName ?? '',
groupName: groupNamesText(c.groups, c.groupName ?? ''),
otherPhones: c.otherPhones ?? '',
address: c.address ?? '',
website: c.website ?? '',
@@ -551,6 +568,8 @@ export class ContactsExcelService {
const value = row[key]?.trim();
if (value) customFields[key] = value;
}
const groupNames = groupNamesFrom(row.group, parseMood(row.mood) ? undefined : row.mood);
if (groupNames.length) delete customFields.group;
const addresses = collectAddresses(
row.address,
@@ -576,6 +595,7 @@ export class ContactsExcelService {
? normalizePersonImportSource(sourceCol)
: 'excel';
const sourceRef = didarId || customerCode || null;
const groupIds = await this.groups.idsForNames(actor.organizationId, groupNames);
await this.people.create(actor, {
firstName: names.firstName,
@@ -596,6 +616,7 @@ export class ContactsExcelService {
importSource,
sourceRef,
registeredAt: registeredAt?.toISOString() ?? null,
groupIds,
});
result.created += 1;
if (ownerMissing) {
@@ -701,8 +722,13 @@ export class ContactsExcelService {
};
try {
const groupIds = await this.groups.idsForNames(
actor.organizationId,
groupNamesFrom(row.groupName),
);
const existing = await this.findImportedCompany(actor, didarId, name);
if (existing) {
if (groupIds.length) await this.groups.addCompanyGroups(existing.id, groupIds);
const merged: Record<string, unknown> = {};
for (const [k, v] of Object.entries(payload)) {
if (k === 'customFields') continue;
@@ -719,6 +745,7 @@ export class ContactsExcelService {
await this.companies.create(actor, {
...payload,
ownerId: resolveOwnerId(actor, ownerId ?? null),
groupIds,
});
result.created += 1;
}
@@ -750,7 +777,7 @@ export class ContactsExcelService {
where: {
organizationId: actor.organizationId,
sourceRef: didarId,
...ownerWhere(actor),
...customersCompanyWhere(actor),
},
select: { id: true, customFields: true },
});
@@ -761,7 +788,7 @@ export class ContactsExcelService {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...(didarId ? { sourceRef: null } : {}),
...ownerWhere(actor),
...customersCompanyWhere(actor),
},
select: { id: true, customFields: true },
});
@@ -779,7 +806,7 @@ export class ContactsExcelService {
where: {
organizationId: actor.organizationId,
sourceRef: ref,
...ownerWhere(actor),
...customersCompanyWhere(actor),
},
select: { id: true },
});
@@ -789,7 +816,7 @@ export class ContactsExcelService {
where: {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...ownerWhere(actor),
...customersCompanyWhere(actor),
},
select: { id: true, sourceRef: true },
});
+2
View File
@@ -9,6 +9,7 @@ import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
import { BehaviorModule } from '../behavior/behavior.module';
import { CustomerGroupsModule } from '../customer-groups/customer-groups.module';
@Module({
imports: [
@@ -17,6 +18,7 @@ import { BehaviorModule } from '../behavior/behavior.module';
MessageAutomationsModule,
PublicApiModule,
BehaviorModule,
CustomerGroupsModule,
],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService],
+45 -5
View File
@@ -3,6 +3,7 @@ import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsBoolean,
IsEmail,
IsIn,
IsInt,
@@ -15,7 +16,7 @@ import {
MinLength,
ValidateIf,
} from 'class-validator';
import { PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
import { PERSON_GENDERS, PERSON_IMPORT_SOURCES, PERSON_MOODS } from '@mento/shared';
export class BirthdaySmsDto {
@IsString()
@@ -50,18 +51,20 @@ export class ListQueryDto {
@Max(100)
limit?: number = 20;
/** وضعیت مشتری؛ چندتایی با کاما */
@IsOptional()
@IsIn([...PERSON_MOODS])
mood?: (typeof PERSON_MOODS)[number];
@IsString()
@MaxLength(120)
mood?: string;
@IsOptional()
@IsIn(['name', 'newest'])
sort?: 'name' | 'newest';
/** فیلتر منبع ورود شخص */
/** فیلتر منبع ورود شخص؛ چندتایی با کاما */
@IsOptional()
@IsString()
@MaxLength(40)
@MaxLength(400)
importSource?: string;
/** استان شرکت مرتبط */
@@ -86,6 +89,12 @@ export class ListQueryDto {
@IsString()
@MaxLength(12)
disc?: string;
/** فیلتر گروه مشتری؛ چندتایی با کاما (هر کدام از گروه‌ها) */
@IsOptional()
@IsString()
@MaxLength(2000)
groupId?: string;
}
class CompanyExtraFieldsDto {
@@ -153,6 +162,11 @@ class CompanyExtraFieldsDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
registeredAt?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
groupIds?: string[];
}
export class CreateCompanyDto extends CompanyExtraFieldsDto {
@@ -269,6 +283,14 @@ export class CreatePersonDto {
@MaxLength(80)
lastName?: string;
@IsOptional()
@IsIn(PERSON_GENDERS)
gender?: (typeof PERSON_GENDERS)[number];
@IsOptional()
@IsBoolean()
showGenderInName?: boolean;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -370,6 +392,11 @@ export class CreatePersonDto {
@IsOptional()
@IsString()
registeredAt?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
groupIds?: string[];
}
export class UpdatePersonDto {
@@ -383,6 +410,14 @@ export class UpdatePersonDto {
@MaxLength(80)
lastName?: string;
@IsOptional()
@IsIn(PERSON_GENDERS)
gender?: (typeof PERSON_GENDERS)[number];
@IsOptional()
@IsBoolean()
showGenderInName?: boolean;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@@ -478,4 +513,9 @@ export class UpdatePersonDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
groupIds?: string[];
}
+29 -19
View File
@@ -1,19 +1,29 @@
export function emptyToNull(value?: string | null): string | null {␍
if (value == null) return null;␍
const trimmed = value.trim();␍
return trimmed.length ? trimmed : null;␍
}␍
␍
export function normalizeEmail(value?: string | null): string | null {␍
const v = emptyToNull(value);␍
return v ? v.toLowerCase() : null;␍
}␍
␍
/** Keep digits and leading + for Iranian/international numbers. */␍
export function normalizePhone(value?: string | null): string | null {␍
const v = emptyToNull(value);␍
if (!v) return null;␍
const cleaned = v.replace(/[^\d+]/g, '');␍
return cleaned.length ? cleaned : null;␍
}␍
␍
export function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
export function normalizeEmail(value?: string | null): string | null {
const v = emptyToNull(value);
return v ? v.toLowerCase() : null;
}
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const AR_DIGITS = '٠١٢٣٤٥٦٧٨٩';
/** ارقام فارسی و عربی را به لاتین برمی‌گرداند تا از شماره حذف نشوند. */
function latinDigits(value: string): string {
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
const fa = FA_DIGITS.indexOf(ch);
return String(fa >= 0 ? fa : AR_DIGITS.indexOf(ch));
});
}
/** Keep digits and leading + for Iranian/international numbers. */
export function normalizePhone(value?: string | null): string | null {
const v = emptyToNull(value);
if (!v) return null;
const cleaned = latinDigits(v).replace(/[^\d+]/g, '');
return cleaned.length ? cleaned : null;
}
+10 -1
View File
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard';
@@ -33,8 +34,9 @@ const excelUpload = FileInterceptor('file', {
});
@Controller('people')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('customers')
@RequirePermission('customers')
export class PeopleController {
constructor(
private readonly peopleService: PeopleService,
@@ -60,6 +62,7 @@ export class PeopleController {
city: query.city,
hasMobile,
disc: query.disc,
groupId: query.groupId,
},
);
}
@@ -77,6 +80,7 @@ export class PeopleController {
city: query.city,
hasMobile,
disc: query.disc,
groupId: query.groupId,
});
}
@@ -85,6 +89,11 @@ export class PeopleController {
return this.peopleService.sourceStats(user);
}
@Get('group-stats')
groupStats(@CurrentUser() user: Actor) {
return this.peopleService.groupStats(user);
}
@Get('export')
async export(
@CurrentUser() user: Actor,
+134 -24
View File
@@ -10,6 +10,7 @@ import {
} from '@nestjs/common';
import type {
CreatePersonRequest,
CustomerGroupDto,
FilteredIdsResponse,
PaginatedResponse,
PersonDto,
@@ -17,7 +18,12 @@ import type {
PersonMood,
UpdatePersonRequest,
} from '@mento/shared';
import { FILTERED_IDS_MAX } from '@mento/shared';
import {
FILTERED_IDS_MAX,
PERSON_MOODS,
applyPersonGenderTitle,
splitPersonHonorific,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
@@ -29,12 +35,12 @@ import {
toJsonAddresses,
toJsonCustomFields,
} from '../common/custom-fields';
import { customersPersonWhere } from '../common/data-access';
import {
type Actor,
canReassignOwner,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
@@ -45,12 +51,16 @@ import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { selectMonthBirthdays } from './birthday-month';
import { renderSmsText } from '../common/sms-text';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search';
import { buildSearchWhere, ci, splitCsv } from '../common/search';
import {
normalizePersonImportSource,
personImportSourceLabel,
} from './person-source';
import { BehaviorAccessService } from '../behavior/behavior-access.service';
import {
CustomerGroupsService,
groupSummarySelect,
} from '../customer-groups/customer-groups.service';
const personInclude = {
company: { select: { id: true, name: true, province: true, city: true } },
@@ -58,12 +68,18 @@ const personInclude = {
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
groupLinks: {
select: { source: true, group: { select: groupSummarySelect } },
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
},
} satisfies Prisma.PersonInclude;
type PersonRow = {
id: string;
firstName: string;
lastName: string;
gender: string;
showGenderInName: boolean;
previousFirstName: string | null;
previousLastName: string | null;
importSource: string;
@@ -102,6 +118,7 @@ type PersonRow = {
discLabel: string | null;
discReliable: boolean;
discAssessedAt: Date | null;
groupLinks: Array<{ source: string; group: { id: string; name: string; color: string } }>;
};
function discFilter(raw: string | undefined): Prisma.PersonWhereInput | null {
@@ -134,23 +151,27 @@ export class PeopleService implements OnModuleInit {
private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService,
private readonly groups: CustomerGroupsService,
) {}
private personListWhere(
actor: Actor,
q?: string,
opts?: {
mood?: PersonMood;
mood?: string;
importSource?: string;
province?: string;
city?: string;
hasMobile?: boolean;
disc?: string;
groupId?: string;
},
): Prisma.PersonWhereInput {
const sourceFilter = opts?.importSource?.trim()
? normalizePersonImportSource(opts.importSource)
: undefined;
const sources = [...new Set(splitCsv(opts?.importSource).map(normalizePersonImportSource))];
const moods = splitCsv(opts?.mood).filter((m): m is PersonMood =>
(PERSON_MOODS as readonly string[]).includes(m),
);
const groupIds = splitCsv(opts?.groupId);
const search = buildSearchWhere<Prisma.PersonWhereInput>(q, (t) => [
{ firstName: ci(t) },
{ lastName: ci(t) },
@@ -190,11 +211,12 @@ export class PeopleService implements OnModuleInit {
}
const disc = discFilter(opts?.disc);
if (disc) and.push(disc);
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
return {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(opts?.mood ? { mood: opts.mood } : {}),
...(sourceFilter ? { importSource: sourceFilter } : {}),
...customersPersonWhere(actor),
...(moods.length ? { mood: { in: moods } } : {}),
...(sources.length ? { importSource: { in: sources } } : {}),
...(and.length ? { AND: and } : {}),
};
}
@@ -205,13 +227,14 @@ export class PeopleService implements OnModuleInit {
page = 1,
limit = 20,
opts?: {
mood?: PersonMood;
mood?: string;
sort?: 'name' | 'newest';
importSource?: string;
province?: string;
city?: string;
hasMobile?: boolean;
disc?: string;
groupId?: string;
},
): Promise<PaginatedResponse<PersonDto>> {
const seeDisc = await this.behaviorAccess.canView(actor);
@@ -243,12 +266,13 @@ export class PeopleService implements OnModuleInit {
actor: Actor,
q?: string,
opts?: {
mood?: PersonMood;
mood?: string;
importSource?: string;
province?: string;
city?: string;
hasMobile?: boolean;
disc?: string;
groupId?: string;
},
max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> {
@@ -274,7 +298,7 @@ export class PeopleService implements OnModuleInit {
by: ['importSource'],
where: {
organizationId: actor.organizationId,
...ownerWhere(actor),
...customersPersonWhere(actor),
},
_count: { _all: true },
});
@@ -287,6 +311,38 @@ export class PeopleService implements OnModuleInit {
.sort((a, b) => b.count - a.count);
}
/** آمار گروه برای فیلتر اشخاص — فقط افراد در دسترس کاربر */
async groupStats(actor: Actor): Promise<CustomerGroupDto[]> {
const counts = await this.prisma.personGroupLink.groupBy({
by: ['groupId'],
where: {
person: {
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
},
_count: { _all: true },
});
if (!counts.length) return [];
const countMap = new Map(counts.map((c) => [c.groupId, c._count._all]));
const groups = await this.prisma.customerGroup.findMany({
where: {
organizationId: actor.organizationId,
id: { in: counts.map((c) => c.groupId) },
},
orderBy: [{ position: 'asc' }, { name: 'asc' }],
});
return groups.map((g) => ({
id: g.id,
name: g.name,
color: g.color,
position: g.position,
fromTahesab: Boolean(g.tahesabGid),
personCount: countMap.get(g.id) ?? 0,
companyCount: 0,
}));
}
onModuleInit() {
void this.backfillStoredBirthDates();
}
@@ -354,7 +410,7 @@ export class PeopleService implements OnModuleInit {
async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
include: personInclude,
});
if (!row) throw new NotFoundException('شخص پیدا نشد');
@@ -368,10 +424,18 @@ export class PeopleService implements OnModuleInit {
}
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
const named = applyPersonGenderTitle({
firstName: dto.firstName ?? '',
lastName: dto.lastName ?? '',
gender: dto.gender,
showGenderInName: dto.showGenderInName,
});
const data: Prisma.PersonUncheckedCreateInput = {
organizationId: actor.organizationId,
firstName: (dto.firstName ?? '').trim(),
lastName: (dto.lastName ?? '').trim(),
firstName: named.firstName,
lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
previousFirstName: emptyToNull(dto.previousFirstName),
previousLastName: emptyToNull(dto.previousLastName),
importSource: normalizePersonImportSource(dto.importSource),
@@ -389,18 +453,24 @@ export class PeopleService implements OnModuleInit {
mood: dto.mood ?? 'newcomer',
tags: normalizeTags(dto.tags),
companyId,
ownerId: resolveOwnerId(actor, dto.ownerId),
ownerId: resolveOwnerId(
actor,
dto.ownerId,
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
),
customFields: toJsonCustomFields(dto.customFields),
...(registeredAt && !Number.isNaN(registeredAt.getTime())
? { createdAt: registeredAt }
: {}),
};
await this.assertNoDuplicate(actor.organizationId, data);
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
const row = await this.prisma.person.create({
data,
include: personInclude,
});
if (groupIds.length) await this.groups.addPersonGroups(row.id, groupIds);
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
const createdCtx = {
organizationId: actor.organizationId,
@@ -446,8 +516,32 @@ export class PeopleService implements OnModuleInit {
await this.ensureExists(actor, id);
const data: Prisma.PersonUncheckedUpdateInput = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
const touchesName =
dto.firstName !== undefined ||
dto.lastName !== undefined ||
dto.gender !== undefined ||
dto.showGenderInName !== undefined;
if (touchesName) {
const existing = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId },
select: { firstName: true, lastName: true, gender: true, showGenderInName: true },
});
if (!existing) throw new NotFoundException('شخص پیدا نشد');
const bare = splitPersonHonorific(existing.firstName, existing.lastName);
const storedGender =
existing.gender === 'male' || existing.gender === 'female' ? existing.gender : 'none';
const named = applyPersonGenderTitle({
firstName: dto.firstName !== undefined ? dto.firstName : bare.firstName,
lastName: dto.lastName !== undefined ? dto.lastName : bare.lastName,
gender: dto.gender !== undefined ? dto.gender : storedGender,
showGenderInName:
dto.showGenderInName !== undefined ? dto.showGenderInName : existing.showGenderInName,
});
data.firstName = named.firstName;
data.lastName = named.lastName;
data.gender = named.gender;
data.showGenderInName = named.showGenderInName;
}
if ('previousFirstName' in dto) {
data.previousFirstName = emptyToNull(dto.previousFirstName);
}
@@ -484,11 +578,21 @@ export class PeopleService implements OnModuleInit {
}
data.companyId = nextCompanyId;
}
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
if (dto.ownerId !== undefined) {
const current = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId },
select: { ownerId: true },
});
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
data.ownerId = nextOwner ?? actor.userId;
}
await this.assertNoDuplicate(actor.organizationId, data, id);
if (dto.groupIds !== undefined) {
const groupIds = await this.groups.validIds(actor.organizationId, dto.groupIds);
await this.groups.setPersonGroups(id, groupIds);
}
const row = await this.prisma.person.update({
where: { id },
data,
@@ -516,7 +620,7 @@ export class PeopleService implements OnModuleInit {
where: {
id: { in: unique },
organizationId: actor.organizationId,
...ownerWhere(actor),
...customersPersonWhere(actor),
},
});
return { deleted: res.count };
@@ -524,7 +628,7 @@ export class PeopleService implements OnModuleInit {
private async ensureExists(actor: Actor, id: string) {
const found = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
where: { id, organizationId: actor.organizationId, ...customersPersonWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('شخص پیدا نشد');
@@ -754,6 +858,8 @@ export class PeopleService implements OnModuleInit {
previousImportSource: row.previousImportSource,
previousSourceRef: row.previousSourceRef,
fullName: `${row.firstName} ${row.lastName}`.trim(),
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
showGenderInName: row.showGenderInName,
email: row.email,
phone: row.phone,
mobile: row.mobile,
@@ -776,6 +882,10 @@ export class PeopleService implements OnModuleInit {
mood: row.mood,
tags: row.tags ?? [],
customFields: asCustomFields(row.customFields),
groups: (row.groupLinks ?? []).map((l) => ({
...l.group,
source: l.source === 'tahesab' ? ('tahesab' as const) : ('manual' as const),
})),
loyaltyPoints: row.loyaltyPoints ?? 0,
loyaltyTier: row.loyaltyTier,
discLabel: includeDisc && row.discReliable ? row.discLabel : null,
@@ -4,6 +4,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { OrgModuleGuard } from '../billing/org-module.guard';
import {
ConversationFileBytesDto,
CreateConversationDmDto,
CreateConversationGroupDto,
CreateConversationChannelDto,
@@ -19,12 +20,34 @@ import {
ToggleMessageReactionDto,
} from './dto';
import { ConversationsService } from './conversations.service';
import { StorageQuotaService } from '../billing/storage-quota.service';
@Controller('conversations')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@RequireOrgModule('conversations')
export class ConversationsController {
constructor(private readonly conversations: ConversationsService) {}
constructor(
private readonly conversations: ConversationsService,
private readonly storage: StorageQuotaService,
) {}
@Post('files/reserve')
reserveFile(
@CurrentUser() user: { organizationId: string },
@Body() dto: ConversationFileBytesDto,
) {
return this.storage.assertCanAdd(user.organizationId, dto.bytes).then(() =>
this.storage.usageForOrganization(user.organizationId),
);
}
@Post('files/commit')
commitFile(
@CurrentUser() user: { organizationId: string },
@Body() dto: ConversationFileBytesDto,
) {
return this.storage.addChat(user.organizationId, dto.bytes);
}
@Get('access')
access(@CurrentUser() user: { userId: string; organizationId: string; role: string }) {
@@ -656,7 +656,7 @@ export class ConversationsService {
});
if (!row) {
throw new BadRequestException(
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیط‌ها → تنظیمات گفتگو پیکربندی کنید.',
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
);
}
try {
+12
View File
@@ -1,9 +1,13 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ArrayMinSize,
ArrayMaxSize,
@@ -32,6 +36,14 @@ export class UpdateConversationSettingsDto {
downloadUrl?: string;
}
export class ConversationFileBytesDto {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100 * 1024 * 1024)
bytes!: number;
}
export class CreateConversationDmDto {
@IsString()
@MinLength(1)
+1
View File
@@ -15,6 +15,7 @@ export enum CustomFieldEntityDto {
person = 'person',
company = 'company',
deal = 'deal',
note = 'note',
}
export enum CustomFieldTypeDto {
@@ -0,0 +1,80 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { CustomerGroupsService } from './customer-groups.service';
class CreateCustomerGroupDto {
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
@IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor;
}
class UpdateCustomerGroupDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
name?: string;
@IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor;
/** null گروه را اصلی می‌کند */
@IsOptional()
@IsString()
parentId?: string | null;
}
@Controller('customer-groups')
@UseGuards(JwtAuthGuard, RolesGuard)
export class CustomerGroupsController {
constructor(private readonly groups: CustomerGroupsService) {}
@Get()
list(@CurrentUser() user: Actor) {
return this.groups.list(user.organizationId);
}
@Post()
@Roles('owner', 'admin')
create(@CurrentUser() user: Actor, @Body() dto: CreateCustomerGroupDto) {
return this.groups.create(user.organizationId, dto);
}
@Patch(':id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateCustomerGroupDto,
) {
return this.groups.update(user.organizationId, id, dto);
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.groups.remove(user.organizationId, id);
return { ok: true };
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CustomerGroupsController } from './customer-groups.controller';
import { CustomerGroupsService } from './customer-groups.service';
@Module({
imports: [AuthModule],
controllers: [CustomerGroupsController],
providers: [CustomerGroupsService],
exports: [CustomerGroupsService],
})
export class CustomerGroupsModule {}
@@ -0,0 +1,346 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const NAME_MAX = 80;
/** نام گروه برای مقایسه: فاصله‌های اضافه و ی/ک عربی یکسان می‌شوند */
function normalizeGroupName(raw: string): string {
return raw
.replace(/\u064A/g, '\u06CC')
.replace(/\u0643/g, '\u06A9')
.replace(/\s+/g, ' ')
.trim()
.slice(0, NAME_MAX);
}
/** «الف، ب / ج» → ['الف', 'ب', 'ج'] */
export function splitGroupNames(raw: string | null | undefined): string[] {
if (!raw) return [];
return [
...new Set(
raw
.split(/\s*[،,/]\s*/)
.map(normalizeGroupName)
.filter(Boolean),
),
];
}
export const groupSummarySelect = {
id: true,
name: true,
color: true,
} satisfies Prisma.CustomerGroupSelect;
@Injectable()
export class CustomerGroupsService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<CustomerGroupDto[]> {
const rows = await this.prisma.customerGroup.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { name: 'asc' }],
include: {
_count: { select: { people: true, companies: true } },
parent: { select: { name: true } },
},
});
return rows.map((r) => ({
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
}));
}
async create(
organizationId: string,
dto: { name: string; color?: CustomerGroupColor },
): Promise<CustomerGroupDto> {
const name = this.cleanName(dto.name);
const max = await this.prisma.customerGroup.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
await this.prisma.customerGroup.create({
data: {
organizationId,
name,
color: this.cleanColor(dto.color),
position: (max._max.position ?? -1) + 1,
},
});
} catch (e) {
throw this.mapError(e);
}
return (await this.list(organizationId)).find((g) => g.name === name)!;
}
async update(
organizationId: string,
id: string,
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null },
): Promise<CustomerGroupDto> {
await this.require(organizationId, id);
const parentId =
dto.parentId === undefined ? undefined : dto.parentId?.trim() || null;
if (parentId !== undefined) {
await this.assertParent(organizationId, id, parentId);
}
try {
await this.prisma.customerGroup.update({
where: { id },
data: {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(parentId !== undefined ? { parentId } : {}),
},
});
} catch (e) {
throw this.mapError(e);
}
return (await this.list(organizationId)).find((g) => g.id === id)!;
}
/** فقط یک سطح: گروه اصلی خودش زیرگروه نیست و گروه فرعی زیرگروه ندارد. */
private async assertParent(
organizationId: string,
id: string,
parentId: string | null,
) {
if (parentId == null) return;
if (parentId === id) {
throw new BadRequestException('گروه نمی‌تواند زیرمجموعهٔ خودش باشد.');
}
const parent = await this.prisma.customerGroup.findFirst({
where: { id: parentId, organizationId },
select: { id: true, parentId: true },
});
if (!parent) throw new NotFoundException('گروه اصلی پیدا نشد.');
if (parent.parentId) {
throw new BadRequestException('فقط یک سطح گروه اصلی و فرعی ممکن است.');
}
const childCount = await this.prisma.customerGroup.count({
where: { organizationId, parentId: id },
});
if (childCount > 0) {
throw new BadRequestException('این گروه خودش زیرگروه دارد و نمی‌تواند فرعی شود.');
}
}
/** مشتری‌ها حذف نمی‌شوند؛ فقط این گروه از رویشان برداشته می‌شود */
async remove(organizationId: string, id: string): Promise<void> {
await this.require(organizationId, id);
await this.prisma.customerGroup.delete({ where: { id } });
}
/** شناسه‌های معتبر همین محیط (بقیه نادیده گرفته می‌شوند) */
async validIds(organizationId: string, ids: string[] | undefined): Promise<string[]> {
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
if (!unique.length) return [];
const rows = await this.prisma.customerGroup.findMany({
where: { organizationId, id: { in: unique } },
select: { id: true },
});
if (rows.length !== unique.length) {
throw new BadRequestException('یکی از گروه‌های انتخاب‌شده معتبر نیست');
}
return rows.map((r) => r.id);
}
/** گروه‌ها با نام (برای اکسل)؛ نبود → ساخته می‌شود */
async idsForNames(organizationId: string, names: string[]): Promise<string[]> {
const clean = [...new Set(names.map(normalizeGroupName).filter(Boolean))];
if (!clean.length) return [];
const ids: string[] = [];
for (const name of clean) {
ids.push((await this.findOrCreateByName(organizationId, name)).id);
}
return ids;
}
private async findOrCreateByName(organizationId: string, name: string) {
const existing = await this.prisma.customerGroup.findFirst({
where: { organizationId, name: { equals: name, mode: 'insensitive' } },
select: { id: true, tahesabGid: true },
});
if (existing) return existing;
const max = await this.prisma.customerGroup.aggregate({
where: { organizationId },
_max: { position: true },
});
try {
return await this.prisma.customerGroup.create({
data: { organizationId, name, position: (max._max.position ?? -1) + 1 },
select: { id: true, tahesabGid: true },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return this.prisma.customerGroup.findFirstOrThrow({
where: { organizationId, name },
select: { id: true, tahesabGid: true },
});
}
throw e;
}
}
/**
* گروه متناظر یک گروه ته‌حساب: اول با GID روی همان اتصال، بعد با نام.
* با تغییر نام در ته‌حساب گروه تکراری ساخته نمی‌شود.
*/
async ensureTahesabGroup(
organizationId: string,
connectionId: string,
gid: string | null,
rawName: string,
): Promise<string | null> {
const name = normalizeGroupName(rawName);
const cleanGid = gid?.trim() || null;
if (!name && !cleanGid) return null;
if (cleanGid) {
const byGid = await this.prisma.customerGroup.findFirst({
where: { organizationId, tahesabConnectionId: connectionId, tahesabGid: cleanGid },
select: { id: true, name: true },
});
if (byGid) {
if (name && byGid.name !== name) {
await this.prisma.customerGroup
.update({ where: { id: byGid.id }, data: { name } })
.catch(() => undefined); // نام در منتکت تکراری است؛ نام فعلی می‌ماند
}
return byGid.id;
}
}
if (!name) return null;
const group = await this.findOrCreateByName(organizationId, name);
if (cleanGid && !group.tahesabGid) {
await this.prisma.customerGroup.update({
where: { id: group.id },
data: { tahesabConnectionId: connectionId, tahesabGid: cleanGid },
});
}
return group.id;
}
/** گروه‌های دستی شخص (جایگزین کامل). منبع لینک‌هایی که می‌مانند حفظ می‌شود. */
async setPersonGroups(personId: string, groupIds: string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.personGroupLink.deleteMany({
where: { personId, groupId: { notIn: groupIds } },
}),
this.prisma.personGroupLink.createMany({
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
skipDuplicates: true,
}),
]);
}
async setCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.companyGroupLink.deleteMany({
where: { companyId, groupId: { notIn: groupIds } },
}),
this.prisma.companyGroupLink.createMany({
data: groupIds.map((groupId) => ({ companyId, groupId })),
skipDuplicates: true,
}),
]);
}
/** فقط اضافه می‌کند (ایمپورت اکسل) */
async addPersonGroups(personId: string, groupIds: string[]): Promise<void> {
if (!groupIds.length) return;
await this.prisma.personGroupLink.createMany({
data: groupIds.map((groupId) => ({ personId, groupId, source: 'manual' })),
skipDuplicates: true,
});
}
async addCompanyGroups(companyId: string, groupIds: string[]): Promise<void> {
if (!groupIds.length) return;
await this.prisma.companyGroupLink.createMany({
data: groupIds.map((groupId) => ({ companyId, groupId })),
skipDuplicates: true,
});
}
/**
* گروهِ آمده از ته‌حساب: گروه ته‌حسابی قبلی برداشته و این یکی گذاشته می‌شود.
* گروه‌های دستی دست نمی‌خورند. اگر همین گروه از قبل دستی بوده، دستی می‌ماند.
* @returns آیا چیزی عوض شد
*/
async setPersonTahesabGroup(personId: string, groupId: string | null): Promise<boolean> {
const current = await this.prisma.personGroupLink.findMany({
where: { personId },
select: { groupId: true, source: true },
});
const staleTahesab = current.filter((l) => l.source === 'tahesab' && l.groupId !== groupId);
const alreadyLinked = groupId ? current.some((l) => l.groupId === groupId) : true;
if (!staleTahesab.length && alreadyLinked) return false;
await this.prisma.$transaction([
...(staleTahesab.length
? [
this.prisma.personGroupLink.deleteMany({
where: { personId, groupId: { in: staleTahesab.map((l) => l.groupId) } },
}),
]
: []),
...(groupId && !alreadyLinked
? [this.prisma.personGroupLink.create({ data: { personId, groupId, source: 'tahesab' } })]
: []),
]);
return true;
}
/** گروهی که موقع «ثبت در ته‌حساب» فرستاده می‌شود: اولین گروه (به ترتیب گروه‌ها) */
async firstGroupName(personId: string): Promise<string | null> {
const link = await this.prisma.personGroupLink.findFirst({
where: { personId },
orderBy: [{ group: { position: 'asc' } }, { group: { name: 'asc' } }],
select: { group: { select: { name: true } } },
});
return link?.group.name ?? null;
}
private async require(organizationId: string, id: string) {
const row = await this.prisma.customerGroup.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!row) throw new NotFoundException('گروه پیدا نشد');
}
private cleanName(raw: string): string {
const name = normalizeGroupName(raw ?? '');
if (!name) throw new BadRequestException('نام گروه لازم است');
return name;
}
private cleanColor(raw?: string): CustomerGroupColor {
return (CUSTOMER_GROUP_COLORS as readonly string[]).includes(raw ?? '')
? (raw as CustomerGroupColor)
: 'teal';
}
private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new ConflictException('گروهی با این نام وجود دارد');
}
return e;
}
}
@@ -30,6 +30,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { type Actor, elevated } from '../common/record-scope';
import { dayKeyToDate, tehranDayKey, addDays } from '../routines/routine-dates';
import { internalRoutineTaskWhere } from '../routines/internal-routine';
import { normalizeLevels, periodStart, previousPeriod, sanitizeConditions } from './gamification-engine';
import { GamificationService, parsePeriod, toRuleDto } from './gamification.service';
import type {
@@ -751,6 +752,7 @@ export class GamificationAdminService {
organizationId,
status: { not: 'cancelled' },
scheduledFor: { gte: dayKeyToDate(fromKey), lte: dayKeyToDate(toKey) },
...internalRoutineTaskWhere,
};
const [total, done] = await Promise.all([
this.prisma.routineTask.count({ where }),
File diff suppressed because it is too large Load Diff
+94
View File
@@ -1,4 +1,5 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
@@ -176,6 +177,35 @@ export class ImportBulkFromTahesabDto {
pageSize?: number;
}
export class SyncGroupsPageDto {
@IsString()
connectionId!: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(100)
@Max(2000)
pageSize?: number;
}
export class ExportPeopleToTahesabDto {
@IsString()
connectionId!: string;
/** هر درخواست چند نفر؛ هر نفر چند تماس با ته‌حساب دارد */
@IsArray()
@ArrayMaxSize(25)
@IsString({ each: true })
personIds!: string[];
}
export class SearchTahesabDto {
@IsString()
connectionId!: string;
@@ -403,6 +433,70 @@ export class PushSanadDto {
factorCode?: string;
}
export class SyncAsnadReportDto {
@IsOptional()
@IsString()
connectionId?: string;
@IsString()
@MinLength(8)
@MaxLength(12)
from!: string;
@IsString()
@MinLength(8)
@MaxLength(12)
to!: string;
/** اگر باشد، فقط سندهای همین شخص گرفته می‌شود */
@IsOptional()
@IsString()
personId?: string;
/** اگر باشد، فقط مشتریان این گروه‌ها (و زیرگروه‌ها) گرفته می‌شوند */
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(200)
groupIds?: string[];
}
export class UpdateTahesabInvoiceSettingsDto {
/** null = حذف لوگو */
@IsOptional()
logoDataUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(200)
companyName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
companySubtitle?: string;
@IsOptional()
@IsString()
@MaxLength(500)
address?: string;
@IsOptional()
@IsString()
@MaxLength(80)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
headerText?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
footerText?: string;
}
// silence unused import warnings for ValidateNested/Type if unused
void ValidateNested;
void Type;
@@ -0,0 +1,111 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type {
TahesabInvoiceSettingsDto,
UpdateTahesabInvoiceSettingsRequest,
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
const MAX_LOGO_CHARS = 700_000; // ~500KB data URL
function toDto(row: {
id: string;
organizationId: string;
logoDataUrl: string | null;
companyName: string;
companySubtitle: string;
address: string;
phone: string;
headerText: string;
footerText: string;
updatedAt: Date;
}): TahesabInvoiceSettingsDto {
return {
id: row.id,
organizationId: row.organizationId,
logoDataUrl: row.logoDataUrl,
companyName: row.companyName,
companySubtitle: row.companySubtitle,
address: row.address,
phone: row.phone,
headerText: row.headerText,
footerText: row.footerText,
updatedAt: row.updatedAt.toISOString(),
};
}
@Injectable()
export class TahesabInvoiceSettingsService {
constructor(private readonly prisma: PrismaService) {}
async getOrCreate(organizationId: string): Promise<TahesabInvoiceSettingsDto> {
const existing = await this.prisma.tahesabInvoiceSettings.findUnique({
where: { organizationId },
});
if (existing) return toDto(existing);
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { name: true },
});
const created = await this.prisma.tahesabInvoiceSettings.create({
data: {
organizationId,
companyName: org?.name ?? '',
},
});
return toDto(created);
}
async update(
organizationId: string,
dto: UpdateTahesabInvoiceSettingsRequest,
): Promise<TahesabInvoiceSettingsDto> {
if (dto.logoDataUrl != null && dto.logoDataUrl.length > MAX_LOGO_CHARS) {
throw new BadRequestException(
'حجم لوگو زیاد است؛ تصویر کوچک‌تری انتخاب کنید.',
);
}
if (
dto.logoDataUrl != null &&
dto.logoDataUrl !== '' &&
!/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(dto.logoDataUrl)
) {
throw new BadRequestException('لوگو باید تصویر با فرمت data URL باشد.');
}
const data = {
...(dto.logoDataUrl !== undefined
? { logoDataUrl: dto.logoDataUrl?.trim() || null }
: {}),
...(dto.companyName !== undefined
? { companyName: dto.companyName.trim().slice(0, 200) }
: {}),
...(dto.companySubtitle !== undefined
? { companySubtitle: dto.companySubtitle.trim().slice(0, 200) }
: {}),
...(dto.address !== undefined
? { address: dto.address.trim().slice(0, 500) }
: {}),
...(dto.phone !== undefined
? { phone: dto.phone.trim().slice(0, 80) }
: {}),
...(dto.headerText !== undefined
? { headerText: dto.headerText.trim().slice(0, 1000) }
: {}),
...(dto.footerText !== undefined
? { footerText: dto.footerText.trim().slice(0, 1000) }
: {}),
};
const row = await this.prisma.tahesabInvoiceSettings.upsert({
where: { organizationId },
create: {
organizationId,
companyName: '',
...data,
},
update: data,
});
return toDto(row);
}
}
@@ -9,16 +9,20 @@ import {
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { carryPreviousSource } from '../../contacts/person-source';
import { applyPersonGenderTitle } from '@mento/shared';
import { TahesabConnectionsService } from './connections.service';
import {
CreateInTahesabDto,
ExportPeopleToTahesabDto,
ImportBulkFromTahesabDto,
ImportFromTahesabDto,
ManualLinkDto,
MatchPersonDto,
SearchTahesabDto,
SyncGroupsPageDto,
} from './dto';
import { parseBirthDate } from '../../common/excel';
import { formatJalaliDate, parseBirthDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
@@ -71,6 +75,23 @@ function pickBirthDate(row: Record<string, unknown>): Date | null {
return parseBirthDate(String(raw));
}
function firstAddress(raw: unknown): string | undefined {
if (!Array.isArray(raw)) return undefined;
const first = raw.find((a) => typeof a === 'string' && a.trim());
return typeof first === 'string' ? first.trim() : undefined;
}
/** گروه مشتری در ته‌حساب (فیلد نام در پاسخ ته‌حساب «GoroupName» غلط‌املایی دارد) */
function pickGroup(row: Record<string, unknown>): { gid: string | null; name: string } | null {
const rawGid = row.GID ?? row.Gid ?? row.GroupID ?? row.GroupId;
const rawName = row.GoroupName ?? row.GroupName ?? row.Group_Name ?? row.Goroup;
if (rawGid == null && rawName == null) return null;
const gid = rawGid == null ? null : String(rawGid).trim() || null;
const name = rawName == null ? '' : String(rawName).trim();
if (!gid && !name) return null;
return { gid, name };
}
@Injectable()
export class TahesabPersonLinkService {
private readonly logger = new Logger(TahesabPersonLinkService.name);
@@ -78,8 +99,37 @@ export class TahesabPersonLinkService {
constructor(
private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService,
private readonly groups: CustomerGroupsService,
) {}
/**
* گروه ته‌حساب روی شخص: فقط گروهِ آمده از ته‌حساب عوض می‌شود و گروه‌های دستی می‌مانند.
* @returns آیا گروه شخص عوض شد
*/
private async applyTahesabGroup(
organizationId: string,
connectionId: string,
personId: string,
row: Record<string, unknown> | undefined,
): Promise<boolean> {
const group = row ? pickGroup(row) : null;
if (!group) return false;
try {
const groupId = await this.groups.ensureTahesabGroup(
organizationId,
connectionId,
group.gid,
group.name,
);
return await this.groups.setPersonTahesabGroup(personId, groupId);
} catch (err) {
this.logger.warn(
`گروه ته‌حساب برای شخص ${personId} ثبت نشد: ${err instanceof Error ? err.message : err}`,
);
return false;
}
}
private async getPerson(organizationId: string, personId: string) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
@@ -305,10 +355,17 @@ export class TahesabPersonLinkService {
person.mobile ??
person.phone ??
'';
const extra = (person.customFields ?? {}) as Record<string, unknown>;
const nationalId = typeof extra.nationalId === 'string' ? extra.nationalId.trim() : '';
const groupName =
dto.groupName?.trim() || (await this.groups.firstGroupName(personId)) || 'CRM';
const args = SanadBuilders.newMoshtari({
name: `${person.firstName} ${person.lastName}`.trim(),
groupName: dto.groupName ?? 'CRM',
groupName,
tel,
address: firstAddress(person.addresses),
codeMeli: nationalId || undefined,
tarikhTavalod: person.birthDate ? formatJalaliDate(person.birthDate) : undefined,
jensFelez: dto.jensFelez ?? 0,
});
const res = await api.doNewMoshtari(args);
@@ -517,9 +574,9 @@ export class TahesabPersonLinkService {
}
const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`;
const tel = pickTel(row);
const parts = name.trim().split(/\s+/);
const firstName = parts[0] || name;
const lastName = parts.slice(1).join(' ') || '—';
const named = applyPersonGenderTitle({ firstName: name, lastName: '' });
const firstName = named.firstName;
const lastName = named.lastName;
const mobile = tel || null;
const sourceRef = String(dto.moshtariCode);
@@ -535,6 +592,8 @@ export class TahesabPersonLinkService {
data: {
firstName,
lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
...(nameChanged
? {
previousFirstName: prevFirst || null,
@@ -575,6 +634,8 @@ export class TahesabPersonLinkService {
data: {
firstName,
lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
...(nameChanged
? {
previousFirstName: byMobile.firstName || null,
@@ -594,6 +655,8 @@ export class TahesabPersonLinkService {
organizationId,
firstName,
lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile,
phone: mobile,
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
@@ -628,6 +691,7 @@ export class TahesabPersonLinkService {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
await this.prisma.accountingSyncJob.create({
data: {
@@ -645,6 +709,7 @@ export class TahesabPersonLinkService {
async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) {
let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1);
const rowsByCode = new Map<number, Record<string, unknown>>();
if (dto.listAll) {
const { api } = await this.connections.getApi(
@@ -656,10 +721,11 @@ export class TahesabPersonLinkService {
dto.page ?? 1,
dto.pageSize,
);
const listedCodes = listed.rows
.map((row) => pickCode(row))
.filter((c): c is number => c != null);
codes = [...new Set([...codes, ...listedCodes])];
for (const row of listed.rows) {
const code = pickCode(row);
if (code != null) rowsByCode.set(code, row);
}
codes = [...new Set([...codes, ...rowsByCode.keys()])];
} else if (dto.fromCode != null && dto.toCode != null) {
if (dto.toCode < dto.fromCode) {
throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.');
@@ -674,10 +740,11 @@ export class TahesabPersonLinkService {
dto.connectionId,
);
const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode);
const listed = asMoshtariList(raw)
.map((row) => pickCode(row))
.filter((c): c is number => c != null);
codes = [...new Set([...codes, ...listed])];
for (const row of asMoshtariList(raw)) {
const code = pickCode(row);
if (code != null) rowsByCode.set(code, row);
}
codes = [...new Set([...codes, ...rowsByCode.keys()])];
}
if (!codes.length) {
@@ -708,11 +775,13 @@ export class TahesabPersonLinkService {
const pending = codes.filter((c) => !linkedByCode.has(c));
let bulkApi: TahesabApi | undefined;
const rowsByCode = new Map<number, Record<string, unknown>>();
const missingRows = pending.filter((c) => !rowsByCode.has(c));
if (pending.length) {
bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api;
const minCode = Math.min(...pending);
const maxCode = Math.max(...pending);
}
if (bulkApi && missingRows.length) {
const minCode = Math.min(...missingRows);
const maxCode = Math.max(...missingRows);
if (maxCode - minCode <= 2000) {
try {
const raw = await bulkApi.doListMoshtariByCode(minCode, maxCode);
@@ -737,6 +806,12 @@ export class TahesabPersonLinkService {
data: { birthDate },
});
}
await this.applyTahesabGroup(
organizationId,
dto.connectionId,
linkedPersonId,
rowsByCode.get(moshtariCode),
);
items.push({
moshtariCode,
ok: true,
@@ -776,6 +851,146 @@ export class TahesabPersonLinkService {
return { imported, skipped, failed, items };
}
/** یک صفحه (بازهٔ کد) از ته‌حساب: گروه مشتری‌های لینک‌شده به‌روز می‌شود */
async syncGroupsPage(organizationId: string, dto: SyncGroupsPageDto) {
const { api } = await this.connections.getApi(organizationId, dto.connectionId);
const listed = await this.listMoshtariPage(api, dto.page ?? 1, dto.pageSize);
const rowsByCode = new Map<number, Record<string, unknown>>();
for (const row of listed.rows) {
const code = pickCode(row);
if (code != null) rowsByCode.set(code, row);
}
const links = rowsByCode.size
? await this.prisma.personAccountingLink.findMany({
where: { connectionId: dto.connectionId, moshtariCode: { in: [...rowsByCode.keys()] } },
select: { moshtariCode: true, personId: true },
})
: [];
let updated = 0;
for (const link of links) {
const changed = await this.applyTahesabGroup(
organizationId,
dto.connectionId,
link.personId,
rowsByCode.get(link.moshtariCode),
);
if (changed) updated += 1;
}
return {
page: listed.page,
fromCode: listed.fromCode,
toCode: listed.toCode,
rows: listed.rows.length,
linked: links.length,
updated,
};
}
/** اشخاصی که روی این اتصال به ته‌حساب لینک نیستند */
async unlinkedPeople(organizationId: string, connectionId: string) {
await this.connections.getOrThrow(organizationId, connectionId);
const rows = await this.prisma.person.findMany({
where: { organizationId, accountingLinks: { none: { connectionId } } },
select: { id: true },
orderBy: { createdAt: 'asc' },
});
return { ids: rows.map((r) => r.id), total: rows.length };
}
/**
* ثبت گروهی در ته‌حساب: برای هر نفر اول تطبیق (موبایل، بعد نام)؛
* تطبیق یکتا → لینک، چند نتیجه → رد می‌شود تا دستی بررسی شود، بدون نتیجه → ساخت مشتری.
*/
async exportPeople(organizationId: string, dto: ExportPeopleToTahesabDto) {
await this.connections.getOrThrow(organizationId, dto.connectionId);
const ids = [...new Set(dto.personIds)];
const people = await this.prisma.person.findMany({
where: { organizationId, id: { in: ids } },
select: {
id: true,
firstName: true,
lastName: true,
accountingLinks: {
where: { connectionId: dto.connectionId },
select: { moshtariCode: true },
},
},
});
const byId = new Map(people.map((p) => [p.id, p]));
const items: Array<{
personId: string;
name: string;
status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error';
moshtariCode?: number;
error?: string;
}> = [];
for (const personId of ids) {
const person = byId.get(personId);
if (!person) {
items.push({ personId, name: '', status: 'error', error: 'شخص یافت نشد.' });
continue;
}
const name = `${person.firstName} ${person.lastName}`.trim();
const existing = person.accountingLinks[0];
if (existing) {
items.push({ personId, name, status: 'skipped', moshtariCode: existing.moshtariCode });
continue;
}
try {
const matched = await this.match(organizationId, personId, {
connectionId: dto.connectionId,
autoLinkIfUnique: true,
});
if (matched.status === 'linked' && matched.link) {
items.push({
personId,
name,
status: 'matched',
moshtariCode: matched.link.moshtariCode,
});
} else if (matched.status === 'none') {
const link = await this.createInTahesab(organizationId, personId, {
connectionId: dto.connectionId,
});
items.push({ personId, name, status: 'created', moshtariCode: link.moshtariCode });
} else {
items.push({ personId, name, status: 'ambiguous' });
}
} catch (e) {
items.push({
personId,
name,
status: 'error',
error: e instanceof Error ? e.message : 'ثبت ناموفق بود',
});
}
}
const count = (s: (typeof items)[number]['status']) =>
items.filter((i) => i.status === s).length;
const summary = {
matched: count('matched'),
created: count('created'),
ambiguous: count('ambiguous'),
skipped: count('skipped'),
failed: count('error'),
};
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'export',
status: summary.failed && !summary.matched && !summary.created ? 'error' : 'success',
request: { count: ids.length } as Prisma.InputJsonValue,
result: summary as Prisma.InputJsonValue,
},
});
return { ...summary, items };
}
/** مشتری‌های قبلاً لینک‌شده که تاریخ تولد ندارند را از فهرست ته‌حساب پر می‌کند. */
async fillMissingBirthDates() {
const connections = await this.prisma.accountingConnection.findMany({
@@ -0,0 +1,220 @@
/** طبقه‌بندی و خالص‌کردن فروش/خرید بعد از مرجوعی، برای گزارش ته‌حساب. */
export const SALES_FACTOR_KEYS = [
'karsakhte',
'etiket_gold',
'etiket_jewel',
'bullion',
'other',
] as const;
export type SalesFactorKey = (typeof SALES_FACTOR_KEYS)[number];
export const SALES_FACTOR_LABELS: Record<SalesFactorKey, string> = {
karsakhte: 'کار ساخته',
etiket_gold: 'اتیکت طلا',
etiket_jewel: 'اتیکت جواهر',
bullion: 'شمش و سکه',
other: 'سایر',
};
export type DocDirection = 'in' | 'out' | 'purchase_return' | 'sale_return' | 'other';
export type SalesDoc = {
bucketKey: string;
factor: SalesFactorKey;
direction: DocDirection;
weight: number;
mali: number;
count: number;
percent: number | null;
tarikh: string;
};
export type PercentStats = {
first: number | null;
last: number | null;
min: number | null;
max: number | null;
avg: number | null;
};
export type MovementStats = {
grossWeight: number;
returnWeight: number;
netWeight: number;
grossMali: number;
returnMali: number;
netMali: number;
grossCount: number;
returnCount: number;
netCount: number;
percent: PercentStats | null;
};
const EMPTY_MOVE: MovementStats = {
grossWeight: 0,
returnWeight: 0,
netWeight: 0,
grossMali: 0,
returnMali: 0,
netMali: 0,
grossCount: 0,
returnCount: 0,
netCount: 0,
percent: null,
};
function norm(raw: string): string {
return raw.replace(/\u064A/g, '\u06CC').replace(/\u0643/g, '\u06A9').replace(/\s+/g, ' ').trim();
}
export function classifyDirection(noRaw: string): DocDirection {
const no = norm(noRaw);
if (no.includes('ورود کارساخته مرجوع')) return 'sale_return';
if (no.includes('خروج کارساخته مرجوع')) return 'purchase_return';
if (no.includes('ورود کارساخته')) return 'in';
if (no.includes('خروج کارساخته')) return 'out';
const isReturn = /مرجوع|برگشت/.test(no);
const isBuy = /خرید/.test(no);
const isSell = /فروش/.test(no);
if (isReturn && isBuy) return 'purchase_return';
if (isReturn) return 'sale_return';
if (isBuy || (/ورود/.test(no) && !isReturn)) return 'in';
if (isSell || (/خروج/.test(no) && !isReturn)) return 'out';
return 'other';
}
export function classifyFactor(noRaw: string, noKar: string | null | undefined): SalesFactorKey {
const blob = norm(`${noRaw} ${noKar ?? ''}`);
if (blob.includes('کارساخته') || blob.includes('کار ساخته')) return 'karsakhte';
if (/شمش|سکه/.test(blob)) return 'bullion';
if (/جواهر|ساچمه/.test(blob)) return 'etiket_jewel';
if (blob.includes('اتیکت')) return 'etiket_gold';
return 'other';
}
export function combinedPercent(
darsad: number | null | undefined,
talayee: number | null | undefined,
maliat: number | null | undefined,
): number | null {
const parts = [darsad, talayee, maliat].filter(
(n): n is number => n != null && Number.isFinite(n),
);
if (!parts.length) return null;
return parts.reduce((sum, n) => sum + n, 0);
}
export function positiveAmount(vazn: number | null | undefined, tabdil: number | null | undefined): number {
if (vazn != null && Number.isFinite(vazn) && vazn !== 0) return Math.abs(vazn);
if (tabdil != null && Number.isFinite(tabdil)) return Math.abs(tabdil);
return 0;
}
type Piece = {
tarikh: string;
seq: number;
weight: number;
mali: number;
count: number;
percent: number | null;
};
/** مرجوعی از آخرین سندهای همان نام کم می‌شود تا فروش/خریدِ برگشتی در آمار نماند. */
function remainingAfterReturns(gross: Piece[], returns: Piece[]): Piece[] {
const docs = gross.map((d) => ({ ...d }));
let leftW = returns.reduce((s, d) => s + d.weight, 0);
let leftM = returns.reduce((s, d) => s + d.mali, 0);
let leftC = returns.reduce((s, d) => s + d.count, 0);
const order = [...docs].sort((a, b) => {
if (a.tarikh !== b.tarikh) return b.tarikh.localeCompare(a.tarikh);
return b.seq - a.seq;
});
for (const doc of order) {
if (leftW <= 0 && leftM <= 0 && leftC <= 0) break;
const takeW = Math.min(doc.weight, Math.max(leftW, 0));
const takeM = Math.min(doc.mali, Math.max(leftM, 0));
const takeC = Math.min(doc.count, Math.max(leftC, 0));
doc.weight -= takeW;
doc.mali -= takeM;
doc.count -= takeC;
leftW -= takeW;
leftM -= takeM;
leftC -= takeC;
}
return docs.filter((d) => d.weight > 1e-9 || d.mali > 1e-6 || d.count > 1e-9);
}
function percentOf(docs: Piece[]): PercentStats | null {
const withPercent = docs.filter((d) => d.percent != null && d.weight > 1e-9);
if (!withPercent.length) return null;
const ordered = [...withPercent].sort((a, b) => {
if (a.tarikh !== b.tarikh) return a.tarikh.localeCompare(b.tarikh);
return a.seq - b.seq;
});
const values = ordered.map((d) => d.percent!);
const weight = ordered.reduce((s, d) => s + d.weight, 0);
const avg =
weight > 0
? ordered.reduce((s, d) => s + d.percent! * d.weight, 0) / weight
: null;
return {
first: ordered[0]?.percent ?? null,
last: ordered[ordered.length - 1]?.percent ?? null,
min: Math.min(...values),
max: Math.max(...values),
avg,
};
}
export function movementStats(lines: SalesDoc[], side: 'in' | 'out'): MovementStats {
const returnDir: DocDirection = side === 'in' ? 'purchase_return' : 'sale_return';
const groups = new Map<string, { gross: Piece[]; ret: Piece[] }>();
let seq = 0;
for (const line of lines) {
if (line.direction !== side && line.direction !== returnDir) continue;
const bag = groups.get(line.bucketKey) ?? { gross: [], ret: [] };
const piece: Piece = {
tarikh: line.tarikh,
seq: seq++,
weight: Math.abs(line.weight),
mali: Math.abs(line.mali),
count: Math.abs(line.count),
percent: line.percent,
};
if (line.direction === side) bag.gross.push(piece);
else bag.ret.push(piece);
groups.set(line.bucketKey, bag);
}
if (!groups.size) return { ...EMPTY_MOVE };
let grossWeight = 0;
let returnWeight = 0;
let grossMali = 0;
let returnMali = 0;
let grossCount = 0;
let returnCount = 0;
const kept: Piece[] = [];
for (const bag of groups.values()) {
grossWeight += bag.gross.reduce((s, d) => s + d.weight, 0);
returnWeight += bag.ret.reduce((s, d) => s + d.weight, 0);
grossMali += bag.gross.reduce((s, d) => s + d.mali, 0);
returnMali += bag.ret.reduce((s, d) => s + d.mali, 0);
grossCount += bag.gross.reduce((s, d) => s + d.count, 0);
returnCount += bag.ret.reduce((s, d) => s + d.count, 0);
kept.push(...remainingAfterReturns(bag.gross, bag.ret));
}
return {
grossWeight,
returnWeight,
netWeight: Math.max(0, grossWeight - returnWeight),
grossMali,
returnMali,
netMali: Math.max(0, grossMali - returnMali),
grossCount,
returnCount,
netCount: Math.max(0, grossCount - returnCount),
percent: percentOf(kept),
};
}
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
@@ -13,14 +14,19 @@ import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { CurrentUser } from '../../auth/current-user.decorator';
import { Roles } from '../../auth/roles.decorator';
import { RolesGuard } from '../../auth/roles.guard';
import { OrgModuleGuard } from '../../billing/org-module.guard';
import { RequireOrgModule } from '../../billing/org-module.decorator';
import { TahesabConnectionsService } from './connections.service';
import { TahesabPersonLinkService } from './person-link.service';
import { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import {
CreateInTahesabDto,
CreateTahesabConnectionDto,
ExportPeopleToTahesabDto,
ImportBulkFromTahesabDto,
ImportBulkProductsFromTahesabDto,
ImportFromTahesabDto,
@@ -30,11 +36,15 @@ import {
PushSanadDto,
SearchTahesabDto,
SearchTahesabProductsDto,
SyncAsnadReportDto,
SyncGroupsPageDto,
UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto,
} from './dto';
@Controller('integrations/tahesab')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@RequireOrgModule('tahesab')
export class TahesabController {
constructor(
private readonly connections: TahesabConnectionsService,
@@ -42,6 +52,8 @@ export class TahesabController {
private readonly products: TahesabProductLinkService,
private readonly balance: TahesabBalanceService,
private readonly documents: TahesabDocumentsService,
private readonly asnadReport: TahesabAsnadReportService,
private readonly invoiceSettings: TahesabInvoiceSettingsService,
) {}
// —— Connections ——
@@ -216,6 +228,33 @@ export class TahesabController {
return this.links.importBulk(user.organizationId, dto);
}
@Post('sync/groups')
@Roles('owner', 'admin')
syncGroups(
@CurrentUser() user: { organizationId: string },
@Body() dto: SyncGroupsPageDto,
) {
return this.links.syncGroupsPage(user.organizationId, dto);
}
@Get('sync/unlinked-people')
@Roles('owner', 'admin')
unlinkedPeople(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId: string,
) {
return this.links.unlinkedPeople(user.organizationId, connectionId);
}
@Post('sync/export-people')
@Roles('owner', 'admin')
exportPeople(
@CurrentUser() user: { organizationId: string },
@Body() dto: ExportPeopleToTahesabDto,
) {
return this.links.exportPeople(user.organizationId, dto);
}
@Post('sync/products/search')
@Roles('owner', 'admin', 'sales')
searchProducts(
@@ -242,4 +281,157 @@ export class TahesabController {
) {
return this.products.importBulk(user.organizationId, dto);
}
// —— تنظیمات فاکتور PDF اسناد ته‌حساب ——
@Get('invoice-settings')
getInvoiceSettings(@CurrentUser() user: { organizationId: string }) {
return this.invoiceSettings.getOrCreate(user.organizationId);
}
@Patch('invoice-settings')
@Roles('owner', 'admin')
updateInvoiceSettings(
@CurrentUser() user: { organizationId: string },
@Body() dto: UpdateTahesabInvoiceSettingsDto,
) {
return this.invoiceSettings.update(user.organizationId, dto);
}
// —— گزارش فروش اسناد (اسنپ‌شات DoListAsnad) ——
@Post('reports/asnad/sync')
@Roles('owner', 'admin', 'sales')
syncAsnadReport(
@CurrentUser() user: { organizationId: string },
@Body() dto: SyncAsnadReportDto,
) {
return this.asnadReport.startSync(
user.organizationId,
dto.connectionId,
dto.from,
dto.to,
{ personId: dto.personId, groupIds: dto.groupIds },
);
}
@Get('reports/asnad/customers')
searchAsnadCustomers(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('q') q?: string,
) {
return this.asnadReport.searchCustomers(
user.organizationId,
connectionId,
q ?? '',
);
}
@Get('reports/asnad/sync/:jobId')
syncAsnadReportStatus(
@CurrentUser() user: { organizationId: string },
@Param('jobId') jobId: string,
) {
return this.asnadReport.getSyncJob(user.organizationId, jobId);
}
@Get('reports/asnad/overview')
asnadReportOverview(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('groupIds') groupIds?: string | string[],
@Query('personId') personId?: string,
@Query('jensFelez') jensFelez?: string,
) {
if (!from || !to) {
throw new BadRequestException('پارامتر from و to الزامی است.');
}
return this.asnadReport.overview(user.organizationId, {
connectionId,
from,
to,
...this.parseAsnadFilters(groupIds, personId, undefined, jensFelez),
});
}
@Get('reports/asnad/summary')
asnadReportSummary(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('groupIds') groupIds?: string | string[],
@Query('personId') personId?: string,
@Query('kinds') kinds?: string | string[],
@Query('jensFelez') jensFelez?: string,
) {
if (!from || !to) {
throw new BadRequestException('پارامتر from و to الزامی است.');
}
return this.asnadReport.summary(user.organizationId, {
connectionId,
from,
to,
...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez),
});
}
@Get('reports/asnad')
listAsnadReport(
@CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
@Query('groupIds') groupIds?: string | string[],
@Query('personId') personId?: string,
@Query('kinds') kinds?: string | string[],
@Query('jensFelez') jensFelez?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
if (!from || !to) {
throw new BadRequestException('پارامتر from و to الزامی است.');
}
return this.asnadReport.query(user.organizationId, {
connectionId,
from,
to,
...this.parseAsnadFilters(groupIds, personId, kinds, jensFelez),
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
});
}
private parseAsnadFilters(
groupIds?: string | string[],
personId?: string,
kinds?: string | string[],
jensFelez?: string,
) {
const groups = Array.isArray(groupIds)
? groupIds
: groupIds
? groupIds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const kindList = Array.isArray(kinds)
? kinds
: kinds
? kinds
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined;
const jf =
jensFelez != null && jensFelez !== '' ? Number(jensFelez) : undefined;
return {
groupIds: groups,
personId,
kinds: kindList,
jensFelez: jf != null && Number.isFinite(jf) ? jf : undefined,
};
}
}
@@ -10,10 +10,13 @@ import {
import { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import { TahesabController } from './tahesab.controller';
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, CustomerGroupsModule],
controllers: [TahesabController],
providers: [
TahesabCryptoService,
@@ -24,6 +27,8 @@ import { TahesabController } from './tahesab.controller';
TahesabProductLinkService,
TahesabBalanceService,
TahesabDocumentsService,
TahesabAsnadReportService,
TahesabInvoiceSettingsService,
],
exports: [
TahesabCryptoService,
@@ -32,6 +37,8 @@ import { TahesabController } from './tahesab.controller';
TahesabProductLinkService,
TahesabBalanceService,
TahesabDocumentsService,
TahesabAsnadReportService,
TahesabInvoiceSettingsService,
],
})
export class TahesabModule {}
@@ -9,6 +9,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
@@ -18,7 +19,8 @@ import { InventoryService } from './inventory.service';
type AuthUser = { organizationId: string };
@Controller('inventory')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
@RequirePermission('products')
export class InventoryController {
constructor(private readonly inventory: InventoryService) {}
+3 -1
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateInvoiceDto,
@@ -22,7 +23,8 @@ import { InvoicesService } from './invoices.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('invoices')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('billing')
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) {}
+2
View File
@@ -32,6 +32,8 @@ const MAP_TO = [
'lead.trade',
'lead.notes',
'lead.source',
'lead.gender',
'person.gender',
'notes',
'custom',
] as const;
+35 -3
View File
@@ -13,9 +13,11 @@ import type {
SubmitLeadFormResponse,
UpdateLeadFormRequest,
} from '@mento/shared';
import { isGenderQuestionLabel, parsePersonGenderAnswer } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import {
@@ -83,6 +85,7 @@ export class LeadFormsService {
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly automations: AutomationsService,
private readonly messageAutomations: MessageAutomationsService,
private readonly leadStages: LeadStagesService,
@@ -210,7 +213,12 @@ export class LeadFormsService {
}
async remove(organizationId: string, id: string): Promise<void> {
await this.findInOrg(organizationId, id);
const row = await this.findInOrg(organizationId, id);
if (row.coverStorageKey) {
const bytes = await this.files.sizeOf(row.coverStorageKey);
await this.files.remove(row.coverStorageKey);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
}
await this.prisma.leadForm.delete({ where: { id } });
}
@@ -391,6 +399,7 @@ export class LeadFormsService {
sourceId: form.id,
sourceType: 'lead_form',
leadId,
syncLeadAssignee: !ownerId && leadCreated && !!leadId,
mobile: mapped.mobile ?? mapped.phone ?? null,
formName: form.name,
formValues: values,
@@ -547,6 +556,7 @@ export class LeadFormsService {
trade: emptyToNull(mapped.trade) ?? existingLead.trade,
source: source ?? existingLead.source,
notes: emptyToNull(mapped.notes) ?? existingLead.notes,
...(mapped.gender ? { gender: mapped.gender } : {}),
assigneeId: existingLead.assigneeId ?? assigneeId,
lastContactAt: new Date(),
},
@@ -595,6 +605,7 @@ export class LeadFormsService {
trade: emptyToNull(mapped.trade),
source,
notes: emptyToNull(mapped.notes),
gender: mapped.gender ?? 'none',
stageId,
position: (maxPos._max.position ?? -1) + 1,
assigneeId,
@@ -654,6 +665,7 @@ export class LeadFormsService {
phone: phone ?? existing.phone,
position: emptyToNull(mapped.position) ?? existing.position,
notes: emptyToNull(mapped.notes) ?? existing.notes,
...(mapped.gender ? { gender: mapped.gender } : {}),
ownerId: existing.ownerId ?? ownerId,
},
});
@@ -670,6 +682,7 @@ export class LeadFormsService {
phone,
position: emptyToNull(mapped.position),
notes: emptyToNull(mapped.notes),
gender: mapped.gender ?? 'none',
ownerId,
},
});
@@ -829,6 +842,9 @@ export class LeadFormsService {
throw new BadRequestException('فرمت تصویر کاور باید PNG، JPG یا WEBP باشد');
}
const key = `lead-forms/${id}/cover.${ext}`;
const prevBytes = row.coverStorageKey ? await this.files.sizeOf(row.coverStorageKey) : 0;
const delta = file.buffer.length - prevBytes;
if (delta > 0) await this.storage.assertCanAdd(organizationId, delta);
await this.files.save(key, file.buffer);
if (row.coverStorageKey && row.coverStorageKey !== key) {
await this.files.remove(row.coverStorageKey);
@@ -837,12 +853,17 @@ export class LeadFormsService {
where: { id },
data: { coverStorageKey: key },
});
if (delta) await this.storage.addLocal(organizationId, delta);
return this.get(organizationId, id);
}
async removeCover(organizationId: string, id: string) {
const row = await this.findInOrg(organizationId, id);
if (row.coverStorageKey) await this.files.remove(row.coverStorageKey);
if (row.coverStorageKey) {
const bytes = await this.files.sizeOf(row.coverStorageKey);
await this.files.remove(row.coverStorageKey);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
}
await this.prisma.leadForm.update({
where: { id },
data: { coverStorageKey: null },
@@ -925,6 +946,7 @@ type MappedValues = {
region?: string;
trade?: string;
leadSource?: string;
gender?: 'male' | 'female' | 'none';
};
function mapValues(
@@ -945,6 +967,15 @@ function mapValues(
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
}
if (!raw) continue;
if (
field.mapTo === 'lead.gender' ||
field.mapTo === 'person.gender' ||
isGenderQuestionLabel(field.label)
) {
const gender = parsePersonGenderAnswer(raw);
if (gender) mapped.gender = gender;
continue;
}
switch (field.mapTo) {
case 'person.fullName': {
const parts = raw.split(/\s+/).filter(Boolean);
@@ -1068,7 +1099,8 @@ function collectExtras(
for (const field of fields) {
if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue;
const raw = (values?.[field.key] ?? '').toString().trim();
if (raw) lines.push(`${field.label}: ${raw}`);
if (!raw || (isGenderQuestionLabel(field.label) && parsePersonGenderAnswer(raw))) continue;
lines.push(`${field.label}: ${raw}`);
}
return lines.join('\n');
}
+5
View File
@@ -8,6 +8,7 @@ import {
MaxLength,
MinLength,
} from 'class-validator';
import { PERSON_GENDERS } from '@mento/shared';
export class LeadSmsDto {
@IsString()
@@ -26,6 +27,8 @@ export class CreateLeadDto {
mobile!: string;
@IsOptional() @IsString() contactName?: string;
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
@IsOptional() @IsBoolean() showGenderInName?: boolean;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() city?: string;
@IsOptional() @IsString() region?: string;
@@ -47,6 +50,8 @@ export class UpdateLeadDto {
@IsOptional() @IsString() @MinLength(1) title?: string;
@IsOptional() @IsString() @MinLength(5) mobile?: string;
@IsOptional() @IsString() contactName?: string | null;
@IsOptional() @IsIn(PERSON_GENDERS) gender?: (typeof PERSON_GENDERS)[number];
@IsOptional() @IsBoolean() showGenderInName?: boolean;
@IsOptional() @IsString() phone?: string | null;
@IsOptional() @IsString() city?: string | null;
@IsOptional() @IsString() region?: string | null;
+42 -17
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import type { LeadStageDto } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const DEFAULT_STAGES: Array<{
@@ -74,20 +75,24 @@ export class LeadStagesService {
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.leadStage.create({
data: {
organizationId,
name: dto.name.trim(),
color: dto.color?.trim() || 'teal',
showOnBoard: dto.showOnBoard !== false,
isConverted: Boolean(dto.isConverted),
isLost: Boolean(dto.isLost),
isUnqualified: Boolean(dto.isUnqualified),
position: (max._max.position ?? -1) + 1,
},
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
try {
const row = await this.prisma.leadStage.create({
data: {
organizationId,
name: this.cleanName(dto.name),
color: dto.color?.trim() || 'teal',
showOnBoard: dto.showOnBoard !== false,
isConverted: Boolean(dto.isConverted),
isLost: Boolean(dto.isLost),
isUnqualified: Boolean(dto.isUnqualified),
position: (max._max.position ?? -1) + 1,
},
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
} catch (e) {
this.rethrow(e);
}
}
async update(
@@ -103,10 +108,11 @@ export class LeadStagesService {
position?: number;
},
): Promise<LeadStageDto> {
try {
await this.prisma.leadStage.updateMany({
where: { id, organizationId },
data: {
...(dto.name != null ? { name: dto.name.trim() } : {}),
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: dto.color } : {}),
...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}),
...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}),
@@ -122,6 +128,9 @@ export class LeadStagesService {
include: { _count: { select: { leads: true } } },
});
return this.toDto(row);
} catch (e) {
this.rethrow(e);
}
}
async reorder(organizationId: string, stageIds: string[]): Promise<LeadStageDto[]> {
@@ -143,11 +152,27 @@ export class LeadStagesService {
});
if (!stage) return;
if (stage._count.leads > 0) {
throw new Error('مرحله‌ای که لید دارد قابل حذف نیست');
throw new BadRequestException(
'این ستون هنوز لید دارد و حذف نمی‌شود. لیدهای فعال و بایگانی را اول به ستون دیگری ببرید.',
);
}
await this.prisma.leadStage.delete({ where: { id } });
}
private cleanName(raw: string): string {
const name = raw?.trim() ?? '';
if (!name) throw new BadRequestException('نام ستون لازم است');
if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است');
return name;
}
private rethrow(e: unknown): never {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new BadRequestException('ستونی با این نام وجود دارد');
}
throw e;
}
private toDto(row: {
id: string;
name: string;
+4 -3
View File
@@ -46,10 +46,12 @@ import {
import { LeadStagesService } from './lead-stages.service';
import { LeadsImportService } from './leads-import.service';
import { LeadsService } from './leads.service';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
@Controller('leads')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('leads')
@RequirePermission('leads', { reads: true })
export class LeadsController {
constructor(
private readonly leads: LeadsService,
@@ -105,6 +107,7 @@ export class LeadsController {
await this.stages.remove(user.organizationId, id);
return { ok: true };
} catch (e) {
if (e instanceof BadRequestException) throw e;
throw new BadRequestException(
e instanceof Error ? e.message : 'حذف مرحله ناموفق بود',
);
@@ -299,8 +302,6 @@ export class LeadsController {
}
@Post('assign')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) {
return this.leads
.assign(user, dto.leadIds, dto.assigneeId)
+143 -43
View File
@@ -20,15 +20,18 @@ import type {
PaginatedResponse,
} from '@mento/shared';
import { FILTERED_IDS_MAX } from '@mento/shared';
import { applyLeadGenderName, applyPersonGenderTitle } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { renderSmsText } from '../common/sms-text';
import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service';
import { leadsDeepWhere } from '../common/data-access';
import {
type Actor,
elevated,
forScope,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import {
asAddresses,
collectAddresses,
@@ -36,6 +39,7 @@ import {
} from '../common/custom-fields';
import { emptyToNull, normalizePhone } from '../contacts/normalize';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service';
@@ -59,18 +63,21 @@ function mobileKey(raw: string | null | undefined): string | null {
return d.startsWith('0') ? d : d;
}
function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
const actor = forScope(baseActor, 'leads');
if (elevated(actor.role)) return {};
if (actor.dataVisibility === 'org') return {};
if (
actor.dataVisibility === 'team' &&
actor.visibleUserIds &&
actor.visibleUserIds.length
) {
return { assigneeId: { in: actor.visibleUserIds } };
function leadAccessWhere(actor: Actor): Prisma.LeadWhereInput {
if (actor.leadsAccess !== undefined) {
return leadsDeepWhere(actor);
}
return { assigneeId: actor.userId };
const scoped = forScope(actor, 'leads');
if (elevated(scoped.role)) return {};
if (scoped.dataVisibility === 'org') return {};
if (
scoped.dataVisibility === 'team' &&
scoped.visibleUserIds &&
scoped.visibleUserIds.length
) {
return { assigneeId: { in: scoped.visibleUserIds } };
}
return { assigneeId: scoped.userId };
}
const leadInclude = {
@@ -86,6 +93,7 @@ export class LeadsService {
private readonly prisma: PrismaService,
private readonly stages: LeadStagesService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
private readonly game: GamificationService,
private readonly sms: SmsGatewayService,
private readonly outboundSms: OutboundSmsLogService,
@@ -337,6 +345,8 @@ export class LeadsService {
title: string;
mobile: string;
contactName?: string | null;
gender?: string | null;
showGenderInName?: boolean | null;
phone?: string | null;
city?: string | null;
region?: string | null;
@@ -370,19 +380,31 @@ export class LeadsService {
const stageId = dto.stageId || stages[0]?.id;
if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده');
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'leads');
let assigneeId: string | null = null;
if (elevated(actor.role) && dto.assigneeId) {
if (allowOthers && dto.assigneeId) {
assigneeId = dto.assigneeId;
} else if (!elevated(actor.role)) {
} else if (!allowOthers) {
assigneeId = actor.userId;
}
const registeredAt = dto.registeredAt ? new Date(dto.registeredAt) : null;
const named = applyLeadGenderName({
title: dto.title,
contactName: dto.contactName,
gender:
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
? dto.gender
: undefined,
showGenderInName: dto.showGenderInName,
});
const row = await this.prisma.lead.create({
data: {
organizationId: actor.organizationId,
title: dto.title.trim(),
contactName: emptyToNull(dto.contactName),
title: named.title,
contactName: named.contactName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile,
phone: emptyToNull(dto.phone),
city: emptyToNull(dto.city),
@@ -457,12 +479,41 @@ export class LeadsService {
}
}
const touchName =
typeof dto.title === 'string' ||
dto.contactName !== undefined ||
dto.gender !== undefined ||
dto.showGenderInName !== undefined;
const named = touchName
? applyLeadGenderName({
title: typeof dto.title === 'string' ? dto.title : existing.title,
contactName:
dto.contactName !== undefined
? (dto.contactName as string | null)
: existing.contactName,
gender:
dto.gender === 'male' || dto.gender === 'female' || dto.gender === 'none'
? dto.gender
: existing.gender === 'male' || existing.gender === 'female'
? existing.gender
: 'none',
showGenderInName:
typeof dto.showGenderInName === 'boolean'
? dto.showGenderInName
: existing.showGenderInName,
})
: null;
const data: Prisma.LeadUpdateInput = {
...(typeof dto.title === 'string' ? { title: dto.title.trim() } : {}),
mobile,
...(dto.contactName !== undefined
? { contactName: emptyToNull(dto.contactName as string) }
...(named
? {
title: named.title,
contactName: named.contactName,
gender: named.gender,
showGenderInName: named.showGenderInName,
}
: {}),
mobile,
...(dto.phone !== undefined
? { phone: emptyToNull(dto.phone as string) }
: {}),
@@ -521,8 +572,9 @@ export class LeadsService {
if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) {
data.stage = { connect: { id: dto.stageId } };
}
if (elevated(actor.role) && dto.assigneeId !== undefined) {
if (dto.assigneeId !== undefined) {
const aid = dto.assigneeId ? String(dto.assigneeId) : null;
await assertAssigneeSet(this.prisma, actor, 'leads', [aid], [existing.assigneeId]);
data.assignee = aid
? { connect: { id: aid } }
: { disconnect: true };
@@ -592,9 +644,7 @@ export class LeadsService {
leadIds: string[],
assigneeId: string,
): Promise<number> {
if (!elevated(actor.role)) {
throw new BadRequestException('فقط مدیر می‌تواند تخصیص دهد');
}
await assertAssigneeSet(this.prisma, actor, 'leads', [assigneeId]);
const user = await this.prisma.user.findFirst({
where: {
id: assigneeId,
@@ -760,14 +810,14 @@ export class LeadsService {
? `تماس ثبت شد — ${dto.outcome}`
: 'تماس ثبت شد';
await this.addEvent(actor, id, 'call', title, dto.body ?? null);
const followAt = this.parseFollowUpAt(dto.nextFollowUpAt);
if (followAt) {
await this.createLeadFollowUpActivity(actor, before, followAt, dto.body);
}
const data: Prisma.LeadUpdateInput = {
lastContactAt: new Date(),
...(dto.nextFollowUpAt !== undefined
? {
nextFollowUpAt: dto.nextFollowUpAt
? new Date(dto.nextFollowUpAt)
: null,
}
? { nextFollowUpAt: followAt }
: {}),
};
if (dto.stageId) data.stage = { connect: { id: dto.stageId } };
@@ -806,8 +856,10 @@ export class LeadsService {
nextFollowUpAt: string,
body?: string,
): Promise<LeadDto> {
await this.findAccessible(actor, id);
const at = new Date(nextFollowUpAt);
const lead = await this.findAccessible(actor, id);
const at = this.parseFollowUpAt(nextFollowUpAt);
if (!at) throw new BadRequestException('زمان پیگیری نامعتبر است');
await this.createLeadFollowUpActivity(actor, lead, at, body);
await this.addEvent(
actor,
id,
@@ -828,6 +880,39 @@ export class LeadsService {
return this.toDto(row);
}
/** پیگیری آینده باید خودش یک فعالیت باز و متصل به لید باشد، نه فقط یک تاریخ. */
private parseFollowUpAt(value?: string | null): Date | null {
if (!value) return null;
const at = new Date(value);
if (Number.isNaN(at.getTime())) {
throw new BadRequestException('زمان پیگیری نامعتبر است');
}
return at;
}
private async createLeadFollowUpActivity(
actor: Actor,
lead: { id: string; title: string; assigneeId: string | null },
dueAt: Date,
body?: string | null,
) {
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'activities');
const assigneeId = allowOthers && lead.assigneeId ? lead.assigneeId : actor.userId;
await this.prisma.activity.create({
data: {
organizationId: actor.organizationId,
type: 'task',
title: `پیگیری: ${lead.title}`.slice(0, 200),
description: body?.trim() || null,
dueAt,
leadId: lead.id,
assigneeId,
assignees: { connect: [{ id: assigneeId }] },
createdById: actor.userId,
},
});
}
async archive(actor: Actor, ids: string[]): Promise<number> {
if (!elevated(actor.role) && ids.length > 1) {
throw new BadRequestException('حذف گروهی فقط برای مدیر');
@@ -873,13 +958,24 @@ export class LeadsService {
? `${dto.firstName ?? ''} ${dto.lastName ?? ''}`.trim()
: (lead.contactName || lead.title).trim();
const nameParts = rawName.split(/\s+/).filter(Boolean);
const firstName = nameParts[0] || lead.title;
const lastName = nameParts.slice(1).join(' ') || '—';
const bareFirst = nameParts[0] || lead.title;
const bareLast = nameParts.slice(1).join(' ');
const named = applyPersonGenderTitle({
firstName: bareFirst,
lastName: bareLast,
gender:
lead.gender === 'male' || lead.gender === 'female' || lead.gender === 'none'
? lead.gender
: 'none',
showGenderInName: lead.showGenderInName,
});
person = await this.prisma.person.create({
data: {
organizationId: actor.organizationId,
firstName,
lastName,
firstName: named.firstName,
lastName: named.lastName,
gender: named.gender,
showGenderInName: named.showGenderInName,
mobile: lead.mobile,
phone: lead.phone,
instagram: lead.instagram,
@@ -975,8 +1071,10 @@ export class LeadsService {
const stages = await this.stages.list(actor.organizationId);
const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id);
const lostIds = stages.filter((s) => s.isLost).map((s) => s.id);
const interested = stages.find((s) => s.name.includes('علاقه‌مند'));
const newStage = stages.find((s) => s.name === 'جدید');
// همان مقصد فرم و ورود از اکسل: اولین ستون باز، مستقل از اسم «جدید»
const newStage = stages.find(
(s) => !s.isConverted && !s.isLost && !s.isUnqualified,
);
const [
total,
@@ -984,7 +1082,6 @@ export class LeadsService {
unassigned,
converted,
lost,
interestedCount,
withFollowUp,
callsLogged,
byAssigneeRaw,
@@ -1004,11 +1101,6 @@ export class LeadsService {
where: { ...base, stageId: { in: lostIds } },
})
: Promise.resolve(0),
interested
? this.prisma.lead.count({
where: { ...base, stageId: interested.id },
})
: Promise.resolve(0),
this.prisma.lead.count({
where: { ...base, nextFollowUpAt: { not: null } },
}),
@@ -1051,7 +1143,7 @@ export class LeadsService {
.sort((a, b) => b.count - a.count),
callsLogged,
withFollowUp,
interested: interestedCount,
interested: 0,
converted,
lost,
conversionRate: Math.round((converted / denom) * 1000) / 10,
@@ -1202,6 +1294,8 @@ export class LeadsService {
id: row.id,
title: row.title,
contactName: row.contactName,
gender: row.gender === 'male' || row.gender === 'female' ? row.gender : 'none',
showGenderInName: row.showGenderInName,
mobile: row.mobile,
phone: row.phone,
city: row.city,
@@ -1252,6 +1346,9 @@ export class LeadsService {
const storageKey = path
.join(actor.organizationId, 'leads', id, `logo.${ext}`)
.replace(/\\/g, '/');
const prevBytes = prevKey ? await this.files.sizeOf(prevKey) : 0;
const delta = file.size - prevBytes;
if (delta > 0) await this.storage.assertCanAdd(actor.organizationId, delta);
await this.files.save(storageKey, file.buffer);
if (prevKey && prevKey !== storageKey) {
await this.files.remove(prevKey);
@@ -1262,13 +1359,16 @@ export class LeadsService {
include: leadInclude,
});
await this.addEvent(actor, id, 'note', 'لوگو به‌روز شد', null);
if (delta) await this.storage.addLocal(actor.organizationId, delta);
return this.toDto(row);
}
async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
const existing = await this.findAccessible(actor, id);
if (existing.logoStorageKey) {
const bytes = await this.files.sizeOf(existing.logoStorageKey);
await this.files.remove(existing.logoStorageKey);
if (bytes) await this.storage.addLocal(actor.organizationId, -bytes);
}
const row = await this.prisma.lead.update({
where: { id },
+13 -1
View File
@@ -1,11 +1,23 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { json, urlencoded } from 'express';
import type { Request } from 'express';
import { brand } from '@mento/shared';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { rawBody: true });
// bodyParser خاموش تا سقف ۲ مگ برای پیوست فشردهٔ فرم عمومی + حفظ rawBody برای وب‌هوک
const app = await NestFactory.create(AppModule, { bodyParser: false });
app.use(
json({
limit: '2mb',
verify: (req: Request, _res, buf) => {
(req as Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
},
}),
);
app.use(urlencoded({ extended: true, limit: '2mb' }));
const config = app.get(ConfigService);
app.setGlobalPrefix('api');
@@ -98,7 +98,7 @@ export class MembershipsService {
where: { organizationId, userId },
include: {
user: true,
orgRole: { select: { id: true, name: true, color: true } },
orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } },
},
});
}
@@ -108,7 +108,7 @@ export class MembershipsService {
where: { organizationId },
include: {
user: true,
orgRole: { select: { id: true, name: true, color: true } },
orgRole: { select: { id: true, name: true, color: true, dataAccessPolicy: true } },
},
orderBy: [{ user: { lastLoginAt: 'desc' } }, { user: { fullName: 'asc' } }],
});
@@ -56,7 +56,7 @@ function testTemplate() {
}) === 'سلام علی 0912',
'name phone vars',
);
assert(clipSms('الف'.repeat(200)).length === 160, 'sms clip');
assert(clipSms('الف'.repeat(200)).length === 200, 'sms is not clipped');
}
testDelay();
@@ -17,7 +17,20 @@ export const MESSAGE_AUTOMATION_TRIGGERS = [
] as const;
export const MAX_MESSAGE_REPEAT = 10;
export const MAX_MESSAGE_SMS = 160;
/** شمارهٔ مشتری: اول شخص، و اگر نبود شمارهٔ خود سرنخ. */
export function customerPhone(
person: { mobile?: string | null; phone?: string | null } | null | undefined,
lead: { mobile?: string | null; phone?: string | null } | null | undefined,
): string {
return (
person?.mobile?.trim() ||
person?.phone?.trim() ||
lead?.mobile?.trim() ||
lead?.phone?.trim() ||
''
);
}
export function delayMs(value: number, unit: MessageDelayUnit): number {
const n = Math.max(0, Math.trunc(Number(value) || 0));
@@ -92,10 +105,9 @@ export function applyMessageTemplate(
});
}
/** متن کامل پیامک؛ بخش‌بندی و هزینه در درگاه حساب می‌شود. */
export function clipSms(text: string): string {
const trimmed = text.trim();
if (trimmed.length <= MAX_MESSAGE_SMS) return trimmed;
return `${trimmed.slice(0, MAX_MESSAGE_SMS - 1)}…`;
return text.trim();
}
/** When this event happens, cancel leftover messages of these earlier triggers. */
@@ -21,6 +21,7 @@ import {
buildSendAts,
cancelTriggersFor,
clipSms,
customerPhone,
} from './message-automation.util';
export type MessageAutomationContext = {
@@ -354,13 +355,15 @@ export class MessageAutomationsService {
},
})
: null;
const lead = await this.lookupLead(job);
const personName = person
? [person.firstName, person.lastName].filter(Boolean).join(' ').trim()
: '';
const phone = customerPhone(person, lead);
const vars = {
title: job.entityTitle,
name: personName || job.entityTitle,
phone: person?.mobile || person?.phone || '',
name: personName || lead?.contactName?.trim() || lead?.title || job.entityTitle,
phone,
date: formatJalaliDayFa(new Date()),
company: person?.company?.name ?? '',
};
@@ -405,26 +408,59 @@ export class MessageAutomationsService {
}
if (targetPerson && wantSms) {
await this.sendSms(person?.mobile || person?.phone, smsText, {
if (!phone) {
throw new Error('شماره مشتری یا سرنخ برای پیامک پیدا نشد');
}
const sent = await this.sendSms(phone, smsText, {
organizationId: job.organizationId,
personId: job.personId,
leadId: lead?.id ?? null,
ruleId: job.ruleId || job.rule.id || '',
});
if (!sent) {
throw new Error('ارسال پیامک به مشتری یا سرنخ ناموفق بود');
}
}
}
/** سرنخ همان پرونده، یا سرنخی که به این شخص تبدیل شده. */
private async lookupLead(job: {
organizationId: string;
entityId: string;
personId: string | null;
}) {
return this.prisma.lead.findFirst({
where: {
organizationId: job.organizationId,
OR: [
{ id: job.entityId },
...(job.personId ? [{ convertedPersonId: job.personId }] : []),
],
},
select: {
id: true,
mobile: true,
phone: true,
contactName: true,
title: true,
},
orderBy: { createdAt: 'desc' },
});
}
private async sendSms(
mobile: string | null | undefined,
text: string,
log: {
organizationId: string;
personId?: string | null;
leadId?: string | null;
ruleId: string;
},
) {
if (!mobile) {
): Promise<boolean> {
if (!mobile?.trim()) {
this.logger.warn('Message automation SMS skipped: no mobile');
return;
return false;
}
const result = await this.sms.send({
organizationId: log.organizationId,
@@ -432,10 +468,11 @@ export class MessageAutomationsService {
text,
source: 'automation',
});
if (log.personId) {
if (log.personId || log.leadId) {
await this.outboundSms.record({
organizationId: log.organizationId,
personId: log.personId,
leadId: log.leadId,
mobile,
body: text,
source: 'automation',
@@ -448,7 +485,9 @@ export class MessageAutomationsService {
this.logger.warn(
`Message automation SMS failed: ${result.reason ?? 'unknown'}`,
);
return false;
}
return true;
}
private toData(
+77 -19
View File
@@ -5,6 +5,7 @@ import {
IsDateString,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
Max,
@@ -15,19 +16,6 @@ import {
ValidateNested,
} from 'class-validator';
const NOTE_TYPES = [
'general',
'call_result',
'meeting',
'important',
'warning',
'next_action',
'follow_up',
'idea',
'suggestion',
'personal',
] as const;
const NOTE_VISIBILITIES = ['team', 'personal'] as const;
const NOTE_SORTS = ['updatedAt', 'createdAt', 'reminderAt'] as const;
@@ -73,8 +61,10 @@ export class ListNotesQueryDto {
tag?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsString()
@MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional()
@IsIn(NOTE_VISIBILITIES)
@@ -110,8 +100,10 @@ export class CreateNoteDto {
body!: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsString()
@MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional()
@IsIn(NOTE_VISIBILITIES)
@@ -171,6 +163,10 @@ export class CreateNoteDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
activityId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
}
export class UpdateNoteDto {
@@ -187,8 +183,10 @@ export class UpdateNoteDto {
body?: string;
@IsOptional()
@IsIn(NOTE_TYPES)
noteType?: (typeof NOTE_TYPES)[number];
@IsString()
@MinLength(1)
@MaxLength(64)
noteType?: string;
@IsOptional()
@IsIn(NOTE_VISIBILITIES)
@@ -248,6 +246,10 @@ export class UpdateNoteDto {
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
activityId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
}
class NoteAiNextActionDtoClass {
@@ -300,3 +302,59 @@ export class ApplyNoteAiSuggestionDto {
@Type(() => ApplyMemoryEditDto)
memoryItems?: ApplyMemoryEditDto[];
}
const NOTE_CATEGORY_TONES = [
'general',
'follow_up',
'idea',
'suggestion',
'meeting',
'personal',
] as const;
export class CreateNoteCategoryDto {
@IsString()
@MinLength(1)
@MaxLength(40)
label!: string;
@IsOptional()
@IsIn(NOTE_CATEGORY_TONES)
tone?: (typeof NOTE_CATEGORY_TONES)[number];
@IsOptional()
@IsBoolean()
showInBar?: boolean;
}
export class UpdateNoteCategoryDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(40)
label?: string;
@IsOptional()
@IsIn(NOTE_CATEGORY_TONES)
tone?: (typeof NOTE_CATEGORY_TONES)[number];
@IsOptional()
@IsBoolean()
showInBar?: boolean;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
}
export class ReorderNoteCategoriesDto {
@IsArray()
@IsString({ each: true })
ids!: string[];
}
@@ -0,0 +1,165 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { NoteCategoryDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreateNoteCategoryDto,
UpdateNoteCategoryDto,
} from './dto';
const DEFAULTS: Array<{
key: string;
label: string;
tone: string;
showInBar: boolean;
}> = [
{ key: 'general', label: 'معمولی', tone: 'general', showInBar: true },
{ key: 'follow_up', label: 'پیگیری', tone: 'follow_up', showInBar: true },
{ key: 'idea', label: 'ایده', tone: 'idea', showInBar: true },
{ key: 'suggestion', label: 'پیشنهاد', tone: 'suggestion', showInBar: true },
{ key: 'meeting', label: 'جلسه', tone: 'meeting', showInBar: true },
{ key: 'personal', label: 'شخصی', tone: 'personal', showInBar: true },
{ key: 'call_result', label: 'نتیجه تماس', tone: 'follow_up', showInBar: false },
{ key: 'important', label: 'نکته مهم', tone: 'suggestion', showInBar: false },
{ key: 'warning', label: 'هشدار', tone: 'follow_up', showInBar: false },
{ key: 'next_action', label: 'کار بعدی', tone: 'follow_up', showInBar: false },
];
@Injectable()
export class NoteCategoriesService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string): Promise<NoteCategoryDto[]> {
await this.ensureDefaults(organizationId);
const rows = await this.prisma.noteCategory.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
dto: CreateNoteCategoryDto,
): Promise<NoteCategoryDto> {
await this.ensureDefaults(organizationId);
const label = dto.label.trim();
if (!label) throw new BadRequestException('عنوان دسته لازم است');
const max = await this.prisma.noteCategory.aggregate({
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.noteCategory.create({
data: {
organizationId,
key: `cat_${Date.now().toString(36)}`,
label,
tone: dto.tone ?? 'general',
showInBar: dto.showInBar ?? true,
position: (max._max.position ?? -1) + 1,
isSystem: false,
},
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
dto: UpdateNoteCategoryDto,
): Promise<NoteCategoryDto> {
const existing = await this.prisma.noteCategory.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('دسته پیدا نشد');
const label = dto.label?.trim();
if (dto.label !== undefined && !label) {
throw new BadRequestException('عنوان دسته لازم است');
}
const row = await this.prisma.noteCategory.update({
where: { id },
data: {
...(label ? { label } : {}),
...(dto.tone !== undefined ? { tone: dto.tone } : {}),
...(dto.showInBar !== undefined ? { showInBar: dto.showInBar } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(dto.position !== undefined ? { position: dto.position } : {}),
},
});
return this.toDto(row);
}
async reorder(organizationId: string, ids: string[]): Promise<NoteCategoryDto[]> {
const rows = await this.prisma.noteCategory.findMany({
where: { organizationId },
select: { id: true },
});
const known = new Set(rows.map((row) => row.id));
const ordered = ids.filter((id) => known.has(id));
await this.prisma.$transaction(
ordered.map((id, index) =>
this.prisma.noteCategory.update({
where: { id },
data: { position: index },
}),
),
);
return this.list(organizationId);
}
async remove(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.noteCategory.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('دسته پیدا نشد');
if (existing.isSystem) {
throw new BadRequestException('دستهٔ پیش‌فرض حذف نمی‌شود؛ می‌توانید از نوار پنهانش کنید');
}
await this.prisma.noteCategory.delete({ where: { id } });
}
private async ensureDefaults(organizationId: string) {
const count = await this.prisma.noteCategory.count({
where: { organizationId },
});
if (count > 0) return;
await this.prisma.noteCategory.createMany({
data: DEFAULTS.map((item, index) => ({
organizationId,
key: item.key,
label: item.label,
tone: item.tone,
position: index,
showInBar: item.showInBar,
isSystem: true,
isActive: true,
})),
skipDuplicates: true,
});
}
private toDto(row: {
id: string;
key: string;
label: string;
tone: string;
position: number;
isActive: boolean;
showInBar: boolean;
isSystem: boolean;
}): NoteCategoryDto {
return {
id: row.id,
key: row.key,
label: row.label,
tone: row.tone,
position: row.position,
isActive: row.isActive,
showInBar: row.showInBar,
isSystem: row.isSystem,
};
}
}
+60 -2
View File
@@ -15,9 +15,21 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto';
import {
CreateNoteDto,
ListNotesQueryDto,
UpdateNoteDto,
ApplyNoteAiSuggestionDto,
CreateNoteCategoryDto,
UpdateNoteCategoryDto,
ReorderNoteCategoriesDto,
} from './dto';
import { NoteAiService } from './note-ai.service';
import { NoteCategoriesService } from './note-categories.service';
import { NotesService } from './notes.service';
import type { DataVisibility } from '@mento/shared';
@@ -30,13 +42,59 @@ type AuthUser = {
};
@Controller('notes')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('notes')
export class NotesController {
constructor(
private readonly notesService: NotesService,
private readonly noteAi: NoteAiService,
private readonly categories: NoteCategoriesService,
) {}
@Get('categories')
listCategories(@CurrentUser() user: AuthUser) {
return this.categories.list(user.organizationId);
}
@Post('categories')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
createCategory(
@CurrentUser() user: AuthUser,
@Body() dto: CreateNoteCategoryDto,
) {
return this.categories.create(user.organizationId, dto);
}
@Patch('categories/reorder')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
reorderCategories(
@CurrentUser() user: AuthUser,
@Body() dto: ReorderNoteCategoriesDto,
) {
return this.categories.reorder(user.organizationId, dto.ids);
}
@Patch('categories/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updateCategory(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateNoteCategoryDto,
) {
return this.categories.update(user.organizationId, id, dto);
}
@Delete('categories/:id')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
async removeCategory(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.categories.remove(user.organizationId, id);
return { ok: true };
}
@Get()
list(
@CurrentUser() user: AuthUser,
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { MemoryModule } from '../memory/memory.module';
import { NotesController } from './notes.controller';
import { NoteAiService } from './note-ai.service';
import { NoteCategoriesService } from './note-categories.service';
import { NotesService } from './notes.service';
@Module({
imports: [MemoryModule],
controllers: [NotesController],
providers: [NotesService, NoteAiService],
providers: [NotesService, NoteAiService, NoteCategoriesService],
exports: [NotesService],
})
export class NotesModule {}
+15 -1
View File
@@ -19,7 +19,9 @@ import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { GamificationService } from '../gamification/gamification.service';
import { asCustomFields, toJsonCustomFields } from '../common/custom-fields';
import {
type Actor,
noteAccessWhere,
@@ -45,6 +47,7 @@ type NoteRow = {
checklist: Prisma.JsonValue | null;
locationText: string | null;
analyzeEnabled: boolean;
customFields: Prisma.JsonValue;
createdAt: Date;
updatedAt: Date;
person: { id: string; firstName: string; lastName: string } | null;
@@ -112,6 +115,7 @@ export class NotesService {
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
private readonly game: GamificationService,
private readonly storage: StorageQuotaService,
) {}
async list(
@@ -224,6 +228,7 @@ export class NotesService {
: Prisma.JsonNull,
locationText: emptyToNull(dto.locationText ?? null),
analyzeEnabled,
customFields: toJsonCustomFields(dto.customFields),
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
dealId: emptyToNull(dto.dealId ?? null),
@@ -280,6 +285,9 @@ export class NotesService {
if (dto.analyzeEnabled !== undefined) {
data.analyzeEnabled = dto.analyzeEnabled;
}
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
@@ -438,10 +446,12 @@ export class NotesService {
): Promise<void> {
const note = await this.prisma.note.findFirst({
where: { id, organizationId, ...noteAccessWhere(actor) },
include: { attachments: { select: { storageKey: true } } },
include: { attachments: { select: { storageKey: true, sizeBytes: true } } },
});
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
const bytes = note.attachments.reduce((sum, file) => sum + file.sizeBytes, 0);
if (bytes) await this.storage.addLocal(organizationId, -bytes);
for (const att of note.attachments) {
await this.files.remove(att.storageKey);
}
@@ -485,6 +495,7 @@ export class NotesService {
.join(organizationId, 'notes', noteId, `${attachmentId}${ext}`)
.replace(/\\/g, '/');
await this.storage.assertCanAdd(organizationId, file.size);
await this.files.save(storageKey, file.buffer);
try {
@@ -500,6 +511,7 @@ export class NotesService {
uploadedById: userId,
},
});
await this.storage.addLocal(organizationId, file.size);
return this.toAttachmentDto(row);
} catch (err) {
await this.files.remove(storageKey);
@@ -520,6 +532,7 @@ export class NotesService {
if (!row) throw new NotFoundException('پیوست پیدا نشد');
await this.files.remove(row.storageKey);
await this.prisma.noteAttachment.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes);
}
async openAttachmentDownload(
@@ -616,6 +629,7 @@ export class NotesService {
checklist: parseChecklist(row.checklist),
locationText: row.locationText ?? null,
analyzeEnabled: row.analyzeEnabled ?? true,
customFields: asCustomFields(row.customFields),
person: row.person
? {
id: row.person.id,
+9
View File
@@ -3,6 +3,7 @@ import {
ArrayUnique,
IsArray,
IsIn,
IsObject,
IsOptional,
IsString,
MaxLength,
@@ -36,6 +37,10 @@ export class CreateOrgRoleDto {
@IsIn(PERMISSION_KEYS, { each: true })
permissions?: RolePermissionKey[];
@IsOptional()
@IsObject()
dataAccessPolicy?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
@@ -64,6 +69,10 @@ export class UpdateOrgRoleDto {
@IsIn(PERMISSION_KEYS, { each: true })
permissions?: RolePermissionKey[];
@IsOptional()
@IsObject()
dataAccessPolicy?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
+17 -1
View File
@@ -5,7 +5,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { OrgRoleDto, RolePermissionKey, UserRole } from '@mento/shared';
import type {
DataAccessPolicy,
OrgRoleDto,
RolePermissionKey,
UserRole,
} from '@mento/shared';
import { parseDataAccessPolicy } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -25,6 +31,7 @@ type RoleRow = {
description: string;
color: string;
permissions: string[];
dataAccessPolicy?: unknown;
systemKey: string | null;
parentId: string | null;
createdAt: Date;
@@ -70,6 +77,7 @@ export class OrgRolesService {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null;
},
): Promise<OrgRoleDto> {
@@ -101,6 +109,9 @@ export class OrgRolesService {
color: dto.color ?? parent?.color ?? 'teal',
permissions,
parentId,
...(dto.dataAccessPolicy
? { dataAccessPolicy: parseDataAccessPolicy(dto.dataAccessPolicy) }
: {}),
},
include: ROLE_MEMBERS_INCLUDE,
});
@@ -122,6 +133,7 @@ export class OrgRolesService {
description?: string;
color?: string;
permissions?: RolePermissionKey[];
dataAccessPolicy?: DataAccessPolicy;
parentId?: string | null;
},
): Promise<OrgRoleDto> {
@@ -144,6 +156,9 @@ export class OrgRolesService {
if (dto.permissions !== undefined) {
data.permissions = sanitizePermissions(dto.permissions);
}
if (dto.dataAccessPolicy !== undefined) {
data.dataAccessPolicy = parseDataAccessPolicy(dto.dataAccessPolicy);
}
if (dto.parentId !== undefined) {
const nextParentId =
@@ -410,6 +425,7 @@ export class OrgRolesService {
description: row.description,
color: row.color,
permissions: sanitizePermissions(row.permissions),
dataAccessPolicy: parseDataAccessPolicy(row.dataAccessPolicy),
systemKey,
parentId: row.parentId,
parentName: row.parentId ? (nameById.get(row.parentId) ?? null) : null,
+3 -1
View File
@@ -41,7 +41,9 @@ export const SYSTEM_ROLE_SEEDS: Array<{
name: 'کارشناس فروش',
description: 'امکان دسترسی به بخش‌های فروش و مشتریان برای انجام فعالیت‌های روزمره.',
color: 'teal',
permissions: [...ALL_ROLE_PERMISSIONS],
permissions: ALL_ROLE_PERMISSIONS.filter(
(key) => key !== 'audit' && !key.startsWith('assign_'),
),
},
{
systemKey: 'support',
+3 -1
View File
@@ -16,6 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard';
@@ -26,8 +27,9 @@ import { DealsExcelService } from './deals-excel.service';
import { brand } from '@mento/shared';
@Controller('deals')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('pipeline')
@RequirePermission('sales')
export class DealsController {
constructor(
private readonly dealsService: DealsService,
+37 -12
View File
@@ -22,11 +22,11 @@ import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
import {
type Actor,
canReassignOwner,
dealAccessWhere,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
@@ -106,6 +106,10 @@ export class DealsService {
organizationId,
dto.assigneeIds,
);
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
const allowOthers = await actorCanAssignToOthers(this.prisma, assignActor, 'sales');
const ownerId = resolveOwnerId(actor, dto.ownerId, allowOthers);
await assertAssigneeSet(this.prisma, assignActor, 'sales', [ownerId, ...assigneeIds]);
const maxPos = await this.prisma.deal.aggregate({
where: { organizationId, stageId: stage.id },
@@ -156,7 +160,7 @@ export class DealsService {
createdById: actor.userId,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
ownerId: resolveOwnerId(actor, dto.ownerId),
ownerId,
position: (maxPos._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
@@ -259,17 +263,38 @@ export class DealsService {
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
}
let assigneeConnect: { id: string }[] | undefined;
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
if (dto.ownerId !== undefined || dto.assigneeIds !== undefined) {
const current = await this.prisma.deal.findFirst({
where: { id, organizationId },
select: { ownerId: true, assignees: { select: { id: true } } },
});
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
if (dto.ownerId !== undefined) {
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(
this.prisma,
assignActor,
'sales',
[nextOwner],
[current?.ownerId],
);
data.ownerId = nextOwner ?? actor.userId;
}
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
await assertAssigneeSet(
this.prisma,
assignActor,
'sales',
assigneeIds,
current?.assignees.map((item) => item.id) ?? [],
);
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
}
}
if (pricedFromLines && dto.lineItems) {
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
@@ -24,7 +25,8 @@ import {
import { PipelinesService } from './pipelines.service';
@Controller('pipelines')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
@RequirePermission('sales')
export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {}
+43
View File
@@ -4,8 +4,10 @@ import {
IsEmail,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
@@ -52,6 +54,31 @@ export class ResetPlatformAdminPasswordDto {
password!: string;
}
export class UpdatePlatformAdminDto {
@IsString()
@MinLength(2)
@MaxLength(120)
fullName!: string;
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== '')
@IsEmail()
email?: string | null;
}
export class SetMasterPasswordDto {
@ValidateIf((_, value) => value !== null)
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string | null;
}
export class UpdatePlatformStatusDto {
@IsBoolean()
isActive!: boolean;
@@ -146,6 +173,22 @@ export class ApplyBillingPlanDto {
status?: 'active' | 'expired' | 'suspended';
}
export class SetUserCapDto {
@IsInt()
@Min(1)
@Max(100_000)
maxUsers!: number;
}
export class SetStorageQuotaDto {
/** گیگابایت؛ null = بدون سقف */
@IsNumber()
@Min(0)
@Max(100_000)
@ValidateIf((_, value) => value !== null)
quotaGb!: number | null;
}
export class UpdateOrgModulesDto {
@IsArray()
@IsString({ each: true })
@@ -1,19 +1,25 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import {
ApplyBillingPlanDto,
SetStorageQuotaDto,
SetUserCapDto,
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
SetMasterPasswordDto,
UpdatePlatformAdminDto,
ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
UpdatePlatformMembershipRoleDto,
@@ -71,6 +77,26 @@ export class PlatformController {
return this.platform.resetAdminPassword(id, dto);
}
@Patch('admins/:id/profile')
updateAdmin(@Param('id') id: string, @Body() dto: UpdatePlatformAdminDto) {
return this.platform.updateAdmin(id, dto);
}
@Delete('admins/:id')
deleteAdmin(@Param('id') id: string, @CurrentUser() actor: PlatformActor) {
return this.platform.deleteAdmin(id, actor.platformAdminId);
}
@Get('master-password')
masterPasswordStatus() {
return this.platform.masterPasswordStatus();
}
@Put('master-password')
setMasterPassword(@Body() dto: SetMasterPasswordDto) {
return this.platform.setMasterPassword(dto.password);
}
@Get('organizations')
listOrganizations(@Query('q') q?: string) {
return this.platform.listOrganizations(q);
@@ -122,6 +148,26 @@ export class PlatformController {
return this.platform.applyBillingPlan(id, dto);
}
@Get('users/:id/storage')
storageUsage(@Param('id') id: string, @Query('refresh') refresh?: string) {
return this.platform.storageUsage(id, refresh === '1' || refresh === 'true');
}
@Patch('users/:id/billing/storage')
setStorageQuota(@Param('id') id: string, @Body() dto: SetStorageQuotaDto) {
return this.platform.setStorageQuota(id, dto.quotaGb);
}
@Get('users/:id/seats')
seatUsage(@Param('id') id: string) {
return this.platform.seatUsage(id);
}
@Patch('users/:id/billing/users')
setUserCap(@Param('id') id: string, @Body() dto: SetUserCapDto) {
return this.platform.setUserCap(id, dto.maxUsers);
}
@Patch('organizations/:id/modules')
updateOrganizationModules(
@Param('id') id: string,
+166 -2
View File
@@ -19,6 +19,8 @@ import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { BillingService, toSummary } from '../billing/billing.service';
import { StorageQuotaService } from '../billing/storage-quota.service';
import { PlatformAdminAlertService } from '../billing/platform-admin-alert.service';
import { PrismaService } from '../prisma/prisma.service';
import { buildSearchWhere, ci } from '../common/search';
import type {
@@ -50,6 +52,8 @@ export class PlatformService {
private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService,
private readonly billing: BillingService,
private readonly storage: StorageQuotaService,
private readonly adminAlerts: PlatformAdminAlertService,
) {}
async stats(): Promise<PlatformStatsDto> {
@@ -107,15 +111,23 @@ export class PlatformService {
where: { role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
take: 1,
select: { user: { select: { billingAccount: true } } },
select: {
userId: true,
user: { select: { fullName: true, billingAccount: true } },
},
},
conversationSettings: { select: { organizationId: true } },
},
});
return rows.map((row) => {
const account = row.memberships[0]?.user.billingAccount;
const owner = row.memberships[0];
const account = owner?.user.billingAccount;
return {
...this.orgToDto(row),
subscription: account ? toSummary(account) : null,
ownerUserId: owner?.userId ?? null,
ownerName: owner?.user.fullName ?? null,
chatConfigured: Boolean(row.conversationSettings),
};
});
}
@@ -275,6 +287,12 @@ export class PlatformService {
await this.billing.ensureTrialForOwner(owner.id, { maxOrganizations: 1 });
await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id);
await this.adminAlerts.notifyNewAccount({
source: 'admin',
organizationName: organization.name,
ownerName: owner.fullName,
mobile: owner.mobile,
});
return this.getOrganization(organization.id);
} catch (e) {
@@ -400,6 +418,50 @@ export class PlatformService {
return this.userToDto(row);
}
async storageUsage(userId: string, refresh = false) {
const owner = await this.prisma.membership.findFirst({
where: { userId, role: 'owner', isActive: true },
select: { id: true },
});
if (!owner) {
throw new BadRequestException('حجم فایل فقط برای مالک محیط معنی دارد');
}
const account = await this.billing.getByOwnerUserId(userId);
if (!account) {
throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید');
}
return this.storage.usageForOwner(userId, refresh);
}
async seatUsage(userId: string) {
await this.requireBillingOwner(userId);
return this.billing.seatUsage(userId);
}
async setUserCap(userId: string, maxUsers: number) {
await this.requireBillingOwner(userId);
return this.billing.setMaxUsers(userId, maxUsers);
}
private async requireBillingOwner(userId: string) {
const owner = await this.prisma.membership.findFirst({
where: { userId, role: 'owner', isActive: true },
select: { id: true },
});
if (!owner) {
throw new BadRequestException('سقف کاربر فقط برای مالک محیط معنی دارد');
}
const account = await this.billing.getByOwnerUserId(userId);
if (!account) {
throw new BadRequestException('اول برای این مالک یک پلن اعمال کنید');
}
}
async setStorageQuota(userId: string, quotaGb: number | null) {
await this.storageUsage(userId, false);
return this.storage.setQuotaGb(userId, quotaGb);
}
async applyBillingPlan(userId: string, dto: ApplyBillingPlanDto) {
const existing = await this.prisma.user.findUnique({
where: { id: userId },
@@ -465,9 +527,13 @@ export class PlatformService {
userId: true,
organizationId: true,
role: true,
isActive: true,
},
});
if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (isActive && !row.isActive) {
await this.billing.assertSeatAvailable(row.organizationId, row.userId);
}
if (!isActive && row.role === 'owner') {
const activeOwners = await this.memberships.countActiveOwners(
row.organizationId,
@@ -640,6 +706,104 @@ export class PlatformService {
return { ok: true };
}
async updateAdmin(
id: string,
dto: { fullName: string; mobile: string; email?: string | null },
): Promise<PlatformAdminDto> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام معتبر نیست');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const email = dto.email?.trim().toLowerCase() || null;
const mobileTaken = await this.prisma.platformAdmin.findFirst({
where: { mobile, id: { not: id } },
select: { id: true },
});
if (mobileTaken) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
if (email) {
const emailTaken = await this.prisma.platformAdmin.findFirst({
where: { email, id: { not: id } },
select: { id: true },
});
if (emailTaken) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
}
try {
const updated = await this.prisma.platformAdmin.update({
where: { id },
data: { fullName, mobile, email },
});
return this.adminToDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('موبایل یا ایمیل تکراری است');
}
throw e;
}
}
async deleteAdmin(id: string, actorId: string): Promise<{ ok: true }> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
if (id === actorId) {
throw new BadRequestException('نمی‌توانید حساب خودتان را حذف کنید');
}
const others = await this.prisma.platformAdmin.count({
where: { id: { not: id } },
});
if (others < 1) {
throw new BadRequestException('حداقل یک سوپرادمین باید بماند');
}
await this.prisma.platformAdmin.delete({ where: { id } });
return { ok: true };
}
async masterPasswordStatus(): Promise<{ enabled: boolean; updatedAt: string | null }> {
const row = await this.prisma.platformMasterPassword.findUnique({
where: { id: 'default' },
select: { updatedAt: true },
});
return {
enabled: Boolean(row),
updatedAt: row?.updatedAt.toISOString() ?? null,
};
}
async setMasterPassword(
password: string | null,
): Promise<{ enabled: boolean; updatedAt: string | null }> {
const trimmed = password?.trim() ?? '';
if (!trimmed) {
await this.prisma.platformMasterPassword.deleteMany({ where: { id: 'default' } });
return { enabled: false, updatedAt: null };
}
if (trimmed.length < 8) {
throw new BadRequestException('رمز کلی حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(trimmed, 10);
const row = await this.prisma.platformMasterPassword.upsert({
where: { id: 'default' },
create: { id: 'default', passwordHash },
update: { passwordHash },
});
return { enabled: true, updatedAt: row.updatedAt.toISOString() };
}
private adminToDto(row: {
id: string;
mobile: string | null;
+3 -1
View File
@@ -17,6 +17,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
@@ -27,7 +28,8 @@ import { ProductsExcelService } from './products-excel.service';
import { brand } from '@mento/shared';
@Controller('products')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
@RequirePermission('products')
export class ProductsController {
constructor(
private readonly productsService: ProductsService,
@@ -15,12 +15,12 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
canReassignOwner,
dealAccessWhere,
ownerWhere,
projectCardAccessWhere,
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { normalizeTags } from '../common/tags';
const DEFAULT_STAGES = ['صف', 'در حال انجام', 'بازبینی', 'انجام‌شده'] as const;
@@ -35,7 +35,13 @@ const cardInclude = {
type CardActor = Pick<
Actor,
'userId' | 'role' | 'dataVisibility' | 'visibleUserIds'
| 'userId'
| 'role'
| 'dataVisibility'
| 'visibleUserIds'
| 'scopes'
| 'projectsAccess'
| 'teamPeerIds'
>;
@Injectable()
@@ -200,6 +206,13 @@ export class ProjectBoardsService {
});
const assigneeIds = [...new Set((dto.assigneeIds ?? []).map((id) => id.trim()).filter(Boolean))];
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
const ownerId = resolveOwnerId(
actor,
dto.ownerId,
await actorCanAssignToOthers(this.prisma, assignActor, 'projects'),
);
await assertAssigneeSet(this.prisma, assignActor, 'projects', [ownerId, ...assigneeIds]);
const created = await this.prisma.projectCard.create({
data: {
organizationId,
@@ -211,7 +224,7 @@ export class ProjectBoardsService {
personId: emptyToNull(dto.personId),
companyId: emptyToNull(dto.companyId),
dealId: emptyToNull(dto.dealId),
ownerId: resolveOwnerId(actor, dto.ownerId),
ownerId,
position: (maxPos._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
@@ -251,24 +264,40 @@ export class ProjectBoardsService {
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('ownerId' in dto) {
if (!canReassignOwner(actor.role) && dto.ownerId !== existing.ownerId) {
throw new BadRequestException('تغییر مسئول مجاز نیست');
}
data.ownerId = emptyToNull(dto.ownerId ?? null);
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'projects',
[nextOwner],
[existing.ownerId],
);
data.ownerId = nextOwner;
}
const nextAssigneeIds = dto.assigneeIds
? [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))]
: undefined;
if (nextAssigneeIds) {
const currentAssignees = await this.prisma.projectCard.findFirst({
where: { id },
select: { assignees: { select: { id: true } } },
});
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'projects',
nextAssigneeIds,
currentAssignees?.assignees.map((item) => item.id) ?? [],
);
}
const row = await this.prisma.projectCard.update({
where: { id },
data: {
...data,
...(dto.assigneeIds
? {
assignees: {
set: [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))].map(
(uid) => ({ id: uid }),
),
},
}
...(nextAssigneeIds
? { assignees: { set: nextAssigneeIds.map((uid) => ({ id: uid })) } }
: {}),
},
include: cardInclude,
+3 -1
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateQuoteDto,
@@ -21,7 +22,8 @@ import { QuotesService } from './quotes.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('quotes')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('billing')
export class QuotesController {
constructor(private readonly quotesService: QuotesService) {}
+5 -1
View File
@@ -17,6 +17,7 @@ import {
MaxLength,
} from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { SavedViewsService } from '../saved-views/saved-views.service';
@@ -52,7 +53,8 @@ class UpdateReportViewDto {
}
@Controller('reports')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('reports', { reads: true })
export class ReportsController {
constructor(
private readonly reportsService: ReportsService,
@@ -95,7 +97,9 @@ export class ReportsController {
});
}
/** داشبورد صفحهٔ خانه برای همه باز است */
@Get('home')
@RequirePermission('reports', { reads: false })
home(@CurrentUser() user: Actor) {
return this.reportsService.home(user);
}
+26
View File
@@ -0,0 +1,26 @@
import { Prisma, type RoutineTaskSource } from '@prisma/client';
/**
* کارهای روتین فقط کار داخلی تیم است.
* پیگیری، لید، مشتری و معامله روی پرونده خودشان می‌مانند و وارد میز روتین نمی‌شوند.
*/
export const internalRoutineTaskWhere: Prisma.RoutineTaskWhereInput = {
source: { in: ['routine', 'manual'] },
leadId: null,
personId: null,
companyId: null,
dealId: null,
invoiceId: null,
activityId: null,
};
/** فیلتر میز روتین. منبع مشتری حتی اگر صریحاً خواسته شود وارد لیست نمی‌شود. */
export function deskRoutineTaskWhere(
source?: RoutineTaskSource | null,
): Prisma.RoutineTaskWhereInput {
if (source === 'routine' || source === 'manual') {
return { ...internalRoutineTaskWhere, source };
}
if (source) return { AND: [internalRoutineTaskWhere, { source }] };
return internalRoutineTaskWhere;
}
@@ -3,6 +3,7 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { dayKeyToDate, tehranDayKey, tehranHour, toFaDigits } from './routine-dates';
import { internalRoutineTaskWhere } from './internal-routine';
const TIME_FMT = new Intl.DateTimeFormat('fa-IR', {
timeZone: 'Asia/Tehran',
@@ -44,6 +45,7 @@ export class RoutineRemindersService {
status: { in: ['todo', 'in_progress'] },
reminderAt: { lte: now },
reminderSentAt: null,
...internalRoutineTaskWhere,
},
select: { id: true, organizationId: true, assigneeId: true, title: true, dueAt: true, reminderAt: true },
take: 300,
@@ -76,7 +78,7 @@ export class RoutineRemindersService {
/** هشدار Task اجباری که مهلتش گذشته («… هنوز انجام نشده») */
private async sendOverdueAlerts() {
const rows = await this.prisma.routineTask.findMany({
where: { status: 'overdue', isMandatory: true, overdueNotifiedAt: null },
where: { status: 'overdue', isMandatory: true, overdueNotifiedAt: null, ...internalRoutineTaskWhere },
select: { id: true, organizationId: true, assigneeId: true, title: true },
take: 300,
});
@@ -114,7 +116,11 @@ export class RoutineRemindersService {
const groups = await this.prisma.routineTask.groupBy({
by: ['organizationId', 'assigneeId'],
where: { scheduledFor: todayDate, status: { in: ['todo', 'in_progress'] } },
where: {
scheduledFor: todayDate,
status: { in: ['todo', 'in_progress'] },
...internalRoutineTaskWhere,
},
_count: { _all: true },
});
if (!groups.length) return;
+22 -2
View File
@@ -1,4 +1,6 @@
import { ForbiddenException } from '@nestjs/common';
import { sectionHasDataAccessLimits } from '@mento/shared';
import { sectionOwnerIdUnion } from '../common/data-access';
import { type Actor, elevated, scopedOwnerIds } from '../common/record-scope';
import type { PrismaService } from '../prisma/prisma.service';
@@ -38,8 +40,26 @@ export async function resolveRoutineScope(
manageable = [...new Set([actor.userId, ...members.map((m) => m.userId)])];
}
const visible = scopedOwnerIds(actor, 'routines');
const viewable = visible === null ? null : [...new Set([...visible, ...manageable])];
let viewable: string[] | null;
if (actor.routinesAccess !== undefined) {
if (!sectionHasDataAccessLimits(actor.routinesAccess)) {
viewable = null;
} else {
const owners = sectionOwnerIdUnion(actor.routinesAccess, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
// فقط-مالک: محدود به owners؛ اگر قانون بدون owners (مثلاً دسته) باشد null = همه + فیلتر deepWhere
viewable = owners === null ? null : owners.length ? owners : [actor.userId];
}
} else {
const visible = scopedOwnerIds(actor, 'routines');
viewable = visible === null ? null : [...new Set([...visible, ...manageable])];
}
if (viewable !== null) {
viewable = [...new Set([...viewable, ...manageable])];
}
return {
...base,
@@ -18,6 +18,7 @@ import type {
} from '@mento/shared';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { deskRoutineTaskWhere, internalRoutineTaskWhere } from './internal-routine';
import type { RoutineRangeQueryDto } from './dto';
import { describeRecurrence } from './recurrence';
import { routineRule } from './routine-mapper';
@@ -118,6 +119,7 @@ export class RoutineStatsService {
organizationId,
assigneeId: userId,
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(day) },
...internalRoutineTaskWhere,
},
select: { scheduledFor: true, status: true },
});
@@ -155,6 +157,7 @@ export class RoutineStatsService {
isMandatory: true,
status: { not: 'cancelled' },
scheduledFor: { gte: dayKeyToDate(addDays(today, -180)), lte: dayKeyToDate(today) },
...internalRoutineTaskWhere,
},
_count: { _all: true },
});
@@ -437,8 +440,8 @@ export class RoutineStatsService {
organizationId,
assigneeId: { in: userIds },
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
...deskRoutineTaskWhere(q.source),
...(q.categoryId ? { categoryId: q.categoryId } : {}),
...(q.source ? { source: q.source } : {}),
...(q.status ? { status: q.status } : {}),
},
select: statSelect,
+40 -44
View File
@@ -16,7 +16,7 @@ import type {
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import type { Actor } from '../common/record-scope';
import { type Actor, routineTaskAccessWhere } from '../common/record-scope';
import type {
CreateRoutineTaskDto,
RoutineEntityQueryDto,
@@ -47,6 +47,7 @@ import {
tehranDateTime,
tehranDayKey,
} from './routine-dates';
import { deskRoutineTaskWhere, internalRoutineTaskWhere } from './internal-routine';
const OPEN_STATUSES: RoutineTaskStatus[] = ['todo', 'in_progress', 'overdue'];
@@ -63,6 +64,10 @@ export class RoutineTasksService {
return resolveRoutineScope(this.prisma, actor);
}
private deepWhere(actor: Actor): Prisma.RoutineTaskWhereInput {
return routineTaskAccessWhere(actor);
}
/* ───────── خواندن ───────── */
async today(actor: Actor, userId?: string, date?: string): Promise<RoutineTodayDto> {
@@ -71,6 +76,7 @@ export class RoutineTasksService {
if (!canView(scope, target)) throw new ForbiddenException('دسترسی ندارید');
const day = date && isDayKey(date) ? date : tehranDayKey();
const dayDate = dayKeyToDate(day);
const deep = this.deepWhere(actor);
const [todayRows, overdueRows, score] = await Promise.all([
this.prisma.routineTask.findMany({
@@ -79,6 +85,8 @@ export class RoutineTasksService {
assigneeId: target,
scheduledFor: dayDate,
status: { not: 'cancelled' },
...internalRoutineTaskWhere,
...deep,
},
include: routineTaskInclude,
orderBy: [{ dueAt: 'asc' }, { createdAt: 'asc' }],
@@ -89,6 +97,8 @@ export class RoutineTasksService {
assigneeId: target,
status: 'overdue',
scheduledFor: { lt: dayDate },
...internalRoutineTaskWhere,
...deep,
},
include: { ...routineTaskInclude, routine: { select: { ...routineTaskInclude.routine.select, carryOverOverdue: true } } },
orderBy: [{ dueAt: 'asc' }],
@@ -134,10 +144,16 @@ export class RoutineTasksService {
assigneeId: actor.userId,
scheduledFor: day,
status: { in: ['todo', 'in_progress'] },
...internalRoutineTaskWhere,
},
}),
this.prisma.routineTask.count({
where: { organizationId: actor.organizationId, assigneeId: actor.userId, status: 'overdue' },
where: {
organizationId: actor.organizationId,
assigneeId: actor.userId,
status: 'overdue',
...internalRoutineTaskWhere,
},
}),
]);
return { remaining, overdue };
@@ -148,7 +164,13 @@ export class RoutineTasksService {
const filter =
userId === 'all' ? assigneeFilter(scope) : assigneeFilter(scope, userId || actor.userId);
const rows = await this.prisma.routineTask.findMany({
where: { organizationId: actor.organizationId, status: 'overdue', ...filter },
where: {
organizationId: actor.organizationId,
status: 'overdue',
...internalRoutineTaskWhere,
...filter,
...this.deepWhere(actor),
},
include: routineTaskInclude,
orderBy: [{ dueAt: 'asc' }],
take: 300,
@@ -166,9 +188,10 @@ export class RoutineTasksService {
organizationId: actor.organizationId,
scheduledFor: { gte: dayKeyToDate(from), lte: dayKeyToDate(to) },
...filter,
...this.deepWhere(actor),
...deskRoutineTaskWhere(q.source),
...(q.categoryId ? { categoryId: q.categoryId } : {}),
...(q.status ? { status: q.status } : {}),
...(q.source ? { source: q.source } : {}),
};
const rows = await this.prisma.routineTask.findMany({
where,
@@ -196,6 +219,7 @@ export class RoutineTasksService {
OR: or,
status: { not: 'cancelled' },
...assigneeFilter(scope),
...this.deepWhere(actor),
},
include: routineTaskInclude,
orderBy: [{ scheduledFor: 'desc' }, { dueAt: 'desc' }],
@@ -208,6 +232,14 @@ export class RoutineTasksService {
const scope = await this.scope(actor);
const row = await this.findTask(actor.organizationId, id);
if (!canView(scope, row.assigneeId)) throw new ForbiddenException('دسترسی ندارید');
const deep = this.deepWhere(actor);
if (Object.keys(deep).length) {
const allowed = await this.prisma.routineTask.findFirst({
where: { id, organizationId: actor.organizationId, ...deep },
select: { id: true },
});
if (!allowed) throw new ForbiddenException('دسترسی ندارید');
}
const events = await this.prisma.routineTaskEvent.findMany({
where: { taskId: id },
include: { actor: { select: { id: true, fullName: true } } },
@@ -289,46 +321,10 @@ export class RoutineTasksService {
return toTaskDto(row);
}
async createFromActivity(actor: Actor, activityId: string): Promise<RoutineTaskDto> {
const activity = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId: actor.organizationId },
select: {
id: true,
title: true,
description: true,
dueAt: true,
priority: true,
personId: true,
companyId: true,
dealId: true,
assigneeId: true,
},
});
if (!activity) throw new NotFoundException('پیگیری پیدا نشد');
const existing = await this.prisma.routineTask.findFirst({
where: { organizationId: actor.organizationId, activityId, status: { not: 'cancelled' } },
include: routineTaskInclude,
});
if (existing) return toTaskDto(existing);
const scope = await this.scope(actor);
const assigneeId =
activity.assigneeId && canManage(scope, activity.assigneeId)
? activity.assigneeId
: actor.userId;
return this.create(actor, {
title: activity.title,
description: activity.description,
dueAt: activity.dueAt?.toISOString() ?? null,
priority: activity.priority,
assigneeId,
personId: activity.personId,
companyId: activity.companyId,
dealId: activity.dealId,
activityId: activity.id,
source: 'follow_up',
});
async createFromActivity(_actor: Actor, _activityId: string): Promise<RoutineTaskDto> {
throw new BadRequestException(
'پیگیری مشتری در کارهای روتین ثبت نمی‌شود. کارهای روتین فقط برای کار داخلی تیم است.',
);
}
async update(actor: Actor, id: string, dto: UpdateRoutineTaskDto): Promise<RoutineTaskDto> {
@@ -13,6 +13,7 @@ import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { elevated } from '../common/record-scope';
import { actorCanAssignToOthers, assignOthersDeniedMessage } from '../common/assign-access';
import {
ChangeRoutineAssigneeDto,
ListRoutinesQueryDto,
@@ -360,6 +361,11 @@ export class RoutinesService {
assigneeId?: string | null,
teamId?: string | null,
): Promise<{ assigneeId: string | null; teamId: string | null }> {
if ((assigneeId && assigneeId !== actor.userId) || teamId) {
if (!(await actorCanAssignToOthers(this.prisma, actor, 'routines'))) {
throw new ForbiddenException(assignOthersDeniedMessage('routines'));
}
}
if (assigneeId) {
if (!canManage(scope, assigneeId)) {
throw new ForbiddenException('اجازه تعریف روتین برای این کاربر را ندارید');
+3 -1
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateSalesGoalDto,
@@ -20,7 +21,8 @@ import {
import { SalesService } from './sales.service';
@Controller('sales')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('sales')
export class SalesController {
constructor(private readonly salesService: SalesService) {}
@@ -17,6 +17,7 @@ import { SMS_CAMPAIGN_CHUNK_SIZE } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsGatewayService } from '../sms-billing/sms-gateway.service';
import { renderSmsText } from '../common/sms-text';
import { leadsDeepWhere } from '../common/data-access';
import {
type Actor,
elevated,
@@ -28,6 +29,9 @@ import { OutboundSmsLogService } from '../outbound-sms/outbound-sms-log.service'
import type { Prisma } from '@prisma/client';
function leadAccessWhere(baseActor: Actor): Prisma.LeadWhereInput {
if (baseActor.leadsAccess !== undefined) {
return leadsDeepWhere(baseActor);
}
const actor = forScope(baseActor, 'leads');
if (elevated(actor.role)) return {};
if (actor.dataVisibility === 'org') return {};
@@ -32,6 +32,15 @@ export class LocalFileStorage implements OnModuleInit {
await fs.writeFile(abs, buffer);
}
async sizeOf(storageKey: string): Promise<number> {
try {
const stat = await fs.stat(this.absolutePath(storageKey));
return stat.size;
} catch {
return 0;
}
}
async remove(storageKey: string): Promise<void> {
const abs = this.absolutePath(storageKey);
try {
+3 -6
View File
@@ -20,6 +20,7 @@ import {
elevated,
ticketAccessWhere,
} from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
@@ -136,9 +137,7 @@ export class TicketsService {
if (assigneeId) {
await this.assertUser(actor.organizationId, assigneeId);
}
if (!elevated(actor.role) && assigneeId && assigneeId !== actor.userId) {
assigneeId = actor.userId;
}
await assertAssigneeSet(this.prisma, actor, 'tickets', [assigneeId]);
const number = await this.nextNumber(actor.organizationId);
const status = (dto.status as never) || 'open';
@@ -270,9 +269,7 @@ export class TicketsService {
if ('assigneeId' in dto) {
const next = emptyToNull(dto.assigneeId ?? null);
if (next) await this.assertUser(actor.organizationId, next);
if (!elevated(actor.role) && next && next !== actor.userId) {
throw new BadRequestException('فقط می‌توانید تیکت را به خودتان ارجاع دهید');
}
await assertAssigneeSet(this.prisma, actor, 'tickets', [next], [existing.assigneeId]);
data.assigneeId = next;
if (next && next !== existing.assigneeId && next !== actor.userId) {
await this.notifications.create({
+22 -11
View File
@@ -15,6 +15,7 @@ import type {
} from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope';
import { TreasuryReceivablesService } from './treasury-receivables.service';
@@ -61,6 +62,7 @@ export class TreasuryService {
private readonly prisma: PrismaService,
private readonly receivables: TreasuryReceivablesService,
private readonly files: LocalFileStorage,
private readonly storage: StorageQuotaService,
) {}
async ensureCategories(organizationId: string) {
@@ -613,18 +615,27 @@ export class TreasuryService {
if (!doc) throw new NotFoundException('سند پیدا نشد.');
const safeName = file.originalname.replace(/[^\w.\-\u0600-\u06FF]+/g, '_').slice(0, 80);
const storageKey = `treasury/${actor.organizationId}/${doc.id}/${randomUUID()}-${safeName || 'file'}`;
const bytes = file.buffer.length;
await this.storage.assertCanAdd(actor.organizationId, bytes);
await this.files.save(storageKey, file.buffer);
return this.prisma.financialAttachment.create({
data: {
organizationId: actor.organizationId,
documentId: doc.id,
fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream',
storageKey,
createdById: actor.userId,
},
select: { id: true, fileName: true, createdAt: true },
});
try {
const row = await this.prisma.financialAttachment.create({
data: {
organizationId: actor.organizationId,
documentId: doc.id,
fileName: file.originalname,
mimeType: file.mimetype || 'application/octet-stream',
storageKey,
createdById: actor.userId,
},
select: { id: true, fileName: true, createdAt: true },
});
await this.storage.addLocal(actor.organizationId, bytes);
return row;
} catch (err) {
await this.files.remove(storageKey);
throw err;
}
}
async dashboard(
+22
View File
@@ -81,6 +81,12 @@ export class UpdateUserStatusDto {
isActive!: boolean;
}
export class UpdateUserLoginOtpOnlyDto {
@Type(() => Boolean)
@IsBoolean()
loginOtpOnly!: boolean;
}
export class UpdateUserCommissionDto {
@Type(() => Number)
@IsNumber()
@@ -95,6 +101,11 @@ export class UpdateLogoutTimeDto {
@IsString()
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'ساعت خروج معتبر نیست' })
logoutTime?: string | null;
/** بدون مقدار = بدون تغییر */
@IsOptional()
@IsBoolean()
blockLoginAfterLogout?: boolean;
}
export class UpdateUserVisibilityDto {
@@ -107,3 +118,14 @@ export class UpdateUserVisibilityDto {
@IsObject()
scopes?: Record<string, 'own' | 'team' | 'org' | null>;
}
export class UpdateUserDataAccessDto {
/**
* null = پاک کردن override و ارث از نقش.
* آبجکت = جایگزینی کامل override.
*/
@ValidateIf((_, v) => v !== null)
@IsOptional()
@IsObject()
dataAccessOverride?: Record<string, unknown> | null;
}
+33
View File
@@ -18,8 +18,10 @@ import {
UpdateOrgUserDto,
UpdateUserCommissionDto,
UpdateLogoutTimeDto,
UpdateUserLoginOtpOnlyDto,
UpdateUserRoleDto,
UpdateUserStatusDto,
UpdateUserDataAccessDto,
UpdateUserVisibilityDto,
} from './dto';
import { UsersService } from './users.service';
@@ -79,6 +81,7 @@ export class UsersController {
user.role,
id,
dto.logoutTime ?? null,
dto.blockLoginAfterLogout,
);
}
@@ -97,6 +100,21 @@ export class UsersController {
);
}
@Patch(':id/login-otp-only')
@Roles('owner', 'admin')
updateLoginOtpOnly(
@CurrentUser() user: { organizationId: string; userId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateUserLoginOtpOnlyDto,
) {
return this.usersService.updateLoginOtpOnly(
user.organizationId,
{ userId: user.userId, role: user.role },
id,
dto.loginOtpOnly,
);
}
@Patch(':id/status')
@Roles('owner', 'admin')
updateStatus(
@@ -142,6 +160,21 @@ export class UsersController {
);
}
@Patch(':id/data-access')
@Roles('owner', 'admin')
updateDataAccess(
@CurrentUser() user: { organizationId: string; role: UserRole },
@Param('id') id: string,
@Body() dto: UpdateUserDataAccessDto,
) {
return this.usersService.updateDataAccess(
user.organizationId,
user.role,
id,
dto,
);
}
@Patch(':id/role')
@Roles('owner', 'admin')
updateRole(
+94 -3
View File
@@ -14,7 +14,8 @@ import type {
UpdateUserVisibilityRequest,
UserRole,
} from '@mento/shared';
import { DATA_SCOPES, DATA_VISIBILITIES } from '@mento/shared';
import { DATA_SCOPES, DATA_VISIBILITIES, parseDataAccessPolicy } from '@mento/shared';
import { resolveAllSectionAccess } from '../common/data-access';
import { parseVisibilityScopes } from '../common/record-scope';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
@@ -22,6 +23,7 @@ import { normalizeMobile } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { MembershipsService } from '../memberships/memberships.service';
import { BillingService } from '../billing/billing.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import * as path from 'path';
@@ -44,10 +46,13 @@ type MembershipRow = {
commissionPercent: { toString(): string } | number | null;
dataVisibility?: string | null;
dataVisibilityScopes?: unknown;
dataAccessOverride?: unknown;
priorArchiveEnabled?: boolean;
logoutTime?: string | null;
blockLoginAfterLogout?: boolean;
loginOtpOnly?: boolean;
createdAt: Date;
orgRole: { id: string; name: string; color: string } | null;
orgRole: { id: string; name: string; color: string; dataAccessPolicy?: unknown } | null;
user: {
id: string;
email: string;
@@ -66,6 +71,7 @@ export class UsersService {
private readonly orgRoles: OrgRolesService,
private readonly memberships: MembershipsService,
private readonly files: LocalFileStorage,
private readonly billing: BillingService,
) {}
async list(organizationId: string): Promise<OrgUserDto[]> {
@@ -101,6 +107,7 @@ export class UsersService {
return this.addExistingUser(organizationId, existingUser.id, resolved);
}
await this.billing.assertSeatAvailable(organizationId, null);
const password = dto.password?.trim() ?? '';
if (password.length < 8) {
throw new BadRequestException(
@@ -248,6 +255,7 @@ export class UsersService {
actorRole: UserRole,
targetId: string,
logoutTime: string | null,
blockLoginAfterLogout?: boolean,
): Promise<OrgUserDto> {
if (actorRole !== 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند ساعت خروج را تعیین کند');
@@ -263,7 +271,15 @@ export class UsersService {
}
await this.prisma.membership.update({
where: { id: target.id },
data: { logoutTime: value },
data: {
logoutTime: value,
// بدون ساعت خروج، مسدودسازی معنی ندارد
...(!value
? { blockLoginAfterLogout: false }
: blockLoginAfterLogout !== undefined
? { blockLoginAfterLogout }
: {}),
},
});
return this.dtoOf(organizationId, targetId);
}
@@ -350,6 +366,31 @@ export class UsersService {
return this.dtoOf(organizationId, targetId);
}
async updateDataAccess(
organizationId: string,
actorRole: UserRole,
targetId: string,
dto: { dataAccessOverride?: Record<string, unknown> | null },
): Promise<OrgUserDto> {
if (actorRole !== 'owner' && actorRole !== 'admin') {
throw new ForbiddenException('اجازه تغییر محدوده داده را ندارید');
}
if (dto.dataAccessOverride === undefined) {
throw new BadRequestException('تغییری ارسال نشده است');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' || target.role === 'admin') {
throw new BadRequestException('مالک و مدیر محدودیت عمق داده ندارند');
}
const next =
dto.dataAccessOverride === null ? {} : parseDataAccessPolicy(dto.dataAccessOverride);
await this.prisma.membership.update({
where: { id: target.id },
data: { dataAccessOverride: next },
});
return this.dtoOf(organizationId, targetId);
}
async updateRole(
organizationId: string,
actor: { userId: string; role: UserRole },
@@ -417,6 +458,32 @@ export class UsersService {
return this.dtoOf(organizationId, targetId);
}
async updateLoginOtpOnly(
organizationId: string,
actor: { userId: string; role: UserRole },
targetId: string,
loginOtpOnly: boolean,
): Promise<OrgUserDto> {
if (actor.role !== 'owner' && actor.role !== 'admin') {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند شیوهٔ ورود را تغییر دهد');
}
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner') {
throw new BadRequestException('برای مالک نمی‌توان ورود فقط با کد را فعال کرد');
}
if (loginOtpOnly) {
const mobile = target.user.mobile?.trim();
if (!mobile) {
throw new BadRequestException('برای ورود با کد، کاربر باید موبایل داشته باشد');
}
}
await this.prisma.membership.update({
where: { id: target.id },
data: { loginOtpOnly },
});
return this.dtoOf(organizationId, targetId);
}
async updateStatus(
organizationId: string,
actor: { userId: string; role: UserRole },
@@ -442,6 +509,9 @@ export class UsersService {
throw new BadRequestException('حداقل یک مالک فعال لازم است');
}
}
if (isActive && !target.isActive) {
await this.billing.assertSeatAvailable(organizationId, targetId);
}
await this.prisma.membership.update({
where: { id: target.id },
data: { isActive },
@@ -469,6 +539,7 @@ export class UsersService {
userId: string,
resolved: { role: UserRole; orgRoleId: string },
): Promise<OrgUserDto> {
await this.billing.assertSeatAvailable(organizationId, userId);
const existing = await this.prisma.membership.findFirst({
where: { userId, organizationId },
});
@@ -531,8 +602,28 @@ export class UsersService {
row.role === 'owner' || row.role === 'admin'
? {}
: parseVisibilityScopes(row.dataVisibilityScopes),
dataAccessOverride:
row.role === 'owner' || row.role === 'admin'
? {}
: parseDataAccessPolicy(row.dataAccessOverride),
...(() => {
const effectiveDataAccess = resolveAllSectionAccess({
role: row.role,
rolePolicy: row.orgRole?.dataAccessPolicy,
override: row.dataAccessOverride,
legacyVisibility: row.dataVisibility,
legacyScopes: parseVisibilityScopes(row.dataVisibilityScopes),
});
return {
effectiveDataAccess,
effectiveCustomersAccess: effectiveDataAccess.customers,
};
})(),
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
blockLoginAfterLogout:
row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout),
loginOtpOnly: row.role === 'owner' ? false : Boolean(row.loginOtpOnly),
createdAt: row.createdAt.toISOString(),
};
}