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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render as rtlRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { DomainsOrder } from '@/lib/domains-api';
|
||||
import de from '@/messages/de.json';
|
||||
import { ORDERS_POLL_MS, OrdersTab } from './OrdersTab';
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockList = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
const mockCancel = vi.fn();
|
||||
|
||||
vi.mock('@/lib/domains-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
|
||||
return {
|
||||
...actual,
|
||||
listOrders: (...a: unknown[]) => mockList(...a),
|
||||
refreshOrders: (...a: unknown[]) => mockRefresh(...a),
|
||||
cancelOrder: (...a: unknown[]) => mockCancel(...a),
|
||||
};
|
||||
});
|
||||
|
||||
function order(over: Partial<DomainsOrder> = {}): DomainsOrder {
|
||||
return {
|
||||
id: 'o1',
|
||||
environment: 'DEMO',
|
||||
domainName: 'beispiel.de',
|
||||
domainNameUnicode: 'beispiel.de',
|
||||
status: 'DRAFT',
|
||||
jobStatus: null,
|
||||
errorText: null,
|
||||
confirmedByUsername: null,
|
||||
confirmedAt: null,
|
||||
createdAt: '2026-10-08T10:00:00.000Z',
|
||||
lastCheckedAt: null,
|
||||
price: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockList.mockReset().mockResolvedValue([]);
|
||||
mockRefresh.mockReset().mockResolvedValue([]);
|
||||
mockCancel.mockReset().mockResolvedValue(order({ status: 'CANCELED' }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('OrdersTab — Anzeige', () => {
|
||||
it('ruft beim Oeffnen den Abgleich auf', async () => {
|
||||
render(<OrdersTab canManage />);
|
||||
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText('Es gibt noch keine Aufträge.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('zeigt Domain, System, Status, Bestaetigung und Fehlertext', async () => {
|
||||
mockRefresh.mockResolvedValue([
|
||||
order({
|
||||
id: 'a',
|
||||
domainName: 'xn--mller-kva.de',
|
||||
domainNameUnicode: 'müller.de',
|
||||
environment: 'LIVE',
|
||||
status: 'SUCCESS',
|
||||
confirmedByUsername: 'anna',
|
||||
confirmedAt: '2026-10-08T09:30:00.000Z',
|
||||
}),
|
||||
order({
|
||||
id: 'b',
|
||||
domainName: 'fehler.de',
|
||||
domainNameUnicode: 'fehler.de',
|
||||
status: 'FAILED',
|
||||
errorText: 'AutoDNS meldet: Nameserver nicht erreichbar',
|
||||
}),
|
||||
]);
|
||||
render(<OrdersTab canManage={false} />);
|
||||
expect(await screen.findByText('müller.de')).toBeTruthy();
|
||||
expect(screen.getByText('Live')).toBeTruthy();
|
||||
expect(screen.getByText('Registriert')).toBeTruthy();
|
||||
expect(screen.getByText('anna, 08.10.2026, 11:30')).toBeTruthy();
|
||||
expect(screen.getByText('Fehlgeschlagen')).toBeTruthy();
|
||||
expect(screen.getByText('AutoDNS meldet: Nameserver nicht erreichbar')).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ status: 'DRAFT' }, 'Entwurf'],
|
||||
[{ status: 'SUBMITTING' }, 'Wird übermittelt'],
|
||||
[{ status: 'SUBMITTED', jobStatus: 'RUNNING' }, 'In Bearbeitung'],
|
||||
[{ status: 'SUBMITTED', jobStatus: 'SUPPORT' }, 'Rückfrage nötig'],
|
||||
[{ status: 'UNKNOWN' }, 'Ergebnis ungeklärt'],
|
||||
[{ status: 'CANCELED' }, 'Verworfen'],
|
||||
] as [Partial<DomainsOrder>, string][])('Status %j heisst "%s"', async (over, label) => {
|
||||
mockRefresh.mockResolvedValue([order(over)]);
|
||||
render(<OrdersTab canManage={false} />);
|
||||
expect(await screen.findByText(label)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OrdersTab — Abgleich im Takt', () => {
|
||||
it('mit offenem Auftrag laeuft alle 30 s ein Abgleich und stoppt, wenn keiner mehr offen ist', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockRefresh.mockResolvedValue([order({ status: 'SUBMITTED', jobStatus: 'RUNNING' })]);
|
||||
render(<OrdersTab canManage />);
|
||||
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
||||
await screen.findByText('In Bearbeitung');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS);
|
||||
});
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(2);
|
||||
|
||||
mockRefresh.mockResolvedValue([order({ status: 'SUCCESS' })]);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS);
|
||||
});
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(3);
|
||||
await screen.findByText('Registriert');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS * 3);
|
||||
});
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('ohne offene Auftraege gibt es keinen Takt', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockRefresh.mockResolvedValue([order({ status: 'FAILED' })]);
|
||||
render(<OrdersTab canManage />);
|
||||
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS * 2);
|
||||
});
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('bei unsichtbarer Seite wird nicht abgeglichen', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockRefresh.mockResolvedValue([order({ status: 'SUBMITTED' })]);
|
||||
render(<OrdersTab canManage />);
|
||||
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
||||
const spy = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ORDERS_POLL_MS);
|
||||
});
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('"Aktualisieren" gleicht sofort ab', async () => {
|
||||
render(<OrdersTab canManage />);
|
||||
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Aktualisieren' }));
|
||||
await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('OrdersTab — Verwerfen', () => {
|
||||
it('erscheint nur fuer Verwalter, nur bei Entwurf und bei geprueftem unklarem Auftrag', async () => {
|
||||
mockRefresh.mockResolvedValue([
|
||||
order({
|
||||
id: 'd',
|
||||
domainName: 'entwurf.de',
|
||||
domainNameUnicode: 'entwurf.de',
|
||||
status: 'DRAFT',
|
||||
}),
|
||||
order({
|
||||
id: 'u1',
|
||||
domainName: 'u1.de',
|
||||
domainNameUnicode: 'u1.de',
|
||||
status: 'UNKNOWN',
|
||||
lastCheckedAt: '2026-10-08T10:00:00.000Z',
|
||||
}),
|
||||
order({
|
||||
id: 'u2',
|
||||
domainName: 'u2.de',
|
||||
domainNameUnicode: 'u2.de',
|
||||
status: 'UNKNOWN',
|
||||
lastCheckedAt: null,
|
||||
}),
|
||||
order({ id: 's', domainName: 's.de', domainNameUnicode: 's.de', status: 'SUBMITTED' }),
|
||||
]);
|
||||
render(<OrdersTab canManage />);
|
||||
await screen.findByText('entwurf.de');
|
||||
expect(screen.getAllByRole('button', { name: 'Verwerfen' })).toHaveLength(2);
|
||||
cleanup();
|
||||
|
||||
render(<OrdersTab canManage={false} />);
|
||||
await screen.findByText('entwurf.de');
|
||||
expect(screen.queryByRole('button', { name: 'Verwerfen' })).toBeNull();
|
||||
});
|
||||
|
||||
it('fragt nach; bei "unklar" mit dem Hinweis, erst in AutoDNS zu pruefen; dann cancelOrder', async () => {
|
||||
mockRefresh.mockResolvedValue([
|
||||
order({ id: 'u1', status: 'UNKNOWN', lastCheckedAt: '2026-10-08T10:00:00.000Z' }),
|
||||
]);
|
||||
render(<OrdersTab canManage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Verwerfen' }));
|
||||
expect(mockCancel).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/nur, nachdem Sie in AutoDNS geprüft haben, dass die Domain nicht bestellt wurde/,
|
||||
),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ja, verwerfen' }));
|
||||
await waitFor(() => expect(mockCancel).toHaveBeenCalledWith('u1'));
|
||||
});
|
||||
|
||||
it('"Abbrechen" im Dialog verwirft nichts', async () => {
|
||||
mockRefresh.mockResolvedValue([order({ id: 'd', status: 'DRAFT' })]);
|
||||
render(<OrdersTab canManage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Verwerfen' }));
|
||||
expect(screen.getByText('Diesen Entwurf wirklich verwerfen?')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
|
||||
expect(mockCancel).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'Verwerfen' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { isDiscardable, isOpen, orderStatusInfo } from '@/components/domains/order-status';
|
||||
import {
|
||||
SECONDARY_BUTTON,
|
||||
TABLE_CELL,
|
||||
TABLE_HEAD_CELL,
|
||||
TABLE_HEAD_CLASS,
|
||||
} from '@/components/domains/ui-classes';
|
||||
import {
|
||||
cancelOrder,
|
||||
type DomainsOrder,
|
||||
DomainsRequestError,
|
||||
listOrders,
|
||||
refreshOrders,
|
||||
} from '@/lib/domains-api';
|
||||
|
||||
/** Abgleich mit AutoDNS, solange offene Auftraege existieren und die Seite sichtbar ist (D-N). */
|
||||
export const ORDERS_POLL_MS = 30_000;
|
||||
|
||||
function formatDateTime(iso: string | null, locale: string): string {
|
||||
if (!iso) return '–';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '–';
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'Europe/Berlin',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter "Aufträge": Stand jeder Registrierung. Beim Oeffnen wird die Liste
|
||||
* sofort gezeigt und danach mit AutoDNS abgeglichen; solange ein Auftrag offen
|
||||
* ist, wiederholt sich der Abgleich alle 30 Sekunden (nur bei sichtbarer Seite).
|
||||
* Ein unklarer Auftrag laesst sich erst verwerfen, nachdem Tessera bei AutoDNS
|
||||
* nachgesehen hat — und nur nach ausdruecklicher Bestaetigung.
|
||||
*/
|
||||
export function OrdersTab({ canManage }: { canManage: boolean }) {
|
||||
const t = useTranslations('domains.orders');
|
||||
const tc = useTranslations('domains.common');
|
||||
const locale = useLocale();
|
||||
|
||||
const [orders, setOrders] = useState<DomainsOrder[] | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [discardId, setDiscardId] = useState<string | null>(null);
|
||||
const [discardError, setDiscardError] = useState<string | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
setOrders(await refreshOrders());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [tc]);
|
||||
|
||||
// Beim Oeffnen: erst die gespeicherte Liste, dann der Abgleich.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
listOrders()
|
||||
.then((list) => {
|
||||
if (alive) setOrders((current) => current ?? list);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
});
|
||||
void refresh();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [refresh, tc]);
|
||||
|
||||
const hasOpen = (orders ?? []).some(isOpen);
|
||||
useEffect(() => {
|
||||
if (!hasOpen) return;
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void refresh();
|
||||
}, ORDERS_POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasOpen, refresh]);
|
||||
|
||||
async function onDiscard(order: DomainsOrder) {
|
||||
setDiscardError(null);
|
||||
try {
|
||||
await cancelOrder(order.id);
|
||||
setDiscardId(null);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setDiscardError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">{t('intro')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={refreshing}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
{refreshing ? t('refreshing') : t('refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{orders === null && !error && <p className="text-sm text-muted-foreground">{t('loading')}</p>}
|
||||
{orders !== null && orders.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('empty')}</p>
|
||||
)}
|
||||
|
||||
{orders !== null && orders.length > 0 && (
|
||||
<SettingsSection flush>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className={TABLE_HEAD_CLASS}>
|
||||
<tr>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.domain')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.system')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.status')}</th>
|
||||
<th className={TABLE_HEAD_CELL}>{t('columns.confirmed')}</th>
|
||||
{canManage && <th className={TABLE_HEAD_CELL} />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((order) => {
|
||||
const info = orderStatusInfo(order);
|
||||
const discardable = canManage && isDiscardable(order);
|
||||
return (
|
||||
<tr key={order.id} className="border-t border-border align-top">
|
||||
<td className={`${TABLE_CELL} font-medium text-foreground`}>
|
||||
{order.domainNameUnicode}
|
||||
</td>
|
||||
<td className={TABLE_CELL}>{t(`environment.${order.environment}`)}</td>
|
||||
<td className={TABLE_CELL}>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${info.className}`}
|
||||
>
|
||||
{t(`status.${info.labelKey}`)}
|
||||
</span>
|
||||
{order.status === 'FAILED' && order.errorText && (
|
||||
<p className="mt-1 text-xs text-destructive">{order.errorText}</p>
|
||||
)}
|
||||
{order.status === 'SUBMITTED' && order.jobStatus === 'SUPPORT' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('supportHint')}</p>
|
||||
)}
|
||||
{order.status === 'UNKNOWN' && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{order.lastCheckedAt ? t('unknownChecked') : t('unknownUnchecked')}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className={`${TABLE_CELL} tabular-nums`}>
|
||||
{order.confirmedByUsername && order.confirmedAt
|
||||
? t('confirmedBy', {
|
||||
user: order.confirmedByUsername,
|
||||
date: formatDateTime(order.confirmedAt, locale),
|
||||
})
|
||||
: t('notConfirmed')}
|
||||
</td>
|
||||
{canManage && (
|
||||
<td className={TABLE_CELL}>
|
||||
{discardable && discardId !== order.id && (
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => {
|
||||
setDiscardError(null);
|
||||
setDiscardId(order.id);
|
||||
}}
|
||||
>
|
||||
{t('discard')}
|
||||
</button>
|
||||
)}
|
||||
{discardable && discardId === order.id && (
|
||||
<div role="alertdialog" className="max-w-xs space-y-2 text-xs">
|
||||
<p className="text-foreground">
|
||||
{order.status === 'UNKNOWN'
|
||||
? t('confirmDiscardUnknown')
|
||||
: t('confirmDiscard')}
|
||||
</p>
|
||||
{discardError && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{discardError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => void onDiscard(order)}
|
||||
>
|
||||
{t('discardYes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setDiscardId(null)}
|
||||
>
|
||||
{t('discardNo')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
type AvailabilityResult,
|
||||
type DomainsContact,
|
||||
type DomainsCustomer,
|
||||
type DomainsOrder,
|
||||
DomainsRequestError,
|
||||
type DomainsStatus,
|
||||
type OrderSummary,
|
||||
} from '@/lib/domains-api';
|
||||
import de from '@/messages/de.json';
|
||||
import { RegisterTab } from './RegisterTab';
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const mockCheck = vi.fn();
|
||||
const mockCreate = vi.fn();
|
||||
const mockSubmit = vi.fn();
|
||||
const mockCancel = vi.fn();
|
||||
const mockListContacts = vi.fn();
|
||||
|
||||
vi.mock('@/lib/domains-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
|
||||
return {
|
||||
...actual,
|
||||
checkAvailability: (...a: unknown[]) => mockCheck(...a),
|
||||
createOrder: (...a: unknown[]) => mockCreate(...a),
|
||||
submitOrder: (...a: unknown[]) => mockSubmit(...a),
|
||||
cancelOrder: (...a: unknown[]) => mockCancel(...a),
|
||||
listContacts: (...a: unknown[]) => mockListContacts(...a),
|
||||
};
|
||||
});
|
||||
|
||||
function status(over: Partial<DomainsStatus> = {}): DomainsStatus {
|
||||
return {
|
||||
environment: 'DEMO',
|
||||
configured: true,
|
||||
demoConfigured: true,
|
||||
liveConfigured: true,
|
||||
defaultNameServers: ['ns1.example.com', 'ns2.example.com'],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const CUSTOMERS: DomainsCustomer[] = [
|
||||
{ id: 'c-own', name: 'Unsere Firma', isOwnCompany: true, contactCount: 1 },
|
||||
{ id: 'c-a', name: 'Alpha GmbH', isOwnCompany: false, contactCount: 1 },
|
||||
];
|
||||
|
||||
function contact(id: number, name: string, customerId: string | null): DomainsContact {
|
||||
return {
|
||||
id,
|
||||
type: 'ORG',
|
||||
displayName: name,
|
||||
fname: null,
|
||||
lname: null,
|
||||
organization: name,
|
||||
address: [],
|
||||
pcode: null,
|
||||
city: 'Berlin',
|
||||
country: 'DE',
|
||||
email: null,
|
||||
phone: null,
|
||||
alias: null,
|
||||
customerId,
|
||||
customerName:
|
||||
customerId === 'c-own' ? 'Unsere Firma' : customerId === 'c-a' ? 'Alpha GmbH' : null,
|
||||
};
|
||||
}
|
||||
|
||||
const CONTACTS = [
|
||||
contact(11, 'Alpha GmbH', 'c-a'),
|
||||
contact(22, 'Unsere Firma GmbH', 'c-own'),
|
||||
contact(33, 'Frei Fritz', null),
|
||||
];
|
||||
|
||||
function free(over: Partial<AvailabilityResult> = {}): AvailabilityResult {
|
||||
return {
|
||||
environment: 'DEMO',
|
||||
domain: 'beispiel.de',
|
||||
domainUnicode: 'beispiel.de',
|
||||
available: true,
|
||||
whoisStatus: 'FREE',
|
||||
price: { amount: 4.9, currency: 'EUR' },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function order(over: Partial<DomainsOrder> = {}): DomainsOrder {
|
||||
return {
|
||||
id: 'o1',
|
||||
environment: 'DEMO',
|
||||
domainName: 'beispiel.de',
|
||||
domainNameUnicode: 'beispiel.de',
|
||||
status: 'DRAFT',
|
||||
jobStatus: null,
|
||||
errorText: null,
|
||||
confirmedByUsername: null,
|
||||
confirmedAt: null,
|
||||
createdAt: '2026-10-08T10:00:00.000Z',
|
||||
lastCheckedAt: null,
|
||||
price: { amount: 4.9, currency: 'EUR' },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function summary(over: Partial<OrderSummary> = {}): OrderSummary {
|
||||
return {
|
||||
order: order(),
|
||||
contacts: {
|
||||
owner: { id: 11, displayName: 'Alpha GmbH' },
|
||||
admin: { id: 11, displayName: 'Alpha GmbH' },
|
||||
tech: { id: 22, displayName: 'Unsere Firma GmbH' },
|
||||
zone: { id: 22, displayName: 'Unsere Firma GmbH' },
|
||||
},
|
||||
nameServers: ['ns1.example.com', 'ns2.example.com'],
|
||||
periodYears: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function renderTab(statusOver: Partial<DomainsStatus> = {}, onOpenOrders = vi.fn()) {
|
||||
render(
|
||||
<RegisterTab status={status(statusOver)} customers={CUSTOMERS} onOpenOrders={onOpenOrders} />,
|
||||
);
|
||||
return { onOpenOrders };
|
||||
}
|
||||
|
||||
async function checkFree() {
|
||||
fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'beispiel.de' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Verfügbarkeit prüfen' }));
|
||||
await screen.findByText('beispiel.de ist frei.');
|
||||
}
|
||||
|
||||
async function reachSummary() {
|
||||
await checkFree();
|
||||
const owner = await screen.findByLabelText('Inhaber');
|
||||
await waitFor(() => expect(mockListContacts).toHaveBeenCalled());
|
||||
fireEvent.change(owner, { target: { value: '11' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zusammenfassung anzeigen' }));
|
||||
await screen.findByRole('button', { name: 'Jetzt verbindlich registrieren' });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheck.mockReset().mockResolvedValue(free());
|
||||
mockCreate.mockReset().mockResolvedValue(summary());
|
||||
mockSubmit.mockReset().mockResolvedValue(order({ status: 'SUBMITTED', jobStatus: 'RUNNING' }));
|
||||
mockCancel.mockReset().mockResolvedValue(order({ status: 'CANCELED' }));
|
||||
mockListContacts.mockReset().mockResolvedValue({
|
||||
environment: 'DEMO',
|
||||
contacts: CONTACTS,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-10-08T10:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('RegisterTab — Verfuegbarkeit', () => {
|
||||
it('zeigt "frei" mit Preis; Kontakte erscheinen erst danach', async () => {
|
||||
renderTab();
|
||||
expect(screen.queryByLabelText('Inhaber')).toBeNull();
|
||||
await checkFree();
|
||||
expect(mockCheck).toHaveBeenCalledWith('beispiel.de');
|
||||
expect(screen.getByText(/Preis für ein Jahr: 4,90/)).toBeTruthy();
|
||||
expect(await screen.findByLabelText('Inhaber')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('nicht frei: Hinweis, keine Kontaktauswahl', async () => {
|
||||
mockCheck.mockResolvedValue(free({ available: false, whoisStatus: 'ASSIGNED' }));
|
||||
renderTab();
|
||||
fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'beispiel.de' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Verfügbarkeit prüfen' }));
|
||||
expect(
|
||||
await screen.findByText('beispiel.de ist nicht frei und kann nicht registriert werden.'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByLabelText('Inhaber')).toBeNull();
|
||||
expect(mockListContacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('frei ohne Preis: "Preis nicht ermittelbar"', async () => {
|
||||
mockCheck.mockResolvedValue(free({ price: null }));
|
||||
renderTab();
|
||||
await checkFree();
|
||||
expect(screen.getByText('Preis nicht ermittelbar')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('zeigt den Fehlertext der API', async () => {
|
||||
mockCheck.mockRejectedValue(new DomainsRequestError(400, 'invalidDomain', 'Ungültiger Name.'));
|
||||
renderTab();
|
||||
fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'x' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Verfügbarkeit prüfen' }));
|
||||
expect((await screen.findByRole('alert')).textContent).toBe('Ungültiger Name.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RegisterTab — Kontakte und Nameserver', () => {
|
||||
it('Voreinstellung: Admin = Inhaber, Technik/Zone = Kontakt der eigenen Firma; Nameserver aus den Einstellungen', async () => {
|
||||
renderTab();
|
||||
await checkFree();
|
||||
const owner = (await screen.findByLabelText('Inhaber')) as HTMLSelectElement;
|
||||
await waitFor(() => expect(owner.options.length).toBeGreaterThan(1));
|
||||
expect(owner.value).toBe('');
|
||||
fireEvent.change(owner, { target: { value: '33' } });
|
||||
expect((screen.getByLabelText('Admin-Kontakt') as HTMLSelectElement).value).toBe('33');
|
||||
expect((screen.getByLabelText('Technischer Kontakt') as HTMLSelectElement).value).toBe('22');
|
||||
expect((screen.getByLabelText('Zonenkontakt') as HTMLSelectElement).value).toBe('22');
|
||||
expect((screen.getByLabelText('Nameserver 1') as HTMLInputElement).value).toBe(
|
||||
'ns1.example.com',
|
||||
);
|
||||
expect((screen.getByLabelText('Nameserver 2') as HTMLInputElement).value).toBe(
|
||||
'ns2.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('ohne Kontakt der eigenen Firma folgen Technik und Zone dem Inhaber', async () => {
|
||||
render(<RegisterTab status={status()} customers={[CUSTOMERS[1]]} onOpenOrders={vi.fn()} />);
|
||||
await checkFree();
|
||||
const owner = (await screen.findByLabelText('Inhaber')) as HTMLSelectElement;
|
||||
await waitFor(() => expect(owner.options.length).toBeGreaterThan(1));
|
||||
fireEvent.change(owner, { target: { value: '11' } });
|
||||
expect((screen.getByLabelText('Technischer Kontakt') as HTMLSelectElement).value).toBe('11');
|
||||
expect((screen.getByLabelText('Zonenkontakt') as HTMLSelectElement).value).toBe('11');
|
||||
});
|
||||
|
||||
it('"Zusammenfassung anzeigen" ist ohne Inhaber gesperrt', async () => {
|
||||
renderTab();
|
||||
await checkFree();
|
||||
await screen.findByLabelText('Inhaber');
|
||||
expect(
|
||||
(screen.getByRole('button', { name: 'Zusammenfassung anzeigen' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('Nameserver lassen sich zwischen 2 und 6 hinzufuegen und entfernen', async () => {
|
||||
renderTab({ defaultNameServers: [] });
|
||||
await checkFree();
|
||||
await screen.findByLabelText('Inhaber');
|
||||
expect(screen.queryByRole('button', { name: 'Entfernen' })).toBeNull();
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Nameserver hinzufügen' }));
|
||||
}
|
||||
expect(screen.queryByRole('button', { name: 'Nameserver hinzufügen' })).toBeNull();
|
||||
expect(screen.getByLabelText('Nameserver 6')).toBeTruthy();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Entfernen' })[0]);
|
||||
expect(screen.queryByLabelText('Nameserver 6')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RegisterTab — Zusammenfassung und verbindliche Bestaetigung', () => {
|
||||
it('createOrder bekommt die Auswahl; die Zusammenfassung nennt System, Domain, Preis, Kontakte, Nameserver', async () => {
|
||||
renderTab();
|
||||
await reachSummary();
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
domain: 'beispiel.de',
|
||||
ownerContactId: 11,
|
||||
adminContactId: 11,
|
||||
techContactId: 22,
|
||||
zoneContactId: 22,
|
||||
nameServers: ['ns1.example.com', 'ns2.example.com'],
|
||||
});
|
||||
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
|
||||
'Demo-System (Testbetrieb)',
|
||||
);
|
||||
expect(screen.getByText('1 Jahr')).toBeTruthy();
|
||||
expect(screen.getByText(/4,90/)).toBeTruthy();
|
||||
expect(screen.getByText('ns1.example.com, ns2.example.com')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('Ich bestätige die Registrierung im Demo-System von AutoDNS (Testbetrieb).'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Live: Kennzeichnung und kostenpflichtiger Bestaetigungstext; ohne Preis "Preis nicht ermittelbar"', async () => {
|
||||
mockCreate.mockResolvedValue(summary({ order: order({ environment: 'LIVE', price: null }) }));
|
||||
renderTab({ environment: 'LIVE' });
|
||||
await reachSummary();
|
||||
expect(screen.getByTestId('domains-environment-badge').textContent).toBe(
|
||||
'Live-System – Registrierungen kosten Geld',
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Ich bestätige die verbindliche und kostenpflichtige Registrierung bei AutoDNS.',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('Preis nicht ermittelbar')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('der Knopf ist gesperrt, bis das Haekchen gesetzt ist', async () => {
|
||||
renderTab();
|
||||
await reachSummary();
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'Jetzt verbindlich registrieren',
|
||||
}) as HTMLButtonElement;
|
||||
expect(button.disabled).toBe(true);
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
expect(button.disabled).toBe(false);
|
||||
expect(mockSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('zwei schnelle Klicks rufen submitOrder genau einmal auf, der Knopf zeigt "Wird übermittelt …"', async () => {
|
||||
let resolveSubmit!: (o: DomainsOrder) => void;
|
||||
mockSubmit.mockImplementation(
|
||||
() =>
|
||||
new Promise<DomainsOrder>((resolve) => {
|
||||
resolveSubmit = resolve;
|
||||
}),
|
||||
);
|
||||
renderTab();
|
||||
await reachSummary();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
const button = screen.getByRole('button', { name: 'Jetzt verbindlich registrieren' });
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(mockSubmit).toHaveBeenCalledWith('o1');
|
||||
expect(screen.getByRole('button', { name: 'Wird übermittelt …' })).toBeTruthy();
|
||||
resolveSubmit(order({ status: 'SUBMITTED', jobStatus: 'RUNNING' }));
|
||||
await screen.findByText(/wird bearbeitet/);
|
||||
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SUBMITTED zeigt den Fortschrittstext und fuehrt zu den Auftraegen', async () => {
|
||||
const { onOpenOrders } = renderTab();
|
||||
await reachSummary();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Jetzt verbindlich registrieren' }));
|
||||
await screen.findByText(/Die Registrierung wurde an AutoDNS übergeben und wird bearbeitet/);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zu den Aufträgen' }));
|
||||
expect(onOpenOrders).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FAILED zeigt den Fehler von AutoDNS', async () => {
|
||||
mockSubmit.mockResolvedValue(
|
||||
order({ status: 'FAILED', errorText: 'AutoDNS meldet: Domain not available' }),
|
||||
);
|
||||
renderTab();
|
||||
await reachSummary();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Jetzt verbindlich registrieren' }));
|
||||
expect(await screen.findByText('AutoDNS meldet: Domain not available')).toBeTruthy();
|
||||
expect(screen.getByText('AutoDNS hat die Registrierung nicht ausgeführt.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('UNKNOWN zeigt "Ergebnis ungeklärt" mit dem Rat, nicht erneut zu bestellen', async () => {
|
||||
mockSubmit.mockResolvedValue(order({ status: 'UNKNOWN' }));
|
||||
renderTab();
|
||||
await reachSummary();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Jetzt verbindlich registrieren' }));
|
||||
expect(await screen.findByText('Ergebnis ungeklärt')).toBeTruthy();
|
||||
expect(screen.getByText(/Bestellen Sie die Domain bitte nicht erneut/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Weitere Domain registrieren' })).toBeNull();
|
||||
});
|
||||
|
||||
it('ein Fehler beim Abschicken laesst den Knopf gesperrt, bis "Zurück" gewaehlt wird', async () => {
|
||||
mockSubmit.mockRejectedValue(
|
||||
new DomainsRequestError(409, 'alreadySubmitted', 'Dieser Auftrag wurde bereits abgeschickt.'),
|
||||
);
|
||||
renderTab();
|
||||
await reachSummary();
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
const button = screen.getByRole('button', {
|
||||
name: 'Jetzt verbindlich registrieren',
|
||||
}) as HTMLButtonElement;
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByText('Dieser Auftrag wurde bereits abgeschickt.')).toBeTruthy();
|
||||
expect(button.disabled).toBe(true);
|
||||
fireEvent.click(button);
|
||||
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zurück' }));
|
||||
expect(screen.queryByRole('button', { name: 'Jetzt verbindlich registrieren' })).toBeNull();
|
||||
});
|
||||
|
||||
it('"Abbrechen" ruft cancelOrder auf und beginnt neu', async () => {
|
||||
renderTab();
|
||||
await reachSummary();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
|
||||
await waitFor(() => expect(mockCancel).toHaveBeenCalledWith('o1'));
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText('Domain') as HTMLInputElement).value).toBe(''),
|
||||
);
|
||||
expect(mockSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SettingsSection } from '@/components/control-center/settings-section';
|
||||
import { groupByCustomer } from '@/components/domains/group-by-customer';
|
||||
import { orderStatusInfo } from '@/components/domains/order-status';
|
||||
import { INPUT_CLASS, PRIMARY_BUTTON, SECONDARY_BUTTON } from '@/components/domains/ui-classes';
|
||||
import {
|
||||
type AvailabilityResult,
|
||||
cancelOrder,
|
||||
checkAvailability,
|
||||
createOrder,
|
||||
type DomainsContact,
|
||||
type DomainsCustomer,
|
||||
type DomainsOrder,
|
||||
DomainsRequestError,
|
||||
type DomainsStatus,
|
||||
listContacts,
|
||||
type OrderPrice,
|
||||
type OrderSummary,
|
||||
submitOrder,
|
||||
} from '@/lib/domains-api';
|
||||
import { EnvironmentBadge } from './EnvironmentBadge';
|
||||
|
||||
const MIN_NAMESERVERS = 2;
|
||||
const MAX_NAMESERVERS = 6;
|
||||
|
||||
type Role = 'owner' | 'admin' | 'tech' | 'zone';
|
||||
const ROLES: Role[] = ['owner', 'admin', 'tech', 'zone'];
|
||||
|
||||
export function formatPrice(price: OrderPrice, locale: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, { style: 'currency', currency: price.currency }).format(
|
||||
price.amount,
|
||||
);
|
||||
} catch {
|
||||
return `${price.amount} ${price.currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function initialNameServers(defaults: string[]): string[] {
|
||||
const list = defaults.slice(0, MAX_NAMESERVERS);
|
||||
while (list.length < MIN_NAMESERVERS) list.push('');
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter "Registrieren" (L-04): Verfuegbarkeit -> Kontakte und Nameserver ->
|
||||
* Zusammenfassung -> verbindliche Bestaetigung. Der Knopf "Jetzt verbindlich
|
||||
* registrieren" ist nur Komfort-Schutz gegen Doppelklick (`useRef` sperrt noch
|
||||
* vor dem naechsten Zeichnen); die eigentliche Sperre ist der atomare Anspruch
|
||||
* auf dem Server. Nach einem Fehler beim Abschicken bleibt der Knopf gesperrt,
|
||||
* bis der Benutzer bewusst "Zurueck" waehlt — der Auftrag koennte schon
|
||||
* angekommen sein.
|
||||
*/
|
||||
export function RegisterTab({
|
||||
status,
|
||||
customers,
|
||||
onOpenOrders,
|
||||
}: {
|
||||
status: DomainsStatus;
|
||||
customers: DomainsCustomer[];
|
||||
onOpenOrders: () => void;
|
||||
}) {
|
||||
const t = useTranslations('domains.register');
|
||||
const tc = useTranslations('domains.common');
|
||||
const locale = useLocale();
|
||||
|
||||
const [domainInput, setDomainInput] = useState('');
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [availability, setAvailability] = useState<AvailabilityResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [contacts, setContacts] = useState<DomainsContact[] | null>(null);
|
||||
const [contactsLoading, setContactsLoading] = useState(false);
|
||||
const [chosen, setChosen] = useState<Partial<Record<Role, number>>>({});
|
||||
const [nameServers, setNameServers] = useState<string[]>(() =>
|
||||
initialNameServers(status.defaultNameServers),
|
||||
);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [summary, setSummary] = useState<OrderSummary | null>(null);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [canceling, setCanceling] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<DomainsOrder | null>(null);
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
const available = availability?.available === true;
|
||||
|
||||
// Kontakte erst laden, wenn die Domain frei ist.
|
||||
useEffect(() => {
|
||||
if (!available || contacts !== null) return;
|
||||
let alive = true;
|
||||
setContactsLoading(true);
|
||||
listContacts()
|
||||
.then((list) => {
|
||||
if (alive) setContacts(list.contacts);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setContactsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [available, contacts, tc]);
|
||||
|
||||
const ownCompanyId = customers.find((c) => c.isOwnCompany)?.id ?? null;
|
||||
const ownCompanyContact = useMemo(
|
||||
() => (ownCompanyId ? contacts?.find((c) => c.customerId === ownCompanyId) : undefined),
|
||||
[contacts, ownCompanyId],
|
||||
);
|
||||
|
||||
/** Vorbelegung (D-O): Admin = Inhaber; Technik/Zone = Kontakt der eigenen Firma, sonst Inhaber. */
|
||||
const effective = (role: Role): number | null => {
|
||||
const picked = chosen[role];
|
||||
if (picked !== undefined) return picked;
|
||||
if (role === 'owner') return null;
|
||||
if (role === 'admin') return chosen.owner ?? null;
|
||||
return ownCompanyContact?.id ?? chosen.owner ?? null;
|
||||
};
|
||||
|
||||
const groups = useMemo(() => groupByCustomer(contacts ?? [], customers), [contacts, customers]);
|
||||
|
||||
const filledNameServers = nameServers.map((n) => n.trim()).filter((n) => n.length > 0);
|
||||
const contactIds = ROLES.map(effective);
|
||||
const canSummarize =
|
||||
available &&
|
||||
contactIds.every((id) => id !== null) &&
|
||||
filledNameServers.length >= MIN_NAMESERVERS &&
|
||||
!creating;
|
||||
|
||||
function resetAll() {
|
||||
submittingRef.current = false;
|
||||
setDomainInput('');
|
||||
setAvailability(null);
|
||||
setError(null);
|
||||
setChosen({});
|
||||
setNameServers(initialNameServers(status.defaultNameServers));
|
||||
setSummary(null);
|
||||
setConfirmed(false);
|
||||
setSubmitting(false);
|
||||
setSubmitError(null);
|
||||
setResult(null);
|
||||
}
|
||||
|
||||
async function onCheck() {
|
||||
setError(null);
|
||||
setChecking(true);
|
||||
setAvailability(null);
|
||||
setSummary(null);
|
||||
try {
|
||||
setAvailability(await checkAvailability(domainInput));
|
||||
} catch (e) {
|
||||
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSummary() {
|
||||
if (!availability || contactIds.some((id) => id === null)) return;
|
||||
setError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const [ownerContactId, adminContactId, techContactId, zoneContactId] = contactIds as number[];
|
||||
setSummary(
|
||||
await createOrder({
|
||||
domain: availability.domain,
|
||||
ownerContactId,
|
||||
adminContactId,
|
||||
techContactId,
|
||||
zoneContactId,
|
||||
nameServers: filledNameServers,
|
||||
}),
|
||||
);
|
||||
setConfirmed(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
// Sperre noch VOR dem naechsten Zeichnen: ein zweiter Klick kommt hier nicht weiter.
|
||||
if (submittingRef.current || !summary || !confirmed) return;
|
||||
submittingRef.current = true;
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
setResult(await submitOrder(summary.order.id));
|
||||
} catch (e) {
|
||||
// Die Sperre bleibt: der Auftrag koennte angekommen sein. Nur "Zurueck" hebt sie auf.
|
||||
setSubmitError(e instanceof DomainsRequestError ? e.message : t('submitFailed'));
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
submittingRef.current = false;
|
||||
setSubmitError(null);
|
||||
setSubmitting(false);
|
||||
setSummary(null);
|
||||
setConfirmed(false);
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
if (!summary || canceling) return;
|
||||
setCanceling(true);
|
||||
try {
|
||||
await cancelOrder(summary.order.id);
|
||||
resetAll();
|
||||
} catch (e) {
|
||||
setSubmitError(e instanceof DomainsRequestError ? e.message : tc('requestFailed'));
|
||||
} finally {
|
||||
setCanceling(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ergebnis ---------------------------------------------------------------
|
||||
|
||||
if (result) {
|
||||
const info = orderStatusInfo(result);
|
||||
return (
|
||||
<SettingsSection title={t('result.title')}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">{result.domainNameUnicode}</p>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-semibold ${info.className}`}
|
||||
>
|
||||
{result.status === 'UNKNOWN' ? t('result.UNKNOWN') : t(`result.${resultKey(result)}`)}
|
||||
</span>
|
||||
{result.status === 'UNKNOWN' && (
|
||||
<p role="alert" className="text-sm text-foreground">
|
||||
{t('result.unknownAdvice')}
|
||||
</p>
|
||||
)}
|
||||
{result.status === 'FAILED' && result.errorText && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{result.errorText}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button type="button" className={PRIMARY_BUTTON} onClick={onOpenOrders}>
|
||||
{t('result.toOrders')}
|
||||
</button>
|
||||
{result.status !== 'UNKNOWN' && (
|
||||
<button type="button" className={SECONDARY_BUTTON} onClick={resetAll}>
|
||||
{t('result.another')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Zusammenfassung --------------------------------------------------------
|
||||
|
||||
if (summary) {
|
||||
const live = summary.order.environment === 'LIVE';
|
||||
const rows: { label: string; value: string }[] = [
|
||||
{ label: t('summary.domain'), value: summary.order.domainNameUnicode },
|
||||
{ label: t('summary.period'), value: t('summary.periodValue') },
|
||||
{
|
||||
label: t('summary.price'),
|
||||
value: summary.order.price ? formatPrice(summary.order.price, locale) : t('priceUnknown'),
|
||||
},
|
||||
...ROLES.map((role) => ({
|
||||
label: t(`roles.${role}`),
|
||||
value: `${summary.contacts[role].displayName} (#${summary.contacts[role].id})`,
|
||||
})),
|
||||
{ label: t('summary.nameServers'), value: summary.nameServers.join(', ') },
|
||||
];
|
||||
return (
|
||||
<SettingsSection title={t('summary.title')} description={t('summary.description')}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">{t('summary.system')}</span>
|
||||
<EnvironmentBadge
|
||||
status={{ ...status, environment: summary.order.environment, configured: true }}
|
||||
/>
|
||||
</div>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-[12rem_1fr]">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="contents">
|
||||
<dt className="text-muted-foreground">{row.label}</dt>
|
||||
<dd className="text-foreground">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<label className="flex items-start gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={confirmed}
|
||||
disabled={submitting || submitError !== null}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
/>
|
||||
{live ? t('confirmLive') : t('confirmDemo')}
|
||||
</label>
|
||||
{submitError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={!confirmed || submitting || submitError !== null}
|
||||
onClick={() => void onSubmit()}
|
||||
>
|
||||
{submitting ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
{submitError ? (
|
||||
<>
|
||||
<button type="button" className={SECONDARY_BUTTON} onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<button type="button" className={SECONDARY_BUTTON} onClick={onOpenOrders}>
|
||||
{t('result.toOrders')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
disabled={submitting || canceling}
|
||||
onClick={() => void onCancel()}
|
||||
>
|
||||
{canceling ? t('canceling') : t('cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Eingabe ----------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsSection title={t('domainLabel')} description={t('intro')}>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-56 flex-1">
|
||||
<label htmlFor="domains-register-domain" className="sr-only">
|
||||
{t('domainLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="domains-register-domain"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder={t('domainPlaceholder')}
|
||||
value={domainInput}
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
setDomainInput(e.target.value);
|
||||
setAvailability(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && domainInput.trim() && !checking) void onCheck();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={checking || domainInput.trim() === ''}
|
||||
onClick={() => void onCheck()}
|
||||
>
|
||||
{checking ? t('checking') : t('check')}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{availability && available && (
|
||||
<p className="mt-3 text-sm text-foreground">
|
||||
<span className="mr-2 inline-flex rounded-full bg-status-ok/12 px-2 py-0.5 text-xs font-medium text-status-ok-fg">
|
||||
{t('free', { domain: availability.domainUnicode })}
|
||||
</span>
|
||||
{availability.price
|
||||
? t('price', { price: formatPrice(availability.price, locale) })
|
||||
: t('priceUnknown')}
|
||||
</p>
|
||||
)}
|
||||
{availability && !available && (
|
||||
<div className="mt-3 space-y-1 text-sm">
|
||||
<p className="text-foreground">
|
||||
{t('notFree', { domain: availability.domainUnicode })}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t('notFreeStatus', { status: availability.whoisStatus })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{available && (
|
||||
<>
|
||||
<SettingsSection title={t('contactsTitle')} description={t('contactsDescription')}>
|
||||
{contactsLoading && (
|
||||
<p className="text-sm text-muted-foreground">{t('contactsLoading')}</p>
|
||||
)}
|
||||
{contacts !== null && contacts.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('contactsEmpty')}</p>
|
||||
)}
|
||||
{contacts !== null && contacts.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{ROLES.map((role) => {
|
||||
const id = `domains-register-${role}`;
|
||||
const value = effective(role);
|
||||
return (
|
||||
<div key={role}>
|
||||
<label htmlFor={id} className="mb-1 block text-xs text-muted-foreground">
|
||||
{t(`roles.${role}`)}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setChosen((prev) => {
|
||||
const copy = { ...prev };
|
||||
if (next === '') delete copy[role];
|
||||
else copy[role] = Number(next);
|
||||
return copy;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{t('choose')}</option>
|
||||
{groups.map((group) => (
|
||||
<optgroup key={group.key} label={group.name ?? tc('unassigned')}>
|
||||
{group.items.map((contact) => (
|
||||
<option key={contact.id} value={String(contact.id)}>
|
||||
{contact.displayName}
|
||||
{contact.city ? ` – ${contact.city}` : ''} (#{contact.id})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('nameServersTitle')}
|
||||
description={t('nameServersDescription')}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
className={PRIMARY_BUTTON}
|
||||
disabled={!canSummarize}
|
||||
onClick={() => void onSummary()}
|
||||
>
|
||||
{creating ? t('creating') : t('showSummary')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{nameServers.map((value, index) => {
|
||||
const label = t('nameServer', { number: index + 1 });
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: feste, nummerierte Eingabefelder
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={label}
|
||||
className={INPUT_CLASS}
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
setNameServers((prev) =>
|
||||
prev.map((n, i) => (i === index ? e.target.value : n)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{nameServers.length > MIN_NAMESERVERS && (
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setNameServers((prev) => prev.filter((_, i) => i !== index))}
|
||||
>
|
||||
{t('removeNameServer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{nameServers.length < MAX_NAMESERVERS && (
|
||||
<button
|
||||
type="button"
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setNameServers((prev) => [...prev, ''])}
|
||||
>
|
||||
{t('addNameServer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Schluessel unter `domains.register.result.*` fuer einen Auftrag nach dem Abschicken. */
|
||||
function resultKey(order: DomainsOrder): 'SUBMITTED' | 'SUCCESS' | 'FAILED' {
|
||||
if (order.status === 'FAILED') return 'FAILED';
|
||||
if (order.status === 'SUCCESS') return 'SUCCESS';
|
||||
return 'SUBMITTED';
|
||||
}
|
||||
@@ -37,6 +37,8 @@ const mockListDomains = vi.fn();
|
||||
const mockAssignContacts = vi.fn();
|
||||
const mockCreateCustomer = vi.fn();
|
||||
const mockDeleteCustomer = vi.fn();
|
||||
const mockListOrders = vi.fn();
|
||||
const mockRefreshOrders = vi.fn();
|
||||
|
||||
vi.mock('@/lib/domains-api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/domains-api')>();
|
||||
@@ -52,6 +54,8 @@ vi.mock('@/lib/domains-api', async (importOriginal) => {
|
||||
assignContacts: (...args: unknown[]) => mockAssignContacts(...args),
|
||||
createCustomer: (...args: unknown[]) => mockCreateCustomer(...args),
|
||||
deleteCustomer: (...args: unknown[]) => mockDeleteCustomer(...args),
|
||||
listOrders: (...args: unknown[]) => mockListOrders(...args),
|
||||
refreshOrders: (...args: unknown[]) => mockRefreshOrders(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -185,6 +189,8 @@ beforeEach(() => {
|
||||
mockAssignContacts.mockReset().mockResolvedValue({ assigned: 1, removed: 0 });
|
||||
mockCreateCustomer.mockReset().mockResolvedValue(CUSTOMERS[1]);
|
||||
mockDeleteCustomer.mockReset().mockResolvedValue({ deleted: true });
|
||||
mockListOrders.mockReset().mockResolvedValue([]);
|
||||
mockRefreshOrders.mockReset().mockResolvedValue([]);
|
||||
mockGetStatus.mockReset().mockResolvedValue(status());
|
||||
mockGetSettings.mockReset().mockResolvedValue(settings());
|
||||
mockSaveSettings.mockReset().mockImplementation(async () => settings());
|
||||
@@ -517,3 +523,38 @@ describe('Reiter Domains, Kontakte und Kunden', () => {
|
||||
expect(mockListDomains).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reiter Registrieren und Aufträge', () => {
|
||||
it('Verwalter sieht beide Reiter in der festen Reihenfolge', async () => {
|
||||
render(<DomainsPage />);
|
||||
await screen.findByRole('button', { name: 'Einstellungen' });
|
||||
const wanted = ['Domains', 'Kontakte', 'Kunden', 'Registrieren', 'Aufträge', 'Einstellungen'];
|
||||
const names = screen
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent ?? '')
|
||||
.filter((x) => wanted.includes(x));
|
||||
expect(names).toEqual(wanted);
|
||||
});
|
||||
|
||||
it('Benutzer ohne Verwalten sieht Aufträge, aber weder Registrieren noch Einstellungen', async () => {
|
||||
mockCanManage = false;
|
||||
render(<DomainsPage />);
|
||||
await screen.findByTestId('domains-environment-badge');
|
||||
expect(screen.getByRole('button', { name: 'Aufträge' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Registrieren' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
|
||||
});
|
||||
|
||||
it('der Reiter Aufträge gleicht beim Öffnen mit AutoDNS ab', async () => {
|
||||
mockCanManage = false;
|
||||
render(<DomainsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Aufträge' }));
|
||||
await waitFor(() => expect(mockRefreshOrders).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('der Reiter Registrieren zeigt die Domain-Eingabe', async () => {
|
||||
render(<DomainsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Registrieren' }));
|
||||
expect(await screen.findByRole('button', { name: 'Verfügbarkeit prüfen' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,16 +16,18 @@ import { ContactsTab } from './components/ContactsTab';
|
||||
import { CustomersTab } from './components/CustomersTab';
|
||||
import { DomainsTab } from './components/DomainsTab';
|
||||
import { EnvironmentBadge } from './components/EnvironmentBadge';
|
||||
import { OrdersTab } from './components/OrdersTab';
|
||||
import { RegisterTab } from './components/RegisterTab';
|
||||
import { SettingsTab } from './components/SettingsTab';
|
||||
|
||||
type TabId = 'domains' | 'contacts' | 'customers' | 'settings';
|
||||
type TabId = 'domains' | 'contacts' | 'customers' | 'register' | 'orders' | 'settings';
|
||||
|
||||
/**
|
||||
* Domains (quick-261008-dts): Domains bei AutoDNS registrieren, Kontakte und
|
||||
* Kunden zuordnen. Der Seitenkopf traegt dauerhaft die Kennzeichnung des
|
||||
* aktiven Systems (Demo/Live). Die Reiter Domains, Kontakte und Kunden sehen alle
|
||||
* mit "Benutzen"; "Einstellungen" und die Schreibknoepfe erscheinen nur fuer
|
||||
* Administratoren und Benutzer mit der Freigabestufe Verwalten — bindend ist
|
||||
* aktiven Systems (Demo/Live). Die Reiter Domains, Kontakte, Kunden und Auftraege
|
||||
* sehen alle mit "Benutzen"; "Registrieren", "Einstellungen" und die Schreibknoepfe
|
||||
* erscheinen nur fuer Administratoren und Benutzer mit der Freigabestufe Verwalten — bindend ist
|
||||
* allein die API. Die Kundenliste wird einmal hier geladen und nach unten
|
||||
* gereicht (Filter, Zuordnung, Formular).
|
||||
*/
|
||||
@@ -65,6 +67,8 @@ export default function DomainsPage() {
|
||||
{ id: 'contacts', label: t('tabs.contacts') },
|
||||
{ id: 'customers', label: t('tabs.customers') },
|
||||
];
|
||||
if (canManage) tabs.push({ id: 'register', label: t('tabs.register') });
|
||||
tabs.push({ id: 'orders', label: t('tabs.orders') });
|
||||
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
||||
const activeTab = tabs.some((x) => x.id === tab) ? tab : tabs[0]?.id;
|
||||
|
||||
@@ -111,6 +115,15 @@ export default function DomainsPage() {
|
||||
{activeTab === 'customers' && (
|
||||
<CustomersTab customers={customers} canManage={canManage} onChanged={reloadCustomers} />
|
||||
)}
|
||||
{activeTab === 'register' && canManage && status?.configured && (
|
||||
<RegisterTab
|
||||
key={status.environment}
|
||||
status={status}
|
||||
customers={customers}
|
||||
onOpenOrders={() => setTab('orders')}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'orders' && <OrdersTab canManage={canManage} />}
|
||||
{activeTab === 'settings' && (
|
||||
<SettingsTab
|
||||
onChanged={() => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isDiscardable, isOpen, orderStatusInfo } from './order-status';
|
||||
|
||||
const base = { jobStatus: null, lastCheckedAt: null };
|
||||
|
||||
describe('orderStatusInfo', () => {
|
||||
it('SUBMITTED mit jobStatus SUPPORT heisst "Rückfrage nötig"', () => {
|
||||
expect(orderStatusInfo({ ...base, status: 'SUBMITTED', jobStatus: 'SUPPORT' }).labelKey).toBe(
|
||||
'SUPPORT',
|
||||
);
|
||||
expect(orderStatusInfo({ ...base, status: 'SUBMITTED', jobStatus: 'RUNNING' }).labelKey).toBe(
|
||||
'SUBMITTED',
|
||||
);
|
||||
});
|
||||
|
||||
it('Farben: Erfolg ok, Fehler down, unklar warn, Entwurf/Verworfen idle', () => {
|
||||
expect(orderStatusInfo({ ...base, status: 'SUCCESS' }).className).toContain('status-ok');
|
||||
expect(orderStatusInfo({ ...base, status: 'FAILED' }).className).toContain('status-down');
|
||||
expect(orderStatusInfo({ ...base, status: 'UNKNOWN' }).className).toContain('status-warn');
|
||||
expect(orderStatusInfo({ ...base, status: 'DRAFT' }).className).toContain('status-idle');
|
||||
expect(orderStatusInfo({ ...base, status: 'CANCELED' }).className).toContain('status-idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOpen / isDiscardable', () => {
|
||||
it('offen sind laufende Auftraege und ungeprueft unklare', () => {
|
||||
expect(isOpen({ ...base, status: 'SUBMITTING' })).toBe(true);
|
||||
expect(isOpen({ ...base, status: 'SUBMITTED' })).toBe(true);
|
||||
expect(isOpen({ ...base, status: 'UNKNOWN' })).toBe(true);
|
||||
expect(isOpen({ ...base, status: 'UNKNOWN', lastCheckedAt: '2026-10-08T10:00:00Z' })).toBe(
|
||||
false,
|
||||
);
|
||||
for (const status of ['DRAFT', 'SUCCESS', 'FAILED', 'CANCELED'] as const) {
|
||||
expect(isOpen({ ...base, status })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('verworfen werden nur Entwuerfe und geprueft unklare Auftraege', () => {
|
||||
expect(isDiscardable({ ...base, status: 'DRAFT' })).toBe(true);
|
||||
expect(isDiscardable({ ...base, status: 'UNKNOWN' })).toBe(false);
|
||||
expect(
|
||||
isDiscardable({ ...base, status: 'UNKNOWN', lastCheckedAt: '2026-10-08T10:00:00Z' }),
|
||||
).toBe(true);
|
||||
expect(isDiscardable({ ...base, status: 'SUBMITTED' })).toBe(false);
|
||||
expect(isDiscardable({ ...base, status: 'SUCCESS' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { DomainOrderStatus } from '@/lib/domains-api';
|
||||
|
||||
/**
|
||||
* Darstellung des Auftragszustands (quick-261008-dts): Beschriftungsschluessel
|
||||
* und Pillen-Klassen. Die Klassen stehen als vollstaendige Zeichenketten,
|
||||
* damit Tailwind sie findet.
|
||||
*/
|
||||
|
||||
export interface OrderLike {
|
||||
status: DomainOrderStatus;
|
||||
jobStatus: string | null;
|
||||
lastCheckedAt: string | null;
|
||||
}
|
||||
|
||||
export interface OrderStatusInfo {
|
||||
/** Schluessel unter `domains.orders.status.*`. */
|
||||
labelKey: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const OK = 'bg-status-ok/12 text-status-ok-fg';
|
||||
const WARN = 'bg-status-warn/12 text-status-warn-fg';
|
||||
const DOWN = 'bg-status-down/12 text-status-down-fg';
|
||||
const IDLE = 'bg-status-idle/12 text-status-idle-fg';
|
||||
|
||||
export function orderStatusInfo(order: OrderLike): OrderStatusInfo {
|
||||
switch (order.status) {
|
||||
case 'SUCCESS':
|
||||
return { labelKey: 'SUCCESS', className: OK };
|
||||
case 'FAILED':
|
||||
return { labelKey: 'FAILED', className: DOWN };
|
||||
case 'UNKNOWN':
|
||||
return { labelKey: 'UNKNOWN', className: WARN };
|
||||
case 'SUBMITTING':
|
||||
return { labelKey: 'SUBMITTING', className: WARN };
|
||||
case 'SUBMITTED':
|
||||
// Eingriff bei InterNetX noetig: nicht "laeuft", sondern "Rueckfrage".
|
||||
return order.jobStatus === 'SUPPORT'
|
||||
? { labelKey: 'SUPPORT', className: WARN }
|
||||
: { labelKey: 'SUBMITTED', className: WARN };
|
||||
case 'CANCELED':
|
||||
return { labelKey: 'CANCELED', className: IDLE };
|
||||
default:
|
||||
return { labelKey: 'DRAFT', className: IDLE };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird der Auftrag noch verfolgt? Dann lohnt der regelmaessige Abgleich. Ein
|
||||
* unklarer Auftrag zaehlt nur, solange noch nie bei AutoDNS nachgesehen wurde —
|
||||
* danach wartet er auf den Benutzer und soll nicht dauernd Anfragen ausloesen.
|
||||
*/
|
||||
export function isOpen(order: OrderLike): boolean {
|
||||
if (order.status === 'SUBMITTING' || order.status === 'SUBMITTED') return true;
|
||||
return order.status === 'UNKNOWN' && order.lastCheckedAt === null;
|
||||
}
|
||||
|
||||
/** Darf der Auftrag verworfen werden? (Entwurf; unklar erst nach einem Abgleich.) */
|
||||
export function isDiscardable(order: OrderLike): boolean {
|
||||
if (order.status === 'DRAFT') return true;
|
||||
return order.status === 'UNKNOWN' && order.lastCheckedAt !== null;
|
||||
}
|
||||
@@ -244,3 +244,99 @@ export function assignContacts(
|
||||
export function listDomains(opts: { refresh?: boolean } = {}): Promise<DomainsDomainList> {
|
||||
return request<DomainsDomainList>(`/domains${opts.refresh ? '?refresh=1' : ''}`);
|
||||
}
|
||||
|
||||
// --- Verfuegbarkeit, Registrierung und Auftraege (quick-261008-dts, Aufgabe 3) ----
|
||||
|
||||
export type DomainOrderStatus =
|
||||
| 'DRAFT'
|
||||
| 'SUBMITTING'
|
||||
| 'SUBMITTED'
|
||||
| 'SUCCESS'
|
||||
| 'FAILED'
|
||||
| 'UNKNOWN'
|
||||
| 'CANCELED';
|
||||
|
||||
export interface OrderPrice {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface AvailabilityResult {
|
||||
environment: DomainsEnvironment;
|
||||
/** ASCII-Schreibweise (Punycode) — so geht sie an AutoDNS. */
|
||||
domain: string;
|
||||
domainUnicode: string;
|
||||
/** Nur `FREE` ist bestellbar. */
|
||||
available: boolean;
|
||||
whoisStatus: string;
|
||||
/** Preis fuer ein Jahr; `null`, wenn AutoDNS keinen nennt. */
|
||||
price: OrderPrice | null;
|
||||
}
|
||||
|
||||
export interface DomainsOrder {
|
||||
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: OrderPrice | null;
|
||||
}
|
||||
|
||||
export interface OrderContact {
|
||||
id: number;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/** Zusammenfassung eines Entwurfs — das, was der Benutzer verbindlich bestaetigt. */
|
||||
export interface OrderSummary {
|
||||
order: DomainsOrder;
|
||||
contacts: {
|
||||
owner: OrderContact;
|
||||
admin: OrderContact;
|
||||
tech: OrderContact;
|
||||
zone: OrderContact;
|
||||
};
|
||||
nameServers: string[];
|
||||
periodYears: 1;
|
||||
}
|
||||
|
||||
export interface CreateOrderInput {
|
||||
domain: string;
|
||||
ownerContactId: number;
|
||||
adminContactId: number;
|
||||
techContactId: number;
|
||||
zoneContactId: number;
|
||||
nameServers: string[];
|
||||
}
|
||||
|
||||
export function checkAvailability(domain: string): Promise<AvailabilityResult> {
|
||||
return request<AvailabilityResult>('/availability', { method: 'POST', json: { domain } });
|
||||
}
|
||||
|
||||
export function createOrder(input: CreateOrderInput): Promise<OrderSummary> {
|
||||
return request<OrderSummary>('/orders', { method: 'POST', json: input });
|
||||
}
|
||||
|
||||
/** Verbindlich: schickt die Registrierung an AutoDNS (kostet im Live-System Geld). */
|
||||
export function submitOrder(id: string): Promise<DomainsOrder> {
|
||||
return request<DomainsOrder>(`/orders/${encodeURIComponent(id)}/submit`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function cancelOrder(id: string): Promise<DomainsOrder> {
|
||||
return request<DomainsOrder>(`/orders/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function listOrders(): Promise<DomainsOrder[]> {
|
||||
return request<DomainsOrder[]>('/orders');
|
||||
}
|
||||
|
||||
/** Gleicht offene Auftraege mit AutoDNS ab und liefert die aktuelle Liste. */
|
||||
export function refreshOrders(): Promise<DomainsOrder[]> {
|
||||
return request<DomainsOrder[]>('/orders/refresh', { method: 'POST' });
|
||||
}
|
||||
|
||||
@@ -2061,6 +2061,8 @@
|
||||
"domains": "Domains",
|
||||
"contacts": "Kontakte",
|
||||
"customers": "Kunden",
|
||||
"register": "Registrieren",
|
||||
"orders": "Aufträge",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"notConfigured": {
|
||||
@@ -2206,6 +2208,101 @@
|
||||
"confirmDelete": "Kunde „{name}“ wirklich löschen?",
|
||||
"confirmDeleteYes": "Endgültig löschen"
|
||||
},
|
||||
"register": {
|
||||
"intro": "Prüfen Sie, ob eine Domain frei ist, wählen Sie die Kontakte und Nameserver und registrieren Sie die Domain verbindlich bei AutoDNS.",
|
||||
"domainLabel": "Domain",
|
||||
"domainPlaceholder": "beispiel.de",
|
||||
"check": "Verfügbarkeit prüfen",
|
||||
"checking": "Wird geprüft …",
|
||||
"free": "{domain} ist frei.",
|
||||
"price": "Preis für ein Jahr: {price}",
|
||||
"priceUnknown": "Preis nicht ermittelbar",
|
||||
"notFree": "{domain} ist nicht frei und kann nicht registriert werden.",
|
||||
"notFreeStatus": "Meldung der Registry: {status}",
|
||||
"contactsTitle": "Kontakte",
|
||||
"contactsDescription": "Wählen Sie für jede Rolle einen Kontakt aus AutoDNS. Der Inhaber ist Pflicht; Admin-, Technik- und Zonenkontakt sind vorausgewählt und änderbar.",
|
||||
"contactsLoading": "Kontakte werden geladen …",
|
||||
"contactsEmpty": "Bei AutoDNS gibt es noch keine Kontakte. Legen Sie zuerst unter „Kontakte“ einen Kontakt an.",
|
||||
"choose": "Bitte wählen …",
|
||||
"roles": {
|
||||
"owner": "Inhaber",
|
||||
"admin": "Admin-Kontakt",
|
||||
"tech": "Technischer Kontakt",
|
||||
"zone": "Zonenkontakt"
|
||||
},
|
||||
"nameServersTitle": "Nameserver",
|
||||
"nameServersDescription": "Mindestens zwei, höchstens sechs. Die Nameserver müssen bei Ihrem Anbieter bereits eingerichtet sein, sonst scheitert die Registrierung.",
|
||||
"nameServer": "Nameserver {number}",
|
||||
"addNameServer": "Nameserver hinzufügen",
|
||||
"removeNameServer": "Entfernen",
|
||||
"showSummary": "Zusammenfassung anzeigen",
|
||||
"creating": "Wird vorbereitet …",
|
||||
"summary": {
|
||||
"title": "Zusammenfassung",
|
||||
"description": "Bitte prüfen Sie alle Angaben. Mit dem Knopf unten wird die Registrierung verbindlich.",
|
||||
"system": "System",
|
||||
"domain": "Domain",
|
||||
"period": "Laufzeit",
|
||||
"periodValue": "1 Jahr",
|
||||
"price": "Preis",
|
||||
"nameServers": "Nameserver"
|
||||
},
|
||||
"confirmLive": "Ich bestätige die verbindliche und kostenpflichtige Registrierung bei AutoDNS.",
|
||||
"confirmDemo": "Ich bestätige die Registrierung im Demo-System von AutoDNS (Testbetrieb).",
|
||||
"submit": "Jetzt verbindlich registrieren",
|
||||
"submitting": "Wird übermittelt …",
|
||||
"cancel": "Abbrechen",
|
||||
"canceling": "Wird verworfen …",
|
||||
"back": "Zurück",
|
||||
"submitFailed": "Die Registrierung konnte nicht abgeschickt werden. Bitte sehen Sie unter „Aufträge“ nach, bevor Sie es erneut versuchen.",
|
||||
"result": {
|
||||
"title": "Ergebnis",
|
||||
"SUBMITTED": "Die Registrierung wurde an AutoDNS übergeben und wird bearbeitet. Den Fortschritt sehen Sie unter „Aufträge“.",
|
||||
"SUCCESS": "Die Domain wurde registriert.",
|
||||
"FAILED": "AutoDNS hat die Registrierung nicht ausgeführt.",
|
||||
"UNKNOWN": "Ergebnis ungeklärt",
|
||||
"unknownAdvice": "Tessera konnte nicht feststellen, ob AutoDNS die Registrierung angenommen hat. Bestellen Sie die Domain bitte nicht erneut. Sehen Sie unter „Aufträge“ nach: Dort gleicht Tessera den Auftrag mit AutoDNS ab.",
|
||||
"toOrders": "Zu den Aufträgen",
|
||||
"another": "Weitere Domain registrieren"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"intro": "Hier sehen Sie jede Registrierung und ihren Stand. Tessera gleicht offene Aufträge beim Öffnen und danach alle 30 Sekunden mit AutoDNS ab.",
|
||||
"refresh": "Aktualisieren",
|
||||
"refreshing": "Wird abgeglichen …",
|
||||
"loading": "Aufträge werden geladen …",
|
||||
"empty": "Es gibt noch keine Aufträge.",
|
||||
"columns": {
|
||||
"domain": "Domain",
|
||||
"system": "System",
|
||||
"status": "Status",
|
||||
"confirmed": "Bestätigt"
|
||||
},
|
||||
"environment": {
|
||||
"DEMO": "Demo",
|
||||
"LIVE": "Live"
|
||||
},
|
||||
"status": {
|
||||
"DRAFT": "Entwurf",
|
||||
"SUBMITTING": "Wird übermittelt",
|
||||
"SUBMITTED": "In Bearbeitung",
|
||||
"SUPPORT": "Rückfrage nötig",
|
||||
"SUCCESS": "Registriert",
|
||||
"FAILED": "Fehlgeschlagen",
|
||||
"UNKNOWN": "Ergebnis ungeklärt",
|
||||
"CANCELED": "Verworfen"
|
||||
},
|
||||
"confirmedBy": "{user}, {date}",
|
||||
"notConfirmed": "–",
|
||||
"supportHint": "AutoDNS hat zu diesem Auftrag eine Rückfrage. Bitte wenden Sie sich an AutoDNS beziehungsweise InterNetX.",
|
||||
"unknownUnchecked": "Tessera hat noch nicht bei AutoDNS nachgesehen. Klicken Sie auf „Aktualisieren“. Bestellen Sie die Domain bitte nicht erneut.",
|
||||
"unknownChecked": "Bei AutoDNS wurden weder die Domain noch ein passender Auftrag gefunden. Prüfen Sie zur Sicherheit in AutoDNS selbst, bevor Sie den Auftrag verwerfen.",
|
||||
"discard": "Verwerfen",
|
||||
"confirmDiscard": "Diesen Entwurf wirklich verwerfen?",
|
||||
"confirmDiscardUnknown": "Verwerfen Sie diesen Auftrag nur, nachdem Sie in AutoDNS geprüft haben, dass die Domain nicht bestellt wurde. Sonst könnte sie doppelt bestellt werden.",
|
||||
"discardYes": "Ja, verwerfen",
|
||||
"discardNo": "Abbrechen"
|
||||
},
|
||||
"errors": {
|
||||
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"loadStatus": "Der Zustand der AutoDNS-Anbindung konnte nicht geladen werden."
|
||||
|
||||
@@ -2061,6 +2061,8 @@
|
||||
"domains": "Domains",
|
||||
"contacts": "Contacts",
|
||||
"customers": "Customers",
|
||||
"register": "Register",
|
||||
"orders": "Orders",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"notConfigured": {
|
||||
@@ -2206,6 +2208,101 @@
|
||||
"confirmDelete": "Really delete customer “{name}”?",
|
||||
"confirmDeleteYes": "Delete permanently"
|
||||
},
|
||||
"register": {
|
||||
"intro": "Check whether a domain is available, choose the contacts and name servers, and register the domain with AutoDNS as a binding order.",
|
||||
"domainLabel": "Domain",
|
||||
"domainPlaceholder": "example.com",
|
||||
"check": "Check availability",
|
||||
"checking": "Checking …",
|
||||
"free": "{domain} is available.",
|
||||
"price": "Price for one year: {price}",
|
||||
"priceUnknown": "Price could not be determined",
|
||||
"notFree": "{domain} is not available and cannot be registered.",
|
||||
"notFreeStatus": "Registry response: {status}",
|
||||
"contactsTitle": "Contacts",
|
||||
"contactsDescription": "Choose a contact from AutoDNS for each role. The owner is required; the admin, technical and zone contacts are preselected and can be changed.",
|
||||
"contactsLoading": "Loading contacts …",
|
||||
"contactsEmpty": "There are no contacts at AutoDNS yet. Create a contact under “Contacts” first.",
|
||||
"choose": "Please choose …",
|
||||
"roles": {
|
||||
"owner": "Owner",
|
||||
"admin": "Admin contact",
|
||||
"tech": "Technical contact",
|
||||
"zone": "Zone contact"
|
||||
},
|
||||
"nameServersTitle": "Name servers",
|
||||
"nameServersDescription": "At least two, at most six. The name servers must already be set up with your provider, otherwise the registration fails.",
|
||||
"nameServer": "Name server {number}",
|
||||
"addNameServer": "Add name server",
|
||||
"removeNameServer": "Remove",
|
||||
"showSummary": "Show summary",
|
||||
"creating": "Preparing …",
|
||||
"summary": {
|
||||
"title": "Summary",
|
||||
"description": "Please check all details. The button below makes the registration binding.",
|
||||
"system": "System",
|
||||
"domain": "Domain",
|
||||
"period": "Term",
|
||||
"periodValue": "1 year",
|
||||
"price": "Price",
|
||||
"nameServers": "Name servers"
|
||||
},
|
||||
"confirmLive": "I confirm the binding registration with AutoDNS, which costs money.",
|
||||
"confirmDemo": "I confirm the registration in the AutoDNS demo system (test mode).",
|
||||
"submit": "Register now (binding)",
|
||||
"submitting": "Submitting …",
|
||||
"cancel": "Cancel",
|
||||
"canceling": "Discarding …",
|
||||
"back": "Back",
|
||||
"submitFailed": "The registration could not be submitted. Please check under “Orders” before you try again.",
|
||||
"result": {
|
||||
"title": "Result",
|
||||
"SUBMITTED": "The registration was handed to AutoDNS and is being processed. You can follow the progress under “Orders”.",
|
||||
"SUCCESS": "The domain was registered.",
|
||||
"FAILED": "AutoDNS did not carry out the registration.",
|
||||
"UNKNOWN": "Outcome unclear",
|
||||
"unknownAdvice": "Tessera could not determine whether AutoDNS accepted the registration. Please do not order the domain again. Check under “Orders”: Tessera compares the order with AutoDNS there.",
|
||||
"toOrders": "Go to orders",
|
||||
"another": "Register another domain"
|
||||
}
|
||||
},
|
||||
"orders": {
|
||||
"intro": "Every registration and its state. Tessera compares open orders with AutoDNS when you open this page and every 30 seconds afterwards.",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Comparing …",
|
||||
"loading": "Loading orders …",
|
||||
"empty": "There are no orders yet.",
|
||||
"columns": {
|
||||
"domain": "Domain",
|
||||
"system": "System",
|
||||
"status": "Status",
|
||||
"confirmed": "Confirmed"
|
||||
},
|
||||
"environment": {
|
||||
"DEMO": "Demo",
|
||||
"LIVE": "Live"
|
||||
},
|
||||
"status": {
|
||||
"DRAFT": "Draft",
|
||||
"SUBMITTING": "Submitting",
|
||||
"SUBMITTED": "In progress",
|
||||
"SUPPORT": "Query pending",
|
||||
"SUCCESS": "Registered",
|
||||
"FAILED": "Failed",
|
||||
"UNKNOWN": "Outcome unclear",
|
||||
"CANCELED": "Discarded"
|
||||
},
|
||||
"confirmedBy": "{user}, {date}",
|
||||
"notConfirmed": "–",
|
||||
"supportHint": "AutoDNS has a query about this order. Please contact AutoDNS or InterNetX.",
|
||||
"unknownUnchecked": "Tessera has not checked with AutoDNS yet. Click “Refresh”. Please do not order the domain again.",
|
||||
"unknownChecked": "AutoDNS found neither the domain nor a matching order. For safety, check in AutoDNS itself before you discard the order.",
|
||||
"discard": "Discard",
|
||||
"confirmDiscard": "Really discard this draft?",
|
||||
"confirmDiscardUnknown": "Only discard this order after you have checked in AutoDNS that the domain was not ordered. Otherwise it could be ordered twice.",
|
||||
"discardYes": "Yes, discard",
|
||||
"discardNo": "Cancel"
|
||||
},
|
||||
"errors": {
|
||||
"request": "The request failed. Please try again.",
|
||||
"loadStatus": "The state of the AutoDNS connection could not be loaded."
|
||||
|
||||
@@ -228,4 +228,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-261008-dts: Modul Domains, Kunden und Kontakte — korrektes Deutsch mit „ss“
|
||||
'Adresszeile',
|
||||
'fassen',
|
||||
// quick-261008-dts (Aufgabe 3): Registrieren und Aufträge — korrektes Deutsch mit „ss“
|
||||
'Zusammenfassung',
|
||||
'passender',
|
||||
'dass',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user