mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56:40 +02:00
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:
parent
ba4eec951a
commit
6e77bf43b0
3 changed files with 210 additions and 43 deletions
|
|
@ -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`*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue