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