feat(domains): Kunden, Kontakte aus AutoDNS mit Zuordnung und Domainliste
- Kunden mit Markierung eigene Firma, Kontakte live aus AutoDNS (seitenweise, 60 s Zwischenspeicher), Zuordnung je System - Kontakt anlegen (Person/Organisation) und einem oder mehreren Kunden zuordnen, nur mit Verwalten - Domainliste mit Kunde (ueber den Inhaber-Kontakt), Inhaber, Ablaufdatum, Status; Filter und Gruppierung nach Kunde - Mandantenschutz-Inventar um die beiden neuen Zugriffspaare ergaenzt Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseContacts, parseDomains } from './autodns-parse';
|
||||
import { TtlCache } from './domains-cache';
|
||||
|
||||
describe('parseContacts', () => {
|
||||
it('liest Organisation, Person, Alias und Rueckfall auf die Id', () => {
|
||||
const result = parseContacts([
|
||||
{
|
||||
id: 11,
|
||||
type: 'ORG',
|
||||
organization: 'Muster GmbH',
|
||||
fname: 'Max',
|
||||
lname: 'Muster',
|
||||
address: ['Musterstr. 1', 'Hinterhaus'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'info@example.com',
|
||||
phone: '+49 30 123456',
|
||||
alias: 'muster-1',
|
||||
},
|
||||
{ id: 12, type: 'PERSON', fname: 'Erika', lname: 'Muster' },
|
||||
{ id: 13, alias: 'nur-alias' },
|
||||
{ id: 14 },
|
||||
]);
|
||||
expect(result.map((c) => c.displayName)).toEqual([
|
||||
'Muster GmbH',
|
||||
'Erika Muster',
|
||||
'nur-alias',
|
||||
'#14',
|
||||
]);
|
||||
expect(result[0]).toEqual({
|
||||
id: 11,
|
||||
type: 'ORG',
|
||||
displayName: 'Muster GmbH',
|
||||
fname: 'Max',
|
||||
lname: 'Muster',
|
||||
organization: 'Muster GmbH',
|
||||
address: ['Musterstr. 1', 'Hinterhaus'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'info@example.com',
|
||||
phone: '+49 30 123456',
|
||||
alias: 'muster-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('wandelt eine Id als Zeichenkette in eine Zahl und ueberspringt Eintraege ohne Zahl', () => {
|
||||
const result = parseContacts([
|
||||
{ id: '123', lname: 'A' },
|
||||
{ id: 'abc', lname: 'B' },
|
||||
{ lname: 'C' },
|
||||
null,
|
||||
'x',
|
||||
{ id: -4 },
|
||||
]);
|
||||
expect(result.map((c) => c.id)).toEqual([123]);
|
||||
});
|
||||
|
||||
it('kuerzt lange Zeichenketten auf 200 Zeichen und akzeptiert Nicht-Listen', () => {
|
||||
const long = 'x'.repeat(500);
|
||||
expect(parseContacts([{ id: 1, organization: long }])[0].organization).toHaveLength(200);
|
||||
expect(parseContacts(undefined)).toEqual([]);
|
||||
expect(parseContacts({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDomains', () => {
|
||||
it('liest Name, Ablaufdatum, Status und Inhaber', () => {
|
||||
const result = parseDomains([
|
||||
{
|
||||
name: 'beispiel.de',
|
||||
expire: '2027-03-01T00:00:00.000+0100',
|
||||
registryStatus: 'ACTIVE',
|
||||
cancelationStatus: 'NONE',
|
||||
ownerc: { id: 11 },
|
||||
},
|
||||
{ name: 'zwei.de', ownerc: 22 },
|
||||
{ name: 'drei.de', expire: 'kein Datum', ownerc: '33' },
|
||||
{ name: 'vier.de' },
|
||||
{ expire: '2027-01-01' },
|
||||
]);
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]).toEqual({
|
||||
name: 'beispiel.de',
|
||||
expire: new Date('2027-03-01T00:00:00.000+0100').toISOString(),
|
||||
registryStatus: 'ACTIVE',
|
||||
cancelationStatus: 'NONE',
|
||||
ownerContactId: 11,
|
||||
});
|
||||
expect(result[1].ownerContactId).toBe(22);
|
||||
expect(result[2].expire).toBeNull();
|
||||
expect(result[2].ownerContactId).toBe(33);
|
||||
expect(result[3].ownerContactId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TtlCache', () => {
|
||||
it('trifft nach 59 s, verfehlt nach 61 s', () => {
|
||||
let now = 1_000_000;
|
||||
const cache = new TtlCache<string>(60_000, () => now);
|
||||
cache.set('a', 'wert');
|
||||
now += 59_000;
|
||||
expect(cache.get('a')).toBe('wert');
|
||||
now += 2_000;
|
||||
expect(cache.get('a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('set raeumt abgelaufene Eintraege weg', () => {
|
||||
let now = 0;
|
||||
const cache = new TtlCache<number>(60_000, () => now);
|
||||
cache.set('alt', 1);
|
||||
now += 61_000;
|
||||
cache.set('neu', 2);
|
||||
expect(cache.size).toBe(1);
|
||||
expect(cache.get('neu')).toBe(2);
|
||||
});
|
||||
|
||||
it('loescht nach Praefix', () => {
|
||||
const cache = new TtlCache<number>(60_000, () => 0);
|
||||
cache.set('t1|DEMO|1|contacts', 1);
|
||||
cache.set('t1|DEMO|1|domains', 2);
|
||||
cache.set('t2|DEMO|1|contacts', 3);
|
||||
cache.deleteByPrefix('t1|');
|
||||
expect(cache.get('t1|DEMO|1|contacts')).toBeUndefined();
|
||||
expect(cache.get('t1|DEMO|1|domains')).toBeUndefined();
|
||||
expect(cache.get('t2|DEMO|1|contacts')).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Reine Auswertung der AutoDNS-Antwortdaten (quick-261008-dts) — kein Nest,
|
||||
* kein Netz. Alles defensiv: unbekannte Felder werden ignoriert, Zeichenketten
|
||||
* auf 200 Zeichen gekuerzt, weil die Daten von einem Fremdsystem kommen und im
|
||||
* Browser angezeigt werden.
|
||||
*/
|
||||
|
||||
const MAX_TEXT = 200;
|
||||
|
||||
export interface ParsedContact {
|
||||
/** AutoDNS-Kontakt-Id als Zahl (die API gibt sie als Zahl weiter). */
|
||||
id: number;
|
||||
type: string | null;
|
||||
displayName: string;
|
||||
fname: string | null;
|
||||
lname: string | null;
|
||||
organization: string | null;
|
||||
address: string[];
|
||||
pcode: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
alias: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedDomain {
|
||||
name: string;
|
||||
/** ISO-Zeichenkette oder null. */
|
||||
expire: string | null;
|
||||
registryStatus: string | null;
|
||||
cancelationStatus: string | null;
|
||||
ownerContactId: number | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : trimmed.slice(0, MAX_TEXT);
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ganzzahl aus Zahl oder Zeichenkette ('123'), sonst null. */
|
||||
export function toContactId(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
||||
if (typeof value === 'string' && /^\d{1,15}$/.test(value.trim())) {
|
||||
const n = Number.parseInt(value.trim(), 10);
|
||||
return n > 0 ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function addressLines(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(text).filter((l): l is string => l !== null);
|
||||
}
|
||||
const single = text(value);
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
export function parseContacts(data: unknown): ParsedContact[] {
|
||||
if (!Array.isArray(data)) return [];
|
||||
const result: ParsedContact[] = [];
|
||||
for (const entry of data) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const id = toContactId(entry.id);
|
||||
if (id === null) continue;
|
||||
const fname = text(entry.fname);
|
||||
const lname = text(entry.lname);
|
||||
const organization = text(entry.organization);
|
||||
const alias = text(entry.alias);
|
||||
const person = [fname, lname].filter(Boolean).join(' ');
|
||||
result.push({
|
||||
id,
|
||||
type: text(entry.type),
|
||||
displayName: organization ?? (person || (alias ?? `#${id}`)),
|
||||
fname,
|
||||
lname,
|
||||
organization,
|
||||
address: addressLines(entry.address),
|
||||
pcode: text(entry.pcode),
|
||||
city: text(entry.city),
|
||||
country: text(entry.country),
|
||||
email: text(entry.email),
|
||||
phone: text(entry.phone),
|
||||
alias,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isoOrNull(value: unknown): string | null {
|
||||
const raw = text(value);
|
||||
if (!raw) return null;
|
||||
const date = new Date(raw);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
export function parseDomains(data: unknown): ParsedDomain[] {
|
||||
if (!Array.isArray(data)) return [];
|
||||
const result: ParsedDomain[] = [];
|
||||
for (const entry of data) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const name = text(entry.name);
|
||||
if (!name) continue;
|
||||
const owner = entry.ownerc;
|
||||
result.push({
|
||||
name,
|
||||
expire: isoOrNull(entry.expire),
|
||||
registryStatus: text(entry.registryStatus),
|
||||
cancelationStatus: text(entry.cancelationStatus),
|
||||
ownerContactId: isRecord(owner) ? toContactId(owner.id) : toContactId(owner),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Kleiner Zwischenspeicher im Arbeitsspeicher (quick-261008-dts, D-I).
|
||||
*
|
||||
* Warum im Arbeitsspeicher: AutoDNS erlaubt nur 3 Anfragen pro Sekunde und IP,
|
||||
* die Listen werden aber bei jedem Oeffnen eines Reiters gebraucht. 60 Sekunden
|
||||
* reichen, um dieses Limit zu schuetzen, und es gibt keinen Zustand, der einen
|
||||
* Neustart ueberleben muss. Der Schluessel enthaelt Organisation, System und den
|
||||
* Aenderungsstand der Einstellungen (`DomainsConfig.updatedAt`): wer Zugang oder
|
||||
* System wechselt, sieht nie Daten des vorherigen Zugangs.
|
||||
*/
|
||||
export class TtlCache<T> {
|
||||
private readonly entries = new Map<string, { value: T; expiresAt: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly ttlMs: number,
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
) {}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (entry.expiresAt <= this.now()) {
|
||||
this.entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T): void {
|
||||
this.prune();
|
||||
this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs });
|
||||
}
|
||||
|
||||
/** Loescht alle Eintraege, deren Schluessel mit `prefix` beginnt. */
|
||||
deleteByPrefix(prefix: string): void {
|
||||
for (const key of [...this.entries.keys()]) {
|
||||
if (key.startsWith(prefix)) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private prune(): void {
|
||||
const now = this.now();
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.expiresAt <= now) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
|
||||
}));
|
||||
|
||||
vi.mock('./autodns-client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./autodns-client')>()),
|
||||
autodnsRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
import { autodnsRequest } from './autodns-client';
|
||||
import { DomainsDirectoryService } from './domains-directory.service';
|
||||
|
||||
const mockRequest = vi.mocked(autodnsRequest);
|
||||
|
||||
const CREDENTIALS = { environment: 'DEMO', user: 'api-user', password: 'geheim', context: 4 };
|
||||
|
||||
function success(data: unknown[], summary: number | null = null) {
|
||||
return {
|
||||
ok: true as const,
|
||||
httpStatus: 200,
|
||||
statusCode: 'S0301',
|
||||
statusType: 'SUCCESS',
|
||||
object: { type: 'contact', value: null, summary },
|
||||
data,
|
||||
messages: [],
|
||||
stid: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
isOwnCompany: boolean;
|
||||
}
|
||||
interface Assignment {
|
||||
tenantId: string;
|
||||
environment: 'DEMO' | 'LIVE';
|
||||
autodnsContactId: string;
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
function matches(row: Record<string, any>, where: Record<string, any> = {}): boolean {
|
||||
return Object.entries(where).every(([key, expected]) => {
|
||||
if (key === 'NOT') return !matches(row, expected);
|
||||
if (expected && typeof expected === 'object' && 'in' in expected) {
|
||||
return (expected.in as unknown[]).includes(row[key]);
|
||||
}
|
||||
return row[key] === expected;
|
||||
});
|
||||
}
|
||||
|
||||
function makeService(opts: { environment?: 'DEMO' | 'LIVE'; configVersion?: number } = {}) {
|
||||
const state = {
|
||||
customers: [] as Customer[],
|
||||
assignments: [] as Assignment[],
|
||||
environment: opts.environment ?? ('DEMO' as 'DEMO' | 'LIVE'),
|
||||
configVersion: opts.configVersion ?? 1,
|
||||
nextId: 1,
|
||||
uniqueNameConflict: false,
|
||||
};
|
||||
const customerApi = {
|
||||
findMany: vi.fn(async ({ where }: any) =>
|
||||
state.customers.filter((c) => matches(c, where)).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
),
|
||||
findFirst: vi.fn(
|
||||
async ({ where }: any) => state.customers.find((c) => matches(c, where)) ?? null,
|
||||
),
|
||||
create: vi.fn(async ({ data }: any) => {
|
||||
if (state.uniqueNameConflict) throw Object.assign(new Error('unique'), { code: 'P2002' });
|
||||
const row = { id: `c${state.nextId++}`, ...data };
|
||||
state.customers.push(row);
|
||||
return row;
|
||||
}),
|
||||
updateMany: vi.fn(async ({ where, data }: any) => {
|
||||
const rows = state.customers.filter((c) => matches(c, where));
|
||||
for (const r of rows) Object.assign(r, data);
|
||||
return { count: rows.length };
|
||||
}),
|
||||
deleteMany: vi.fn(async ({ where }: any) => {
|
||||
const before = state.customers.length;
|
||||
state.customers = state.customers.filter((c) => !matches(c, where));
|
||||
return { count: before - state.customers.length };
|
||||
}),
|
||||
};
|
||||
const assignmentApi = {
|
||||
findMany: vi.fn(async ({ where }: any) => state.assignments.filter((a) => matches(a, where))),
|
||||
count: vi.fn(
|
||||
async ({ where }: any) => state.assignments.filter((a) => matches(a, where)).length,
|
||||
),
|
||||
upsert: vi.fn(async ({ where, create, update }: any) => {
|
||||
const key = where.tenantId_environment_autodnsContactId;
|
||||
const row = state.assignments.find(
|
||||
(a) =>
|
||||
a.tenantId === key.tenantId &&
|
||||
a.environment === key.environment &&
|
||||
a.autodnsContactId === key.autodnsContactId,
|
||||
);
|
||||
if (row) Object.assign(row, update);
|
||||
else state.assignments.push({ ...create });
|
||||
}),
|
||||
deleteMany: vi.fn(async ({ where }: any) => {
|
||||
const before = state.assignments.length;
|
||||
state.assignments = state.assignments.filter((a) => !matches(a, where));
|
||||
return { count: before - state.assignments.length };
|
||||
}),
|
||||
};
|
||||
const db = {
|
||||
__bound: () => ({ domainsCustomer: customerApi, domainsContactAssignment: assignmentApi }),
|
||||
};
|
||||
const settings = {
|
||||
transport: {},
|
||||
getStatus: vi.fn(async () => ({ environment: state.environment })),
|
||||
getActiveCredentials: vi.fn(async () => ({
|
||||
environment: state.environment,
|
||||
credentials: { ...CREDENTIALS, environment: state.environment },
|
||||
configVersion: state.configVersion,
|
||||
})),
|
||||
};
|
||||
const service = new DomainsDirectoryService(db as any, settings as any);
|
||||
return { service, state, customerApi, assignmentApi, settings };
|
||||
}
|
||||
|
||||
function contactPage(from: number, count: number) {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: from + i, lname: `Name${from + i}` }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest.mockReset();
|
||||
});
|
||||
|
||||
describe('listContacts', () => {
|
||||
it('liest die erste Seite mit genau dem erwarteten Aufruf', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 1, lname: 'A' }], 1));
|
||||
const result = await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
const [credentials, method, path, options] = mockRequest.mock.calls[0];
|
||||
expect(credentials).toEqual(CREDENTIALS);
|
||||
expect(method).toBe('POST');
|
||||
expect(path).toBe('/contact/_search');
|
||||
expect((options as any).body).toEqual({
|
||||
filters: [],
|
||||
view: { limit: 100, offset: 0 },
|
||||
orders: [{ key: 'lname', type: 'ASC' }],
|
||||
});
|
||||
expect((options as any).keys).toBeUndefined();
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.environment).toBe('DEMO');
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('summary 250 -> drei Aufrufe mit Offset 0, 100, 200', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest
|
||||
.mockResolvedValueOnce(success(contactPage(1, 100), 250))
|
||||
.mockResolvedValueOnce(success(contactPage(101, 100), 250))
|
||||
.mockResolvedValueOnce(success(contactPage(201, 50), 250));
|
||||
const result = await service.listContacts('t1');
|
||||
expect(mockRequest.mock.calls.map((c) => (c[3] as any).body.view.offset)).toEqual([
|
||||
0, 100, 200,
|
||||
]);
|
||||
expect(result.contacts).toHaveLength(250);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('summary 5000 -> haelt bei 2000 Eintraegen an und meldet truncated', async () => {
|
||||
const { service } = makeService();
|
||||
let from = 1;
|
||||
mockRequest.mockImplementation(async () => {
|
||||
const page = success(contactPage(from, 100), 5000);
|
||||
from += 100;
|
||||
return page;
|
||||
});
|
||||
const result = await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(20);
|
||||
expect(result.contacts).toHaveLength(2000);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('nimmt die Kundenzuordnung nur aus dem AKTIVEN System', async () => {
|
||||
const { service, state } = makeService({ environment: 'DEMO' });
|
||||
state.customers.push({ id: 'c-demo', tenantId: 't1', name: 'Demo-Kunde', isOwnCompany: false });
|
||||
state.customers.push({ id: 'c-live', tenantId: 't1', name: 'Live-Kunde', isOwnCompany: false });
|
||||
state.assignments.push(
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '1', customerId: 'c-demo' },
|
||||
{ tenantId: 't1', environment: 'LIVE', autodnsContactId: '2', customerId: 'c-live' },
|
||||
{ tenantId: 't1', environment: 'LIVE', autodnsContactId: '1', customerId: 'c-live' },
|
||||
);
|
||||
mockRequest.mockResolvedValueOnce(
|
||||
success(
|
||||
[
|
||||
{ id: 1, lname: 'Eins' },
|
||||
{ id: 2, lname: 'Zwei' },
|
||||
],
|
||||
2,
|
||||
),
|
||||
);
|
||||
const { contacts } = await service.listContacts('t1');
|
||||
expect(contacts.map((c) => [c.id, c.customerId, c.customerName])).toEqual([
|
||||
[1, 'c-demo', 'Demo-Kunde'],
|
||||
[2, null, null],
|
||||
]);
|
||||
});
|
||||
|
||||
it('nutzt den Zwischenspeicher, refresh und neuer Einstellungsstand umgehen ihn', async () => {
|
||||
const { service, state } = makeService();
|
||||
mockRequest.mockResolvedValue(success([{ id: 1, lname: 'A' }], 1));
|
||||
await service.listContacts('t1');
|
||||
await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
await service.listContacts('t1', { refresh: true });
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
state.configVersion = 2;
|
||||
await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Anmeldefehler bei AutoDNS -> 502 autodnsAuth', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
kind: 'auth',
|
||||
httpStatus: 401,
|
||||
statusCode: null,
|
||||
messages: [],
|
||||
stid: null,
|
||||
});
|
||||
const error = await service.listContacts('t1').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(BadGatewayException);
|
||||
expect(error.getResponse().code).toBe('autodnsAuth');
|
||||
});
|
||||
|
||||
it('nicht eingerichtet -> 409 notConfigured, ohne Aufruf', async () => {
|
||||
const { service, settings } = makeService();
|
||||
settings.getActiveCredentials.mockRejectedValueOnce(
|
||||
new ConflictException({ code: 'notConfigured', message: 'x' }),
|
||||
);
|
||||
const error = await service.listContacts('t1').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getResponse().code).toBe('notConfigured');
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createContact', () => {
|
||||
const person = {
|
||||
type: 'PERSON' as const,
|
||||
fname: 'Erika',
|
||||
lname: 'Muster',
|
||||
street: ['Musterstraße 1'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'erika@example.com',
|
||||
phone: '+49 30 123456',
|
||||
};
|
||||
|
||||
it('PERSON: genau ein POST /contact mit dem erwarteten Koerper, ohne organization', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 4711 }], 1));
|
||||
const created = await service.createContact('t1', person);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
const [, method, path, options] = mockRequest.mock.calls[0];
|
||||
expect(method).toBe('POST');
|
||||
expect(path).toBe('/contact');
|
||||
expect((options as any).body).toEqual({
|
||||
type: 'PERSON',
|
||||
fname: 'Erika',
|
||||
lname: 'Muster',
|
||||
address: ['Musterstraße 1'],
|
||||
pcode: '12345',
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: 'erika@example.com',
|
||||
phone: '+49 30 123456',
|
||||
});
|
||||
expect('organization' in (options as any).body).toBe(false);
|
||||
expect(created.id).toBe(4711);
|
||||
expect(created.displayName).toBe('Erika Muster');
|
||||
});
|
||||
|
||||
it('ORG verlangt die Organisation und sendet sie mit', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.createContact('t1', { ...person, type: 'ORG', organization: ' ' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 5 }], 1));
|
||||
const created = await service.createContact('t1', {
|
||||
...person,
|
||||
type: 'ORG',
|
||||
organization: ' Muster GmbH ',
|
||||
});
|
||||
expect((mockRequest.mock.calls[0][3] as any).body.organization).toBe('Muster GmbH');
|
||||
expect(created.displayName).toBe('Muster GmbH');
|
||||
});
|
||||
|
||||
it('fasst Leerraum in der Telefonnummer zusammen', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 9 }], 1));
|
||||
await service.createContact('t1', { ...person, phone: ' +49 30 123456 ' });
|
||||
expect((mockRequest.mock.calls[0][3] as any).body.phone).toBe('+49 30 123456');
|
||||
});
|
||||
|
||||
it('mit Kunde: legt die Zuordnung im aktiven System an', async () => {
|
||||
const { service, state } = makeService();
|
||||
state.customers.push({ id: 'c1', tenantId: 't1', name: 'Kunde A', isOwnCompany: false });
|
||||
mockRequest.mockResolvedValueOnce(success([{ id: 4711 }], 1));
|
||||
const created = await service.createContact('t1', { ...person, customerId: 'c1' });
|
||||
expect(state.assignments).toEqual([
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '4711', customerId: 'c1' },
|
||||
]);
|
||||
expect(created.customerName).toBe('Kunde A');
|
||||
expect(JSON.stringify(mockRequest.mock.calls[0][3])).not.toContain('Kunde A');
|
||||
});
|
||||
|
||||
it('unbekannter Kunde -> 404, bevor AutoDNS etwas anlegt', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.createContact('t1', { ...person, customerId: 'gibt-es-nicht' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leert den Zwischenspeicher der Organisation', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValue(success([{ id: 1, lname: 'A' }], 1));
|
||||
await service.listContacts('t1');
|
||||
await service.createContact('t1', person);
|
||||
mockRequest.mockClear();
|
||||
mockRequest.mockResolvedValue(success([{ id: 1, lname: 'A' }], 1));
|
||||
await service.listContacts('t1');
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignContacts', () => {
|
||||
it('upsert je verschiedener Id im aktiven System', async () => {
|
||||
const { service, state, assignmentApi } = makeService();
|
||||
state.customers.push({ id: 'c1', tenantId: 't1', name: 'A', isOwnCompany: false });
|
||||
const result = await service.assignContacts('t1', { contactIds: [1, 2, 2], customerId: 'c1' });
|
||||
expect(result).toEqual({ assigned: 2, removed: 0 });
|
||||
expect(assignmentApi.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(state.assignments.map((a) => [a.environment, a.autodnsContactId, a.customerId])).toEqual(
|
||||
[
|
||||
['DEMO', '1', 'c1'],
|
||||
['DEMO', '2', 'c1'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('customerId null loest die Zuordnung nur im aktiven System', async () => {
|
||||
const { service, state } = makeService();
|
||||
state.assignments.push(
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '1', customerId: 'c1' },
|
||||
{ tenantId: 't1', environment: 'LIVE', autodnsContactId: '1', customerId: 'c1' },
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '3', customerId: 'c1' },
|
||||
);
|
||||
const result = await service.assignContacts('t1', { contactIds: [1, 2], customerId: null });
|
||||
expect(result).toEqual({ assigned: 0, removed: 1 });
|
||||
expect(state.assignments.map((a) => `${a.environment}:${a.autodnsContactId}`)).toEqual([
|
||||
'LIVE:1',
|
||||
'DEMO:3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fremder Kunde -> 404, mehr als 500 Kontakte -> 400', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.assignContacts('t1', { contactIds: [1], customerId: 'fremd' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
const many = Array.from({ length: 501 }, (_, i) => i + 1);
|
||||
await expect(
|
||||
service.assignContacts('t1', { contactIds: many, customerId: null }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kunden', () => {
|
||||
it('legt an, trimmt den Namen und lehnt doppelte Namen mit 409 ab', async () => {
|
||||
const { service } = makeService();
|
||||
const created = await service.createCustomer('t1', { name: ' Kunde A ' });
|
||||
expect(created).toMatchObject({ name: 'Kunde A', isOwnCompany: false, contactCount: 0 });
|
||||
const error = await service.createCustomer('t1', { name: 'Kunde A' }).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getResponse().code).toBe('customerNameTaken');
|
||||
});
|
||||
|
||||
it('Unique-Verletzung beim Anlegen (Wettlauf) -> 409 customerNameTaken', async () => {
|
||||
const { service, state } = makeService();
|
||||
state.uniqueNameConflict = true;
|
||||
const error = await service.createCustomer('t1', { name: 'X' }).catch((e) => e);
|
||||
expect(error.getResponse().code).toBe('customerNameTaken');
|
||||
});
|
||||
|
||||
it('eigene Firma: loest die Markierung bei allen anderen', async () => {
|
||||
const { service, state } = makeService();
|
||||
await service.createCustomer('t1', { name: 'Erste', isOwnCompany: true });
|
||||
await service.createCustomer('t1', { name: 'Zweite', isOwnCompany: true });
|
||||
expect(state.customers.map((c) => [c.name, c.isOwnCompany])).toEqual([
|
||||
['Erste', false],
|
||||
['Zweite', true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('abgelehnter Name kostet keinem anderen Kunden die Markierung', async () => {
|
||||
const { service, state } = makeService();
|
||||
await service.createCustomer('t1', { name: 'Eigene', isOwnCompany: true });
|
||||
await service.createCustomer('t1', { name: 'Doppelt' });
|
||||
await expect(
|
||||
service.createCustomer('t1', { name: 'Doppelt', isOwnCompany: true }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(state.customers.find((c) => c.name === 'Eigene')?.isOwnCompany).toBe(true);
|
||||
});
|
||||
|
||||
it('aendern und loeschen fremder Ids -> 404', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(service.updateCustomer('t1', 'fremd', { name: 'X' })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.deleteCustomer('t1', 'fremd')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('aendern: Name und Markierung', async () => {
|
||||
const { service, state } = makeService();
|
||||
const a = await service.createCustomer('t1', { name: 'A', isOwnCompany: true });
|
||||
const b = await service.createCustomer('t1', { name: 'B' });
|
||||
const updated = await service.updateCustomer('t1', b.id, { name: 'B2', isOwnCompany: true });
|
||||
expect(updated).toMatchObject({ id: b.id, name: 'B2', isOwnCompany: true });
|
||||
expect(state.customers.find((c) => c.id === a.id)?.isOwnCompany).toBe(false);
|
||||
});
|
||||
|
||||
it('loeschen mit Zuordnungen -> 409 customerInUse, sonst geloescht', async () => {
|
||||
const { service, state } = makeService();
|
||||
const a = await service.createCustomer('t1', { name: 'A' });
|
||||
state.assignments.push({
|
||||
tenantId: 't1',
|
||||
environment: 'LIVE',
|
||||
autodnsContactId: '7',
|
||||
customerId: a.id,
|
||||
});
|
||||
const error = await service.deleteCustomer('t1', a.id).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(ConflictException);
|
||||
expect(error.getResponse().code).toBe('customerInUse');
|
||||
state.assignments.length = 0;
|
||||
await expect(service.deleteCustomer('t1', a.id)).resolves.toEqual({ deleted: true });
|
||||
expect(state.customers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('listCustomers: Anzahl der Kontakte im aktiven System, nach Name sortiert', async () => {
|
||||
const { service, state } = makeService({ environment: 'DEMO' });
|
||||
const b = await service.createCustomer('t1', { name: 'B' });
|
||||
const a = await service.createCustomer('t1', { name: 'A', isOwnCompany: true });
|
||||
state.assignments.push(
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '1', customerId: a.id },
|
||||
{ tenantId: 't1', environment: 'DEMO', autodnsContactId: '2', customerId: a.id },
|
||||
{ tenantId: 't1', environment: 'LIVE', autodnsContactId: '3', customerId: b.id },
|
||||
);
|
||||
const list = await service.listCustomers('t1');
|
||||
expect(list).toEqual([
|
||||
{ id: a.id, name: 'A', isOwnCompany: true, contactCount: 2 },
|
||||
{ id: b.id, name: 'B', isOwnCompany: false, contactCount: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listDomains', () => {
|
||||
it('liest /domain/_search mit keys und ordnet Inhaber und Kunde zu', async () => {
|
||||
const { service, state } = makeService();
|
||||
state.customers.push({ id: 'c1', tenantId: 't1', name: 'Kunde A', isOwnCompany: false });
|
||||
state.assignments.push({
|
||||
tenantId: 't1',
|
||||
environment: 'DEMO',
|
||||
autodnsContactId: '11',
|
||||
customerId: 'c1',
|
||||
});
|
||||
mockRequest.mockImplementation(async (_c, _m, path) => {
|
||||
if (path === '/domain/_search') {
|
||||
return success(
|
||||
[
|
||||
{
|
||||
name: 'beispiel.de',
|
||||
expire: '2027-03-01T00:00:00.000Z',
|
||||
registryStatus: 'ACTIVE',
|
||||
cancelationStatus: 'NONE',
|
||||
ownerc: { id: 11 },
|
||||
},
|
||||
{
|
||||
name: 'frei.de',
|
||||
registryStatus: 'PENDING',
|
||||
cancelationStatus: 'CANCELATION',
|
||||
ownerc: { id: 99 },
|
||||
},
|
||||
{ name: 'ohne-inhaber.de' },
|
||||
],
|
||||
3,
|
||||
);
|
||||
}
|
||||
return success([{ id: 11, organization: 'Muster GmbH' }], 1);
|
||||
});
|
||||
const result = await service.listDomains('t1');
|
||||
|
||||
const domainCall = mockRequest.mock.calls.find((c) => c[2] === '/domain/_search');
|
||||
expect(domainCall?.[1]).toBe('POST');
|
||||
expect((domainCall?.[3] as any).keys).toEqual(['expire', 'ownerc']);
|
||||
expect((domainCall?.[3] as any).body).toEqual({
|
||||
filters: [],
|
||||
view: { limit: 100, offset: 0 },
|
||||
orders: [{ key: 'name', type: 'ASC' }],
|
||||
});
|
||||
|
||||
expect(result.domains).toEqual([
|
||||
{
|
||||
name: 'beispiel.de',
|
||||
expire: '2027-03-01T00:00:00.000Z',
|
||||
status: 'ACTIVE',
|
||||
cancelationPending: false,
|
||||
ownerContactId: 11,
|
||||
ownerName: 'Muster GmbH',
|
||||
customerId: 'c1',
|
||||
customerName: 'Kunde A',
|
||||
},
|
||||
{
|
||||
name: 'frei.de',
|
||||
expire: null,
|
||||
status: 'PENDING',
|
||||
cancelationPending: true,
|
||||
ownerContactId: 99,
|
||||
ownerName: null,
|
||||
customerId: null,
|
||||
customerName: null,
|
||||
},
|
||||
{
|
||||
name: 'ohne-inhaber.de',
|
||||
expire: null,
|
||||
status: null,
|
||||
cancelationPending: false,
|
||||
ownerContactId: null,
|
||||
ownerName: null,
|
||||
customerId: null,
|
||||
customerName: null,
|
||||
},
|
||||
]);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('meldet truncated bei mehr als 2000 Domains', async () => {
|
||||
const { service } = makeService();
|
||||
let n = 0;
|
||||
mockRequest.mockImplementation(async (_c, _m, path) => {
|
||||
if (path === '/domain/_search') {
|
||||
const page = Array.from({ length: 100 }, () => ({ name: `d${n++}.de` }));
|
||||
return success(page, 2500);
|
||||
}
|
||||
return success([], 0);
|
||||
});
|
||||
const result = await service.listDomains('t1');
|
||||
expect(result.domains).toHaveLength(2000);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findContactsByIds', () => {
|
||||
it('liefert nur die gewuenschten Kontakte', async () => {
|
||||
const { service } = makeService();
|
||||
mockRequest.mockResolvedValueOnce(
|
||||
success(
|
||||
[
|
||||
{ id: 1, lname: 'A' },
|
||||
{ id: 2, lname: 'B' },
|
||||
{ id: 3, lname: 'C' },
|
||||
],
|
||||
3,
|
||||
),
|
||||
);
|
||||
const result = await service.findContactsByIds('t1', [1, 3]);
|
||||
expect(result.map((c) => c.id)).toEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,585 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import {
|
||||
type AutodnsCredentials,
|
||||
type AutodnsResult,
|
||||
type AutodnsSuccess,
|
||||
autodnsRequest,
|
||||
} from './autodns-client';
|
||||
import { type ParsedContact, parseContacts, parseDomains, toContactId } from './autodns-parse';
|
||||
import { autodnsFailureToHttp, type DomainsEnvironment } from './domains.types';
|
||||
import { TtlCache } from './domains-cache';
|
||||
import { DomainsSettingsService } from './domains-settings.service';
|
||||
import type { AssignDomainsContactsDto, CreateDomainsContactDto } from './dto/domains-contact.dto';
|
||||
import type { DomainsCustomerDto } from './dto/domains-customer.dto';
|
||||
|
||||
/** Seitengroesse und Obergrenze der Listen (D-I). */
|
||||
export const DIRECTORY_PAGE_SIZE = 100;
|
||||
export const DIRECTORY_MAX_ENTRIES = 2000;
|
||||
export const DIRECTORY_CACHE_TTL_MS = 60_000;
|
||||
export const MAX_ASSIGN_CONTACTS = 500;
|
||||
|
||||
export interface DomainsCustomerView {
|
||||
id: string;
|
||||
name: string;
|
||||
isOwnCompany: boolean;
|
||||
/** Zugeordnete Kontakte im AKTIVEN System. */
|
||||
contactCount: number;
|
||||
}
|
||||
|
||||
export interface DomainsContactView extends ParsedContact {
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
export interface DomainsContactListView {
|
||||
environment: DomainsEnvironment;
|
||||
contacts: DomainsContactView[];
|
||||
truncated: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface DomainsDomainView {
|
||||
name: string;
|
||||
expire: string | null;
|
||||
/** Rohwert von AutoDNS (`registryStatus`); die Beschriftung macht der Browser. */
|
||||
status: string | null;
|
||||
cancelationPending: boolean;
|
||||
ownerContactId: number | null;
|
||||
ownerName: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
}
|
||||
|
||||
export interface DomainsDomainListView {
|
||||
environment: DomainsEnvironment;
|
||||
domains: DomainsDomainView[];
|
||||
truncated: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
interface RawList<T> {
|
||||
items: T[];
|
||||
truncated: boolean;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
interface CustomerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
isOwnCompany: boolean;
|
||||
}
|
||||
|
||||
interface AssignmentRow {
|
||||
autodnsContactId: string;
|
||||
customerId: string;
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2002'
|
||||
);
|
||||
}
|
||||
|
||||
function isForeignKeyViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2003'
|
||||
);
|
||||
}
|
||||
|
||||
const CUSTOMER_NAME_TAKEN = {
|
||||
code: 'customerNameTaken',
|
||||
message: 'Es gibt bereits einen Kunden mit diesem Namen.',
|
||||
};
|
||||
const CUSTOMER_IN_USE = {
|
||||
code: 'customerInUse',
|
||||
message: 'Diesem Kunden sind noch Kontakte zugeordnet. Bitte lösen Sie zuerst die Zuordnungen.',
|
||||
};
|
||||
const CUSTOMER_NOT_FOUND = { code: 'customerNotFound', message: 'Der Kunde wurde nicht gefunden.' };
|
||||
|
||||
/** Kein `cancelationStatus` (oder "NONE") heisst: keine Kuendigung vorgemerkt. */
|
||||
function cancelationPending(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const upper = value.toUpperCase();
|
||||
return upper !== 'NONE' && upper !== 'NOT_SET';
|
||||
}
|
||||
|
||||
/**
|
||||
* Kunden, AutoDNS-Kontakte und Domainliste (quick-261008-dts).
|
||||
*
|
||||
* AutoDNS bleibt die Quelle der Wahrheit fuer Kontakte und Domains (D-B): sie
|
||||
* werden live gelesen und nie gespiegelt. Lokal liegt nur, was AutoDNS nicht
|
||||
* kennt — der Kunde und die Zuordnung Kontakt -> Kunde je System. Kontakte
|
||||
* werden hier angelegt und gelesen, aber nie geaendert oder geloescht (eine
|
||||
* Aenderung des Inhabers kann Domains beeinflussen, Recherche Pitfall 9).
|
||||
*
|
||||
* Gesamter Zugriff auf `domainsCustomer` und `domainsContactAssignment` liegt
|
||||
* ausschliesslich hier; jede Methode bindet ihren eigenen `forTenant`-Klienten.
|
||||
* Kein `include:`/relationales `select:` (Mandantenschutz-Inventar).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DomainsDirectoryService {
|
||||
private readonly contactCache = new TtlCache<RawList<ParsedContact>>(DIRECTORY_CACHE_TTL_MS);
|
||||
private readonly domainCache = new TtlCache<RawList<ReturnType<typeof parseDomains>[number]>>(
|
||||
DIRECTORY_CACHE_TTL_MS,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly settings: DomainsSettingsService,
|
||||
) {}
|
||||
|
||||
// --- AutoDNS-Aufruf ----------------------------------------------------------
|
||||
|
||||
private async call(
|
||||
credentials: AutodnsCredentials,
|
||||
method: 'GET' | 'POST',
|
||||
path: string,
|
||||
opts: { body?: unknown; keys?: string[] } = {},
|
||||
): Promise<AutodnsSuccess> {
|
||||
const result: AutodnsResult = await autodnsRequest(credentials, method, path, {
|
||||
...opts,
|
||||
fetchImpl: this.settings.transport?.fetchImpl,
|
||||
limiter: this.settings.transport?.limiter,
|
||||
});
|
||||
if (!result.ok) throw autodnsFailureToHttp(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine Liste seitenweise: nacheinander (der Begrenzer des Clients
|
||||
* taktet), nie parallel. Haelt bei `object.summary`, bei leerer Seite oder
|
||||
* bei 2000 Eintraegen an.
|
||||
*/
|
||||
private async fetchAll<T>(
|
||||
credentials: AutodnsCredentials,
|
||||
path: string,
|
||||
orderKey: string,
|
||||
parse: (data: unknown) => T[],
|
||||
keys?: string[],
|
||||
): Promise<{ items: T[]; truncated: boolean }> {
|
||||
const items: T[] = [];
|
||||
let total: number | null = null;
|
||||
let truncated = false;
|
||||
for (let offset = 0; ; offset += DIRECTORY_PAGE_SIZE) {
|
||||
if (offset >= DIRECTORY_MAX_ENTRIES) {
|
||||
truncated = total === null ? true : total > DIRECTORY_MAX_ENTRIES;
|
||||
break;
|
||||
}
|
||||
if (total !== null && offset >= total) break;
|
||||
const page = await this.call(credentials, 'POST', path, {
|
||||
body: {
|
||||
filters: [],
|
||||
view: { limit: DIRECTORY_PAGE_SIZE, offset },
|
||||
orders: [{ key: orderKey, type: 'ASC' }],
|
||||
},
|
||||
...(keys ? { keys } : {}),
|
||||
});
|
||||
if (total === null && page.object?.summary !== null && page.object?.summary !== undefined) {
|
||||
total = page.object.summary;
|
||||
}
|
||||
const parsed = parse(page.data);
|
||||
items.push(...parsed);
|
||||
if (page.data.length === 0) break;
|
||||
if (total === null && page.data.length < DIRECTORY_PAGE_SIZE) break;
|
||||
}
|
||||
if (items.length > DIRECTORY_MAX_ENTRIES) {
|
||||
items.length = DIRECTORY_MAX_ENTRIES;
|
||||
truncated = true;
|
||||
}
|
||||
return { items, truncated };
|
||||
}
|
||||
|
||||
// --- Zwischenspeicher ---------------------------------------------------------
|
||||
|
||||
private cacheKey(
|
||||
tenantId: string,
|
||||
environment: DomainsEnvironment,
|
||||
configVersion: number,
|
||||
kind: 'contacts' | 'domains',
|
||||
): string {
|
||||
return `${tenantId}|${environment}|${configVersion}|${kind}`;
|
||||
}
|
||||
|
||||
/** Verwirft die Listen der Organisation (nach Kontakt anlegen / Registrierung). */
|
||||
invalidate(tenantId: string): void {
|
||||
this.contactCache.deleteByPrefix(`${tenantId}|`);
|
||||
this.domainCache.deleteByPrefix(`${tenantId}|`);
|
||||
}
|
||||
|
||||
// --- Zuordnungen --------------------------------------------------------------
|
||||
|
||||
private async loadAssignments(
|
||||
tenantId: string,
|
||||
environment: DomainsEnvironment,
|
||||
): Promise<Map<number, string>> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const rows = (await tenantPrisma.domainsContactAssignment.findMany({
|
||||
where: { tenantId, environment },
|
||||
})) as AssignmentRow[];
|
||||
const map = new Map<number, string>();
|
||||
for (const row of rows) {
|
||||
const id = toContactId(row.autodnsContactId);
|
||||
if (id !== null) map.set(id, row.customerId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private async loadCustomers(tenantId: string): Promise<CustomerRow[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return (await tenantPrisma.domainsCustomer.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
})) as CustomerRow[];
|
||||
}
|
||||
|
||||
private async requireCustomer(tenantId: string, customerId: string): Promise<CustomerRow> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const customer = (await tenantPrisma.domainsCustomer.findFirst({
|
||||
where: { id: customerId, tenantId },
|
||||
})) as CustomerRow | null;
|
||||
if (!customer) throw new NotFoundException(CUSTOMER_NOT_FOUND);
|
||||
return customer;
|
||||
}
|
||||
|
||||
// --- Kunden -------------------------------------------------------------------
|
||||
|
||||
async listCustomers(tenantId: string): Promise<DomainsCustomerView[]> {
|
||||
const { environment } = await this.settings.getStatus(tenantId);
|
||||
const [customers, assignments] = await Promise.all([
|
||||
this.loadCustomers(tenantId),
|
||||
this.loadAssignments(tenantId, environment),
|
||||
]);
|
||||
const counts = new Map<string, number>();
|
||||
for (const customerId of assignments.values()) {
|
||||
counts.set(customerId, (counts.get(customerId) ?? 0) + 1);
|
||||
}
|
||||
return customers.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
isOwnCompany: c.isOwnCompany,
|
||||
contactCount: counts.get(c.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
private cleanCustomerName(dto: DomainsCustomerDto): string {
|
||||
const name = (dto.name ?? '').trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException({
|
||||
code: 'customerNameRequired',
|
||||
message: 'Bitte geben Sie einen Namen an.',
|
||||
});
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
async createCustomer(tenantId: string, dto: DomainsCustomerDto): Promise<DomainsCustomerView> {
|
||||
const name = this.cleanCustomerName(dto);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
// Namenspruefung VOR dem Loesen der Markierung "eigene Firma", damit ein
|
||||
// abgelehnter Name die Markierung eines anderen Kunden nicht kostet.
|
||||
const existing = await tenantPrisma.domainsCustomer.findFirst({ where: { tenantId, name } });
|
||||
if (existing) throw new ConflictException(CUSTOMER_NAME_TAKEN);
|
||||
|
||||
const isOwnCompany = dto.isOwnCompany === true;
|
||||
if (isOwnCompany) {
|
||||
await tenantPrisma.domainsCustomer.updateMany({
|
||||
where: { tenantId, isOwnCompany: true },
|
||||
data: { isOwnCompany: false },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const created = (await tenantPrisma.domainsCustomer.create({
|
||||
data: { tenantId, name, isOwnCompany },
|
||||
})) as CustomerRow;
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.name,
|
||||
isOwnCompany: created.isOwnCompany,
|
||||
contactCount: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new ConflictException(CUSTOMER_NAME_TAKEN);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCustomer(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: DomainsCustomerDto,
|
||||
): Promise<DomainsCustomerView> {
|
||||
const name = this.cleanCustomerName(dto);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const current = await this.requireCustomer(tenantId, id);
|
||||
|
||||
const isOwnCompany = dto.isOwnCompany === undefined ? current.isOwnCompany : dto.isOwnCompany;
|
||||
if (isOwnCompany && !current.isOwnCompany) {
|
||||
await tenantPrisma.domainsCustomer.updateMany({
|
||||
where: { tenantId, isOwnCompany: true, NOT: { id } },
|
||||
data: { isOwnCompany: false },
|
||||
});
|
||||
}
|
||||
let count: number;
|
||||
try {
|
||||
({ count } = await tenantPrisma.domainsCustomer.updateMany({
|
||||
where: { id, tenantId },
|
||||
data: { name, isOwnCompany },
|
||||
}));
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new ConflictException(CUSTOMER_NAME_TAKEN);
|
||||
throw error;
|
||||
}
|
||||
if (count === 0) throw new NotFoundException(CUSTOMER_NOT_FOUND);
|
||||
const { environment } = await this.settings.getStatus(tenantId);
|
||||
const assignments = await this.loadAssignments(tenantId, environment);
|
||||
let contactCount = 0;
|
||||
for (const customerId of assignments.values()) if (customerId === id) contactCount += 1;
|
||||
return { id, name, isOwnCompany, contactCount };
|
||||
}
|
||||
|
||||
async deleteCustomer(tenantId: string, id: string): Promise<{ deleted: true }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
await this.requireCustomer(tenantId, id);
|
||||
const inUse = await tenantPrisma.domainsContactAssignment.count({
|
||||
where: { tenantId, customerId: id },
|
||||
});
|
||||
if (inUse > 0) throw new ConflictException(CUSTOMER_IN_USE);
|
||||
try {
|
||||
const { count } = await tenantPrisma.domainsCustomer.deleteMany({ where: { id, tenantId } });
|
||||
if (count === 0) throw new NotFoundException(CUSTOMER_NOT_FOUND);
|
||||
} catch (error) {
|
||||
if (isForeignKeyViolation(error)) throw new ConflictException(CUSTOMER_IN_USE);
|
||||
throw error;
|
||||
}
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// --- Kontakte -----------------------------------------------------------------
|
||||
|
||||
private async loadRawContacts(
|
||||
tenantId: string,
|
||||
refresh: boolean,
|
||||
): Promise<{
|
||||
environment: DomainsEnvironment;
|
||||
raw: RawList<ParsedContact>;
|
||||
}> {
|
||||
const { environment, credentials, configVersion } =
|
||||
await this.settings.getActiveCredentials(tenantId);
|
||||
const key = this.cacheKey(tenantId, environment, configVersion, 'contacts');
|
||||
if (!refresh) {
|
||||
const hit = this.contactCache.get(key);
|
||||
if (hit) return { environment, raw: hit };
|
||||
}
|
||||
const { items, truncated } = await this.fetchAll(
|
||||
credentials,
|
||||
'/contact/_search',
|
||||
'lname',
|
||||
parseContacts,
|
||||
);
|
||||
const raw: RawList<ParsedContact> = {
|
||||
items,
|
||||
truncated,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
this.contactCache.set(key, raw);
|
||||
return { environment, raw };
|
||||
}
|
||||
|
||||
async listContacts(
|
||||
tenantId: string,
|
||||
opts: { refresh?: boolean } = {},
|
||||
): Promise<DomainsContactListView> {
|
||||
const { environment, raw } = await this.loadRawContacts(tenantId, opts.refresh === true);
|
||||
const [customers, assignments] = await Promise.all([
|
||||
this.loadCustomers(tenantId),
|
||||
this.loadAssignments(tenantId, environment),
|
||||
]);
|
||||
const names = new Map(customers.map((c) => [c.id, c.name]));
|
||||
return {
|
||||
environment,
|
||||
contacts: raw.items.map((contact) => {
|
||||
const customerId = assignments.get(contact.id) ?? null;
|
||||
return {
|
||||
...contact,
|
||||
customerId,
|
||||
customerName: customerId ? (names.get(customerId) ?? null) : null,
|
||||
};
|
||||
}),
|
||||
truncated: raw.truncated,
|
||||
fetchedAt: raw.fetchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fuer die Zusammenfassung einer Registrierung: Kontakte zu den gewaehlten Ids. */
|
||||
async findContactsByIds(tenantId: string, ids: number[]): Promise<DomainsContactView[]> {
|
||||
const wanted = new Set(ids);
|
||||
const { contacts } = await this.listContacts(tenantId);
|
||||
return contacts.filter((c) => wanted.has(c.id));
|
||||
}
|
||||
|
||||
async createContact(tenantId: string, dto: CreateDomainsContactDto): Promise<DomainsContactView> {
|
||||
const organization = (dto.organization ?? '').trim();
|
||||
if (dto.type === 'ORG' && !organization) {
|
||||
throw new BadRequestException({
|
||||
code: 'organizationRequired',
|
||||
message: 'Bitte geben Sie die Organisation an.',
|
||||
});
|
||||
}
|
||||
const { environment, credentials } = await this.settings.getActiveCredentials(tenantId);
|
||||
|
||||
// Unbekannter Kunde -> 404 BEVOR AutoDNS etwas anlegt.
|
||||
let customer: CustomerRow | null = null;
|
||||
if (dto.customerId) customer = await this.requireCustomer(tenantId, dto.customerId);
|
||||
|
||||
// Der Kunde gehoert nur Tessera — er wird nie an AutoDNS geschickt.
|
||||
const body: Record<string, unknown> = {
|
||||
type: dto.type,
|
||||
fname: dto.fname.trim(),
|
||||
lname: dto.lname.trim(),
|
||||
address: dto.street.map((line) => line.trim()),
|
||||
pcode: dto.pcode.trim(),
|
||||
city: dto.city.trim(),
|
||||
country: dto.country,
|
||||
email: dto.email.trim(),
|
||||
phone: dto.phone.trim().replace(/\s+/g, ' '),
|
||||
};
|
||||
if (dto.type === 'ORG') body.organization = organization;
|
||||
|
||||
const result = await this.call(credentials, 'POST', '/contact', { body });
|
||||
const first = result.data[0];
|
||||
const id =
|
||||
first && typeof first === 'object'
|
||||
? toContactId((first as Record<string, unknown>).id)
|
||||
: null;
|
||||
if (id === null) {
|
||||
// Nicht raten: der Kontakt kann angelegt worden sein.
|
||||
this.invalidate(tenantId);
|
||||
throw new ConflictException({
|
||||
code: 'contactIdMissing',
|
||||
message:
|
||||
'AutoDNS hat den Kontakt angenommen, aber keine Nummer geliefert. Bitte lesen Sie die Kontakte neu ein.',
|
||||
});
|
||||
}
|
||||
|
||||
if (customer) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
await tenantPrisma.domainsContactAssignment.upsert({
|
||||
where: {
|
||||
tenantId_environment_autodnsContactId: {
|
||||
tenantId,
|
||||
environment,
|
||||
autodnsContactId: String(id),
|
||||
},
|
||||
},
|
||||
create: { tenantId, environment, autodnsContactId: String(id), customerId: customer.id },
|
||||
update: { customerId: customer.id },
|
||||
});
|
||||
}
|
||||
this.invalidate(tenantId);
|
||||
|
||||
const [parsed] = parseContacts([{ ...body, id }]);
|
||||
return {
|
||||
...parsed,
|
||||
customerId: customer?.id ?? null,
|
||||
customerName: customer?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async assignContacts(
|
||||
tenantId: string,
|
||||
dto: AssignDomainsContactsDto,
|
||||
): Promise<{ assigned: number; removed: number }> {
|
||||
const ids = [...new Set(dto.contactIds)];
|
||||
if (ids.length > MAX_ASSIGN_CONTACTS) {
|
||||
throw new BadRequestException({
|
||||
code: 'tooManyContacts',
|
||||
message: `Es können höchstens ${MAX_ASSIGN_CONTACTS} Kontakte auf einmal zugeordnet werden.`,
|
||||
});
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'noContacts',
|
||||
message: 'Bitte wählen Sie mindestens einen Kontakt aus.',
|
||||
});
|
||||
}
|
||||
const { environment } = await this.settings.getStatus(tenantId);
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
if (dto.customerId === null) {
|
||||
const { count } = await tenantPrisma.domainsContactAssignment.deleteMany({
|
||||
where: { tenantId, environment, autodnsContactId: { in: ids.map(String) } },
|
||||
});
|
||||
return { assigned: 0, removed: count };
|
||||
}
|
||||
|
||||
const customer = await this.requireCustomer(tenantId, dto.customerId);
|
||||
for (const id of ids) {
|
||||
await tenantPrisma.domainsContactAssignment.upsert({
|
||||
where: {
|
||||
tenantId_environment_autodnsContactId: {
|
||||
tenantId,
|
||||
environment,
|
||||
autodnsContactId: String(id),
|
||||
},
|
||||
},
|
||||
create: { tenantId, environment, autodnsContactId: String(id), customerId: customer.id },
|
||||
update: { customerId: customer.id },
|
||||
});
|
||||
}
|
||||
return { assigned: ids.length, removed: 0 };
|
||||
}
|
||||
|
||||
// --- Domains ------------------------------------------------------------------
|
||||
|
||||
async listDomains(
|
||||
tenantId: string,
|
||||
opts: { refresh?: boolean } = {},
|
||||
): Promise<DomainsDomainListView> {
|
||||
const refresh = opts.refresh === true;
|
||||
const { environment, credentials, configVersion } =
|
||||
await this.settings.getActiveCredentials(tenantId);
|
||||
const key = this.cacheKey(tenantId, environment, configVersion, 'domains');
|
||||
|
||||
let raw = refresh ? undefined : this.domainCache.get(key);
|
||||
if (!raw) {
|
||||
const { items, truncated } = await this.fetchAll(
|
||||
credentials,
|
||||
'/domain/_search',
|
||||
'name',
|
||||
parseDomains,
|
||||
['expire', 'ownerc'],
|
||||
);
|
||||
raw = { items, truncated, fetchedAt: new Date().toISOString() };
|
||||
this.domainCache.set(key, raw);
|
||||
}
|
||||
|
||||
const contacts = await this.listContacts(tenantId, { refresh });
|
||||
const byId = new Map(contacts.contacts.map((c) => [c.id, c]));
|
||||
return {
|
||||
environment,
|
||||
domains: raw.items.map((d) => {
|
||||
const owner = d.ownerContactId !== null ? byId.get(d.ownerContactId) : undefined;
|
||||
return {
|
||||
name: d.name,
|
||||
expire: d.expire,
|
||||
status: d.registryStatus,
|
||||
cancelationPending: cancelationPending(d.cancelationStatus),
|
||||
ownerContactId: d.ownerContactId,
|
||||
ownerName: owner?.displayName ?? null,
|
||||
customerId: owner?.customerId ?? null,
|
||||
customerName: owner?.customerName ?? null,
|
||||
};
|
||||
}),
|
||||
truncated: raw.truncated,
|
||||
fetchedAt: raw.fetchedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,31 @@ import { DomainsController } from './domains.controller';
|
||||
const proto = DomainsController.prototype as any;
|
||||
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||
|
||||
const MANAGE_HANDLERS = [
|
||||
'getSettings',
|
||||
'saveSettings',
|
||||
'testConnection',
|
||||
'createCustomer',
|
||||
'updateCustomer',
|
||||
'deleteCustomer',
|
||||
'createContact',
|
||||
'assignContacts',
|
||||
];
|
||||
const USE_HANDLERS = ['getStatus', 'listCustomers', 'listContacts', 'listDomains'];
|
||||
|
||||
function makeDirectory() {
|
||||
return {
|
||||
listCustomers: vi.fn(async (..._a: unknown[]) => []),
|
||||
createCustomer: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
updateCustomer: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
deleteCustomer: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
listContacts: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
createContact: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
assignContacts: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
listDomains: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
};
|
||||
}
|
||||
|
||||
function makeSettings() {
|
||||
return {
|
||||
getStatus: vi.fn(async (..._a: unknown[]) => ({})),
|
||||
@@ -23,16 +48,18 @@ describe('DomainsController — Metadaten', () => {
|
||||
expect(Reflect.getMetadata(MODULE_SLUG_KEY, DomainsController)).toBe('domains');
|
||||
});
|
||||
|
||||
it('Einstellungen und Verbindungstest verlangen Verwalten, ohne Rollen-Decorator', () => {
|
||||
for (const name of ['getSettings', 'saveSettings', 'testConnection']) {
|
||||
it('Einstellungen, Verbindungstest, Kunden-/Kontakt-Schreibwege verlangen Verwalten, ohne Rollen-Decorator', () => {
|
||||
for (const name of MANAGE_HANDLERS) {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBe(true);
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('getStatus steht auf Benutzen-Ebene', () => {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.getStatus)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto.getStatus)).toBeUndefined();
|
||||
it('Lesewege stehen auf Benutzen-Ebene', () => {
|
||||
for (const name of USE_HANDLERS) {
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('Pfade und Methoden', () => {
|
||||
@@ -45,6 +72,14 @@ describe('DomainsController — Metadaten', () => {
|
||||
expect(route('getSettings')).toEqual([0, 'settings']);
|
||||
expect(route('saveSettings')).toEqual([2, 'settings']);
|
||||
expect(route('testConnection')).toEqual([1, 'connection-test']);
|
||||
expect(route('listCustomers')).toEqual([0, 'customers']);
|
||||
expect(route('createCustomer')).toEqual([1, 'customers']);
|
||||
expect(route('listContacts')).toEqual([0, 'contacts']);
|
||||
expect(route('createContact')).toEqual([1, 'contacts']);
|
||||
expect(route('assignContacts')).toEqual([1, 'contacts/assign']);
|
||||
expect(route('listDomains')).toEqual([0, 'domains']);
|
||||
expect(route('updateCustomer')).toEqual([2, 'customers/:id']);
|
||||
expect(route('deleteCustomer')).toEqual([3, 'customers/:id']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +90,7 @@ describe('DomainsController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||
);
|
||||
const isIdRoute = (n: string) => String(Reflect.getMetadata('path', proto[n])).includes(':id');
|
||||
const firstId = names.findIndex(isIdRoute);
|
||||
if (firstId === -1) return;
|
||||
expect(firstId, 'es gibt :id-Routen').toBeGreaterThan(-1);
|
||||
names.slice(firstId).forEach((n) => {
|
||||
expect(isIdRoute(n), `${n} steht nach einer :id-Route, ist aber statisch`).toBe(true);
|
||||
});
|
||||
@@ -65,7 +100,8 @@ describe('DomainsController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||
describe('DomainsController — Verhalten', () => {
|
||||
it('reicht req.tenantId weiter', async () => {
|
||||
const settings = makeSettings();
|
||||
const c = new DomainsController(settings as any);
|
||||
const directory = makeDirectory();
|
||||
const c = new DomainsController(settings as any, directory as any);
|
||||
await c.getStatus(req('t1'));
|
||||
await c.getSettings(req('t1'));
|
||||
await c.saveSettings(req('t1'), { demoUser: 'x' } as any);
|
||||
@@ -76,8 +112,42 @@ describe('DomainsController — Verhalten', () => {
|
||||
expect(settings.testConnection).toHaveBeenCalledWith('t1', 'DEMO');
|
||||
});
|
||||
|
||||
it('Kunden-, Kontakt- und Domainwege reichen req.tenantId weiter; refresh nur bei 1/true', async () => {
|
||||
const settings = makeSettings();
|
||||
const directory = makeDirectory();
|
||||
const c = new DomainsController(settings as any, directory as any);
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
await c.listCustomers(req('t1'));
|
||||
await c.createCustomer(req('t1'), { name: 'A' } as any);
|
||||
await c.updateCustomer(req('t1'), id, { name: 'B' } as any);
|
||||
await c.deleteCustomer(req('t1'), id);
|
||||
await c.listContacts(req('t1'), '1');
|
||||
await c.listContacts(req('t1'), 'true');
|
||||
await c.listContacts(req('t1'), undefined);
|
||||
await c.listContacts(req('t1'), '0');
|
||||
await c.createContact(req('t1'), { type: 'PERSON' } as any);
|
||||
await c.assignContacts(req('t1'), { contactIds: [1], customerId: null });
|
||||
await c.listDomains(req('t1'), 'true');
|
||||
expect(directory.listCustomers).toHaveBeenCalledWith('t1');
|
||||
expect(directory.createCustomer).toHaveBeenCalledWith('t1', { name: 'A' });
|
||||
expect(directory.updateCustomer).toHaveBeenCalledWith('t1', id, { name: 'B' });
|
||||
expect(directory.deleteCustomer).toHaveBeenCalledWith('t1', id);
|
||||
expect(directory.listContacts.mock.calls.map((x) => x[1])).toEqual([
|
||||
{ refresh: true },
|
||||
{ refresh: true },
|
||||
{ refresh: false },
|
||||
{ refresh: false },
|
||||
]);
|
||||
expect(directory.createContact).toHaveBeenCalledWith('t1', { type: 'PERSON' });
|
||||
expect(directory.assignContacts).toHaveBeenCalledWith('t1', {
|
||||
contactIds: [1],
|
||||
customerId: null,
|
||||
});
|
||||
expect(directory.listDomains).toHaveBeenCalledWith('t1', { refresh: true });
|
||||
});
|
||||
|
||||
it('ohne Mandantenkontext 403', async () => {
|
||||
const c = new DomainsController(makeSettings() as any);
|
||||
const c = new DomainsController(makeSettings() as any, makeDirectory() as any);
|
||||
await expect(c.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Post, Put, Req } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||
import { DomainsDirectoryService } from './domains-directory.service';
|
||||
import { DomainsSettingsService } from './domains-settings.service';
|
||||
import { AssignDomainsContactsDto, CreateDomainsContactDto } from './dto/domains-contact.dto';
|
||||
import { DomainsCustomerDto } from './dto/domains-customer.dto';
|
||||
import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.dto';
|
||||
|
||||
/** `?refresh=1` oder `?refresh=true` umgeht den Zwischenspeicher. */
|
||||
function isTruthyFlag(value: string | undefined): boolean {
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* `@UseModule('domains')` auf Klassenebene — Aktivierung UND Freigabe.
|
||||
* `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query.
|
||||
@@ -28,7 +48,10 @@ import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-
|
||||
@Controller('modules/domains')
|
||||
@UseModule('domains')
|
||||
export class DomainsController {
|
||||
constructor(private readonly settings: DomainsSettingsService) {}
|
||||
constructor(
|
||||
private readonly settings: DomainsSettingsService,
|
||||
private readonly directory: DomainsDirectoryService,
|
||||
) {}
|
||||
|
||||
private requireTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
@@ -60,4 +83,62 @@ export class DomainsController {
|
||||
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: TestDomainsConnectionDto) {
|
||||
return this.settings.testConnection(this.requireTenantId(req), dto.environment);
|
||||
}
|
||||
|
||||
// --- Kunden, Kontakte, Domains (Aufgabe 2) -----------------------------------
|
||||
// Alle statischen Routen vor den `:id`-Routen am Ende der Klasse.
|
||||
|
||||
@Get('customers')
|
||||
async listCustomers(@Req() req: AuthenticatedRequest) {
|
||||
return this.directory.listCustomers(this.requireTenantId(req));
|
||||
}
|
||||
|
||||
@Post('customers')
|
||||
@ModuleManage('domains')
|
||||
async createCustomer(@Req() req: AuthenticatedRequest, @Body() dto: DomainsCustomerDto) {
|
||||
return this.directory.createCustomer(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Get('contacts')
|
||||
async listContacts(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) {
|
||||
return this.directory.listContacts(this.requireTenantId(req), {
|
||||
refresh: isTruthyFlag(refresh),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('contacts')
|
||||
@ModuleManage('domains')
|
||||
async createContact(@Req() req: AuthenticatedRequest, @Body() dto: CreateDomainsContactDto) {
|
||||
return this.directory.createContact(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Post('contacts/assign')
|
||||
@ModuleManage('domains')
|
||||
async assignContacts(@Req() req: AuthenticatedRequest, @Body() dto: AssignDomainsContactsDto) {
|
||||
return this.directory.assignContacts(this.requireTenantId(req), dto);
|
||||
}
|
||||
|
||||
@Get('domains')
|
||||
async listDomains(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) {
|
||||
return this.directory.listDomains(this.requireTenantId(req), {
|
||||
refresh: isTruthyFlag(refresh),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Routen mit :id (immer ganz am Ende) -------------------------------------
|
||||
|
||||
@Put('customers/:id')
|
||||
@ModuleManage('domains')
|
||||
async updateCustomer(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: DomainsCustomerDto,
|
||||
) {
|
||||
return this.directory.updateCustomer(this.requireTenantId(req), id, dto);
|
||||
}
|
||||
|
||||
@Delete('customers/:id')
|
||||
@ModuleManage('domains')
|
||||
async deleteCustomer(@Req() req: AuthenticatedRequest, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.directory.deleteCustomer(this.requireTenantId(req), id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ModuleRegistryModule } from '../module-registry/module-registry.module'
|
||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||
import { DomainsController } from './domains.controller';
|
||||
import { seedDomainsModule } from './domains.seed';
|
||||
import { DomainsDirectoryService } from './domains-directory.service';
|
||||
import { DomainsSettingsService } from './domains-settings.service';
|
||||
|
||||
/**
|
||||
@@ -13,7 +14,7 @@ import { DomainsSettingsService } from './domains-settings.service';
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [DomainsController],
|
||||
providers: [DomainsSettingsService],
|
||||
providers: [DomainsSettingsService, DomainsDirectoryService],
|
||||
})
|
||||
export class DomainsModule implements OnModuleInit {
|
||||
private readonly logger = new Logger(DomainsModule.name);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
/** Neuer AutoDNS-Kontakt (quick-261008-dts, L-03). Person oder Organisation. */
|
||||
export class CreateDomainsContactDto {
|
||||
@IsIn(['PERSON', 'ORG'])
|
||||
type!: 'PERSON' | 'ORG';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
organization?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(80)
|
||||
fname!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(80)
|
||||
lname!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(3)
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@MaxLength(100, { each: true })
|
||||
street!: string[];
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
pcode!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(80)
|
||||
city!: string;
|
||||
|
||||
@Matches(/^[A-Z]{2}$/)
|
||||
country!: string;
|
||||
|
||||
@IsEmail()
|
||||
@MaxLength(200)
|
||||
email!: string;
|
||||
|
||||
@Matches(/^\+[0-9][0-9 .\-/]{5,30}$/)
|
||||
phone!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
}
|
||||
|
||||
/** Ein oder mehrere Kontakte einem Kunden zuordnen (oder die Zuordnung loesen). */
|
||||
export class AssignDomainsContactsDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
contactIds!: number[];
|
||||
|
||||
/** `null` loest die Zuordnung. */
|
||||
@ValidateIf((_o, value) => value !== null)
|
||||
@IsUUID()
|
||||
customerId!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/** Anlegen und Aendern eines Kunden (quick-261008-dts, D-H). */
|
||||
export class DomainsCustomerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isOwnCompany?: boolean;
|
||||
}
|
||||
@@ -85,14 +85,25 @@ describe('Umgestellte Handler (Verwalten)', () => {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['getSettings', 'saveSettings', 'testConnection'])(
|
||||
'DomainsController.%s verlangt Verwalten für domains (quick-261008-dts)',
|
||||
(name) => {
|
||||
expectManage(DomainsController, name, 'domains');
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
'getSettings',
|
||||
'saveSettings',
|
||||
'testConnection',
|
||||
'createCustomer',
|
||||
'updateCustomer',
|
||||
'deleteCustomer',
|
||||
'createContact',
|
||||
'assignContacts',
|
||||
])('DomainsController.%s verlangt Verwalten für domains (quick-261008-dts)', (name) => {
|
||||
expectManage(DomainsController, name, 'domains');
|
||||
});
|
||||
|
||||
it.each(['getStatus'])('DomainsController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
it.each([
|
||||
'getStatus',
|
||||
'listCustomers',
|
||||
'listContacts',
|
||||
'listDomains',
|
||||
])('DomainsController.%s bleibt auf Benutzen-Ebene', (name) => {
|
||||
const fn = handler(DomainsController, name);
|
||||
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
|
||||
|
||||
Reference in New Issue
Block a user