mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 04: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`*
|
*Bitte neue Eintraege unten anfuegen. Format: `## YYYY-MM-DD | Absender: Betreff`*
|
||||||
|
|
|
||||||
|
|
@ -579,3 +579,82 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-text-muted);
|
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 importAsContact = useImportLexwareAsContact();
|
||||||
const createContact = useCreateContact();
|
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
|
// Debounce search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
|
@ -227,13 +245,28 @@ function ImportTab() {
|
||||||
const roles = lexwareRoles(contact);
|
const roles = lexwareRoles(contact);
|
||||||
const contactPersons = contact.company?.contactPersons ?? [];
|
const contactPersons = contact.company?.contactPersons ?? [];
|
||||||
const isExpanded = expandedIds.has(contact.id);
|
const isExpanded = expandedIds.has(contact.id);
|
||||||
|
const linked = linkedMap.get(contact.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={contact.id} className={styles.resultCard}>
|
<div key={contact.id} className={`${styles.resultCard} ${linked ? styles.resultCardLinked : ''}`}>
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<div className={styles.resultCardMain}>
|
<div className={styles.resultCardMain}>
|
||||||
<div className={styles.resultInfo}>
|
<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}>
|
<div className={styles.resultMeta}>
|
||||||
{roles !== '—' && (
|
{roles !== '—' && (
|
||||||
<span className={styles.roleBadge}>{roles}</span>
|
<span className={styles.roleBadge}>{roles}</span>
|
||||||
|
|
@ -274,6 +307,8 @@ function ImportTab() {
|
||||||
{contactPersons.length} AP
|
{contactPersons.length} AP
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!linked ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
className={styles.importBtn}
|
className={styles.importBtn}
|
||||||
disabled={isImporting}
|
disabled={isImporting}
|
||||||
|
|
@ -316,6 +351,20 @@ function ImportTab() {
|
||||||
</svg>
|
</svg>
|
||||||
Kontakt
|
Kontakt
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={linked.type === 'company' ? `/crm/companies/${linked.id}` : `/crm/contacts/${linked.id}`}
|
||||||
|
className={styles.importBtnLinked}
|
||||||
|
title="Im CRM öffnen"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue