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,56 @@
|
||||
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 { CreateDealDto, MoveDealDto, UpdateDealDto } from './dto';
|
||||
import { DealsService } from './deals.service';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
|
||||
@Controller('deals')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DealsController {
|
||||
constructor(private readonly dealsService: DealsService) {}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.dealsService.get(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreateDealDto) {
|
||||
return this.dealsService.create(user.organizationId, user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateDealDto,
|
||||
) {
|
||||
return this.dealsService.update(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/move')
|
||||
move(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: MoveDealDto,
|
||||
) {
|
||||
return this.dealsService.move(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
await this.dealsService.remove(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateDealRequest,
|
||||
DealDto,
|
||||
DealLineItemInput,
|
||||
MoveDealRequest,
|
||||
UpdateDealRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
import { toJsonCustomFields } from '../common/custom-fields';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import {
|
||||
type Actor,
|
||||
canReassignOwner,
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
|
||||
const dealInclude = {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
lineItems: { orderBy: { position: 'asc' as const } },
|
||||
} satisfies Prisma.DealInclude;
|
||||
|
||||
type DealActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
@Injectable()
|
||||
export class DealsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly pipelines: PipelinesService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
): Promise<DealDto> {
|
||||
const row = await this.findDeal(organizationId, id, actor);
|
||||
return this.pipelines.toDealDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
actor: DealActor,
|
||||
dto: CreateDealRequest,
|
||||
): Promise<DealDto> {
|
||||
const pipeline =
|
||||
dto.pipelineId != null
|
||||
? await this.prisma.pipeline.findFirst({
|
||||
where: { id: dto.pipelineId, organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
})
|
||||
: null;
|
||||
|
||||
const resolvedPipeline =
|
||||
pipeline ??
|
||||
(await this.prisma.pipeline.findFirst({
|
||||
where: { organizationId, isDefault: true },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
}));
|
||||
|
||||
if (!resolvedPipeline) {
|
||||
await this.pipelines.ensureDefaultPipeline(organizationId);
|
||||
return this.create(organizationId, actor, dto);
|
||||
}
|
||||
|
||||
const stage =
|
||||
(dto.stageId
|
||||
? resolvedPipeline.stages.find((s) => s.id === dto.stageId)
|
||||
: resolvedPipeline.stages.find((s) => !s.isWon && !s.isLost)) ??
|
||||
resolvedPipeline.stages[0];
|
||||
|
||||
if (!stage) {
|
||||
throw new BadRequestException('کاریز مرحلهای ندارد');
|
||||
}
|
||||
|
||||
await this.assertLinks(organizationId, actor, dto.personId, dto.companyId);
|
||||
if (dto.lineItems?.length) {
|
||||
await this.assertProducts(organizationId, dto.lineItems);
|
||||
}
|
||||
|
||||
const maxPos = await this.prisma.deal.aggregate({
|
||||
where: { organizationId, stageId: stage.id },
|
||||
_max: { position: true },
|
||||
});
|
||||
|
||||
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
|
||||
const amountFromLines = sumLineItems(dto.lineItems);
|
||||
const currency =
|
||||
dto.lineItems?.[0]?.currency?.trim() ||
|
||||
(dto.currency ?? 'IRR').trim() ||
|
||||
'IRR';
|
||||
|
||||
const created = await this.prisma.deal.create({
|
||||
data: {
|
||||
organizationId,
|
||||
pipelineId: resolvedPipeline.id,
|
||||
stageId: stage.id,
|
||||
title: dto.title.trim(),
|
||||
amount:
|
||||
amountFromLines != null
|
||||
? new Prisma.Decimal(amountFromLines)
|
||||
: dto.amount == null || Number.isNaN(Number(dto.amount))
|
||||
? null
|
||||
: new Prisma.Decimal(dto.amount),
|
||||
currency,
|
||||
status,
|
||||
source: emptyToNull(dto.source),
|
||||
notes: emptyToNull(dto.notes),
|
||||
tags: normalizeTags(dto.tags),
|
||||
isStale: dto.isStale ?? false,
|
||||
isRotten: dto.isRotten ?? false,
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
expectedCloseAt: dto.expectedCloseAt
|
||||
? new Date(dto.expectedCloseAt)
|
||||
: null,
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
...(dto.lineItems?.length
|
||||
? {
|
||||
lineItems: {
|
||||
create: dto.lineItems.map((item, index) => ({
|
||||
productId: emptyToNull(item.productId ?? null),
|
||||
name: item.name.trim(),
|
||||
quantity: new Prisma.Decimal(item.quantity),
|
||||
unitPrice: new Prisma.Decimal(item.unitPrice),
|
||||
currency: (item.currency ?? currency).trim() || 'IRR',
|
||||
position: index,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: dealInclude,
|
||||
});
|
||||
|
||||
return this.pipelines.toDealDto(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
dto: UpdateDealRequest,
|
||||
): Promise<DealDto> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.assertLinks(organizationId, actor, dto.personId, dto.companyId);
|
||||
if (dto.lineItems) {
|
||||
await this.assertProducts(organizationId, dto.lineItems);
|
||||
}
|
||||
|
||||
const data: Prisma.DealUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if ('amount' in dto && dto.lineItems === undefined) {
|
||||
data.amount =
|
||||
dto.amount == null || Number.isNaN(Number(dto.amount))
|
||||
? null
|
||||
: new Prisma.Decimal(dto.amount);
|
||||
}
|
||||
if (dto.currency !== undefined && dto.lineItems === undefined) {
|
||||
data.currency = dto.currency.trim() || 'IRR';
|
||||
}
|
||||
if ('source' in dto) data.source = emptyToNull(dto.source);
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
|
||||
if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags);
|
||||
if (dto.isStale !== undefined) data.isStale = dto.isStale;
|
||||
if (dto.isRotten !== undefined) data.isRotten = dto.isRotten;
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
if ('lostReason' in dto) data.lostReason = emptyToNull(dto.lostReason);
|
||||
if ('expectedCloseAt' in dto) {
|
||||
data.expectedCloseAt = dto.expectedCloseAt
|
||||
? new Date(dto.expectedCloseAt)
|
||||
: null;
|
||||
}
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
|
||||
data.ownerId = resolveOwnerId(actor, dto.ownerId);
|
||||
}
|
||||
|
||||
if (dto.lineItems !== undefined) {
|
||||
const total = sumLineItems(dto.lineItems);
|
||||
data.amount =
|
||||
total == null ? null : new Prisma.Decimal(total);
|
||||
if (dto.lineItems[0]?.currency) {
|
||||
data.currency = dto.lineItems[0].currency.trim() || 'IRR';
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
if (dto.lineItems !== undefined) {
|
||||
await tx.dealLineItem.deleteMany({ where: { dealId: id } });
|
||||
if (dto.lineItems.length) {
|
||||
const currency =
|
||||
(typeof data.currency === 'string' ? data.currency : null) ||
|
||||
'IRR';
|
||||
await tx.dealLineItem.createMany({
|
||||
data: dto.lineItems.map((item, index) => ({
|
||||
dealId: id,
|
||||
productId: emptyToNull(item.productId ?? null),
|
||||
name: item.name.trim(),
|
||||
quantity: new Prisma.Decimal(item.quantity),
|
||||
unitPrice: new Prisma.Decimal(item.unitPrice),
|
||||
currency: (item.currency ?? currency).trim() || 'IRR',
|
||||
position: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
await tx.deal.update({ where: { id }, data });
|
||||
});
|
||||
|
||||
return this.get(organizationId, id, actor);
|
||||
}
|
||||
|
||||
async move(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
dto: MoveDealRequest,
|
||||
): Promise<DealDto> {
|
||||
const deal = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
pipelineId: true,
|
||||
stageId: true,
|
||||
ownerId: true,
|
||||
},
|
||||
});
|
||||
if (!deal) throw new NotFoundException('معامله پیدا نشد');
|
||||
|
||||
const stage = await this.prisma.pipelineStage.findFirst({
|
||||
where: { id: dto.stageId, pipelineId: deal.pipelineId },
|
||||
});
|
||||
if (!stage) {
|
||||
throw new BadRequestException('مرحله در این کاریز معتبر نیست');
|
||||
}
|
||||
|
||||
const status = stage.isWon ? 'won' : stage.isLost ? 'lost' : 'open';
|
||||
const position =
|
||||
dto.position ??
|
||||
((
|
||||
await this.prisma.deal.aggregate({
|
||||
where: { organizationId, stageId: stage.id, NOT: { id } },
|
||||
_max: { position: true },
|
||||
})
|
||||
)._max.position ?? -1) + 1;
|
||||
|
||||
const row = await this.prisma.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stageId: stage.id,
|
||||
position,
|
||||
status,
|
||||
lostReason: stage.isLost ? undefined : null,
|
||||
},
|
||||
include: dealInclude,
|
||||
});
|
||||
|
||||
if (deal.ownerId && deal.stageId !== stage.id) {
|
||||
await this.notifications.create({
|
||||
organizationId,
|
||||
userId: deal.ownerId,
|
||||
type: 'deal_moved',
|
||||
title: 'جابهجایی معامله در کاریز',
|
||||
body: `«${deal.title}» به مرحله «${stage.name}» منتقل شد`,
|
||||
link: `/deals/${deal.id}`,
|
||||
entityType: 'deal',
|
||||
entityId: deal.id,
|
||||
});
|
||||
}
|
||||
|
||||
return this.pipelines.toDealDto(row);
|
||||
}
|
||||
|
||||
async remove(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
): Promise<void> {
|
||||
await this.ensureExists(organizationId, id, actor);
|
||||
await this.prisma.deal.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private async ensureExists(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
) {
|
||||
const found = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('معامله پیدا نشد');
|
||||
}
|
||||
|
||||
private async findDeal(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: DealActor,
|
||||
) {
|
||||
const row = await this.prisma.deal.findFirst({
|
||||
where: { id, organizationId, ...ownerWhere(actor) },
|
||||
include: dealInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('معامله پیدا نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertProducts(
|
||||
organizationId: string,
|
||||
items: DealLineItemInput[],
|
||||
) {
|
||||
const ids = [
|
||||
...new Set(
|
||||
items
|
||||
.map((i) => emptyToNull(i.productId ?? null))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
if (!ids.length) return;
|
||||
const count = await this.prisma.product.count({
|
||||
where: { organizationId, id: { in: ids } },
|
||||
});
|
||||
if (count !== ids.length) {
|
||||
throw new BadRequestException('یکی از محصولات انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLinks(
|
||||
organizationId: string,
|
||||
actor: DealActor,
|
||||
personId?: string | null,
|
||||
companyId?: string | null,
|
||||
) {
|
||||
const pId = emptyToNull(personId ?? null);
|
||||
const cId = emptyToNull(companyId ?? null);
|
||||
const scope = ownerWhere(actor);
|
||||
if (pId) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: pId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!person) throw new BadRequestException('شخص انتخابشده معتبر نیست');
|
||||
}
|
||||
if (cId) {
|
||||
const company = await this.prisma.company.findFirst({
|
||||
where: { id: cId, organizationId, ...scope },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!company) throw new BadRequestException('شرکت انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeTags(tags?: string[]): string[] {
|
||||
if (!tags?.length) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of tags) {
|
||||
const tag = raw.trim();
|
||||
if (!tag) continue;
|
||||
const key = tag.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(tag.slice(0, 40));
|
||||
}
|
||||
return out.slice(0, 20);
|
||||
}
|
||||
|
||||
function sumLineItems(items?: DealLineItemInput[]): number | null {
|
||||
if (!items?.length) return null;
|
||||
return items.reduce(
|
||||
(sum, item) => sum + Number(item.quantity) * Number(item.unitPrice),
|
||||
0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreatePipelineDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class DealLineItemInputDto {
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
productId?: string | null;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
name!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
quantity!: number;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export class CreateDealDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
pipelineId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stageId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
source?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isStale?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRotten?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
expectedCloseAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DealLineItemInputDto)
|
||||
lineItems?: DealLineItemInputDto[];
|
||||
}
|
||||
|
||||
export class UpdateDealDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
source?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isStale?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRotten?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(400)
|
||||
lostReason?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
expectedCloseAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
personId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => DealLineItemInputDto)
|
||||
lineItems?: DealLineItemInputDto[];
|
||||
}
|
||||
|
||||
export class MoveDealDto {
|
||||
@IsString()
|
||||
stageId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
position?: number;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
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 { CreatePipelineDto } from './dto';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
|
||||
@Controller('pipelines')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class PipelinesController {
|
||||
constructor(private readonly pipelinesService: PipelinesService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: { organizationId: string }) {
|
||||
return this.pipelinesService.list(user.organizationId);
|
||||
}
|
||||
|
||||
@Get('board')
|
||||
board(
|
||||
@CurrentUser() user: { organizationId: string; userId: string; role: string },
|
||||
@Query('pipelineId') pipelineId?: string,
|
||||
) {
|
||||
return this.pipelinesService.getBoard(user.organizationId, pipelineId, {
|
||||
userId: user.userId,
|
||||
role: user.role,
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreatePipelineDto,
|
||||
) {
|
||||
return this.pipelinesService.create(user.organizationId, dto.name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DealsController } from './deals.controller';
|
||||
import { DealsService } from './deals.service';
|
||||
import { PipelinesController } from './pipelines.controller';
|
||||
import { PipelinesService } from './pipelines.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule, AuthModule],
|
||||
controllers: [PipelinesController, DealsController],
|
||||
providers: [PipelinesService, DealsService],
|
||||
exports: [PipelinesService, DealsService],
|
||||
})
|
||||
export class PipelinesModule {}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
DealDto,
|
||||
PipelineBoardDto,
|
||||
PipelineDto,
|
||||
PipelineStageDto,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { asCustomFields } from '../common/custom-fields';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
import { decimalToBase, loadOrgRates } from '../common/currency';
|
||||
|
||||
const DEFAULT_STAGES = [
|
||||
{ name: 'سرنخ', isWon: false, isLost: false },
|
||||
{ name: 'تماس اول', isWon: false, isLost: false },
|
||||
{ name: 'پیشنهاد', isWon: false, isLost: false },
|
||||
{ name: 'مذاکره', isWon: false, isLost: false },
|
||||
{ name: 'برنده', isWon: true, isLost: false },
|
||||
{ name: 'از دسترفته', isWon: false, isLost: true },
|
||||
] as const;
|
||||
|
||||
type DealRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
amount: Prisma.Decimal | null;
|
||||
currency: string;
|
||||
status: 'open' | 'won' | 'lost';
|
||||
source: string | null;
|
||||
lostReason: string | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
isStale: boolean;
|
||||
isRotten: boolean;
|
||||
customFields: unknown;
|
||||
expectedCloseAt: Date | null;
|
||||
position: number;
|
||||
pipelineId: string;
|
||||
stageId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
company: { id: string; name: string } | null;
|
||||
owner: { id: string; fullName: string } | null;
|
||||
lineItems?: Array<{
|
||||
id: string;
|
||||
productId: string | null;
|
||||
name: string;
|
||||
quantity: Prisma.Decimal;
|
||||
unitPrice: Prisma.Decimal;
|
||||
currency: string;
|
||||
position: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PipelinesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async ensureDefaultPipeline(organizationId: string): Promise<PipelineDto> {
|
||||
const existing = await this.prisma.pipeline.findFirst({
|
||||
where: { organizationId, isDefault: true },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (existing) return this.toPipelineDto(existing);
|
||||
|
||||
const created = await this.prisma.pipeline.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: 'کاریز فروش',
|
||||
isDefault: true,
|
||||
stages: {
|
||||
create: DEFAULT_STAGES.map((s, index) => ({
|
||||
name: s.name,
|
||||
position: index,
|
||||
isWon: s.isWon,
|
||||
isLost: s.isLost,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
return this.toPipelineDto(created);
|
||||
}
|
||||
|
||||
async list(organizationId: string): Promise<PipelineDto[]> {
|
||||
await this.ensureDefaultPipeline(organizationId);
|
||||
const rows = await this.prisma.pipeline.findMany({
|
||||
where: { organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => this.toPipelineDto(row));
|
||||
}
|
||||
|
||||
async create(organizationId: string, name: string): Promise<PipelineDto> {
|
||||
await this.ensureDefaultPipeline(organizationId);
|
||||
const created = await this.prisma.pipeline.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name: name.trim(),
|
||||
isDefault: false,
|
||||
stages: {
|
||||
create: DEFAULT_STAGES.map((s, index) => ({
|
||||
name: s.name,
|
||||
position: index,
|
||||
isWon: s.isWon,
|
||||
isLost: s.isLost,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
});
|
||||
return this.toPipelineDto(created);
|
||||
}
|
||||
|
||||
async getBoard(
|
||||
organizationId: string,
|
||||
pipelineId?: string,
|
||||
actor?: Pick<Actor, 'userId' | 'role'>,
|
||||
): Promise<PipelineBoardDto> {
|
||||
const pipeline = pipelineId
|
||||
? await this.prisma.pipeline.findFirst({
|
||||
where: { id: pipelineId, organizationId },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
})
|
||||
: null;
|
||||
|
||||
const resolved =
|
||||
pipeline ??
|
||||
(await this.prisma.pipeline.findFirst({
|
||||
where: { organizationId, isDefault: true },
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
}));
|
||||
|
||||
if (!resolved) {
|
||||
const created = await this.ensureDefaultPipeline(organizationId);
|
||||
return this.getBoard(organizationId, created.id, actor);
|
||||
}
|
||||
|
||||
const deals = await this.prisma.deal.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
pipelineId: resolved.id,
|
||||
...(actor ? ownerWhere(actor) : {}),
|
||||
},
|
||||
include: {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
company: { select: { id: true, name: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
|
||||
const rateMap = await loadOrgRates(this.prisma, organizationId);
|
||||
|
||||
const byStage = new Map<string, DealDto[]>();
|
||||
for (const stage of resolved.stages) byStage.set(stage.id, []);
|
||||
for (const deal of deals) {
|
||||
const list = byStage.get(deal.stageId);
|
||||
if (list) list.push(this.toDealDto(deal));
|
||||
}
|
||||
|
||||
return {
|
||||
pipeline: this.toPipelineDto(resolved),
|
||||
stages: resolved.stages.map((stage) => {
|
||||
const stageDeals = byStage.get(stage.id) ?? [];
|
||||
return {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
position: stage.position,
|
||||
isWon: stage.isWon,
|
||||
isLost: stage.isLost,
|
||||
deals: stageDeals,
|
||||
totalAmount: stageDeals.reduce(
|
||||
(sum, d) =>
|
||||
sum +
|
||||
decimalToBase(
|
||||
d.amount == null ? null : new Prisma.Decimal(d.amount),
|
||||
d.currency,
|
||||
rateMap,
|
||||
),
|
||||
0,
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
toDealDto(row: DealRow): DealDto {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
amount: row.amount == null ? null : Number(row.amount),
|
||||
currency: row.currency,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
lostReason: row.lostReason,
|
||||
notes: row.notes,
|
||||
tags: row.tags ?? [],
|
||||
isStale: row.isStale,
|
||||
isRotten: row.isRotten,
|
||||
customFields: asCustomFields(row.customFields),
|
||||
expectedCloseAt: row.expectedCloseAt?.toISOString() ?? null,
|
||||
position: row.position,
|
||||
pipelineId: row.pipelineId,
|
||||
stageId: row.stageId,
|
||||
person: row.person
|
||||
? {
|
||||
id: row.person.id,
|
||||
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
|
||||
}
|
||||
: null,
|
||||
company: row.company,
|
||||
owner: row.owner,
|
||||
lineItems: (row.lineItems ?? []).map((item) => {
|
||||
const quantity = Number(item.quantity);
|
||||
const unitPrice = Number(item.unitPrice);
|
||||
return {
|
||||
id: item.id,
|
||||
productId: item.productId,
|
||||
name: item.name,
|
||||
quantity,
|
||||
unitPrice,
|
||||
currency: item.currency,
|
||||
position: item.position,
|
||||
lineTotal: quantity * unitPrice,
|
||||
};
|
||||
}),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toPipelineDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
stages: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
isWon: boolean;
|
||||
isLost: boolean;
|
||||
}>;
|
||||
}): PipelineDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
isDefault: row.isDefault,
|
||||
stages: row.stages.map(
|
||||
(s): PipelineStageDto => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
position: s.position,
|
||||
isWon: s.isWon,
|
||||
isLost: s.isLost,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user