feat(crm): prevent duplicate Lexware imports — show linked status in import list

Import tab now loads all CRM companies/contacts and cross-references
lexwareContactId to detect already-imported entries. Linked contacts show
a green badge and "Öffnen" link instead of import buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-11 10:48:32 +01:00
parent ba4eec951a
commit 6e77bf43b0
3 changed files with 210 additions and 43 deletions

View file

@ -1013,4 +1013,43 @@ In `lexware-contacts.service.ts` Zeile 229 wird `tenantId` als **TypeScript-Typ
---
## 2026-03-11 | Backend: Fix — Lexware Import 500 (fehlende tenantId)
### Ursache
Der `TenantGuard` liess `PLATFORM_ADMIN`-User ohne `tenantId`-Pruefung durch:
```typescript
// ALT (fehlerhaft):
if (user?.role === 'PLATFORM_ADMIN') {
return true; // ← Kein tenantId-Check!
}
```
Wenn ein User mit Rolle `PLATFORM_ADMIN` keiner Tenant-Membership zugeordnet war (oder die Membership inaktiv), fehlte `tenantId` im JWT. Der Controller uebergab dann `user.tenantId!` = `undefined` an den Service, was zum Prisma-Validierungsfehler fuehrte.
### Fixes
**1. TenantGuard (`src/auth/guards/tenant.guard.ts`):**
- ALLE User (auch PLATFORM_ADMIN) muessen jetzt eine `tenantId` haben, um auf CRM-Ressourcen zuzugreifen
- Klare Fehlermeldung: "Kein Mandant zugeordnet. Bitte mit einem mandanten-gebundenen Account anmelden."
**2. Defensive Pruefung in Lexware-Service (`src/lexware/lexware-contacts.service.ts`):**
- `importAsCompany()` und `importAsContact()` pruefen zusaetzlich `if (!tenantId)` und werfen `BadRequestException` mit klarer Meldung
### Betroffene Dateien
| Datei | Aenderung |
|-------|-----------|
| `src/auth/guards/tenant.guard.ts` | PLATFORM_ADMIN Bypass entfernt, tenantId immer required |
| `src/lexware/lexware-contacts.service.ts` | Defensive tenantId-Pruefung in Import-Methoden |
### Auswirkung
- PLATFORM_ADMIN ohne Tenant-Zuordnung bekommt jetzt **403 Forbidden** statt **500 Internal Server Error**
- Alle anderen User sind nicht betroffen (hatten vorher schon den tenantId-Check)
- TypeScript-Check: 0 Fehler
---
*Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`*

View file

@ -579,3 +579,82 @@
font-size: 0.875rem;
color: var(--color-text-muted);
}
/* Linked (already imported) indicator */
.resultCardLinked {
background: rgba(5, 150, 105, 0.04);
border-radius: var(--radius-sm);
padding-left: 0.75rem;
padding-right: 0.75rem;
}
:global([data-theme='dark']) .resultCardLinked {
background: rgba(5, 150, 105, 0.08);
}
.linkedBadge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.5rem;
padding: 0.0625rem 0.5rem;
border-radius: 9999px;
background: #d1fae5;
color: #065f46;
font-size: 0.6875rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
transition: background 0.15s;
}
.linkedBadge:hover {
background: #a7f3d0;
color: #064e3b;
}
:global([data-theme='dark']) .linkedBadge {
background: #064e3b;
color: #a7f3d0;
}
:global([data-theme='dark']) .linkedBadge:hover {
background: #065f46;
color: #d1fae5;
}
.importBtnLinked {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid #a7f3d0;
border-radius: var(--radius-sm);
background: #ecfdf5;
color: #059669;
font-size: 0.8125rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.importBtnLinked:hover {
background: #d1fae5;
border-color: #059669;
color: #047857;
}
:global([data-theme='dark']) .importBtnLinked {
background: #064e3b;
border-color: #065f46;
color: #6ee7b7;
}
:global([data-theme='dark']) .importBtnLinked:hover {
background: #065f46;
border-color: #059669;
color: #a7f3d0;
}

View file

@ -74,6 +74,24 @@ function ImportTab() {
const importAsContact = useImportLexwareAsContact();
const createContact = useCreateContact();
// Load CRM entities to detect already-imported Lexware contacts
const companiesQuery = useCompanies({ page: 1, pageSize: 500, sort: 'name', order: 'asc' });
const contactsQuery = useContacts({ page: 1, pageSize: 500, sort: 'lastName', order: 'asc' });
// Build lookup: lexwareContactId → { type, name, id }
const linkedMap = new Map<string, { type: 'company' | 'contact'; name: string; id: string }>();
for (const c of companiesQuery.data?.data ?? []) {
if (c.lexwareContactId) {
linkedMap.set(c.lexwareContactId, { type: 'company', name: c.name, id: c.id });
}
}
for (const c of contactsQuery.data?.data ?? []) {
if (c.lexwareContactId) {
const name = [c.firstName, c.lastName].filter(Boolean).join(' ') || c.companyName || '—';
linkedMap.set(c.lexwareContactId, { type: 'contact', name, id: c.id });
}
}
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
@ -227,13 +245,28 @@ function ImportTab() {
const roles = lexwareRoles(contact);
const contactPersons = contact.company?.contactPersons ?? [];
const isExpanded = expandedIds.has(contact.id);
const linked = linkedMap.get(contact.id);
return (
<div key={contact.id} className={styles.resultCard}>
<div key={contact.id} className={`${styles.resultCard} ${linked ? styles.resultCardLinked : ''}`}>
{/* Main row */}
<div className={styles.resultCardMain}>
<div className={styles.resultInfo}>
<div className={styles.resultName}>{name}</div>
<div className={styles.resultName}>
{name}
{linked && (
<Link
to={linked.type === 'company' ? `/crm/companies/${linked.id}` : `/crm/contacts/${linked.id}`}
className={styles.linkedBadge}
title={`Bereits als ${linked.type === 'company' ? 'Unternehmen' : 'Kontakt'} importiert — Klicken zum Öffnen`}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8.5l3 3 7-7" />
</svg>
{linked.type === 'company' ? 'Unternehmen' : 'Kontakt'} im CRM
</Link>
)}
</div>
<div className={styles.resultMeta}>
{roles !== '—' && (
<span className={styles.roleBadge}>{roles}</span>
@ -274,48 +307,64 @@ function ImportTab() {
{contactPersons.length} AP
</button>
)}
<button
className={styles.importBtn}
disabled={isImporting}
onClick={() => handleImportAsCompany(contact.id, name)}
title="Als Unternehmen importieren"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
{!linked ? (
<>
<button
className={styles.importBtn}
disabled={isImporting}
onClick={() => handleImportAsCompany(contact.id, name)}
title="Als Unternehmen importieren"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="6" width="12" height="9" rx="1" />
<path d="M5 6V3a1 1 0 011-1h4a1 1 0 011 1v3" />
</svg>
Unternehmen
</button>
<button
className={styles.importBtn}
disabled={isImporting}
onClick={() => handleImportAsContact(contact.id, name)}
title="Als Kontakt importieren"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.5 2.5-4.5 6-4.5s6 2 6 4.5" />
</svg>
Kontakt
</button>
</>
) : (
<Link
to={linked.type === 'company' ? `/crm/companies/${linked.id}` : `/crm/contacts/${linked.id}`}
className={styles.importBtnLinked}
title="Im CRM öffnen"
>
<rect x="2" y="6" width="12" height="9" rx="1" />
<path d="M5 6V3a1 1 0 011-1h4a1 1 0 011 1v3" />
</svg>
Unternehmen
</button>
<button
className={styles.importBtn}
disabled={isImporting}
onClick={() => handleImportAsContact(contact.id, name)}
title="Als Kontakt importieren"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.5 2.5-4.5 6-4.5s6 2 6 4.5" />
</svg>
Kontakt
</button>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3H3v10h10v-3" />
<path d="M10 2h4v4M9 7l5-5" />
</svg>
Öffnen
</Link>
)}
</div>
</div>