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