diff --git a/CHANGELOG.md b/CHANGELOG.md index 8170861..01b5ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ## Unveröffentlicht +### Neu + +- Neues Modul „Domains“ in der Gruppe „Domains“: Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen. Ein Administrator aktiviert das Modul im Marktplatz; wer es nutzen soll, bekommt zusätzlich die Freigabe. Mit „Benutzen“ sehen Sie die Domainliste, die Kontakte, die Kunden und die Aufträge; mit „Verwalten“ (und als Administrator) registrieren Sie Domains, legen Kontakte und Kunden an und richten die Anbindung ein. +- Domains, Anbindung an AutoDNS: Unter „Einstellungen“ tragen Sie Benutzername, Passwort und Kontext getrennt für das Demo-System (zum Ausprobieren) und das Live-System (echte, kostenpflichtige Registrierungen) ein. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt. Neue Installationen starten im Demo-System; der Wechsel auf das Live-System verlangt eine ausdrückliche Bestätigung, und eine Kennzeichnung oben auf der Seite zeigt jederzeit, welches System gerade gilt. „Verbindung testen“ prüft den Zugang mit einem einzigen Aufruf. Dazu kommen die Standard-Nameserver, die bei jeder Registrierung vorausgefüllt werden. +- Domains, Kontakte und Kunden: Alle Kontakte, die bei AutoDNS vorhanden sind, erscheinen automatisch in Tessera („Aus AutoDNS neu einlesen“ holt sie auf Wunsch sofort neu). Neue Kontakte (Person oder Organisation) legen Sie über ein Formular an. Jeden Kontakt – einzeln oder viele auf einmal – ordnen Sie einem Kunden zu; Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie. Die Listen lassen sich nach Kunde filtern und gruppieren. +- Domains, Domainliste: Alle Domains aus AutoDNS mit Kunde (über den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status, ebenfalls filterbar und nach Kunde gruppierbar. +- Domains, Registrieren: Verfügbarkeit prüfen, Kontakte und Nameserver wählen, Zusammenfassung mit System und Preis ansehen und mit „Jetzt verbindlich registrieren“ bestätigen. Tessera schützt vor Doppelbestellungen: Jede Registrierung wird höchstens ein einziges Mal an AutoDNS geschickt, auch bei Doppelklick oder zwei geöffneten Fenstern, und nie automatisch wiederholt. Ist unklar, ob AutoDNS die Bestellung angenommen hat, steht der Auftrag auf „Ergebnis ungeklärt“, bis Tessera ihn mit AutoDNS abgeglichen hat. Den Stand jeder Registrierung sehen Sie im Reiter „Aufträge“; er wird beim Öffnen und danach alle 30 Sekunden mit AutoDNS abgeglichen. + ### Geändert - DKV-Rechnung: Die Modulbeschreibung im Marktplatz weist jetzt darauf hin, dass das Modul die Freigabestufe „Verwalten“ benötigt. Mit „Benutzen“ erscheint es zwar in der Seitenleiste, öffnet aber nur die Seite „Kein Zugriff“. Modulversion 1.1.0. diff --git a/apps/api/src/domains/autodns-parse.spec.ts b/apps/api/src/domains/autodns-parse.spec.ts index a33cf6a..f3ad7f4 100644 --- a/apps/api/src/domains/autodns-parse.spec.ts +++ b/apps/api/src/domains/autodns-parse.spec.ts @@ -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 }); + }); +}); diff --git a/apps/api/src/domains/autodns-parse.ts b/apps/api/src/domains/autodns-parse.ts index 8520ca2..bfa0c30 100644 --- a/apps/api/src/domains/autodns-parse.ts +++ b/apps/api/src/domains/autodns-parse.ts @@ -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 | 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; +} diff --git a/apps/api/src/domains/domain-name.spec.ts b/apps/api/src/domains/domain-name.spec.ts new file mode 100644 index 0000000..459d23f --- /dev/null +++ b/apps/api/src/domains/domain-name.spec.ts @@ -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' }); + }); +}); diff --git a/apps/api/src/domains/domain-name.ts b/apps/api/src/domains/domain-name.ts new file mode 100644 index 0000000..9eb0183 --- /dev/null +++ b/apps/api/src/domains/domain-name.ts @@ -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) }; +} diff --git a/apps/api/src/domains/domains-orders.service.spec.ts b/apps/api/src/domains/domains-orders.service.spec.ts new file mode 100644 index 0000000..60f5b4b --- /dev/null +++ b/apps/api/src/domains/domains-orders.service.spec.ts @@ -0,0 +1,1007 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)), +})); + +import { AutodnsRateLimiter } from './autodns-client'; +import { DomainsOrdersService } from './domains-orders.service'; + +/** + * Das Netz ist hier NUR `fetchImpl`: die Tests pruefen die tatsaechlich + * gesendete Anfrage (Methode, genaue Adresse, Kopfzeilen, Rumpf) mit Literalen + * — nie mit Werten, die der Produktivcode selbst baut. + */ + +interface Call { + url: string; + init: { method: string; headers: Record; body?: string; redirect: string }; +} + +type Responder = (call: Call) => Promise | unknown; + +function reply(status: number, body: unknown) { + return { + status, + headers: { get: () => null }, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + }; +} + +function envelope( + data: unknown[], + extra: { type?: string; object?: unknown; messages?: unknown[] } = {}, +) { + return { + status: { code: 'S0000', text: 'ok', type: extra.type ?? 'SUCCESS' }, + stid: 'stid-1', + object: extra.object ?? { type: 'x', value: null, summary: data.length }, + messages: extra.messages ?? [], + data, + }; +} + +const CREDENTIALS_DEMO = { environment: 'DEMO', user: 'api-user', password: 'geheim', context: 4 }; +const CREDENTIALS_LIVE = { environment: 'LIVE', user: 'api-user', password: 'geheim', context: 4 }; + +interface OrderRow { + id: string; + tenantId: string; + environment: 'DEMO' | 'LIVE'; + domainName: string; + openKey: string | null; + status: string; + payload: any; + 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; +} + +function matches(row: Record, where: Record = {}): boolean { + return Object.entries(where).every(([key, expected]) => { + if (expected && typeof expected === 'object' && !(expected instanceof Date)) { + if ('in' in expected) return (expected.in as unknown[]).includes(row[key]); + if ('not' in expected) return row[key] !== expected.not; + } + return row[key] === expected; + }); +} + +const PAYLOAD = { + ownerContactId: 11, + adminContactId: 11, + techContactId: 22, + zoneContactId: 22, + nameServers: ['ns1.example.com', 'ns2.example.com'], + periodYears: 1, + price: { amount: 4.9, currency: 'EUR' }, + availabilityCheckedAt: '2026-10-08T10:00:00.000Z', +}; + +function makeHarness(opts: { environment?: 'DEMO' | 'LIVE'; timeoutMs?: number } = {}) { + const state = { + orders: [] as OrderRow[], + environment: opts.environment ?? ('DEMO' as 'DEMO' | 'LIVE'), + nextId: 1, + createConflict: false, + readBarrier: null as null | { remaining: number; release: () => void; gate: Promise }, + }; + const calls: Call[] = []; + let responder: Responder = () => { + throw new Error('kein Netz erwartet'); + }; + + const fetchImpl = vi.fn(async (url: string, init: any) => { + const call: Call = { url, init }; + calls.push(call); + return (await responder(call)) as never; + }); + + const orderApi = { + findFirst: vi.fn(async ({ where }: any) => { + const found = state.orders.find((o) => matches(o, where)) ?? null; + const snapshot = found ? { ...found } : null; + if (state.readBarrier) { + const barrier = state.readBarrier; + barrier.remaining -= 1; + if (barrier.remaining <= 0) barrier.release(); + await barrier.gate; + } + return snapshot; + }), + findMany: vi.fn(async ({ where, orderBy, take }: any) => { + let rows = state.orders.filter((o) => matches(o, where)).map((o) => ({ ...o })); + if (orderBy?.createdAt === 'desc') { + rows = rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + } + if (orderBy?.lastCheckedAt) { + rows = rows.sort( + (a, b) => (a.lastCheckedAt?.getTime() ?? -1) - (b.lastCheckedAt?.getTime() ?? -1), + ); + } + return typeof take === 'number' ? rows.slice(0, take) : rows; + }), + create: vi.fn(async ({ data }: any) => { + if (state.createConflict) throw Object.assign(new Error('unique'), { code: 'P2002' }); + const row: OrderRow = { + id: `o${state.nextId++}`, + jobId: null, + jobStatus: null, + errorText: null, + confirmedByUserId: null, + confirmedByUsername: null, + confirmedAt: null, + lastCheckedAt: null, + createdAt: new Date(Date.now() + state.nextId), + updatedAt: new Date(), + ...data, + }; + state.orders.push(row); + return { ...row }; + }), + // Wie Postgres: prueft die Bedingung gegen den AKTUELLEN Zeilenstand, synchron. + updateMany: vi.fn(async ({ where, data }: any) => { + const rows = state.orders.filter((o) => matches(o, where)); + for (const r of rows) Object.assign(r, data); + return { count: rows.length }; + }), + }; + const db = { __bound: () => ({ domainsOrder: orderApi }) }; + + const limiter = new AutodnsRateLimiter(0); + const settings = { + transport: { fetchImpl, limiter, timeoutMs: opts.timeoutMs }, + getStatus: vi.fn(async () => ({ environment: state.environment })), + getActiveCredentials: vi.fn(async () => ({ + environment: state.environment, + credentials: state.environment === 'DEMO' ? CREDENTIALS_DEMO : CREDENTIALS_LIVE, + configVersion: 1, + })), + }; + const contacts = [ + { id: 11, displayName: 'Muster GmbH' }, + { id: 22, displayName: 'Erika Muster' }, + ]; + const directory = { + findContactsByIds: vi.fn(async (_t: string, ids: number[]) => + contacts.filter((c) => ids.includes(c.id)), + ), + invalidate: vi.fn(), + }; + const service = new DomainsOrdersService(db as any, settings as any, directory as any); + + const addOrder = (patch: Partial = {}): OrderRow => { + const row: OrderRow = { + id: `o${state.nextId++}`, + tenantId: 't1', + environment: 'DEMO', + domainName: 'beispiel.de', + openKey: 'beispiel.de', + status: 'DRAFT', + payload: { ...PAYLOAD }, + jobId: null, + jobStatus: null, + errorText: null, + createdByUserId: 'u1', + confirmedByUserId: null, + confirmedByUsername: null, + confirmedAt: null, + lastCheckedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...patch, + }; + state.orders.push(row); + return row; + }; + + return { + service, + state, + calls, + fetchImpl, + orderApi, + settings, + directory, + addOrder, + respond: (r: Responder) => { + responder = r; + }, + }; +} + +function studioReply(domain: string, status: string, prices?: unknown[]) { + return reply( + 200, + envelope([ + { + domain, + services: { + whois: { data: { status } }, + ...(prices ? { price: { data: { prices } } } : {}), + }, + }, + ]), + ); +} + +const USER = { id: 'u1', username: 'anna' }; + +const EXPECTED_SUBMIT_BODY = { + name: 'beispiel.de', + period: { unit: 'YEAR', period: 1 }, + ownerc: { id: 11 }, + adminc: { id: 11 }, + techc: { id: 22 }, + zonec: { id: 22 }, + nameServers: [{ name: 'ns1.example.com' }, { name: 'ns2.example.com' }], +}; + +beforeEach(() => { + vi.useRealTimers(); +}); + +describe('checkAvailability', () => { + it('lehnt einen ungueltigen Namen ab, ohne das Netz zu beruehren', async () => { + const h = makeHarness(); + await expect(h.service.checkAvailability('t1', 'bei spiel')).rejects.toMatchObject({ + response: { code: 'invalidDomain' }, + }); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + it('sendet genau einen POST /domainstudio mit dem erwarteten Rumpf', async () => { + const h = makeHarness(); + h.respond(() => + studioReply('beispiel.de', 'FREE', [ + { amount: 4.9, currency: 'EUR', period: { unit: 'YEAR', period: 1 } }, + ]), + ); + const result = await h.service.checkAvailability('t1', ' https://Beispiel.DE/x '); + expect(h.calls).toHaveLength(1); + expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/domainstudio'); + expect(h.calls[0].init.method).toBe('POST'); + expect(JSON.parse(h.calls[0].init.body as string)).toEqual({ + searchToken: 'beispiel', + currency: 'EUR', + sources: { initial: { tlds: ['de'], services: ['WHOIS', 'PRICE'] } }, + }); + expect(result).toMatchObject({ + domain: 'beispiel.de', + available: true, + whoisStatus: 'FREE', + price: { amount: 4.9, currency: 'EUR' }, + }); + }); + + it('ASSIGNED ist nicht bestellbar', async () => { + const h = makeHarness(); + h.respond(() => studioReply('beispiel.de', 'ASSIGNED')); + const result = await h.service.checkAvailability('t1', 'beispiel.de'); + expect(result).toMatchObject({ available: false, whoisStatus: 'ASSIGNED', price: null }); + }); + + it.each([ + 'ERROR', + 'TIMEOUT', + 'PREMIUM', + 'RESERVED', + ])('%s ist nicht bestellbar', async (status) => { + const h = makeHarness(); + h.respond(() => studioReply('beispiel.de', status)); + const result = await h.service.checkAvailability('t1', 'beispiel.de'); + expect(result).toMatchObject({ available: false, whoisStatus: status }); + }); + + it('der Umschlag einer anderen Domain zaehlt nicht', async () => { + const h = makeHarness(); + h.respond(() => studioReply('beispiel.com', 'FREE')); + const result = await h.service.checkAvailability('t1', 'beispiel.de'); + expect(result).toMatchObject({ available: false, whoisStatus: 'NO_RESULT' }); + }); + + it('ein AutoDNS-Fehler wird zu 502', async () => { + const h = makeHarness(); + h.respond(() => + reply(401, envelope([], { type: 'ERROR', messages: [{ text: 'x', status: 'ERROR' }] })), + ); + await expect(h.service.checkAvailability('t1', 'beispiel.de')).rejects.toMatchObject({ + status: 502, + }); + }); +}); + +describe('createOrder', () => { + const dto = { + domain: 'Beispiel.de', + ownerContactId: 11, + adminContactId: 11, + techContactId: 22, + zoneContactId: 22, + nameServers: ['NS1.example.com ', 'ns2.example.com'], + }; + + function free() { + return studioReply('beispiel.de', 'FREE', [ + { amount: 4.9, currency: 'EUR', period: { unit: 'YEAR', period: 1 } }, + ]); + } + + it('legt einen Entwurf im aktiven System an und liefert die Zusammenfassung', async () => { + const h = makeHarness(); + h.respond(free); + const result = await h.service.createOrder('t1', 'u1', dto as any); + expect(h.state.orders).toHaveLength(1); + expect(h.state.orders[0]).toMatchObject({ + tenantId: 't1', + environment: 'DEMO', + domainName: 'beispiel.de', + openKey: 'beispiel.de', + status: 'DRAFT', + createdByUserId: 'u1', + }); + expect(h.state.orders[0].payload).toMatchObject({ + ownerContactId: 11, + adminContactId: 11, + techContactId: 22, + zoneContactId: 22, + nameServers: ['ns1.example.com', 'ns2.example.com'], + periodYears: 1, + price: { amount: 4.9, currency: 'EUR' }, + }); + expect(typeof h.state.orders[0].payload.availabilityCheckedAt).toBe('string'); + expect(result.contacts.owner).toEqual({ id: 11, displayName: 'Muster GmbH' }); + expect(result.contacts.zone).toEqual({ id: 22, displayName: 'Erika Muster' }); + expect(result.nameServers).toEqual(['ns1.example.com', 'ns2.example.com']); + expect(result.order).toMatchObject({ domainName: 'beispiel.de', status: 'DRAFT' }); + // Der Entwurf selbst bestellt nichts. + expect(h.calls.every((c) => !c.url.endsWith('/domain'))).toBe(true); + }); + + it('nicht freie Domain -> 409 notAvailable, keine Zeile', async () => { + const h = makeHarness(); + h.respond(() => studioReply('beispiel.de', 'ASSIGNED')); + await expect(h.service.createOrder('t1', 'u1', dto as any)).rejects.toMatchObject({ + status: 409, + response: { code: 'notAvailable' }, + }); + expect(h.state.orders).toHaveLength(0); + }); + + it.each([ + [['ns1.example.com'], 'tooFewNameServers'], + [ + [ + 'a.example.com', + 'b.example.com', + 'c.example.com', + 'd.example.com', + 'e.example.com', + 'f.example.com', + 'g.example.com', + ], + 'tooManyNameServers', + ], + [['ns1.example.com', 'kein rechner'], 'invalidNameServer'], + ])('Nameserver %j -> BadRequest %s', async (nameServers, code) => { + const h = makeHarness(); + h.respond(free); + await expect( + h.service.createOrder('t1', 'u1', { ...dto, nameServers } as any), + ).rejects.toMatchObject({ response: { code } }); + expect(h.state.orders).toHaveLength(0); + }); + + it('fehlender Kontakt -> BadRequest', async () => { + const h = makeHarness(); + h.respond(free); + await expect( + h.service.createOrder('t1', 'u1', { ...dto, techContactId: undefined } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(h.state.orders).toHaveLength(0); + }); + + it('ein Kontakt, den AutoDNS nicht kennt -> BadRequest contactNotFound', async () => { + const h = makeHarness(); + h.respond(free); + await expect( + h.service.createOrder('t1', 'u1', { ...dto, zoneContactId: 999 } as any), + ).rejects.toMatchObject({ response: { code: 'contactNotFound' } }); + }); + + it('ein vorhandener Entwurf derselben Domain wird aktualisiert (gleiche Nummer)', async () => { + const h = makeHarness(); + const existing = h.addOrder({ payload: { ...PAYLOAD, ownerContactId: 22 } }); + h.respond(free); + const result = await h.service.createOrder('t1', 'u1', dto as any); + expect(result.order.id).toBe(existing.id); + expect(h.state.orders).toHaveLength(1); + expect(h.state.orders[0].payload.ownerContactId).toBe(11); + expect(h.orderApi.create).not.toHaveBeenCalled(); + }); + + it.each([ + 'SUBMITTING', + 'SUBMITTED', + 'UNKNOWN', + 'SUCCESS', + ])('bestehender Auftrag im Zustand %s -> 409 orderOpen, ohne Netz', async (status) => { + const h = makeHarness(); + h.addOrder({ status }); + await expect(h.service.createOrder('t1', 'u1', dto as any)).rejects.toMatchObject({ + status: 409, + response: { code: 'orderOpen' }, + }); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + it('Wettlauf (P2002) -> 409 orderOpen', async () => { + const h = makeHarness(); + h.respond(free); + h.state.createConflict = true; + await expect(h.service.createOrder('t1', 'u1', dto as any)).rejects.toMatchObject({ + response: { code: 'orderOpen' }, + }); + }); + + it('ein FAILED/CANCELED-Auftrag (openKey null) blockiert nicht', async () => { + const h = makeHarness(); + h.addOrder({ status: 'FAILED', openKey: null }); + h.respond(free); + const result = await h.service.createOrder('t1', 'u1', dto as any); + expect(result.order.status).toBe('DRAFT'); + expect(h.state.orders).toHaveLength(2); + }); +}); + +describe('submitOrder — der Anspruch', () => { + it('sendet nach dem Anspruch genau EINEN POST /domain ohne Query und mit dem erwarteten Rumpf', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => + reply( + 200, + envelope([{ id: 987, status: 'RUNNING' }], { + object: { type: 'job', value: '987', summary: 1 }, + }), + ), + ); + + const view = await h.service.submitOrder('t1', USER, order.id); + + expect(h.orderApi.updateMany).toHaveBeenCalledTimes(2); + const claim = h.orderApi.updateMany.mock.calls[0][0]; + expect(claim.where).toMatchObject({ + id: order.id, + tenantId: 't1', + status: 'DRAFT', + environment: 'DEMO', + }); + expect(claim.data).toMatchObject({ + status: 'SUBMITTING', + confirmedByUserId: 'u1', + confirmedByUsername: 'anna', + }); + expect(claim.data.confirmedAt).toBeInstanceOf(Date); + + expect(h.calls).toHaveLength(1); + expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/domain'); + expect(h.calls[0].init.method).toBe('POST'); + expect(h.calls[0].init.redirect).toBe('error'); + expect(h.calls[0].init.headers).toEqual({ + Authorization: 'Basic YXBpLXVzZXI6Z2VoZWlt', + 'X-Domainrobot-Context': '4', + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': expect.stringMatching(/^Tessera\//), + }); + expect(JSON.parse(h.calls[0].init.body as string)).toEqual(EXPECTED_SUBMIT_BODY); + + expect(view).toMatchObject({ status: 'SUBMITTED', jobStatus: 'RUNNING' }); + expect(h.state.orders[0]).toMatchObject({ + status: 'SUBMITTED', + jobId: '987', + jobStatus: 'RUNNING', + confirmedByUsername: 'anna', + openKey: 'beispiel.de', + }); + expect(h.directory.invalidate).toHaveBeenCalledWith('t1'); + }); + + it('zwei gleichzeitige Bestaetigungen: genau ein POST, eine 409 alreadySubmitted', async () => { + const h = makeHarness(); + const order = h.addOrder(); + // Beide Aufrufe lesen noch den Entwurf, bevor einer den Anspruch stellt: + // so beweist der Test den Zaehler (count === 1), nicht die Vorabpruefung. + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + h.state.readBarrier = { remaining: 2, release, gate }; + h.respond(async () => { + await new Promise((r) => setTimeout(r, 5)); + return reply(200, envelope([{ id: 987, status: 'RUNNING' }])); + }); + + const results = await Promise.allSettled([ + h.service.submitOrder('t1', USER, order.id), + h.service.submitOrder('t1', USER, order.id), + ]); + + const ok = results.filter((r) => r.status === 'fulfilled'); + const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; + expect(ok).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0].reason).toBeInstanceOf(ConflictException); + expect(rejected[0].reason.getResponse()).toMatchObject({ code: 'alreadySubmitted' }); + expect(h.calls.filter((c) => c.url.endsWith('/domain'))).toHaveLength(1); + }); + + it('Systemwechsel nach dem Entwurf -> 409 environmentChanged, kein Netz, Entwurf bleibt', async () => { + const h = makeHarness({ environment: 'DEMO' }); + const order = h.addOrder({ environment: 'DEMO' }); + h.state.environment = 'LIVE'; + await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ + status: 409, + response: { code: 'environmentChanged' }, + }); + expect(h.fetchImpl).not.toHaveBeenCalled(); + expect(h.state.orders[0].status).toBe('DRAFT'); + }); + + it('Systemwechsel zwischen Lesen und Anspruch -> der Anspruch greift nicht, kein Netz', async () => { + const h = makeHarness({ environment: 'DEMO' }); + const order = h.addOrder({ environment: 'LIVE' }); // gehoert zu LIVE, aktiv ist DEMO + // Vorabpruefung umgehen: die Zeile gibt sich beim Lesen als DEMO aus. + const original = h.orderApi.findFirst.getMockImplementation() as (a: any) => Promise; + let reads = 0; + h.orderApi.findFirst.mockImplementation(async (a: any) => { + const row = await original(a); + reads += 1; + return reads === 1 && row ? { ...row, environment: 'DEMO' } : row; + }); + await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ + response: { code: 'environmentChanged' }, + }); + expect(h.fetchImpl).not.toHaveBeenCalled(); + expect(h.state.orders[0].status).toBe('DRAFT'); + }); + + it.each([ + 'SUBMITTING', + 'SUBMITTED', + 'UNKNOWN', + 'SUCCESS', + 'FAILED', + 'CANCELED', + ])('Auftrag im Zustand %s -> 409 alreadySubmitted, kein Netz', async (status) => { + const h = makeHarness(); + const order = h.addOrder({ status }); + await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ + status: 409, + response: { code: 'alreadySubmitted' }, + }); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); + + it('fremde Nummer -> 404, kein Netz', async () => { + const h = makeHarness(); + h.addOrder({ tenantId: 'andere' }); + await expect(h.service.submitOrder('t1', USER, 'o1')).rejects.toBeInstanceOf(NotFoundException); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe('submitOrder — Ausgang', () => { + it('Zeitablauf -> UNKNOWN, genau ein Aufruf, lastCheckedAt bleibt leer', async () => { + const h = makeHarness({ timeoutMs: 20 }); + const order = h.addOrder(); + h.respond(() => new Promise(() => undefined)); + const view = await h.service.submitOrder('t1', USER, order.id); + expect(view.status).toBe('UNKNOWN'); + expect(h.calls).toHaveLength(1); + expect(h.state.orders[0].status).toBe('UNKNOWN'); + expect(h.state.orders[0].lastCheckedAt).toBeNull(); + expect(h.state.orders[0].openKey).toBe('beispiel.de'); + }); + + it('Verbindungsabbruch (ECONNRESET) -> UNKNOWN', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => { + throw Object.assign(new Error('reset'), { code: 'ECONNRESET' }); + }); + const view = await h.service.submitOrder('t1', USER, order.id); + expect(view.status).toBe('UNKNOWN'); + expect(h.calls).toHaveLength(1); + }); + + it('HTTP 200 mit HTML -> UNKNOWN', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => reply(200, 'Wartung')); + expect((await h.service.submitOrder('t1', USER, order.id)).status).toBe('UNKNOWN'); + }); + + it('HTTP 502 ohne Umschlag (Gateway) -> UNKNOWN, nicht FAILED', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => reply(502, 'Bad Gateway')); + expect((await h.service.submitOrder('t1', USER, order.id)).status).toBe('UNKNOWN'); + }); + + it('HTTP 200 mit status.type ERROR -> FAILED mit Text, openKey leer', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => + reply( + 200, + envelope([], { + type: 'ERROR', + messages: [{ code: 'E1', text: 'Domain not available', status: 'ERROR' }], + }), + ), + ); + const view = await h.service.submitOrder('t1', USER, order.id); + expect(view.status).toBe('FAILED'); + expect(view.errorText).toContain('Domain not available'); + expect(h.state.orders[0].openKey).toBeNull(); + expect(h.directory.invalidate).not.toHaveBeenCalled(); + }); + + it('HTTP 401 -> FAILED mit dem Anmeldetext, openKey leer', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => + reply( + 401, + envelope([], { + type: 'ERROR', + messages: [{ code: 'EF00202', text: 'bad login', status: 'ERROR' }], + }), + ), + ); + const view = await h.service.submitOrder('t1', USER, order.id); + expect(view.status).toBe('FAILED'); + expect(view.errorText).toContain('Benutzername, Passwort und Kontext'); + expect(h.state.orders[0].openKey).toBeNull(); + }); + + it('Erfolg ohne Auftragsnummer -> SUBMITTED mit jobId null', async () => { + const h = makeHarness(); + const order = h.addOrder(); + h.respond(() => + reply(200, envelope([{}], { object: { type: 'domain', value: 'beispiel.de', summary: 1 } })), + ); + const view = await h.service.submitOrder('t1', USER, order.id); + expect(view.status).toBe('SUBMITTED'); + expect(h.state.orders[0].jobId).toBeNull(); + }); + + it('nach UNKNOWN kann derselbe Auftrag nie erneut abgeschickt werden', async () => { + const h = makeHarness({ timeoutMs: 20 }); + const order = h.addOrder(); + h.respond(() => new Promise(() => undefined)); + await h.service.submitOrder('t1', USER, order.id); + await expect(h.service.submitOrder('t1', USER, order.id)).rejects.toMatchObject({ + response: { code: 'alreadySubmitted' }, + }); + expect(h.calls).toHaveLength(1); + }); +}); + +describe('refreshOpenOrders', () => { + it('SUBMITTED mit Auftrag: SUCCESS -> SUCCESS, openKey bleibt, Zwischenspeicher geleert', async () => { + const h = makeHarness(); + h.addOrder({ status: 'SUBMITTED', jobId: '987', jobStatus: 'RUNNING' }); + h.respond(() => reply(200, envelope([{ job: { id: 987, status: 'SUCCESS' } }]))); + await h.service.refreshOpenOrders('t1'); + expect(h.calls).toHaveLength(1); + expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/job/987'); + expect(h.calls[0].init.method).toBe('GET'); + expect(h.state.orders[0]).toMatchObject({ status: 'SUCCESS', openKey: 'beispiel.de' }); + expect(h.directory.invalidate).toHaveBeenCalledWith('t1'); + }); + + it('FAILED -> FAILED mit Text, openKey leer', async () => { + const h = makeHarness(); + h.addOrder({ status: 'SUBMITTED', jobId: '987' }); + h.respond(() => + reply( + 200, + envelope([ + { + job: { + id: 987, + status: 'FAILED', + events: [{ status: 'ERROR', message: 'Nameserver nicht erreichbar' }], + }, + }, + ]), + ), + ); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0]).toMatchObject({ status: 'FAILED', openKey: null }); + expect(h.state.orders[0].errorText).toContain('Nameserver nicht erreichbar'); + }); + + it('CANCELED -> CANCELED, openKey leer', async () => { + const h = makeHarness(); + h.addOrder({ status: 'SUBMITTED', jobId: '987' }); + h.respond(() => reply(200, envelope([{ job: { id: 987, status: 'CANCELED' } }]))); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0]).toMatchObject({ status: 'CANCELED', openKey: null }); + }); + + it('RUNNING bleibt SUBMITTED, lastCheckedAt wird gesetzt', async () => { + const h = makeHarness(); + h.addOrder({ status: 'SUBMITTED', jobId: '987' }); + h.respond(() => reply(200, envelope([{ job: { id: 987, status: 'RUNNING' } }]))); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0]).toMatchObject({ status: 'SUBMITTED', jobStatus: 'RUNNING' }); + expect(h.state.orders[0].lastCheckedAt).toBeInstanceOf(Date); + }); + + it('SUPPORT bleibt SUBMITTED mit jobStatus SUPPORT', async () => { + const h = makeHarness(); + h.addOrder({ status: 'SUBMITTED', jobId: '987' }); + h.respond(() => reply(200, envelope([{ job: { id: 987, status: 'SUPPORT' } }]))); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0]).toMatchObject({ status: 'SUBMITTED', jobStatus: 'SUPPORT' }); + }); + + it('SUBMITTING seit 3 Minuten -> UNKNOWN, seit 30 s -> unveraendert', async () => { + const h = makeHarness(); + h.addOrder({ id: 'alt', status: 'SUBMITTING', confirmedAt: new Date(Date.now() - 3 * 60_000) }); + h.addOrder({ + id: 'neu', + domainName: 'neu.de', + openKey: 'neu.de', + status: 'SUBMITTING', + confirmedAt: new Date(Date.now() - 30_000), + }); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders.find((o) => o.id === 'alt')?.status).toBe('UNKNOWN'); + expect(h.state.orders.find((o) => o.id === 'neu')?.status).toBe('SUBMITTING'); + // Der gerade unklar gewordene Auftrag wird sofort abgeglichen; der junge nicht. + expect(h.calls.map((c) => c.url)).toEqual([ + 'https://api.demo.autodns.com/v1/domain/beispiel.de', + ]); + }); + + describe('UNKNOWN-Abgleich', () => { + const confirmedAt = new Date(Date.now() - 10 * 60_000); + + it('GET /domain/{name} gelingt -> SUCCESS', async () => { + const h = makeHarness(); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + h.respond(() => reply(200, envelope([{ name: 'beispiel.de' }]))); + await h.service.refreshOpenOrders('t1'); + expect(h.calls).toHaveLength(1); + expect(h.calls[0].url).toBe('https://api.demo.autodns.com/v1/domain/beispiel.de'); + expect(h.state.orders[0].status).toBe('SUCCESS'); + expect(h.directory.invalidate).toHaveBeenCalledWith('t1'); + }); + + it('nicht gefunden, aber Auftrag nach der Bestaetigung -> SUBMITTED mit dieser Nummer', async () => { + const h = makeHarness(); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + h.respond((call) => { + if (call.url.endsWith('/domain/beispiel.de')) { + return reply( + 404, + envelope([], { type: 'ERROR', messages: [{ text: 'not found', status: 'ERROR' }] }), + ); + } + return reply( + 200, + envelope([ + { + job: { + id: 555, + status: 'RUNNING', + created: new Date(Date.now() - 9 * 60_000).toISOString(), + }, + object: 'beispiel.de', + }, + { + job: { id: 1, status: 'FAILED', created: '2020-01-01T00:00:00Z' }, + object: 'beispiel.de', + }, + ]), + ); + }); + await h.service.refreshOpenOrders('t1'); + expect(h.calls.map((c) => c.url)).toEqual([ + 'https://api.demo.autodns.com/v1/domain/beispiel.de', + 'https://api.demo.autodns.com/v1/job/_search', + ]); + expect(JSON.parse(h.calls[1].init.body as string)).toEqual({ + filters: [{ key: 'object', operator: 'EQUAL', value: 'beispiel.de' }], + view: { limit: 10, offset: 0 }, + orders: [{ key: 'created', type: 'DESC' }], + }); + expect(h.state.orders[0]).toMatchObject({ + status: 'SUBMITTED', + jobId: '555', + jobStatus: 'RUNNING', + }); + }); + + it('nichts gefunden -> bleibt UNKNOWN, lastCheckedAt gesetzt', async () => { + const h = makeHarness(); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + h.respond((call) => + call.url.endsWith('/domain/beispiel.de') + ? reply( + 404, + envelope([], { type: 'ERROR', messages: [{ text: 'not found', status: 'ERROR' }] }), + ) + : reply(200, envelope([])), + ); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0].status).toBe('UNKNOWN'); + expect(h.state.orders[0].lastCheckedAt).toBeInstanceOf(Date); + }); + + it('ein Fehler beim Nachsehen (Zeitablauf) setzt lastCheckedAt NICHT', async () => { + const h = makeHarness({ timeoutMs: 20 }); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + h.respond(() => new Promise(() => undefined)); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0].status).toBe('UNKNOWN'); + expect(h.state.orders[0].lastCheckedAt).toBeNull(); + }); + + it('ein alter Auftrag ohne lesbare Anlagezeit zaehlt nicht', async () => { + const h = makeHarness(); + h.addOrder({ status: 'UNKNOWN', confirmedAt }); + h.respond((call) => + call.url.endsWith('/domain/beispiel.de') + ? reply(404, envelope([], { type: 'ERROR', messages: [{ text: 'nf', status: 'ERROR' }] })) + : reply(200, envelope([{ job: { id: 7, status: 'FAILED' }, object: 'beispiel.de' }])), + ); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders[0]).toMatchObject({ status: 'UNKNOWN', jobId: null }); + }); + }); + + it('prueft hoechstens 20 Auftraege, die am laengsten ungeprueften zuerst', async () => { + const h = makeHarness(); + for (let i = 0; i < 25; i += 1) { + h.addOrder({ + id: `x${i}`, + domainName: `d${i}.de`, + openKey: `d${i}.de`, + status: 'SUBMITTED', + jobId: String(100 + i), + lastCheckedAt: new Date(Date.now() - (25 - i) * 1000), + }); + } + h.respond(() => reply(200, envelope([{ job: { status: 'RUNNING' } }]))); + await h.service.refreshOpenOrders('t1'); + expect(h.calls).toHaveLength(20); + // x0 ist am aeltesten geprueft, x24 am juengsten -> x20..x24 bleiben liegen. + const urls = h.calls.map((c) => c.url); + expect(urls[0]).toBe('https://api.demo.autodns.com/v1/job/100'); + expect(urls).not.toContain('https://api.demo.autodns.com/v1/job/124'); + }); + + it('ein AutoDNS-Fehler bei einem Auftrag haelt die anderen nicht auf', async () => { + const h = makeHarness(); + h.addOrder({ id: 'a', domainName: 'a.de', openKey: 'a.de', status: 'SUBMITTED', jobId: '1' }); + h.addOrder({ id: 'b', domainName: 'b.de', openKey: 'b.de', status: 'SUBMITTED', jobId: '2' }); + h.respond((call) => + call.url.endsWith('/job/1') + ? reply(500, 'kaputt') + : reply(200, envelope([{ job: { id: 2, status: 'SUCCESS' } }])), + ); + await h.service.refreshOpenOrders('t1'); + expect(h.state.orders.find((o) => o.id === 'b')?.status).toBe('SUCCESS'); + expect(h.state.orders.find((o) => o.id === 'a')?.status).toBe('SUBMITTED'); + }); + + it('Auftraege des anderen Systems werden nicht angefasst', async () => { + const h = makeHarness({ environment: 'LIVE' }); + h.addOrder({ environment: 'DEMO', status: 'SUBMITTED', jobId: '9' }); + await h.service.refreshOpenOrders('t1'); + expect(h.fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe('cancelOrder', () => { + it('DRAFT -> CANCELED, openKey leer', async () => { + const h = makeHarness(); + const order = h.addOrder(); + const view = await h.service.cancelOrder('t1', order.id); + expect(view.status).toBe('CANCELED'); + expect(h.state.orders[0]).toMatchObject({ status: 'CANCELED', openKey: null }); + }); + + it('UNKNOWN mit gesetztem lastCheckedAt -> CANCELED', async () => { + const h = makeHarness(); + const order = h.addOrder({ status: 'UNKNOWN', lastCheckedAt: new Date() }); + await h.service.cancelOrder('t1', order.id); + expect(h.state.orders[0]).toMatchObject({ status: 'CANCELED', openKey: null }); + }); + + it('UNKNOWN ohne Abgleich -> 409 checkFirst', async () => { + const h = makeHarness(); + const order = h.addOrder({ status: 'UNKNOWN' }); + await expect(h.service.cancelOrder('t1', order.id)).rejects.toMatchObject({ + response: { code: 'checkFirst' }, + }); + expect(h.state.orders[0].status).toBe('UNKNOWN'); + }); + + it.each([ + 'SUBMITTING', + 'SUBMITTED', + 'SUCCESS', + 'FAILED', + 'CANCELED', + ])('%s -> 409 notCancelable', async (status) => { + const h = makeHarness(); + const order = h.addOrder({ status }); + await expect(h.service.cancelOrder('t1', order.id)).rejects.toMatchObject({ + response: { code: 'notCancelable' }, + }); + }); + + it('fremde Nummer -> 404', async () => { + const h = makeHarness(); + h.addOrder({ tenantId: 'andere' }); + await expect(h.service.cancelOrder('t1', 'o1')).rejects.toBeInstanceOf(NotFoundException); + }); +}); + +describe('listOrders', () => { + it('liefert die neuesten zuerst mit den Anzeigefeldern', async () => { + const h = makeHarness(); + h.addOrder({ + id: 'alt', + domainName: 'alt.de', + openKey: null, + status: 'FAILED', + errorText: 'Fehler', + createdAt: new Date('2026-10-01T10:00:00Z'), + }); + h.addOrder({ + id: 'neu', + domainName: 'xn--mller-kva.de', + openKey: 'xn--mller-kva.de', + status: 'SUCCESS', + confirmedByUsername: 'anna', + confirmedAt: new Date('2026-10-08T09:00:00Z'), + createdAt: new Date('2026-10-08T08:00:00Z'), + }); + const list = await h.service.listOrders('t1'); + expect(list.map((o) => o.id)).toEqual(['neu', 'alt']); + expect(list[0]).toEqual({ + id: 'neu', + environment: 'DEMO', + domainName: 'xn--mller-kva.de', + domainNameUnicode: 'müller.de', + status: 'SUCCESS', + jobStatus: null, + errorText: null, + confirmedByUsername: 'anna', + confirmedAt: '2026-10-08T09:00:00.000Z', + createdAt: '2026-10-08T08:00:00.000Z', + lastCheckedAt: null, + price: { amount: 4.9, currency: 'EUR' }, + }); + expect(h.orderApi.findMany.mock.calls[0][0]).toMatchObject({ + where: { tenantId: 't1' }, + take: 200, + }); + }); +}); diff --git a/apps/api/src/domains/domains-orders.service.ts b/apps/api/src/domains/domains-orders.service.ts new file mode 100644 index 0000000..a9e6664 --- /dev/null +++ b/apps/api/src/domains/domains-orders.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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: }` 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 { + // 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; + 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 { + 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 { + 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 { + 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, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/api/src/domains/domains-settings.service.ts b/apps/api/src/domains/domains-settings.service.ts index e508c3b..6e15b8d 100644 --- a/apps/api/src/domains/domains-settings.service.ts +++ b/apps/api/src/domains/domains-settings.service.ts @@ -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, diff --git a/apps/api/src/domains/domains.controller.spec.ts b/apps/api/src/domains/domains.controller.spec.ts index bdc6c83..46b6172 100644 --- a/apps/api/src/domains/domains.controller.spec.ts +++ b/apps/api/src/domains/domains.controller.spec.ts @@ -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, + ); + }); +}); diff --git a/apps/api/src/domains/domains.controller.ts b/apps/api/src/domains/domains.controller.ts index a1bc970..b269a1a 100644 --- a/apps/api/src/domains/domains.controller.ts +++ b/apps/api/src/domains/domains.controller.ts @@ -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); + } } diff --git a/apps/api/src/domains/domains.module.ts b/apps/api/src/domains/domains.module.ts index 45d9095..46d0900 100644 --- a/apps/api/src/domains/domains.module.ts +++ b/apps/api/src/domains/domains.module.ts @@ -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); diff --git a/apps/api/src/domains/dto/domains-order.dto.ts b/apps/api/src/domains/dto/domains-order.dto.ts new file mode 100644 index 0000000..6fee195 --- /dev/null +++ b/apps/api/src/domains/dto/domains-order.dto.ts @@ -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[]; +} diff --git a/apps/api/src/module-registry/module-manage-handlers.spec.ts b/apps/api/src/module-registry/module-manage-handlers.spec.ts index 29492ec..245564c 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -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(); diff --git a/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.test.tsx b/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.test.tsx new file mode 100644 index 0000000..46ef842 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.test.tsx @@ -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( + + {ui} + , + ); +} + +const mockList = vi.fn(); +const mockRefresh = vi.fn(); +const mockCancel = vi.fn(); + +vi.mock('@/lib/domains-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listOrders: (...a: unknown[]) => mockList(...a), + refreshOrders: (...a: unknown[]) => mockRefresh(...a), + cancelOrder: (...a: unknown[]) => mockCancel(...a), + }; +}); + +function order(over: Partial = {}): 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(); + 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(); + 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, string][])('Status %j heisst "%s"', async (over, label) => { + mockRefresh.mockResolvedValue([order(over)]); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + await screen.findByText('entwurf.de'); + expect(screen.getAllByRole('button', { name: 'Verwerfen' })).toHaveLength(2); + cleanup(); + + render(); + 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(); + 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(); + 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(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx b/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx new file mode 100644 index 0000000..b8db55a --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx @@ -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(null); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [discardId, setDiscardId] = useState(null); + const [discardError, setDiscardError] = useState(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 ( +
+
+

{t('intro')}

+ +
+ + {error && ( +

+ {error} +

+ )} + {orders === null && !error &&

{t('loading')}

} + {orders !== null && orders.length === 0 && ( +

{t('empty')}

+ )} + + {orders !== null && orders.length > 0 && ( + + + + + + + + + {canManage && + + + {orders.map((order) => { + const info = orderStatusInfo(order); + const discardable = canManage && isDiscardable(order); + return ( + + + + + + {canManage && ( + + )} + + ); + })} + +
{t('columns.domain')}{t('columns.system')}{t('columns.status')}{t('columns.confirmed')}} +
+ {order.domainNameUnicode} + {t(`environment.${order.environment}`)} + + {t(`status.${info.labelKey}`)} + + {order.status === 'FAILED' && order.errorText && ( +

{order.errorText}

+ )} + {order.status === 'SUBMITTED' && order.jobStatus === 'SUPPORT' && ( +

{t('supportHint')}

+ )} + {order.status === 'UNKNOWN' && ( +

+ {order.lastCheckedAt ? t('unknownChecked') : t('unknownUnchecked')} +

+ )} +
+ {order.confirmedByUsername && order.confirmedAt + ? t('confirmedBy', { + user: order.confirmedByUsername, + date: formatDateTime(order.confirmedAt, locale), + }) + : t('notConfirmed')} + + {discardable && discardId !== order.id && ( + + )} + {discardable && discardId === order.id && ( +
+

+ {order.status === 'UNKNOWN' + ? t('confirmDiscardUnknown') + : t('confirmDiscard')} +

+ {discardError && ( +

+ {discardError} +

+ )} +
+ + +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx new file mode 100644 index 0000000..549d3fb --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx @@ -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( + + {ui} + , + ); +} + +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(); + 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 { + 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 { + 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 { + 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 { + 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 = {}, onOpenOrders = vi.fn()) { + render( + , + ); + 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(); + 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((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(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx new file mode 100644 index 0000000..c144d9a --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx @@ -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(null); + const [error, setError] = useState(null); + + const [contacts, setContacts] = useState(null); + const [contactsLoading, setContactsLoading] = useState(false); + const [chosen, setChosen] = useState>>({}); + const [nameServers, setNameServers] = useState(() => + initialNameServers(status.defaultNameServers), + ); + + const [creating, setCreating] = useState(false); + const [summary, setSummary] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [canceling, setCanceling] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [result, setResult] = useState(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 ( + +
+

{result.domainNameUnicode}

+ + {result.status === 'UNKNOWN' ? t('result.UNKNOWN') : t(`result.${resultKey(result)}`)} + + {result.status === 'UNKNOWN' && ( +

+ {t('result.unknownAdvice')} +

+ )} + {result.status === 'FAILED' && result.errorText && ( +

+ {result.errorText} +

+ )} +
+ + {result.status !== 'UNKNOWN' && ( + + )} +
+
+
+ ); + } + + // --- 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 ( + +
+
+ {t('summary.system')} + +
+
+ {rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ + {submitError && ( +

+ {submitError} +

+ )} +
+ + {submitError ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); + } + + // --- Eingabe ---------------------------------------------------------------- + + return ( +
+ +
+
+ + { + setDomainInput(e.target.value); + setAvailability(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && domainInput.trim() && !checking) void onCheck(); + }} + /> +
+ +
+ {error && ( +

+ {error} +

+ )} + {availability && available && ( +

+ + {t('free', { domain: availability.domainUnicode })} + + {availability.price + ? t('price', { price: formatPrice(availability.price, locale) }) + : t('priceUnknown')} +

+ )} + {availability && !available && ( +
+

+ {t('notFree', { domain: availability.domainUnicode })} +

+

+ {t('notFreeStatus', { status: availability.whoisStatus })} +

+
+ )} +
+ + {available && ( + <> + + {contactsLoading && ( +

{t('contactsLoading')}

+ )} + {contacts !== null && contacts.length === 0 && ( +

{t('contactsEmpty')}

+ )} + {contacts !== null && contacts.length > 0 && ( +
+ {ROLES.map((role) => { + const id = `domains-register-${role}`; + const value = effective(role); + return ( +
+ + +
+ ); + })} +
+ )} +
+ + void onSummary()} + > + {creating ? t('creating') : t('showSummary')} + + } + > +
+ {nameServers.map((value, index) => { + const label = t('nameServer', { number: index + 1 }); + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: feste, nummerierte Eingabefelder +
+ + setNameServers((prev) => + prev.map((n, i) => (i === index ? e.target.value : n)), + ) + } + /> + {nameServers.length > MIN_NAMESERVERS && ( + + )} +
+ ); + })} + {nameServers.length < MAX_NAMESERVERS && ( + + )} +
+
+ + )} +
+ ); +} + +/** 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'; +} diff --git a/apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx b/apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx index aca5067..7a27f3c 100644 --- a/apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx +++ b/apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx @@ -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(); @@ -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(); + 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(); + 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(); + 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(); + fireEvent.click(await screen.findByRole('button', { name: 'Registrieren' })); + expect(await screen.findByRole('button', { name: 'Verfügbarkeit prüfen' })).toBeTruthy(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/domains/page.tsx b/apps/web/src/app/(portal)/modules/domains/page.tsx index 889d896..9f21dea 100644 --- a/apps/web/src/app/(portal)/modules/domains/page.tsx +++ b/apps/web/src/app/(portal)/modules/domains/page.tsx @@ -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' && ( )} + {activeTab === 'register' && canManage && status?.configured && ( + setTab('orders')} + /> + )} + {activeTab === 'orders' && } {activeTab === 'settings' && ( { diff --git a/apps/web/src/components/domains/order-status.test.ts b/apps/web/src/components/domains/order-status.test.ts new file mode 100644 index 0000000..8b2b2c2 --- /dev/null +++ b/apps/web/src/components/domains/order-status.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/components/domains/order-status.ts b/apps/web/src/components/domains/order-status.ts new file mode 100644 index 0000000..2a125ed --- /dev/null +++ b/apps/web/src/components/domains/order-status.ts @@ -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; +} diff --git a/apps/web/src/lib/domains-api.ts b/apps/web/src/lib/domains-api.ts index e788037..65d6e51 100644 --- a/apps/web/src/lib/domains-api.ts +++ b/apps/web/src/lib/domains-api.ts @@ -244,3 +244,99 @@ export function assignContacts( export function listDomains(opts: { refresh?: boolean } = {}): Promise { return request(`/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 { + return request('/availability', { method: 'POST', json: { domain } }); +} + +export function createOrder(input: CreateOrderInput): Promise { + return request('/orders', { method: 'POST', json: input }); +} + +/** Verbindlich: schickt die Registrierung an AutoDNS (kostet im Live-System Geld). */ +export function submitOrder(id: string): Promise { + return request(`/orders/${encodeURIComponent(id)}/submit`, { method: 'POST' }); +} + +export function cancelOrder(id: string): Promise { + return request(`/orders/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); +} + +export function listOrders(): Promise { + return request('/orders'); +} + +/** Gleicht offene Auftraege mit AutoDNS ab und liefert die aktuelle Liste. */ +export function refreshOrders(): Promise { + return request('/orders/refresh', { method: 'POST' }); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 7a7d994..c4b6662 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -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." diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 719d8b1..5225f39 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -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." diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index 360c303..06020c4 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -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', ]; diff --git a/docs/anleitung-administration.md b/docs/anleitung-administration.md index b49e802..26f5086 100644 --- a/docs/anleitung-administration.md +++ b/docs/anleitung-administration.md @@ -356,6 +356,19 @@ Das Modul „Nextcloud-Status“ (Gruppe Infrastruktur) aktivieren Sie wie jedes - **Benachrichtigungen:** Jeder Benutzer mit Zugriff auf das Modul kann über die Glocke auf einer Kachel für sich Benachrichtigungen einschalten. Tessera schickt dann eine E-Mail und, solange Tessera geöffnet ist, eine Meldung auf dem Bildschirm, wenn die Cloud rot wird und wenn sie wieder in Ordnung ist (eine Nachricht je Änderung). Für die E-Mails muss der Mailversand eingerichtet sein (Kapitel 6) und der Empfänger eine Adresse im Profil haben. Ob das Konto aktiv ist und das Modul noch genutzt werden darf, prüft Tessera erst beim Senden; sonst wird nichts verschickt. Scheitert der Versand, versucht Tessera es bis zu dreimal. Ändern Sie die Adresse einer roten Cloud und die neue Adresse antwortet in Ordnung, geht an die Abonnenten die Nachricht „wieder in Ordnung“. - **Logo:** Entweder ein hochgeladenes Bild (PNG, JPEG, GIF oder WebP, höchstens 1 MB; SVG wird bewusst nicht angenommen) oder die https-Adresse eines Bildes, das der Browser des Betrachters direkt lädt – der Server ruft diese Adresse nie ab. Beides zugleich gibt es nicht: ein Upload ersetzt die Adresse und umgekehrt. +### Domains: AutoDNS anbinden + +Das Modul „Domains“ (Gruppe Domains) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Anbindung einrichten, Kunden und Kontakte anlegen und Domains registrieren** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“ – vergeben Sie diese Stufe nur an Personen, denen Sie Bestellungen auf Ihre Kosten zutrauen, denn eine Registrierung im Live-System ist verbindlich und kostenpflichtig. + +- **Eigener AutoDNS-Benutzer:** Legen Sie bei AutoDNS einen eigenen Benutzer für Tessera an, **ohne Zwei-Faktor-Anmeldung** – Tessera meldet sich mit Benutzername, Passwort und der Kontextnummer an, einen Zusatzcode kann es nicht eingeben. Der Benutzer braucht die Rechte, Domains und Kontakte zu lesen sowie Kontakte anzulegen und Domains zu registrieren. +- **Kontext je System:** Tragen Sie unter Domains → Einstellungen für das Demo-System und das Live-System je Benutzername, Passwort und Kontext ein. Beim Live-System ist der Kontext in der Regel **4** (das Feld ist damit vorbelegt). Für das Demo-System nennt InterNetX den Wert; er steht nicht fest und ist deshalb frei eintragbar. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; lassen Sie das Feld später leer, um es zu behalten. +- **Mit dem Demo-System beginnen:** Neue Installationen laufen im Demo-System, dort entstehen keine echten Registrierungen. Probieren Sie zuerst dort alles aus. Der Wechsel auf das Live-System (Einstellungen → System) verlangt eine ausdrückliche Bestätigung; danach zeigt die Kennzeichnung oben auf der Seite „Live-System – Registrierungen kosten Geld“. Für jedes System gelten eigene Zugangsdaten, eigene Kontakt-Zuordnungen und eigene Aufträge – ein Entwurf aus dem Demo-System kann nie im Live-System bestellt werden. +- **Verbindung testen:** Der Knopf schickt genau einen Anmeldeversuch an AutoDNS. Klicken Sie bei einer Fehlermeldung nicht wiederholt: **Mehrere falsche Anmeldungen hintereinander können den AutoDNS-Benutzer sperren.** Prüfen Sie erst Benutzername, Passwort und Kontext, speichern Sie, und testen Sie dann einmal. +- **Standard-Nameserver:** Unter „Standard-Nameserver“ (zwei bis sechs) hinterlegen Sie die Nameserver, die bei jeder Registrierung vorausgefüllt werden. Sie **müssen bei Ihrem Anbieter bereits eingerichtet sein**: Für `.de`-Domains prüft die Registry (DENIC) die Nameserver bei der Registrierung, und eine Registrierung mit nicht eingerichteten Nameservern scheitert („Fehlgeschlagen“). +- **Netzwerk:** Der API-Container braucht ausgehenden Zugriff (https) auf `api.autodns.com` (Live) und `api.demo.autodns.com` (Demo). Die Adressen sind fest eingebaut und nicht änderbar; Zertifikate werden geprüft, Weiterleitungen nicht befolgt. +- **Abfragegrenze:** AutoDNS erlaubt etwa drei Anfragen pro Sekunde und IP-Adresse. Tessera hält das ein (höchstens eine Anfrage alle 350 Millisekunden, Listen seitenweise, ein Zwischenspeicher von einer Minute) und wiederholt nie selbstständig eine Anfrage. Listen zeigen höchstens die ersten 2000 Einträge. +- **Keine Doppelbestellung:** Jede Registrierung wird höchstens einmal an AutoDNS geschickt. Bei Zeitüberschreitung oder Verbindungsabbruch steht der Auftrag auf „Ergebnis ungeklärt“ und wird beim nächsten Abgleich (Reiter „Aufträge“) mit AutoDNS verglichen. Ein unklarer Auftrag lässt sich erst verwerfen, nachdem dieser Abgleich ohne Fund durchgelaufen ist. + ## 6. SMTP Unter **Administration → E-Mail-Versand (SMTP)** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde. diff --git a/docs/anleitung-anwender.md b/docs/anleitung-anwender.md index bf6a3cc..fd88a1d 100644 --- a/docs/anleitung-anwender.md +++ b/docs/anleitung-anwender.md @@ -15,6 +15,7 @@ Diese Anleitung richtet sich an alle Kolleginnen und Kollegen, die Tessera im Ar - [DKV-Rechnung](#dkv-rechnung) - [Zertifikat-Manager](#zertifikat-manager) - [Domaincheck](#domaincheck) + - [Domains](#domains) - [Proxmox](#proxmox) - [Nextcloud-Status](#nextcloud-status) - [Kantinenabrechnung](#kantinenabrechnung) @@ -160,6 +161,40 @@ Unterstützte Dateiformate sind unter anderem `.pem`, `.crt`, `.cer`, `.der`, `. Ein einfaches Werkzeug, um zu prüfen, ob eine Internet-Domain verfügbar ist. Geben Sie einen Domain-Namen ein (z. B. `beispiel.de`) und klicken Sie auf „Prüfen" — das Ergebnis zeigt für die geprüften Endungen jeweils „Verfügbar" oder „Registriert" an, ergänzt um alternative Vorschläge. +### Domains + +Mit dem Modul „Domains“ (Gruppe **Domains**) verwalten Sie Ihre Domains bei AutoDNS: Sie sehen alle vorhandenen Domains, ordnen Kontakte Kunden zu und registrieren neue Domains. Ein Administrator aktiviert das Modul im Marktplatz; Sie brauchen außerdem die Freigabe. Mit **„Benutzen“** sehen Sie die Reiter **Domains**, **Kontakte**, **Kunden** und **Aufträge**. Mit **„Verwalten“** (und als Administrator) kommen **Registrieren** und **Einstellungen** hinzu, und Sie können Kontakte und Kunden anlegen und zuordnen. + +**Das System oben rechts:** Eine Kennzeichnung zeigt immer, womit Tessera gerade arbeitet. **„Demo-System (Testbetrieb)“** ist ein Übungssystem von AutoDNS, dort entstehen keine echten Registrierungen. **„Live-System – Registrierungen kosten Geld“** ist das echte System. Steht dort „AutoDNS nicht eingerichtet“, hinterlegt zuerst ein Administrator oder jemand mit „Verwalten“ den Zugang in den Einstellungen. + +**Domains:** Alle Domains des gewählten Systems mit Kunde, Inhaber, Ablaufdatum und Status. Der Kunde ergibt sich aus dem Inhaber-Kontakt; ist dieser keinem Kunden zugeordnet, steht dort „Nicht zugeordnet“. Über das Suchfeld, die Kundenauswahl und „Nach Kunde gruppieren“ grenzen Sie die Liste ein. „Aus AutoDNS neu laden“ holt den aktuellen Stand; sonst merkt sich Tessera die Liste für eine Minute, damit AutoDNS nicht überlastet wird. + +**Kontakte und Kunden:** Alle Kontakte, die bei AutoDNS im gewählten System vorhanden sind, erscheinen automatisch im Reiter „Kontakte“. Wer „Verwalten“ hat, ordnet sie einem **Kunden** zu: Kontakte ankreuzen, bei „Ausgewählte zuordnen“ den Kunden wählen, „Zuordnen“ klicken – auch viele auf einmal. „Zuordnung entfernen“ löst sie wieder. Mit „Neuer Kontakt“ legen Sie einen neuen Kontakt bei AutoDNS an (Person oder Organisation; die Telefonnummer in internationaler Schreibweise, zum Beispiel +49 30 123456). Im Reiter „Kunden“ legen Sie Kunden an; Ihre eigene Firma legen Sie ebenfalls als Kunden an und haken „Das ist unsere eigene Firma“ an. Ein Kunde, dem noch Kontakte zugeordnet sind, lässt sich nicht löschen. Kontakte selbst ändert oder löscht Tessera bei AutoDNS nicht. + +**Eine Domain registrieren** (Reiter „Registrieren“, nur mit „Verwalten“): + +1. Domain eingeben und **„Verfügbarkeit prüfen“**. Nur „frei“ lässt sich registrieren; der Preis für ein Jahr wird angezeigt, sofern AutoDNS ihn nennt. +2. Für **Inhaber, Admin-Kontakt, technischen Kontakt und Zonenkontakt** je einen Kontakt wählen. Admin-, Technik- und Zonenkontakt sind vorausgewählt (Admin wie der Inhaber, Technik und Zone wie der erste Kontakt Ihrer eigenen Firma, sonst wie der Inhaber) und lassen sich ändern. Die **Nameserver** sind aus den Einstellungen vorausgefüllt; es sind zwei bis sechs nötig. +3. **„Zusammenfassung anzeigen“** und alles prüfen: System, Domain, Laufzeit (ein Jahr), Preis, Kontakte, Nameserver. +4. Das Häkchen zur Bestätigung setzen und **„Jetzt verbindlich registrieren“** klicken. Im Live-System kostet das Geld. + +Tessera schickt eine Registrierung **höchstens ein einziges Mal** an AutoDNS – auch bei Doppelklick, bei zwei geöffneten Fenstern oder nach einem Wechsel des Systems zwischen Zusammenfassung und Bestätigung (dann erscheint eine Meldung, und es wird nichts bestellt). Eine Wiederholung gibt es nie von selbst. + +**Aufträge:** Jede Registrierung erscheint hier mit ihrem Stand. Tessera gleicht die offenen Aufträge beim Öffnen des Reiters, nach „Aktualisieren“ und danach alle 30 Sekunden mit AutoDNS ab, solange der Reiter offen und sichtbar ist. Die Stände bedeuten: + +| Stand | Bedeutung | +|---|---| +| Entwurf | Zusammenfassung erstellt, noch nichts bestellt. | +| Wird übermittelt | Die Bestätigung läuft gerade. | +| In Bearbeitung | AutoDNS hat die Bestellung angenommen und bearbeitet sie. | +| Rückfrage nötig | AutoDNS beziehungsweise InterNetX braucht eine Rückmeldung; wenden Sie sich dorthin. | +| Registriert | Die Domain ist registriert. | +| Fehlgeschlagen | AutoDNS hat die Registrierung abgelehnt oder nicht ausgeführt; der Grund steht dabei. Sie dürfen es erneut versuchen. | +| Ergebnis ungeklärt | Tessera weiß nicht, ob AutoDNS die Bestellung angenommen hat (zum Beispiel wegen einer Verbindungsstörung). | +| Verworfen | Ein Entwurf oder ein geprüfter unklarer Auftrag, den jemand mit „Verwalten“ verworfen hat. | + +**„Ergebnis ungeklärt“ – bestellen Sie nicht erneut.** Die Bestellung kann bei AutoDNS angekommen sein, auch wenn die Antwort fehlte. Klicken Sie unter „Aufträge“ auf „Aktualisieren“: Tessera fragt dann bei AutoDNS nach, ob es die Domain oder einen passenden Auftrag gibt, und stellt den Auftrag entsprechend richtig. Findet Tessera weder die Domain noch einen Auftrag, dürfen Sie den Auftrag verwerfen – prüfen Sie zur Sicherheit vorher in AutoDNS selbst, dass die Domain nicht bestellt wurde. Verwerfen und neu bestellen ist erst danach sicher. + ### Proxmox Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die drei Proxmox-Produkte PVE (Virtualisierung), PBS (Backup) und PMG (Mail-Gateway). Wichtig zu wissen: **Tessera verändert bei Proxmox nichts, es schaut nur zu.** Es gibt in diesem Modul keinen Weg, einen Server zu starten, zu stoppen, eine Sicherung auszulösen oder irgendeine Proxmox-Einstellung zu ändern. diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index c9aed88..d8ad1f7 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -181,9 +181,9 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | reminders | 0 | 12 | 1 | **quick-260929-if2 (Aufgabe 3):** nachgemessen mit der Gate-Schleife: 0/12/1 — +5 gebunden, +1 System. `reminders.service.ts` +1 gebunden (`getEmailAvailability`: `user.findFirst` für die eigene E-Mail-Adresse, an Mandant und Benutzer gebunden). NEU `reminder-mail.scheduler.ts`: +4 gebunden je Kandidatenzeile (`reminder.updateMany` als Anspruch, `reminder.findFirst`, `user.findFirst` für die Adresse des Besitzers, `reminder.updateMany` als Freigabe bei Transportfehler; alle über `forTenant(prisma, c.tenantId)` ohne Benutzer) und +1 System (`systemPrisma.reminder.findMany`, die Kandidatenabfrage über alle Mandanten, nur skalarer Select). Vorher: **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). | | kantine-datev | 0 | 2 | 0 | **quick-261002-fm5:** neu, zwei gebundene Rohtreffer in `kantine-datev.service.ts` (`getSettings` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. | | handelsware-datev | 0 | 8 | 0 | **quick-261002-fm5:** neu, acht gebundene Rohtreffer über `tenantPrisma` in `handelsware-datev.service.ts` (`handelswareDatevConfig` 2, `handelswareKonto` 6), nachgemessen mit der Gate-Schleife: 0/8/0. Dazu fünf Zugriffe über den Transaktionsparameter `tx` von `withTenantTransaction` (`handelswareDatevConfig` 1, `handelswareKonto` 4), die diese einfache Rohtrefferzählung strukturell nicht sieht (siehe Hinweis zu `groups` oben) — die Bestandsaufnahme unten führt sie. Kein ungebundener Zugriff, kein Systemkontext. | -| domains | 0 | 15 | 0 | **quick-261008-dts (Aufgabe 2):** +13 gebunden, nachgemessen mit der Gate-Schleife: 0/15/0. NEU `domains-directory.service.ts` mit 13 gebundenen Rohtreffern über `tenantPrisma`, je Methode ein eigener Klient (`domainsCustomer` 8: `findMany` in `loadCustomers`, `findFirst` in `requireCustomer` und `createCustomer`, `create`, `updateMany` in `createCustomer` und `updateCustomer` (Markierung „eigene Firma“ lösen), `updateMany` fürs Ändern in `updateCustomer`, `deleteMany` in `deleteCustomer`; `domainsContactAssignment` 5: `findMany` in `loadAssignments`, `count` in `deleteCustomer`, zweimal `upsert` in `createContact`/`assignContacts`, `deleteMany` in `assignContacts`). Jeder `where` trägt `tenantId`, bei Zuordnungen zusätzlich das aktive System. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. Vorher: **quick-261008-dts (Aufgabe 1):** neu, zwei gebundene Rohtreffer über `tenantPrisma` in `domains-settings.service.ts` (`domainsConfig`: `loadRow` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. Die weiteren Dienste des Moduls (Kunden und Kontakte, Bestellungen) tragen ihre Zeilen, sobald sie gebaut sind. | +| domains | 0 | 30 | 0 | **quick-261008-dts (Aufgabe 3):** +15 gebunden, nachgemessen mit der Gate-Schleife: 0/30/0. NEU `domains-orders.service.ts` mit 15 gebundenen Rohtreffern auf `domainsOrder` über `tenantPrisma`, je Methode ein eigener Klient (`createOrder` 3: `findFirst`, `updateMany` für den Entwurf, `create`; `submitOrder` 5: `findFirst` vor dem Anspruch, `updateMany` als atomarer Anspruch DRAFT→SUBMITTING mit Zählerprüfung, `findFirst` nach verlorenem Anspruch, `updateMany` fürs Ergebnis, `findFirst` zum Zurückgeben; `refreshOpenOrders` 3: `findMany` für hängende Ansprüche, `updateMany` auf UNKNOWN, `findMany` für offene Aufträge; `writeIf` 1: `updateMany` an den erwarteten Zustand gebunden; `cancelOrder` 2: `findFirst`, `updateMany`; `listOrders` 1: `findMany`). Jeder `where` trägt `tenantId`. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. Vorher: **quick-261008-dts (Aufgabe 2):** +13 gebunden, nachgemessen mit der Gate-Schleife: 0/15/0. NEU `domains-directory.service.ts` mit 13 gebundenen Rohtreffern über `tenantPrisma`, je Methode ein eigener Klient (`domainsCustomer` 8: `findMany` in `loadCustomers`, `findFirst` in `requireCustomer` und `createCustomer`, `create`, `updateMany` in `createCustomer` und `updateCustomer` (Markierung „eigene Firma“ lösen), `updateMany` fürs Ändern in `updateCustomer`, `deleteMany` in `deleteCustomer`; `domainsContactAssignment` 5: `findMany` in `loadAssignments`, `count` in `deleteCustomer`, zweimal `upsert` in `createContact`/`assignContacts`, `deleteMany` in `assignContacts`). Jeder `where` trägt `tenantId`, bei Zuordnungen zusätzlich das aktive System. Kein ungebundener Zugriff, kein Systemkontext, kein `include`/relationales `select`. Vorher: **quick-261008-dts (Aufgabe 1):** neu, zwei gebundene Rohtreffer über `tenantPrisma` in `domains-settings.service.ts` (`domainsConfig`: `loadRow` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. Die weiteren Dienste des Moduls (Kunden und Kontakte, Bestellungen) tragen ihre Zeilen, sobald sie gebaut sind. | | nextcloud-status | 0 | 23 | 1 | **quick-261002-kxc (Aufgabe 3, Endstand):** 21→23 gebunden — `listRecentAlerts` in `nextcloud-alert.service.ts` liest die Abonnements des Aufrufers (`nextcloudAlertSubscription.findMany`, Klient mit Mandant UND Benutzer) und dazu die letzten Übergänge dieser Clouds (`nextcloudInstance.findMany`, nur skalare Felder, `where: { tenantId, id: { in } }`); System unverändert 1. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/23/1. Vorher: **quick-261002-kxc (Aufgabe 1):** 14→21 gebunden, System unverändert 1 — NEU `nextcloud-alert.service.ts` mit sieben gebundenen Rohtreffern (`subscribe`: `nextcloudInstance.findFirst`, `nextcloudAlertSubscription.upsert`; `unsubscribe`: `deleteMany`; `subscribedInstanceIds`: `findMany`; `evaluateAfterCheck`: `nextcloudInstance.updateMany`; `notifySubscribers`: `nextcloudAlertSubscription.findMany`, `user.findMany`), je Methode ein Klient; `nextcloud-status.service.ts` unverändert bei 14. Nachgemessen mit der Gate-Schleife über `nextcloud-status/`: 0/21/1. Vorher: **quick-261002-k67 (Aufgabe 2, Endstand):** 4→14 gebunden, 0→1 System — Schreibwege und Planer: `updateInstance` (`findFirst`, `update`), `deleteInstance` (`findFirst`, `delete`), `uploadLogo` (`findFirst`, `update`), `getLogo` (`findFirst`), `removeLogo` (`findFirst`, `update`), `listInstanceIdsForTenant` (`findMany`) bringen zehn weitere gebundene Rohtreffer, je Methode ein Klient. Der eine System-Rohtreffer ist `loadAllInstancesForScheduler()` (`systemPrisma.nextcloudInstance.findMany`, nur `select: { id, tenantId }`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Nachgemessen mit der Gate-Schleife: 0/14/1. Vorher: **quick-261002-k67 (Aufgabe 1):** neu, vier gebundene Rohtreffer in `nextcloud-status.service.ts` (`listForTenant` `findMany`, `createInstance` `create`, `checkInstance` `findFirst` und `update`). | -| **Summe** | **65** | **310** | **8** | **quick-261008-dts (Aufgabe 2):** Gebunden +13 = `domains` (`domains-directory.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/310/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 1):** Gebunden +2 = `domains` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 65/297/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261003-387:** Ungebunden +4 und Gebunden +22 = `module-categories` (neu, siehe dortige Zeile), System unverändert: 65/295/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor, die Differenz zur vorigen Messung ist genau +4/+22. Vorher: **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| **Summe** | **65** | **325** | **8** | **quick-261008-dts (Aufgabe 3):** Gebunden +15 = `domains` (`domains-orders.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/325/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 2):** Gebunden +13 = `domains` (`domains-directory.service.ts`, siehe dortige Zeile), Ungebunden und System unverändert: 65/310/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261008-dts (Aufgabe 1):** Gebunden +2 = `domains` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 65/297/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor. Vorher: **quick-261003-387:** Ungebunden +4 und Gebunden +22 = `module-categories` (neu, siehe dortige Zeile), System unverändert: 65/295/8; die Gate-Schleife über alle Bereiche liegt um dieselbe ältere Abweichung darüber wie zuvor, die Differenz zur vorigen Messung ist genau +4/+22. Vorher: **quick-261002-kxc (Aufgabe 3):** Gebunden +2 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/273/8; die Gate-Schleife über alle Bereiche ergibt jetzt 61/280/8, die Differenz von 7 ist die unten beschriebene ältere Abweichung. Vorher: **quick-261002-kxc (Aufgabe 1):** Gebunden +7 = `nextcloud-status` (siehe dortige Zeile), Ungebunden und System unverändert: 61/271/8. Hinweis zur Messung: die Gate-Schleife über ALLE Bereiche ergibt heute 61/278/8 — die Mehrzahl von 7 gegenüber 271 stammt nicht aus diesem Plan, sondern aus Zeilen, die ihre Werte seit längerem nicht nachgeführt haben (gemessen: `dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, dazu Bereiche ohne eigene Zeile); sie wurden hier bewusst nicht angefasst. Vorher: **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare) @@ -426,6 +426,10 @@ und `/domainsContactAssignment`, beide `gebunden`): 103 Paare, davon 62 `muss-mandantengebunden`, 23 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. +quick-261008-dts (Aufgabe 3): +1 `muss-mandantengebunden` (`domains-orders.service.ts`/`domainsOrder`, `gebunden`): +104 Paare, davon 63 `muss-mandantengebunden`, 23 `keine-mandantengebundene-tabelle`, 16 `beides`, +2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. + quick-261002-kxc (Aufgabe 3): Paarzahl unverändert (96); `listRecentAlerts` nutzt dieselben Paare `nextcloud-alert.service.ts`/`nextcloudAlertSubscription` und `/nextcloudInstance`. @@ -893,6 +897,7 @@ werden. | apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareKonto | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Kontenliste der Handelsware (Produktname → Gegenkonto, Erlöskonto), mehrere Zeilen je Mandant, Name je Mandant eindeutig. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000, Form aus `ProxmoxServer`), keine `system_read_policy`. Sechs Rohtreffer über `tenantPrisma` (`listAccounts` `findMany`, `createAccount` `create`, `updateAccount` `findFirst` UND `update`, `deleteAccount` `findFirst` UND `delete`) und vier über den Transaktionsparameter von `withTenantTransaction` (`importAccountsCsv` `deleteMany` UND `createMany` als EINE Transaktion; `export` `findMany` UND `createMany` — berechnet die Zuordnung neu und speichert neue Konten in derselben Transaktion, in der die Datei entsteht). `updateAccount`/`deleteAccount` prüfen die Kennung zusätzlich mit `where: { id, tenantId }` und antworten mit 404 (zweites Netz, solange der RLS-Schalter aus ist). | | apps/api/src/domains/domains-directory.service.ts | domainsCustomer | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 2):** neu — Kunden des Moduls Domains (Name je Organisation eindeutig, Markierung „eigene Firma“, höchstens eine). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Organisationsdaten. Bewusst KEINE `system_read_policy`. Acht Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)`, je Methode ein eigener Klient; jeder `where` trägt `tenantId` (auch `updateMany`/`deleteMany` über `id` UND `tenantId`, damit eine fremde Id 0 Treffer liefert und als 404 endet). Kein `include`/relationales `select`. | | apps/api/src/domains/domains-directory.service.ts | domainsContactAssignment | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 2):** neu — Zuordnung AutoDNS-Kontakt → Kunde. Das System (Demo/Live) ist Teil des Schlüssels (`@@unique([tenantId, environment, autodnsContactId])`), weil Kontakt-Nummern beider Systeme zwangsläufig kollidieren; jede Abfrage filtert auf das AKTIVE System. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000). Bewusst KEINE `system_read_policy`. Fünf Rohtreffer über `tenantPrisma` (`findMany`, `count`, zweimal `upsert`, `deleteMany`); der Fremdschlüssel auf den Kunden steht auf `Restrict`, ein Kunde mit Zuordnungen wird nicht gelöscht. Kein `include`/relationales `select`. | +| apps/api/src/domains/domains-orders.service.ts | domainsOrder | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 3):** neu — Registrierungsaufträge des Moduls Domains (Entwurf, Anspruch, Ergebnis, Prüfspur mit Benutzer und Zeitpunkt der Bestätigung). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Organisationsdaten. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Stand wird beim Öffnen des Reiters abgeglichen. Fünfzehn Rohtreffer über `tenantPrisma`, je Methode ein eigener Klient; jeder `where` trägt `tenantId`, fremde Nummern enden als 404. Der Anspruch ist ein `updateMany` mit `status: 'DRAFT'` und dem aktiven System in der Bedingung; erst bei Zähler 1 geht `POST /domain` hinaus (Geld, höchstens einmal). Die Eindeutigkeit offener Aufträge je Domain und System sichert die Spalte `openKey` (`@@unique([tenantId, environment, openKey])`). Kein `include`/relationales `select`. | | apps/api/src/domains/domains-settings.service.ts | domainsConfig | muss-mandantengebunden | gebunden | **quick-261008-dts:** neu — Einstellungen des Moduls Domains (AutoDNS), eine Zeile je Organisation (Singleton): aktives System (Demo/Live), getrennte Zugänge je System mit AES-verschlüsseltem Passwort (CryptoService, nie an den Client zurückgegeben), Standard-Nameserver. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Einstellungen der Organisation, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Auftragsstatus wird beim Öffnen der Seite abgefragt. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`loadRow` `findUnique`, `saveSettings` `upsert`); `where` trägt `tenantId`. | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | nextcloudAlertSubscription | muss-mandantengebunden | gebunden | **quick-261002-kxc:** neu — die persönliche Glocke „Benachrichtigen“ je Benutzer und Cloud. `tenantId`- und `userId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension (Migration 20261002170000, Form aus `Reminder`), bewusst OHNE `system_read_policy` — die Tabelle wird nie im Systemkontext gelesen. Jeder Zugriff läuft an den Mandanten gebunden: `subscribe`/`unsubscribe`/`subscribedInstanceIds` über `forTenant(this.prisma, tenantId, userId)` (Benutzer aus dem Token, nie aus dem Body), der Versand (`notifySubscribers`) über `forTenant(this.prisma, tenantId)` mit `where: { tenantId, instanceId }`; `listRecentAlerts` (Meldung in Tessera) liest nur die Abonnements des Aufrufers (`where: { tenantId, userId }`, Benutzer aus dem Token). | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | nextcloudInstance | muss-mandantengebunden | gebunden | **quick-261002-kxc:** `subscribe` prüft per `findFirst` mit `where: { id, tenantId }`, dass die Cloud dem Mandanten gehört (sonst 404); `evaluateAfterCheck` beansprucht den Übergang per `updateMany` mit `where: { id, tenantId, alertState }` VOR dem Mailversand (nur `count === 1` meldet, mehrere API-Instanzen und Neustarts melden nie doppelt). Beides an den Mandanten gebunden, kein Systemkontext. |