INSIGHT-MVP/packages/crm-service/src/lexware/utils/lexware-mapper.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

481 lines
14 KiB
TypeScript

// ============================================================
// Mapping-Funktionen: Lexware Office <-> CRM
// ============================================================
import { VoucherType } from '.prisma/crm-client';
import {
LexwareContact,
LexwareVoucherDetail,
LexwareVoucherListItem,
} from '../interfaces/lexware-api.interfaces';
// --------------------------------------------------------
// Typen fuer Multi-Value Email/Phone Arrays
// --------------------------------------------------------
interface EmailEntry {
email: string;
type: 'WORK' | 'PERSONAL' | 'OTHER';
isPrimary: boolean;
}
interface PhoneEntry {
phone: string;
type: 'OFFICE' | 'MOBILE' | 'FAX';
isPrimary: boolean;
}
// --------------------------------------------------------
// Lexware Contact -> CRM Company
// --------------------------------------------------------
export function lexwareContactToCompanyData(lc: LexwareContact): {
name: string;
email?: string;
phone?: string;
website?: string;
street?: string;
zip?: string;
city?: string;
country?: string;
notes?: string;
emails: EmailEntry[];
phones: PhoneEntry[];
} {
const name =
lc.company?.name || [lc.person?.firstName, lc.person?.lastName].filter(Boolean).join(' ') || 'Unbekannt';
const billingAddr = lc.addresses?.billing?.[0];
const email = getFirstEmail(lc);
const phone = getFirstPhone(lc);
return {
name,
email: email || undefined,
phone: phone || undefined,
street: billingAddr?.street || undefined,
zip: billingAddr?.zip || undefined,
city: billingAddr?.city || undefined,
country: billingAddr?.countryCode || 'DE',
notes: lc.note || undefined,
emails: extractAllEmails(lc),
phones: extractAllPhones(lc),
};
}
// --------------------------------------------------------
// Lexware Contact -> CRM Contact
// --------------------------------------------------------
export function lexwareContactToContactData(lc: LexwareContact): {
firstName?: string;
lastName?: string;
companyName?: string;
position?: string;
email?: string;
phone?: string;
mobile?: string;
street?: string;
zip?: string;
city?: string;
country?: string;
notes?: string;
type: 'PERSON' | 'ORGANIZATION';
emails: EmailEntry[];
phones: PhoneEntry[];
} {
const isPerson = !!lc.person;
const billingAddr = lc.addresses?.billing?.[0];
const email = getFirstEmail(lc);
const phone = getFirstPhone(lc);
const mobile = lc.phoneNumbers?.mobile?.[0];
const emails = extractAllEmails(lc);
const phones = extractAllPhones(lc);
if (isPerson) {
return {
type: 'PERSON',
firstName: lc.person?.firstName || undefined,
lastName: lc.person?.lastName || 'Unbekannt',
companyName: lc.company?.name || undefined,
position: lc.company?.contactPersons?.[0]?.primary
? 'Primaerkontakt'
: undefined,
email: email || undefined,
phone: phone || undefined,
mobile: mobile || undefined,
street: billingAddr?.street || undefined,
zip: billingAddr?.zip || undefined,
city: billingAddr?.city || undefined,
country: billingAddr?.countryCode || 'DE',
notes: lc.note || undefined,
emails,
phones,
};
}
return {
type: 'ORGANIZATION',
firstName: lc.company?.contactPersons?.[0]?.firstName || undefined,
lastName:
lc.company?.contactPersons?.[0]?.lastName || lc.company?.name || 'Unbekannt',
companyName: lc.company?.name || undefined,
email: email || undefined,
phone: phone || undefined,
mobile: mobile || undefined,
street: billingAddr?.street || undefined,
zip: billingAddr?.zip || undefined,
city: billingAddr?.city || undefined,
country: billingAddr?.countryCode || 'DE',
notes: lc.note || undefined,
emails,
phones,
};
}
// --------------------------------------------------------
// CRM Company -> Lexware Contact Body (fuer POST/PUT)
// --------------------------------------------------------
export function companyToLexwareContactBody(company: {
name: string;
email?: string | null;
phone?: string | null;
street?: string | null;
zip?: string | null;
city?: string | null;
country?: string | null;
notes?: string | null;
emails?: Array<{ email: string; type: string }>;
phones?: Array<{ phone: string; type: string }>;
}): Record<string, unknown> {
const body: Record<string, unknown> = {
version: 0,
roles: { customer: {} },
company: { name: company.name },
};
if (company.street || company.zip || company.city) {
body.addresses = {
billing: [
{
street: company.street || undefined,
zip: company.zip || undefined,
city: company.city || undefined,
countryCode: company.country || 'DE',
},
],
};
}
// Multi-Value emails → Lexware categories
if (company.emails && company.emails.length > 0) {
const emailAddresses: Record<string, string[]> = {};
for (const e of company.emails) {
const category = emailTypeToLexwareCategory(e.type);
if (!emailAddresses[category]) emailAddresses[category] = [];
emailAddresses[category].push(e.email);
}
body.emailAddresses = emailAddresses;
} else if (company.email) {
body.emailAddresses = { business: [company.email] };
}
// Multi-Value phones → Lexware categories
if (company.phones && company.phones.length > 0) {
const phoneNumbers: Record<string, string[]> = {};
for (const p of company.phones) {
const category = phoneTypeToLexwareCategory(p.type);
if (!phoneNumbers[category]) phoneNumbers[category] = [];
phoneNumbers[category].push(p.phone);
}
body.phoneNumbers = phoneNumbers;
} else if (company.phone) {
body.phoneNumbers = { business: [company.phone] };
}
if (company.notes) {
body.note = company.notes.substring(0, 1000);
}
return body;
}
// --------------------------------------------------------
// CRM Contact -> Lexware Contact Body (fuer POST/PUT)
// --------------------------------------------------------
export function contactToLexwareContactBody(contact: {
firstName?: string | null;
lastName?: string | null;
companyName?: string | null;
email?: string | null;
phone?: string | null;
mobile?: string | null;
street?: string | null;
zip?: string | null;
city?: string | null;
country?: string | null;
notes?: string | null;
type: string;
emails?: Array<{ email: string; type: string }>;
phones?: Array<{ phone: string; type: string }>;
}): Record<string, unknown> {
const body: Record<string, unknown> = {
version: 0,
roles: { customer: {} },
};
if (contact.type === 'ORGANIZATION' && contact.companyName) {
body.company = { name: contact.companyName };
} else {
body.person = {
firstName: contact.firstName || undefined,
lastName: contact.lastName || 'Unbekannt',
};
}
if (contact.street || contact.zip || contact.city) {
body.addresses = {
billing: [
{
street: contact.street || undefined,
zip: contact.zip || undefined,
city: contact.city || undefined,
countryCode: contact.country || 'DE',
},
],
};
}
// Multi-Value emails → Lexware categories
if (contact.emails && contact.emails.length > 0) {
const emailAddresses: Record<string, string[]> = {};
for (const e of contact.emails) {
const category = emailTypeToLexwareCategory(e.type);
if (!emailAddresses[category]) emailAddresses[category] = [];
emailAddresses[category].push(e.email);
}
body.emailAddresses = emailAddresses;
} else if (contact.email) {
body.emailAddresses = { business: [contact.email] };
}
// Multi-Value phones → Lexware categories
if (contact.phones && contact.phones.length > 0) {
const phoneNumbers: Record<string, string[]> = {};
for (const p of contact.phones) {
const category = phoneTypeToLexwareCategory(p.type);
if (!phoneNumbers[category]) phoneNumbers[category] = [];
phoneNumbers[category].push(p.phone);
}
body.phoneNumbers = phoneNumbers;
} else {
const phoneNumbers: Record<string, string[]> = {};
if (contact.phone) phoneNumbers.business = [contact.phone];
if (contact.mobile) phoneNumbers.mobile = [contact.mobile];
if (Object.keys(phoneNumbers).length > 0) {
body.phoneNumbers = phoneNumbers;
}
}
if (contact.notes) {
body.note = contact.notes.substring(0, 1000);
}
return body;
}
// --------------------------------------------------------
// Voucher Type Mapping
// --------------------------------------------------------
export function voucherTypeFromLexware(lexwareType: string): VoucherType {
const map: Record<string, VoucherType> = {
invoice: 'INVOICE',
quotation: 'QUOTATION',
orderconfirmation: 'ORDER_CONFIRMATION',
creditnote: 'CREDIT_NOTE',
};
return map[lexwareType.toLowerCase()] || 'INVOICE';
}
export function voucherTypeToLexwareEndpoint(type: VoucherType): string {
const map: Record<VoucherType, string> = {
INVOICE: 'invoices',
QUOTATION: 'quotations',
ORDER_CONFIRMATION: 'order-confirmations',
CREDIT_NOTE: 'credit-notes',
};
return map[type];
}
// --------------------------------------------------------
// Deep Link Builder
// --------------------------------------------------------
export function buildLexwareDeepLink(
voucherType: VoucherType,
voucherId: string,
): string {
const typeMap: Record<VoucherType, string> = {
INVOICE: 'invoices',
QUOTATION: 'quotations',
ORDER_CONFIRMATION: 'order-confirmations',
CREDIT_NOTE: 'credit-notes',
};
const typePath = typeMap[voucherType];
return `https://app.lexware.de/permalink/${typePath}/view/${voucherId}`;
}
// --------------------------------------------------------
// Lexware Voucher Detail -> CRM Cache Daten
// --------------------------------------------------------
export function voucherDetailToCacheData(
detail: LexwareVoucherDetail,
voucherType: VoucherType,
lexwareContactId: string,
): {
voucherType: VoucherType;
voucherNumber: string | null;
voucherDate: Date | null;
voucherStatus: string | null;
totalGrossAmount: number | null;
totalNetAmount: number | null;
totalTaxAmount: number | null;
currency: string;
title: string | null;
lineItemsCount: number | null;
lineItemsJson: string | null;
lexwareContactId: string;
lexwareDeepLink: string;
} {
const lineItems = detail.lineItems?.map((li) => ({
name: li.name,
quantity: li.quantity,
unitName: li.unitName,
unitPrice: li.unitPrice?.grossAmount,
amount: li.lineItemAmount,
}));
return {
voucherType,
voucherNumber: detail.voucherNumber || null,
voucherDate: detail.voucherDate ? new Date(detail.voucherDate) : null,
voucherStatus: detail.voucherStatus || null,
totalGrossAmount: detail.totalPrice?.totalGrossAmount ?? null,
totalNetAmount: detail.totalPrice?.totalNetAmount ?? null,
totalTaxAmount: detail.totalPrice?.totalTaxAmount ?? null,
currency: detail.totalPrice?.currency || 'EUR',
title: detail.title || null,
lineItemsCount: detail.lineItems?.length ?? null,
lineItemsJson: lineItems ? JSON.stringify(lineItems) : null,
lexwareContactId,
lexwareDeepLink: buildLexwareDeepLink(voucherType, detail.id),
};
}
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
function getFirstEmail(lc: LexwareContact): string | undefined {
const emails = lc.emailAddresses;
if (!emails) return undefined;
return (
emails.business?.[0] ||
emails.office?.[0] ||
emails.private?.[0] ||
emails.other?.[0]
);
}
function getFirstPhone(lc: LexwareContact): string | undefined {
const phones = lc.phoneNumbers;
if (!phones) return undefined;
return (
phones.business?.[0] ||
phones.office?.[0] ||
phones.private?.[0] ||
phones.other?.[0]
);
}
/**
* Alle Lexware-Emails in Multi-Value Array konvertieren.
* Mapping: business/office → WORK, private → PERSONAL, other → OTHER
*/
function extractAllEmails(lc: LexwareContact): EmailEntry[] {
const result: EmailEntry[] = [];
const emails = lc.emailAddresses;
if (!emails) return result;
let isFirst = true;
const addEmails = (addresses: string[] | undefined, type: EmailEntry['type']) => {
if (!addresses) return;
for (const addr of addresses) {
result.push({ email: addr, type, isPrimary: isFirst });
isFirst = false;
}
};
addEmails(emails.business, 'WORK');
addEmails(emails.office, 'WORK');
addEmails(emails.private, 'PERSONAL');
addEmails(emails.other, 'OTHER');
return result;
}
/**
* Alle Lexware-Phones in Multi-Value Array konvertieren.
* Mapping: business/office → OFFICE, mobile → MOBILE, fax → FAX, private/other → OFFICE
*/
function extractAllPhones(lc: LexwareContact): PhoneEntry[] {
const result: PhoneEntry[] = [];
const phones = lc.phoneNumbers;
if (!phones) return result;
let isFirst = true;
const addPhones = (numbers: string[] | undefined, type: PhoneEntry['type']) => {
if (!numbers) return;
for (const num of numbers) {
result.push({ phone: num, type, isPrimary: isFirst });
isFirst = false;
}
};
addPhones(phones.business, 'OFFICE');
addPhones(phones.office, 'OFFICE');
addPhones(phones.mobile, 'MOBILE');
addPhones(phones.fax, 'FAX');
addPhones(phones.private, 'OFFICE');
addPhones(phones.other, 'OFFICE');
return result;
}
/**
* CRM EmailType → Lexware Kategorie
*/
function emailTypeToLexwareCategory(type: string): string {
switch (type) {
case 'WORK': return 'business';
case 'PERSONAL': return 'private';
case 'OTHER': return 'other';
default: return 'business';
}
}
/**
* CRM PhoneType → Lexware Kategorie
*/
function phoneTypeToLexwareCategory(type: string): string {
switch (type) {
case 'OFFICE': return 'business';
case 'MOBILE': return 'mobile';
case 'FAX': return 'fax';
default: return 'business';
}
}
// --------------------------------------------------------
// Lexware VoucherList Item -> Voucher Type
// --------------------------------------------------------
export function voucherListItemToType(
item: LexwareVoucherListItem,
): VoucherType {
return voucherTypeFromLexware(item.voucherType);
}