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,155 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
const FIELD_TYPES = ['text', 'email', 'phone', 'textarea'] as const;
|
||||
const MAP_TO = [
|
||||
'person.firstName',
|
||||
'person.lastName',
|
||||
'person.email',
|
||||
'person.mobile',
|
||||
'person.phone',
|
||||
'person.position',
|
||||
'deal.title',
|
||||
'deal.notes',
|
||||
'notes',
|
||||
] as const;
|
||||
|
||||
export class LeadFormFieldInputDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
key?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
label!: string;
|
||||
|
||||
@IsIn(FIELD_TYPES)
|
||||
fieldType!: (typeof FIELD_TYPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
required?: boolean;
|
||||
|
||||
@IsIn(MAP_TO)
|
||||
mapTo!: (typeof MAP_TO)[number];
|
||||
|
||||
@IsOptional()
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class CreateLeadFormDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createPerson?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createDeal?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pipelineId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
source?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
thankYouMessage?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
redirectUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(50)
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LeadFormFieldInputDto)
|
||||
@ArrayMaxSize(30)
|
||||
fields?: LeadFormFieldInputDto[];
|
||||
}
|
||||
|
||||
export class UpdateLeadFormDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createPerson?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createDeal?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pipelineId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
source?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
thankYouMessage?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
redirectUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ArrayMaxSize(50)
|
||||
assigneeIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LeadFormFieldInputDto)
|
||||
@ArrayMaxSize(30)
|
||||
fields?: LeadFormFieldInputDto[];
|
||||
}
|
||||
|
||||
export class SubmitLeadFormDto {
|
||||
@IsObject()
|
||||
values!: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreateLeadFormDto, UpdateLeadFormDto } from './dto';
|
||||
import { LeadFormsService } from './lead-forms.service';
|
||||
|
||||
@Controller('lead-forms')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class LeadFormsController {
|
||||
constructor(private readonly leadFormsService: LeadFormsService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('owner', 'admin')
|
||||
list(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.leadFormsService.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles('owner', 'admin')
|
||||
get(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.leadFormsService.get(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreateLeadFormDto,
|
||||
) {
|
||||
return this.leadFormsService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('owner', 'admin')
|
||||
update(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLeadFormDto,
|
||||
) {
|
||||
return this.leadFormsService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
async remove(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.leadFormsService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { LeadFormsController } from './lead-forms.controller';
|
||||
import { LeadFormsService } from './lead-forms.service';
|
||||
import { PublicLeadFormsController } from './public-lead-forms.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [LeadFormsController, PublicLeadFormsController],
|
||||
providers: [LeadFormsService],
|
||||
exports: [LeadFormsService],
|
||||
})
|
||||
export class LeadFormsModule {}
|
||||
@@ -0,0 +1,525 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { SubmitLeadFormDto } from './dto';
|
||||
import { LeadFormsService } from './lead-forms.service';
|
||||
|
||||
@Controller('public/forms')
|
||||
export class PublicLeadFormsController {
|
||||
constructor(private readonly leadFormsService: LeadFormsService) {}
|
||||
|
||||
@Get(':token')
|
||||
get(@Param('token') token: string) {
|
||||
return this.leadFormsService.getPublic(token);
|
||||
}
|
||||
|
||||
@Post(':token/submit')
|
||||
submit(@Param('token') token: string, @Body() dto: SubmitLeadFormDto) {
|
||||
return this.leadFormsService.submit(token, dto.values ?? {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user