feat(crm): add Company entity + rename Deals to Vorgaenge

- New Company model with full CRUD under /api/v1/crm/companies
- Contact now has companyId relation + position field
- Deal now has companyId relation
- Company detail includes contacts (top 20) and deals (top 10)
- All endpoints include company in responses
- Swagger tags renamed: Deals -> Vorgaenge (Deals)
- Error messages use "Vorgang" instead of "Deal"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 19:30:34 +01:00
parent 3d8f568c9a
commit 56a9ed9647
14 changed files with 614 additions and 10 deletions

View file

@ -18,7 +18,54 @@ datasource db {
}
// --------------------------------------------------------
// Contact - CRM-Kontakte (Personen & Organisationen)
// Company - Unternehmen (uebergeordnete Entity)
// --------------------------------------------------------
model Company {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String @db.VarChar(200)
industry String? @db.VarChar(100)
// Kontaktdaten
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
website String? @db.VarChar(500)
// Adresse
street String? @db.VarChar(200)
zip String? @db.VarChar(20)
city String? @db.VarChar(100)
state String? @db.VarChar(100)
country String? @default("DE") @db.VarChar(2)
// Zusaetzlich
notes String? @db.Text
tags String[] @default([])
isActive Boolean @default(true) @map("is_active")
// Audit-Trail
createdBy String @map("created_by") @db.Uuid
updatedBy String? @map("updated_by") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
contacts Contact[]
deals Deal[]
@@index([tenantId])
@@index([tenantId, name])
@@index([tenantId, industry])
@@index([tenantId, isActive])
@@map("companies")
@@schema("app_crm")
}
// --------------------------------------------------------
// Contact - CRM-Kontakte (Personen)
// --------------------------------------------------------
model Contact {
id String @id @default(uuid()) @db.Uuid
@ -29,8 +76,10 @@ model Contact {
firstName String? @map("first_name") @db.VarChar(100)
lastName String? @map("last_name") @db.VarChar(100)
// Organisation
// Unternehmenszuordnung
companyId String? @map("company_id") @db.Uuid
companyName String? @map("company_name") @db.VarChar(200)
position String? @db.VarChar(200)
// Kontaktdaten
email String? @db.VarChar(255)
@ -59,11 +108,13 @@ model Contact {
updatedAt DateTime @updatedAt @map("updated_at")
// Relationen
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
activities Activity[]
deals Deal[]
@@index([tenantId])
@@index([tenantId, email])
@@index([tenantId, companyId])
@@index([tenantId, companyName])
@@index([tenantId, lastName, firstName])
@@index([tenantId, isActive])
@ -181,6 +232,7 @@ model Deal {
pipelineId String @map("pipeline_id") @db.Uuid
stageId String @map("stage_id") @db.Uuid
contactId String? @map("contact_id") @db.Uuid
companyId String? @map("company_id") @db.Uuid
title String @db.VarChar(500)
value Decimal? @db.Decimal(15, 2)
currency String @default("EUR") @db.VarChar(3)
@ -201,11 +253,13 @@ model Deal {
pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade)
stage PipelineStage @relation(fields: [stageId], references: [id])
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@index([tenantId, pipelineId])
@@index([tenantId, stageId])
@@index([tenantId, contactId])
@@index([tenantId, companyId])
@@index([tenantId, status])
@@map("deals")
@@schema("app_crm")

View file

@ -12,6 +12,7 @@ import { ContactsModule } from './contacts/contacts.module';
import { ActivitiesModule } from './activities/activities.module';
import { PipelinesModule } from './pipelines/pipelines.module';
import { DealsModule } from './deals/deals.module';
import { CompaniesModule } from './companies/companies.module';
@Module({
imports: [
@ -27,6 +28,7 @@ import { DealsModule } from './deals/deals.module';
ActivitiesModule,
PipelinesModule,
DealsModule,
CompaniesModule,
],
providers: [
{

View file

@ -0,0 +1,119 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { QueryCompaniesDto } from './dto/query-companies.dto';
import { CurrentUser, JwtPayload } from '../common/decorators';
import { TenantGuard } from '../auth/guards/tenant.guard';
import {
paginatedResponse,
singleResponse,
} from '../common/dto/pagination.dto';
@ApiTags('Companies')
@ApiBearerAuth('access-token')
@UseGuards(TenantGuard)
@Controller('companies')
export class CompaniesController {
constructor(private readonly companiesService: CompaniesService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Unternehmen erstellen' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateCompanyDto,
) {
const company = await this.companiesService.create(
user.tenantId!,
user.sub,
dto,
);
return singleResponse(company);
}
@Get()
@ApiOperation({ summary: 'Unternehmen auflisten (paginiert, suchbar)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'industry', required: false })
@ApiQuery({ name: 'sort', required: false })
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: QueryCompaniesDto,
) {
const result = await this.companiesService.findAll(
user.tenantId!,
query,
);
return paginatedResponse(
result.data,
result.total,
result.page,
result.pageSize,
);
}
@Get(':id')
@ApiOperation({
summary: 'Unternehmensdetails (inkl. Kontakte und Deals)',
})
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async findOne(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
const company = await this.companiesService.findOne(user.tenantId!, id);
return singleResponse(company);
}
@Patch(':id')
@ApiOperation({ summary: 'Unternehmen aktualisieren' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async update(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCompanyDto,
) {
const company = await this.companiesService.update(
user.tenantId!,
id,
user.sub,
dto,
);
return singleResponse(company);
}
@Delete(':id')
@ApiOperation({ summary: 'Unternehmen loeschen' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async remove(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
const company = await this.companiesService.remove(user.tenantId!, id);
return singleResponse(company);
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { CrmPrismaModule } from '../prisma/crm-prisma.module';
@Module({
imports: [CrmPrismaModule],
controllers: [CompaniesController],
providers: [CompaniesService],
exports: [CompaniesService],
})
export class CompaniesModule {}

View file

@ -0,0 +1,145 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { QueryCompaniesDto } from './dto/query-companies.dto';
import { Prisma } from '.prisma/crm-client';
@Injectable()
export class CompaniesService {
constructor(private readonly prisma: CrmPrismaService) {}
async create(tenantId: string, userId: string, dto: CreateCompanyDto) {
return this.prisma.company.create({
data: {
tenantId,
createdBy: userId,
name: dto.name,
industry: dto.industry,
email: dto.email,
phone: dto.phone,
website: dto.website,
street: dto.street,
zip: dto.zip,
city: dto.city,
state: dto.state,
country: dto.country,
notes: dto.notes,
tags: dto.tags ?? [],
isActive: dto.isActive ?? true,
},
include: {
_count: { select: { contacts: true, deals: true } },
},
});
}
async findAll(tenantId: string, query: QueryCompaniesDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const where: Prisma.CompanyWhereInput = { tenantId };
if (query.industry) {
where.industry = { contains: query.industry, mode: 'insensitive' };
}
if (query.search) {
where.OR = [
{ name: { contains: query.search, mode: 'insensitive' } },
{ industry: { contains: query.search, mode: 'insensitive' } },
{ email: { contains: query.search, mode: 'insensitive' } },
{ city: { contains: query.search, mode: 'insensitive' } },
];
}
const allowedSortFields = [
'createdAt',
'updatedAt',
'name',
'industry',
'city',
];
const sortField = allowedSortFields.includes(query.sort ?? '')
? (query.sort as string)
: 'createdAt';
const [data, total] = await Promise.all([
this.prisma.company.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { [sortField]: query.order ?? 'desc' },
include: {
_count: { select: { contacts: true, deals: true } },
},
}),
this.prisma.company.count({ where }),
]);
return { data, total, page, pageSize };
}
async findOne(tenantId: string, id: string) {
const company = await this.prisma.company.findFirst({
where: { id, tenantId },
include: {
contacts: {
where: { isActive: true },
orderBy: { createdAt: 'desc' },
take: 20,
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
position: true,
isActive: true,
},
},
deals: {
orderBy: { createdAt: 'desc' },
take: 10,
include: {
pipeline: { select: { id: true, name: true } },
stage: { select: { id: true, name: true, color: true } },
},
},
_count: { select: { contacts: true, deals: true } },
},
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
return company;
}
async update(
tenantId: string,
id: string,
userId: string,
dto: UpdateCompanyDto,
) {
await this.findOne(tenantId, id);
return this.prisma.company.update({
where: { id },
data: {
...dto,
updatedBy: userId,
},
include: {
_count: { select: { contacts: true, deals: true } },
},
});
}
async remove(tenantId: string, id: string) {
await this.findOne(tenantId, id);
return this.prisma.company.delete({ where: { id } });
}
}

View file

@ -0,0 +1,87 @@
import {
IsString,
IsOptional,
IsBoolean,
IsEmail,
IsUrl,
IsArray,
MaxLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCompanyDto {
@ApiProperty({ maxLength: 200 })
@IsString()
@MaxLength(200)
name!: string;
@ApiPropertyOptional({ maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
industry?: string;
@ApiPropertyOptional({ maxLength: 255 })
@IsOptional()
@IsEmail()
@MaxLength(255)
email?: string;
@ApiPropertyOptional({ maxLength: 50 })
@IsOptional()
@IsString()
@MaxLength(50)
phone?: string;
@ApiPropertyOptional({ maxLength: 500 })
@IsOptional()
@IsUrl()
@MaxLength(500)
website?: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
street?: string;
@ApiPropertyOptional({ maxLength: 20 })
@IsOptional()
@IsString()
@MaxLength(20)
zip?: string;
@ApiPropertyOptional({ maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
city?: string;
@ApiPropertyOptional({ maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
state?: string;
@ApiPropertyOptional({ maxLength: 2, default: 'DE' })
@IsOptional()
@IsString()
@MaxLength(2)
country?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View file

@ -0,0 +1,40 @@
import { IsOptional, IsString, IsInt, Min, Max, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class QueryCompaniesDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 25, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@ApiPropertyOptional({ description: 'Suche in Name, Branche, E-Mail' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ description: 'Filter nach Branche' })
@IsOptional()
@IsString()
industry?: string;
@ApiPropertyOptional({ default: 'createdAt' })
@IsOptional()
@IsString()
sort?: string;
@ApiPropertyOptional({ default: 'desc', enum: ['asc', 'desc'] })
@IsOptional()
@IsIn(['asc', 'desc'])
order?: 'asc' | 'desc';
}

View file

@ -0,0 +1,88 @@
import {
IsString,
IsOptional,
IsBoolean,
IsEmail,
IsUrl,
IsArray,
MaxLength,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateCompanyDto {
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
industry?: string;
@ApiPropertyOptional({ maxLength: 255 })
@IsOptional()
@IsEmail()
@MaxLength(255)
email?: string;
@ApiPropertyOptional({ maxLength: 50 })
@IsOptional()
@IsString()
@MaxLength(50)
phone?: string;
@ApiPropertyOptional({ maxLength: 500 })
@IsOptional()
@IsUrl()
@MaxLength(500)
website?: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
street?: string;
@ApiPropertyOptional({ maxLength: 20 })
@IsOptional()
@IsString()
@MaxLength(20)
zip?: string;
@ApiPropertyOptional({ maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
city?: string;
@ApiPropertyOptional({ maxLength: 100 })
@IsOptional()
@IsString()
@MaxLength(100)
state?: string;
@ApiPropertyOptional({ maxLength: 2 })
@IsOptional()
@IsString()
@MaxLength(2)
country?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View file

@ -17,7 +17,9 @@ export class ContactsService {
type: dto.type,
firstName: dto.firstName,
lastName: dto.lastName,
companyId: dto.companyId,
companyName: dto.companyName,
position: dto.position,
email: dto.email,
phone: dto.phone,
mobile: dto.mobile,
@ -71,6 +73,9 @@ export class ContactsService {
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { [sortField]: query.order ?? 'desc' },
include: {
company: { select: { id: true, name: true, industry: true } },
},
}),
this.prisma.contact.count({ where }),
]);
@ -81,7 +86,10 @@ export class ContactsService {
async findOne(tenantId: string, id: string) {
const contact = await this.prisma.contact.findFirst({
where: { id, tenantId },
include: { activities: { orderBy: { createdAt: 'desc' }, take: 10 } },
include: {
company: { select: { id: true, name: true, industry: true, city: true, website: true } },
activities: { orderBy: { createdAt: 'desc' }, take: 10 },
},
});
if (!contact) {

View file

@ -4,6 +4,7 @@ import {
IsEnum,
IsBoolean,
IsArray,
IsUUID,
MaxLength,
IsEmail,
IsUrl,
@ -33,12 +34,23 @@ export class CreateContactDto {
@MaxLength(100)
lastName?: string;
@ApiPropertyOptional({ description: 'UUID des zugeordneten Unternehmens' })
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
companyName?: string;
@ApiPropertyOptional({ maxLength: 200, description: 'Position/Rolle im Unternehmen' })
@IsOptional()
@IsString()
@MaxLength(200)
position?: string;
@ApiPropertyOptional({ maxLength: 255 })
@IsOptional()
@IsEmail()

View file

@ -29,7 +29,7 @@ import {
singleResponse,
} from '../common/dto/pagination.dto';
@ApiTags('Deals')
@ApiTags('Vorgaenge (Deals)')
@ApiBearerAuth('access-token')
@UseGuards(TenantGuard)
@Controller('deals')
@ -38,7 +38,7 @@ export class DealsController {
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Deal erstellen' })
@ApiOperation({ summary: 'Vorgang erstellen' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateDealDto,
@ -52,7 +52,7 @@ export class DealsController {
}
@Get()
@ApiOperation({ summary: 'Deals auflisten (paginiert, filterbar)' })
@ApiOperation({ summary: 'Vorgaenge auflisten (paginiert, filterbar)' })
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: QueryDealsDto,
@ -67,7 +67,7 @@ export class DealsController {
}
@Get(':id')
@ApiOperation({ summary: 'Deal-Details abrufen' })
@ApiOperation({ summary: 'Vorgangsdetails abrufen' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async findOne(
@CurrentUser() user: JwtPayload,
@ -78,7 +78,7 @@ export class DealsController {
}
@Patch(':id')
@ApiOperation({ summary: 'Deal aktualisieren' })
@ApiOperation({ summary: 'Vorgang aktualisieren' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async update(
@CurrentUser() user: JwtPayload,
@ -95,7 +95,7 @@ export class DealsController {
}
@Delete(':id')
@ApiOperation({ summary: 'Deal loeschen' })
@ApiOperation({ summary: 'Vorgang loeschen' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
async remove(
@CurrentUser() user: JwtPayload,

View file

@ -35,12 +35,23 @@ export class DealsService {
}
}
// 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');
}
}
return 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',
@ -62,6 +73,12 @@ export class DealsService {
companyName: true,
},
},
company: {
select: {
id: true,
name: true,
},
},
},
});
}
@ -81,6 +98,9 @@ export class DealsService {
if (query.contactId) {
where.contactId = query.contactId;
}
if (query.companyId) {
where.companyId = query.companyId;
}
if (query.status) {
where.status = query.status;
}
@ -116,6 +136,12 @@ export class DealsService {
companyName: true,
},
},
company: {
select: {
id: true,
name: true,
},
},
},
}),
this.prisma.deal.count({ where }),
@ -131,11 +157,12 @@ export class DealsService {
pipeline: { include: { stages: { orderBy: { sortOrder: 'asc' } } } },
stage: true,
contact: true,
company: true,
},
});
if (!deal) {
throw new NotFoundException('Deal nicht gefunden');
throw new NotFoundException('Vorgang nicht gefunden');
}
return deal;

View file

@ -30,6 +30,11 @@ export class CreateDealDto {
@IsUUID()
contactId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Zugeordnetes Unternehmen' })
@IsOptional()
@IsUUID()
companyId?: string;
@ApiProperty({ maxLength: 500 })
@IsString()
@MaxLength(500)

View file

@ -19,6 +19,11 @@ export class QueryDealsDto extends PaginationDto {
@IsUUID()
contactId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter nach Unternehmen' })
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({ enum: DealStatus })
@IsOptional()
@IsEnum(DealStatus)