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();