Ship loyalty club and form builder so production pull gets the new screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-06 13:50:05 +03:30
co-authored by Cursor
parent 175f1fa440
commit 820c0780d8
21 changed files with 3658 additions and 893 deletions
+48 -5
View File
@@ -11,11 +11,12 @@ import {
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { LEAD_FORM_FIELD_TYPES } from '@mento/shared';
const FIELD_TYPES = ['text', 'email', 'phone', 'textarea'] as const;
const MAP_TO = [
'person.firstName',
'person.lastName',
'person.fullName',
'person.email',
'person.mobile',
'person.phone',
@@ -23,6 +24,7 @@ const MAP_TO = [
'deal.title',
'deal.notes',
'notes',
'custom',
] as const;
export class LeadFormFieldInputDto {
@@ -36,8 +38,8 @@ export class LeadFormFieldInputDto {
@MaxLength(120)
label!: string;
@IsIn(FIELD_TYPES)
fieldType!: (typeof FIELD_TYPES)[number];
@IsIn([...LEAD_FORM_FIELD_TYPES])
fieldType!: (typeof LEAD_FORM_FIELD_TYPES)[number];
@IsOptional()
@IsBoolean()
@@ -48,6 +50,17 @@ export class LeadFormFieldInputDto {
@IsOptional()
position?: number;
@IsOptional()
@IsString()
@MaxLength(160)
placeholder?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(40)
options?: string[];
}
export class CreateLeadFormDto {
@@ -56,6 +69,11 @@ export class CreateLeadFormDto {
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(400)
description?: string | null;
@IsOptional()
@IsBoolean()
createPerson?: boolean;
@@ -89,11 +107,21 @@ export class CreateLeadFormDto {
@ArrayMaxSize(50)
assigneeIds?: string[];
@IsOptional()
@IsString()
@MaxLength(80)
submitLabel?: string;
@IsOptional()
@IsString()
@MaxLength(20)
submitColor?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeadFormFieldInputDto)
@ArrayMaxSize(30)
@ArrayMaxSize(40)
fields?: LeadFormFieldInputDto[];
}
@@ -104,6 +132,11 @@ export class UpdateLeadFormDto {
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(400)
description?: string | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@@ -141,11 +174,21 @@ export class UpdateLeadFormDto {
@ArrayMaxSize(50)
assigneeIds?: string[];
@IsOptional()
@IsString()
@MaxLength(80)
submitLabel?: string;
@IsOptional()
@IsString()
@MaxLength(20)
submitColor?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeadFormFieldInputDto)
@ArrayMaxSize(30)
@ArrayMaxSize(40)
fields?: LeadFormFieldInputDto[];
}
@@ -6,19 +6,30 @@ import {
Param,
Patch,
Post,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
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';
import { LocalFileStorage } from '../storage/local-file.storage';
@Controller('lead-forms')
@UseGuards(JwtAuthGuard, RolesGuard)
export class LeadFormsController {
constructor(private readonly leadFormsService: LeadFormsService) {}
constructor(
private readonly leadFormsService: LeadFormsService,
private readonly files: LocalFileStorage,
) {}
@Get()
@Roles('owner', 'admin')
@@ -54,6 +65,51 @@ export class LeadFormsController {
return this.leadFormsService.update(user.organizationId, id, dto);
}
@Get(':id/submissions')
@Roles('owner', 'admin')
submissions(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.leadFormsService.listSubmissions(user.organizationId, id);
}
@Post(':id/cover')
@Roles('owner', 'admin')
@UseInterceptors(
FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 4_000_000 } }),
)
uploadCover(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.leadFormsService.saveCover(user.organizationId, id, file);
}
@Delete(':id/cover')
@Roles('owner', 'admin')
removeCover(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.leadFormsService.removeCover(user.organizationId, id);
}
@Get(':id/cover')
@Roles('owner', 'admin')
async cover(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Res({ passthrough: true }) res: Response,
) {
const form = await this.leadFormsService.get(user.organizationId, id);
const row = await this.leadFormsService.getByToken(form.publicToken);
if (!row?.coverStorageKey) return { ok: false };
res.set({ 'Content-Type': 'image/jpeg' });
return new StreamableFile(this.files.openReadStream(row.coverStorageKey));
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(
+192 -48
View File
@@ -13,6 +13,7 @@ import type {
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import {
emptyToNull,
normalizeEmail,
@@ -21,44 +22,40 @@ import {
const DEFAULT_FIELDS: LeadFormFieldInput[] = [
{
key: 'firstName',
label: 'نام',
key: 'fullName',
label: 'نام و نام خانوادگی',
fieldType: 'text',
required: true,
mapTo: 'person.firstName',
mapTo: 'person.fullName',
placeholder: 'مثلاً علی منتظر',
position: 0,
},
{
key: 'lastName',
label: 'نام خانوادگی',
fieldType: 'text',
required: true,
mapTo: 'person.lastName',
position: 1,
},
{
key: 'mobile',
label: 'موبایل',
label: 'شماره تماس',
fieldType: 'phone',
required: true,
mapTo: 'person.mobile',
placeholder: '۰۹۱۲۱۲۳۴۵۶۷',
position: 1,
},
{
key: 'subject',
label: 'موضوع درخواست',
fieldType: 'select',
required: true,
mapTo: 'deal.title',
options: ['مشاوره فروش', 'پشتیبانی', 'همکاری'],
position: 2,
},
{
key: 'email',
label: 'ایمیل',
fieldType: 'email',
required: false,
mapTo: 'person.email',
position: 3,
},
{
key: 'message',
label: 'پیام',
label: 'توضیحات',
fieldType: 'textarea',
required: false,
mapTo: 'deal.notes',
position: 4,
placeholder: 'شرح درخواست خود را بنویسید',
position: 3,
},
];
@@ -66,15 +63,24 @@ type FormRow = Prisma.LeadFormGetPayload<{ include: { fields: true } }>;
@Injectable()
export class LeadFormsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
) {}
async list(organizationId: string): Promise<LeadFormDto[]> {
const rows = await this.prisma.leadForm.findMany({
where: { organizationId },
include: { fields: { orderBy: { position: 'asc' } } },
include: {
fields: { orderBy: { position: 'asc' } },
_count: { select: { submissions: true } },
},
orderBy: { updatedAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
return rows.map((row) => ({
...this.toDto(row),
submissionCount: row._count.submissions,
}));
}
async get(organizationId: string, id: string): Promise<LeadFormDto> {
@@ -94,6 +100,7 @@ export class LeadFormsService {
data: {
organizationId,
name: dto.name.trim(),
description: emptyToNull(dto.description ?? null),
createPerson: dto.createPerson ?? true,
createDeal: dto.createDeal ?? true,
pipelineId: emptyToNull(dto.pipelineId ?? null),
@@ -103,15 +110,10 @@ export class LeadFormsService {
'پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.',
redirectUrl: emptyToNull(dto.redirectUrl ?? null),
assigneeIds: dto.assigneeIds ?? [],
submitLabel: dto.submitLabel?.trim() || 'ارسال درخواست',
submitColor: dto.submitColor?.trim() || '#14b8a6',
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,
})),
create: fields.map((f, index) => fieldCreateData(f, index)),
},
},
include: { fields: { orderBy: { position: 'asc' } } },
@@ -134,6 +136,13 @@ export class LeadFormsService {
const data: Prisma.LeadFormUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if ('description' in dto) data.description = emptyToNull(dto.description ?? null);
if (dto.submitLabel !== undefined) {
data.submitLabel = dto.submitLabel.trim() || 'ارسال درخواست';
}
if (dto.submitColor !== undefined) {
data.submitColor = dto.submitColor.trim() || '#14b8a6';
}
if (dto.isActive !== undefined) data.isActive = dto.isActive;
if (dto.createPerson !== undefined) data.createPerson = dto.createPerson;
if (dto.createDeal !== undefined) data.createDeal = dto.createDeal;
@@ -156,14 +165,7 @@ export class LeadFormsService {
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,
})),
create: fields.map((f, index) => fieldCreateData(f, index)),
},
},
});
@@ -188,13 +190,24 @@ export class LeadFormsService {
if (!row) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
return {
name: row.name,
description: row.description,
thankYouMessage: row.thankYouMessage,
fields: row.fields.map((f) => ({
key: f.key,
label: f.label,
fieldType: f.fieldType,
required: f.required,
})),
submitLabel: row.submitLabel || 'ارسال درخواست',
submitColor: row.submitColor || '#14b8a6',
hasCover: Boolean(row.coverStorageKey),
coverPath: row.coverStorageKey
? `/api/public/forms/${row.publicToken}/cover`
: null,
fields: row.fields
.filter((f) => f.fieldType !== 'heading' || true)
.map((f) => ({
key: f.key,
label: f.label,
fieldType: f.fieldType,
required: f.required,
placeholder: f.placeholder,
options: asOptions(f.options),
})),
};
}
@@ -209,6 +222,10 @@ export class LeadFormsService {
if (!form) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
const mapped = mapValues(form.fields, values);
const extras = collectExtras(form.fields, values);
if (extras) {
mapped.notes = [mapped.notes, extras].filter(Boolean).join('\n');
}
if (form.createPerson) {
if (!mapped.firstName?.trim() && !mapped.lastName?.trim()) {
@@ -239,6 +256,14 @@ export class LeadFormsService {
);
}
await this.prisma.leadFormSubmission.create({
data: {
formId: form.id,
values: values as Prisma.InputJsonValue,
personId,
},
});
if (form.createDeal) {
await this.createDeal(form, mapped, personId, actorId, ownerId);
} else if (personId && mapped.notes) {
@@ -410,10 +435,70 @@ export class LeadFormsService {
}
}
async listSubmissions(organizationId: string, id: string) {
await this.findInOrg(organizationId, id);
const rows = await this.prisma.leadFormSubmission.findMany({
where: { formId: id },
orderBy: { createdAt: 'desc' },
take: 200,
});
return rows.map((r) => ({
id: r.id,
values: asStringRecord(r.values),
personId: r.personId,
createdAt: r.createdAt.toISOString(),
}));
}
async saveCover(
organizationId: string,
id: string,
file: { buffer: Buffer; originalname: string },
) {
if (!file?.buffer) throw new BadRequestException('فایل کاور ارسال نشده');
const row = await this.findInOrg(organizationId, id);
const ext = (file.originalname.split('.').pop() || 'jpg')
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.slice(0, 6);
const key = `lead-forms/${id}/cover.${ext || 'jpg'}`;
await this.files.save(key, file.buffer);
if (row.coverStorageKey && row.coverStorageKey !== key) {
await this.files.remove(row.coverStorageKey);
}
await this.prisma.leadForm.update({
where: { id },
data: { coverStorageKey: key },
});
return this.get(organizationId, id);
}
async removeCover(organizationId: string, id: string) {
const row = await this.findInOrg(organizationId, id);
if (row.coverStorageKey) await this.files.remove(row.coverStorageKey);
await this.prisma.leadForm.update({
where: { id },
data: { coverStorageKey: null },
});
return this.get(organizationId, id);
}
coverKey(row: FormRow) {
return row.coverStorageKey;
}
async getByToken(token: string, onlyActive = false) {
return this.prisma.leadForm.findFirst({
where: { publicToken: token, ...(onlyActive ? { isActive: true } : {}) },
include: { fields: true },
});
}
private toDto(row: FormRow): LeadFormDto {
return {
id: row.id,
name: row.name,
description: row.description,
publicToken: row.publicToken,
isActive: row.isActive,
createPerson: row.createPerson,
@@ -423,6 +508,9 @@ export class LeadFormsService {
thankYouMessage: row.thankYouMessage,
redirectUrl: row.redirectUrl,
assigneeIds: row.assigneeIds,
submitLabel: row.submitLabel || 'ارسال درخواست',
submitColor: row.submitColor || '#14b8a6',
hasCover: Boolean(row.coverStorageKey),
fields: row.fields.map((f) => ({
id: f.id,
key: f.key,
@@ -431,6 +519,8 @@ export class LeadFormsService {
required: f.required,
mapTo: f.mapTo,
position: f.position,
placeholder: f.placeholder,
options: asOptions(f.options),
})),
publicPath: `/f/${row.publicToken}`,
createdAt: row.createdAt.toISOString(),
@@ -452,17 +542,30 @@ type MappedValues = {
};
function mapValues(
fields: Array<{ key: string; mapTo: string; required: boolean; label: string }>,
fields: Array<{
key: string;
mapTo: string;
required: boolean;
label: string;
fieldType?: string;
}>,
values: Record<string, string>,
): MappedValues {
const mapped: MappedValues = {};
for (const field of fields) {
if (field.fieldType === 'heading') continue;
const raw = (values?.[field.key] ?? '').toString().trim();
if (field.required && !raw) {
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
}
if (!raw) continue;
switch (field.mapTo) {
case 'person.fullName': {
const parts = raw.split(/\s+/).filter(Boolean);
mapped.firstName = parts[0] || raw;
mapped.lastName = parts.slice(1).join(' ') || '—';
break;
}
case 'person.firstName':
mapped.firstName = raw;
break;
@@ -515,6 +618,47 @@ function normalizeFields(fields: LeadFormFieldInput[]): LeadFormFieldInput[] {
});
}
function fieldCreateData(f: LeadFormFieldInput, index: number) {
return {
key: f.key!,
label: f.label,
fieldType: f.fieldType,
required: f.required ?? false,
mapTo: f.mapTo,
position: f.position ?? index,
placeholder: emptyToNull(f.placeholder ?? null),
options: f.options?.length ? f.options : undefined,
};
}
function asOptions(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((v) => String(v).trim()).filter(Boolean);
}
function asStringRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (v == null) continue;
out[k] = typeof v === 'string' ? v : JSON.stringify(v);
}
return out;
}
function collectExtras(
fields: Array<{ key: string; mapTo: string; label: string; fieldType: string }>,
values: Record<string, string>,
): string {
const lines: string[] = [];
for (const field of fields) {
if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue;
const raw = (values?.[field.key] ?? '').toString().trim();
if (raw) lines.push(`${field.label}: ${raw}`);
}
return lines.join('\n');
}
function slugKey(label: string): string {
return label
.trim()
@@ -1,16 +1,41 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Post,
Res,
StreamableFile,
} from '@nestjs/common';
import type { Response } from 'express';
import { SubmitLeadFormDto } from './dto';
import { LeadFormsService } from './lead-forms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
@Controller('public/forms')
export class PublicLeadFormsController {
constructor(private readonly leadFormsService: LeadFormsService) {}
constructor(
private readonly leadFormsService: LeadFormsService,
private readonly files: LocalFileStorage,
) {}
@Get(':token')
get(@Param('token') token: string) {
return this.leadFormsService.getPublic(token);
}
@Get(':token/cover')
async cover(
@Param('token') token: string,
@Res({ passthrough: true }) res: Response,
) {
const row = await this.leadFormsService.getByToken(token);
if (!row?.coverStorageKey) throw new NotFoundException('کاور پیدا نشد');
res.set({ 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=3600' });
return new StreamableFile(this.files.openReadStream(row.coverStorageKey));
}
@Post(':token/submit')
submit(@Param('token') token: string, @Body() dto: SubmitLeadFormDto) {
return this.leadFormsService.submit(token, dto.values ?? {});