INSIGHT-MVP/packages/crm-service/src/owners/owners.service.ts
Thomas Reitz 48df3c3144 feat(crm): Phase 1 backend schema expansion + frontend integration
Backend (CRM-Expert Phase 1):
- New enums: ContactSource, EntityStatus, CompanySize, OwnerRole,
  LostReason, EmailType, PhoneType
- Contact: add linkedinUrl, birthday, source, department, status
- Company: add vatId, taxId, tradeRegisterNumber, registerCourt,
  companySize, deliveryAddress, dataEnrichedAt/Source, status
- Deal: add lostReason + lostReasonText (required when status=LOST)
- Multi-value emails/phones tables (contact_emails, contact_phones)
- Owner m:n model (contact_owners, company_owners, deal_owners)
- Redis Pub/Sub CRM events (crm.contact.created, crm.deal.won, etc.)
- Activity due_soon scheduler (cron every 15 min)
- SQL migration with data migration for existing records

Frontend integration:
- types.ts: all new enums, interfaces, label maps
- api.ts: owner CRUD endpoints (add/remove for contacts/companies/deals)
- hooks.ts: 6 new owner mutation hooks
- ContactFormModal: LinkedIn, birthday, source, department, status fields
- ContactDetailPage: display new fields (LinkedIn, department, birthday,
  source, status badge)
- CompanyDetailPage: display vatId, taxId, trade register, company size,
  delivery address, data enrichment info
- DealFormModal: lost reason dropdown + text (shown when status=LOST)
- DealDetailPage: display lost reason with label
- CompaniesPage: EntityStatus-aware status dots (ACTIVE/INACTIVE/BLOCKED)
- ActivityType: add FOLLOWUP to all label maps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:56:41 +01:00

166 lines
4.5 KiB
TypeScript

// ============================================================
// Shared Owner Service fuer Contact, Company, Deal
// ============================================================
import { Injectable, NotFoundException } from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { AddOwnerDto } from '../common/dto/owner.dto';
@Injectable()
export class OwnersService {
constructor(private readonly prisma: CrmPrismaService) {}
// --------------------------------------------------------
// Contact Owners
// --------------------------------------------------------
async addContactOwner(tenantId: string, contactId: string, dto: AddOwnerDto) {
// Contact existiert + gehoert zum Tenant
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
// Upsert: Falls Owner bereits existiert, Rolle aktualisieren
return this.prisma.contactOwner.upsert({
where: {
contactId_userId: { contactId, userId: dto.userId },
},
create: {
tenantId,
contactId,
userId: dto.userId,
role: dto.role ?? 'OWNER',
},
update: {
role: dto.role ?? 'OWNER',
},
});
}
async removeContactOwner(tenantId: string, contactId: string, userId: string) {
// Contact existiert + gehoert zum Tenant
const contact = await this.prisma.contact.findFirst({
where: { id: contactId, tenantId },
});
if (!contact) {
throw new NotFoundException('Kontakt nicht gefunden');
}
// Owner suchen
const owner = await this.prisma.contactOwner.findUnique({
where: {
contactId_userId: { contactId, userId },
},
});
if (!owner) {
throw new NotFoundException('Owner nicht gefunden');
}
return this.prisma.contactOwner.delete({
where: { id: owner.id },
});
}
// --------------------------------------------------------
// Company Owners
// --------------------------------------------------------
async addCompanyOwner(tenantId: string, companyId: string, dto: AddOwnerDto) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
return this.prisma.companyOwner.upsert({
where: {
companyId_userId: { companyId, userId: dto.userId },
},
create: {
tenantId,
companyId,
userId: dto.userId,
role: dto.role ?? 'OWNER',
},
update: {
role: dto.role ?? 'OWNER',
},
});
}
async removeCompanyOwner(tenantId: string, companyId: string, userId: string) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
const owner = await this.prisma.companyOwner.findUnique({
where: {
companyId_userId: { companyId, userId },
},
});
if (!owner) {
throw new NotFoundException('Owner nicht gefunden');
}
return this.prisma.companyOwner.delete({
where: { id: owner.id },
});
}
// --------------------------------------------------------
// Deal Owners
// --------------------------------------------------------
async addDealOwner(tenantId: string, dealId: string, dto: AddOwnerDto) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, tenantId },
});
if (!deal) {
throw new NotFoundException('Vorgang nicht gefunden');
}
return this.prisma.dealOwner.upsert({
where: {
dealId_userId: { dealId, userId: dto.userId },
},
create: {
tenantId,
dealId,
userId: dto.userId,
role: dto.role ?? 'OWNER',
},
update: {
role: dto.role ?? 'OWNER',
},
});
}
async removeDealOwner(tenantId: string, dealId: string, userId: string) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, tenantId },
});
if (!deal) {
throw new NotFoundException('Vorgang nicht gefunden');
}
const owner = await this.prisma.dealOwner.findUnique({
where: {
dealId_userId: { dealId, userId },
},
});
if (!owner) {
throw new NotFoundException('Owner nicht gefunden');
}
return this.prisma.dealOwner.delete({
where: { id: owner.id },
});
}
}