526 lines
15 KiB
TypeScript
526 lines
15 KiB
TypeScript
import {
|
|||
|
|
BadRequestException,
|
||
|
|
Injectable,
|
||
|
|
NotFoundException,
|
||
|
|
} from '@nestjs/common';
|
||
|
|
import type {
|
||
|
|
CreateLeadFormRequest,
|
||
|
|
LeadFormDto,
|
||
|
|
LeadFormFieldInput,
|
||
|
|
PublicLeadFormDto,
|
||
|
|
SubmitLeadFormResponse,
|
||
|
|
UpdateLeadFormRequest,
|
||
|
|
} from '@mento/shared';
|
||
|
|
import { Prisma } from '@prisma/client';
|
||
|
|
import { PrismaService } from '../prisma/prisma.service';
|
||
|
|
import {
|
||
|
|
emptyToNull,
|
||
|
|
normalizeEmail,
|
||
|
|
normalizePhone,
|
||
|
|
} from '../contacts/normalize';
|
||
|
|
|
||
|
|
const DEFAULT_FIELDS: LeadFormFieldInput[] = [
|
||
|
|
{
|
||
|
|
key: 'firstName',
|
||
|
|
label: 'نام',
|
||
|
|
fieldType: 'text',
|
||
|
|
required: true,
|
||
|
|
mapTo: 'person.firstName',
|
||
|
|
position: 0,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'lastName',
|
||
|
|
label: 'نام خانوادگی',
|
||
|
|
fieldType: 'text',
|
||
|
|
required: true,
|
||
|
|
mapTo: 'person.lastName',
|
||
|
|
position: 1,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'mobile',
|
||
|
|
label: 'موبایل',
|
||
|
|
fieldType: 'phone',
|
||
|
|
required: true,
|
||
|
|
mapTo: 'person.mobile',
|
||
|
|
position: 2,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'email',
|
||
|
|
label: 'ایمیل',
|
||
|
|
fieldType: 'email',
|
||
|
|
required: false,
|
||
|
|
mapTo: 'person.email',
|
||
|
|
position: 3,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'message',
|
||
|
|
label: 'پیام',
|
||
|
|
fieldType: 'textarea',
|
||
|
|
required: false,
|
||
|
|
mapTo: 'deal.notes',
|
||
|
|
position: 4,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
type FormRow = Prisma.LeadFormGetPayload<{ include: { fields: true } }>;
|
||
|
|
|
||
|
|
@Injectable()
|
||
|
|
export class LeadFormsService {
|
||
|
|
constructor(private readonly prisma: PrismaService) {}
|
||
|
|
|
||
|
|
async list(organizationId: string): Promise<LeadFormDto[]> {
|
||
|
|
const rows = await this.prisma.leadForm.findMany({
|
||
|
|
where: { organizationId },
|
||
|
|
include: { fields: { orderBy: { position: 'asc' } } },
|
||
|
|
orderBy: { updatedAt: 'desc' },
|
||
|
|
});
|
||
|
|
return rows.map((row) => this.toDto(row));
|
||
|
|
}
|
||
|
|
|
||
|
|
async get(organizationId: string, id: string): Promise<LeadFormDto> {
|
||
|
|
const row = await this.findInOrg(organizationId, id);
|
||
|
|
return this.toDto(row);
|
||
|
|
}
|
||
|
|
|
||
|
|
async create(
|
||
|
|
organizationId: string,
|
||
|
|
dto: CreateLeadFormRequest,
|
||
|
|
): Promise<LeadFormDto> {
|
||
|
|
await this.assertPipeline(organizationId, dto.pipelineId);
|
||
|
|
await this.assertAssignees(organizationId, dto.assigneeIds);
|
||
|
|
const fields = normalizeFields(dto.fields?.length ? dto.fields : DEFAULT_FIELDS);
|
||
|
|
|
||
|
|
const row = await this.prisma.leadForm.create({
|
||
|
|
data: {
|
||
|
|
organizationId,
|
||
|
|
name: dto.name.trim(),
|
||
|
|
createPerson: dto.createPerson ?? true,
|
||
|
|
createDeal: dto.createDeal ?? true,
|
||
|
|
pipelineId: emptyToNull(dto.pipelineId ?? null),
|
||
|
|
source: emptyToNull(dto.source ?? null) ?? 'فرم وب',
|
||
|
|
thankYouMessage:
|
||
|
|
emptyToNull(dto.thankYouMessage ?? null) ??
|
||
|
|
'پیام شما ثبت شد. بهزودی با شما تماس میگیریم.',
|
||
|
|
redirectUrl: emptyToNull(dto.redirectUrl ?? null),
|
||
|
|
assigneeIds: dto.assigneeIds ?? [],
|
||
|
|
fields: {
|
||
|
|
create: fields.map((f, index) => ({
|
||
|
|
key: f.key!,
|
||
|
|
label: f.label,
|
||
|
|
fieldType: f.fieldType,
|
||
|
|
required: f.required ?? false,
|
||
|
|
mapTo: f.mapTo,
|
||
|
|
position: f.position ?? index,
|
||
|
|
})),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
include: { fields: { orderBy: { position: 'asc' } } },
|
||
|
|
});
|
||
|
|
return this.toDto(row);
|
||
|
|
}
|
||
|
|
|
||
|
|
async update(
|
||
|
|
organizationId: string,
|
||
|
|
id: string,
|
||
|
|
dto: UpdateLeadFormRequest,
|
||
|
|
): Promise<LeadFormDto> {
|
||
|
|
await this.findInOrg(organizationId, id);
|
||
|
|
if ('pipelineId' in dto) {
|
||
|
|
await this.assertPipeline(organizationId, dto.pipelineId);
|
||
|
|
}
|
||
|
|
if (dto.assigneeIds) {
|
||
|
|
await this.assertAssignees(organizationId, dto.assigneeIds);
|
||
|
|
}
|
||
|
|
|
||
|
|
const data: Prisma.LeadFormUpdateInput = {};
|
||
|
|
if (dto.name !== undefined) data.name = dto.name.trim();
|
||
|
|
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||
|
|
if (dto.createPerson !== undefined) data.createPerson = dto.createPerson;
|
||
|
|
if (dto.createDeal !== undefined) data.createDeal = dto.createDeal;
|
||
|
|
if ('pipelineId' in dto) data.pipelineId = emptyToNull(dto.pipelineId ?? null);
|
||
|
|
if ('source' in dto) data.source = emptyToNull(dto.source ?? null);
|
||
|
|
if ('thankYouMessage' in dto) {
|
||
|
|
data.thankYouMessage = emptyToNull(dto.thankYouMessage ?? null);
|
||
|
|
}
|
||
|
|
if ('redirectUrl' in dto) {
|
||
|
|
data.redirectUrl = emptyToNull(dto.redirectUrl ?? null);
|
||
|
|
}
|
||
|
|
if (dto.assigneeIds !== undefined) data.assigneeIds = dto.assigneeIds;
|
||
|
|
|
||
|
|
if (dto.fields) {
|
||
|
|
const fields = normalizeFields(dto.fields);
|
||
|
|
await this.prisma.$transaction(async (tx) => {
|
||
|
|
await tx.leadFormField.deleteMany({ where: { formId: id } });
|
||
|
|
await tx.leadForm.update({
|
||
|
|
where: { id },
|
||
|
|
data: {
|
||
|
|
...data,
|
||
|
|
fields: {
|
||
|
|
create: fields.map((f, index) => ({
|
||
|
|
key: f.key!,
|
||
|
|
label: f.label,
|
||
|
|
fieldType: f.fieldType,
|
||
|
|
required: f.required ?? false,
|
||
|
|
mapTo: f.mapTo,
|
||
|
|
position: f.position ?? index,
|
||
|
|
})),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
} else if (Object.keys(data).length) {
|
||
|
|
await this.prisma.leadForm.update({ where: { id }, data });
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.get(organizationId, id);
|
||
|
|
}
|
||
|
|
|
||
|
|
async remove(organizationId: string, id: string): Promise<void> {
|
||
|
|
await this.findInOrg(organizationId, id);
|
||
|
|
await this.prisma.leadForm.delete({ where: { id } });
|
||
|
|
}
|
||
|
|
|
||
|
|
async getPublic(token: string): Promise<PublicLeadFormDto> {
|
||
|
|
const row = await this.prisma.leadForm.findFirst({
|
||
|
|
where: { publicToken: token, isActive: true },
|
||
|
|
include: { fields: { orderBy: { position: 'asc' } } },
|
||
|
|
});
|
||
|
|
if (!row) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
|
||
|
|
return {
|
||
|
|
name: row.name,
|
||
|
|
thankYouMessage: row.thankYouMessage,
|
||
|
|
fields: row.fields.map((f) => ({
|
||
|
|
key: f.key,
|
||
|
|
label: f.label,
|
||
|
|
fieldType: f.fieldType,
|
||
|
|
required: f.required,
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async submit(
|
||
|
|
token: string,
|
||
|
|
values: Record<string, string>,
|
||
|
|
): Promise<SubmitLeadFormResponse> {
|
||
|
|
const form = await this.prisma.leadForm.findFirst({
|
||
|
|
where: { publicToken: token, isActive: true },
|
||
|
|
include: { fields: { orderBy: { position: 'asc' } } },
|
||
|
|
});
|
||
|
|
if (!form) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
|
||
|
|
|
||
|
|
const mapped = mapValues(form.fields, values);
|
||
|
|
|
||
|
|
if (form.createPerson) {
|
||
|
|
if (!mapped.firstName?.trim() && !mapped.lastName?.trim()) {
|
||
|
|
throw new BadRequestException('نام یا نام خانوادگی لازم است');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const ownerId = await this.nextAssignee(form);
|
||
|
|
const actorId =
|
||
|
|
ownerId ??
|
||
|
|
(
|
||
|
|
await this.prisma.user.findFirst({
|
||
|
|
where: { organizationId: form.organizationId, role: 'owner' },
|
||
|
|
select: { id: true },
|
||
|
|
})
|
||
|
|
)?.id;
|
||
|
|
|
||
|
|
if (!actorId) {
|
||
|
|
throw new BadRequestException('سازمان کاربر فعالی برای ثبت سرنخ ندارد');
|
||
|
|
}
|
||
|
|
|
||
|
|
let personId: string | null = null;
|
||
|
|
if (form.createPerson) {
|
||
|
|
personId = await this.upsertPerson(
|
||
|
|
form.organizationId,
|
||
|
|
mapped,
|
||
|
|
ownerId ?? actorId,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (form.createDeal) {
|
||
|
|
await this.createDeal(form, mapped, personId, actorId, ownerId);
|
||
|
|
} else if (personId && mapped.notes) {
|
||
|
|
await this.prisma.person.update({
|
||
|
|
where: { id: personId },
|
||
|
|
data: {
|
||
|
|
notes: mapped.notes,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
thankYouMessage: form.thankYouMessage,
|
||
|
|
redirectUrl: form.redirectUrl,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private async upsertPerson(
|
||
|
|
organizationId: string,
|
||
|
|
mapped: MappedValues,
|
||
|
|
ownerId: string,
|
||
|
|
): Promise<string> {
|
||
|
|
const email = normalizeEmail(mapped.email);
|
||
|
|
const mobile = normalizePhone(mapped.mobile);
|
||
|
|
const phone = normalizePhone(mapped.phone);
|
||
|
|
|
||
|
|
const or: Prisma.PersonWhereInput[] = [];
|
||
|
|
if (email) or.push({ email });
|
||
|
|
if (mobile) or.push({ mobile });
|
||
|
|
|
||
|
|
const existing = or.length
|
||
|
|
? await this.prisma.person.findFirst({
|
||
|
|
where: { organizationId, OR: or },
|
||
|
|
})
|
||
|
|
: null;
|
||
|
|
|
||
|
|
if (existing) {
|
||
|
|
await this.prisma.person.update({
|
||
|
|
where: { id: existing.id },
|
||
|
|
data: {
|
||
|
|
firstName: mapped.firstName?.trim() || existing.firstName,
|
||
|
|
lastName: mapped.lastName?.trim() || existing.lastName,
|
||
|
|
email: email ?? existing.email,
|
||
|
|
mobile: mobile ?? existing.mobile,
|
||
|
|
phone: phone ?? existing.phone,
|
||
|
|
position: emptyToNull(mapped.position) ?? existing.position,
|
||
|
|
notes: emptyToNull(mapped.notes) ?? existing.notes,
|
||
|
|
ownerId: existing.ownerId ?? ownerId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
return existing.id;
|
||
|
|
}
|
||
|
|
|
||
|
|
const created = await this.prisma.person.create({
|
||
|
|
data: {
|
||
|
|
organizationId,
|
||
|
|
firstName: (mapped.firstName?.trim() || 'سرنخ').slice(0, 80),
|
||
|
|
lastName: (mapped.lastName?.trim() || 'جدید').slice(0, 80),
|
||
|
|
email,
|
||
|
|
mobile,
|
||
|
|
phone,
|
||
|
|
position: emptyToNull(mapped.position),
|
||
|
|
notes: emptyToNull(mapped.notes),
|
||
|
|
ownerId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
return created.id;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async createDeal(
|
||
|
|
form: FormRow,
|
||
|
|
mapped: MappedValues,
|
||
|
|
personId: string | null,
|
||
|
|
actorId: string,
|
||
|
|
ownerId: string | null,
|
||
|
|
) {
|
||
|
|
const pipeline =
|
||
|
|
(form.pipelineId
|
||
|
|
? await this.prisma.pipeline.findFirst({
|
||
|
|
where: { id: form.pipelineId, organizationId: form.organizationId },
|
||
|
|
include: { stages: { orderBy: { position: 'asc' } } },
|
||
|
|
})
|
||
|
|
: null) ??
|
||
|
|
(await this.prisma.pipeline.findFirst({
|
||
|
|
where: { organizationId: form.organizationId, isDefault: true },
|
||
|
|
include: { stages: { orderBy: { position: 'asc' } } },
|
||
|
|
}));
|
||
|
|
|
||
|
|
if (!pipeline?.stages.length) {
|
||
|
|
throw new BadRequestException('کاریز فعالی برای ثبت معامله وجود ندارد');
|
||
|
|
}
|
||
|
|
|
||
|
|
const stage =
|
||
|
|
pipeline.stages.find((s) => !s.isWon && !s.isLost) ?? pipeline.stages[0];
|
||
|
|
|
||
|
|
const title =
|
||
|
|
mapped.dealTitle?.trim() ||
|
||
|
|
[mapped.firstName, mapped.lastName].filter(Boolean).join(' ').trim() ||
|
||
|
|
`سرنخ از ${form.name}`;
|
||
|
|
|
||
|
|
const maxPos = await this.prisma.deal.aggregate({
|
||
|
|
where: { organizationId: form.organizationId, stageId: stage.id },
|
||
|
|
_max: { position: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
await this.prisma.deal.create({
|
||
|
|
data: {
|
||
|
|
organizationId: form.organizationId,
|
||
|
|
pipelineId: pipeline.id,
|
||
|
|
stageId: stage.id,
|
||
|
|
title: title.slice(0, 200),
|
||
|
|
source: form.source ?? 'فرم وب',
|
||
|
|
notes: emptyToNull(mapped.dealNotes ?? mapped.notes),
|
||
|
|
personId,
|
||
|
|
ownerId: ownerId ?? actorId,
|
||
|
|
position: (maxPos._max.position ?? -1) + 1,
|
||
|
|
status: 'open',
|
||
|
|
currency: 'IRR',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
private async nextAssignee(form: FormRow): Promise<string | null> {
|
||
|
|
const ids = form.assigneeIds.filter(Boolean);
|
||
|
|
if (!ids.length) return null;
|
||
|
|
|
||
|
|
const index = form.roundRobinIndex % ids.length;
|
||
|
|
const chosen = ids[index];
|
||
|
|
await this.prisma.leadForm.update({
|
||
|
|
where: { id: form.id },
|
||
|
|
data: { roundRobinIndex: index + 1 },
|
||
|
|
});
|
||
|
|
return chosen;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async findInOrg(organizationId: string, id: string): Promise<FormRow> {
|
||
|
|
const row = await this.prisma.leadForm.findFirst({
|
||
|
|
where: { id, organizationId },
|
||
|
|
include: { fields: { orderBy: { position: 'asc' } } },
|
||
|
|
});
|
||
|
|
if (!row) throw new NotFoundException('فرم پیدا نشد');
|
||
|
|
return row;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async assertPipeline(
|
||
|
|
organizationId: string,
|
||
|
|
pipelineId?: string | null,
|
||
|
|
) {
|
||
|
|
const id = emptyToNull(pipelineId ?? null);
|
||
|
|
if (!id) return;
|
||
|
|
const found = await this.prisma.pipeline.findFirst({
|
||
|
|
where: { id, organizationId },
|
||
|
|
select: { id: true },
|
||
|
|
});
|
||
|
|
if (!found) throw new BadRequestException('کاریز انتخابشده معتبر نیست');
|
||
|
|
}
|
||
|
|
|
||
|
|
private async assertAssignees(
|
||
|
|
organizationId: string,
|
||
|
|
assigneeIds?: string[],
|
||
|
|
) {
|
||
|
|
if (!assigneeIds?.length) return;
|
||
|
|
const count = await this.prisma.user.count({
|
||
|
|
where: { organizationId, id: { in: assigneeIds } },
|
||
|
|
});
|
||
|
|
if (count !== assigneeIds.length) {
|
||
|
|
throw new BadRequestException('یکی از مسئولان انتخابشده معتبر نیست');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private toDto(row: FormRow): LeadFormDto {
|
||
|
|
return {
|
||
|
|
id: row.id,
|
||
|
|
name: row.name,
|
||
|
|
publicToken: row.publicToken,
|
||
|
|
isActive: row.isActive,
|
||
|
|
createPerson: row.createPerson,
|
||
|
|
createDeal: row.createDeal,
|
||
|
|
pipelineId: row.pipelineId,
|
||
|
|
source: row.source,
|
||
|
|
thankYouMessage: row.thankYouMessage,
|
||
|
|
redirectUrl: row.redirectUrl,
|
||
|
|
assigneeIds: row.assigneeIds,
|
||
|
|
fields: row.fields.map((f) => ({
|
||
|
|
id: f.id,
|
||
|
|
key: f.key,
|
||
|
|
label: f.label,
|
||
|
|
fieldType: f.fieldType,
|
||
|
|
required: f.required,
|
||
|
|
mapTo: f.mapTo,
|
||
|
|
position: f.position,
|
||
|
|
})),
|
||
|
|
publicPath: `/f/${row.publicToken}`,
|
||
|
|
createdAt: row.createdAt.toISOString(),
|
||
|
|
updatedAt: row.updatedAt.toISOString(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type MappedValues = {
|
||
|
|
firstName?: string;
|
||
|
|
lastName?: string;
|
||
|
|
email?: string;
|
||
|
|
mobile?: string;
|
||
|
|
phone?: string;
|
||
|
|
position?: string;
|
||
|
|
dealTitle?: string;
|
||
|
|
dealNotes?: string;
|
||
|
|
notes?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
function mapValues(
|
||
|
|
fields: Array<{ key: string; mapTo: string; required: boolean; label: string }>,
|
||
|
|
values: Record<string, string>,
|
||
|
|
): MappedValues {
|
||
|
|
const mapped: MappedValues = {};
|
||
|
|
for (const field of fields) {
|
||
|
|
const raw = (values?.[field.key] ?? '').toString().trim();
|
||
|
|
if (field.required && !raw) {
|
||
|
|
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
|
||
|
|
}
|
||
|
|
if (!raw) continue;
|
||
|
|
switch (field.mapTo) {
|
||
|
|
case 'person.firstName':
|
||
|
|
mapped.firstName = raw;
|
||
|
|
break;
|
||
|
|
case 'person.lastName':
|
||
|
|
mapped.lastName = raw;
|
||
|
|
break;
|
||
|
|
case 'person.email':
|
||
|
|
mapped.email = raw;
|
||
|
|
break;
|
||
|
|
case 'person.mobile':
|
||
|
|
mapped.mobile = raw;
|
||
|
|
break;
|
||
|
|
case 'person.phone':
|
||
|
|
mapped.phone = raw;
|
||
|
|
break;
|
||
|
|
case 'person.position':
|
||
|
|
mapped.position = raw;
|
||
|
|
break;
|
||
|
|
case 'deal.title':
|
||
|
|
mapped.dealTitle = raw;
|
||
|
|
break;
|
||
|
|
case 'deal.notes':
|
||
|
|
mapped.dealNotes = raw;
|
||
|
|
break;
|
||
|
|
case 'notes':
|
||
|
|
mapped.notes = raw;
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return mapped;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeFields(fields: LeadFormFieldInput[]): LeadFormFieldInput[] {
|
||
|
|
return fields.map((f, index) => {
|
||
|
|
const key =
|
||
|
|
f.key?.trim() ||
|
||
|
|
slugKey(f.label) ||
|
||
|
|
`field_${index + 1}`;
|
||
|
|
return {
|
||
|
|
...f,
|
||
|
|
key: key.slice(0, 64),
|
||
|
|
label: f.label.trim(),
|
||
|
|
fieldType: f.fieldType || 'text',
|
||
|
|
required: f.required ?? false,
|
||
|
|
mapTo: f.mapTo,
|
||
|
|
position: f.position ?? index,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function slugKey(label: string): string {
|
||
|
|
return label
|
||
|
|
.trim()
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[^\w\u0600-\u06FF]+/g, '_')
|
||
|
|
.replace(/^_+|_+$/g, '')
|
||
|
|
.slice(0, 40);
|
||
|
|
}
|