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