INSIGHT-MVP/packages/crm-service/src/deals/deals.service.ts
Thomas Reitz aaedf68085 feat(crm): Phase 2.1 Custom Fields — backend + frontend integration
Backend (CRM expert): Custom field definitions CRUD, bulk value upsert,
7 endpoints, Prisma schema with CustomFieldDef + CustomFieldValue tables.

Frontend: Types, API, hooks, admin settings page with field management,
CustomFieldsDisplay for detail pages, CustomFieldsForm for edit modals.
Also fix Vite allowedHosts for insight.xinion.lan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:22:57 +01:00

336 lines
9.2 KiB
TypeScript

import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { CreateDealDto } from './dto/create-deal.dto';
import { UpdateDealDto } from './dto/update-deal.dto';
import { QueryDealsDto } from './dto/query-deals.dto';
import { CrmEventPublisher } from '../events/crm-event-publisher.service';
import { CustomFieldsService } from '../custom-fields/custom-fields.service';
import { CustomFieldEntityType } from '../custom-fields/dto/create-custom-field.dto';
import { Prisma } from '.prisma/crm-client';
@Injectable()
export class DealsService {
constructor(
private readonly prisma: CrmPrismaService,
private readonly eventPublisher: CrmEventPublisher,
private readonly customFieldsService: CustomFieldsService,
) {}
async create(tenantId: string, userId: string, dto: CreateDealDto) {
// Pipeline und Stage validieren
const pipeline = await this.prisma.pipeline.findFirst({
where: { id: dto.pipelineId, tenantId },
});
if (!pipeline) {
throw new NotFoundException('Pipeline nicht gefunden');
}
const stage = await this.prisma.pipelineStage.findFirst({
where: { id: dto.stageId, pipelineId: dto.pipelineId },
});
if (!stage) {
throw new NotFoundException('Pipeline-Stufe nicht gefunden');
}
// Kontakt validieren (optional)
if (dto.contactId) {
const contact = await this.prisma.contact.findFirst({
where: { id: dto.contactId, tenantId },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
}
// Unternehmen validieren (optional)
if (dto.companyId) {
const company = await this.prisma.company.findFirst({
where: { id: dto.companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
}
const deal = await this.prisma.deal.create({
data: {
tenantId,
pipelineId: dto.pipelineId,
stageId: dto.stageId,
contactId: dto.contactId,
companyId: dto.companyId,
title: dto.title,
value: dto.value,
currency: dto.currency ?? 'EUR',
status: dto.status ?? 'OPEN',
expectedCloseDate: dto.expectedCloseDate
? new Date(dto.expectedCloseDate)
: undefined,
notes: dto.notes,
lostReason: dto.lostReason,
lostReasonText: dto.lostReasonText,
createdBy: userId,
owners: {
create: { tenantId, userId, role: 'OWNER' },
},
},
include: {
pipeline: { select: { id: true, name: true } },
stage: { select: { id: true, name: true, color: true } },
contact: {
select: {
id: true,
firstName: true,
lastName: true,
companyName: true,
},
},
company: {
select: {
id: true,
name: true,
},
},
owners: true,
},
});
// Event publizieren (async, nicht blockierend)
this.eventPublisher.dealCreated(tenantId, deal.id, userId).catch(() => {});
return deal;
}
async findAll(tenantId: string, query: QueryDealsDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const where: Prisma.DealWhereInput = { tenantId };
if (query.pipelineId) {
where.pipelineId = query.pipelineId;
}
if (query.stageId) {
where.stageId = query.stageId;
}
if (query.contactId) {
where.contactId = query.contactId;
}
if (query.companyId) {
where.companyId = query.companyId;
}
if (query.status) {
where.status = query.status;
}
if (query.search) {
where.title = { contains: query.search, mode: 'insensitive' };
}
const allowedSortFields = [
'createdAt',
'updatedAt',
'title',
'value',
'expectedCloseDate',
];
const sortField = allowedSortFields.includes(query.sort ?? '')
? (query.sort as string)
: 'createdAt';
const [data, total] = await Promise.all([
this.prisma.deal.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { [sortField]: query.order ?? 'desc' },
include: {
pipeline: { select: { id: true, name: true } },
stage: { select: { id: true, name: true, color: true } },
contact: {
select: {
id: true,
firstName: true,
lastName: true,
companyName: true,
},
},
company: {
select: {
id: true,
name: true,
},
},
owners: true,
},
}),
this.prisma.deal.count({ where }),
]);
return { data, total, page, pageSize };
}
async findOne(tenantId: string, id: string) {
const deal = await this.prisma.deal.findFirst({
where: { id, tenantId },
include: {
pipeline: { include: { stages: { orderBy: { sortOrder: 'asc' } } } },
stage: true,
contact: true,
company: true,
owners: true,
dealVouchers: {
include: {
voucher: {
select: {
id: true,
voucherType: true,
voucherNumber: true,
voucherDate: true,
voucherStatus: true,
totalGrossAmount: true,
currency: true,
title: true,
lexwareDeepLink: true,
},
},
},
orderBy: { linkedAt: 'desc' },
},
},
});
if (!deal) {
throw new NotFoundException('Vorgang nicht gefunden');
}
// Custom Fields anhaengen
const customFields = await this.customFieldsService.getCustomFieldsForEntity(
tenantId,
CustomFieldEntityType.DEAL,
id,
);
return { ...deal, customFields };
}
async update(
tenantId: string,
id: string,
userId: string,
dto: UpdateDealDto,
) {
const existing = await this.findOne(tenantId, id);
// Stage validieren wenn geaendert
if (dto.stageId) {
const pipelineId = dto.pipelineId ?? existing.pipelineId;
const stage = await this.prisma.pipelineStage.findFirst({
where: { id: dto.stageId, pipelineId },
});
if (!stage) {
throw new NotFoundException('Pipeline-Stufe nicht gefunden');
}
}
// Lost-Reason Validation
if (dto.status === 'LOST') {
// Wenn LOST gesetzt wird, muss lostReason vorhanden sein (im DTO oder bereits gesetzt)
if (!dto.lostReason && !existing.lostReason) {
throw new BadRequestException(
'Verlustgrund (lostReason) ist Pflicht wenn Deal auf LOST gesetzt wird',
);
}
}
const { lostReason, lostReasonText, ...restDto } = dto;
const updateData: Prisma.DealUpdateInput = {
...restDto,
expectedCloseDate: dto.expectedCloseDate
? new Date(dto.expectedCloseDate)
: undefined,
updatedBy: userId,
};
// Lost-Reason Felder setzen
if (lostReason !== undefined) {
updateData.lostReason = lostReason;
}
if (lostReasonText !== undefined) {
updateData.lostReasonText = lostReasonText;
}
// Wenn Deal gewonnen → lostReason und lostReasonText loeschen
if (dto.status === 'WON') {
updateData.lostReason = null;
updateData.lostReasonText = null;
}
// Wenn Deal gewonnen/verloren, closedAt setzen
if (dto.status === 'WON' || dto.status === 'LOST') {
updateData.closedAt = new Date();
}
const updated = await this.prisma.deal.update({
where: { id },
data: updateData,
include: {
pipeline: { select: { id: true, name: true } },
stage: { select: { id: true, name: true, color: true } },
contact: {
select: {
id: true,
firstName: true,
lastName: true,
companyName: true,
},
},
company: {
select: {
id: true,
name: true,
},
},
owners: true,
},
});
// Events publizieren (async, nicht blockierend)
if (dto.stageId && dto.stageId !== existing.stageId) {
this.eventPublisher.dealStageChanged(tenantId, id, userId, {
previousStageId: existing.stageId,
newStageId: dto.stageId,
}).catch(() => {});
}
if (dto.status === 'WON') {
this.eventPublisher.dealWon(tenantId, id, userId, {
value: updated.value?.toNumber() ?? null,
currency: updated.currency ?? 'EUR',
}).catch(() => {});
}
if (dto.status === 'LOST') {
this.eventPublisher.dealLost(tenantId, id, userId, {
lostReason: updated.lostReason ?? null,
}).catch(() => {});
}
return updated;
}
async remove(tenantId: string, id: string) {
await this.findOne(tenantId, id);
// Custom Field Values entfernen (entityId hat keinen FK, daher manuell)
await this.prisma.customFieldValue.deleteMany({
where: { tenantId, entityId: id },
});
return this.prisma.deal.delete({ where: { id } });
}
}