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
+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(),
};
}
}