feat(domains): Kunden, Kontakte aus AutoDNS mit Zuordnung und Domainliste
- Kunden mit Markierung eigene Firma, Kontakte live aus AutoDNS (seitenweise, 60 s Zwischenspeicher), Zuordnung je System - Kontakt anlegen (Person/Organisation) und einem oder mehreren Kunden zuordnen, nur mit Verwalten - Domainliste mit Kunde (ueber den Inhaber-Kontakt), Inhaber, Ablaufdatum, Status; Filter und Gruppierung nach Kunde - Mandantenschutz-Inventar um die beiden neuen Zugriffspaare ergaenzt Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseContacts, parseDomains } from './autodns-parse';
|
||||
import { TtlCache } from './domains-cache';
|
||||
|
||||
describe('parseContacts', () => {
|
||||
it('liest Organisation, Person, Alias und Rueckfall auf die Id', () => {
|
||||
const result = parseContacts([
|
||||
{
|
||||
id: 11,
|
||||
type: 'ORG',
|
||||
organization: 'Muster GmbH',
|
||||
fname: 'Max',
|
||||
lname: 'Muster',
|
||||
address: ['Musterstr. 1', 'Hinterhaus'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'info@example.com',
|
||||
phone: '+49 30 123456',
|
||||
alias: 'muster-1',
|
||||
},
|
||||
{ id: 12, type: 'PERSON', fname: 'Erika', lname: 'Muster' },
|
||||
{ id: 13, alias: 'nur-alias' },
|
||||
{ id: 14 },
|
||||
]);
|
||||
expect(result.map((c) => c.displayName)).toEqual([
|
||||
'Muster GmbH',
|
||||
'Erika Muster',
|
||||
'nur-alias',
|
||||
'#14',
|
||||
]);
|
||||
expect(result[0]).toEqual({
|
||||
id: 11,
|
||||
type: 'ORG',
|
||||
displayName: 'Muster GmbH',
|
||||
fname: 'Max',
|
||||
lname: 'Muster',
|
||||
organization: 'Muster GmbH',
|
||||
address: ['Musterstr. 1', 'Hinterhaus'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'info@example.com',
|
||||
phone: '+49 30 123456',
|
||||
alias: 'muster-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('wandelt eine Id als Zeichenkette in eine Zahl und ueberspringt Eintraege ohne Zahl', () => {
|
||||
const result = parseContacts([
|
||||
{ id: '123', lname: 'A' },
|
||||
{ id: 'abc', lname: 'B' },
|
||||
{ lname: 'C' },
|
||||
null,
|
||||
'x',
|
||||
{ id: -4 },
|
||||
]);
|
||||
expect(result.map((c) => c.id)).toEqual([123]);
|
||||
});
|
||||
|
||||
it('kuerzt lange Zeichenketten auf 200 Zeichen und akzeptiert Nicht-Listen', () => {
|
||||
const long = 'x'.repeat(500);
|
||||
expect(parseContacts([{ id: 1, organization: long }])[0].organization).toHaveLength(200);
|
||||
expect(parseContacts(undefined)).toEqual([]);
|
||||
expect(parseContacts({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDomains', () => {
|
||||
it('liest Name, Ablaufdatum, Status und Inhaber', () => {
|
||||
const result = parseDomains([
|
||||
{
|
||||
name: 'beispiel.de',
|
||||
expire: '2027-03-01T00:00:00.000+0100',
|
||||
registryStatus: 'ACTIVE',
|
||||
cancelationStatus: 'NONE',
|
||||
ownerc: { id: 11 },
|
||||
},
|
||||
{ name: 'zwei.de', ownerc: 22 },
|
||||
{ name: 'drei.de', expire: 'kein Datum', ownerc: '33' },
|
||||
{ name: 'vier.de' },
|
||||
{ expire: '2027-01-01' },
|
||||
]);
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]).toEqual({
|
||||
name: 'beispiel.de',
|
||||
expire: new Date('2027-03-01T00:00:00.000+0100').toISOString(),
|
||||
registryStatus: 'ACTIVE',
|
||||
cancelationStatus: 'NONE',
|
||||
ownerContactId: 11,
|
||||
});
|
||||
expect(result[1].ownerContactId).toBe(22);
|
||||
expect(result[2].expire).toBeNull();
|
||||
expect(result[2].ownerContactId).toBe(33);
|
||||
expect(result[3].ownerContactId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TtlCache', () => {
|
||||
it('trifft nach 59 s, verfehlt nach 61 s', () => {
|
||||
let now = 1_000_000;
|
||||
const cache = new TtlCache<string>(60_000, () => now);
|
||||
cache.set('a', 'wert');
|
||||
now += 59_000;
|
||||
expect(cache.get('a')).toBe('wert');
|
||||
now += 2_000;
|
||||
expect(cache.get('a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('set raeumt abgelaufene Eintraege weg', () => {
|
||||
let now = 0;
|
||||
const cache = new TtlCache<number>(60_000, () => now);
|
||||
cache.set('alt', 1);
|
||||
now += 61_000;
|
||||
cache.set('neu', 2);
|
||||
expect(cache.size).toBe(1);
|
||||
expect(cache.get('neu')).toBe(2);
|
||||
});
|
||||
|
||||
it('loescht nach Praefix', () => {
|
||||
const cache = new TtlCache<number>(60_000, () => 0);
|
||||
cache.set('t1|DEMO|1|contacts', 1);
|
||||
cache.set('t1|DEMO|1|domains', 2);
|
||||
cache.set('t2|DEMO|1|contacts', 3);
|
||||
cache.deleteByPrefix('t1|');
|
||||
expect(cache.get('t1|DEMO|1|contacts')).toBeUndefined();
|
||||
expect(cache.get('t1|DEMO|1|domains')).toBeUndefined();
|
||||
expect(cache.get('t2|DEMO|1|contacts')).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Reine Auswertung der AutoDNS-Antwortdaten (quick-261008-dts) — kein Nest,
|
||||
* kein Netz. Alles defensiv: unbekannte Felder werden ignoriert, Zeichenketten
|
||||
* auf 200 Zeichen gekuerzt, weil die Daten von einem Fremdsystem kommen und im
|
||||
* Browser angezeigt werden.
|
||||
*/
|
||||
|
||||
const MAX_TEXT = 200;
|
||||
|
||||
export interface ParsedContact {
|
||||
/** AutoDNS-Kontakt-Id als Zahl (die API gibt sie als Zahl weiter). */
|
||||
id: number;
|
||||
type: string | null;
|
||||
displayName: string;
|
||||
fname: string | null;
|
||||
lname: string | null;
|
||||
organization: string | null;
|
||||
address: string[];
|
||||
pcode: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedDomain {
|
||||
name: string;
|
||||
/** ISO-Zeichenkette oder null. */
|
||||
expire: string | null;
|
||||
registryStatus: string | null;
|
||||
cancelationStatus: string | null;
|
||||
ownerContactId: number | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : trimmed.slice(0, MAX_TEXT);
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ganzzahl aus Zahl oder Zeichenkette ('123'), sonst null. */
|
||||
export function toContactId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
||||
if (typeof value === 'string' && /^\d{1,15}$/.test(value.trim())) {
|
||||
const n = Number.parseInt(value.trim(), 10);
|
||||
return n > 0 ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addressLines(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(text).filter((l): l is string => l !== null);
|
||||
}
|
||||
const single = text(value);
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
export function parseContacts(data: unknown): ParsedContact[] {
|
||||
if (!Array.isArray(data)) return [];
|
||||
const result: ParsedContact[] = [];
|
||||
for (const entry of data) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const id = toContactId(entry.id);
|
||||
if (id === null) continue;
|
||||
const fname = text(entry.fname);
|
||||
const lname = text(entry.lname);
|
||||
const organization = text(entry.organization);
|
||||
const alias = text(entry.alias);
|
||||
const person = [fname, lname].filter(Boolean).join(' ');
|
||||
result.push({
|
||||
id,
|
||||
type: text(entry.type),
|
||||
displayName: organization ?? (person || (alias ?? `#${id}`)),
|
||||
fname,
|
||||
lname,
|
||||
organization,
|
||||
address: addressLines(entry.address),
|
||||
pcode: text(entry.pcode),
|
||||
city: text(entry.city),
|
||||
country: text(entry.country),
|
||||
email: text(entry.email),
|
||||
phone: text(entry.phone),
|
||||
alias,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isoOrNull(value: unknown): string | null {
|
||||
const raw = text(value);
|
||||
if (!raw) return null;
|
||||
const date = new Date(raw);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
export function parseDomains(data: unknown): ParsedDomain[] {
|
||||
if (!Array.isArray(data)) return [];
|
||||
const result: ParsedDomain[] = [];
|
||||
for (const entry of data) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const name = text(entry.name);
|
||||
if (!name) continue;
|
||||
const owner = entry.ownerc;
|
||||
result.push({
|
||||
name,
|
||||
expire: isoOrNull(entry.expire),
|
||||
registryStatus: text(entry.registryStatus),
|
||||
cancelationStatus: text(entry.cancelationStatus),
|
||||
ownerContactId: isRecord(owner) ? toContactId(owner.id) : toContactId(owner),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Kleiner Zwischenspeicher im Arbeitsspeicher (quick-261008-dts, D-I).
|
||||
*
|
||||
* Warum im Arbeitsspeicher: AutoDNS erlaubt nur 3 Anfragen pro Sekunde und IP,
|
||||
* die Listen werden aber bei jedem Oeffnen eines Reiters gebraucht. 60 Sekunden
|
||||
* reichen, um dieses Limit zu schuetzen, und es gibt keinen Zustand, der einen
|
||||
* Neustart ueberleben muss. Der Schluessel enthaelt Organisation, System und den
|
||||
* Aenderungsstand der Einstellungen (`DomainsConfig.updatedAt`): wer Zugang oder
|
||||
* System wechselt, sieht nie Daten des vorherigen Zugangs.
|
||||
*/
|
||||
export class TtlCache<T> {
|
||||
private readonly entries = new Map<string, { value: T; expiresAt: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly ttlMs: number,
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
) {}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= this.now()) {
|
||||
this.entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T): void {
|
||||
this.prune();
|
||||
this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs });
|
||||
}
|
||||
|
||||
/** Loescht alle Eintraege, deren Schluessel mit `prefix` beginnt. */
|
||||
deleteByPrefix(prefix: string): void {
|
||||
for (const key of [...this.entries.keys()]) {
|
||||
if (key.startsWith(prefix)) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private prune(): void {
|
||||
const now = this.now();
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.expiresAt <= now) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
|
||||
}));
|
||||
|
||||
vi.mock('./autodns-client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./autodns-client')>()),
|
||||
autodnsRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
import { autodnsRequest } from './autodns-client';
|
||||
import { DomainsDirectoryService } from './domains-directory.service';
|
||||
|
||||
const mockRequest = vi.mocked(autodnsRequest);
|
||||
|
||||
const CREDENTIALS = { environment: 'DEMO', user: 'api-user', password: 'geheim', context: 4 };
|
||||
|
||||
function success(data: unknown[], summary: number | null = null) {
|
||||
return {
|
||||
ok: true as const,
|
||||
httpStatus: 200,
|
||||
statusCode: 'S0301',
|
||||
statusType: 'SUCCESS',
|
||||
object: { type: 'contact', value: null, summary },
|
||||
data,
|
||||
messages: [],
|
||||
stid: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
isOwnCompany: boolean;
|
||||
}
|
||||
interface Assignment {
|
||||
tenantId: string;
|
||||
environment: 'DEMO' | 'LIVE';
|
||||
autodnsContactId: string;
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
function matches(row: Record<string, any>, where: Record<string, any> = {}): boolean {
|
||||
return Object.entries(where).every(([key, expected]) => {
|
||||
if (key === 'NOT') return !matches(row, expected);
|
||||
if (expected && typeof expected === 'object' && 'in' in expected) {
|
||||
return (expected.in as unknown[]).includes(row[key]);
|
||||
}
|
||||
return row[key] === expected;
|
||||
});
|
||||
}
|
||||
|
||||
function makeService(opts: { environment?: 'DEMO' | 'LIVE'; configVersion?: number } = {}) {
|
||||
const state = {
|
||||
customers: [] as Customer[],
|
||||
assignments: [] as Assignment[],
|
||||
environment: opts.environment ?? ('DEMO' as 'DEMO' | 'LIVE'),
|
||||
configVersion: opts.configVersion ?? 1,
|
||||
nextId: 1,
|
||||
uniqueNameConflict: false,
|
||||
};
|
||||
const customerApi = {
|
||||
findMany: vi.fn(async ({ where }: any) =>
|
||||
state.customers.filter((c) => matches(c, where)).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
),
|
||||
findFirst: vi.fn(
|
||||
async ({ where }: any) => state.customers.find((c) => matches(c, where)) ?? null,
|
||||
),
|
||||
create: vi.fn(async ({ data }: any) => {
|
||||
if (state.uniqueNameConflict) throw Object.assign(new Error('unique'), { code: 'P2002' });
|
||||
const row = { id: `c${state.nextId++}`, ...data };
|
||||
state.customers.push(row);
|
||||
return row;
|
||||
}),
|
||||
updateMany: vi.fn(async ({ where, data }: any) => {
|
||||
const rows = state.customers.filter((c) => matches(c, where));
|
||||
for (const r of rows) Object.assign(r, data);
|
||||
return { count: rows.length };
|
||||
}),
|
||||
deleteMany: vi.fn(async ({ where }: any) => {
|
||||
const before = state.customers.length;
|
||||
state.customers = state.customers.filter((c) => !matches(c, where));
|
||||
return { count: before - state.customers.length };
|
||||
}),
|
||||
};
|
||||
const assignmentApi = {
|
||||
findMany: vi.fn(async ({ where }: any) => state.assignments.filter((a) => matches(a, where))),
|
||||
count: vi.fn(
|
||||
async ({ where }: any) => state.assignments.filter((a) => matches(a, where)).length,
|
||||
),
|
||||
upsert: vi.fn(async ({ where, create, update }: any) => {
|
||||
const key = where.tenantId_environment_autodnsContactId;
|
||||
const row = state.assignments.find(
|
||||
(a) =>
|
||||
a.tenantId === key.tenantId &&
|
||||
a.environment === key.environment &&
|
||||
a.autodnsContactId === key.autodnsContactId,
|
||||
);
|
||||
if (row) Object.assign(row, update);
|
||||
else state.assignments.push({ ...create });
|
||||
}),
|
||||
deleteMany: vi.fn(async ({ where }: any) => {
|
||||
const before = state.assignments.length;
|
||||
state.assignments = state.assignments.filter((a) => !matches(a, where));
|
||||
return { count: before - state.assignments.length };
|
||||
}),
|
||||
};
|
||||
const db = {
|
||||
__bound: () => ({ domainsCustomer: customerApi, domainsContactAssignment: assignmentApi }),
|
||||
};
|
||||
const settings = {
|
||||
transport: {},
|
||||
getStatus: vi.fn(async () => ({ environment: state.environment })),
|
||||
getActiveCredentials: vi.fn(async () => ({
|
||||
environment: state.environment,
|
||||
credentials: { ...CREDENTIALS, environment: state.environment },
|
||||
configVersion: state.configVersion,
|
||||
})),
|
||||
};
|
||||
const service = new DomainsDirectoryService(db as any, settings as any);
|
||||
return { service, state, customerApi, assignmentApi, settings };
|
||||
}
|
||||
|
||||
function contactPage(from: number, count: number) {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: from + i, lname: `Name${from + i}` }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest.mockReset();
|
||||
});
|
||||
|
||||
describe('listContacts', () => {
|
||||
it('liest die erste Seite mit genau dem erwarteten Aufruf', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 1, lname: 'A' }], 1));
|
||||
const result = await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
const [credentials, method, path, options] = mockRequest.mock.calls[0];
|
||||
expect(credentials).toEqual(CREDENTIALS);
|
||||
expect(method).toBe('POST');
|
||||
expect(path).toBe('/contact/_search');
|
||||
expect((options as any).body).toEqual({
|
||||
filters: [],
|
||||
view: { limit: 100, offset: 0 },
|
||||
orders: [{ key: 'lname', type: 'ASC' }],
|
||||
});
|
||||
expect((options as any).keys).toBeUndefined();
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.environment).toBe('DEMO');
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('summary 250 -> drei Aufrufe mit Offset 0, 100, 200', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest
|
||||
.mockResolvedValueOnce(success(contactPage(1, 100), 250))
|
||||
.mockResolvedValueOnce(success(contactPage(101, 100), 250))
|
||||
.mockResolvedValueOnce(success(contactPage(201, 50), 250));
|
||||
const result = await service.listContacts('t1');
|
||||
expect(mockRequest.mock.calls.map((c) => (c[3] as any).body.view.offset)).toEqual([
|
||||
0, 100, 200,
|
||||
]);
|
||||
expect(result.contacts).toHaveLength(250);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('summary 5000 -> haelt bei 2000 Eintraegen an und meldet truncated', async () => {
|
||||
const { service } = makeService();
|
||||
let from = 1;
|
||||
mockRequest.mockImplementation(async () => {
|
||||
const page = success(contactPage(from, 100), 5000);
|
||||
from += 100;
|
||||
return page;
|
||||
});
|
||||
const result = await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(20);
|
||||
expect(result.contacts).toHaveLength(2000);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('nimmt die Kundenzuordnung nur aus dem AKTIVEN System', async () => {
|
||||
const { service, state } = makeService({ environment: 'DEMO' });
|
||||
state.customers.push({ id: 'c-demo', tenantId: 't1', name: 'Demo-Kunde', isOwnCompany: false });
|
||||
state.customers.push({ id: 'c-live', tenantId: 't1', name: 'Live-Kunde', isOwnCompany: false });
|
||||
state.assignments.push(
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '1', customerId: 'c-demo' },
|
||||
{ tenantId: 't1', environment: 'LIVE', autodnsContactId: '2', customerId: 'c-live' },
|
||||
{ tenantId: 't1', environment: 'LIVE', autodnsContactId: '1', customerId: 'c-live' },
|
||||
);
|
||||
mockRequest.mockResolvedValueOnce(
|
||||
success(
|
||||
[
|
||||
{ id: 1, lname: 'Eins' },
|
||||
{ id: 2, lname: 'Zwei' },
|
||||
],
|
||||
2,
|
||||
),
|
||||
);
|
||||
const { contacts } = await service.listContacts('t1');
|
||||
expect(contacts.map((c) => [c.id, c.customerId, c.customerName])).toEqual([
|
||||
[1, 'c-demo', 'Demo-Kunde'],
|
||||
[2, null, null],
|
||||
]);
|
||||
});
|
||||
|
||||
it('nutzt den Zwischenspeicher, refresh und neuer Einstellungsstand umgehen ihn', async () => {
|
||||
const { service, state } = makeService();
|
||||
mockRequest.mockResolvedValue(success([{ id: 1, lname: 'A' }], 1));
|
||||
await service.listContacts('t1');
|
||||
await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
await service.listContacts('t1', { refresh: true });
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
state.configVersion = 2;
|
||||
await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Anmeldefehler bei AutoDNS -> 502 autodnsAuth', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
kind: 'auth',
|
||||
httpStatus: 401,
|
||||
statusCode: null,
|
||||
messages: [],
|
||||
stid: null,
|
||||
});
|
||||
const error = await service.listContacts('t1').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(BadGatewayException);
|
||||
expect(error.getResponse().code).toBe('autodnsAuth');
|
||||
});
|
||||
|
||||
it('nicht eingerichtet -> 409 notConfigured, ohne Aufruf', async () => {
|
||||
const { service, settings } = makeService();
|
||||
settings.getActiveCredentials.mockRejectedValueOnce(
|
||||
new ConflictException({ code: 'notConfigured', message: 'x' }),
|
||||
);
|
||||
const error = await service.listContacts('t1').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getResponse().code).toBe('notConfigured');
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContact', () => {
|
||||
const person = {
|
||||
type: 'PERSON' as const,
|
||||
fname: 'Erika',
|
||||
lname: 'Muster',
|
||||
street: ['Musterstraße 1'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'erika@example.com',
|
||||
phone: '+49 30 123456',
|
||||
};
|
||||
|
||||
it('PERSON: genau ein POST /contact mit dem erwarteten Koerper, ohne organization', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 4711 }], 1));
|
||||
const created = await service.createContact('t1', person);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
const [, method, path, options] = mockRequest.mock.calls[0];
|
||||
expect(method).toBe('POST');
|
||||
expect(path).toBe('/contact');
|
||||
expect((options as any).body).toEqual({
|
||||
type: 'PERSON',
|
||||
fname: 'Erika',
|
||||
lname: 'Muster',
|
||||
address: ['Musterstraße 1'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'erika@example.com',
|
||||
phone: '+49 30 123456',
|
||||
});
|
||||
expect('organization' in (options as any).body).toBe(false);
|
||||
expect(created.id).toBe(4711);
|
||||
expect(created.displayName).toBe('Erika Muster');
|
||||
});
|
||||
|
||||
it('ORG verlangt die Organisation und sendet sie mit', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.createContact('t1', { ...person, type: 'ORG', organization: ' ' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 5 }], 1));
|
||||
const created = await service.createContact('t1', {
|
||||
...person,
|
||||
type: 'ORG',
|
||||
organization: ' Muster GmbH ',
|
||||
});
|
||||
expect((mockRequest.mock.calls[0][3] as any).body.organization).toBe('Muster GmbH');
|
||||
expect(created.displayName).toBe('Muster GmbH');
|
||||
});
|
||||
|
||||
it('fasst Leerraum in der Telefonnummer zusammen', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 9 }], 1));
|
||||
await service.createContact('t1', { ...person, phone: ' +49 30 123456 ' });
|
||||
expect((mockRequest.mock.calls[0][3] as any).body.phone).toBe('+49 30 123456');
|
||||
});
|
||||
|
||||
it('mit Kunde: legt die Zuordnung im aktiven System an', async () => {
|
||||
const { service, state } = makeService();
|
||||
state.customers.push({ id: 'c1', tenantId: 't1', name: 'Kunde A', isOwnCompany: false });
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 4711 }], 1));
|
||||
const created = await service.createContact('t1', { ...person, customerId: 'c1' });
|
||||
expect(state.assignments).toEqual([
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '4711', customerId: 'c1' },
|
||||
]);
|
||||
expect(created.customerName).toBe('Kunde A');
|
||||
expect(JSON.stringify(mockRequest.mock.calls[0][3])).not.toContain('Kunde A');
|
||||
});
|
||||
|
||||
it('unbekannter Kunde -> 404, bevor AutoDNS etwas anlegt', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.createContact('t1', { ...person, customerId: 'gibt-es-nicht' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leert den Zwischenspeicher der Organisation', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValue(success([{ id: 1, lname: 'A' }], 1));
|
||||
await service.listContacts('t1');
|
||||
await service.createContact('t1', person);
|
||||
mockRequest.mockClear();
|
||||
mockRequest.mockResolvedValue(success([{ id: 1, lname: 'A' }], 1));
|
||||
await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignContacts', () => {
|
||||
it('upsert je verschiedener Id im aktiven System', async () => {
|
||||
const { service, state, assignmentApi } = makeService();
|
||||
state.customers.push({ id: 'c1', tenantId: 't1', name: 'A', isOwnCompany: false });
|
||||
const result = await service.assignContacts('t1', { contactIds: [1, 2, 2], customerId: 'c1' });
|
||||
expect(result).toEqual({ assigned: 2, removed: 0 });
|
||||
expect(assignmentApi.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(state.assignments.map((a) => [a.environment, a.autodnsContactId, a.customerId])).toEqual(
|
||||
[
|
||||
['DEMO', '1', 'c1'],
|
||||
['DEMO', '2', 'c1'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('customerId null loest die Zuordnung nur im aktiven System', async () => {
|
||||
const { service, state } = makeService();
|
||||
state.assignments.push(
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '1', customerId: 'c1' },
|
||||
{ tenantId: 't1', environment: 'LIVE', autodnsContactId: '1', customerId: 'c1' },
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '3', customerId: 'c1' },
|
||||
);
|
||||
const result = await service.assignContacts('t1', { contactIds: [1, 2], customerId: null });
|
||||
expect(result).toEqual({ assigned: 0, removed: 1 });
|
||||
expect(state.assignments.map((a) => `${a.environment}:${a.autodnsContactId}`)).toEqual([
|
||||
'LIVE:1',
|
||||
'DEMO:3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fremder Kunde -> 404, mehr als 500 Kontakte -> 400', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.assignContacts('t1', { contactIds: [1], customerId: 'fremd' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
const many = Array.from({ length: 501 }, (_, i) => i + 1);
|
||||
await expect(
|
||||
service.assignContacts('t1', { contactIds: many, customerId: null }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kunden', () => {
|
||||
it('legt an, trimmt den Namen und lehnt doppelte Namen mit 409 ab', async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createCustomer('t1', { name: ' Kunde A ' });
|
||||
expect(created).toMatchObject({ name: 'Kunde A', isOwnCompany: false, contactCount: 0 });
|
||||
const error = await service.createCustomer('t1', { name: 'Kunde A' }).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getResponse().code).toBe('customerNameTaken');
|
||||
});
|
||||
|
||||
it('Unique-Verletzung beim Anlegen (Wettlauf) -> 409 customerNameTaken', async () => {
|
||||
const { service, state } = makeService();
|
||||
state.uniqueNameConflict = true;
|
||||
const error = await service.createCustomer('t1', { name: 'X' }).catch((e) => e);
|
||||
expect(error.getResponse().code).toBe('customerNameTaken');
|
||||
});
|
||||
|
||||
it('eigene Firma: loest die Markierung bei allen anderen', async () => {
|
||||
const { service, state } = makeService();
|
||||
await service.createCustomer('t1', { name: 'Erste', isOwnCompany: true });
|
||||
await service.createCustomer('t1', { name: 'Zweite', isOwnCompany: true });
|
||||
expect(state.customers.map((c) => [c.name, c.isOwnCompany])).toEqual([
|
||||
['Erste', false],
|
||||
['Zweite', true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('abgelehnter Name kostet keinem anderen Kunden die Markierung', async () => {
|
||||
const { service, state } = makeService();
|
||||
await service.createCustomer('t1', { name: 'Eigene', isOwnCompany: true });
|
||||
await service.createCustomer('t1', { name: 'Doppelt' });
|
||||
await expect(
|
||||
service.createCustomer('t1', { name: 'Doppelt', isOwnCompany: true }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(state.customers.find((c) => c.name === 'Eigene')?.isOwnCompany).toBe(true);
|
||||
});
|
||||
|
||||
it('aendern und loeschen fremder Ids -> 404', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(service.updateCustomer('t1', 'fremd', { name: 'X' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.deleteCustomer('t1', 'fremd')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('aendern: Name und Markierung', async () => {
|
||||
const { service, state } = makeService();
|
||||
const a = await service.createCustomer('t1', { name: 'A', isOwnCompany: true });
|
||||
const b = await service.createCustomer('t1', { name: 'B' });
|
||||
const updated = await service.updateCustomer('t1', b.id, { name: 'B2', isOwnCompany: true });
|
||||
expect(updated).toMatchObject({ id: b.id, name: 'B2', isOwnCompany: true });
|
||||
expect(state.customers.find((c) => c.id === a.id)?.isOwnCompany).toBe(false);
|
||||
});
|
||||
|
||||
it('loeschen mit Zuordnungen -> 409 customerInUse, sonst geloescht', async () => {
|
||||
const { service, state } = makeService();
|
||||
const a = await service.createCustomer('t1', { name: 'A' });
|
||||
state.assignments.push({
|
||||
tenantId: 't1',
|
||||
environment: 'LIVE',
|
||||
autodnsContactId: '7',
|
||||
customerId: a.id,
|
||||
});
|
||||
const error = await service.deleteCustomer('t1', a.id).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getResponse().code).toBe('customerInUse');
|
||||
state.assignments.length = 0;
|
||||
await expect(service.deleteCustomer('t1', a.id)).resolves.toEqual({ deleted: true });
|
||||
expect(state.customers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('listCustomers: Anzahl der Kontakte im aktiven System, nach Name sortiert', async () => {
|
||||
const { service, state } = makeService({ environment: 'DEMO' });
|
||||
const b = await service.createCustomer('t1', { name: 'B' });
|
||||
const a = await service.createCustomer('t1', { name: 'A', isOwnCompany: true });
|
||||
state.assignments.push(
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '1', customerId: a.id },
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '2', customerId: a.id },
|
||||
{ tenantId: 't1', environment: 'LIVE', autodnsContactId: '3', customerId: b.id },
|
||||
);
|
||||
const list = await service.listCustomers('t1');
|
||||
expect(list).toEqual([
|
||||
{ id: a.id, name: 'A', isOwnCompany: true, contactCount: 2 },
|
||||
{ id: b.id, name: 'B', isOwnCompany: false, contactCount: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listDomains', () => {
|
||||
it('liest /domain/_search mit keys und ordnet Inhaber und Kunde zu', async () => {
|
||||
const { service, state } = makeService();
|
||||
state.customers.push({ id: 'c1', tenantId: 't1', name: 'Kunde A', isOwnCompany: false });
|
||||
state.assignments.push({
|
||||
tenantId: 't1',
|
||||
environment: 'DEMO',
|
||||
autodnsContactId: '11',
|
||||
customerId: 'c1',
|
||||
});
|
||||
mockRequest.mockImplementation(async (_c, _m, path) => {
|
||||
if (path === '/domain/_search') {
|
||||
return success(
|
||||
[
|
||||
{
|
||||
name: 'beispiel.de',
|
||||
expire: '2027-03-01T00:00:00.000Z',
|
||||
registryStatus: 'ACTIVE',
|
||||
cancelationStatus: 'NONE',
|
||||
ownerc: { id: 11 },
|
||||
},
|
||||
{
|
||||
name: 'frei.de',
|
||||
registryStatus: 'PENDING',
|
||||
cancelationStatus: 'CANCELATION',
|
||||
ownerc: { id: 99 },
|
||||
},
|
||||
{ name: 'ohne-inhaber.de' },
|
||||
],
|
||||
3,
|
||||
);
|
||||
}
|
||||
return success([{ id: 11, organization: 'Muster GmbH' }], 1);
|
||||
});
|
||||
const result = await service.listDomains('t1');
|
||||
|
||||
const domainCall = mockRequest.mock.calls.find((c) => c[2] === '/domain/_search');
|
||||
expect(domainCall?.[1]).toBe('POST');
|
||||
expect((domainCall?.[3] as any).keys).toEqual(['expire', 'ownerc']);
|
||||
expect((domainCall?.[3] as any).body).toEqual({
|
||||
filters: [],
|
||||
view: { limit: 100, offset: 0 },
|
||||
orders: [{ key: 'name', type: 'ASC' }],
|
||||
});
|
||||
|
||||
expect(result.domains).toEqual([
|
||||
{
|
||||
name: 'beispiel.de',
|
||||
expire: '2027-03-01T00:00:00.000Z',
|
||||
status: 'ACTIVE',
|
||||
cancelationPending: false,
|
||||
ownerContactId: 11,
|
||||
ownerName: 'Muster GmbH',
|
||||
customerId: 'c1',
|
||||
customerName: 'Kunde A',
|
||||
},
|
||||
{
|
||||
name: 'frei.de',
|
||||
expire: null,
|
||||
status: 'PENDING',
|
||||
cancelationPending: true,
|
||||
ownerContactId: 99,
|
||||
ownerName: null,
|
||||
customerId: null,
|
||||
customerName: null,
|
||||
},
|
||||
{
|
||||
name: 'ohne-inhaber.de',
|
||||
expire: null,
|
||||
status: null,
|
||||
cancelationPending: false,
|
||||
ownerContactId: null,
|
||||
ownerName: null,
|
||||
customerId: null,
|
||||
customerName: null,
|
||||
},
|
||||
]);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('meldet truncated bei mehr als 2000 Domains', async () => {
|
||||
const { service } = makeService();
|
||||
let n = 0;
|
||||
mockRequest.mockImplementation(async (_c, _m, path) => {
|
||||
if (path === '/domain/_search') {
|
||||
const page = Array.from({ length: 100 }, () => ({ name: `d${n++}.de` }));
|
||||
return success(page, 2500);
|
||||
}
|
||||
return success([], 0);
|
||||
});
|
||||
const result = await service.listDomains('t1');
|
||||
expect(result.domains).toHaveLength(2000);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findContactsByIds', () => {
|
||||
it('liefert nur die gewuenschten Kontakte', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce(
|
||||
success(
|
||||
[
|
||||
{ id: 1, lname: 'A' },
|
||||
{ id: 2, lname: 'B' },
|
||||
{ id: 3, lname: 'C' },
|
||||
],
|
||||
3,
|
||||
),
|
||||
);
|
||||
const result = await service.findContactsByIds('t1', [1, 3]);
|
||||
expect(result.map((c) => c.id)).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,585 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import {
|
||||
type AutodnsCredentials,
|
||||
type AutodnsResult,
|
||||
type AutodnsSuccess,
|
||||
autodnsRequest,
|
||||
} from './autodns-client';
|
||||
import { type ParsedContact, parseContacts, parseDomains, toContactId } from './autodns-parse';
|
||||
import { autodnsFailureToHttp, type DomainsEnvironment } from './domains.types';
|
||||
import { TtlCache } from './domains-cache';
|
||||
import { DomainsSettingsService } from './domains-settings.service';
|
||||
import type { AssignDomainsContactsDto, CreateDomainsContactDto } from './dto/domains-contact.dto';
|
||||
import type { DomainsCustomerDto } from './dto/domains-customer.dto';
|
||||
|
||||
/** Seitengroesse und Obergrenze der Listen (D-I). */
|
||||
export const DIRECTORY_PAGE_SIZE = 100;
|
||||
export const DIRECTORY_MAX_ENTRIES = 2000;
|
||||
export const DIRECTORY_CACHE_TTL_MS = 60_000;
|
||||
export const MAX_ASSIGN_CONTACTS = 500;
|
||||
|
||||
export interface DomainsCustomerView {
|
||||
id: string;
|
||||
name: string;
|
||||
isOwnCompany: boolean;
|
||||
/** Zugeordnete Kontakte im AKTIVEN System. */
|
||||
contactCount: number;
|
||||
}
|
||||
|
||||
export interface DomainsContactView extends ParsedContact {
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
export interface DomainsContactListView {
|
||||
environment: DomainsEnvironment;
|
||||
contacts: DomainsContactView[];
|
||||
truncated: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface DomainsDomainView {
|
||||
name: string;
|
||||
expire: string | null;
|
||||
/** Rohwert von AutoDNS (`registryStatus`); die Beschriftung macht der Browser. */
|
||||
status: string | null;
|
||||
cancelationPending: boolean;
|
||||
ownerContactId: number | null;
|
||||
ownerName: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
export interface DomainsDomainListView {
|
||||
environment: DomainsEnvironment;
|
||||
domains: DomainsDomainView[];
|
||||
truncated: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
interface RawList<T> {
|
||||
items: T[];
|
||||
truncated: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
interface CustomerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
isOwnCompany: boolean;
|
||||
}
|
||||
|
||||
interface AssignmentRow {
|
||||
autodnsContactId: string;
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2002'
|
||||
);
|
||||
}
|
||||
|
||||
function isForeignKeyViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2003'
|
||||
);
|
||||
}
|
||||
|
||||
const CUSTOMER_NAME_TAKEN = {
|
||||
code: 'customerNameTaken',
|
||||
message: 'Es gibt bereits einen Kunden mit diesem Namen.',
|
||||
};
|
||||
const CUSTOMER_IN_USE = {
|
||||
code: 'customerInUse',
|
||||
message: 'Diesem Kunden sind noch Kontakte zugeordnet. Bitte lösen Sie zuerst die Zuordnungen.',
|
||||
};
|
||||
const CUSTOMER_NOT_FOUND = { code: 'customerNotFound', message: 'Der Kunde wurde nicht gefunden.' };
|
||||
|
||||
/** Kein `cancelationStatus` (oder "NONE") heisst: keine Kuendigung vorgemerkt. */
|
||||
function cancelationPending(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const upper = value.toUpperCase();
|
||||
return upper !== 'NONE' && upper !== 'NOT_SET';
|
||||
}
|
||||
|
||||
/**
|
||||
* Kunden, AutoDNS-Kontakte und Domainliste (quick-261008-dts).
|
||||
*
|
||||
* AutoDNS bleibt die Quelle der Wahrheit fuer Kontakte und Domains (D-B): sie
|
||||
* werden live gelesen und nie gespiegelt. Lokal liegt nur, was AutoDNS nicht
|
||||
* kennt — der Kunde und die Zuordnung Kontakt -> Kunde je System. Kontakte
|
||||
* werden hier angelegt und gelesen, aber nie geaendert oder geloescht (eine
|
||||
* Aenderung des Inhabers kann Domains beeinflussen, Recherche Pitfall 9).
|
||||
*
|
||||
* Gesamter Zugriff auf `domainsCustomer` und `domainsContactAssignment` liegt
|
||||
* ausschliesslich hier; jede Methode bindet ihren eigenen `forTenant`-Klienten.
|
||||
* Kein `include:`/relationales `select:` (Mandantenschutz-Inventar).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DomainsDirectoryService {
|
||||
private readonly contactCache = new TtlCache<RawList<ParsedContact>>(DIRECTORY_CACHE_TTL_MS);
|
||||
private readonly domainCache = new TtlCache<RawList<ReturnType<typeof parseDomains>[number]>>(
|
||||
DIRECTORY_CACHE_TTL_MS,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly settings: DomainsSettingsService,
|
||||
) {}
|
||||
|
||||
// --- AutoDNS-Aufruf ----------------------------------------------------------
|
||||
|
||||
private async call(
|
||||
credentials: AutodnsCredentials,
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
opts: { body?: unknown; keys?: string[] } = {},
|
||||
): Promise<AutodnsSuccess> {
|
||||
const result: AutodnsResult = await autodnsRequest(credentials, method, path, {
|
||||
...opts,
|
||||
fetchImpl: this.settings.transport?.fetchImpl,
|
||||
limiter: this.settings.transport?.limiter,
|
||||
});
|
||||
if (!result.ok) throw autodnsFailureToHttp(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine Liste seitenweise: nacheinander (der Begrenzer des Clients
|
||||
* taktet), nie parallel. Haelt bei `object.summary`, bei leerer Seite oder
|
||||
* bei 2000 Eintraegen an.
|
||||
*/
|
||||
private async fetchAll<T>(
|
||||
credentials: AutodnsCredentials,
|
||||
path: string,
|
||||
orderKey: string,
|
||||
parse: (data: unknown) => T[],
|
||||
keys?: string[],
|
||||
): Promise<{ items: T[]; truncated: boolean }> {
|
||||
const items: T[] = [];
|
||||
let total: number | null = null;
|
||||
let truncated = false;
|
||||
for (let offset = 0; ; offset += DIRECTORY_PAGE_SIZE) {
|
||||
if (offset >= DIRECTORY_MAX_ENTRIES) {
|
||||
truncated = total === null ? true : total > DIRECTORY_MAX_ENTRIES;
|
||||
break;
|
||||
}
|
||||
if (total !== null && offset >= total) break;
|
||||
const page = await this.call(credentials, 'POST', path, {
|
||||
body: {
|
||||
filters: [],
|
||||
view: { limit: DIRECTORY_PAGE_SIZE, offset },
|
||||
orders: [{ key: orderKey, type: 'ASC' }],
|
||||
},
|
||||
...(keys ? { keys } : {}),
|
||||
});
|
||||
if (total === null && page.object?.summary !== null && page.object?.summary !== undefined) {
|
||||
total = page.object.summary;
|
||||
}
|
||||
const parsed = parse(page.data);
|
||||
items.push(...parsed);
|
||||
if (page.data.length === 0) break;
|
||||
if (total === null && page.data.length < DIRECTORY_PAGE_SIZE) break;
|
||||
}
|
||||
if (items.length > DIRECTORY_MAX_ENTRIES) {
|
||||
items.length = DIRECTORY_MAX_ENTRIES;
|
||||
truncated = true;
|
||||
}
|
||||
return { items, truncated };
|
||||
}
|
||||
|
||||
// --- Zwischenspeicher ---------------------------------------------------------
|
||||
|
||||
private cacheKey(
|
||||
tenantId: string,
|
||||
environment: DomainsEnvironment,
|
||||
configVersion: number,
|
||||
kind: 'contacts' | 'domains',
|
||||
): string {
|
||||
return `${tenantId}|${environment}|${configVersion}|${kind}`;
|
||||
}
|
||||
|
||||
/** Verwirft die Listen der Organisation (nach Kontakt anlegen / Registrierung). */
|
||||
invalidate(tenantId: string): void {
|
||||
this.contactCache.deleteByPrefix(`${tenantId}|`);
|
||||
this.domainCache.deleteByPrefix(`${tenantId}|`);
|
||||
}
|
||||
|
||||
// --- Zuordnungen --------------------------------------------------------------
|
||||
|
||||
private async loadAssignments(
|
||||
tenantId: string,
|
||||
environment: DomainsEnvironment,
|
||||
): Promise<Map<number, string>> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const rows = (await tenantPrisma.domainsContactAssignment.findMany({
|
||||
where: { tenantId, environment },
|
||||
})) as AssignmentRow[];
|
||||
const map = new Map<number, string>();
|
||||
for (const row of rows) {
|
||||
const id = toContactId(row.autodnsContactId);
|
||||
if (id !== null) map.set(id, row.customerId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private async loadCustomers(tenantId: string): Promise<CustomerRow[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return (await tenantPrisma.domainsCustomer.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
})) as CustomerRow[];
|
||||
}
|
||||
|
||||
private async requireCustomer(tenantId: string, customerId: string): Promise<CustomerRow> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const customer = (await tenantPrisma.domainsCustomer.findFirst({
|
||||
where: { id: customerId, tenantId },
|
||||
})) as CustomerRow | null;
|
||||
if (!customer) throw new NotFoundException(CUSTOMER_NOT_FOUND);
|
||||
return customer;
|
||||
}
|
||||
|
||||
// --- Kunden -------------------------------------------------------------------
|
||||
|
||||
async listCustomers(tenantId: string): Promise<DomainsCustomerView[]> {
|
||||
const { environment } = await this.settings.getStatus(tenantId);
|
||||
const [customers, assignments] = await Promise.all([
|
||||
this.loadCustomers(tenantId),
|
||||
this.loadAssignments(tenantId, environment),
|
||||
]);
|
||||
const counts = new Map<string, number>();
|
||||
for (const customerId of assignments.values()) {
|
||||
counts.set(customerId, (counts.get(customerId) ?? 0) + 1);
|
||||
}
|
||||
return customers.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOwnCompany: c.isOwnCompany,
|
||||
contactCount: counts.get(c.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
private cleanCustomerName(dto: DomainsCustomerDto): string {
|
||||
const name = (dto.name ?? '').trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException({
|
||||
code: 'customerNameRequired',
|
||||
message: 'Bitte geben Sie einen Namen an.',
|
||||
});
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
async createCustomer(tenantId: string, dto: DomainsCustomerDto): Promise<DomainsCustomerView> {
|
||||
const name = this.cleanCustomerName(dto);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
// Namenspruefung VOR dem Loesen der Markierung "eigene Firma", damit ein
|
||||
// abgelehnter Name die Markierung eines anderen Kunden nicht kostet.
|
||||
const existing = await tenantPrisma.domainsCustomer.findFirst({ where: { tenantId, name } });
|
||||
if (existing) throw new ConflictException(CUSTOMER_NAME_TAKEN);
|
||||
|
||||
const isOwnCompany = dto.isOwnCompany === true;
|
||||
if (isOwnCompany) {
|
||||
await tenantPrisma.domainsCustomer.updateMany({
|
||||
where: { tenantId, isOwnCompany: true },
|
||||
data: { isOwnCompany: false },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const created = (await tenantPrisma.domainsCustomer.create({
|
||||
data: { tenantId, name, isOwnCompany },
|
||||
})) as CustomerRow;
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
isOwnCompany: created.isOwnCompany,
|
||||
contactCount: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new ConflictException(CUSTOMER_NAME_TAKEN);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCustomer(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: DomainsCustomerDto,
|
||||
): Promise<DomainsCustomerView> {
|
||||
const name = this.cleanCustomerName(dto);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const current = await this.requireCustomer(tenantId, id);
|
||||
|
||||
const isOwnCompany = dto.isOwnCompany === undefined ? current.isOwnCompany : dto.isOwnCompany;
|
||||
if (isOwnCompany && !current.isOwnCompany) {
|
||||
await tenantPrisma.domainsCustomer.updateMany({
|
||||
where: { tenantId, isOwnCompany: true, NOT: { id } },
|
||||
data: { isOwnCompany: false },
|
||||
});
|
||||
}
|
||||
let count: number;
|
||||
try {
|
||||
({ count } = await tenantPrisma.domainsCustomer.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { name, isOwnCompany },
|
||||
}));
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new ConflictException(CUSTOMER_NAME_TAKEN);
|
||||
throw error;
|
||||
}
|
||||
if (count === 0) throw new NotFoundException(CUSTOMER_NOT_FOUND);
|
||||
const { environment } = await this.settings.getStatus(tenantId);
|
||||
const assignments = await this.loadAssignments(tenantId, environment);
|
||||
let contactCount = 0;
|
||||
for (const customerId of assignments.values()) if (customerId === id) contactCount += 1;
|
||||
return { id, name, isOwnCompany, contactCount };
|
||||
}
|
||||
|
||||
async deleteCustomer(tenantId: string, id: string): Promise<{ deleted: true }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
await this.requireCustomer(tenantId, id);
|
||||
const inUse = await tenantPrisma.domainsContactAssignment.count({
|
||||
where: { tenantId, customerId: id },
|
||||
});
|
||||
if (inUse > 0) throw new ConflictException(CUSTOMER_IN_USE);
|
||||
try {
|
||||
const { count } = await tenantPrisma.domainsCustomer.deleteMany({ where: { id, tenantId } });
|
||||
if (count === 0) throw new NotFoundException(CUSTOMER_NOT_FOUND);
|
||||
} catch (error) {
|
||||
if (isForeignKeyViolation(error)) throw new ConflictException(CUSTOMER_IN_USE);
|
||||
throw error;
|
||||
}
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// --- Kontakte -----------------------------------------------------------------
|
||||
|
||||
private async loadRawContacts(
|
||||
tenantId: string,
|
||||
refresh: boolean,
|
||||
): Promise<{
|
||||
environment: DomainsEnvironment;
|
||||
raw: RawList<ParsedContact>;
|
||||
}> {
|
||||
const { environment, credentials, configVersion } =
|
||||
await this.settings.getActiveCredentials(tenantId);
|
||||
const key = this.cacheKey(tenantId, environment, configVersion, 'contacts');
|
||||
if (!refresh) {
|
||||
const hit = this.contactCache.get(key);
|
||||
if (hit) return { environment, raw: hit };
|
||||
}
|
||||
const { items, truncated } = await this.fetchAll(
|
||||
credentials,
|
||||
'/contact/_search',
|
||||
'lname',
|
||||
parseContacts,
|
||||
);
|
||||
const raw: RawList<ParsedContact> = {
|
||||
items,
|
||||
truncated,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
this.contactCache.set(key, raw);
|
||||
return { environment, raw };
|
||||
}
|
||||
|
||||
async listContacts(
|
||||
tenantId: string,
|
||||
opts: { refresh?: boolean } = {},
|
||||
): Promise<DomainsContactListView> {
|
||||
const { environment, raw } = await this.loadRawContacts(tenantId, opts.refresh === true);
|
||||
const [customers, assignments] = await Promise.all([
|
||||
this.loadCustomers(tenantId),
|
||||
this.loadAssignments(tenantId, environment),
|
||||
]);
|
||||
const names = new Map(customers.map((c) => [c.id, c.name]));
|
||||
return {
|
||||
environment,
|
||||
contacts: raw.items.map((contact) => {
|
||||
const customerId = assignments.get(contact.id) ?? null;
|
||||
return {
|
||||
...contact,
|
||||
customerId,
|
||||
customerName: customerId ? (names.get(customerId) ?? null) : null,
|
||||
};
|
||||
}),
|
||||
truncated: raw.truncated,
|
||||
fetchedAt: raw.fetchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fuer die Zusammenfassung einer Registrierung: Kontakte zu den gewaehlten Ids. */
|
||||
async findContactsByIds(tenantId: string, ids: number[]): Promise<DomainsContactView[]> {
|
||||
const wanted = new Set(ids);
|
||||
const { contacts } = await this.listContacts(tenantId);
|
||||
return contacts.filter((c) => wanted.has(c.id));
|
||||
}
|
||||
|
||||
async createContact(tenantId: string, dto: CreateDomainsContactDto): Promise<DomainsContactView> {
|
||||
const organization = (dto.organization ?? '').trim();
|
||||
if (dto.type === 'ORG' && !organization) {
|
||||
throw new BadRequestException({
|
||||
code: 'organizationRequired',
|
||||
message: 'Bitte geben Sie die Organisation an.',
|
||||
});
|
||||
}
|
||||
const { environment, credentials } = await this.settings.getActiveCredentials(tenantId);
|
||||
|
||||
// Unbekannter Kunde -> 404 BEVOR AutoDNS etwas anlegt.
|
||||
let customer: CustomerRow | null = null;
|
||||
if (dto.customerId) customer = await this.requireCustomer(tenantId, dto.customerId);
|
||||
|
||||
// Der Kunde gehoert nur Tessera — er wird nie an AutoDNS geschickt.
|
||||
const body: Record<string, unknown> = {
|
||||
type: dto.type,
|
||||
fname: dto.fname.trim(),
|
||||
lname: dto.lname.trim(),
|
||||
address: dto.street.map((line) => line.trim()),
|
||||
pcode: dto.pcode.trim(),
|
||||
city: dto.city.trim(),
|
||||
country: dto.country,
|
||||
email: dto.email.trim(),
|
||||
phone: dto.phone.trim().replace(/\s+/g, ' '),
|
||||
};
|
||||
if (dto.type === 'ORG') body.organization = organization;
|
||||
|
||||
const result = await this.call(credentials, 'POST', '/contact', { body });
|
||||
const first = result.data[0];
|
||||
const id =
|
||||
first && typeof first === 'object'
|
||||
? toContactId((first as Record<string, unknown>).id)
|
||||
: null;
|
||||
if (id === null) {
|
||||
// Nicht raten: der Kontakt kann angelegt worden sein.
|
||||
this.invalidate(tenantId);
|
||||
throw new ConflictException({
|
||||
code: 'contactIdMissing',
|
||||
message:
|
||||
'AutoDNS hat den Kontakt angenommen, aber keine Nummer geliefert. Bitte lesen Sie die Kontakte neu ein.',
|
||||
});
|
||||
}
|
||||
|
||||
if (customer) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
await tenantPrisma.domainsContactAssignment.upsert({
|
||||
where: {
|
||||
tenantId_environment_autodnsContactId: {
|
||||
tenantId,
|
||||
environment,
|
||||
autodnsContactId: String(id),
|
||||
},
|
||||
},
|
||||
create: { tenantId, environment, autodnsContactId: String(id), customerId: customer.id },
|
||||
update: { customerId: customer.id },
|
||||
});
|
||||
}
|
||||
this.invalidate(tenantId);
|
||||
|
||||
const [parsed] = parseContacts([{ ...body, id }]);
|
||||
return {
|
||||
...parsed,
|
||||
customerId: customer?.id ?? null,
|
||||
customerName: customer?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async assignContacts(
|
||||
tenantId: string,
|
||||
dto: AssignDomainsContactsDto,
|
||||
): Promise<{ assigned: number; removed: number }> {
|
||||
const ids = [...new Set(dto.contactIds)];
|
||||
if (ids.length > MAX_ASSIGN_CONTACTS) {
|
||||
throw new BadRequestException({
|
||||
code: 'tooManyContacts',
|
||||
message: `Es können höchstens ${MAX_ASSIGN_CONTACTS} Kontakte auf einmal zugeordnet werden.`,
|
||||
});
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'noContacts',
|
||||
message: 'Bitte wählen Sie mindestens einen Kontakt aus.',
|
||||
});
|
||||
}
|
||||
const { environment } = await this.settings.getStatus(tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
if (dto.customerId === null) {
|
||||
const { count } = await tenantPrisma.domainsContactAssignment.deleteMany({
|
||||
where: { tenantId, environment, autodnsContactId: { in: ids.map(String) } },
|
||||
});
|
||||
return { assigned: 0, removed: count };
|
||||
}
|
||||
|
||||
const customer = await this.requireCustomer(tenantId, dto.customerId);
|
||||
for (const id of ids) {
|
||||
await tenantPrisma.domainsContactAssignment.upsert({
|
||||
where: {
|
||||
tenantId_environment_autodnsContactId: {
|
||||
tenantId,
|
||||
environment,
|
||||
autodnsContactId: String(id),
|
||||
},
|
||||
},
|
||||
create: { tenantId, environment, autodnsContactId: String(id), customerId: customer.id },
|
||||
update: { customerId: customer.id },
|
||||
});
|
||||
}
|
||||
return { assigned: ids.length, removed: 0 };
|
||||
}
|
||||
|
||||
// --- Domains ------------------------------------------------------------------
|
||||
|
||||
async listDomains(
|
||||
tenantId: string,
|
||||
opts: { refresh?: boolean } = {},
|
||||
): Promise<DomainsDomainListView> {
|
||||
const refresh = opts.refresh === true;
|
||||
const { environment, credentials, configVersion } =
|
||||
await this.settings.getActiveCredentials(tenantId);
|
||||
const key = this.cacheKey(tenantId, environment, configVersion, 'domains');
|
||||
|
||||
let raw = refresh ? undefined : this.domainCache.get(key);
|
||||
if (!raw) {
|
||||
const { items, truncated } = await this.fetchAll(
|
||||
credentials,
|
||||
'/domain/_search',
|
||||
'name',
|
||||
parseDomains,
|
||||
['expire', 'ownerc'],
|
||||
);
|
||||
raw = { items, truncated, fetchedAt: new Date().toISOString() };
|
||||
this.domainCache.set(key, raw);
|
||||
}
|
||||
|
||||
const contacts = await this.listContacts(tenantId, { refresh });
|
||||
const byId = new Map(contacts.contacts.map((c) => [c.id, c]));
|
||||
return {
|
||||
environment,
|
||||
domains: raw.items.map((d) => {
|
||||
const owner = d.ownerContactId !== null ? byId.get(d.ownerContactId) : undefined;
|
||||
return {
|
||||
name: d.name,
|
||||
expire: d.expire,
|
||||
status: d.registryStatus,
|
||||
cancelationPending: cancelationPending(d.cancelationStatus),
|
||||
ownerContactId: d.ownerContactId,
|
||||
ownerName: owner?.displayName ?? null,
|
||||
customerId: owner?.customerId ?? null,
|
||||
customerName: owner?.customerName ?? null,
|
||||
};
|
||||
}),
|
||||
truncated: raw.truncated,
|
||||
fetchedAt: raw.fetchedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,31 @@ import { DomainsController } from './domains.controller';
|
||||
const proto = DomainsController.prototype as any;
|
||||
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||
|
||||
const MANAGE_HANDLERS = [
|
||||
'getSettings',
|
||||
'saveSettings',
|
||||
'testConnection',
|
||||
'createCustomer',
|
||||
'updateCustomer',
|
||||
'deleteCustomer',
|
||||
'createContact',
|
||||
'assignContacts',
|
||||
];
|
||||
const USE_HANDLERS = ['getStatus', 'listCustomers', 'listContacts', 'listDomains'];
|
||||
|
||||
function makeDirectory() {
|
||||
return {
|
||||
listCustomers: vi.fn(async (..._a: unknown[]) => []),
|
||||
createCustomer: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
updateCustomer: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
deleteCustomer: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
listContacts: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
createContact: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
assignContacts: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
listDomains: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
function makeSettings() {
|
||||
return {
|
||||
getStatus: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
@@ -23,16 +48,18 @@ describe('DomainsController — Metadaten', () => {
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, DomainsController)).toBe('domains');
|
||||
});
|
||||
|
||||
it('Einstellungen und Verbindungstest verlangen Verwalten, ohne Rollen-Decorator', () => {
|
||||
for (const name of ['getSettings', 'saveSettings', 'testConnection']) {
|
||||
it('Einstellungen, Verbindungstest, Kunden-/Kontakt-Schreibwege verlangen Verwalten, ohne Rollen-Decorator', () => {
|
||||
for (const name of MANAGE_HANDLERS) {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBe(true);
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('getStatus steht auf Benutzen-Ebene', () => {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.getStatus)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto.getStatus)).toBeUndefined();
|
||||
it('Lesewege stehen auf Benutzen-Ebene', () => {
|
||||
for (const name of USE_HANDLERS) {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('Pfade und Methoden', () => {
|
||||
@@ -45,6 +72,14 @@ describe('DomainsController — Metadaten', () => {
|
||||
expect(route('getSettings')).toEqual([0, 'settings']);
|
||||
expect(route('saveSettings')).toEqual([2, 'settings']);
|
||||
expect(route('testConnection')).toEqual([1, 'connection-test']);
|
||||
expect(route('listCustomers')).toEqual([0, 'customers']);
|
||||
expect(route('createCustomer')).toEqual([1, 'customers']);
|
||||
expect(route('listContacts')).toEqual([0, 'contacts']);
|
||||
expect(route('createContact')).toEqual([1, 'contacts']);
|
||||
expect(route('assignContacts')).toEqual([1, 'contacts/assign']);
|
||||
expect(route('listDomains')).toEqual([0, 'domains']);
|
||||
expect(route('updateCustomer')).toEqual([2, 'customers/:id']);
|
||||
expect(route('deleteCustomer')).toEqual([3, 'customers/:id']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +90,7 @@ describe('DomainsController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||
);
|
||||
const isIdRoute = (n: string) => String(Reflect.getMetadata('path', proto[n])).includes(':id');
|
||||
const firstId = names.findIndex(isIdRoute);
|
||||
if (firstId === -1) return;
|
||||
expect(firstId, 'es gibt :id-Routen').toBeGreaterThan(-1);
|
||||
names.slice(firstId).forEach((n) => {
|
||||
expect(isIdRoute(n), `${n} steht nach einer :id-Route, ist aber statisch`).toBe(true);
|
||||
});
|
||||
@@ -65,7 +100,8 @@ describe('DomainsController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||
describe('DomainsController — Verhalten', () => {
|
||||
it('reicht req.tenantId weiter', async () => {
|
||||
const settings = makeSettings();
|
||||
const c = new DomainsController(settings as any);
|
||||
const directory = makeDirectory();
|
||||
const c = new DomainsController(settings as any, directory as any);
|
||||
await c.getStatus(req('t1'));
|
||||
await c.getSettings(req('t1'));
|
||||
await c.saveSettings(req('t1'), { demoUser: 'x' } as any);
|
||||
@@ -76,8 +112,42 @@ describe('DomainsController — Verhalten', () => {
|
||||
expect(settings.testConnection).toHaveBeenCalledWith('t1', 'DEMO');
|
||||
});
|
||||
|
||||
it('Kunden-, Kontakt- und Domainwege reichen req.tenantId weiter; refresh nur bei 1/true', async () => {
|
||||
const settings = makeSettings();
|
||||
const directory = makeDirectory();
|
||||
const c = new DomainsController(settings as any, directory as any);
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
await c.listCustomers(req('t1'));
|
||||
await c.createCustomer(req('t1'), { name: 'A' } as any);
|
||||
await c.updateCustomer(req('t1'), id, { name: 'B' } as any);
|
||||
await c.deleteCustomer(req('t1'), id);
|
||||
await c.listContacts(req('t1'), '1');
|
||||
await c.listContacts(req('t1'), 'true');
|
||||
await c.listContacts(req('t1'), undefined);
|
||||
await c.listContacts(req('t1'), '0');
|
||||
await c.createContact(req('t1'), { type: 'PERSON' } as any);
|
||||
await c.assignContacts(req('t1'), { contactIds: [1], customerId: null });
|
||||
await c.listDomains(req('t1'), 'true');
|
||||
expect(directory.listCustomers).toHaveBeenCalledWith('t1');
|
||||
expect(directory.createCustomer).toHaveBeenCalledWith('t1', { name: 'A' });
|
||||
expect(directory.updateCustomer).toHaveBeenCalledWith('t1', id, { name: 'B' });
|
||||
expect(directory.deleteCustomer).toHaveBeenCalledWith('t1', id);
|
||||
expect(directory.listContacts.mock.calls.map((x) => x[1])).toEqual([
|
||||
{ refresh: true },
|
||||
{ refresh: true },
|
||||
{ refresh: false },
|
||||
{ refresh: false },
|
||||
]);
|
||||
expect(directory.createContact).toHaveBeenCalledWith('t1', { type: 'PERSON' });
|
||||
expect(directory.assignContacts).toHaveBeenCalledWith('t1', {
|
||||
contactIds: [1],
|
||||
customerId: null,
|
||||
});
|
||||
expect(directory.listDomains).toHaveBeenCalledWith('t1', { refresh: true });
|
||||
});
|
||||
|
||||
it('ohne Mandantenkontext 403', async () => {
|
||||
const c = new DomainsController(makeSettings() as any);
|
||||
const c = new DomainsController(makeSettings() as any, makeDirectory() as any);
|
||||
await expect(c.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Post, Put, Req } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||
import { DomainsDirectoryService } from './domains-directory.service';
|
||||
import { DomainsSettingsService } from './domains-settings.service';
|
||||
import { AssignDomainsContactsDto, CreateDomainsContactDto } from './dto/domains-contact.dto';
|
||||
import { DomainsCustomerDto } from './dto/domains-customer.dto';
|
||||
import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.dto';
|
||||
|
||||
/** `?refresh=1` oder `?refresh=true` umgeht den Zwischenspeicher. */
|
||||
function isTruthyFlag(value: string | undefined): boolean {
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* `@UseModule('domains')` auf Klassenebene — Aktivierung UND Freigabe.
|
||||
* `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query.
|
||||
@@ -28,7 +48,10 @@ import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-
|
||||
@Controller('modules/domains')
|
||||
@UseModule('domains')
|
||||
export class DomainsController {
|
||||
constructor(private readonly settings: DomainsSettingsService) {}
|
||||
constructor(
|
||||
private readonly settings: DomainsSettingsService,
|
||||
private readonly directory: DomainsDirectoryService,
|
||||
) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
@@ -60,4 +83,62 @@ export class DomainsController {
|
||||
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: TestDomainsConnectionDto) {
|
||||
return this.settings.testConnection(this.requireTenantId(req), dto.environment);
|
||||
}
|
||||
|
||||
// --- Kunden, Kontakte, Domains (Aufgabe 2) -----------------------------------
|
||||
// Alle statischen Routen vor den `:id`-Routen am Ende der Klasse.
|
||||
|
||||
@Get('customers')
|
||||
async listCustomers(@Req() req: AuthenticatedRequest) {
|
||||
return this.directory.listCustomers(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Post('customers')
|
||||
@ModuleManage('domains')
|
||||
async createCustomer(@Req() req: AuthenticatedRequest, @Body() dto: DomainsCustomerDto) {
|
||||
return this.directory.createCustomer(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Get('contacts')
|
||||
async listContacts(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) {
|
||||
return this.directory.listContacts(this.requireTenantId(req), {
|
||||
refresh: isTruthyFlag(refresh),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('contacts')
|
||||
@ModuleManage('domains')
|
||||
async createContact(@Req() req: AuthenticatedRequest, @Body() dto: CreateDomainsContactDto) {
|
||||
return this.directory.createContact(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Post('contacts/assign')
|
||||
@ModuleManage('domains')
|
||||
async assignContacts(@Req() req: AuthenticatedRequest, @Body() dto: AssignDomainsContactsDto) {
|
||||
return this.directory.assignContacts(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Get('domains')
|
||||
async listDomains(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) {
|
||||
return this.directory.listDomains(this.requireTenantId(req), {
|
||||
refresh: isTruthyFlag(refresh),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Routen mit :id (immer ganz am Ende) -------------------------------------
|
||||
|
||||
@Put('customers/:id')
|
||||
@ModuleManage('domains')
|
||||
async updateCustomer(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DomainsCustomerDto,
|
||||
) {
|
||||
return this.directory.updateCustomer(this.requireTenantId(req), id, dto);
|
||||
}
|
||||
|
||||
@Delete('customers/:id')
|
||||
@ModuleManage('domains')
|
||||
async deleteCustomer(@Req() req: AuthenticatedRequest, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.directory.deleteCustomer(this.requireTenantId(req), id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ModuleRegistryModule } from '../module-registry/module-registry.module'
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { DomainsController } from './domains.controller';
|
||||
import { seedDomainsModule } from './domains.seed';
|
||||
import { DomainsDirectoryService } from './domains-directory.service';
|
||||
import { DomainsSettingsService } from './domains-settings.service';
|
||||
|
||||
/**
|
||||
@@ -13,7 +14,7 @@ import { DomainsSettingsService } from './domains-settings.service';
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [DomainsController],
|
||||
providers: [DomainsSettingsService],
|
||||
providers: [DomainsSettingsService, DomainsDirectoryService],
|
||||
})
|
||||
export class DomainsModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(DomainsModule.name);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
/** Neuer AutoDNS-Kontakt (quick-261008-dts, L-03). Person oder Organisation. */
|
||||
export class CreateDomainsContactDto {
|
||||
@IsIn(['PERSON', 'ORG'])
|
||||
type!: 'PERSON' | 'ORG';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
organization?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(80)
|
||||
fname!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(80)
|
||||
lname!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(3)
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@MaxLength(100, { each: true })
|
||||
street!: string[];
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
pcode!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(80)
|
||||
city!: string;
|
||||
|
||||
@Matches(/^[A-Z]{2}$/)
|
||||
country!: string;
|
||||
|
||||
@IsEmail()
|
||||
@MaxLength(200)
|
||||
email!: string;
|
||||
|
||||
@Matches(/^\+[0-9][0-9 .\-/]{5,30}$/)
|
||||
phone!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
}
|
||||
|
||||
/** Ein oder mehrere Kontakte einem Kunden zuordnen (oder die Zuordnung loesen). */
|
||||
export class AssignDomainsContactsDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
contactIds!: number[];
|
||||
|
||||
/** `null` loest die Zuordnung. */
|
||||
@ValidateIf((_o, value) => value !== null)
|
||||
@IsUUID()
|
||||
customerId!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/** Anlegen und Aendern eines Kunden (quick-261008-dts, D-H). */
|
||||
export class DomainsCustomerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isOwnCompany?: boolean;
|
||||
}
|
||||
@@ -85,14 +85,25 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['getSettings', 'saveSettings', 'testConnection'])(
|
||||
'DomainsController.%s verlangt Verwalten für domains (quick-261008-dts)',
|
||||
(name) => {
|
||||
expectManage(DomainsController, name, 'domains');
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
'getSettings',
|
||||
'saveSettings',
|
||||
'testConnection',
|
||||
'createCustomer',
|
||||
'updateCustomer',
|
||||
'deleteCustomer',
|
||||
'createContact',
|
||||
'assignContacts',
|
||||
])('DomainsController.%s verlangt Verwalten für domains (quick-261008-dts)', (name) => {
|
||||
expectManage(DomainsController, name, 'domains');
|
||||
});
|
||||
|
||||
it.each(['getStatus'])('DomainsController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
it.each([
|
||||
'getStatus',
|
||||
'listCustomers',
|
||||
'listContacts',
|
||||
'listDomains',
|
||||
])('DomainsController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
const fn = handler(DomainsController, name);
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type DomainsCustomer, DomainsRequestError } from '@/lib/domains-api';
|
||||
import de from '@/messages/de.json';
|
||||
import { ContactForm, EMPTY_CONTACT, toCreateInput, validateContact } from './ContactForm';
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockCreateContact = vi.fn();
|
||||
vi.mock('@/lib/domains-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
|
||||
return { ...actual, createContact: (...args: unknown[]) => mockCreateContact(...args) };
|
||||
});
|
||||
|
||||
const CUSTOMERS: DomainsCustomer[] = [
|
||||
{ id: 'c-a', name: 'Alpha GmbH', isOwnCompany: false, contactCount: 0 },
|
||||
];
|
||||
|
||||
const VALID = {
|
||||
...EMPTY_CONTACT,
|
||||
fname: 'Erika',
|
||||
lname: 'Muster',
|
||||
street: 'Musterstraße 1',
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
email: 'erika@example.com',
|
||||
phone: '+49 30 123456',
|
||||
};
|
||||
|
||||
describe('validateContact', () => {
|
||||
it('Person: alle Pflichtfelder, Organisation nicht', () => {
|
||||
expect(Object.keys(validateContact(EMPTY_CONTACT)).sort()).toEqual(
|
||||
['city', 'email', 'fname', 'lname', 'pcode', 'phone', 'street'].sort(),
|
||||
);
|
||||
expect(validateContact(VALID)).toEqual({});
|
||||
});
|
||||
|
||||
it('Organisation verlangt zusaetzlich die Organisation', () => {
|
||||
expect(validateContact({ ...VALID, type: 'ORG' })).toEqual({ organization: 'required' });
|
||||
expect(validateContact({ ...VALID, type: 'ORG', organization: 'Muster GmbH' })).toEqual({});
|
||||
});
|
||||
|
||||
it('Telefon muss mit + beginnen, die E-Mail muss gueltig sein', () => {
|
||||
expect(validateContact({ ...VALID, phone: '030 123456' })).toEqual({ phone: 'phone' });
|
||||
expect(validateContact({ ...VALID, email: 'keine-adresse' })).toEqual({ email: 'email' });
|
||||
expect(validateContact({ ...VALID, phone: '+49' })).toEqual({ phone: 'phone' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCreateInput', () => {
|
||||
it('schneidet Leerraum ab, laesst leere Zeilen weg, sendet den Kunden nur wenn gewaehlt', () => {
|
||||
const person = toCreateInput({
|
||||
...VALID,
|
||||
fname: ' Erika ',
|
||||
street2: ' ',
|
||||
phone: ' +49 30 123456 ',
|
||||
organization: 'wird bei Person ignoriert',
|
||||
});
|
||||
expect(person).toEqual({
|
||||
type: 'PERSON',
|
||||
fname: 'Erika',
|
||||
lname: 'Muster',
|
||||
street: ['Musterstraße 1'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'erika@example.com',
|
||||
phone: '+49 30 123456',
|
||||
});
|
||||
expect(
|
||||
toCreateInput({
|
||||
...VALID,
|
||||
type: 'ORG',
|
||||
organization: ' Muster GmbH ',
|
||||
street2: 'Hof',
|
||||
customerId: 'c-a',
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: 'ORG',
|
||||
organization: 'Muster GmbH',
|
||||
street: ['Musterstraße 1', 'Hof'],
|
||||
customerId: 'c-a',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function fill(id: string, value: string) {
|
||||
fireEvent.change(document.getElementById(id) as HTMLInputElement, { target: { value } });
|
||||
}
|
||||
|
||||
function fillValid() {
|
||||
fill('contact-fname', 'Erika');
|
||||
fill('contact-lname', 'Muster');
|
||||
fill('contact-street', 'Musterstraße 1');
|
||||
fill('contact-pcode', '12345');
|
||||
fill('contact-city', 'Berlin');
|
||||
fill('contact-phone', '+49 30 123456');
|
||||
fill('contact-email', 'erika@example.com');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreateContact.mockReset();
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('ContactForm', () => {
|
||||
it('zeigt Pflichtfehler und sendet nichts, solange etwas fehlt', () => {
|
||||
const onCreated = vi.fn();
|
||||
render(<ContactForm customers={CUSTOMERS} onCreated={onCreated} onCancel={() => undefined} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(mockCreateContact).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByText('Bitte füllen Sie dieses Feld aus.').length).toBeGreaterThan(3);
|
||||
expect(screen.getByText('Internationale Schreibweise, z. B. +49 30 123456')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ungueltige E-Mail blockiert das Absenden', () => {
|
||||
render(<ContactForm customers={CUSTOMERS} onCreated={vi.fn()} onCancel={() => undefined} />);
|
||||
fillValid();
|
||||
fill('contact-email', 'ohne-at');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(mockCreateContact).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Bitte geben Sie eine gültige E-Mail-Adresse an.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Organisation ist erst bei "Firma oder Organisation" ein Feld und Pflicht', () => {
|
||||
render(<ContactForm customers={CUSTOMERS} onCreated={vi.fn()} onCancel={() => undefined} />);
|
||||
expect(document.getElementById('contact-organization')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Firma oder Organisation' }));
|
||||
expect(document.getElementById('contact-organization')).not.toBeNull();
|
||||
fillValid();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(mockCreateContact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ruft createContact einmal mit den getrimmten Werten und dem Kunden auf', async () => {
|
||||
const onCreated = vi.fn();
|
||||
mockCreateContact.mockResolvedValue({ id: 4711 });
|
||||
render(<ContactForm customers={CUSTOMERS} onCreated={onCreated} onCancel={() => undefined} />);
|
||||
fillValid();
|
||||
fill('contact-fname', ' Erika ');
|
||||
fireEvent.change(document.getElementById('contact-customer') as HTMLSelectElement, {
|
||||
target: { value: 'c-a' },
|
||||
});
|
||||
const submit = screen.getByRole('button', { name: 'Speichern' });
|
||||
fireEvent.click(submit);
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreateContact).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateContact).toHaveBeenCalledWith({
|
||||
type: 'PERSON',
|
||||
fname: 'Erika',
|
||||
lname: 'Muster',
|
||||
street: ['Musterstraße 1'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'erika@example.com',
|
||||
phone: '+49 30 123456',
|
||||
customerId: 'c-a',
|
||||
});
|
||||
});
|
||||
|
||||
it('sperrt den Knopf waehrend des Speicherns', async () => {
|
||||
let resolve: (v: unknown) => void = () => undefined;
|
||||
mockCreateContact.mockImplementation(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
render(<ContactForm customers={CUSTOMERS} onCreated={vi.fn()} onCancel={() => undefined} />);
|
||||
fillValid();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
const busy = await screen.findByRole('button', { name: 'Speichern …' });
|
||||
expect((busy as HTMLButtonElement).disabled).toBe(true);
|
||||
resolve({ id: 1 });
|
||||
});
|
||||
|
||||
it('zeigt den Fehlertext der API im Formular', async () => {
|
||||
mockCreateContact.mockRejectedValue(
|
||||
new DomainsRequestError(502, 'autodnsError', 'AutoDNS meldet: Ungültige Postleitzahl.'),
|
||||
);
|
||||
const onCreated = vi.fn();
|
||||
render(<ContactForm customers={CUSTOMERS} onCreated={onCreated} onCancel={() => undefined} />);
|
||||
fillValid();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByText('AutoDNS meldet: Ungültige Postleitzahl.')).toBeTruthy();
|
||||
expect(onCreated).not.toHaveBeenCalled();
|
||||
expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('Land: Deutschland ist vorgewaehlt, Namen kommen aus der Sprache', () => {
|
||||
render(<ContactForm customers={CUSTOMERS} onCreated={vi.fn()} onCancel={() => undefined} />);
|
||||
const select = document.getElementById('contact-country') as HTMLSelectElement;
|
||||
expect(select.value).toBe('DE');
|
||||
const labels = Array.from(select.options).map((o) => o.textContent);
|
||||
expect(labels).toContain('Deutschland');
|
||||
expect(labels).toContain('Österreich');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { type FormEvent, useMemo, useRef, useState } from 'react';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { INPUT_CLASS, PRIMARY_BUTTON, SECONDARY_BUTTON } from '@/components/domains/ui-classes';
|
||||
import {
|
||||
type CreateDomainsContactInput,
|
||||
createContact,
|
||||
type DomainsContact,
|
||||
type DomainsContactType,
|
||||
type DomainsCustomer,
|
||||
DomainsRequestError,
|
||||
} from '@/lib/domains-api';
|
||||
|
||||
/** Laender zur Auswahl: DACH, alle EU-Staaten, GB, NO, US. */
|
||||
export const CONTACT_COUNTRIES = [
|
||||
'DE',
|
||||
'AT',
|
||||
'CH',
|
||||
'BE',
|
||||
'BG',
|
||||
'HR',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DK',
|
||||
'EE',
|
||||
'FI',
|
||||
'FR',
|
||||
'GR',
|
||||
'HU',
|
||||
'IE',
|
||||
'IT',
|
||||
'LV',
|
||||
'LT',
|
||||
'LU',
|
||||
'MT',
|
||||
'NL',
|
||||
'PL',
|
||||
'PT',
|
||||
'RO',
|
||||
'SK',
|
||||
'SI',
|
||||
'ES',
|
||||
'SE',
|
||||
'GB',
|
||||
'NO',
|
||||
'US',
|
||||
] as const;
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
/** Gleiche Regel wie in der API (`CreateDomainsContactDto`). */
|
||||
const PHONE_PATTERN = /^\+[0-9][0-9 .\-/]{5,30}$/;
|
||||
|
||||
export interface ContactFormValues {
|
||||
type: DomainsContactType;
|
||||
organization: string;
|
||||
fname: string;
|
||||
lname: string;
|
||||
street: string;
|
||||
street2: string;
|
||||
pcode: string;
|
||||
city: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
export type ContactFormField = keyof ContactFormValues;
|
||||
export type ContactFormErrors = Partial<Record<ContactFormField, 'required' | 'email' | 'phone'>>;
|
||||
|
||||
export const EMPTY_CONTACT: ContactFormValues = {
|
||||
type: 'PERSON',
|
||||
organization: '',
|
||||
fname: '',
|
||||
lname: '',
|
||||
street: '',
|
||||
street2: '',
|
||||
pcode: '',
|
||||
city: '',
|
||||
country: 'DE',
|
||||
phone: '',
|
||||
email: '',
|
||||
customerId: '',
|
||||
};
|
||||
|
||||
/** Reine Pruefung (ohne React), damit sie einzeln testbar bleibt. */
|
||||
export function validateContact(values: ContactFormValues): ContactFormErrors {
|
||||
const errors: ContactFormErrors = {};
|
||||
const required: ContactFormField[] = [
|
||||
'fname',
|
||||
'lname',
|
||||
'street',
|
||||
'pcode',
|
||||
'city',
|
||||
'country',
|
||||
'email',
|
||||
'phone',
|
||||
];
|
||||
if (values.type === 'ORG') required.push('organization');
|
||||
for (const field of required) {
|
||||
if (values[field].trim() === '') errors[field] = 'required';
|
||||
}
|
||||
if (!errors.email && !EMAIL_PATTERN.test(values.email.trim())) errors.email = 'email';
|
||||
if (!errors.phone && !PHONE_PATTERN.test(values.phone.trim().replace(/\s+/g, ' '))) {
|
||||
errors.phone = 'phone';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function toCreateInput(values: ContactFormValues): CreateDomainsContactInput {
|
||||
const street = [values.street.trim(), values.street2.trim()].filter((line) => line !== '');
|
||||
return {
|
||||
type: values.type,
|
||||
...(values.type === 'ORG' ? { organization: values.organization.trim() } : {}),
|
||||
fname: values.fname.trim(),
|
||||
lname: values.lname.trim(),
|
||||
street,
|
||||
pcode: values.pcode.trim(),
|
||||
city: values.city.trim(),
|
||||
country: values.country,
|
||||
email: values.email.trim(),
|
||||
phone: values.phone.trim().replace(/\s+/g, ' '),
|
||||
...(values.customerId ? { customerId: values.customerId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular "Neuer Kontakt" (L-03): legt einen Person- oder Organisations-Kontakt
|
||||
* bei AutoDNS an und ordnet ihn optional gleich einem Kunden zu. Der Kunde
|
||||
* geht nie an AutoDNS — er wird nur in Tessera gespeichert.
|
||||
*/
|
||||
export function ContactForm({
|
||||
customers,
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
customers: DomainsCustomer[];
|
||||
onCreated: (contact: DomainsContact) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const t = useTranslations('domains.contactForm');
|
||||
const tc = useTranslations('domains.common');
|
||||
const locale = useLocale();
|
||||
const [values, setValues] = useState<ContactFormValues>(EMPTY_CONTACT);
|
||||
const [errors, setErrors] = useState<ContactFormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
// Sperre gegen einen zweiten Aufruf noch vor dem naechsten Render.
|
||||
const submitting = useRef(false);
|
||||
|
||||
const countries = useMemo(() => {
|
||||
const names = new Intl.DisplayNames([locale], { type: 'region' });
|
||||
return CONTACT_COUNTRIES.map((code) => ({ code, label: names.of(code) ?? code })).sort((a, b) =>
|
||||
a.label.localeCompare(b.label, locale),
|
||||
);
|
||||
}, [locale]);
|
||||
|
||||
const set = <K extends ContactFormField>(field: K, value: ContactFormValues[K]) => {
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
setErrors((prev) => {
|
||||
if (!prev[field]) return prev;
|
||||
const { [field]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitting.current) return;
|
||||
const found = validateContact(values);
|
||||
setErrors(found);
|
||||
if (Object.keys(found).length > 0) return;
|
||||
submitting.current = true;
|
||||
setSaving(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const created = await createContact(toCreateInput(values));
|
||||
onCreated(created);
|
||||
} catch (e) {
|
||||
setApiError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const errorText = (field: ContactFormField) => {
|
||||
const kind = errors[field];
|
||||
return kind ? t(`errors.${kind}`) : null;
|
||||
};
|
||||
|
||||
const field = (
|
||||
name: ContactFormField,
|
||||
label: string,
|
||||
opts: { type?: string; autoComplete?: string; hint?: string; optional?: boolean } = {},
|
||||
) => (
|
||||
<div>
|
||||
<label htmlFor={`contact-${name}`} className="mb-1 block text-xs text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={`contact-${name}`}
|
||||
type={opts.type ?? 'text'}
|
||||
autoComplete={opts.autoComplete ?? 'off'}
|
||||
className={INPUT_CLASS}
|
||||
value={values[name]}
|
||||
aria-invalid={errors[name] ? true : undefined}
|
||||
aria-describedby={errors[name] ? `contact-${name}-error` : undefined}
|
||||
onChange={(e) => set(name, e.target.value as ContactFormValues[typeof name])}
|
||||
/>
|
||||
{opts.hint && <p className="mt-1 text-xs text-muted-foreground">{opts.hint}</p>}
|
||||
{errors[name] && (
|
||||
<p id={`contact-${name}-error`} className="mt-1 text-xs text-destructive">
|
||||
{errorText(name)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} noValidate>
|
||||
<SettingsSection
|
||||
id="domains-contact-form"
|
||||
title={t('title')}
|
||||
footer={
|
||||
<>
|
||||
{apiError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{apiError}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" className={SECONDARY_BUTTON} disabled={saving} onClick={onCancel}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button type="submit" className={PRIMARY_BUTTON} disabled={saving}>
|
||||
{saving ? t('saving') : t('save')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<fieldset className="flex flex-wrap gap-6">
|
||||
<legend className="mb-1 text-xs text-muted-foreground">{t('type')}</legend>
|
||||
{(['PERSON', 'ORG'] as const).map((type) => (
|
||||
<label key={type} className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="contact-type"
|
||||
value={type}
|
||||
checked={values.type === type}
|
||||
onChange={() => set('type', type)}
|
||||
/>
|
||||
{type === 'PERSON' ? t('person') : t('org')}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{values.type === 'ORG' && field('organization', t('organization'))}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{field('fname', t('fname'), { autoComplete: 'given-name' })}
|
||||
{field('lname', t('lname'), { autoComplete: 'family-name' })}
|
||||
</div>
|
||||
{field('street', t('street'), { autoComplete: 'address-line1' })}
|
||||
{field('street2', t('street2'), { autoComplete: 'address-line2' })}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{field('pcode', t('pcode'), { autoComplete: 'postal-code' })}
|
||||
<div className="sm:col-span-2">
|
||||
{field('city', t('city'), { autoComplete: 'address-level2' })}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-country" className="mb-1 block text-xs text-muted-foreground">
|
||||
{t('country')}
|
||||
</label>
|
||||
<select
|
||||
id="contact-country"
|
||||
className={INPUT_CLASS}
|
||||
value={values.country}
|
||||
onChange={(e) => set('country', e.target.value)}
|
||||
>
|
||||
{countries.map((c) => (
|
||||
<option key={c.code} value={c.code}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{field('phone', t('phone'), { type: 'tel', autoComplete: 'tel', hint: t('phoneHint') })}
|
||||
{field('email', t('email'), { type: 'email', autoComplete: 'email' })}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contact-customer" className="mb-1 block text-xs text-muted-foreground">
|
||||
{t('customer')}
|
||||
</label>
|
||||
<select
|
||||
id="contact-customer"
|
||||
className={INPUT_CLASS}
|
||||
value={values.customerId}
|
||||
onChange={(e) => set('customerId', e.target.value)}
|
||||
>
|
||||
<option value="">{t('noCustomer')}</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import {
|
||||
ALL_CUSTOMERS,
|
||||
filterByCustomer,
|
||||
groupByCustomer,
|
||||
matchesText,
|
||||
UNASSIGNED,
|
||||
} from '@/components/domains/group-by-customer';
|
||||
import {
|
||||
INPUT_CLASS,
|
||||
PRIMARY_BUTTON,
|
||||
SECONDARY_BUTTON,
|
||||
TABLE_CELL,
|
||||
TABLE_HEAD_CELL,
|
||||
TABLE_HEAD_CLASS,
|
||||
} from '@/components/domains/ui-classes';
|
||||
import {
|
||||
assignContacts,
|
||||
type DomainsContact,
|
||||
type DomainsContactList,
|
||||
type DomainsCustomer,
|
||||
DomainsRequestError,
|
||||
listContacts,
|
||||
} from '@/lib/domains-api';
|
||||
import { ContactForm } from './ContactForm';
|
||||
|
||||
/** Wert im Zuordnungs-Auswahlfeld fuer "Zuordnung entfernen". */
|
||||
const REMOVE_ASSIGNMENT = '__remove__';
|
||||
|
||||
function ContactTable({
|
||||
rows,
|
||||
canManage,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
rows: DomainsContact[];
|
||||
canManage: boolean;
|
||||
selected: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
}) {
|
||||
const t = useTranslations('domains.contactList');
|
||||
const tc = useTranslations('domains.common');
|
||||
return (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className={TABLE_HEAD_CLASS}>
|
||||
<tr>
|
||||
{canManage && <th className={`${TABLE_HEAD_CELL} w-10`} />}
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.name')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.organization')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.city')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.email')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.customer')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((contact) => {
|
||||
const person = [contact.fname, contact.lname].filter(Boolean).join(' ');
|
||||
return (
|
||||
<tr key={contact.id} className="border-t border-border">
|
||||
{canManage && (
|
||||
<td className={TABLE_CELL}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('selectContact', { name: contact.displayName })}
|
||||
checked={selected.has(contact.id)}
|
||||
onChange={() => onToggle(contact.id)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className={`${TABLE_CELL} font-medium text-foreground`}>
|
||||
{person || contact.displayName}
|
||||
</td>
|
||||
<td className={TABLE_CELL}>{contact.organization ?? '–'}</td>
|
||||
<td className={TABLE_CELL}>{contact.city ?? '–'}</td>
|
||||
<td className={TABLE_CELL}>{contact.email ?? '–'}</td>
|
||||
<td className={TABLE_CELL}>{contact.customerName ?? tc('unassigned')}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter "Kontakte" (L-03, D-G): die Kontakte des aktiven AutoDNS-Systems,
|
||||
* live gelesen. Bereits vorhandene AutoDNS-Kontakte erscheinen hier von selbst
|
||||
* und lassen sich einem Kunden zuordnen. Anlegen und Zuordnen nur mit
|
||||
* "Verwalten"; bindend ist allein die API.
|
||||
*/
|
||||
export function ContactsTab({
|
||||
customers,
|
||||
canManage,
|
||||
onCustomersChanged,
|
||||
}: {
|
||||
customers: DomainsCustomer[];
|
||||
canManage: boolean;
|
||||
onCustomersChanged: () => void;
|
||||
}) {
|
||||
const t = useTranslations('domains.contactList');
|
||||
const tc = useTranslations('domains.common');
|
||||
const [data, setData] = useState<DomainsContactList | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<string>(ALL_CUSTOMERS);
|
||||
const [grouped, setGrouped] = useState(true);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [target, setTarget] = useState('');
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = useCallback(
|
||||
async (refresh: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await listContacts({ refresh }));
|
||||
} catch (e) {
|
||||
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[tc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, [load]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return filterByCustomer(data.contacts, filter).filter((c) =>
|
||||
matchesText(c, search, [
|
||||
(x) => x.displayName,
|
||||
(x) => x.organization,
|
||||
(x) => x.city,
|
||||
(x) => x.email,
|
||||
(x) => x.customerName,
|
||||
]),
|
||||
);
|
||||
}, [data, filter, search]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => (grouped ? groupByCustomer(visible, customers) : []),
|
||||
[grouped, visible, customers],
|
||||
);
|
||||
|
||||
const toggle = (id: number) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const allVisibleSelected = visible.length > 0 && visible.every((c) => selected.has(c.id));
|
||||
const toggleAllVisible = () =>
|
||||
setSelected(allVisibleSelected ? new Set() : new Set(visible.map((c) => c.id)));
|
||||
|
||||
const assign = async () => {
|
||||
if (selected.size === 0 || !target) return;
|
||||
setAssigning(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
await assignContacts([...selected], target === REMOVE_ASSIGNMENT ? null : target);
|
||||
setSelected(new Set());
|
||||
setTarget('');
|
||||
setNotice(t('assigned'));
|
||||
await load(false);
|
||||
onCustomersChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const created = async () => {
|
||||
setCreating(false);
|
||||
setNotice(t('created'));
|
||||
await load(false);
|
||||
onCustomersChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('intro')}</p>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-48 flex-1">
|
||||
<label htmlFor="contacts-search" className="mb-1 block text-xs text-muted-foreground">
|
||||
{t('search')}
|
||||
</label>
|
||||
<input
|
||||
id="contacts-search"
|
||||
type="search"
|
||||
className={INPUT_CLASS}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="contacts-filter" className="mb-1 block text-xs text-muted-foreground">
|
||||
{tc('customerFilter')}
|
||||
</label>
|
||||
<select
|
||||
id="contacts-filter"
|
||||
className={INPUT_CLASS}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
<option value={ALL_CUSTOMERS}>{tc('allCustomers')}</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={UNASSIGNED}>{tc('unassigned')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 pb-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
|
||||
{tc('groupByCustomer')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={loading}
|
||||
onClick={() => void load(true)}
|
||||
>
|
||||
{t('refresh')}
|
||||
</button>
|
||||
{canManage && !creating && (
|
||||
<button type="button" className={PRIMARY_BUTTON} onClick={() => setCreating(true)}>
|
||||
{t('newContact')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && creating && (
|
||||
<ContactForm
|
||||
customers={customers}
|
||||
onCreated={() => void created()}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-muted/30 p-3">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={allVisibleSelected} onChange={toggleAllVisible} />
|
||||
{t('selectAllVisible')}
|
||||
</label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('selectedCount', { count: selected.size })}
|
||||
</span>
|
||||
<label htmlFor="contacts-assign-target" className="text-sm font-medium text-foreground">
|
||||
{t('assignLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="contacts-assign-target"
|
||||
className={`${INPUT_CLASS} w-auto`}
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
>
|
||||
<option value="">{t('assignPlaceholder')}</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={REMOVE_ASSIGNMENT}>{t('removeAssignment')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={assigning || selected.size === 0 || !target}
|
||||
onClick={() => void assign()}
|
||||
>
|
||||
{t('assign')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<p role="status" className="text-sm text-status-ok-fg">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{loading && !data && <p className="text-sm text-muted-foreground">{tc('loading')}</p>}
|
||||
{data?.truncated && <p className="text-sm text-muted-foreground">{tc('truncated')}</p>}
|
||||
|
||||
{data && visible.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.contacts.length === 0 ? t('empty') : t('emptyFiltered')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{grouped
|
||||
? groups.map((group) => (
|
||||
<SettingsSection
|
||||
key={group.key}
|
||||
flush
|
||||
title={group.name ?? tc('unassigned')}
|
||||
description={t('groupCount', { count: group.items.length })}
|
||||
>
|
||||
<ContactTable
|
||||
rows={group.items}
|
||||
canManage={canManage}
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
</SettingsSection>
|
||||
))
|
||||
: visible.length > 0 && (
|
||||
<SettingsSection flush description={t('groupCount', { count: visible.length })}>
|
||||
<ContactTable
|
||||
rows={visible}
|
||||
canManage={canManage}
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { INPUT_CLASS, PRIMARY_BUTTON, SECONDARY_BUTTON } from '@/components/domains/ui-classes';
|
||||
import {
|
||||
createCustomer,
|
||||
type DomainsCustomer,
|
||||
DomainsRequestError,
|
||||
deleteCustomer,
|
||||
updateCustomer,
|
||||
} from '@/lib/domains-api';
|
||||
|
||||
/**
|
||||
* Reiter "Kunden" (D-H): Kunden, denen AutoDNS-Kontakte zugeordnet werden.
|
||||
* Die eigene Firma ist ein Kunde mit Markierung (hoechstens einer). Anlegen,
|
||||
* Aendern und Loeschen nur mit "Verwalten"; ein Kunde mit Zuordnungen laesst
|
||||
* sich nicht loeschen (die API antwortet dann 409).
|
||||
*/
|
||||
export function CustomersTab({
|
||||
customers,
|
||||
canManage,
|
||||
onChanged,
|
||||
}: {
|
||||
customers: DomainsCustomer[];
|
||||
canManage: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const t = useTranslations('domains.customers');
|
||||
const tc = useTranslations('domains.common');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newOwn, setNewOwn] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editOwn, setEditOwn] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async (action: () => Promise<unknown>, after?: () => void) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
after?.();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const create = () =>
|
||||
run(
|
||||
() => createCustomer({ name: newName.trim(), isOwnCompany: newOwn }),
|
||||
() => {
|
||||
setNewName('');
|
||||
setNewOwn(false);
|
||||
},
|
||||
);
|
||||
|
||||
const startEdit = (customer: DomainsCustomer) => {
|
||||
setEditingId(customer.id);
|
||||
setEditName(customer.name);
|
||||
setEditOwn(customer.isOwnCompany);
|
||||
setDeletingId(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const saveEdit = (id: string) =>
|
||||
run(
|
||||
() => updateCustomer(id, { name: editName.trim(), isOwnCompany: editOwn }),
|
||||
() => setEditingId(null),
|
||||
);
|
||||
|
||||
const remove = (id: string) =>
|
||||
run(
|
||||
() => deleteCustomer(id),
|
||||
() => setDeletingId(null),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('intro')}</p>
|
||||
|
||||
{canManage && (
|
||||
<SettingsSection
|
||||
id="domains-customer-new"
|
||||
title={t('addTitle')}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={busy || newName.trim() === ''}
|
||||
onClick={() => void create()}
|
||||
>
|
||||
{t('add')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="customer-new-name"
|
||||
className="mb-1 block text-xs text-muted-foreground"
|
||||
>
|
||||
{t('name')}
|
||||
</label>
|
||||
<input
|
||||
id="customer-new-name"
|
||||
type="text"
|
||||
maxLength={120}
|
||||
className={INPUT_CLASS}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newOwn}
|
||||
onChange={(e) => setNewOwn(e.target.checked)}
|
||||
/>
|
||||
{t('ownCompanyLabel')}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{customers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('empty')}</p>
|
||||
) : (
|
||||
<SettingsSection flush>
|
||||
<ul className="divide-y divide-border">
|
||||
{customers.map((customer) => (
|
||||
<li key={customer.id} className="space-y-3 px-4 py-3">
|
||||
{editingId === customer.id ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`customer-edit-${customer.id}`}
|
||||
className="mb-1 block text-xs text-muted-foreground"
|
||||
>
|
||||
{t('name')}
|
||||
</label>
|
||||
<input
|
||||
id={`customer-edit-${customer.id}`}
|
||||
type="text"
|
||||
maxLength={120}
|
||||
className={INPUT_CLASS}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editOwn}
|
||||
onChange={(e) => setEditOwn(e.target.checked)}
|
||||
/>
|
||||
{t('ownCompanyLabel')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={busy || editName.trim() === ''}
|
||||
onClick={() => void saveEdit(customer.id)}
|
||||
>
|
||||
{t('save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="font-medium text-foreground">{customer.name}</span>
|
||||
{customer.isOwnCompany && (
|
||||
<span className="inline-flex rounded-full bg-status-ok/12 px-2 py-0.5 text-xs font-medium text-status-ok-fg">
|
||||
{t('ownCompany')}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('contactCount', { count: customer.contactCount })}
|
||||
</span>
|
||||
{canManage && (
|
||||
<span className="ml-auto flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={() => startEdit(customer)}
|
||||
>
|
||||
{t('edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setDeletingId(customer.id);
|
||||
setEditingId(null);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canManage && deletingId === customer.id && (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-label={t('confirmDelete', { name: customer.name })}
|
||||
className="space-y-3 rounded-md border border-status-down/40 bg-status-down/10 p-4"
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('confirmDelete', { name: customer.name })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={() => void remove(customer.id)}
|
||||
>
|
||||
{t('confirmDeleteYes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={busy}
|
||||
onClick={() => setDeletingId(null)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import {
|
||||
ALL_CUSTOMERS,
|
||||
filterByCustomer,
|
||||
groupByCustomer,
|
||||
matchesText,
|
||||
UNASSIGNED,
|
||||
} from '@/components/domains/group-by-customer';
|
||||
import {
|
||||
INPUT_CLASS,
|
||||
SECONDARY_BUTTON,
|
||||
TABLE_CELL,
|
||||
TABLE_HEAD_CELL,
|
||||
TABLE_HEAD_CLASS,
|
||||
} from '@/components/domains/ui-classes';
|
||||
import {
|
||||
type DomainsCustomer,
|
||||
type DomainsDomain,
|
||||
type DomainsDomainList,
|
||||
DomainsRequestError,
|
||||
listDomains,
|
||||
} from '@/lib/domains-api';
|
||||
|
||||
/** Ablaufdatum als Datum (dd.mm.yyyy in Deutsch), unabhaengig von der Zeitzone des Browsers. */
|
||||
function formatDate(iso: string | null, locale: string): string {
|
||||
if (!iso) return '–';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '–';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'Europe/Berlin',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function DomainTable({ rows }: { rows: DomainsDomain[] }) {
|
||||
const t = useTranslations('domains.domainList');
|
||||
const locale = useLocale();
|
||||
const statusLabel = (status: string | null) => {
|
||||
if (!status) return '–';
|
||||
return t.has(`status.${status}`) ? t(`status.${status}`) : status;
|
||||
};
|
||||
return (
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className={TABLE_HEAD_CLASS}>
|
||||
<tr>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.domain')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.customer')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.owner')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.expires')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((domain) => (
|
||||
<tr key={domain.name} className="border-t border-border">
|
||||
<td className={`${TABLE_CELL} font-medium text-foreground`}>{domain.name}</td>
|
||||
<td className={TABLE_CELL}>{domain.customerName ?? t('unassigned')}</td>
|
||||
<td className={TABLE_CELL}>{domain.ownerName ?? '–'}</td>
|
||||
<td className={`${TABLE_CELL} tabular-nums`}>{formatDate(domain.expire, locale)}</td>
|
||||
<td className={TABLE_CELL}>
|
||||
{statusLabel(domain.status)}
|
||||
{domain.cancelationPending && (
|
||||
<span className="ml-2 inline-flex rounded-full bg-status-warn/12 px-2 py-0.5 text-xs font-medium text-status-warn-fg">
|
||||
{t('cancelPending')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter "Domains" (L-05): alle Domains des aktiven AutoDNS-Systems mit Kunde
|
||||
* (ueber den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status — filterbar und
|
||||
* nach Kunde gruppierbar. Nur lesend; sichtbar fuer alle mit "Benutzen".
|
||||
*/
|
||||
export function DomainsTab({ customers }: { customers: DomainsCustomer[] }) {
|
||||
const t = useTranslations('domains.domainList');
|
||||
const tc = useTranslations('domains.common');
|
||||
const [data, setData] = useState<DomainsDomainList | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<string>(ALL_CUSTOMERS);
|
||||
const [grouped, setGrouped] = useState(true);
|
||||
|
||||
const load = useCallback(
|
||||
async (refresh: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await listDomains({ refresh }));
|
||||
} catch (e) {
|
||||
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[tc],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, [load]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return filterByCustomer(data.domains, filter).filter((d) =>
|
||||
matchesText(d, search, [(x) => x.name, (x) => x.ownerName, (x) => x.customerName]),
|
||||
);
|
||||
}, [data, filter, search]);
|
||||
|
||||
const groups = useMemo(
|
||||
() => (grouped ? groupByCustomer(visible, customers) : []),
|
||||
[grouped, visible, customers],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-48 flex-1">
|
||||
<label htmlFor="domains-search" className="mb-1 block text-xs text-muted-foreground">
|
||||
{t('search')}
|
||||
</label>
|
||||
<input
|
||||
id="domains-search"
|
||||
type="search"
|
||||
className={INPUT_CLASS}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="domains-filter" className="mb-1 block text-xs text-muted-foreground">
|
||||
{tc('customerFilter')}
|
||||
</label>
|
||||
<select
|
||||
id="domains-filter"
|
||||
className={INPUT_CLASS}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
<option value={ALL_CUSTOMERS}>{tc('allCustomers')}</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={UNASSIGNED}>{tc('unassigned')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 pb-2 text-sm text-foreground">
|
||||
<input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} />
|
||||
{tc('groupByCustomer')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={loading}
|
||||
onClick={() => void load(true)}
|
||||
>
|
||||
{t('refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{loading && !data && <p className="text-sm text-muted-foreground">{tc('loading')}</p>}
|
||||
{data?.truncated && <p className="text-sm text-muted-foreground">{tc('truncated')}</p>}
|
||||
|
||||
{data && visible.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.domains.length === 0 ? t('empty') : t('emptyFiltered')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{grouped
|
||||
? groups.map((group) => (
|
||||
<SettingsSection
|
||||
key={group.key}
|
||||
flush
|
||||
title={group.name ?? tc('unassigned')}
|
||||
description={t('groupCount', { count: group.items.length })}
|
||||
>
|
||||
<DomainTable rows={group.items} />
|
||||
</SettingsSection>
|
||||
))
|
||||
: visible.length > 0 && (
|
||||
<SettingsSection flush description={t('groupCount', { count: visible.length })}>
|
||||
<DomainTable rows={visible} />
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DomainsSettings, DomainsStatus } from '@/lib/domains-api';
|
||||
import type {
|
||||
DomainsContactList,
|
||||
DomainsCustomer,
|
||||
DomainsDomainList,
|
||||
DomainsSettings,
|
||||
DomainsStatus,
|
||||
} from '@/lib/domains-api';
|
||||
import de from '@/messages/de.json';
|
||||
import DomainsPage from './page';
|
||||
|
||||
@@ -25,6 +31,12 @@ const mockGetStatus = vi.fn();
|
||||
const mockGetSettings = vi.fn();
|
||||
const mockSaveSettings = vi.fn();
|
||||
const mockTestConnection = vi.fn();
|
||||
const mockListCustomers = vi.fn();
|
||||
const mockListContacts = vi.fn();
|
||||
const mockListDomains = vi.fn();
|
||||
const mockAssignContacts = vi.fn();
|
||||
const mockCreateCustomer = vi.fn();
|
||||
const mockDeleteCustomer = vi.fn();
|
||||
|
||||
vi.mock('@/lib/domains-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
|
||||
@@ -34,6 +46,12 @@ vi.mock('@/lib/domains-api', async (importOriginal) => {
|
||||
getDomainsSettings: (...args: unknown[]) => mockGetSettings(...args),
|
||||
saveDomainsSettings: (...args: unknown[]) => mockSaveSettings(...args),
|
||||
testDomainsConnection: (...args: unknown[]) => mockTestConnection(...args),
|
||||
listCustomers: (...args: unknown[]) => mockListCustomers(...args),
|
||||
listContacts: (...args: unknown[]) => mockListContacts(...args),
|
||||
listDomains: (...args: unknown[]) => mockListDomains(...args),
|
||||
assignContacts: (...args: unknown[]) => mockAssignContacts(...args),
|
||||
createCustomer: (...args: unknown[]) => mockCreateCustomer(...args),
|
||||
deleteCustomer: (...args: unknown[]) => mockDeleteCustomer(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -64,8 +82,109 @@ function settings(over: Partial<DomainsSettings> = {}): DomainsSettings {
|
||||
};
|
||||
}
|
||||
|
||||
const CUSTOMERS: DomainsCustomer[] = [
|
||||
{ id: 'c-own', name: 'Unsere Firma', isOwnCompany: true, contactCount: 1 },
|
||||
{ id: 'c-a', name: 'Alpha GmbH', isOwnCompany: false, contactCount: 2 },
|
||||
];
|
||||
|
||||
function domainList(): DomainsDomainList {
|
||||
return {
|
||||
environment: 'DEMO',
|
||||
truncated: false,
|
||||
fetchedAt: '2026-10-08T10:00:00.000Z',
|
||||
domains: [
|
||||
{
|
||||
name: 'frei.de',
|
||||
expire: '2027-03-01T12:00:00.000Z',
|
||||
status: 'ACTIVE',
|
||||
cancelationPending: false,
|
||||
ownerContactId: 5,
|
||||
ownerName: 'Niemand',
|
||||
customerId: null,
|
||||
customerName: null,
|
||||
},
|
||||
{
|
||||
name: 'alpha.de',
|
||||
expire: '2027-07-15T12:00:00.000Z',
|
||||
status: 'LOCK',
|
||||
cancelationPending: true,
|
||||
ownerContactId: 6,
|
||||
ownerName: 'Alpha GmbH',
|
||||
customerId: 'c-a',
|
||||
customerName: 'Alpha GmbH',
|
||||
},
|
||||
{
|
||||
name: 'eigen.de',
|
||||
expire: null,
|
||||
status: 'SONDERWERT',
|
||||
cancelationPending: false,
|
||||
ownerContactId: 7,
|
||||
ownerName: 'Unsere Firma',
|
||||
customerId: 'c-own',
|
||||
customerName: 'Unsere Firma',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function contactList(): DomainsContactList {
|
||||
return {
|
||||
environment: 'DEMO',
|
||||
truncated: false,
|
||||
fetchedAt: '2026-10-08T10:00:00.000Z',
|
||||
contacts: [
|
||||
{
|
||||
id: 6,
|
||||
type: 'ORG',
|
||||
displayName: 'Alpha GmbH',
|
||||
fname: 'Anna',
|
||||
lname: 'Alpha',
|
||||
organization: 'Alpha GmbH',
|
||||
address: ['Weg 1'],
|
||||
pcode: '10115',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'anna@alpha.example',
|
||||
phone: '+49 30 1',
|
||||
alias: null,
|
||||
customerId: 'c-a',
|
||||
customerName: 'Alpha GmbH',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'PERSON',
|
||||
displayName: 'Bert Beta',
|
||||
fname: 'Bert',
|
||||
lname: 'Beta',
|
||||
organization: null,
|
||||
address: [],
|
||||
pcode: null,
|
||||
city: 'Hamburg',
|
||||
country: 'DE',
|
||||
email: 'bert@beta.example',
|
||||
phone: null,
|
||||
alias: null,
|
||||
customerId: null,
|
||||
customerName: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Oeffnet den Reiter Einstellungen (Standardreiter ist Domains). */
|
||||
async function renderSettings() {
|
||||
render(<DomainsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCanManage = true;
|
||||
mockListCustomers.mockReset().mockResolvedValue(CUSTOMERS);
|
||||
mockListDomains.mockReset().mockResolvedValue(domainList());
|
||||
mockListContacts.mockReset().mockResolvedValue(contactList());
|
||||
mockAssignContacts.mockReset().mockResolvedValue({ assigned: 1, removed: 0 });
|
||||
mockCreateCustomer.mockReset().mockResolvedValue(CUSTOMERS[1]);
|
||||
mockDeleteCustomer.mockReset().mockResolvedValue({ deleted: true });
|
||||
mockGetStatus.mockReset().mockResolvedValue(status());
|
||||
mockGetSettings.mockReset().mockResolvedValue(settings());
|
||||
mockSaveSettings.mockReset().mockImplementation(async () => settings());
|
||||
@@ -82,6 +201,9 @@ describe('Domains-Seite — Kopf und Reiter', () => {
|
||||
it('Verwalter sieht den Reiter Einstellungen und die Demo-Kennzeichnung', async () => {
|
||||
render(<DomainsPage />);
|
||||
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Domains' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Kontakte' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Kunden' })).toBeTruthy();
|
||||
expect((await screen.findByTestId('domains-environment-badge')).textContent).toBe(
|
||||
'Demo-System (Testbetrieb)',
|
||||
);
|
||||
@@ -129,7 +251,7 @@ describe('Domains-Seite — Kopf und Reiter', () => {
|
||||
|
||||
describe('Einstellungen', () => {
|
||||
it('Passwortfelder beginnen leer, mit Hinweis auf das gespeicherte Passwort', async () => {
|
||||
render(<DomainsPage />);
|
||||
await renderSettings();
|
||||
const demoPassword = (await screen.findByLabelText('Passwort', {
|
||||
selector: '#domains-demo-password',
|
||||
})) as HTMLInputElement;
|
||||
@@ -140,7 +262,7 @@ describe('Einstellungen', () => {
|
||||
});
|
||||
|
||||
it('der Live-Kontext ist mit 4 vorbelegt, der Demo-Kontext zeigt den gespeicherten Wert', async () => {
|
||||
render(<DomainsPage />);
|
||||
await renderSettings();
|
||||
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
|
||||
expect((document.getElementById('domains-live-context') as HTMLInputElement).value).toBe('4');
|
||||
expect((document.getElementById('domains-demo-context') as HTMLInputElement).value).toBe('1');
|
||||
@@ -148,7 +270,7 @@ describe('Einstellungen', () => {
|
||||
|
||||
it('Wechsel auf Live verlangt Bestaetigung; erst die bestaetigte Speicherung sendet confirmLive', async () => {
|
||||
mockSaveSettings.mockResolvedValue(settings({ environment: 'LIVE' }));
|
||||
render(<DomainsPage />);
|
||||
await renderSettings();
|
||||
const liveRadio = await screen.findByRole('radio', { name: /Live-System \(kostenpflichtig\)/ });
|
||||
fireEvent.click(liveRadio);
|
||||
|
||||
@@ -176,7 +298,7 @@ describe('Einstellungen', () => {
|
||||
});
|
||||
|
||||
it('Speichern des Zugangs sendet nur die eigenen Felder; ohne Eingabe kein Passwort', async () => {
|
||||
render(<DomainsPage />);
|
||||
await renderSettings();
|
||||
const user = (await screen.findByLabelText('Benutzername', {
|
||||
selector: '#domains-demo-user',
|
||||
})) as HTMLInputElement;
|
||||
@@ -195,7 +317,7 @@ describe('Einstellungen', () => {
|
||||
resolveTest = resolve;
|
||||
}),
|
||||
);
|
||||
render(<DomainsPage />);
|
||||
await renderSettings();
|
||||
await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' });
|
||||
const demoCard = document.getElementById('domains-demo') as HTMLElement;
|
||||
const button = within(demoCard).getByRole('button', { name: 'Verbindung testen' });
|
||||
@@ -213,7 +335,7 @@ describe('Einstellungen', () => {
|
||||
});
|
||||
|
||||
it('der Verbindungstest ist bei ungespeicherten Aenderungen gesperrt', async () => {
|
||||
render(<DomainsPage />);
|
||||
await renderSettings();
|
||||
const password = (await screen.findByLabelText('Passwort', {
|
||||
selector: '#domains-demo-password',
|
||||
})) as HTMLInputElement;
|
||||
@@ -227,7 +349,7 @@ describe('Einstellungen', () => {
|
||||
});
|
||||
|
||||
it('Nameserver: speichert die bereinigte Liste', async () => {
|
||||
render(<DomainsPage />);
|
||||
await renderSettings();
|
||||
const first = (await screen.findByLabelText('Nameserver 1')) as HTMLInputElement;
|
||||
fireEvent.change(first, { target: { value: ' NS1.Example.com ' } });
|
||||
fireEvent.change(screen.getByLabelText('Nameserver 2'), {
|
||||
@@ -241,3 +363,157 @@ describe('Einstellungen', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reiter Domains, Kontakte und Kunden', () => {
|
||||
it('Benutzen-Benutzer sieht Domains, Kontakte, Kunden, aber weder Einstellungen noch Schreibknoepfe', async () => {
|
||||
mockCanManage = false;
|
||||
render(<DomainsPage />);
|
||||
expect(await screen.findByRole('button', { name: 'Domains' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Kontakte' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Kunden' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kontakte' }));
|
||||
await screen.findByText('Bert Beta');
|
||||
expect(screen.queryByRole('button', { name: 'Neuer Kontakt' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Zuordnen' })).toBeNull();
|
||||
expect(screen.queryByRole('checkbox', { name: /auswählen/ })).toBeNull();
|
||||
// Lesen darf jeder: neu einlesen steht allen zur Verfuegung.
|
||||
expect(screen.getByRole('button', { name: 'Aus AutoDNS neu einlesen' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kunden' }));
|
||||
await screen.findByText('Alpha GmbH');
|
||||
expect(screen.queryByRole('button', { name: 'Kunde anlegen' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Bearbeiten' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Löschen' })).toBeNull();
|
||||
});
|
||||
|
||||
it('Verwalter sieht die Schreibknoepfe', async () => {
|
||||
render(<DomainsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' }));
|
||||
expect(await screen.findByRole('button', { name: 'Neuer Kontakt' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Zuordnen' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kunden' }));
|
||||
expect(await screen.findByRole('button', { name: 'Kunde anlegen' })).toBeTruthy();
|
||||
expect(screen.getAllByRole('button', { name: 'Bearbeiten' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Domainliste: Gruppen je Kunde, "Nicht zugeordnet" zuletzt, Datum und Statusbeschriftung', async () => {
|
||||
render(<DomainsPage />);
|
||||
await screen.findByText('alpha.de');
|
||||
const titles = Array.from(document.querySelectorAll('.cc-section-title')).map(
|
||||
(h) => h.textContent,
|
||||
);
|
||||
expect(titles).toEqual(['Unsere Firma', 'Alpha GmbH', 'Nicht zugeordnet']);
|
||||
|
||||
const row = screen.getByText('alpha.de').closest('tr') as HTMLElement;
|
||||
expect(within(row).getByText('15.07.2027')).toBeTruthy();
|
||||
expect(within(row).getByText(/Gesperrt/)).toBeTruthy();
|
||||
expect(within(row).getByText('Kündigung vorgemerkt')).toBeTruthy();
|
||||
|
||||
const frei = screen.getByText('frei.de').closest('tr') as HTMLElement;
|
||||
expect(within(frei).getByText('01.03.2027')).toBeTruthy();
|
||||
expect(within(frei).getByText('Aktiv')).toBeTruthy();
|
||||
|
||||
// Unbekannter Status: Rohwert als Rueckfall, fehlendes Datum als Strich.
|
||||
const eigen = screen.getByText('eigen.de').closest('tr') as HTMLElement;
|
||||
expect(within(eigen).getByText('SONDERWERT')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Kundenfilter blendet die anderen Gruppen aus', async () => {
|
||||
render(<DomainsPage />);
|
||||
await screen.findByText('alpha.de');
|
||||
fireEvent.change(document.getElementById('domains-filter') as HTMLSelectElement, {
|
||||
target: { value: 'c-a' },
|
||||
});
|
||||
expect(screen.getByText('alpha.de')).toBeTruthy();
|
||||
expect(screen.queryByText('frei.de')).toBeNull();
|
||||
expect(screen.queryByText('eigen.de')).toBeNull();
|
||||
|
||||
fireEvent.change(document.getElementById('domains-filter') as HTMLSelectElement, {
|
||||
target: { value: 'unassigned' },
|
||||
});
|
||||
expect(screen.getByText('frei.de')).toBeTruthy();
|
||||
expect(screen.queryByText('alpha.de')).toBeNull();
|
||||
});
|
||||
|
||||
it('Textsuche und "Aus AutoDNS neu laden"', async () => {
|
||||
render(<DomainsPage />);
|
||||
await screen.findByText('alpha.de');
|
||||
fireEvent.change(document.getElementById('domains-search') as HTMLInputElement, {
|
||||
target: { value: 'FREI' },
|
||||
});
|
||||
expect(screen.queryByText('alpha.de')).toBeNull();
|
||||
expect(screen.getByText('frei.de')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Aus AutoDNS neu laden' }));
|
||||
await waitFor(() => expect(mockListDomains).toHaveBeenLastCalledWith({ refresh: true }));
|
||||
});
|
||||
|
||||
it('zeigt den Hinweis bei abgeschnittener Liste und die Fehlermeldung der API', async () => {
|
||||
mockListDomains.mockResolvedValueOnce({ ...domainList(), truncated: true });
|
||||
render(<DomainsPage />);
|
||||
expect(await screen.findByText('Es werden die ersten 2000 Einträge angezeigt.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Kontakte zuordnen: ausgewaehlte Kontakte gehen an den gewaehlten Kunden', async () => {
|
||||
render(<DomainsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' }));
|
||||
await screen.findByText('Bert Beta');
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Kontakt Bert Beta auswählen' }));
|
||||
fireEvent.change(document.getElementById('contacts-assign-target') as HTMLSelectElement, {
|
||||
target: { value: 'c-a' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zuordnen' }));
|
||||
await waitFor(() => expect(mockAssignContacts).toHaveBeenCalledTimes(1));
|
||||
expect(mockAssignContacts).toHaveBeenCalledWith([5], 'c-a');
|
||||
});
|
||||
|
||||
it('Zuordnung entfernen sendet null', async () => {
|
||||
render(<DomainsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' }));
|
||||
await screen.findByText('Bert Beta');
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Kontakt Alpha GmbH auswählen' }));
|
||||
fireEvent.change(document.getElementById('contacts-assign-target') as HTMLSelectElement, {
|
||||
target: { value: '__remove__' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zuordnen' }));
|
||||
await waitFor(() => expect(mockAssignContacts).toHaveBeenCalledWith([6], null));
|
||||
});
|
||||
|
||||
it('Kunden: Loeschen verlangt eine Bestaetigung und zeigt den 409-Text der API', async () => {
|
||||
const { DomainsRequestError } = await import('@/lib/domains-api');
|
||||
mockDeleteCustomer.mockRejectedValueOnce(
|
||||
new DomainsRequestError(409, 'customerInUse', 'Diesem Kunden sind noch Kontakte zugeordnet.'),
|
||||
);
|
||||
render(<DomainsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Kunden' }));
|
||||
const buttons = await screen.findAllByRole('button', { name: 'Löschen' });
|
||||
fireEvent.click(buttons[1]);
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(mockDeleteCustomer).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Endgültig löschen' }));
|
||||
await waitFor(() => expect(mockDeleteCustomer).toHaveBeenCalledWith('c-a'));
|
||||
expect(await screen.findByText('Diesem Kunden sind noch Kontakte zugeordnet.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Kunden: neuer Kunde mit Markierung eigene Firma', async () => {
|
||||
render(<DomainsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Kunden' }));
|
||||
fireEvent.change(await screen.findByLabelText('Name', { selector: '#customer-new-name' }), {
|
||||
target: { value: ' Neu AG ' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Das ist unsere eigene Firma' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kunde anlegen' }));
|
||||
await waitFor(() => expect(mockCreateCustomer).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreateCustomer).toHaveBeenCalledWith({ name: 'Neu AG', isOwnCompany: true });
|
||||
});
|
||||
|
||||
it('nicht eingerichtet: keine Liste, nur der Hinweis', async () => {
|
||||
mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false }));
|
||||
render(<DomainsPage />);
|
||||
await screen.findByText('AutoDNS ist noch nicht eingerichtet.');
|
||||
expect(mockListDomains).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,19 +5,29 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { TabBar } from '@/components/accounting/tab-bar';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { type DomainsStatus, getDomainsStatus } from '@/lib/domains-api';
|
||||
import {
|
||||
type DomainsCustomer,
|
||||
type DomainsStatus,
|
||||
getDomainsStatus,
|
||||
listCustomers,
|
||||
} from '@/lib/domains-api';
|
||||
import { useCanManageModule } from '@/lib/use-module-capability';
|
||||
import { ContactsTab } from './components/ContactsTab';
|
||||
import { CustomersTab } from './components/CustomersTab';
|
||||
import { DomainsTab } from './components/DomainsTab';
|
||||
import { EnvironmentBadge } from './components/EnvironmentBadge';
|
||||
import { SettingsTab } from './components/SettingsTab';
|
||||
|
||||
type TabId = 'settings';
|
||||
type TabId = 'domains' | 'contacts' | 'customers' | 'settings';
|
||||
|
||||
/**
|
||||
* Domains (quick-261008-dts): Domains bei AutoDNS registrieren, Kontakte und
|
||||
* Kunden zuordnen. Der Seitenkopf traegt dauerhaft die Kennzeichnung des
|
||||
* aktiven Systems (Demo/Live). Der Reiter "Einstellungen" erscheint nur fuer
|
||||
* aktiven Systems (Demo/Live). Die Reiter Domains, Kontakte und Kunden sehen alle
|
||||
* mit "Benutzen"; "Einstellungen" und die Schreibknoepfe erscheinen nur fuer
|
||||
* Administratoren und Benutzer mit der Freigabestufe Verwalten — bindend ist
|
||||
* allein die API.
|
||||
* allein die API. Die Kundenliste wird einmal hier geladen und nach unten
|
||||
* gereicht (Filter, Zuordnung, Formular).
|
||||
*/
|
||||
export default function DomainsPage() {
|
||||
const t = useTranslations('domains');
|
||||
@@ -25,7 +35,8 @@ export default function DomainsPage() {
|
||||
|
||||
const [status, setStatus] = useState<DomainsStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState(false);
|
||||
const [tab, setTab] = useState<TabId>('settings');
|
||||
const [tab, setTab] = useState<TabId>('domains');
|
||||
const [customers, setCustomers] = useState<DomainsCustomer[]>([]);
|
||||
|
||||
const reloadStatus = useCallback(async () => {
|
||||
try {
|
||||
@@ -36,11 +47,24 @@ export default function DomainsPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadCustomers = useCallback(async () => {
|
||||
try {
|
||||
setCustomers(await listCustomers());
|
||||
} catch {
|
||||
// Die Kundenliste ist Beiwerk (Filter, Zuordnung); die Reiter melden eigene Fehler.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadStatus();
|
||||
}, [reloadStatus]);
|
||||
void reloadCustomers();
|
||||
}, [reloadStatus, reloadCustomers]);
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [];
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'domains', label: t('tabs.domains') },
|
||||
{ id: 'contacts', label: t('tabs.contacts') },
|
||||
{ id: 'customers', label: t('tabs.customers') },
|
||||
];
|
||||
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
||||
const activeTab = tabs.some((x) => x.id === tab) ? tab : tabs[0]?.id;
|
||||
|
||||
@@ -76,7 +100,25 @@ export default function DomainsPage() {
|
||||
{tabs.length > 0 && activeTab && (
|
||||
<>
|
||||
<TabBar tabs={tabs} active={activeTab} onChange={setTab} />
|
||||
{activeTab === 'settings' && <SettingsTab onChanged={reloadStatus} />}
|
||||
{activeTab === 'domains' && status?.configured && <DomainsTab customers={customers} />}
|
||||
{activeTab === 'contacts' && status?.configured && (
|
||||
<ContactsTab
|
||||
customers={customers}
|
||||
canManage={canManage}
|
||||
onCustomersChanged={reloadCustomers}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'customers' && (
|
||||
<CustomersTab customers={customers} canManage={canManage} onChanged={reloadCustomers} />
|
||||
)}
|
||||
{activeTab === 'settings' && (
|
||||
<SettingsTab
|
||||
onChanged={() => {
|
||||
void reloadStatus();
|
||||
void reloadCustomers();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ALL_CUSTOMERS,
|
||||
filterByCustomer,
|
||||
groupByCustomer,
|
||||
matchesText,
|
||||
UNASSIGNED,
|
||||
} from './group-by-customer';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
const customers = [
|
||||
{ id: 'c-z', name: 'Zeller GmbH', isOwnCompany: false },
|
||||
{ id: 'c-ae', name: 'Äpfel AG', isOwnCompany: false },
|
||||
{ id: 'c-own', name: 'Unsere Firma', isOwnCompany: true },
|
||||
{ id: 'c-b', name: 'Bauer', isOwnCompany: false },
|
||||
];
|
||||
|
||||
const items: Item[] = [
|
||||
{ name: 'a.de', customerId: null, customerName: null },
|
||||
{ name: 'b.de', customerId: 'c-z', customerName: 'Zeller GmbH' },
|
||||
{ name: 'c.de', customerId: 'c-own', customerName: 'Unsere Firma' },
|
||||
{ name: 'd.de', customerId: 'c-ae', customerName: 'Äpfel AG' },
|
||||
{ name: 'e.de', customerId: 'c-b', customerName: 'Bauer' },
|
||||
{ name: 'f.de', customerId: 'c-z', customerName: 'Zeller GmbH' },
|
||||
];
|
||||
|
||||
describe('groupByCustomer', () => {
|
||||
it('eigene Firma zuerst, dann deutsch sortiert, "Nicht zugeordnet" zuletzt', () => {
|
||||
const groups = groupByCustomer(items, customers);
|
||||
expect(groups.map((g) => g.key)).toEqual(['c-own', 'c-ae', 'c-b', 'c-z', UNASSIGNED]);
|
||||
expect(groups[0].isOwnCompany).toBe(true);
|
||||
expect(groups[4].name).toBeNull();
|
||||
expect(groups.find((g) => g.key === 'c-z')?.items.map((i) => i.name)).toEqual(['b.de', 'f.de']);
|
||||
});
|
||||
|
||||
it('laesst leere Gruppen weg und veraendert die Eingabe nicht', () => {
|
||||
const copy = JSON.parse(JSON.stringify(items));
|
||||
const groups = groupByCustomer(items.slice(0, 1), customers);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].key).toBe(UNASSIGNED);
|
||||
groupByCustomer(items, customers);
|
||||
expect(items).toEqual(copy);
|
||||
});
|
||||
|
||||
it('ein Kunde, den die Kundenliste nicht kennt, behaelt seinen Namen aus dem Eintrag', () => {
|
||||
const groups = groupByCustomer(
|
||||
[{ name: 'x.de', customerId: 'c-weg', customerName: 'Gelöscht AG' }],
|
||||
customers,
|
||||
);
|
||||
expect(groups[0].name).toBe('Gelöscht AG');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByCustomer', () => {
|
||||
it('all, unassigned und eine Kunden-Id', () => {
|
||||
expect(filterByCustomer(items, ALL_CUSTOMERS)).toHaveLength(6);
|
||||
expect(filterByCustomer(items, UNASSIGNED).map((i) => i.name)).toEqual(['a.de']);
|
||||
expect(filterByCustomer(items, 'c-z').map((i) => i.name)).toEqual(['b.de', 'f.de']);
|
||||
expect(filterByCustomer(items, 'c-gibt-es-nicht')).toEqual([]);
|
||||
});
|
||||
|
||||
it('gibt bei "all" eine Kopie zurueck, nicht die Eingabe', () => {
|
||||
expect(filterByCustomer(items, ALL_CUSTOMERS)).not.toBe(items);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesText', () => {
|
||||
const fields = [(i: Item) => i.name, (i: Item) => i.customerName];
|
||||
|
||||
it('ignoriert Gross-/Kleinschreibung und durchsucht alle Felder', () => {
|
||||
expect(matchesText(items[1], 'ZELLER', fields)).toBe(true);
|
||||
expect(matchesText(items[1], 'b.de', fields)).toBe(true);
|
||||
expect(matchesText(items[1], 'xyz', fields)).toBe(false);
|
||||
});
|
||||
|
||||
it('leere Suche trifft alles, null-Felder stoeren nicht', () => {
|
||||
expect(matchesText(items[0], ' ', fields)).toBe(true);
|
||||
expect(matchesText(items[0], 'zeller', fields)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Filtern, Suchen und Gruppieren nach Kunde (quick-261008-dts). Reine
|
||||
* Hilfsfunktionen ohne React: Kontakte und Domains tragen beide
|
||||
* `customerId`/`customerName`. Die Eingabelisten werden nie veraendert.
|
||||
*/
|
||||
|
||||
/** Schluessel fuer "keinem Kunden zugeordnet" (Filterwert und Gruppenschluessel). */
|
||||
export const UNASSIGNED = 'unassigned';
|
||||
/** Filterwert "alle Kunden". */
|
||||
export const ALL_CUSTOMERS = 'all';
|
||||
|
||||
export interface HasCustomer {
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
export interface CustomerRef {
|
||||
id: string;
|
||||
name: string;
|
||||
isOwnCompany: boolean;
|
||||
}
|
||||
|
||||
export interface CustomerGroup<T> {
|
||||
/** Kunden-Id oder `UNASSIGNED`. */
|
||||
key: string;
|
||||
/** Anzeigename; `null` bei der Gruppe "Nicht zugeordnet" (Beschriftung kommt aus den Texten). */
|
||||
name: string | null;
|
||||
isOwnCompany: boolean;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/** `'all'`, `'unassigned'` oder eine Kunden-Id. */
|
||||
export function filterByCustomer<T extends HasCustomer>(items: T[], filter: string): T[] {
|
||||
if (filter === ALL_CUSTOMERS) return [...items];
|
||||
if (filter === UNASSIGNED) return items.filter((item) => item.customerId === null);
|
||||
return items.filter((item) => item.customerId === filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppen nach Kunde: eigene Firma zuerst, dann nach Name (deutsche
|
||||
* Sortierung), "Nicht zugeordnet" zuletzt. Leere Gruppen entfallen.
|
||||
*/
|
||||
export function groupByCustomer<T extends HasCustomer>(
|
||||
items: T[],
|
||||
customers: CustomerRef[],
|
||||
): CustomerGroup<T>[] {
|
||||
const known = new Map(customers.map((c) => [c.id, c]));
|
||||
const groups = new Map<string, CustomerGroup<T>>();
|
||||
for (const item of items) {
|
||||
const key = item.customerId ?? UNASSIGNED;
|
||||
let group = groups.get(key);
|
||||
if (!group) {
|
||||
const customer = item.customerId ? known.get(item.customerId) : undefined;
|
||||
group = {
|
||||
key,
|
||||
name: item.customerId ? (customer?.name ?? item.customerName ?? item.customerId) : null,
|
||||
isOwnCompany: customer?.isOwnCompany ?? false,
|
||||
items: [],
|
||||
};
|
||||
groups.set(key, group);
|
||||
}
|
||||
group.items.push(item);
|
||||
}
|
||||
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
|
||||
return [...groups.values()].sort((a, b) => {
|
||||
if (a.key === UNASSIGNED) return 1;
|
||||
if (b.key === UNASSIGNED) return -1;
|
||||
if (a.isOwnCompany !== b.isOwnCompany) return a.isOwnCompany ? -1 : 1;
|
||||
return collator.compare(a.name ?? '', b.name ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
/** Gross-/Kleinschreibung egal; leere Suche trifft alles. */
|
||||
export function matchesText<T>(
|
||||
item: T,
|
||||
query: string,
|
||||
fields: ((item: T) => string | null | undefined)[],
|
||||
): boolean {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return fields.some((field) => (field(item) ?? '').toLowerCase().includes(needle));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Gemeinsame Klassen der Domains-Seite (Muster `SettingsTab.tsx`). */
|
||||
export const INPUT_CLASS =
|
||||
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
export const PRIMARY_BUTTON =
|
||||
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
|
||||
export const SECONDARY_BUTTON =
|
||||
'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50';
|
||||
export const TABLE_HEAD_CLASS = 'bg-muted/50 text-xs text-muted-foreground';
|
||||
export const TABLE_HEAD_CELL = 'px-3 py-2 font-medium';
|
||||
export const TABLE_CELL = 'px-3 py-2';
|
||||
@@ -122,3 +122,125 @@ export function testDomainsConnection(
|
||||
json: { environment },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Kunden, Kontakte und Domains (quick-261008-dts, Aufgabe 2) ----------------
|
||||
|
||||
export interface DomainsCustomer {
|
||||
id: string;
|
||||
name: string;
|
||||
isOwnCompany: boolean;
|
||||
/** Zugeordnete Kontakte im AKTIVEN System. */
|
||||
contactCount: number;
|
||||
}
|
||||
|
||||
export interface DomainsCustomerInput {
|
||||
name: string;
|
||||
isOwnCompany?: boolean;
|
||||
}
|
||||
|
||||
export type DomainsContactType = 'PERSON' | 'ORG';
|
||||
|
||||
export interface DomainsContact {
|
||||
/** AutoDNS-Kontakt-Nummer. */
|
||||
id: number;
|
||||
type: string | null;
|
||||
displayName: string;
|
||||
fname: string | null;
|
||||
lname: string | null;
|
||||
organization: string | null;
|
||||
address: string[];
|
||||
pcode: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
alias: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
export interface DomainsContactList {
|
||||
environment: DomainsEnvironment;
|
||||
contacts: DomainsContact[];
|
||||
/** Es werden nur die ersten 2000 Eintraege geliefert. */
|
||||
truncated: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface DomainsDomain {
|
||||
name: string;
|
||||
/** ISO-Zeitpunkt oder null. */
|
||||
expire: string | null;
|
||||
/** Rohwert von AutoDNS (`registryStatus`). */
|
||||
status: string | null;
|
||||
cancelationPending: boolean;
|
||||
ownerContactId: number | null;
|
||||
ownerName: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
export interface DomainsDomainList {
|
||||
environment: DomainsEnvironment;
|
||||
domains: DomainsDomain[];
|
||||
truncated: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateDomainsContactInput {
|
||||
type: DomainsContactType;
|
||||
organization?: string;
|
||||
fname: string;
|
||||
lname: string;
|
||||
/** Anschriftzeilen (Strasse und Hausnummer, optional eine zweite Zeile). */
|
||||
street: string[];
|
||||
pcode: string;
|
||||
city: string;
|
||||
/** Laendercode nach ISO 3166-1 (zwei Grossbuchstaben). */
|
||||
country: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
customerId?: string;
|
||||
}
|
||||
|
||||
export function listCustomers(): Promise<DomainsCustomer[]> {
|
||||
return request<DomainsCustomer[]>('/customers');
|
||||
}
|
||||
|
||||
export function createCustomer(input: DomainsCustomerInput): Promise<DomainsCustomer> {
|
||||
return request<DomainsCustomer>('/customers', { method: 'POST', json: input });
|
||||
}
|
||||
|
||||
export function updateCustomer(id: string, input: DomainsCustomerInput): Promise<DomainsCustomer> {
|
||||
return request<DomainsCustomer>(`/customers/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
json: input,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCustomer(id: string): Promise<{ deleted: true }> {
|
||||
return request<{ deleted: true }>(`/customers/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function listContacts(opts: { refresh?: boolean } = {}): Promise<DomainsContactList> {
|
||||
return request<DomainsContactList>(`/contacts${opts.refresh ? '?refresh=1' : ''}`);
|
||||
}
|
||||
|
||||
export function createContact(input: CreateDomainsContactInput): Promise<DomainsContact> {
|
||||
return request<DomainsContact>('/contacts', { method: 'POST', json: input });
|
||||
}
|
||||
|
||||
/** `customerId: null` loest die Zuordnung. */
|
||||
export function assignContacts(
|
||||
contactIds: number[],
|
||||
customerId: string | null,
|
||||
): Promise<{ assigned: number; removed: number }> {
|
||||
return request<{ assigned: number; removed: number }>('/contacts/assign', {
|
||||
method: 'POST',
|
||||
json: { contactIds, customerId },
|
||||
});
|
||||
}
|
||||
|
||||
export function listDomains(opts: { refresh?: boolean } = {}): Promise<DomainsDomainList> {
|
||||
return request<DomainsDomainList>(`/domains${opts.refresh ? '?refresh=1' : ''}`);
|
||||
}
|
||||
|
||||
@@ -2058,6 +2058,9 @@
|
||||
"notConfigured": "AutoDNS nicht eingerichtet"
|
||||
},
|
||||
"tabs": {
|
||||
"domains": "Domains",
|
||||
"contacts": "Kontakte",
|
||||
"customers": "Kunden",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"notConfigured": {
|
||||
@@ -2104,6 +2107,105 @@
|
||||
"remove": "Entfernen"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"customerFilter": "Kunde",
|
||||
"allCustomers": "Alle Kunden",
|
||||
"unassigned": "Nicht zugeordnet",
|
||||
"groupByCustomer": "Nach Kunde gruppieren",
|
||||
"loading": "Wird geladen …",
|
||||
"truncated": "Es werden die ersten 2000 Einträge angezeigt.",
|
||||
"requestFailed": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"domainList": {
|
||||
"search": "Domain suchen",
|
||||
"refresh": "Aus AutoDNS neu laden",
|
||||
"empty": "Bei AutoDNS wurden keine Domains gefunden.",
|
||||
"emptyFiltered": "Keine Domain entspricht Ihrer Auswahl.",
|
||||
"unassigned": "Nicht zugeordnet",
|
||||
"cancelPending": "Kündigung vorgemerkt",
|
||||
"groupCount": "{count, plural, one {# Domain} other {# Domains}}",
|
||||
"columns": {
|
||||
"domain": "Domain",
|
||||
"customer": "Kunde",
|
||||
"owner": "Inhaber",
|
||||
"expires": "Ablaufdatum",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"ACTIVE": "Aktiv",
|
||||
"PENDING": "In Bearbeitung",
|
||||
"HOLD": "Gesperrt (Registry)",
|
||||
"LOCK": "Gesperrt"
|
||||
}
|
||||
},
|
||||
"contactList": {
|
||||
"intro": "Hier erscheinen alle Kontakte, die bei AutoDNS im gewählten System vorhanden sind. Ordnen Sie sie einem Kunden zu, damit Domains beim richtigen Kunden auftauchen.",
|
||||
"search": "Kontakt suchen",
|
||||
"refresh": "Aus AutoDNS neu einlesen",
|
||||
"newContact": "Neuer Kontakt",
|
||||
"empty": "Bei AutoDNS wurden keine Kontakte gefunden.",
|
||||
"emptyFiltered": "Kein Kontakt entspricht Ihrer Auswahl.",
|
||||
"groupCount": "{count, plural, one {# Kontakt} other {# Kontakte}}",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"organization": "Organisation",
|
||||
"city": "Ort",
|
||||
"email": "E-Mail",
|
||||
"customer": "Kunde"
|
||||
},
|
||||
"selectContact": "Kontakt {name} auswählen",
|
||||
"selectAllVisible": "Alle angezeigten auswählen",
|
||||
"selectedCount": "{count, plural, =0 {Nichts ausgewählt} one {# ausgewählt} other {# ausgewählt}}",
|
||||
"assignLabel": "Ausgewählte zuordnen",
|
||||
"assignPlaceholder": "Kunde wählen …",
|
||||
"removeAssignment": "Zuordnung entfernen",
|
||||
"assign": "Zuordnen",
|
||||
"assigned": "Die Zuordnung wurde gespeichert.",
|
||||
"created": "Der Kontakt wurde bei AutoDNS angelegt."
|
||||
},
|
||||
"contactForm": {
|
||||
"title": "Neuer Kontakt",
|
||||
"type": "Typ",
|
||||
"person": "Person",
|
||||
"org": "Firma oder Organisation",
|
||||
"organization": "Organisation",
|
||||
"fname": "Vorname",
|
||||
"lname": "Nachname",
|
||||
"street": "Straße und Hausnummer",
|
||||
"street2": "Zweite Adresszeile (optional)",
|
||||
"pcode": "PLZ",
|
||||
"city": "Ort",
|
||||
"country": "Land",
|
||||
"phone": "Telefon",
|
||||
"phoneHint": "Internationale Schreibweise, z. B. +49 30 123456",
|
||||
"email": "E-Mail",
|
||||
"customer": "Kunde (optional)",
|
||||
"noCustomer": "Kein Kunde",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern …",
|
||||
"cancel": "Abbrechen",
|
||||
"errors": {
|
||||
"required": "Bitte füllen Sie dieses Feld aus.",
|
||||
"email": "Bitte geben Sie eine gültige E-Mail-Adresse an.",
|
||||
"phone": "Bitte verwenden Sie die internationale Schreibweise, z. B. +49 30 123456."
|
||||
}
|
||||
},
|
||||
"customers": {
|
||||
"intro": "Kunden fassen AutoDNS-Kontakte zusammen. Eine Domain erscheint beim Kunden ihres Inhabers. Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie.",
|
||||
"addTitle": "Kunde anlegen",
|
||||
"name": "Name",
|
||||
"ownCompanyLabel": "Das ist unsere eigene Firma",
|
||||
"ownCompany": "Eigene Firma",
|
||||
"add": "Kunde anlegen",
|
||||
"empty": "Es sind noch keine Kunden angelegt.",
|
||||
"contactCount": "{count, plural, =0 {Keine Kontakte} one {# Kontakt} other {# Kontakte}}",
|
||||
"edit": "Bearbeiten",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"confirmDelete": "Kunde „{name}“ wirklich löschen?",
|
||||
"confirmDeleteYes": "Endgültig löschen"
|
||||
},
|
||||
"errors": {
|
||||
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"loadStatus": "Der Zustand der AutoDNS-Anbindung konnte nicht geladen werden."
|
||||
|
||||
@@ -2058,6 +2058,9 @@
|
||||
"notConfigured": "AutoDNS not set up"
|
||||
},
|
||||
"tabs": {
|
||||
"domains": "Domains",
|
||||
"contacts": "Contacts",
|
||||
"customers": "Customers",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"notConfigured": {
|
||||
@@ -2104,6 +2107,105 @@
|
||||
"remove": "Remove"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"customerFilter": "Customer",
|
||||
"allCustomers": "All customers",
|
||||
"unassigned": "Unassigned",
|
||||
"groupByCustomer": "Group by customer",
|
||||
"loading": "Loading …",
|
||||
"truncated": "Only the first 2000 entries are shown.",
|
||||
"requestFailed": "The request failed. Please try again."
|
||||
},
|
||||
"domainList": {
|
||||
"search": "Search domains",
|
||||
"refresh": "Reload from AutoDNS",
|
||||
"empty": "No domains were found at AutoDNS.",
|
||||
"emptyFiltered": "No domain matches your selection.",
|
||||
"unassigned": "Unassigned",
|
||||
"cancelPending": "Cancellation pending",
|
||||
"groupCount": "{count, plural, one {# domain} other {# domains}}",
|
||||
"columns": {
|
||||
"domain": "Domain",
|
||||
"customer": "Customer",
|
||||
"owner": "Owner",
|
||||
"expires": "Expiry date",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"ACTIVE": "Active",
|
||||
"PENDING": "In progress",
|
||||
"HOLD": "On hold (registry)",
|
||||
"LOCK": "Locked"
|
||||
}
|
||||
},
|
||||
"contactList": {
|
||||
"intro": "All contacts that exist at AutoDNS in the selected system appear here. Assign them to a customer so that domains show up under the right customer.",
|
||||
"search": "Search contacts",
|
||||
"refresh": "Re-read from AutoDNS",
|
||||
"newContact": "New contact",
|
||||
"empty": "No contacts were found at AutoDNS.",
|
||||
"emptyFiltered": "No contact matches your selection.",
|
||||
"groupCount": "{count, plural, one {# contact} other {# contacts}}",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"organization": "Organisation",
|
||||
"city": "City",
|
||||
"email": "E-mail",
|
||||
"customer": "Customer"
|
||||
},
|
||||
"selectContact": "Select contact {name}",
|
||||
"selectAllVisible": "Select all shown",
|
||||
"selectedCount": "{count, plural, =0 {Nothing selected} other {# selected}}",
|
||||
"assignLabel": "Assign selected to",
|
||||
"assignPlaceholder": "Choose customer …",
|
||||
"removeAssignment": "Remove assignment",
|
||||
"assign": "Assign",
|
||||
"assigned": "The assignment was saved.",
|
||||
"created": "The contact was created at AutoDNS."
|
||||
},
|
||||
"contactForm": {
|
||||
"title": "New contact",
|
||||
"type": "Type",
|
||||
"person": "Person",
|
||||
"org": "Company or organisation",
|
||||
"organization": "Organisation",
|
||||
"fname": "First name",
|
||||
"lname": "Last name",
|
||||
"street": "Street and house number",
|
||||
"street2": "Second address line (optional)",
|
||||
"pcode": "Postal code",
|
||||
"city": "City",
|
||||
"country": "Country",
|
||||
"phone": "Phone",
|
||||
"phoneHint": "International format, e.g. +49 30 123456",
|
||||
"email": "E-mail",
|
||||
"customer": "Customer (optional)",
|
||||
"noCustomer": "No customer",
|
||||
"save": "Save",
|
||||
"saving": "Saving …",
|
||||
"cancel": "Cancel",
|
||||
"errors": {
|
||||
"required": "Please fill in this field.",
|
||||
"email": "Please enter a valid e-mail address.",
|
||||
"phone": "Please use the international format, e.g. +49 30 123456."
|
||||
}
|
||||
},
|
||||
"customers": {
|
||||
"intro": "Customers group AutoDNS contacts. A domain appears under the customer of its owner. Add your own company as a customer too and mark it.",
|
||||
"addTitle": "Add customer",
|
||||
"name": "Name",
|
||||
"ownCompanyLabel": "This is our own company",
|
||||
"ownCompany": "Own company",
|
||||
"add": "Add customer",
|
||||
"empty": "No customers have been added yet.",
|
||||
"contactCount": "{count, plural, =0 {No contacts} one {# contact} other {# contacts}}",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Really delete customer “{name}”?",
|
||||
"confirmDeleteYes": "Delete permanently"
|
||||
},
|
||||
"errors": {
|
||||
"request": "The request failed. Please try again.",
|
||||
"loadStatus": "The state of the AutoDNS connection could not be loaded."
|
||||
|
||||
@@ -225,4 +225,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'aktuellen',
|
||||
'Passworts',
|
||||
'tessera',
|
||||
// quick-261008-dts: Modul Domains, Kunden und Kontakte — korrektes Deutsch mit „ss“
|
||||
'Adresszeile',
|
||||
'fassen',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user