feat(domains): Domain registrieren mit Schutz vor Doppelbestellung, Aufträge, Changelog und Anleitung

- Verfügbarkeitsprüfung über DomainStudio, Entwurf mit Kontakten und Nameservern, Zusammenfassung mit System und Preis
- Verbindliches Abschicken: atomarer Anspruch DRAFT -> SUBMITTING vor dem Netzaufruf, genau ein POST /domain ohne Wiederholung, unklarer Ausgang als UNKNOWN, Bindung an das System
- Aufträge: Stand aus dem AutoDNS-Auftrag, Abgleich unklarer Aufträge, Verwerfen erst nach Abgleich
- Reiter Registrieren und Aufträge, Changelog, Anwender- und Administrationshandbuch, Mandantenschutz-Inventar

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:10:03 +02:00
parent 450218fe77
commit d332246bde
28 changed files with 4330 additions and 15 deletions
+145 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { parseContacts, parseDomains } from './autodns-parse';
import {
extractJobFromSubmit,
parseContacts,
parseDomainStudio,
parseDomains,
parseJob,
parseJobList,
} from './autodns-parse';
import { TtlCache } from './domains-cache';
describe('parseContacts', () => {
@@ -128,3 +135,140 @@ describe('TtlCache', () => {
expect(cache.get('t2|DEMO|1|contacts')).toBe(3);
});
});
function studio(domain: string, status: string, prices?: unknown[]) {
return {
domain,
services: {
whois: { data: { status } },
...(prices ? { price: { data: { prices } } } : {}),
},
};
}
describe('parseDomainStudio', () => {
it('liest FREE und den Ein-Jahres-Preis der angefragten Domain', () => {
const result = parseDomainStudio(
[
studio('beispiel.de', 'FREE', [
{ amount: 9.9, currency: 'EUR', period: { unit: 'YEAR', period: 2 } },
{ amount: 4.9, currency: 'EUR', period: { unit: 'YEAR', period: 1 } },
]),
],
'beispiel.de',
);
expect(result).toEqual({ whoisStatus: 'FREE', price: { amount: 4.9, currency: 'EUR' } });
});
it('liest den Betrag auch unter `price` und als Zeichenkette', () => {
const result = parseDomainStudio(
[studio('beispiel.de', 'FREE', [{ price: { amount: '4,90', currency: 'EUR' } }])],
'beispiel.de',
);
expect(result.price).toEqual({ amount: 4.9, currency: 'EUR' });
});
it('nimmt den ersten Preis, wenn kein Ein-Jahres-Eintrag da ist', () => {
const result = parseDomainStudio(
[studio('beispiel.de', 'FREE', [{ amount: 7, currency: 'EUR' }])],
'beispiel.de',
);
expect(result.price).toEqual({ amount: 7, currency: 'EUR' });
});
it('ohne Preisdienst ist der Preis null', () => {
expect(parseDomainStudio([studio('beispiel.de', 'ASSIGNED')], 'beispiel.de')).toEqual({
whoisStatus: 'ASSIGNED',
price: null,
});
});
it('ignoriert Umschlaege anderer Domains', () => {
expect(parseDomainStudio([studio('beispiel.com', 'FREE')], 'beispiel.de')).toEqual({
whoisStatus: 'NO_RESULT',
price: null,
});
});
it('erkennt die Unicode-Schreibweise der angefragten Domain', () => {
expect(parseDomainStudio([studio('müller.de', 'FREE')], 'xn--mller-kva.de').whoisStatus).toBe(
'FREE',
);
});
it('ist robust gegen Unsinn', () => {
expect(parseDomainStudio(null, 'a.de').whoisStatus).toBe('NO_RESULT');
expect(parseDomainStudio([null, 3, {}], 'a.de').whoisStatus).toBe('NO_RESULT');
});
});
describe('extractJobFromSubmit', () => {
it('liest data[0].id und status', () => {
expect(extractJobFromSubmit({ data: [{ id: 987, status: 'RUNNING' }], object: null })).toEqual({
id: '987',
status: 'RUNNING',
});
});
it('faellt auf object.value zurueck, wenn object.type job ist', () => {
expect(extractJobFromSubmit({ data: [], object: { type: 'job', value: '555' } })).toEqual({
id: '555',
status: null,
});
});
it('liefert id null ohne erkennbare Nummer', () => {
expect(extractJobFromSubmit({ data: [{}], object: { type: 'domain', value: 'x' } }).id).toBe(
null,
);
});
});
describe('parseJob', () => {
it('liest data[0].job.status', () => {
expect(parseJob([{ job: { id: 987, status: 'SUCCESS', subStatus: 'X' } }])).toMatchObject({
id: '987',
status: 'SUCCESS',
subStatus: 'X',
});
});
it('liest data[0].status ohne job-Ebene', () => {
expect(parseJob([{ id: 5, status: 'failed' }])?.status).toBe('FAILED');
});
it('sammelt Fehlermeldungen aus den Ereignissen', () => {
const job = parseJob([
{
job: {
id: 1,
status: 'FAILED',
events: [
{ status: 'SUCCESS', message: 'ok' },
{ status: 'ERROR', message: 'Nameserver nicht erreichbar' },
],
},
},
]);
expect(job?.messages).toEqual(['Nameserver nicht erreichbar']);
});
it('liefert null bei leeren Daten', () => {
expect(parseJob([])).toBeNull();
expect(parseJob(undefined)).toBeNull();
});
});
describe('parseJobList', () => {
it('liest Id, Objekt und Anlagezeit', () => {
const list = parseJobList([
{ job: { id: 9, status: 'running', created: '2026-10-08T10:00:00Z' }, object: 'Beispiel.de' },
{ job: { id: 10 }, object: { value: 'x.de' } },
{ object: 'ohne-id.de' },
]);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({ id: '9', status: 'RUNNING', object: 'beispiel.de' });
expect(list[0].created?.toISOString()).toBe('2026-10-08T10:00:00.000Z');
expect(list[1]).toMatchObject({ id: '10', object: 'x.de', created: null });
});
});
+184
View File
@@ -1,3 +1,5 @@
import { domainToASCII } from 'node:url';
/**
* Reine Auswertung der AutoDNS-Antwortdaten (quick-261008-dts) — kein Nest,
* kein Netz. Alles defensiv: unbekannte Felder werden ignoriert, Zeichenketten
@@ -120,3 +122,185 @@ export function parseDomains(data: unknown): ParsedDomain[] {
}
return result;
}
// --- Verfuegbarkeit (DomainStudio), Bestellung, Auftrag (Aufgabe 3) --------------
export interface ParsedPrice {
amount: number;
currency: string;
}
export interface ParsedAvailability {
/** WHOIS-Status der angefragten Domain, `NO_RESULT` wenn DomainStudio sie nicht lieferte. */
whoisStatus: string;
price: ParsedPrice | null;
}
function pickRecord(value: unknown, ...path: string[]): Record<string, unknown> | null {
let current: unknown = value;
for (const key of path) {
if (!isRecord(current)) return null;
current = current[key];
}
return isRecord(current) ? current : null;
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && /^\d+(?:[.,]\d+)?$/.test(value.trim())) {
return Number.parseFloat(value.trim().replace(',', '.'));
}
return null;
}
/** Ein Jahr: `{unit:'YEAR',period:1}`, `{unit:'MONTH',period:12}` oder die nackte Zahl 1. */
function isOneYear(period: unknown): boolean {
if (isRecord(period)) {
const unit = typeof period.unit === 'string' ? period.unit.toUpperCase() : '';
const n = toNumber(period.period);
return (unit === 'YEAR' && n === 1) || (unit === 'MONTH' && n === 12);
}
return toNumber(period) === 1;
}
function parsePriceEntry(entry: unknown): ParsedPrice | null {
if (!isRecord(entry)) return null;
// Betrag direkt am Eintrag oder darunter in `price` (Form laut Recherche unklar).
const source = isRecord(entry.price) ? entry.price : entry;
const amount = toNumber(source.amount);
const currency = text(source.currency);
if (amount === null || currency === null) return null;
return { amount, currency };
}
/**
* Wertet die Antwort von `POST /domainstudio` aus. Nur der Umschlag, dessen
* `domain` der angefragten Domain entspricht, zaehlt (D-K) — alles andere
* ist `NO_RESULT`. Als Preis gilt der Ein-Jahres-Eintrag, sonst der erste.
*/
export function parseDomainStudio(data: unknown, wantedAscii: string): ParsedAvailability {
const wanted = wantedAscii.toLowerCase();
if (Array.isArray(data)) {
for (const envelope of data) {
if (!isRecord(envelope)) continue;
const name = text(envelope.domain)?.toLowerCase();
if (!name) continue;
if (name !== wanted && domainToAsciiSafe(name) !== wanted) continue;
const whois = pickRecord(envelope, 'services', 'whois', 'data');
const status = whois ? text(whois.status) : null;
const prices = pickRecord(envelope, 'services', 'price', 'data')?.prices;
let price: ParsedPrice | null = null;
if (Array.isArray(prices)) {
const oneYear = prices.find((p) => isRecord(p) && isOneYear(p.period));
price = parsePriceEntry(oneYear) ?? parsePriceEntry(prices[0]);
}
return { whoisStatus: status ? status.toUpperCase() : 'NO_RESULT', price };
}
}
return { whoisStatus: 'NO_RESULT', price: null };
}
function domainToAsciiSafe(name: string): string {
try {
return domainToASCII(name);
} catch {
return name;
}
}
/** Auftragsnummern sind Dezimalzahlen; als Zeichenkette gefuehrt (keine int32-Grenze). */
function toJobId(value: unknown): string | null {
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value);
if (typeof value === 'string' && /^\d{1,20}$/.test(value.trim())) return value.trim();
return null;
}
/**
* Auftrag aus der Antwort von `POST /domain`: `data[0].id` / `data[0].status`,
* ersatzweise `object.value`, wenn `object.type === 'job'` (Recherche A4).
*/
export function extractJobFromSubmit(result: {
data: unknown[];
object: { type: string | null; value: string | null } | null;
}): { id: string | null; status: string | null } {
const first = result.data[0];
let id: string | null = null;
let status: string | null = null;
if (isRecord(first)) {
id = toJobId(first.id);
status = text(first.status);
}
if (id === null && result.object?.type === 'job') id = toJobId(result.object.value);
return { id, status };
}
export interface ParsedJob {
id: string | null;
status: string | null;
subStatus: string | null;
messages: string[];
}
/** `GET /job/{id}`: `data[0].job ?? data[0]`. */
export function parseJob(data: unknown): ParsedJob | null {
if (!Array.isArray(data) || data.length === 0) return null;
const first = data[0];
if (!isRecord(first)) return null;
const job = isRecord(first.job) ? first.job : first;
const status = text(job.status);
const messages: string[] = [];
const events = job.events;
if (Array.isArray(events)) {
for (const event of events) {
if (!isRecord(event)) continue;
const msg = text(event.message) ?? text(event.text);
if (msg && String(event.status ?? '').toUpperCase() !== 'SUCCESS') messages.push(msg);
if (messages.length >= 5) break;
}
}
return {
id: toJobId(job.id),
status: status ? status.toUpperCase() : null,
subStatus: text(job.subStatus),
messages,
};
}
export interface ParsedJobListEntry {
id: string;
status: string | null;
/** Domainname, auf den sich der Auftrag bezieht. */
object: string | null;
/** Zeitpunkt der Anlage; `null`, wenn AutoDNS keinen lesbaren liefert. */
created: Date | null;
}
function dateOrNull(value: unknown): Date | null {
const raw = text(value);
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
/** `POST /job/_search`: Auftragsliste mit Objekt (Domain) und Anlagezeit. */
export function parseJobList(data: unknown): ParsedJobListEntry[] {
if (!Array.isArray(data)) return [];
const result: ParsedJobListEntry[] = [];
for (const entry of data) {
if (!isRecord(entry)) continue;
const job = isRecord(entry.job) ? entry.job : entry;
const id = toJobId(job.id);
if (id === null) continue;
const rawObject = entry.object ?? job.object;
const object = isRecord(rawObject) ? text(rawObject.value) : text(rawObject);
const status = text(job.status);
result.push({
id,
status: status ? status.toUpperCase() : null,
object: object ? object.toLowerCase() : null,
created: dateOrNull(job.created) ?? dateOrNull(job.started) ?? dateOrNull(job.added),
});
}
return result;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { normalizeDomainName, splitDomain } from './domain-name';
describe('normalizeDomainName', () => {
it('entfernt Protokoll, Pfad, Leerraum und Grossschreibung', () => {
expect(normalizeDomainName(' https://Beispiel.DE/pfad ')?.ascii).toBe('beispiel.de');
});
it('entfernt einen abschliessenden Punkt', () => {
expect(normalizeDomainName('beispiel.de.')?.ascii).toBe('beispiel.de');
});
it('wandelt Umlaute in Punycode und behaelt die Unicode-Form zur Anzeige', () => {
expect(normalizeDomainName('müller.de')).toEqual({
ascii: 'xn--mller-kva.de',
unicode: 'müller.de',
});
});
it.each([
'beispiel',
'bei spiel.de',
'-a.de',
'a-.de',
`${'a'.repeat(64)}.de`,
'',
' ',
'beispiel..de',
'bei"spiel.de',
])('lehnt %j ab', (input) => {
expect(normalizeDomainName(input)).toBeNull();
});
it('lehnt Nicht-Zeichenketten ab', () => {
expect(normalizeDomainName(undefined)).toBeNull();
expect(normalizeDomainName(42)).toBeNull();
});
it('nimmt ein 63 Zeichen langes Label noch an', () => {
expect(normalizeDomainName(`${'a'.repeat(63)}.de`)?.ascii).toBe(`${'a'.repeat(63)}.de`);
});
});
describe('splitDomain', () => {
it('trennt Namensteil und Endung, auch bei Mehrteil-Endungen', () => {
expect(splitDomain('beispiel.de')).toEqual({ label: 'beispiel', tld: 'de' });
expect(splitDomain('beispiel.co.uk')).toEqual({ label: 'beispiel', tld: 'co.uk' });
});
});
+47
View File
@@ -0,0 +1,47 @@
import { domainToASCII, domainToUnicode } from 'node:url';
/**
* Domainnamen aus Benutzereingaben (quick-261008-dts, D-K): normalisieren,
* in die ASCII-Form (Punycode) umwandeln und mit einem verankerten Muster
* vorpruefen. Die Gueltigkeit im Sinne der Registry entscheidet am Ende
* AutoDNS/DomainStudio; dies hier ist nur der Vorfilter, damit nichts
* Unsinniges (Pfade, Leerzeichen, Anfuehrungszeichen) ins Netz geht.
*/
/** Labels 1-63 Zeichen aus Buchstaben/Ziffern/Bindestrich, kein Bindestrich am Rand. */
const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
const DOMAIN_PATTERN = new RegExp(`^(?:${LABEL}\\.)+${LABEL}$`);
const MAX_TOTAL_LENGTH = 253;
export interface NormalizedDomain {
/** ASCII-/Punycode-Form — das geht an AutoDNS. */
ascii: string;
/** Unicode-Form fuer die Anzeige. */
unicode: string;
}
export function normalizeDomainName(raw: unknown): NormalizedDomain | null {
if (typeof raw !== 'string') return null;
let value = raw.trim().toLowerCase();
if (value === '') return null;
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
// Pfad, Query, Fragment und Port abschneiden.
value = value.split(/[/?#]/)[0] ?? '';
value = value.replace(/:\d+$/, '');
value = value.replace(/\.+$/, '');
if (value === '' || /\s/.test(value)) return null;
const ascii = domainToASCII(value);
if (ascii === '' || ascii.length > MAX_TOTAL_LENGTH) return null;
if (!DOMAIN_PATTERN.test(ascii)) return null;
return { ascii, unicode: domainToUnicode(ascii) || ascii };
}
/**
* Trennt den ersten Namensteil vom Rest. `beispiel.co.uk` -> label `beispiel`,
* tld `co.uk` (DomainStudio sucht je Endung, ohne Mehrteil-Endungen zu kennen).
*/
export function splitDomain(ascii: string): { label: string; tld: string } {
const dot = ascii.indexOf('.');
return { label: ascii.slice(0, dot), tld: ascii.slice(dot + 1) };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,782 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import {
type AutodnsCredentials,
type AutodnsFailure,
type AutodnsResult,
type AutodnsSuccess,
autodnsRequest,
} from './autodns-client';
import {
extractJobFromSubmit,
type ParsedPrice,
parseDomainStudio,
parseJob,
parseJobList,
} from './autodns-parse';
import { normalizeDomainName, splitDomain } from './domain-name';
import { autodnsFailureToHttp, type DomainsEnvironment } from './domains.types';
import { DomainsDirectoryService } from './domains-directory.service';
import { DomainsSettingsService, HOSTNAME_PATTERN } from './domains-settings.service';
import type { CreateDomainsOrderDto } from './dto/domains-order.dto';
export type DomainOrderStatus =
| 'DRAFT'
| 'SUBMITTING'
| 'SUBMITTED'
| 'SUCCESS'
| 'FAILED'
| 'UNKNOWN'
| 'CANCELED';
/** Hoechstens so viele offene Auftraege pro Abgleich (D-N, Ratenbegrenzung). */
export const REFRESH_MAX_ORDERS = 20;
/** Ein Auftrag, der laenger als das in SUBMITTING steht, gilt als unklar (Prozess starb mitten im Aufruf). */
export const SUBMITTING_STALE_MS = 120_000;
/** Ein Auftrag aus der Auftragssuche zaehlt ab dieser Toleranz vor der Bestaetigung. */
const JOB_SEARCH_TOLERANCE_MS = 5 * 60_000;
const LIST_MAX_ORDERS = 200;
const MIN_NAMESERVERS = 2;
const MAX_NAMESERVERS = 6;
interface OrderPayload {
ownerContactId: number;
adminContactId: number;
techContactId: number;
zoneContactId: number;
nameServers: string[];
periodYears: 1;
price: ParsedPrice | null;
availabilityCheckedAt: string;
}
interface OrderRow {
id: string;
tenantId: string;
environment: DomainsEnvironment;
domainName: string;
openKey: string | null;
status: DomainOrderStatus;
payload: unknown;
jobId: string | null;
jobStatus: string | null;
errorText: string | null;
createdByUserId: string;
confirmedByUserId: string | null;
confirmedByUsername: string | null;
confirmedAt: Date | null;
lastCheckedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface DomainsOrderView {
id: string;
environment: DomainsEnvironment;
domainName: string;
domainNameUnicode: string;
status: DomainOrderStatus;
jobStatus: string | null;
errorText: string | null;
confirmedByUsername: string | null;
confirmedAt: string | null;
createdAt: string;
lastCheckedAt: string | null;
price: ParsedPrice | null;
}
export interface AvailabilityView {
environment: DomainsEnvironment;
domain: string;
domainUnicode: string;
available: boolean;
whoisStatus: string;
price: ParsedPrice | null;
}
export interface OrderContactView {
id: number;
displayName: string;
}
export interface OrderSummaryView {
order: DomainsOrderView;
contacts: {
owner: OrderContactView;
admin: OrderContactView;
tech: OrderContactView;
zone: OrderContactView;
};
nameServers: string[];
periodYears: 1;
}
const ERR = {
notAvailable: {
code: 'notAvailable',
message: 'Die Domain ist nicht frei und kann nicht registriert werden.',
},
orderOpen: {
code: 'orderOpen',
message:
'Für diese Domain gibt es bereits einen offenen Auftrag. Bitte sehen Sie unter „Aufträge“ nach.',
},
alreadySubmitted: {
code: 'alreadySubmitted',
message: 'Dieser Auftrag wurde bereits abgeschickt.',
},
environmentChanged: {
code: 'environmentChanged',
message: 'Das System wurde inzwischen gewechselt. Bitte prüfen Sie die Verfügbarkeit erneut.',
},
checkFirst: {
code: 'checkFirst',
message:
'Bitte aktualisieren Sie den Auftrag zuerst, damit Tessera bei AutoDNS nachsehen kann.',
},
notCancelable: {
code: 'notCancelable',
message: 'Dieser Auftrag kann nicht mehr verworfen werden.',
},
orderNotFound: { code: 'orderNotFound', message: 'Der Auftrag wurde nicht gefunden.' },
invalidDomain: {
code: 'invalidDomain',
message: 'Bitte geben Sie einen gültigen Domainnamen an, zum Beispiel beispiel.de.',
},
contactNotFound: {
code: 'contactNotFound',
message:
'Einer der gewählten Kontakte wurde in AutoDNS nicht gefunden. Bitte lesen Sie die Kontakte neu ein.',
},
};
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2002'
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Meldungstext eines abgelehnten AutoDNS-Aufrufs, wie ihn der Browser auch sonst sieht. */
function failureText(failure: AutodnsFailure): string {
const response = autodnsFailureToHttp(failure).getResponse();
return isRecord(response) && typeof response.message === 'string'
? response.message
: 'AutoDNS hat die Anfrage nicht angenommen.';
}
function readPayload(raw: unknown): OrderPayload | null {
if (!isRecord(raw)) return null;
const ids = [raw.ownerContactId, raw.adminContactId, raw.techContactId, raw.zoneContactId];
if (!ids.every((v) => typeof v === 'number' && Number.isInteger(v) && v > 0)) return null;
if (!Array.isArray(raw.nameServers) || !raw.nameServers.every((n) => typeof n === 'string')) {
return null;
}
const price =
isRecord(raw.price) &&
typeof raw.price.amount === 'number' &&
typeof raw.price.currency === 'string'
? { amount: raw.price.amount, currency: raw.price.currency }
: null;
return {
ownerContactId: raw.ownerContactId as number,
adminContactId: raw.adminContactId as number,
techContactId: raw.techContactId as number,
zoneContactId: raw.zoneContactId as number,
nameServers: raw.nameServers as string[],
periodYears: 1,
price,
availabilityCheckedAt:
typeof raw.availabilityCheckedAt === 'string' ? raw.availabilityCheckedAt : '',
};
}
function toView(row: OrderRow): DomainsOrderView {
const normalized = normalizeDomainName(row.domainName);
return {
id: row.id,
environment: row.environment,
domainName: row.domainName,
domainNameUnicode: normalized?.unicode ?? row.domainName,
status: row.status,
jobStatus: row.jobStatus,
errorText: row.errorText,
confirmedByUsername: row.confirmedByUsername,
confirmedAt: row.confirmedAt ? row.confirmedAt.toISOString() : null,
createdAt: row.createdAt.toISOString(),
lastCheckedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,
price: readPayload(row.payload)?.price ?? null,
};
}
/**
* Verfuegbarkeit, Registrierung und Auftragsverfolgung (quick-261008-dts, L-04).
*
* Eine Registrierung kostet Geld; deshalb gilt in diesem Dienst durchgehend:
* HOECHSTENS EIN `POST /domain` je Auftrag — ohne Wiederholung, auch nicht nach
* einem Zeitablauf. Der Auftrag steht lokal in `DomainsOrder`; der Zustand
* wechselt atomar, bevor das Netz angefasst wird (siehe `submitOrder`).
*
* Gesamter Zugriff auf `domainsOrder` liegt ausschliesslich hier; jede Methode
* bindet ihren eigenen `forTenant`-Klienten, jede `where`-Klausel nennt die
* Organisation. Kein `include:`/relationales `select:`.
*/
@Injectable()
export class DomainsOrdersService {
private readonly logger = new Logger(DomainsOrdersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly settings: DomainsSettingsService,
private readonly directory: DomainsDirectoryService,
) {}
// --- AutoDNS-Aufruf ----------------------------------------------------------
private callRaw(
credentials: AutodnsCredentials,
method: 'GET' | 'POST',
path: string,
body?: unknown,
): Promise<AutodnsResult> {
const transport = this.settings.transport;
return autodnsRequest(credentials, method, path, {
...(body !== undefined ? { body } : {}),
fetchImpl: transport?.fetchImpl,
limiter: transport?.limiter,
timeoutMs: transport?.timeoutMs,
});
}
private async callOrThrow(
credentials: AutodnsCredentials,
method: 'GET' | 'POST',
path: string,
body?: unknown,
): Promise<AutodnsSuccess> {
const result = await this.callRaw(credentials, method, path, body);
if (!result.ok) throw autodnsFailureToHttp(result);
return result;
}
// --- Verfuegbarkeit ----------------------------------------------------------
private async availabilityFor(
credentials: AutodnsCredentials,
ascii: string,
unicode: string,
): Promise<AvailabilityView> {
const { label, tld } = splitDomain(ascii);
const result = await this.callOrThrow(credentials, 'POST', '/domainstudio', {
searchToken: label,
currency: 'EUR',
sources: { initial: { tlds: [tld], services: ['WHOIS', 'PRICE'] } },
});
const parsed = parseDomainStudio(result.data, ascii);
return {
environment: credentials.environment,
domain: ascii,
domainUnicode: unicode,
// Nur FREE ist bestellbar; alles andere (auch ERROR/TIMEOUT) ist es nicht.
available: parsed.whoisStatus === 'FREE',
whoisStatus: parsed.whoisStatus,
price: parsed.price,
};
}
async checkAvailability(tenantId: string, rawDomain: string): Promise<AvailabilityView> {
const normalized = normalizeDomainName(rawDomain);
if (!normalized) throw new BadRequestException(ERR.invalidDomain);
const { credentials } = await this.settings.getActiveCredentials(tenantId);
return this.availabilityFor(credentials, normalized.ascii, normalized.unicode);
}
// --- Entwurf -----------------------------------------------------------------
private normalizeNameServers(raw: string[]): string[] {
const list = [...new Set((raw ?? []).map((n) => String(n).trim().toLowerCase()))].filter(
(n) => n.length > 0,
);
if (list.length < MIN_NAMESERVERS) {
throw new BadRequestException({
code: 'tooFewNameServers',
message: 'Bitte geben Sie mindestens zwei Nameserver an.',
});
}
if (list.length > MAX_NAMESERVERS) {
throw new BadRequestException({
code: 'tooManyNameServers',
message: 'Höchstens sechs Nameserver sind möglich.',
});
}
for (const name of list) {
if (!HOSTNAME_PATTERN.test(name)) {
throw new BadRequestException({
code: 'invalidNameServer',
message: `Der Nameserver „${name}“ ist kein gültiger Rechnername.`,
});
}
}
return list;
}
async createOrder(
tenantId: string,
userId: string,
dto: CreateDomainsOrderDto,
): Promise<OrderSummaryView> {
const normalized = normalizeDomainName(dto.domain);
if (!normalized) throw new BadRequestException(ERR.invalidDomain);
const nameServers = this.normalizeNameServers(dto.nameServers);
const contactIds = [
dto.ownerContactId,
dto.adminContactId,
dto.techContactId,
dto.zoneContactId,
];
if (!contactIds.every((id) => Number.isInteger(id) && id > 0)) {
throw new BadRequestException({
code: 'contactsRequired',
message: 'Bitte wählen Sie Inhaber, Admin-, Technik- und Zonenkontakt aus.',
});
}
const { environment, credentials } = await this.settings.getActiveCredentials(tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId);
const domain = normalized.ascii;
// Offener Auftrag? Nur ein Entwurf darf ueberschrieben werden.
const existing = (await tenantPrisma.domainsOrder.findFirst({
where: { tenantId, environment, openKey: domain },
})) as OrderRow | null;
if (existing && existing.status !== 'DRAFT') throw new ConflictException(ERR.orderOpen);
// Die Verfuegbarkeit wird serverseitig neu geprueft — dem Browser traut hier niemand.
const availability = await this.availabilityFor(credentials, domain, normalized.unicode);
if (!availability.available) throw new ConflictException(ERR.notAvailable);
const found = await this.directory.findContactsByIds(tenantId, contactIds);
const byId = new Map(found.map((c) => [c.id, c]));
if (!contactIds.every((id) => byId.has(id))) {
throw new BadRequestException(ERR.contactNotFound);
}
const payload: OrderPayload = {
ownerContactId: dto.ownerContactId,
adminContactId: dto.adminContactId,
techContactId: dto.techContactId,
zoneContactId: dto.zoneContactId,
nameServers,
periodYears: 1,
price: availability.price,
availabilityCheckedAt: new Date().toISOString(),
};
let row: OrderRow;
if (existing) {
const { count } = await tenantPrisma.domainsOrder.updateMany({
where: { id: existing.id, tenantId, status: 'DRAFT', environment },
data: { payload: payload as never },
});
// Zwischenzeitlich abgeschickt: nicht mehr anfassen.
if (count !== 1) throw new ConflictException(ERR.orderOpen);
row = { ...existing, payload };
} else {
try {
row = (await tenantPrisma.domainsOrder.create({
data: {
tenantId,
environment,
domainName: domain,
openKey: domain,
status: 'DRAFT',
payload: payload as never,
createdByUserId: userId,
},
})) as OrderRow;
} catch (error) {
if (isUniqueViolation(error)) throw new ConflictException(ERR.orderOpen);
throw error;
}
}
const contact = (id: number): OrderContactView => ({
id,
displayName: byId.get(id)?.displayName ?? `#${id}`,
});
return {
order: toView(row),
contacts: {
owner: contact(payload.ownerContactId),
admin: contact(payload.adminContactId),
tech: contact(payload.techContactId),
zone: contact(payload.zoneContactId),
},
nameServers: payload.nameServers,
periodYears: 1,
};
}
// --- Verbindlich abschicken --------------------------------------------------
/**
* Schickt die Registrierung genau EINMAL an AutoDNS.
*
* Warum so (L-04, Recherche Pitfalls 1-3):
*
* - ATOMISCHER ANSPRUCH: `updateMany where { id, tenantId, status: 'DRAFT',
* environment: <aktiv> }` setzt DRAFT -> SUBMITTING. Postgres prueft die
* Bedingung beim zweiten, gleichzeitigen UPDATE nach dem Commit des ersten
* erneut. Genau EIN Aufruf sieht `count === 1`; wer `count !== 1` erhaelt,
* erreicht das Netz nie. Der Knopf im Browser ist nur Komfort — dieser
* Zaehler ist die Sperre (zweiter Tab, Doppelklick, erneuter Aufruf).
* - ZAEHLER VOR DEM NETZ: Der Anspruch wird festgeschrieben, BEVOR
* `POST /domain` startet. Stuerzt der Prozess danach ab, bleibt der
* Auftrag in SUBMITTING und wird beim Abgleich zu UNKNOWN — nie wieder
* zu DRAFT, denn der Aufruf kann schon draussen gewesen sein.
* - SYSTEM IM ANSPRUCH: Das aktive System steht in der `where`-Klausel. Ein
* Entwurf aus dem Demo-System kann nach einem Wechsel nie im Live-System
* (oder umgekehrt) bestellt werden; stattdessen 409 `environmentChanged`.
* - KEINE WIEDERHOLUNG: Es gibt genau einen `autodnsRequest`-Aufruf, keine
* Schleife, keinen zweiten Versuch. Ein Zeitablauf heisst NICHT "nicht
* bestellt". Ein unklarer Ausgang (Zeitablauf, Netzfehler, unlesbare oder
* gatewayartige Antwort) wird als UNKNOWN gespeichert und spaeter gegen
* AutoDNS abgeglichen — es wird nie geraten.
*/
async submitOrder(
tenantId: string,
user: { id: string; username: string },
id: string,
): Promise<DomainsOrderView> {
// Zugang zuerst: scheitert er, wurde noch nichts angespruchen.
const { environment, credentials } = await this.settings.getActiveCredentials(tenantId);
const tenantPrisma = forTenant(this.prisma, tenantId);
// Lesen VOR dem Anspruch: der Inhalt, den der Benutzer bestaetigt hat, geht raus.
const before = (await tenantPrisma.domainsOrder.findFirst({
where: { id, tenantId },
})) as OrderRow | null;
if (!before) throw new NotFoundException(ERR.orderNotFound);
if (before.environment !== environment) throw new ConflictException(ERR.environmentChanged);
if (before.status !== 'DRAFT') throw new ConflictException(ERR.alreadySubmitted);
const payload = readPayload(before.payload);
if (!payload) {
throw new BadRequestException({
code: 'orderInvalid',
message: 'Der Auftrag ist unvollständig. Bitte legen Sie ihn neu an.',
});
}
const claim = await tenantPrisma.domainsOrder.updateMany({
where: { id, tenantId, status: 'DRAFT', environment },
data: {
status: 'SUBMITTING',
confirmedAt: new Date(),
confirmedByUserId: user.id,
confirmedByUsername: user.username,
},
});
if (claim.count !== 1) {
// Verloren: jemand war schneller. Kein Netzaufruf.
const current = (await tenantPrisma.domainsOrder.findFirst({
where: { id, tenantId },
})) as OrderRow | null;
if (!current) throw new NotFoundException(ERR.orderNotFound);
if (current.environment !== environment) throw new ConflictException(ERR.environmentChanged);
throw new ConflictException(ERR.alreadySubmitted);
}
// --- Ab hier gibt es kein Zurueck zu DRAFT. Genau ein Aufruf, ohne Wiederholung. ---
const body = {
name: before.domainName,
period: { unit: 'YEAR', period: 1 },
ownerc: { id: payload.ownerContactId },
adminc: { id: payload.adminContactId },
techc: { id: payload.techContactId },
zonec: { id: payload.zoneContactId },
nameServers: payload.nameServers.map((name) => ({ name })),
};
let outcome: Record<string, unknown>;
try {
const result = await this.callRaw(credentials, 'POST', '/domain', body);
outcome = this.outcomeOfSubmit(result);
} catch (error) {
this.logger.error(
`POST /domain für Auftrag ${id} warf einen Fehler: ${error instanceof Error ? error.name : 'unbekannt'}`,
);
outcome = { status: 'UNKNOWN' };
}
// Bewusst OHNE `lastCheckedAt`: bei UNKNOWN heisst das "noch nicht bei AutoDNS
// nachgesehen" und sperrt das Verwerfen, bis ein Abgleich stattgefunden hat.
await tenantPrisma.domainsOrder.updateMany({
where: { id, tenantId, status: 'SUBMITTING' },
data: outcome,
});
this.logger.log(`Auftrag ${id}: Registrierung abgeschickt, Ergebnis ${String(outcome.status)}`);
if (outcome.status === 'SUBMITTED') this.directory.invalidate(tenantId);
const after = (await tenantPrisma.domainsOrder.findFirst({
where: { id, tenantId },
})) as OrderRow | null;
if (!after) throw new NotFoundException(ERR.orderNotFound);
return toView(after);
}
/** Abbildung der Antwort von `POST /domain` auf die zu speichernden Felder (D-M). */
private outcomeOfSubmit(result: AutodnsResult): Record<string, unknown> {
if (result.ok) {
const job = extractJobFromSubmit(result);
return {
status: 'SUBMITTED',
jobId: job.id,
jobStatus: job.status ? job.status.toUpperCase() : null,
};
}
switch (result.kind) {
// AutoDNS hat ausdruecklich abgelehnt (Umschlag bzw. Anmeldefehler): nichts wurde bestellt.
case 'auth':
case 'forbidden':
case 'rate-limit':
case 'business':
return { status: 'FAILED', errorText: failureText(result), openKey: null };
// Zeitablauf, Netz, TLS, unlesbare oder umschlaglose Antwort: Ausgang unbekannt.
default:
return { status: 'UNKNOWN' };
}
}
// --- Abgleich ----------------------------------------------------------------
/**
* Gleicht offene Auftraege des aktiven Systems mit AutoDNS ab (hoechstens 20,
* der am laengsten ungeprueften zuerst). Wird beim Oeffnen des Reiters, bei
* "Aktualisieren" und alle 30 s bei offenen Auftraegen aufgerufen (D-N, ohne
* Cron). Auftraege des anderen Systems bleiben unveraendert, bis dieses
* wieder aktiv ist — ihre Zugangsdaten sind gerade nicht massgeblich.
*/
async refreshOpenOrders(tenantId: string): Promise<DomainsOrderView[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
// 1. Haengende Ansprueche -> UNKNOWN (alle Systeme; braucht kein Netz).
const stale = (await tenantPrisma.domainsOrder.findMany({
where: { tenantId, status: 'SUBMITTING' },
})) as OrderRow[];
const now = Date.now();
for (const row of stale) {
const since = (row.confirmedAt ?? row.updatedAt).getTime();
if (now - since > SUBMITTING_STALE_MS) {
await tenantPrisma.domainsOrder.updateMany({
where: { id: row.id, tenantId, status: 'SUBMITTING' },
data: { status: 'UNKNOWN' },
});
}
}
// 2. Offene Auftraege des aktiven Systems.
let active: { environment: DomainsEnvironment; credentials: AutodnsCredentials } | null = null;
try {
active = await this.settings.getActiveCredentials(tenantId);
} catch (error) {
if (!(error instanceof ConflictException)) throw error;
}
if (active) {
const open = (await tenantPrisma.domainsOrder.findMany({
where: {
tenantId,
environment: active.environment,
status: { in: ['SUBMITTED', 'UNKNOWN'] },
},
orderBy: { lastCheckedAt: { sort: 'asc', nulls: 'first' } },
take: REFRESH_MAX_ORDERS,
})) as OrderRow[];
for (const row of open) {
try {
await this.refreshOrder(tenantId, active.credentials, row);
} catch (error) {
// Ein Fehler bei einem Auftrag haelt die anderen nicht auf.
this.logger.warn(
`Abgleich von Auftrag ${row.id} fehlgeschlagen: ${error instanceof Error ? error.name : 'unbekannt'}`,
);
}
}
}
return this.listOrders(tenantId);
}
/** Einziger Einstieg fuer den Abgleich eines Auftrags (D-N). */
private async refreshOrder(
tenantId: string,
credentials: AutodnsCredentials,
row: OrderRow,
): Promise<void> {
if (row.status === 'SUBMITTED' && row.jobId) {
await this.refreshFromJob(tenantId, credentials, row);
} else if (row.status === 'UNKNOWN' || row.status === 'SUBMITTED') {
await this.reconcile(tenantId, credentials, row);
}
}
/** Schreibt nur, solange der Auftrag noch im erwarteten Zustand ist. */
private async writeIf(
tenantId: string,
row: OrderRow,
data: Record<string, unknown>,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId);
await tenantPrisma.domainsOrder.updateMany({
where: { id: row.id, tenantId, status: row.status },
data,
});
if (data.status === 'SUCCESS') this.directory.invalidate(tenantId);
}
private async refreshFromJob(
tenantId: string,
credentials: AutodnsCredentials,
row: OrderRow,
): Promise<void> {
const result = await this.callRaw(
credentials,
'GET',
`/job/${encodeURIComponent(row.jobId as string)}`,
);
if (!result.ok) return; // nicht gepruefte Lage bleibt, wie sie ist
const job = parseJob(result.data);
const checkedAt = new Date();
if (!job?.status) {
await this.writeIf(tenantId, row, { lastCheckedAt: checkedAt });
return;
}
switch (job.status) {
case 'SUCCESS':
await this.writeIf(tenantId, row, {
status: 'SUCCESS',
jobStatus: 'SUCCESS',
lastCheckedAt: checkedAt,
});
return;
case 'FAILED':
await this.writeIf(tenantId, row, {
status: 'FAILED',
jobStatus: 'FAILED',
errorText: job.messages.length
? `AutoDNS meldet: ${job.messages.join(' ')}`
: 'AutoDNS hat die Registrierung nicht ausgeführt.',
openKey: null,
lastCheckedAt: checkedAt,
});
return;
case 'CANCELED':
await this.writeIf(tenantId, row, {
status: 'CANCELED',
jobStatus: 'CANCELED',
errorText: 'Der Auftrag wurde bei AutoDNS abgebrochen.',
openKey: null,
lastCheckedAt: checkedAt,
});
return;
default:
// RUNNING, WAIT, DEFERRED, NOT_SET, SUPPORT: bleibt offen.
await this.writeIf(tenantId, row, { jobStatus: job.status, lastCheckedAt: checkedAt });
}
}
/**
* UNKNOWN (oder SUBMITTED ohne Auftragsnummer): erst `GET /domain/{name}` —
* gibt es die Domain, ist sie registriert. Sonst Auftragssuche nach dem
* Domainnamen, nur Auftraege ab 5 Minuten vor der Bestaetigung. Nur ein
* ERGEBNIS ("nicht gefunden") setzt `lastCheckedAt`; ein Fehler beim Nachsehen
* (Zeitablauf, Anmeldung, Gateway) laesst den Auftrag unveraendert, damit
* "Verwerfen" nie auf einer Pruefung beruht, die gar nichts geprueft hat.
*/
private async reconcile(
tenantId: string,
credentials: AutodnsCredentials,
row: OrderRow,
): Promise<void> {
const domain = await this.callRaw(
credentials,
'GET',
`/domain/${encodeURIComponent(row.domainName)}`,
);
if (domain.ok && domain.data.length > 0) {
await this.writeIf(tenantId, row, { status: 'SUCCESS', lastCheckedAt: new Date() });
return;
}
// Nur eine ausdrueckliche AutoDNS-Antwort "gibt es nicht" ist ein Ergebnis.
if (!domain.ok && domain.kind !== 'business') return;
const search = await this.callRaw(credentials, 'POST', '/job/_search', {
filters: [{ key: 'object', operator: 'EQUAL', value: row.domainName }],
view: { limit: 10, offset: 0 },
orders: [{ key: 'created', type: 'DESC' }],
});
if (!search.ok) return;
const since = (row.confirmedAt ?? row.createdAt).getTime() - JOB_SEARCH_TOLERANCE_MS;
const candidates = parseJobList(search.data)
.filter((j) => j.object === row.domainName && j.created && j.created.getTime() >= since)
.sort((a, b) => (b.created?.getTime() ?? 0) - (a.created?.getTime() ?? 0));
const checkedAt = new Date();
const job = candidates[0];
if (job) {
await this.writeIf(tenantId, row, {
status: 'SUBMITTED',
jobId: job.id,
jobStatus: job.status,
lastCheckedAt: checkedAt,
});
return;
}
await this.writeIf(tenantId, row, { lastCheckedAt: checkedAt });
}
// --- Verwerfen ---------------------------------------------------------------
async cancelOrder(tenantId: string, id: string): Promise<DomainsOrderView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = (await tenantPrisma.domainsOrder.findFirst({
where: { id, tenantId },
})) as OrderRow | null;
if (!row) throw new NotFoundException(ERR.orderNotFound);
if (row.status === 'UNKNOWN' && !row.lastCheckedAt) throw new ConflictException(ERR.checkFirst);
if (row.status !== 'DRAFT' && row.status !== 'UNKNOWN') {
throw new ConflictException(ERR.notCancelable);
}
const { count } = await tenantPrisma.domainsOrder.updateMany({
where: {
id,
tenantId,
status: row.status,
...(row.status === 'UNKNOWN' ? { lastCheckedAt: { not: null } } : {}),
},
data: { status: 'CANCELED', openKey: null },
});
if (count !== 1) throw new ConflictException(ERR.notCancelable);
return toView({ ...row, status: 'CANCELED', openKey: null });
}
// --- Liste -------------------------------------------------------------------
async listOrders(tenantId: string): Promise<DomainsOrderView[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const rows = (await tenantPrisma.domainsOrder.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
take: LIST_MAX_ORDERS,
})) as OrderRow[];
return rows.map(toView);
}
}
@@ -41,7 +41,7 @@ const MIN_NAMESERVERS = 2;
const MAX_NAMESERVERS = 6;
/** Rechnername: Labels aus Buchstaben, Ziffern, Bindestrich; mindestens zwei Labels. */
const HOSTNAME_PATTERN =
export const HOSTNAME_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])$/;
interface ConfigRow {
@@ -82,7 +82,11 @@ export class DomainsSettingsService {
* Nur fuer Tests: ersetzt den Netzzugang und den Takt-Begrenzer. Im
* Betrieb bleibt das leer (echter `undiciFetch`, prozessweiter Begrenzer).
*/
transport: { fetchImpl?: AutodnsFetch; limiter?: AutodnsRateLimiter } = {};
transport: {
fetchImpl?: AutodnsFetch;
limiter?: AutodnsRateLimiter;
timeoutMs?: number;
} = {};
constructor(
private readonly prisma: PrismaService,
@@ -17,8 +17,19 @@ const MANAGE_HANDLERS = [
'deleteCustomer',
'createContact',
'assignContacts',
'checkAvailability',
'createOrder',
'submitOrder',
'cancelOrder',
];
const USE_HANDLERS = [
'getStatus',
'listCustomers',
'listContacts',
'listDomains',
'listOrders',
'refreshOrders',
];
const USE_HANDLERS = ['getStatus', 'listCustomers', 'listContacts', 'listDomains'];
function makeDirectory() {
return {
@@ -33,6 +44,17 @@ function makeDirectory() {
};
}
function makeOrders() {
return {
checkAvailability: vi.fn(async (..._a: unknown[]) => ({})),
listOrders: vi.fn(async (..._a: unknown[]) => []),
createOrder: vi.fn(async (..._a: unknown[]) => ({})),
refreshOpenOrders: vi.fn(async (..._a: unknown[]) => []),
submitOrder: vi.fn(async (..._a: unknown[]) => ({})),
cancelOrder: vi.fn(async (..._a: unknown[]) => ({})),
};
}
function makeSettings() {
return {
getStatus: vi.fn(async (..._a: unknown[]) => ({})),
@@ -80,6 +102,12 @@ describe('DomainsController — Metadaten', () => {
expect(route('listDomains')).toEqual([0, 'domains']);
expect(route('updateCustomer')).toEqual([2, 'customers/:id']);
expect(route('deleteCustomer')).toEqual([3, 'customers/:id']);
expect(route('checkAvailability')).toEqual([1, 'availability']);
expect(route('listOrders')).toEqual([0, 'orders']);
expect(route('createOrder')).toEqual([1, 'orders']);
expect(route('refreshOrders')).toEqual([1, 'orders/refresh']);
expect(route('submitOrder')).toEqual([1, 'orders/:id/submit']);
expect(route('cancelOrder')).toEqual([1, 'orders/:id/cancel']);
});
});
@@ -97,11 +125,22 @@ describe('DomainsController — Routen-Reihenfolge (statisch vor :id)', () => {
});
});
describe('DomainsController — Auftraege: Reihenfolge', () => {
it("'orders/refresh' steht vor jedem :id-Handler", () => {
const names = Object.getOwnPropertyNames(DomainsController.prototype);
const refresh = names.indexOf('refreshOrders');
expect(refresh).toBeGreaterThan(-1);
for (const n of ['updateCustomer', 'deleteCustomer', 'submitOrder', 'cancelOrder']) {
expect(refresh, n).toBeLessThan(names.indexOf(n));
}
});
});
describe('DomainsController — Verhalten', () => {
it('reicht req.tenantId weiter', async () => {
const settings = makeSettings();
const directory = makeDirectory();
const c = new DomainsController(settings as any, directory as any);
const c = new DomainsController(settings as any, directory as any, makeOrders() as any);
await c.getStatus(req('t1'));
await c.getSettings(req('t1'));
await c.saveSettings(req('t1'), { demoUser: 'x' } as any);
@@ -115,7 +154,7 @@ describe('DomainsController — Verhalten', () => {
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 c = new DomainsController(settings as any, directory as any, makeOrders() as any);
const id = '11111111-1111-4111-8111-111111111111';
await c.listCustomers(req('t1'));
await c.createCustomer(req('t1'), { name: 'A' } as any);
@@ -147,7 +186,44 @@ describe('DomainsController — Verhalten', () => {
});
it('ohne Mandantenkontext 403', async () => {
const c = new DomainsController(makeSettings() as any, makeDirectory() as any);
const c = new DomainsController(
makeSettings() as any,
makeDirectory() as any,
makeOrders() as any,
);
await expect(c.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException);
});
});
describe('DomainsController — Auftraege: Verhalten', () => {
const user = { id: 'u1', username: 'anna' } as any;
const id = '22222222-2222-4222-8222-222222222222';
it('reicht Organisation, Benutzer und Auftragsnummer weiter', async () => {
const orders = makeOrders();
const c = new DomainsController(makeSettings() as any, makeDirectory() as any, orders as any);
await c.checkAvailability(req('t1'), { domain: 'beispiel.de' });
await c.listOrders(req('t1'));
await c.createOrder(req('t1'), user, { domain: 'beispiel.de' } as any);
await c.refreshOrders(req('t1'));
await c.submitOrder(req('t1'), user, id);
await c.cancelOrder(req('t1'), id);
expect(orders.checkAvailability).toHaveBeenCalledWith('t1', 'beispiel.de');
expect(orders.listOrders).toHaveBeenCalledWith('t1');
expect(orders.createOrder).toHaveBeenCalledWith('t1', 'u1', { domain: 'beispiel.de' });
expect(orders.refreshOpenOrders).toHaveBeenCalledWith('t1');
expect(orders.submitOrder).toHaveBeenCalledWith('t1', { id: 'u1', username: 'anna' }, id);
expect(orders.cancelOrder).toHaveBeenCalledWith('t1', id);
});
it('ohne Mandantenkontext 403', async () => {
const c = new DomainsController(
makeSettings() as any,
makeDirectory() as any,
makeOrders() as any,
);
await expect(c.submitOrder(req(undefined), user, id)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
});
+55 -1
View File
@@ -11,12 +11,15 @@ import {
Query,
Req,
} from '@nestjs/common';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { DomainsDirectoryService } from './domains-directory.service';
import { DomainsOrdersService } from './domains-orders.service';
import { DomainsSettingsService } from './domains-settings.service';
import { AssignDomainsContactsDto, CreateDomainsContactDto } from './dto/domains-contact.dto';
import { DomainsCustomerDto } from './dto/domains-customer.dto';
import { CheckAvailabilityDto, CreateDomainsOrderDto } from './dto/domains-order.dto';
import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.dto';
/** `?refresh=1` oder `?refresh=true` umgeht den Zwischenspeicher. */
@@ -51,6 +54,7 @@ export class DomainsController {
constructor(
private readonly settings: DomainsSettingsService,
private readonly directory: DomainsDirectoryService,
private readonly orders: DomainsOrdersService,
) {}
private requireTenantId(req: AuthenticatedRequest): string {
@@ -124,6 +128,36 @@ export class DomainsController {
});
}
// --- Registrierung und Auftraege (Aufgabe 3) ---------------------------------
// `orders/refresh` steht VOR jeder `orders/:id/...`-Route.
@Post('availability')
@ModuleManage('domains')
async checkAvailability(@Req() req: AuthenticatedRequest, @Body() dto: CheckAvailabilityDto) {
return this.orders.checkAvailability(this.requireTenantId(req), dto.domain);
}
@Get('orders')
async listOrders(@Req() req: AuthenticatedRequest) {
return this.orders.listOrders(this.requireTenantId(req));
}
@Post('orders')
@ModuleManage('domains')
async createOrder(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Body() dto: CreateDomainsOrderDto,
) {
return this.orders.createOrder(this.requireTenantId(req), user.id, dto);
}
/** Gleicht nur den Zustand mit AutoDNS ab und aendert dort nichts: Benutzen genuegt. */
@Post('orders/refresh')
async refreshOrders(@Req() req: AuthenticatedRequest) {
return this.orders.refreshOpenOrders(this.requireTenantId(req));
}
// --- Routen mit :id (immer ganz am Ende) -------------------------------------
@Put('customers/:id')
@@ -141,4 +175,24 @@ export class DomainsController {
async deleteCustomer(@Req() req: AuthenticatedRequest, @Param('id', ParseUUIDPipe) id: string) {
return this.directory.deleteCustomer(this.requireTenantId(req), id);
}
@Post('orders/:id/submit')
@ModuleManage('domains')
async submitOrder(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.orders.submitOrder(
this.requireTenantId(req),
{ id: user.id, username: user.username },
id,
);
}
@Post('orders/:id/cancel')
@ModuleManage('domains')
async cancelOrder(@Req() req: AuthenticatedRequest, @Param('id', ParseUUIDPipe) id: string) {
return this.orders.cancelOrder(this.requireTenantId(req), id);
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { ModuleRegistryService } from '../module-registry/module-registry.servic
import { DomainsController } from './domains.controller';
import { seedDomainsModule } from './domains.seed';
import { DomainsDirectoryService } from './domains-directory.service';
import { DomainsOrdersService } from './domains-orders.service';
import { DomainsSettingsService } from './domains-settings.service';
/**
@@ -14,7 +15,7 @@ import { DomainsSettingsService } from './domains-settings.service';
@Module({
imports: [ModuleRegistryModule],
controllers: [DomainsController],
providers: [DomainsSettingsService, DomainsDirectoryService],
providers: [DomainsSettingsService, DomainsDirectoryService, DomainsOrdersService],
})
export class DomainsModule implements OnModuleInit {
private readonly logger = new Logger(DomainsModule.name);
@@ -0,0 +1,52 @@
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsInt,
IsNotEmpty,
IsString,
MaxLength,
Min,
} from 'class-validator';
/** Verfuegbarkeitspruefung einer Domain (quick-261008-dts, D-K). */
export class CheckAvailabilityDto {
@IsString()
@IsNotEmpty()
@MaxLength(300)
domain!: string;
}
/**
* Entwurf einer Registrierung (D-O): alle vier Kontakte sind Pflicht, die
* Laufzeit steht fest bei einem Jahr und ist deshalb kein Feld.
*/
export class CreateDomainsOrderDto {
@IsString()
@IsNotEmpty()
@MaxLength(300)
domain!: string;
@IsInt()
@Min(1)
ownerContactId!: number;
@IsInt()
@Min(1)
adminContactId!: number;
@IsInt()
@Min(1)
techContactId!: number;
@IsInt()
@Min(1)
zoneContactId!: number;
@IsArray()
@ArrayMinSize(2)
@ArrayMaxSize(6)
@IsString({ each: true })
@MaxLength(253, { each: true })
nameServers!: string[];
}
@@ -94,6 +94,10 @@ describe('Umgestellte Handler (Verwalten)', () => {
'deleteCustomer',
'createContact',
'assignContacts',
'checkAvailability',
'createOrder',
'submitOrder',
'cancelOrder',
])('DomainsController.%s verlangt Verwalten für domains (quick-261008-dts)', (name) => {
expectManage(DomainsController, name, 'domains');
});
@@ -103,6 +107,8 @@ describe('Umgestellte Handler (Verwalten)', () => {
'listCustomers',
'listContacts',
'listDomains',
'listOrders',
'refreshOrders',
])('DomainsController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(DomainsController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();