Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 21:06:30 +03:30
co-authored by Cursor
commit 29a040c21d
195 changed files with 39430 additions and 0 deletions
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto, ListQueryDto, UpdateCompanyDto } from './dto';
import { ContactsExcelService } from './contacts-excel.service';
@Controller('companies')
@UseGuards(JwtAuthGuard)
export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
private readonly excelService: ContactsExcelService,
) {}
@Get()
list(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
return this.companiesService.list(
user,
query.q,
query.page ?? 1,
query.limit ?? 20,
);
}
@Get('export')
async export(
@CurrentUser() user: Actor,
@Query('q') q: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const buffer = await this.excelService.exportCompanies(user, q);
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="mento-companies.xlsx"',
});
return new StreamableFile(buffer);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.importCompanies(user, file);
}
@Get(':id')
get(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.companiesService.get(user, id);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreateCompanyDto) {
return this.companiesService.create(user, dto);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateCompanyDto,
) {
return this.companiesService.update(user, id, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.companiesService.remove(user, id);
return { ok: true };
}
}
+238
View File
@@ -0,0 +1,238 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CompanyDto,
CreateCompanyRequest,
PaginatedResponse,
UpdateCompanyRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
import {
asCustomFields,
toJsonCustomFields,
} from '../common/custom-fields';
import {
type Actor,
canReassignOwner,
elevated,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
const companyInclude = {
_count: { select: { people: true } },
owner: { select: { id: true, fullName: true } },
} satisfies Prisma.CompanyInclude;
type CompanyRow = {
id: string;
name: string;
nationalId: string | null;
phone: string | null;
email: string | null;
website: string | null;
address: string | null;
notes: string | null;
customFields: unknown;
createdAt: Date;
updatedAt: Date;
_count: { people: number };
owner: { id: string; fullName: string } | null;
};
@Injectable()
export class CompaniesService {
constructor(private readonly prisma: PrismaService) {}
async list(
actor: Actor,
q?: string,
page = 1,
limit = 20,
): Promise<PaginatedResponse<CompanyDto>> {
const where: Prisma.CompanyWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(q?.trim()
? {
OR: [
{ name: { contains: q.trim(), mode: 'insensitive' } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ phone: { contains: q.trim() } },
{ nationalId: { contains: q.trim() } },
],
}
: {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.company.findMany({
where,
include: companyInclude,
orderBy: [{ name: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.company.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(actor: Actor, id: string): Promise<CompanyDto> {
const row = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
include: companyInclude,
});
if (!row) throw new NotFoundException('شرکت پیدا نشد');
return this.toDto(row);
}
async create(actor: Actor, dto: CreateCompanyRequest): Promise<CompanyDto> {
const data: Prisma.CompanyUncheckedCreateInput = {
organizationId: actor.organizationId,
name: dto.name.trim(),
nationalId: emptyToNull(dto.nationalId),
phone: normalizePhone(dto.phone),
email: normalizeEmail(dto.email),
website: emptyToNull(dto.website),
address: emptyToNull(dto.address),
notes: emptyToNull(dto.notes),
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
};
await this.assertNoDuplicate(actor.organizationId, data);
const row = await this.prisma.company.create({
data,
include: companyInclude,
});
return this.toDto(row);
}
async update(
actor: Actor,
id: string,
dto: UpdateCompanyRequest,
): Promise<CompanyDto> {
await this.ensureExists(actor.organizationId, id, actor);
const data: Prisma.CompanyUncheckedUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if ('nationalId' in dto) data.nationalId = emptyToNull(dto.nationalId);
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('address' in dto) data.address = emptyToNull(dto.address);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
}
await this.assertNoDuplicate(actor.organizationId, data, id);
const row = await this.prisma.company.update({
where: { id },
data,
include: companyInclude,
});
return this.toDto(row);
}
async remove(actor: Actor, id: string): Promise<void> {
await this.ensureExists(actor.organizationId, id, actor);
await this.prisma.company.delete({ where: { id } });
}
async ensureInOrg(
organizationId: string,
companyId: string,
actor?: Pick<Actor, 'userId' | 'role'>,
): Promise<void> {
await this.ensureExists(organizationId, companyId, actor);
}
private async ensureExists(
organizationId: string,
id: string,
actor?: Pick<Actor, 'userId' | 'role'>,
) {
const found = await this.prisma.company.findFirst({
where: {
id,
organizationId,
...(actor && !elevated(actor.role)
? { ownerId: actor.userId }
: {}),
},
select: { id: true },
});
if (!found) throw new NotFoundException('شرکت پیدا نشد');
}
private async assertNoDuplicate(
organizationId: string,
data: { email?: unknown; phone?: unknown; nationalId?: unknown },
excludeId?: string,
) {
const email = typeof data.email === 'string' ? data.email : null;
const phone = typeof data.phone === 'string' ? data.phone : null;
const nationalId =
typeof data.nationalId === 'string' ? data.nationalId : null;
const or: Prisma.CompanyWhereInput[] = [];
if (email) or.push({ email });
if (phone) or.push({ phone });
if (nationalId) or.push({ nationalId });
if (!or.length) return;
const clash = await this.prisma.company.findFirst({
where: {
organizationId,
OR: or,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!clash) return;
if (email && clash.email === email) {
throw new ConflictException('شرکتی با این ایمیل از قبل وجود دارد');
}
if (phone && clash.phone === phone) {
throw new ConflictException('شرکتی با این تلفن از قبل وجود دارد');
}
if (nationalId && clash.nationalId === nationalId) {
throw new ConflictException('شرکتی با این شناسه ملی از قبل وجود دارد');
}
throw new ConflictException('شرکت تکراری است');
}
private toDto(row: CompanyRow): CompanyDto {
return {
id: row.id,
name: row.name,
nationalId: row.nationalId,
phone: row.phone,
email: row.email,
website: row.website,
address: row.address,
notes: row.notes,
customFields: asCustomFields(row.customFields),
peopleCount: row._count.people,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -0,0 +1,312 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import ExcelJS from 'exceljs';
import type { ImportContactsResult } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { ownerWhere } from '../common/record-scope';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
function cellStr(value: ExcelJS.CellValue | undefined): string {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim();
}
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
return value.text.trim();
}
if (typeof value === 'object' && 'result' in value && value.result != null) {
return String(value.result).trim();
}
return String(value).trim();
}
function normalizeHeader(raw: string) {
return raw.trim().toLowerCase().replace(/\s+/g, '');
}
const PERSON_ALIASES: Record<string, string> = {
firstname: 'firstName',
نام: 'firstName',
lastname: 'lastName',
نامخانوادگی: 'lastName',
'نام خانوادگی': 'lastName',
email: 'email',
ایمیل: 'email',
phone: 'phone',
تلفن: 'phone',
mobile: 'mobile',
موبایل: 'mobile',
position: 'position',
سمت: 'position',
company: 'companyName',
companyname: 'companyName',
شرکت: 'companyName',
notes: 'notes',
یادداشت: 'notes',
};
const COMPANY_ALIASES: Record<string, string> = {
name: 'name',
نام: 'name',
نامشرکت: 'name',
'نام شرکت': 'name',
nationalid: 'nationalId',
شناسهملی: 'nationalId',
'شناسه ملی': 'nationalId',
phone: 'phone',
تلفن: 'phone',
email: 'email',
ایمیل: 'email',
website: 'website',
وبسایت: 'website',
address: 'address',
آدرس: 'address',
notes: 'notes',
یادداشت: 'notes',
};
@Injectable()
export class ContactsExcelService {
constructor(
private readonly prisma: PrismaService,
private readonly people: PeopleService,
private readonly companies: CompaniesService,
) {}
async exportPeople(actor: Actor, q?: string): Promise<Buffer> {
const { items } = await this.people.list(actor, q, 1, 5000);
const wb = new ExcelJS.Workbook();
const sheet = wb.addWorksheet('اشخاص');
sheet.columns = [
{ header: 'نام', key: 'firstName', width: 16 },
{ header: 'نام خانوادگی', key: 'lastName', width: 18 },
{ header: 'موبایل', key: 'mobile', width: 16 },
{ header: 'تلفن', key: 'phone', width: 16 },
{ header: 'ایمیل', key: 'email', width: 28 },
{ header: 'سمت', key: 'position', width: 18 },
{ header: 'شرکت', key: 'companyName', width: 24 },
{ header: 'یادداشت', key: 'notes', width: 32 },
];
for (const p of items) {
sheet.addRow({
firstName: p.firstName,
lastName: p.lastName,
mobile: p.mobile ?? '',
phone: p.phone ?? '',
email: p.email ?? '',
position: p.position ?? '',
companyName: p.company?.name ?? '',
notes: p.notes ?? '',
});
}
const buf = await wb.xlsx.writeBuffer();
return Buffer.from(buf);
}
async exportCompanies(actor: Actor, q?: string): Promise<Buffer> {
const { items } = await this.companies.list(actor, q, 1, 5000);
const wb = new ExcelJS.Workbook();
const sheet = wb.addWorksheet('شرکت‌ها');
sheet.columns = [
{ header: 'نام شرکت', key: 'name', width: 28 },
{ header: 'شناسه ملی', key: 'nationalId', width: 16 },
{ header: 'تلفن', key: 'phone', width: 16 },
{ header: 'ایمیل', key: 'email', width: 28 },
{ header: 'وب‌سایت', key: 'website', width: 24 },
{ header: 'آدرس', key: 'address', width: 32 },
{ header: 'یادداشت', key: 'notes', width: 32 },
];
for (const c of items) {
sheet.addRow({
name: c.name,
nationalId: c.nationalId ?? '',
phone: c.phone ?? '',
email: c.email ?? '',
website: c.website ?? '',
address: c.address ?? '',
notes: c.notes ?? '',
});
}
const buf = await wb.xlsx.writeBuffer();
return Buffer.from(buf);
}
async importPeople(
actor: Actor,
file: Express.Multer.File,
): Promise<ImportContactsResult> {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل اکسل انتخاب نشده است');
}
const rows = await this.readRows(file, PERSON_ALIASES);
const result: ImportContactsResult = {
created: 0,
skipped: 0,
errors: [],
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const rowNum = i + 2;
const firstName = row.firstName?.trim() ?? '';
const lastName = row.lastName?.trim() ?? '';
if (!firstName || !lastName) {
result.errors.push({
row: rowNum,
message: 'نام و نام خانوادگی الزامی است',
});
continue;
}
let companyId: string | null = null;
const companyName = row.companyName?.trim();
if (companyName) {
companyId = await this.findOrCreateCompanyId(actor, companyName);
}
try {
await this.people.create(actor, {
firstName,
lastName,
email: row.email || undefined,
phone: row.phone || undefined,
mobile: row.mobile || undefined,
position: row.position || undefined,
notes: row.notes || undefined,
companyId,
});
result.created += 1;
} catch (e) {
if (e instanceof ConflictException) {
result.skipped += 1;
} else {
result.errors.push({
row: rowNum,
message: e instanceof Error ? e.message : 'خطای ناشناخته',
});
}
}
}
return result;
}
async importCompanies(
actor: Actor,
file: Express.Multer.File,
): Promise<ImportContactsResult> {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل اکسل انتخاب نشده است');
}
const rows = await this.readRows(file, COMPANY_ALIASES);
const result: ImportContactsResult = {
created: 0,
skipped: 0,
errors: [],
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i]!;
const rowNum = i + 2;
const name = row.name?.trim() ?? '';
if (!name) {
result.errors.push({ row: rowNum, message: 'نام شرکت الزامی است' });
continue;
}
try {
await this.companies.create(actor, {
name,
nationalId: row.nationalId || undefined,
phone: row.phone || undefined,
email: row.email || undefined,
website: row.website || undefined,
address: row.address || undefined,
notes: row.notes || undefined,
});
result.created += 1;
} catch (e) {
if (e instanceof ConflictException) {
result.skipped += 1;
} else {
result.errors.push({
row: rowNum,
message: e instanceof Error ? e.message : 'خطای ناشناخته',
});
}
}
}
return result;
}
private async findOrCreateCompanyId(
actor: Actor,
name: string,
): Promise<string> {
const existing = await this.prisma.company.findFirst({
where: {
organizationId: actor.organizationId,
name: { equals: name, mode: 'insensitive' },
...ownerWhere(actor),
},
select: { id: true },
});
if (existing) return existing.id;
const created = await this.companies.create(actor, { name });
return created.id;
}
private async readRows(
file: Express.Multer.File,
aliases: Record<string, string>,
): Promise<Record<string, string>[]> {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل اکسل خالی است');
}
const wb = new ExcelJS.Workbook();
// exceljs accepts Node Buffer
await wb.xlsx.load(Uint8Array.from(file.buffer) as unknown as ExcelJS.Buffer);
const sheet = wb.worksheets[0];
if (!sheet) throw new BadRequestException('شیت اکسل پیدا نشد');
const headerRow = sheet.getRow(1);
const map = new Map<number, string>();
headerRow.eachCell((cell, col) => {
const raw = cellStr(cell.value);
if (!raw) return;
const key =
aliases[normalizeHeader(raw)] ??
aliases[raw.trim()] ??
aliases[normalizeHeader(raw.replace(/‌/g, ''))];
if (key) map.set(col, key);
});
if (!map.size) {
throw new BadRequestException(
'ستون‌های فایل شناسایی نشد. از قالب خروجی همین سیستم استفاده کنید.',
);
}
const rows: Record<string, string>[] = [];
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const item: Record<string, string> = {};
let any = false;
for (const [col, key] of map) {
const value = cellStr(row.getCell(col).value);
if (value) any = true;
item[key] = value;
}
if (any) rows.push(item);
});
if (!rows.length) {
throw new BadRequestException('هیچ ردیف داده‌ای در فایل نیست');
}
return rows;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
@Module({
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService],
exports: [PeopleService, CompaniesService],
})
export class ContactsModule {}
+229
View File
@@ -0,0 +1,229 @@
import { Type } from 'class-transformer';
import {
IsEmail,
IsInt,
IsObject,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class ListQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;
}
export class CreateCompanyDto {
@IsString()
@MinLength(2)
@MaxLength(160)
name!: string;
@IsOptional()
@IsString()
@MaxLength(32)
nationalId?: string;
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(200)
website?: string;
@IsOptional()
@IsString()
@MaxLength(400)
address?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class UpdateCompanyDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(160)
name?: string;
@IsOptional()
@IsString()
@MaxLength(32)
nationalId?: string;
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(200)
website?: string;
@IsOptional()
@IsString()
@MaxLength(400)
address?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class CreatePersonDto {
@IsString()
@MinLength(1)
@MaxLength(80)
firstName!: string;
@IsString()
@MinLength(1)
@MaxLength(80)
lastName!: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(40)
mobile?: string;
@IsOptional()
@IsString()
@MaxLength(120)
position?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
export class UpdatePersonDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(80)
lastName?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(40)
mobile?: string;
@IsOptional()
@IsString()
@MaxLength(120)
position?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
companyId?: string | null;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
ownerId?: string | null;
}
+19
View File
@@ -0,0 +1,19 @@
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;␍
}␍
␍
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { CreatePersonDto, ListQueryDto, UpdatePersonDto } from './dto';
import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
@Controller('people')
@UseGuards(JwtAuthGuard)
export class PeopleController {
constructor(
private readonly peopleService: PeopleService,
private readonly excelService: ContactsExcelService,
) {}
@Get()
list(@CurrentUser() user: Actor, @Query() query: ListQueryDto) {
return this.peopleService.list(
user,
query.q,
query.page ?? 1,
query.limit ?? 20,
);
}
@Get('export')
async export(
@CurrentUser() user: Actor,
@Query('q') q: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const buffer = await this.excelService.exportPeople(user, q);
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="mento-people.xlsx"',
});
return new StreamableFile(buffer);
}
@Post('import')
@UseInterceptors(FileInterceptor('file'))
import(
@CurrentUser() user: Actor,
@UploadedFile() file: Express.Multer.File,
) {
return this.excelService.importPeople(user, file);
}
@Get(':id')
get(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.peopleService.get(user, id);
}
@Post()
create(@CurrentUser() user: Actor, @Body() dto: CreatePersonDto) {
return this.peopleService.create(user, dto);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdatePersonDto,
) {
return this.peopleService.update(user, id, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.peopleService.remove(user, id);
return { ok: true };
}
}
+238
View File
@@ -0,0 +1,238 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreatePersonRequest,
PaginatedResponse,
PersonDto,
UpdatePersonRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CompaniesService } from './companies.service';
import { emptyToNull, normalizeEmail, normalizePhone } from './normalize';
import {
asCustomFields,
toJsonCustomFields,
} from '../common/custom-fields';
import {
type Actor,
canReassignOwner,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
const personInclude = {
company: { select: { id: true, name: true } },
owner: { select: { id: true, fullName: true } },
} satisfies Prisma.PersonInclude;
type PersonRow = {
id: string;
firstName: string;
lastName: string;
email: string | null;
phone: string | null;
mobile: string | null;
position: string | null;
notes: string | null;
customFields: unknown;
createdAt: Date;
updatedAt: Date;
company: { id: string; name: string } | null;
owner: { id: string; fullName: string } | null;
};
@Injectable()
export class PeopleService {
constructor(
private readonly prisma: PrismaService,
private readonly companies: CompaniesService,
) {}
async list(
actor: Actor,
q?: string,
page = 1,
limit = 20,
): Promise<PaginatedResponse<PersonDto>> {
const where: Prisma.PersonWhereInput = {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(q?.trim()
? {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
{ mobile: { contains: q.trim() } },
{ phone: { contains: q.trim() } },
{ company: { name: { contains: q.trim(), mode: 'insensitive' } } },
],
}
: {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.person.findMany({
where,
include: personInclude,
orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.person.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
include: personInclude,
});
if (!row) throw new NotFoundException('شخص پیدا نشد');
return this.toDto(row);
}
async create(actor: Actor, dto: CreatePersonRequest): Promise<PersonDto> {
const companyId = emptyToNull(dto.companyId ?? null);
if (companyId) {
await this.companies.ensureInOrg(actor.organizationId, companyId, actor);
}
const data: Prisma.PersonUncheckedCreateInput = {
organizationId: actor.organizationId,
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
email: normalizeEmail(dto.email),
phone: normalizePhone(dto.phone),
mobile: normalizePhone(dto.mobile),
position: emptyToNull(dto.position),
notes: emptyToNull(dto.notes),
companyId,
ownerId: resolveOwnerId(actor, dto.ownerId),
customFields: toJsonCustomFields(dto.customFields),
};
await this.assertNoDuplicate(actor.organizationId, data);
const row = await this.prisma.person.create({
data,
include: personInclude,
});
return this.toDto(row);
}
async update(
actor: Actor,
id: string,
dto: UpdatePersonRequest,
): Promise<PersonDto> {
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();
if ('email' in dto) data.email = normalizeEmail(dto.email);
if ('phone' in dto) data.phone = normalizePhone(dto.phone);
if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile);
if ('position' in dto) data.position = emptyToNull(dto.position);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if ('companyId' in dto) {
const nextCompanyId = emptyToNull(dto.companyId ?? null);
if (nextCompanyId) {
await this.companies.ensureInOrg(
actor.organizationId,
nextCompanyId,
actor,
);
}
data.companyId = nextCompanyId;
}
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
}
await this.assertNoDuplicate(actor.organizationId, data, id);
const row = await this.prisma.person.update({
where: { id },
data,
include: personInclude,
});
return this.toDto(row);
}
async remove(actor: Actor, id: string): Promise<void> {
await this.ensureExists(actor, id);
await this.prisma.person.delete({ where: { id } });
}
private async ensureExists(actor: Actor, id: string) {
const found = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('شخص پیدا نشد');
}
private async assertNoDuplicate(
organizationId: string,
data: { email?: unknown; mobile?: unknown },
excludeId?: string,
) {
const email = typeof data.email === 'string' ? data.email : null;
const mobile = typeof data.mobile === 'string' ? data.mobile : null;
const or: Prisma.PersonWhereInput[] = [];
if (email) or.push({ email });
if (mobile) or.push({ mobile });
if (!or.length) return;
const clash = await this.prisma.person.findFirst({
where: {
organizationId,
OR: or,
...(excludeId ? { NOT: { id: excludeId } } : {}),
},
});
if (!clash) return;
if (email && clash.email === email) {
throw new ConflictException('شخصی با این ایمیل از قبل وجود دارد');
}
if (mobile && clash.mobile === mobile) {
throw new ConflictException('شخصی با این موبایل از قبل وجود دارد');
}
throw new ConflictException('شخص تکراری است');
}
private toDto(row: PersonRow): PersonDto {
return {
id: row.id,
firstName: row.firstName,
lastName: row.lastName,
fullName: `${row.firstName} ${row.lastName}`.trim(),
email: row.email,
phone: row.phone,
mobile: row.mobile,
position: row.position,
notes: row.notes,
customFields: asCustomFields(row.customFields),
company: row.company,
owner: row.owner,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}