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:
2026-10-08 10:50:06 +02:00
parent 1f1c984184
commit 450218fe77
26 changed files with 3943 additions and 36 deletions
+130
View File
@@ -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);
});
});
+122
View File
@@ -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;
}
+51
View File
@@ -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);
});
});
+83 -2
View File
@@ -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);
}
}
+2 -1
View File
@@ -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
View File
@@ -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' : ''}`);
}
+102
View File
@@ -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."
+102
View File
@@ -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',
];
File diff suppressed because one or more lines are too long