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
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
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 {
CreateCustomFieldDto,
ListCustomFieldsQueryDto,
UpdateCustomFieldDto,
} from './dto';
import { CustomFieldsService } from './custom-fields.service';
@Controller('custom-fields')
@UseGuards(JwtAuthGuard, RolesGuard)
export class CustomFieldsController {
constructor(private readonly customFieldsService: CustomFieldsService) {}
@Get()
list(
@CurrentUser() user: { organizationId: string },
@Query() query: ListCustomFieldsQueryDto,
) {
return this.customFieldsService.list(user.organizationId, {
entityType: query.entityType,
activeOnly: query.activeOnly,
});
}
@Post()
@Roles('owner', 'admin')
create(
@CurrentUser() user: { organizationId: string },
@Body() dto: CreateCustomFieldDto,
) {
return this.customFieldsService.create(user.organizationId, dto);
}
@Patch(':id')
@Roles('owner', 'admin')
update(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Body() dto: UpdateCustomFieldDto,
) {
return this.customFieldsService.update(user.organizationId, id, dto);
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
await this.customFieldsService.remove(user.organizationId, id);
return { ok: true };
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CustomFieldsController } from './custom-fields.controller';
import { CustomFieldsService } from './custom-fields.service';
@Module({
imports: [AuthModule],
controllers: [CustomFieldsController],
providers: [CustomFieldsService],
exports: [CustomFieldsService],
})
export class CustomFieldsModule {}
@@ -0,0 +1,181 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateCustomFieldRequest,
CustomFieldDefinitionDto,
CustomFieldEntity,
UpdateCustomFieldRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
type DefinitionRow = {
id: string;
entityType: CustomFieldEntity;
key: string;
label: string;
fieldType: CustomFieldDefinitionDto['fieldType'];
options: string[];
required: boolean;
position: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class CustomFieldsService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
opts?: { entityType?: CustomFieldEntity; activeOnly?: boolean },
): Promise<CustomFieldDefinitionDto[]> {
const rows = await this.prisma.customFieldDefinition.findMany({
where: {
organizationId,
...(opts?.entityType ? { entityType: opts.entityType } : {}),
...(opts?.activeOnly ? { isActive: true } : {}),
},
orderBy: [{ entityType: 'asc' }, { position: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async create(
organizationId: string,
dto: CreateCustomFieldRequest,
): Promise<CustomFieldDefinitionDto> {
const label = dto.label.trim();
if (!label) throw new BadRequestException('عنوان فیلد الزامی است');
const key = this.normalizeKey(dto.key) || this.keyFromLabel(label);
const fieldType = dto.fieldType ?? 'text';
const options =
fieldType === 'select'
? (dto.options ?? []).map((o) => o.trim()).filter(Boolean)
: [];
if (fieldType === 'select' && options.length === 0) {
throw new BadRequestException('برای فیلد انتخابی حداقل یک گزینه لازم است');
}
const maxPos = await this.prisma.customFieldDefinition.aggregate({
where: { organizationId, entityType: dto.entityType },
_max: { position: true },
});
try {
const row = await this.prisma.customFieldDefinition.create({
data: {
organizationId,
entityType: dto.entityType,
key,
label,
fieldType,
options,
required: dto.required ?? false,
position: dto.position ?? (maxPos._max.position ?? -1) + 1,
isActive: dto.isActive ?? true,
},
});
return this.toDto(row);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کلید این فیلد قبلاً برای همین موجودیت ثبت شده');
}
throw e;
}
}
async update(
organizationId: string,
id: string,
dto: UpdateCustomFieldRequest,
): Promise<CustomFieldDefinitionDto> {
const existing = await this.prisma.customFieldDefinition.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('فیلد سفارشی پیدا نشد');
const data: Prisma.CustomFieldDefinitionUpdateInput = {};
if (dto.label !== undefined) {
const label = dto.label.trim();
if (!label) throw new BadRequestException('عنوان فیلد الزامی است');
data.label = label;
}
if (dto.fieldType !== undefined) data.fieldType = dto.fieldType;
if (dto.options !== undefined) {
data.options = dto.options.map((o) => o.trim()).filter(Boolean);
}
if (dto.required !== undefined) data.required = dto.required;
if (dto.position !== undefined) data.position = dto.position;
if (dto.isActive !== undefined) data.isActive = dto.isActive;
const nextType = dto.fieldType ?? existing.fieldType;
const nextOptions =
dto.options !== undefined
? dto.options.map((o) => o.trim()).filter(Boolean)
: existing.options;
if (nextType === 'select' && nextOptions.length === 0) {
throw new BadRequestException('برای فیلد انتخابی حداقل یک گزینه لازم است');
}
if (nextType !== 'select') {
data.options = [];
}
const row = await this.prisma.customFieldDefinition.update({
where: { id },
data,
});
return this.toDto(row);
}
async remove(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.customFieldDefinition.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!existing) throw new NotFoundException('فیلد سفارشی پیدا نشد');
await this.prisma.customFieldDefinition.delete({ where: { id } });
}
private normalizeKey(raw?: string) {
if (!raw?.trim()) return '';
return raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
private keyFromLabel(label: string) {
const latin = this.normalizeKey(label);
if (latin) return latin.slice(0, 48);
return `cf_${Date.now().toString(36)}`;
}
private toDto(row: DefinitionRow): CustomFieldDefinitionDto {
return {
id: row.id,
entityType: row.entityType,
key: row.key,
label: row.label,
fieldType: row.fieldType,
options: row.options,
required: row.required,
position: row.position,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+107
View File
@@ -0,0 +1,107 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export enum CustomFieldEntityDto {
person = 'person',
company = 'company',
deal = 'deal',
}
export enum CustomFieldTypeDto {
text = 'text',
number = 'number',
date = 'date',
boolean = 'boolean',
select = 'select',
}
export class ListCustomFieldsQueryDto {
@IsOptional()
@IsEnum(CustomFieldEntityDto)
entityType?: CustomFieldEntityDto;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
activeOnly?: boolean;
}
export class CreateCustomFieldDto {
@IsEnum(CustomFieldEntityDto)
entityType!: CustomFieldEntityDto;
@IsString()
@MinLength(1)
@MaxLength(120)
label!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(64)
key?: string;
@IsOptional()
@IsEnum(CustomFieldTypeDto)
fieldType?: CustomFieldTypeDto;
@IsOptional()
@IsArray()
@IsString({ each: true })
options?: string[];
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateCustomFieldDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
label?: string;
@IsOptional()
@IsEnum(CustomFieldTypeDto)
fieldType?: CustomFieldTypeDto;
@IsOptional()
@IsArray()
@IsString({ each: true })
options?: string[];
@IsOptional()
@IsBoolean()
required?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
position?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}