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,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 };
}
}
+398
View File
@@ -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,
);
}
+218
View File
@@ -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 {}
+262
View File
@@ -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,
}),
),
};
}
}