diff --git a/apps/api/src/domains/autodns-parse.spec.ts b/apps/api/src/domains/autodns-parse.spec.ts new file mode 100644 index 0000000..a33cf6a --- /dev/null +++ b/apps/api/src/domains/autodns-parse.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { parseContacts, parseDomains } from './autodns-parse'; +import { TtlCache } from './domains-cache'; + +describe('parseContacts', () => { + it('liest Organisation, Person, Alias und Rueckfall auf die Id', () => { + const result = parseContacts([ + { + id: 11, + type: 'ORG', + organization: 'Muster GmbH', + fname: 'Max', + lname: 'Muster', + address: ['Musterstr. 1', 'Hinterhaus'], + pcode: '12345', + city: 'Berlin', + country: 'DE', + email: 'info@example.com', + phone: '+49 30 123456', + alias: 'muster-1', + }, + { id: 12, type: 'PERSON', fname: 'Erika', lname: 'Muster' }, + { id: 13, alias: 'nur-alias' }, + { id: 14 }, + ]); + expect(result.map((c) => c.displayName)).toEqual([ + 'Muster GmbH', + 'Erika Muster', + 'nur-alias', + '#14', + ]); + expect(result[0]).toEqual({ + id: 11, + type: 'ORG', + displayName: 'Muster GmbH', + fname: 'Max', + lname: 'Muster', + organization: 'Muster GmbH', + address: ['Musterstr. 1', 'Hinterhaus'], + pcode: '12345', + city: 'Berlin', + country: 'DE', + email: 'info@example.com', + phone: '+49 30 123456', + alias: 'muster-1', + }); + }); + + it('wandelt eine Id als Zeichenkette in eine Zahl und ueberspringt Eintraege ohne Zahl', () => { + const result = parseContacts([ + { id: '123', lname: 'A' }, + { id: 'abc', lname: 'B' }, + { lname: 'C' }, + null, + 'x', + { id: -4 }, + ]); + expect(result.map((c) => c.id)).toEqual([123]); + }); + + it('kuerzt lange Zeichenketten auf 200 Zeichen und akzeptiert Nicht-Listen', () => { + const long = 'x'.repeat(500); + expect(parseContacts([{ id: 1, organization: long }])[0].organization).toHaveLength(200); + expect(parseContacts(undefined)).toEqual([]); + expect(parseContacts({})).toEqual([]); + }); +}); + +describe('parseDomains', () => { + it('liest Name, Ablaufdatum, Status und Inhaber', () => { + const result = parseDomains([ + { + name: 'beispiel.de', + expire: '2027-03-01T00:00:00.000+0100', + registryStatus: 'ACTIVE', + cancelationStatus: 'NONE', + ownerc: { id: 11 }, + }, + { name: 'zwei.de', ownerc: 22 }, + { name: 'drei.de', expire: 'kein Datum', ownerc: '33' }, + { name: 'vier.de' }, + { expire: '2027-01-01' }, + ]); + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ + name: 'beispiel.de', + expire: new Date('2027-03-01T00:00:00.000+0100').toISOString(), + registryStatus: 'ACTIVE', + cancelationStatus: 'NONE', + ownerContactId: 11, + }); + expect(result[1].ownerContactId).toBe(22); + expect(result[2].expire).toBeNull(); + expect(result[2].ownerContactId).toBe(33); + expect(result[3].ownerContactId).toBeNull(); + }); +}); + +describe('TtlCache', () => { + it('trifft nach 59 s, verfehlt nach 61 s', () => { + let now = 1_000_000; + const cache = new TtlCache(60_000, () => now); + cache.set('a', 'wert'); + now += 59_000; + expect(cache.get('a')).toBe('wert'); + now += 2_000; + expect(cache.get('a')).toBeUndefined(); + }); + + it('set raeumt abgelaufene Eintraege weg', () => { + let now = 0; + const cache = new TtlCache(60_000, () => now); + cache.set('alt', 1); + now += 61_000; + cache.set('neu', 2); + expect(cache.size).toBe(1); + expect(cache.get('neu')).toBe(2); + }); + + it('loescht nach Praefix', () => { + const cache = new TtlCache(60_000, () => 0); + cache.set('t1|DEMO|1|contacts', 1); + cache.set('t1|DEMO|1|domains', 2); + cache.set('t2|DEMO|1|contacts', 3); + cache.deleteByPrefix('t1|'); + expect(cache.get('t1|DEMO|1|contacts')).toBeUndefined(); + expect(cache.get('t1|DEMO|1|domains')).toBeUndefined(); + expect(cache.get('t2|DEMO|1|contacts')).toBe(3); + }); +}); diff --git a/apps/api/src/domains/autodns-parse.ts b/apps/api/src/domains/autodns-parse.ts new file mode 100644 index 0000000..8520ca2 --- /dev/null +++ b/apps/api/src/domains/autodns-parse.ts @@ -0,0 +1,122 @@ +/** + * Reine Auswertung der AutoDNS-Antwortdaten (quick-261008-dts) — kein Nest, + * kein Netz. Alles defensiv: unbekannte Felder werden ignoriert, Zeichenketten + * auf 200 Zeichen gekuerzt, weil die Daten von einem Fremdsystem kommen und im + * Browser angezeigt werden. + */ + +const MAX_TEXT = 200; + +export interface ParsedContact { + /** AutoDNS-Kontakt-Id als Zahl (die API gibt sie als Zahl weiter). */ + id: number; + type: string | null; + displayName: string; + fname: string | null; + lname: string | null; + organization: string | null; + address: string[]; + pcode: string | null; + city: string | null; + country: string | null; + email: string | null; + phone: string | null; + alias: string | null; +} + +export interface ParsedDomain { + name: string; + /** ISO-Zeichenkette oder null. */ + expire: string | null; + registryStatus: string | null; + cancelationStatus: string | null; + ownerContactId: number | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed === '' ? null : trimmed.slice(0, MAX_TEXT); + } + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return null; +} + +/** Ganzzahl aus Zahl oder Zeichenkette ('123'), sonst null. */ +export function toContactId(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value; + if (typeof value === 'string' && /^\d{1,15}$/.test(value.trim())) { + const n = Number.parseInt(value.trim(), 10); + return n > 0 ? n : null; + } + return null; +} + +function addressLines(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map(text).filter((l): l is string => l !== null); + } + const single = text(value); + return single ? [single] : []; +} + +export function parseContacts(data: unknown): ParsedContact[] { + if (!Array.isArray(data)) return []; + const result: ParsedContact[] = []; + for (const entry of data) { + if (!isRecord(entry)) continue; + const id = toContactId(entry.id); + if (id === null) continue; + const fname = text(entry.fname); + const lname = text(entry.lname); + const organization = text(entry.organization); + const alias = text(entry.alias); + const person = [fname, lname].filter(Boolean).join(' '); + result.push({ + id, + type: text(entry.type), + displayName: organization ?? (person || (alias ?? `#${id}`)), + fname, + lname, + organization, + address: addressLines(entry.address), + pcode: text(entry.pcode), + city: text(entry.city), + country: text(entry.country), + email: text(entry.email), + phone: text(entry.phone), + alias, + }); + } + return result; +} + +function isoOrNull(value: unknown): string | null { + const raw = text(value); + if (!raw) return null; + const date = new Date(raw); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +export function parseDomains(data: unknown): ParsedDomain[] { + if (!Array.isArray(data)) return []; + const result: ParsedDomain[] = []; + for (const entry of data) { + if (!isRecord(entry)) continue; + const name = text(entry.name); + if (!name) continue; + const owner = entry.ownerc; + result.push({ + name, + expire: isoOrNull(entry.expire), + registryStatus: text(entry.registryStatus), + cancelationStatus: text(entry.cancelationStatus), + ownerContactId: isRecord(owner) ? toContactId(owner.id) : toContactId(owner), + }); + } + return result; +} diff --git a/apps/api/src/domains/domains-cache.ts b/apps/api/src/domains/domains-cache.ts new file mode 100644 index 0000000..a658c53 --- /dev/null +++ b/apps/api/src/domains/domains-cache.ts @@ -0,0 +1,51 @@ +/** + * Kleiner Zwischenspeicher im Arbeitsspeicher (quick-261008-dts, D-I). + * + * Warum im Arbeitsspeicher: AutoDNS erlaubt nur 3 Anfragen pro Sekunde und IP, + * die Listen werden aber bei jedem Oeffnen eines Reiters gebraucht. 60 Sekunden + * reichen, um dieses Limit zu schuetzen, und es gibt keinen Zustand, der einen + * Neustart ueberleben muss. Der Schluessel enthaelt Organisation, System und den + * Aenderungsstand der Einstellungen (`DomainsConfig.updatedAt`): wer Zugang oder + * System wechselt, sieht nie Daten des vorherigen Zugangs. + */ +export class TtlCache { + private readonly entries = new Map(); + + constructor( + private readonly ttlMs: number, + private readonly now: () => number = () => Date.now(), + ) {} + + get(key: string): T | undefined { + const entry = this.entries.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= this.now()) { + this.entries.delete(key); + return undefined; + } + return entry.value; + } + + set(key: string, value: T): void { + this.prune(); + this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs }); + } + + /** Loescht alle Eintraege, deren Schluessel mit `prefix` beginnt. */ + deleteByPrefix(prefix: string): void { + for (const key of [...this.entries.keys()]) { + if (key.startsWith(prefix)) this.entries.delete(key); + } + } + + private prune(): void { + const now = this.now(); + for (const [key, entry] of this.entries) { + if (entry.expiresAt <= now) this.entries.delete(key); + } + } + + get size(): number { + return this.entries.size; + } +} diff --git a/apps/api/src/domains/domains-directory.service.spec.ts b/apps/api/src/domains/domains-directory.service.spec.ts new file mode 100644 index 0000000..9fbf989 --- /dev/null +++ b/apps/api/src/domains/domains-directory.service.spec.ts @@ -0,0 +1,589 @@ +import { + BadGatewayException, + BadRequestException, + ConflictException, + NotFoundException, +} from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)), +})); + +vi.mock('./autodns-client', async (importOriginal) => ({ + ...(await importOriginal()), + autodnsRequest: vi.fn(), +})); + +import { autodnsRequest } from './autodns-client'; +import { DomainsDirectoryService } from './domains-directory.service'; + +const mockRequest = vi.mocked(autodnsRequest); + +const CREDENTIALS = { environment: 'DEMO', user: 'api-user', password: 'geheim', context: 4 }; + +function success(data: unknown[], summary: number | null = null) { + return { + ok: true as const, + httpStatus: 200, + statusCode: 'S0301', + statusType: 'SUCCESS', + object: { type: 'contact', value: null, summary }, + data, + messages: [], + stid: null, + }; +} + +interface Customer { + id: string; + tenantId: string; + name: string; + isOwnCompany: boolean; +} +interface Assignment { + tenantId: string; + environment: 'DEMO' | 'LIVE'; + autodnsContactId: string; + customerId: string; +} + +function matches(row: Record, where: Record = {}): boolean { + return Object.entries(where).every(([key, expected]) => { + if (key === 'NOT') return !matches(row, expected); + if (expected && typeof expected === 'object' && 'in' in expected) { + return (expected.in as unknown[]).includes(row[key]); + } + return row[key] === expected; + }); +} + +function makeService(opts: { environment?: 'DEMO' | 'LIVE'; configVersion?: number } = {}) { + const state = { + customers: [] as Customer[], + assignments: [] as Assignment[], + environment: opts.environment ?? ('DEMO' as 'DEMO' | 'LIVE'), + configVersion: opts.configVersion ?? 1, + nextId: 1, + uniqueNameConflict: false, + }; + const customerApi = { + findMany: vi.fn(async ({ where }: any) => + state.customers.filter((c) => matches(c, where)).sort((a, b) => a.name.localeCompare(b.name)), + ), + findFirst: vi.fn( + async ({ where }: any) => state.customers.find((c) => matches(c, where)) ?? null, + ), + create: vi.fn(async ({ data }: any) => { + if (state.uniqueNameConflict) throw Object.assign(new Error('unique'), { code: 'P2002' }); + const row = { id: `c${state.nextId++}`, ...data }; + state.customers.push(row); + return row; + }), + updateMany: vi.fn(async ({ where, data }: any) => { + const rows = state.customers.filter((c) => matches(c, where)); + for (const r of rows) Object.assign(r, data); + return { count: rows.length }; + }), + deleteMany: vi.fn(async ({ where }: any) => { + const before = state.customers.length; + state.customers = state.customers.filter((c) => !matches(c, where)); + return { count: before - state.customers.length }; + }), + }; + const assignmentApi = { + findMany: vi.fn(async ({ where }: any) => state.assignments.filter((a) => matches(a, where))), + count: vi.fn( + async ({ where }: any) => state.assignments.filter((a) => matches(a, where)).length, + ), + upsert: vi.fn(async ({ where, create, update }: any) => { + const key = where.tenantId_environment_autodnsContactId; + const row = state.assignments.find( + (a) => + a.tenantId === key.tenantId && + a.environment === key.environment && + a.autodnsContactId === key.autodnsContactId, + ); + if (row) Object.assign(row, update); + else state.assignments.push({ ...create }); + }), + deleteMany: vi.fn(async ({ where }: any) => { + const before = state.assignments.length; + state.assignments = state.assignments.filter((a) => !matches(a, where)); + return { count: before - state.assignments.length }; + }), + }; + const db = { + __bound: () => ({ domainsCustomer: customerApi, domainsContactAssignment: assignmentApi }), + }; + const settings = { + transport: {}, + getStatus: vi.fn(async () => ({ environment: state.environment })), + getActiveCredentials: vi.fn(async () => ({ + environment: state.environment, + credentials: { ...CREDENTIALS, environment: state.environment }, + configVersion: state.configVersion, + })), + }; + const service = new DomainsDirectoryService(db as any, settings as any); + return { service, state, customerApi, assignmentApi, settings }; +} + +function contactPage(from: number, count: number) { + return Array.from({ length: count }, (_, i) => ({ id: from + i, lname: `Name${from + i}` })); +} + +beforeEach(() => { + mockRequest.mockReset(); +}); + +describe('listContacts', () => { + it('liest die erste Seite mit genau dem erwarteten Aufruf', async () => { + const { service } = makeService(); + mockRequest.mockResolvedValueOnce(success([{ id: 1, lname: 'A' }], 1)); + const result = await service.listContacts('t1'); + expect(mockRequest).toHaveBeenCalledTimes(1); + const [credentials, method, path, options] = mockRequest.mock.calls[0]; + expect(credentials).toEqual(CREDENTIALS); + expect(method).toBe('POST'); + expect(path).toBe('/contact/_search'); + expect((options as any).body).toEqual({ + filters: [], + view: { limit: 100, offset: 0 }, + orders: [{ key: 'lname', type: 'ASC' }], + }); + expect((options as any).keys).toBeUndefined(); + expect(result.contacts).toHaveLength(1); + expect(result.environment).toBe('DEMO'); + expect(result.truncated).toBe(false); + }); + + it('summary 250 -> drei Aufrufe mit Offset 0, 100, 200', async () => { + const { service } = makeService(); + mockRequest + .mockResolvedValueOnce(success(contactPage(1, 100), 250)) + .mockResolvedValueOnce(success(contactPage(101, 100), 250)) + .mockResolvedValueOnce(success(contactPage(201, 50), 250)); + const result = await service.listContacts('t1'); + expect(mockRequest.mock.calls.map((c) => (c[3] as any).body.view.offset)).toEqual([ + 0, 100, 200, + ]); + expect(result.contacts).toHaveLength(250); + expect(result.truncated).toBe(false); + }); + + it('summary 5000 -> haelt bei 2000 Eintraegen an und meldet truncated', async () => { + const { service } = makeService(); + let from = 1; + mockRequest.mockImplementation(async () => { + const page = success(contactPage(from, 100), 5000); + from += 100; + return page; + }); + const result = await service.listContacts('t1'); + expect(mockRequest).toHaveBeenCalledTimes(20); + expect(result.contacts).toHaveLength(2000); + expect(result.truncated).toBe(true); + }); + + it('nimmt die Kundenzuordnung nur aus dem AKTIVEN System', async () => { + const { service, state } = makeService({ environment: 'DEMO' }); + state.customers.push({ id: 'c-demo', tenantId: 't1', name: 'Demo-Kunde', isOwnCompany: false }); + state.customers.push({ id: 'c-live', tenantId: 't1', name: 'Live-Kunde', isOwnCompany: false }); + state.assignments.push( + { tenantId: 't1', environment: 'DEMO', autodnsContactId: '1', customerId: 'c-demo' }, + { tenantId: 't1', environment: 'LIVE', autodnsContactId: '2', customerId: 'c-live' }, + { tenantId: 't1', environment: 'LIVE', autodnsContactId: '1', customerId: 'c-live' }, + ); + mockRequest.mockResolvedValueOnce( + success( + [ + { id: 1, lname: 'Eins' }, + { id: 2, lname: 'Zwei' }, + ], + 2, + ), + ); + const { contacts } = await service.listContacts('t1'); + expect(contacts.map((c) => [c.id, c.customerId, c.customerName])).toEqual([ + [1, 'c-demo', 'Demo-Kunde'], + [2, null, null], + ]); + }); + + it('nutzt den Zwischenspeicher, refresh und neuer Einstellungsstand umgehen ihn', async () => { + const { service, state } = makeService(); + mockRequest.mockResolvedValue(success([{ id: 1, lname: 'A' }], 1)); + await service.listContacts('t1'); + await service.listContacts('t1'); + expect(mockRequest).toHaveBeenCalledTimes(1); + await service.listContacts('t1', { refresh: true }); + expect(mockRequest).toHaveBeenCalledTimes(2); + state.configVersion = 2; + await service.listContacts('t1'); + expect(mockRequest).toHaveBeenCalledTimes(3); + }); + + it('Anmeldefehler bei AutoDNS -> 502 autodnsAuth', async () => { + const { service } = makeService(); + mockRequest.mockResolvedValueOnce({ + ok: false, + kind: 'auth', + httpStatus: 401, + statusCode: null, + messages: [], + stid: null, + }); + const error = await service.listContacts('t1').catch((e) => e); + expect(error).toBeInstanceOf(BadGatewayException); + expect(error.getResponse().code).toBe('autodnsAuth'); + }); + + it('nicht eingerichtet -> 409 notConfigured, ohne Aufruf', async () => { + const { service, settings } = makeService(); + settings.getActiveCredentials.mockRejectedValueOnce( + new ConflictException({ code: 'notConfigured', message: 'x' }), + ); + const error = await service.listContacts('t1').catch((e) => e); + expect(error).toBeInstanceOf(ConflictException); + expect(error.getResponse().code).toBe('notConfigured'); + expect(mockRequest).not.toHaveBeenCalled(); + }); +}); + +describe('createContact', () => { + const person = { + type: 'PERSON' as const, + fname: 'Erika', + lname: 'Muster', + street: ['Musterstraße 1'], + pcode: '12345', + city: 'Berlin', + country: 'DE', + email: 'erika@example.com', + phone: '+49 30 123456', + }; + + it('PERSON: genau ein POST /contact mit dem erwarteten Koerper, ohne organization', async () => { + const { service } = makeService(); + mockRequest.mockResolvedValueOnce(success([{ id: 4711 }], 1)); + const created = await service.createContact('t1', person); + expect(mockRequest).toHaveBeenCalledTimes(1); + const [, method, path, options] = mockRequest.mock.calls[0]; + expect(method).toBe('POST'); + expect(path).toBe('/contact'); + expect((options as any).body).toEqual({ + type: 'PERSON', + fname: 'Erika', + lname: 'Muster', + address: ['Musterstraße 1'], + pcode: '12345', + city: 'Berlin', + country: 'DE', + email: 'erika@example.com', + phone: '+49 30 123456', + }); + expect('organization' in (options as any).body).toBe(false); + expect(created.id).toBe(4711); + expect(created.displayName).toBe('Erika Muster'); + }); + + it('ORG verlangt die Organisation und sendet sie mit', async () => { + const { service } = makeService(); + await expect( + service.createContact('t1', { ...person, type: 'ORG', organization: ' ' }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockRequest).not.toHaveBeenCalled(); + + mockRequest.mockResolvedValueOnce(success([{ id: 5 }], 1)); + const created = await service.createContact('t1', { + ...person, + type: 'ORG', + organization: ' Muster GmbH ', + }); + expect((mockRequest.mock.calls[0][3] as any).body.organization).toBe('Muster GmbH'); + expect(created.displayName).toBe('Muster GmbH'); + }); + + it('fasst Leerraum in der Telefonnummer zusammen', async () => { + const { service } = makeService(); + mockRequest.mockResolvedValueOnce(success([{ id: 9 }], 1)); + await service.createContact('t1', { ...person, phone: ' +49 30 123456 ' }); + expect((mockRequest.mock.calls[0][3] as any).body.phone).toBe('+49 30 123456'); + }); + + it('mit Kunde: legt die Zuordnung im aktiven System an', async () => { + const { service, state } = makeService(); + state.customers.push({ id: 'c1', tenantId: 't1', name: 'Kunde A', isOwnCompany: false }); + mockRequest.mockResolvedValueOnce(success([{ id: 4711 }], 1)); + const created = await service.createContact('t1', { ...person, customerId: 'c1' }); + expect(state.assignments).toEqual([ + { tenantId: 't1', environment: 'DEMO', autodnsContactId: '4711', customerId: 'c1' }, + ]); + expect(created.customerName).toBe('Kunde A'); + expect(JSON.stringify(mockRequest.mock.calls[0][3])).not.toContain('Kunde A'); + }); + + it('unbekannter Kunde -> 404, bevor AutoDNS etwas anlegt', async () => { + const { service } = makeService(); + await expect( + service.createContact('t1', { ...person, customerId: 'gibt-es-nicht' }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('leert den Zwischenspeicher der Organisation', async () => { + const { service } = makeService(); + mockRequest.mockResolvedValue(success([{ id: 1, lname: 'A' }], 1)); + await service.listContacts('t1'); + await service.createContact('t1', person); + mockRequest.mockClear(); + mockRequest.mockResolvedValue(success([{ id: 1, lname: 'A' }], 1)); + await service.listContacts('t1'); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('assignContacts', () => { + it('upsert je verschiedener Id im aktiven System', async () => { + const { service, state, assignmentApi } = makeService(); + state.customers.push({ id: 'c1', tenantId: 't1', name: 'A', isOwnCompany: false }); + const result = await service.assignContacts('t1', { contactIds: [1, 2, 2], customerId: 'c1' }); + expect(result).toEqual({ assigned: 2, removed: 0 }); + expect(assignmentApi.upsert).toHaveBeenCalledTimes(2); + expect(state.assignments.map((a) => [a.environment, a.autodnsContactId, a.customerId])).toEqual( + [ + ['DEMO', '1', 'c1'], + ['DEMO', '2', 'c1'], + ], + ); + }); + + it('customerId null loest die Zuordnung nur im aktiven System', async () => { + const { service, state } = makeService(); + state.assignments.push( + { tenantId: 't1', environment: 'DEMO', autodnsContactId: '1', customerId: 'c1' }, + { tenantId: 't1', environment: 'LIVE', autodnsContactId: '1', customerId: 'c1' }, + { tenantId: 't1', environment: 'DEMO', autodnsContactId: '3', customerId: 'c1' }, + ); + const result = await service.assignContacts('t1', { contactIds: [1, 2], customerId: null }); + expect(result).toEqual({ assigned: 0, removed: 1 }); + expect(state.assignments.map((a) => `${a.environment}:${a.autodnsContactId}`)).toEqual([ + 'LIVE:1', + 'DEMO:3', + ]); + }); + + it('fremder Kunde -> 404, mehr als 500 Kontakte -> 400', async () => { + const { service } = makeService(); + await expect( + service.assignContacts('t1', { contactIds: [1], customerId: 'fremd' }), + ).rejects.toBeInstanceOf(NotFoundException); + const many = Array.from({ length: 501 }, (_, i) => i + 1); + await expect( + service.assignContacts('t1', { contactIds: many, customerId: null }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe('Kunden', () => { + it('legt an, trimmt den Namen und lehnt doppelte Namen mit 409 ab', async () => { + const { service } = makeService(); + const created = await service.createCustomer('t1', { name: ' Kunde A ' }); + expect(created).toMatchObject({ name: 'Kunde A', isOwnCompany: false, contactCount: 0 }); + const error = await service.createCustomer('t1', { name: 'Kunde A' }).catch((e) => e); + expect(error).toBeInstanceOf(ConflictException); + expect(error.getResponse().code).toBe('customerNameTaken'); + }); + + it('Unique-Verletzung beim Anlegen (Wettlauf) -> 409 customerNameTaken', async () => { + const { service, state } = makeService(); + state.uniqueNameConflict = true; + const error = await service.createCustomer('t1', { name: 'X' }).catch((e) => e); + expect(error.getResponse().code).toBe('customerNameTaken'); + }); + + it('eigene Firma: loest die Markierung bei allen anderen', async () => { + const { service, state } = makeService(); + await service.createCustomer('t1', { name: 'Erste', isOwnCompany: true }); + await service.createCustomer('t1', { name: 'Zweite', isOwnCompany: true }); + expect(state.customers.map((c) => [c.name, c.isOwnCompany])).toEqual([ + ['Erste', false], + ['Zweite', true], + ]); + }); + + it('abgelehnter Name kostet keinem anderen Kunden die Markierung', async () => { + const { service, state } = makeService(); + await service.createCustomer('t1', { name: 'Eigene', isOwnCompany: true }); + await service.createCustomer('t1', { name: 'Doppelt' }); + await expect( + service.createCustomer('t1', { name: 'Doppelt', isOwnCompany: true }), + ).rejects.toBeInstanceOf(ConflictException); + expect(state.customers.find((c) => c.name === 'Eigene')?.isOwnCompany).toBe(true); + }); + + it('aendern und loeschen fremder Ids -> 404', async () => { + const { service } = makeService(); + await expect(service.updateCustomer('t1', 'fremd', { name: 'X' })).rejects.toBeInstanceOf( + NotFoundException, + ); + await expect(service.deleteCustomer('t1', 'fremd')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('aendern: Name und Markierung', async () => { + const { service, state } = makeService(); + const a = await service.createCustomer('t1', { name: 'A', isOwnCompany: true }); + const b = await service.createCustomer('t1', { name: 'B' }); + const updated = await service.updateCustomer('t1', b.id, { name: 'B2', isOwnCompany: true }); + expect(updated).toMatchObject({ id: b.id, name: 'B2', isOwnCompany: true }); + expect(state.customers.find((c) => c.id === a.id)?.isOwnCompany).toBe(false); + }); + + it('loeschen mit Zuordnungen -> 409 customerInUse, sonst geloescht', async () => { + const { service, state } = makeService(); + const a = await service.createCustomer('t1', { name: 'A' }); + state.assignments.push({ + tenantId: 't1', + environment: 'LIVE', + autodnsContactId: '7', + customerId: a.id, + }); + const error = await service.deleteCustomer('t1', a.id).catch((e) => e); + expect(error).toBeInstanceOf(ConflictException); + expect(error.getResponse().code).toBe('customerInUse'); + state.assignments.length = 0; + await expect(service.deleteCustomer('t1', a.id)).resolves.toEqual({ deleted: true }); + expect(state.customers).toHaveLength(0); + }); + + it('listCustomers: Anzahl der Kontakte im aktiven System, nach Name sortiert', async () => { + const { service, state } = makeService({ environment: 'DEMO' }); + const b = await service.createCustomer('t1', { name: 'B' }); + const a = await service.createCustomer('t1', { name: 'A', isOwnCompany: true }); + state.assignments.push( + { tenantId: 't1', environment: 'DEMO', autodnsContactId: '1', customerId: a.id }, + { tenantId: 't1', environment: 'DEMO', autodnsContactId: '2', customerId: a.id }, + { tenantId: 't1', environment: 'LIVE', autodnsContactId: '3', customerId: b.id }, + ); + const list = await service.listCustomers('t1'); + expect(list).toEqual([ + { id: a.id, name: 'A', isOwnCompany: true, contactCount: 2 }, + { id: b.id, name: 'B', isOwnCompany: false, contactCount: 0 }, + ]); + }); +}); + +describe('listDomains', () => { + it('liest /domain/_search mit keys und ordnet Inhaber und Kunde zu', async () => { + const { service, state } = makeService(); + state.customers.push({ id: 'c1', tenantId: 't1', name: 'Kunde A', isOwnCompany: false }); + state.assignments.push({ + tenantId: 't1', + environment: 'DEMO', + autodnsContactId: '11', + customerId: 'c1', + }); + mockRequest.mockImplementation(async (_c, _m, path) => { + if (path === '/domain/_search') { + return success( + [ + { + name: 'beispiel.de', + expire: '2027-03-01T00:00:00.000Z', + registryStatus: 'ACTIVE', + cancelationStatus: 'NONE', + ownerc: { id: 11 }, + }, + { + name: 'frei.de', + registryStatus: 'PENDING', + cancelationStatus: 'CANCELATION', + ownerc: { id: 99 }, + }, + { name: 'ohne-inhaber.de' }, + ], + 3, + ); + } + return success([{ id: 11, organization: 'Muster GmbH' }], 1); + }); + const result = await service.listDomains('t1'); + + const domainCall = mockRequest.mock.calls.find((c) => c[2] === '/domain/_search'); + expect(domainCall?.[1]).toBe('POST'); + expect((domainCall?.[3] as any).keys).toEqual(['expire', 'ownerc']); + expect((domainCall?.[3] as any).body).toEqual({ + filters: [], + view: { limit: 100, offset: 0 }, + orders: [{ key: 'name', type: 'ASC' }], + }); + + expect(result.domains).toEqual([ + { + name: 'beispiel.de', + expire: '2027-03-01T00:00:00.000Z', + status: 'ACTIVE', + cancelationPending: false, + ownerContactId: 11, + ownerName: 'Muster GmbH', + customerId: 'c1', + customerName: 'Kunde A', + }, + { + name: 'frei.de', + expire: null, + status: 'PENDING', + cancelationPending: true, + ownerContactId: 99, + ownerName: null, + customerId: null, + customerName: null, + }, + { + name: 'ohne-inhaber.de', + expire: null, + status: null, + cancelationPending: false, + ownerContactId: null, + ownerName: null, + customerId: null, + customerName: null, + }, + ]); + expect(result.truncated).toBe(false); + }); + + it('meldet truncated bei mehr als 2000 Domains', async () => { + const { service } = makeService(); + let n = 0; + mockRequest.mockImplementation(async (_c, _m, path) => { + if (path === '/domain/_search') { + const page = Array.from({ length: 100 }, () => ({ name: `d${n++}.de` })); + return success(page, 2500); + } + return success([], 0); + }); + const result = await service.listDomains('t1'); + expect(result.domains).toHaveLength(2000); + expect(result.truncated).toBe(true); + }); +}); + +describe('findContactsByIds', () => { + it('liefert nur die gewuenschten Kontakte', async () => { + const { service } = makeService(); + mockRequest.mockResolvedValueOnce( + success( + [ + { id: 1, lname: 'A' }, + { id: 2, lname: 'B' }, + { id: 3, lname: 'C' }, + ], + 3, + ), + ); + const result = await service.findContactsByIds('t1', [1, 3]); + expect(result.map((c) => c.id)).toEqual([1, 3]); + }); +}); diff --git a/apps/api/src/domains/domains-directory.service.ts b/apps/api/src/domains/domains-directory.service.ts new file mode 100644 index 0000000..8ef02df --- /dev/null +++ b/apps/api/src/domains/domains-directory.service.ts @@ -0,0 +1,585 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { + type AutodnsCredentials, + type AutodnsResult, + type AutodnsSuccess, + autodnsRequest, +} from './autodns-client'; +import { type ParsedContact, parseContacts, parseDomains, toContactId } from './autodns-parse'; +import { autodnsFailureToHttp, type DomainsEnvironment } from './domains.types'; +import { TtlCache } from './domains-cache'; +import { DomainsSettingsService } from './domains-settings.service'; +import type { AssignDomainsContactsDto, CreateDomainsContactDto } from './dto/domains-contact.dto'; +import type { DomainsCustomerDto } from './dto/domains-customer.dto'; + +/** Seitengroesse und Obergrenze der Listen (D-I). */ +export const DIRECTORY_PAGE_SIZE = 100; +export const DIRECTORY_MAX_ENTRIES = 2000; +export const DIRECTORY_CACHE_TTL_MS = 60_000; +export const MAX_ASSIGN_CONTACTS = 500; + +export interface DomainsCustomerView { + id: string; + name: string; + isOwnCompany: boolean; + /** Zugeordnete Kontakte im AKTIVEN System. */ + contactCount: number; +} + +export interface DomainsContactView extends ParsedContact { + customerId: string | null; + customerName: string | null; +} + +export interface DomainsContactListView { + environment: DomainsEnvironment; + contacts: DomainsContactView[]; + truncated: boolean; + fetchedAt: string; +} + +export interface DomainsDomainView { + name: string; + expire: string | null; + /** Rohwert von AutoDNS (`registryStatus`); die Beschriftung macht der Browser. */ + status: string | null; + cancelationPending: boolean; + ownerContactId: number | null; + ownerName: string | null; + customerId: string | null; + customerName: string | null; +} + +export interface DomainsDomainListView { + environment: DomainsEnvironment; + domains: DomainsDomainView[]; + truncated: boolean; + fetchedAt: string; +} + +interface RawList { + items: T[]; + truncated: boolean; + fetchedAt: string; +} + +interface CustomerRow { + id: string; + name: string; + isOwnCompany: boolean; +} + +interface AssignmentRow { + autodnsContactId: string; + customerId: string; +} + +function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2002' + ); +} + +function isForeignKeyViolation(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2003' + ); +} + +const CUSTOMER_NAME_TAKEN = { + code: 'customerNameTaken', + message: 'Es gibt bereits einen Kunden mit diesem Namen.', +}; +const CUSTOMER_IN_USE = { + code: 'customerInUse', + message: 'Diesem Kunden sind noch Kontakte zugeordnet. Bitte lösen Sie zuerst die Zuordnungen.', +}; +const CUSTOMER_NOT_FOUND = { code: 'customerNotFound', message: 'Der Kunde wurde nicht gefunden.' }; + +/** Kein `cancelationStatus` (oder "NONE") heisst: keine Kuendigung vorgemerkt. */ +function cancelationPending(value: string | null): boolean { + if (!value) return false; + const upper = value.toUpperCase(); + return upper !== 'NONE' && upper !== 'NOT_SET'; +} + +/** + * Kunden, AutoDNS-Kontakte und Domainliste (quick-261008-dts). + * + * AutoDNS bleibt die Quelle der Wahrheit fuer Kontakte und Domains (D-B): sie + * werden live gelesen und nie gespiegelt. Lokal liegt nur, was AutoDNS nicht + * kennt — der Kunde und die Zuordnung Kontakt -> Kunde je System. Kontakte + * werden hier angelegt und gelesen, aber nie geaendert oder geloescht (eine + * Aenderung des Inhabers kann Domains beeinflussen, Recherche Pitfall 9). + * + * Gesamter Zugriff auf `domainsCustomer` und `domainsContactAssignment` liegt + * ausschliesslich hier; jede Methode bindet ihren eigenen `forTenant`-Klienten. + * Kein `include:`/relationales `select:` (Mandantenschutz-Inventar). + */ +@Injectable() +export class DomainsDirectoryService { + private readonly contactCache = new TtlCache>(DIRECTORY_CACHE_TTL_MS); + private readonly domainCache = new TtlCache[number]>>( + DIRECTORY_CACHE_TTL_MS, + ); + + constructor( + private readonly prisma: PrismaService, + private readonly settings: DomainsSettingsService, + ) {} + + // --- AutoDNS-Aufruf ---------------------------------------------------------- + + private async call( + credentials: AutodnsCredentials, + method: 'GET' | 'POST', + path: string, + opts: { body?: unknown; keys?: string[] } = {}, + ): Promise { + const result: AutodnsResult = await autodnsRequest(credentials, method, path, { + ...opts, + fetchImpl: this.settings.transport?.fetchImpl, + limiter: this.settings.transport?.limiter, + }); + if (!result.ok) throw autodnsFailureToHttp(result); + return result; + } + + /** + * Liest eine Liste seitenweise: nacheinander (der Begrenzer des Clients + * taktet), nie parallel. Haelt bei `object.summary`, bei leerer Seite oder + * bei 2000 Eintraegen an. + */ + private async fetchAll( + credentials: AutodnsCredentials, + path: string, + orderKey: string, + parse: (data: unknown) => T[], + keys?: string[], + ): Promise<{ items: T[]; truncated: boolean }> { + const items: T[] = []; + let total: number | null = null; + let truncated = false; + for (let offset = 0; ; offset += DIRECTORY_PAGE_SIZE) { + if (offset >= DIRECTORY_MAX_ENTRIES) { + truncated = total === null ? true : total > DIRECTORY_MAX_ENTRIES; + break; + } + if (total !== null && offset >= total) break; + const page = await this.call(credentials, 'POST', path, { + body: { + filters: [], + view: { limit: DIRECTORY_PAGE_SIZE, offset }, + orders: [{ key: orderKey, type: 'ASC' }], + }, + ...(keys ? { keys } : {}), + }); + if (total === null && page.object?.summary !== null && page.object?.summary !== undefined) { + total = page.object.summary; + } + const parsed = parse(page.data); + items.push(...parsed); + if (page.data.length === 0) break; + if (total === null && page.data.length < DIRECTORY_PAGE_SIZE) break; + } + if (items.length > DIRECTORY_MAX_ENTRIES) { + items.length = DIRECTORY_MAX_ENTRIES; + truncated = true; + } + return { items, truncated }; + } + + // --- Zwischenspeicher --------------------------------------------------------- + + private cacheKey( + tenantId: string, + environment: DomainsEnvironment, + configVersion: number, + kind: 'contacts' | 'domains', + ): string { + return `${tenantId}|${environment}|${configVersion}|${kind}`; + } + + /** Verwirft die Listen der Organisation (nach Kontakt anlegen / Registrierung). */ + invalidate(tenantId: string): void { + this.contactCache.deleteByPrefix(`${tenantId}|`); + this.domainCache.deleteByPrefix(`${tenantId}|`); + } + + // --- Zuordnungen -------------------------------------------------------------- + + private async loadAssignments( + tenantId: string, + environment: DomainsEnvironment, + ): Promise> { + const tenantPrisma = forTenant(this.prisma, tenantId); + const rows = (await tenantPrisma.domainsContactAssignment.findMany({ + where: { tenantId, environment }, + })) as AssignmentRow[]; + const map = new Map(); + for (const row of rows) { + const id = toContactId(row.autodnsContactId); + if (id !== null) map.set(id, row.customerId); + } + return map; + } + + private async loadCustomers(tenantId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + return (await tenantPrisma.domainsCustomer.findMany({ + where: { tenantId }, + orderBy: { name: 'asc' }, + })) as CustomerRow[]; + } + + private async requireCustomer(tenantId: string, customerId: string): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + const customer = (await tenantPrisma.domainsCustomer.findFirst({ + where: { id: customerId, tenantId }, + })) as CustomerRow | null; + if (!customer) throw new NotFoundException(CUSTOMER_NOT_FOUND); + return customer; + } + + // --- Kunden ------------------------------------------------------------------- + + async listCustomers(tenantId: string): Promise { + const { environment } = await this.settings.getStatus(tenantId); + const [customers, assignments] = await Promise.all([ + this.loadCustomers(tenantId), + this.loadAssignments(tenantId, environment), + ]); + const counts = new Map(); + for (const customerId of assignments.values()) { + counts.set(customerId, (counts.get(customerId) ?? 0) + 1); + } + return customers.map((c) => ({ + id: c.id, + name: c.name, + isOwnCompany: c.isOwnCompany, + contactCount: counts.get(c.id) ?? 0, + })); + } + + private cleanCustomerName(dto: DomainsCustomerDto): string { + const name = (dto.name ?? '').trim(); + if (!name) { + throw new BadRequestException({ + code: 'customerNameRequired', + message: 'Bitte geben Sie einen Namen an.', + }); + } + return name; + } + + async createCustomer(tenantId: string, dto: DomainsCustomerDto): Promise { + const name = this.cleanCustomerName(dto); + const tenantPrisma = forTenant(this.prisma, tenantId); + + // Namenspruefung VOR dem Loesen der Markierung "eigene Firma", damit ein + // abgelehnter Name die Markierung eines anderen Kunden nicht kostet. + const existing = await tenantPrisma.domainsCustomer.findFirst({ where: { tenantId, name } }); + if (existing) throw new ConflictException(CUSTOMER_NAME_TAKEN); + + const isOwnCompany = dto.isOwnCompany === true; + if (isOwnCompany) { + await tenantPrisma.domainsCustomer.updateMany({ + where: { tenantId, isOwnCompany: true }, + data: { isOwnCompany: false }, + }); + } + try { + const created = (await tenantPrisma.domainsCustomer.create({ + data: { tenantId, name, isOwnCompany }, + })) as CustomerRow; + return { + id: created.id, + name: created.name, + isOwnCompany: created.isOwnCompany, + contactCount: 0, + }; + } catch (error) { + if (isUniqueViolation(error)) throw new ConflictException(CUSTOMER_NAME_TAKEN); + throw error; + } + } + + async updateCustomer( + tenantId: string, + id: string, + dto: DomainsCustomerDto, + ): Promise { + const name = this.cleanCustomerName(dto); + const tenantPrisma = forTenant(this.prisma, tenantId); + const current = await this.requireCustomer(tenantId, id); + + const isOwnCompany = dto.isOwnCompany === undefined ? current.isOwnCompany : dto.isOwnCompany; + if (isOwnCompany && !current.isOwnCompany) { + await tenantPrisma.domainsCustomer.updateMany({ + where: { tenantId, isOwnCompany: true, NOT: { id } }, + data: { isOwnCompany: false }, + }); + } + let count: number; + try { + ({ count } = await tenantPrisma.domainsCustomer.updateMany({ + where: { id, tenantId }, + data: { name, isOwnCompany }, + })); + } catch (error) { + if (isUniqueViolation(error)) throw new ConflictException(CUSTOMER_NAME_TAKEN); + throw error; + } + if (count === 0) throw new NotFoundException(CUSTOMER_NOT_FOUND); + const { environment } = await this.settings.getStatus(tenantId); + const assignments = await this.loadAssignments(tenantId, environment); + let contactCount = 0; + for (const customerId of assignments.values()) if (customerId === id) contactCount += 1; + return { id, name, isOwnCompany, contactCount }; + } + + async deleteCustomer(tenantId: string, id: string): Promise<{ deleted: true }> { + const tenantPrisma = forTenant(this.prisma, tenantId); + await this.requireCustomer(tenantId, id); + const inUse = await tenantPrisma.domainsContactAssignment.count({ + where: { tenantId, customerId: id }, + }); + if (inUse > 0) throw new ConflictException(CUSTOMER_IN_USE); + try { + const { count } = await tenantPrisma.domainsCustomer.deleteMany({ where: { id, tenantId } }); + if (count === 0) throw new NotFoundException(CUSTOMER_NOT_FOUND); + } catch (error) { + if (isForeignKeyViolation(error)) throw new ConflictException(CUSTOMER_IN_USE); + throw error; + } + return { deleted: true }; + } + + // --- Kontakte ----------------------------------------------------------------- + + private async loadRawContacts( + tenantId: string, + refresh: boolean, + ): Promise<{ + environment: DomainsEnvironment; + raw: RawList; + }> { + const { environment, credentials, configVersion } = + await this.settings.getActiveCredentials(tenantId); + const key = this.cacheKey(tenantId, environment, configVersion, 'contacts'); + if (!refresh) { + const hit = this.contactCache.get(key); + if (hit) return { environment, raw: hit }; + } + const { items, truncated } = await this.fetchAll( + credentials, + '/contact/_search', + 'lname', + parseContacts, + ); + const raw: RawList = { + items, + truncated, + fetchedAt: new Date().toISOString(), + }; + this.contactCache.set(key, raw); + return { environment, raw }; + } + + async listContacts( + tenantId: string, + opts: { refresh?: boolean } = {}, + ): Promise { + const { environment, raw } = await this.loadRawContacts(tenantId, opts.refresh === true); + const [customers, assignments] = await Promise.all([ + this.loadCustomers(tenantId), + this.loadAssignments(tenantId, environment), + ]); + const names = new Map(customers.map((c) => [c.id, c.name])); + return { + environment, + contacts: raw.items.map((contact) => { + const customerId = assignments.get(contact.id) ?? null; + return { + ...contact, + customerId, + customerName: customerId ? (names.get(customerId) ?? null) : null, + }; + }), + truncated: raw.truncated, + fetchedAt: raw.fetchedAt, + }; + } + + /** Fuer die Zusammenfassung einer Registrierung: Kontakte zu den gewaehlten Ids. */ + async findContactsByIds(tenantId: string, ids: number[]): Promise { + const wanted = new Set(ids); + const { contacts } = await this.listContacts(tenantId); + return contacts.filter((c) => wanted.has(c.id)); + } + + async createContact(tenantId: string, dto: CreateDomainsContactDto): Promise { + const organization = (dto.organization ?? '').trim(); + if (dto.type === 'ORG' && !organization) { + throw new BadRequestException({ + code: 'organizationRequired', + message: 'Bitte geben Sie die Organisation an.', + }); + } + const { environment, credentials } = await this.settings.getActiveCredentials(tenantId); + + // Unbekannter Kunde -> 404 BEVOR AutoDNS etwas anlegt. + let customer: CustomerRow | null = null; + if (dto.customerId) customer = await this.requireCustomer(tenantId, dto.customerId); + + // Der Kunde gehoert nur Tessera — er wird nie an AutoDNS geschickt. + const body: Record = { + type: dto.type, + fname: dto.fname.trim(), + lname: dto.lname.trim(), + address: dto.street.map((line) => line.trim()), + pcode: dto.pcode.trim(), + city: dto.city.trim(), + country: dto.country, + email: dto.email.trim(), + phone: dto.phone.trim().replace(/\s+/g, ' '), + }; + if (dto.type === 'ORG') body.organization = organization; + + const result = await this.call(credentials, 'POST', '/contact', { body }); + const first = result.data[0]; + const id = + first && typeof first === 'object' + ? toContactId((first as Record).id) + : null; + if (id === null) { + // Nicht raten: der Kontakt kann angelegt worden sein. + this.invalidate(tenantId); + throw new ConflictException({ + code: 'contactIdMissing', + message: + 'AutoDNS hat den Kontakt angenommen, aber keine Nummer geliefert. Bitte lesen Sie die Kontakte neu ein.', + }); + } + + if (customer) { + const tenantPrisma = forTenant(this.prisma, tenantId); + await tenantPrisma.domainsContactAssignment.upsert({ + where: { + tenantId_environment_autodnsContactId: { + tenantId, + environment, + autodnsContactId: String(id), + }, + }, + create: { tenantId, environment, autodnsContactId: String(id), customerId: customer.id }, + update: { customerId: customer.id }, + }); + } + this.invalidate(tenantId); + + const [parsed] = parseContacts([{ ...body, id }]); + return { + ...parsed, + customerId: customer?.id ?? null, + customerName: customer?.name ?? null, + }; + } + + async assignContacts( + tenantId: string, + dto: AssignDomainsContactsDto, + ): Promise<{ assigned: number; removed: number }> { + const ids = [...new Set(dto.contactIds)]; + if (ids.length > MAX_ASSIGN_CONTACTS) { + throw new BadRequestException({ + code: 'tooManyContacts', + message: `Es können höchstens ${MAX_ASSIGN_CONTACTS} Kontakte auf einmal zugeordnet werden.`, + }); + } + if (ids.length === 0) { + throw new BadRequestException({ + code: 'noContacts', + message: 'Bitte wählen Sie mindestens einen Kontakt aus.', + }); + } + const { environment } = await this.settings.getStatus(tenantId); + const tenantPrisma = forTenant(this.prisma, tenantId); + + if (dto.customerId === null) { + const { count } = await tenantPrisma.domainsContactAssignment.deleteMany({ + where: { tenantId, environment, autodnsContactId: { in: ids.map(String) } }, + }); + return { assigned: 0, removed: count }; + } + + const customer = await this.requireCustomer(tenantId, dto.customerId); + for (const id of ids) { + await tenantPrisma.domainsContactAssignment.upsert({ + where: { + tenantId_environment_autodnsContactId: { + tenantId, + environment, + autodnsContactId: String(id), + }, + }, + create: { tenantId, environment, autodnsContactId: String(id), customerId: customer.id }, + update: { customerId: customer.id }, + }); + } + return { assigned: ids.length, removed: 0 }; + } + + // --- Domains ------------------------------------------------------------------ + + async listDomains( + tenantId: string, + opts: { refresh?: boolean } = {}, + ): Promise { + const refresh = opts.refresh === true; + const { environment, credentials, configVersion } = + await this.settings.getActiveCredentials(tenantId); + const key = this.cacheKey(tenantId, environment, configVersion, 'domains'); + + let raw = refresh ? undefined : this.domainCache.get(key); + if (!raw) { + const { items, truncated } = await this.fetchAll( + credentials, + '/domain/_search', + 'name', + parseDomains, + ['expire', 'ownerc'], + ); + raw = { items, truncated, fetchedAt: new Date().toISOString() }; + this.domainCache.set(key, raw); + } + + const contacts = await this.listContacts(tenantId, { refresh }); + const byId = new Map(contacts.contacts.map((c) => [c.id, c])); + return { + environment, + domains: raw.items.map((d) => { + const owner = d.ownerContactId !== null ? byId.get(d.ownerContactId) : undefined; + return { + name: d.name, + expire: d.expire, + status: d.registryStatus, + cancelationPending: cancelationPending(d.cancelationStatus), + ownerContactId: d.ownerContactId, + ownerName: owner?.displayName ?? null, + customerId: owner?.customerId ?? null, + customerName: owner?.customerName ?? null, + }; + }), + truncated: raw.truncated, + fetchedAt: raw.fetchedAt, + }; + } +} diff --git a/apps/api/src/domains/domains.controller.spec.ts b/apps/api/src/domains/domains.controller.spec.ts index 5529d7b..bdc6c83 100644 --- a/apps/api/src/domains/domains.controller.spec.ts +++ b/apps/api/src/domains/domains.controller.spec.ts @@ -8,6 +8,31 @@ import { DomainsController } from './domains.controller'; const proto = DomainsController.prototype as any; const req = (tenantId?: string) => ({ tenantId }) as any; +const MANAGE_HANDLERS = [ + 'getSettings', + 'saveSettings', + 'testConnection', + 'createCustomer', + 'updateCustomer', + 'deleteCustomer', + 'createContact', + 'assignContacts', +]; +const USE_HANDLERS = ['getStatus', 'listCustomers', 'listContacts', 'listDomains']; + +function makeDirectory() { + return { + listCustomers: vi.fn(async (..._a: unknown[]) => []), + createCustomer: vi.fn(async (..._a: unknown[]) => ({})), + updateCustomer: vi.fn(async (..._a: unknown[]) => ({})), + deleteCustomer: vi.fn(async (..._a: unknown[]) => ({})), + listContacts: vi.fn(async (..._a: unknown[]) => ({})), + createContact: vi.fn(async (..._a: unknown[]) => ({})), + assignContacts: vi.fn(async (..._a: unknown[]) => ({})), + listDomains: vi.fn(async (..._a: unknown[]) => ({})), + }; +} + function makeSettings() { return { getStatus: vi.fn(async (..._a: unknown[]) => ({})), @@ -23,16 +48,18 @@ describe('DomainsController — Metadaten', () => { expect(Reflect.getMetadata(MODULE_SLUG_KEY, DomainsController)).toBe('domains'); }); - it('Einstellungen und Verbindungstest verlangen Verwalten, ohne Rollen-Decorator', () => { - for (const name of ['getSettings', 'saveSettings', 'testConnection']) { + it('Einstellungen, Verbindungstest, Kunden-/Kontakt-Schreibwege verlangen Verwalten, ohne Rollen-Decorator', () => { + for (const name of MANAGE_HANDLERS) { expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBe(true); expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined(); } }); - it('getStatus steht auf Benutzen-Ebene', () => { - expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.getStatus)).toBeUndefined(); - expect(Reflect.getMetadata(ROLES_KEY, proto.getStatus)).toBeUndefined(); + it('Lesewege stehen auf Benutzen-Ebene', () => { + for (const name of USE_HANDLERS) { + expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined(); + expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined(); + } }); it('Pfade und Methoden', () => { @@ -45,6 +72,14 @@ describe('DomainsController — Metadaten', () => { expect(route('getSettings')).toEqual([0, 'settings']); expect(route('saveSettings')).toEqual([2, 'settings']); expect(route('testConnection')).toEqual([1, 'connection-test']); + expect(route('listCustomers')).toEqual([0, 'customers']); + expect(route('createCustomer')).toEqual([1, 'customers']); + expect(route('listContacts')).toEqual([0, 'contacts']); + expect(route('createContact')).toEqual([1, 'contacts']); + expect(route('assignContacts')).toEqual([1, 'contacts/assign']); + expect(route('listDomains')).toEqual([0, 'domains']); + expect(route('updateCustomer')).toEqual([2, 'customers/:id']); + expect(route('deleteCustomer')).toEqual([3, 'customers/:id']); }); }); @@ -55,7 +90,7 @@ describe('DomainsController — Routen-Reihenfolge (statisch vor :id)', () => { ); const isIdRoute = (n: string) => String(Reflect.getMetadata('path', proto[n])).includes(':id'); const firstId = names.findIndex(isIdRoute); - if (firstId === -1) return; + expect(firstId, 'es gibt :id-Routen').toBeGreaterThan(-1); names.slice(firstId).forEach((n) => { expect(isIdRoute(n), `${n} steht nach einer :id-Route, ist aber statisch`).toBe(true); }); @@ -65,7 +100,8 @@ describe('DomainsController — Routen-Reihenfolge (statisch vor :id)', () => { describe('DomainsController — Verhalten', () => { it('reicht req.tenantId weiter', async () => { const settings = makeSettings(); - const c = new DomainsController(settings as any); + const directory = makeDirectory(); + const c = new DomainsController(settings as any, directory as any); await c.getStatus(req('t1')); await c.getSettings(req('t1')); await c.saveSettings(req('t1'), { demoUser: 'x' } as any); @@ -76,8 +112,42 @@ describe('DomainsController — Verhalten', () => { expect(settings.testConnection).toHaveBeenCalledWith('t1', 'DEMO'); }); + it('Kunden-, Kontakt- und Domainwege reichen req.tenantId weiter; refresh nur bei 1/true', async () => { + const settings = makeSettings(); + const directory = makeDirectory(); + const c = new DomainsController(settings as any, directory as any); + const id = '11111111-1111-4111-8111-111111111111'; + await c.listCustomers(req('t1')); + await c.createCustomer(req('t1'), { name: 'A' } as any); + await c.updateCustomer(req('t1'), id, { name: 'B' } as any); + await c.deleteCustomer(req('t1'), id); + await c.listContacts(req('t1'), '1'); + await c.listContacts(req('t1'), 'true'); + await c.listContacts(req('t1'), undefined); + await c.listContacts(req('t1'), '0'); + await c.createContact(req('t1'), { type: 'PERSON' } as any); + await c.assignContacts(req('t1'), { contactIds: [1], customerId: null }); + await c.listDomains(req('t1'), 'true'); + expect(directory.listCustomers).toHaveBeenCalledWith('t1'); + expect(directory.createCustomer).toHaveBeenCalledWith('t1', { name: 'A' }); + expect(directory.updateCustomer).toHaveBeenCalledWith('t1', id, { name: 'B' }); + expect(directory.deleteCustomer).toHaveBeenCalledWith('t1', id); + expect(directory.listContacts.mock.calls.map((x) => x[1])).toEqual([ + { refresh: true }, + { refresh: true }, + { refresh: false }, + { refresh: false }, + ]); + expect(directory.createContact).toHaveBeenCalledWith('t1', { type: 'PERSON' }); + expect(directory.assignContacts).toHaveBeenCalledWith('t1', { + contactIds: [1], + customerId: null, + }); + expect(directory.listDomains).toHaveBeenCalledWith('t1', { refresh: true }); + }); + it('ohne Mandantenkontext 403', async () => { - const c = new DomainsController(makeSettings() as any); + const c = new DomainsController(makeSettings() as any, makeDirectory() as any); await expect(c.getStatus(req(undefined))).rejects.toBeInstanceOf(ForbiddenException); }); }); diff --git a/apps/api/src/domains/domains.controller.ts b/apps/api/src/domains/domains.controller.ts index 7fd4665..a1bc970 100644 --- a/apps/api/src/domains/domains.controller.ts +++ b/apps/api/src/domains/domains.controller.ts @@ -1,9 +1,29 @@ -import { Body, Controller, ForbiddenException, Get, Post, Put, Req } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + ParseUUIDPipe, + Post, + Put, + Query, + Req, +} from '@nestjs/common'; import type { AuthenticatedRequest } from '../auth/types/auth-user'; import { ModuleManage, UseModule } from '../module-registry/module.guard'; +import { DomainsDirectoryService } from './domains-directory.service'; import { DomainsSettingsService } from './domains-settings.service'; +import { AssignDomainsContactsDto, CreateDomainsContactDto } from './dto/domains-contact.dto'; +import { DomainsCustomerDto } from './dto/domains-customer.dto'; import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains-settings.dto'; +/** `?refresh=1` oder `?refresh=true` umgeht den Zwischenspeicher. */ +function isTruthyFlag(value: string | undefined): boolean { + return value === '1' || value === 'true'; +} + /** * `@UseModule('domains')` auf Klassenebene — Aktivierung UND Freigabe. * `tenantId` kommt ausschliesslich aus `req.tenantId`, nie aus Body oder Query. @@ -28,7 +48,10 @@ import { SaveDomainsSettingsDto, TestDomainsConnectionDto } from './dto/domains- @Controller('modules/domains') @UseModule('domains') export class DomainsController { - constructor(private readonly settings: DomainsSettingsService) {} + constructor( + private readonly settings: DomainsSettingsService, + private readonly directory: DomainsDirectoryService, + ) {} private requireTenantId(req: AuthenticatedRequest): string { const tenantId = req.tenantId; @@ -60,4 +83,62 @@ export class DomainsController { async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: TestDomainsConnectionDto) { return this.settings.testConnection(this.requireTenantId(req), dto.environment); } + + // --- Kunden, Kontakte, Domains (Aufgabe 2) ----------------------------------- + // Alle statischen Routen vor den `:id`-Routen am Ende der Klasse. + + @Get('customers') + async listCustomers(@Req() req: AuthenticatedRequest) { + return this.directory.listCustomers(this.requireTenantId(req)); + } + + @Post('customers') + @ModuleManage('domains') + async createCustomer(@Req() req: AuthenticatedRequest, @Body() dto: DomainsCustomerDto) { + return this.directory.createCustomer(this.requireTenantId(req), dto); + } + + @Get('contacts') + async listContacts(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) { + return this.directory.listContacts(this.requireTenantId(req), { + refresh: isTruthyFlag(refresh), + }); + } + + @Post('contacts') + @ModuleManage('domains') + async createContact(@Req() req: AuthenticatedRequest, @Body() dto: CreateDomainsContactDto) { + return this.directory.createContact(this.requireTenantId(req), dto); + } + + @Post('contacts/assign') + @ModuleManage('domains') + async assignContacts(@Req() req: AuthenticatedRequest, @Body() dto: AssignDomainsContactsDto) { + return this.directory.assignContacts(this.requireTenantId(req), dto); + } + + @Get('domains') + async listDomains(@Req() req: AuthenticatedRequest, @Query('refresh') refresh?: string) { + return this.directory.listDomains(this.requireTenantId(req), { + refresh: isTruthyFlag(refresh), + }); + } + + // --- Routen mit :id (immer ganz am Ende) ------------------------------------- + + @Put('customers/:id') + @ModuleManage('domains') + async updateCustomer( + @Req() req: AuthenticatedRequest, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DomainsCustomerDto, + ) { + return this.directory.updateCustomer(this.requireTenantId(req), id, dto); + } + + @Delete('customers/:id') + @ModuleManage('domains') + async deleteCustomer(@Req() req: AuthenticatedRequest, @Param('id', ParseUUIDPipe) id: string) { + return this.directory.deleteCustomer(this.requireTenantId(req), id); + } } diff --git a/apps/api/src/domains/domains.module.ts b/apps/api/src/domains/domains.module.ts index aef55c8..45d9095 100644 --- a/apps/api/src/domains/domains.module.ts +++ b/apps/api/src/domains/domains.module.ts @@ -3,6 +3,7 @@ import { ModuleRegistryModule } from '../module-registry/module-registry.module' import { ModuleRegistryService } from '../module-registry/module-registry.service'; import { DomainsController } from './domains.controller'; import { seedDomainsModule } from './domains.seed'; +import { DomainsDirectoryService } from './domains-directory.service'; import { DomainsSettingsService } from './domains-settings.service'; /** @@ -13,7 +14,7 @@ import { DomainsSettingsService } from './domains-settings.service'; @Module({ imports: [ModuleRegistryModule], controllers: [DomainsController], - providers: [DomainsSettingsService], + providers: [DomainsSettingsService, DomainsDirectoryService], }) export class DomainsModule implements OnModuleInit { private readonly logger = new Logger(DomainsModule.name); diff --git a/apps/api/src/domains/dto/domains-contact.dto.ts b/apps/api/src/domains/dto/domains-contact.dto.ts new file mode 100644 index 0000000..01b6ecc --- /dev/null +++ b/apps/api/src/domains/dto/domains-contact.dto.ts @@ -0,0 +1,86 @@ +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsEmail, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, + MaxLength, + Min, + ValidateIf, +} from 'class-validator'; + +/** Neuer AutoDNS-Kontakt (quick-261008-dts, L-03). Person oder Organisation. */ +export class CreateDomainsContactDto { + @IsIn(['PERSON', 'ORG']) + type!: 'PERSON' | 'ORG'; + + @IsOptional() + @IsString() + @MaxLength(120) + organization?: string; + + @IsString() + @IsNotEmpty() + @MaxLength(80) + fname!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(80) + lname!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(3) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @MaxLength(100, { each: true }) + street!: string[]; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + pcode!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(80) + city!: string; + + @Matches(/^[A-Z]{2}$/) + country!: string; + + @IsEmail() + @MaxLength(200) + email!: string; + + @Matches(/^\+[0-9][0-9 .\-/]{5,30}$/) + phone!: string; + + @IsOptional() + @IsUUID() + customerId?: string; +} + +/** Ein oder mehrere Kontakte einem Kunden zuordnen (oder die Zuordnung loesen). */ +export class AssignDomainsContactsDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(500) + @Type(() => Number) + @IsInt({ each: true }) + @Min(1, { each: true }) + contactIds!: number[]; + + /** `null` loest die Zuordnung. */ + @ValidateIf((_o, value) => value !== null) + @IsUUID() + customerId!: string | null; +} diff --git a/apps/api/src/domains/dto/domains-customer.dto.ts b/apps/api/src/domains/dto/domains-customer.dto.ts new file mode 100644 index 0000000..9f3a6b7 --- /dev/null +++ b/apps/api/src/domains/dto/domains-customer.dto.ts @@ -0,0 +1,13 @@ +import { IsBoolean, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +/** Anlegen und Aendern eines Kunden (quick-261008-dts, D-H). */ +export class DomainsCustomerDto { + @IsString() + @IsNotEmpty() + @MaxLength(120) + name!: string; + + @IsOptional() + @IsBoolean() + isOwnCompany?: boolean; +} 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 48d6800..29492ec 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -85,14 +85,25 @@ describe('Umgestellte Handler (Verwalten)', () => { expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); }); - it.each(['getSettings', 'saveSettings', 'testConnection'])( - 'DomainsController.%s verlangt Verwalten für domains (quick-261008-dts)', - (name) => { - expectManage(DomainsController, name, 'domains'); - }, - ); + it.each([ + 'getSettings', + 'saveSettings', + 'testConnection', + 'createCustomer', + 'updateCustomer', + 'deleteCustomer', + 'createContact', + 'assignContacts', + ])('DomainsController.%s verlangt Verwalten für domains (quick-261008-dts)', (name) => { + expectManage(DomainsController, name, 'domains'); + }); - it.each(['getStatus'])('DomainsController.%s bleibt auf Benutzen-Ebene', (name) => { + it.each([ + 'getStatus', + 'listCustomers', + 'listContacts', + 'listDomains', + ])('DomainsController.%s bleibt auf Benutzen-Ebene', (name) => { const fn = handler(DomainsController, name); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); diff --git a/apps/web/src/app/(portal)/modules/domains/components/ContactForm.test.tsx b/apps/web/src/app/(portal)/modules/domains/components/ContactForm.test.tsx new file mode 100644 index 0000000..9c954fb --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/ContactForm.test.tsx @@ -0,0 +1,210 @@ +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 DomainsCustomer, DomainsRequestError } from '@/lib/domains-api'; +import de from '@/messages/de.json'; +import { ContactForm, EMPTY_CONTACT, toCreateInput, validateContact } from './ContactForm'; + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +const mockCreateContact = vi.fn(); +vi.mock('@/lib/domains-api', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createContact: (...args: unknown[]) => mockCreateContact(...args) }; +}); + +const CUSTOMERS: DomainsCustomer[] = [ + { id: 'c-a', name: 'Alpha GmbH', isOwnCompany: false, contactCount: 0 }, +]; + +const VALID = { + ...EMPTY_CONTACT, + fname: 'Erika', + lname: 'Muster', + street: 'Musterstraße 1', + pcode: '12345', + city: 'Berlin', + email: 'erika@example.com', + phone: '+49 30 123456', +}; + +describe('validateContact', () => { + it('Person: alle Pflichtfelder, Organisation nicht', () => { + expect(Object.keys(validateContact(EMPTY_CONTACT)).sort()).toEqual( + ['city', 'email', 'fname', 'lname', 'pcode', 'phone', 'street'].sort(), + ); + expect(validateContact(VALID)).toEqual({}); + }); + + it('Organisation verlangt zusaetzlich die Organisation', () => { + expect(validateContact({ ...VALID, type: 'ORG' })).toEqual({ organization: 'required' }); + expect(validateContact({ ...VALID, type: 'ORG', organization: 'Muster GmbH' })).toEqual({}); + }); + + it('Telefon muss mit + beginnen, die E-Mail muss gueltig sein', () => { + expect(validateContact({ ...VALID, phone: '030 123456' })).toEqual({ phone: 'phone' }); + expect(validateContact({ ...VALID, email: 'keine-adresse' })).toEqual({ email: 'email' }); + expect(validateContact({ ...VALID, phone: '+49' })).toEqual({ phone: 'phone' }); + }); +}); + +describe('toCreateInput', () => { + it('schneidet Leerraum ab, laesst leere Zeilen weg, sendet den Kunden nur wenn gewaehlt', () => { + const person = toCreateInput({ + ...VALID, + fname: ' Erika ', + street2: ' ', + phone: ' +49 30 123456 ', + organization: 'wird bei Person ignoriert', + }); + expect(person).toEqual({ + type: 'PERSON', + fname: 'Erika', + lname: 'Muster', + street: ['Musterstraße 1'], + pcode: '12345', + city: 'Berlin', + country: 'DE', + email: 'erika@example.com', + phone: '+49 30 123456', + }); + expect( + toCreateInput({ + ...VALID, + type: 'ORG', + organization: ' Muster GmbH ', + street2: 'Hof', + customerId: 'c-a', + }), + ).toMatchObject({ + type: 'ORG', + organization: 'Muster GmbH', + street: ['Musterstraße 1', 'Hof'], + customerId: 'c-a', + }); + }); +}); + +function fill(id: string, value: string) { + fireEvent.change(document.getElementById(id) as HTMLInputElement, { target: { value } }); +} + +function fillValid() { + fill('contact-fname', 'Erika'); + fill('contact-lname', 'Muster'); + fill('contact-street', 'Musterstraße 1'); + fill('contact-pcode', '12345'); + fill('contact-city', 'Berlin'); + fill('contact-phone', '+49 30 123456'); + fill('contact-email', 'erika@example.com'); +} + +beforeEach(() => { + mockCreateContact.mockReset(); +}); +afterEach(() => cleanup()); + +describe('ContactForm', () => { + it('zeigt Pflichtfehler und sendet nichts, solange etwas fehlt', () => { + const onCreated = vi.fn(); + render( undefined} />); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + expect(mockCreateContact).not.toHaveBeenCalled(); + expect(screen.getAllByText('Bitte füllen Sie dieses Feld aus.').length).toBeGreaterThan(3); + expect(screen.getByText('Internationale Schreibweise, z. B. +49 30 123456')).toBeTruthy(); + }); + + it('ungueltige E-Mail blockiert das Absenden', () => { + render( undefined} />); + fillValid(); + fill('contact-email', 'ohne-at'); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + expect(mockCreateContact).not.toHaveBeenCalled(); + expect(screen.getByText('Bitte geben Sie eine gültige E-Mail-Adresse an.')).toBeTruthy(); + }); + + it('Organisation ist erst bei "Firma oder Organisation" ein Feld und Pflicht', () => { + render( undefined} />); + expect(document.getElementById('contact-organization')).toBeNull(); + fireEvent.click(screen.getByRole('radio', { name: 'Firma oder Organisation' })); + expect(document.getElementById('contact-organization')).not.toBeNull(); + fillValid(); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + expect(mockCreateContact).not.toHaveBeenCalled(); + }); + + it('ruft createContact einmal mit den getrimmten Werten und dem Kunden auf', async () => { + const onCreated = vi.fn(); + mockCreateContact.mockResolvedValue({ id: 4711 }); + render( undefined} />); + fillValid(); + fill('contact-fname', ' Erika '); + fireEvent.change(document.getElementById('contact-customer') as HTMLSelectElement, { + target: { value: 'c-a' }, + }); + const submit = screen.getByRole('button', { name: 'Speichern' }); + fireEvent.click(submit); + fireEvent.click(submit); + await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1)); + expect(mockCreateContact).toHaveBeenCalledTimes(1); + expect(mockCreateContact).toHaveBeenCalledWith({ + type: 'PERSON', + fname: 'Erika', + lname: 'Muster', + street: ['Musterstraße 1'], + pcode: '12345', + city: 'Berlin', + country: 'DE', + email: 'erika@example.com', + phone: '+49 30 123456', + customerId: 'c-a', + }); + }); + + it('sperrt den Knopf waehrend des Speicherns', async () => { + let resolve: (v: unknown) => void = () => undefined; + mockCreateContact.mockImplementation( + () => + new Promise((r) => { + resolve = r; + }), + ); + render( undefined} />); + fillValid(); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + const busy = await screen.findByRole('button', { name: 'Speichern …' }); + expect((busy as HTMLButtonElement).disabled).toBe(true); + resolve({ id: 1 }); + }); + + it('zeigt den Fehlertext der API im Formular', async () => { + mockCreateContact.mockRejectedValue( + new DomainsRequestError(502, 'autodnsError', 'AutoDNS meldet: Ungültige Postleitzahl.'), + ); + const onCreated = vi.fn(); + render( undefined} />); + fillValid(); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + expect(await screen.findByText('AutoDNS meldet: Ungültige Postleitzahl.')).toBeTruthy(); + expect(onCreated).not.toHaveBeenCalled(); + expect((screen.getByRole('button', { name: 'Speichern' }) as HTMLButtonElement).disabled).toBe( + false, + ); + }); + + it('Land: Deutschland ist vorgewaehlt, Namen kommen aus der Sprache', () => { + render( undefined} />); + const select = document.getElementById('contact-country') as HTMLSelectElement; + expect(select.value).toBe('DE'); + const labels = Array.from(select.options).map((o) => o.textContent); + expect(labels).toContain('Deutschland'); + expect(labels).toContain('Österreich'); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx b/apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx new file mode 100644 index 0000000..396960f --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx @@ -0,0 +1,317 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { type FormEvent, useMemo, useRef, useState } from 'react'; +import { SettingsSection } from '@/components/control-center/settings-section'; +import { INPUT_CLASS, PRIMARY_BUTTON, SECONDARY_BUTTON } from '@/components/domains/ui-classes'; +import { + type CreateDomainsContactInput, + createContact, + type DomainsContact, + type DomainsContactType, + type DomainsCustomer, + DomainsRequestError, +} from '@/lib/domains-api'; + +/** Laender zur Auswahl: DACH, alle EU-Staaten, GB, NO, US. */ +export const CONTACT_COUNTRIES = [ + 'DE', + 'AT', + 'CH', + 'BE', + 'BG', + 'HR', + 'CY', + 'CZ', + 'DK', + 'EE', + 'FI', + 'FR', + 'GR', + 'HU', + 'IE', + 'IT', + 'LV', + 'LT', + 'LU', + 'MT', + 'NL', + 'PL', + 'PT', + 'RO', + 'SK', + 'SI', + 'ES', + 'SE', + 'GB', + 'NO', + 'US', +] as const; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +/** Gleiche Regel wie in der API (`CreateDomainsContactDto`). */ +const PHONE_PATTERN = /^\+[0-9][0-9 .\-/]{5,30}$/; + +export interface ContactFormValues { + type: DomainsContactType; + organization: string; + fname: string; + lname: string; + street: string; + street2: string; + pcode: string; + city: string; + country: string; + phone: string; + email: string; + customerId: string; +} + +export type ContactFormField = keyof ContactFormValues; +export type ContactFormErrors = Partial>; + +export const EMPTY_CONTACT: ContactFormValues = { + type: 'PERSON', + organization: '', + fname: '', + lname: '', + street: '', + street2: '', + pcode: '', + city: '', + country: 'DE', + phone: '', + email: '', + customerId: '', +}; + +/** Reine Pruefung (ohne React), damit sie einzeln testbar bleibt. */ +export function validateContact(values: ContactFormValues): ContactFormErrors { + const errors: ContactFormErrors = {}; + const required: ContactFormField[] = [ + 'fname', + 'lname', + 'street', + 'pcode', + 'city', + 'country', + 'email', + 'phone', + ]; + if (values.type === 'ORG') required.push('organization'); + for (const field of required) { + if (values[field].trim() === '') errors[field] = 'required'; + } + if (!errors.email && !EMAIL_PATTERN.test(values.email.trim())) errors.email = 'email'; + if (!errors.phone && !PHONE_PATTERN.test(values.phone.trim().replace(/\s+/g, ' '))) { + errors.phone = 'phone'; + } + return errors; +} + +export function toCreateInput(values: ContactFormValues): CreateDomainsContactInput { + const street = [values.street.trim(), values.street2.trim()].filter((line) => line !== ''); + return { + type: values.type, + ...(values.type === 'ORG' ? { organization: values.organization.trim() } : {}), + fname: values.fname.trim(), + lname: values.lname.trim(), + street, + pcode: values.pcode.trim(), + city: values.city.trim(), + country: values.country, + email: values.email.trim(), + phone: values.phone.trim().replace(/\s+/g, ' '), + ...(values.customerId ? { customerId: values.customerId } : {}), + }; +} + +/** + * Formular "Neuer Kontakt" (L-03): legt einen Person- oder Organisations-Kontakt + * bei AutoDNS an und ordnet ihn optional gleich einem Kunden zu. Der Kunde + * geht nie an AutoDNS — er wird nur in Tessera gespeichert. + */ +export function ContactForm({ + customers, + onCreated, + onCancel, +}: { + customers: DomainsCustomer[]; + onCreated: (contact: DomainsContact) => void; + onCancel: () => void; +}) { + const t = useTranslations('domains.contactForm'); + const tc = useTranslations('domains.common'); + const locale = useLocale(); + const [values, setValues] = useState(EMPTY_CONTACT); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [apiError, setApiError] = useState(null); + // Sperre gegen einen zweiten Aufruf noch vor dem naechsten Render. + const submitting = useRef(false); + + const countries = useMemo(() => { + const names = new Intl.DisplayNames([locale], { type: 'region' }); + return CONTACT_COUNTRIES.map((code) => ({ code, label: names.of(code) ?? code })).sort((a, b) => + a.label.localeCompare(b.label, locale), + ); + }, [locale]); + + const set = (field: K, value: ContactFormValues[K]) => { + setValues((prev) => ({ ...prev, [field]: value })); + setErrors((prev) => { + if (!prev[field]) return prev; + const { [field]: _removed, ...rest } = prev; + return rest; + }); + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting.current) return; + const found = validateContact(values); + setErrors(found); + if (Object.keys(found).length > 0) return; + submitting.current = true; + setSaving(true); + setApiError(null); + try { + const created = await createContact(toCreateInput(values)); + onCreated(created); + } catch (e) { + setApiError(e instanceof DomainsRequestError ? e.message : tc('requestFailed')); + } finally { + submitting.current = false; + setSaving(false); + } + }; + + const errorText = (field: ContactFormField) => { + const kind = errors[field]; + return kind ? t(`errors.${kind}`) : null; + }; + + const field = ( + name: ContactFormField, + label: string, + opts: { type?: string; autoComplete?: string; hint?: string; optional?: boolean } = {}, + ) => ( +
+ + set(name, e.target.value as ContactFormValues[typeof name])} + /> + {opts.hint &&

{opts.hint}

} + {errors[name] && ( +

+ {errorText(name)} +

+ )} +
+ ); + + return ( +
+ + {apiError && ( +

+ {apiError} +

+ )} + + + + } + > +
+
+ {t('type')} + {(['PERSON', 'ORG'] as const).map((type) => ( + + ))} +
+ + {values.type === 'ORG' && field('organization', t('organization'))} + +
+ {field('fname', t('fname'), { autoComplete: 'given-name' })} + {field('lname', t('lname'), { autoComplete: 'family-name' })} +
+ {field('street', t('street'), { autoComplete: 'address-line1' })} + {field('street2', t('street2'), { autoComplete: 'address-line2' })} +
+ {field('pcode', t('pcode'), { autoComplete: 'postal-code' })} +
+ {field('city', t('city'), { autoComplete: 'address-level2' })} +
+
+
+ + +
+
+ {field('phone', t('phone'), { type: 'tel', autoComplete: 'tel', hint: t('phoneHint') })} + {field('email', t('email'), { type: 'email', autoComplete: 'email' })} +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx b/apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx new file mode 100644 index 0000000..0a804d8 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx @@ -0,0 +1,340 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SettingsSection } from '@/components/control-center/settings-section'; +import { + ALL_CUSTOMERS, + filterByCustomer, + groupByCustomer, + matchesText, + UNASSIGNED, +} from '@/components/domains/group-by-customer'; +import { + INPUT_CLASS, + PRIMARY_BUTTON, + SECONDARY_BUTTON, + TABLE_CELL, + TABLE_HEAD_CELL, + TABLE_HEAD_CLASS, +} from '@/components/domains/ui-classes'; +import { + assignContacts, + type DomainsContact, + type DomainsContactList, + type DomainsCustomer, + DomainsRequestError, + listContacts, +} from '@/lib/domains-api'; +import { ContactForm } from './ContactForm'; + +/** Wert im Zuordnungs-Auswahlfeld fuer "Zuordnung entfernen". */ +const REMOVE_ASSIGNMENT = '__remove__'; + +function ContactTable({ + rows, + canManage, + selected, + onToggle, +}: { + rows: DomainsContact[]; + canManage: boolean; + selected: Set; + onToggle: (id: number) => void; +}) { + const t = useTranslations('domains.contactList'); + const tc = useTranslations('domains.common'); + return ( + + + + {canManage && + + + + + + + + {rows.map((contact) => { + const person = [contact.fname, contact.lname].filter(Boolean).join(' '); + return ( + + {canManage && ( + + )} + + + + + + + ); + })} + +
} + {t('columns.name')}{t('columns.organization')}{t('columns.city')}{t('columns.email')}{t('columns.customer')}
+ onToggle(contact.id)} + /> + + {person || contact.displayName} + {contact.organization ?? '–'}{contact.city ?? '–'}{contact.email ?? '–'}{contact.customerName ?? tc('unassigned')}
+ ); +} + +/** + * Reiter "Kontakte" (L-03, D-G): die Kontakte des aktiven AutoDNS-Systems, + * live gelesen. Bereits vorhandene AutoDNS-Kontakte erscheinen hier von selbst + * und lassen sich einem Kunden zuordnen. Anlegen und Zuordnen nur mit + * "Verwalten"; bindend ist allein die API. + */ +export function ContactsTab({ + customers, + canManage, + onCustomersChanged, +}: { + customers: DomainsCustomer[]; + canManage: boolean; + onCustomersChanged: () => void; +}) { + const t = useTranslations('domains.contactList'); + const tc = useTranslations('domains.common'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState(ALL_CUSTOMERS); + const [grouped, setGrouped] = useState(true); + const [selected, setSelected] = useState>(new Set()); + const [target, setTarget] = useState(''); + const [assigning, setAssigning] = useState(false); + const [notice, setNotice] = useState(null); + const [creating, setCreating] = useState(false); + + const load = useCallback( + async (refresh: boolean) => { + setLoading(true); + setError(null); + try { + setData(await listContacts({ refresh })); + } catch (e) { + setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed')); + } finally { + setLoading(false); + } + }, + [tc], + ); + + useEffect(() => { + void load(false); + }, [load]); + + const visible = useMemo(() => { + if (!data) return []; + return filterByCustomer(data.contacts, filter).filter((c) => + matchesText(c, search, [ + (x) => x.displayName, + (x) => x.organization, + (x) => x.city, + (x) => x.email, + (x) => x.customerName, + ]), + ); + }, [data, filter, search]); + + const groups = useMemo( + () => (grouped ? groupByCustomer(visible, customers) : []), + [grouped, visible, customers], + ); + + const toggle = (id: number) => + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const allVisibleSelected = visible.length > 0 && visible.every((c) => selected.has(c.id)); + const toggleAllVisible = () => + setSelected(allVisibleSelected ? new Set() : new Set(visible.map((c) => c.id))); + + const assign = async () => { + if (selected.size === 0 || !target) return; + setAssigning(true); + setNotice(null); + setError(null); + try { + await assignContacts([...selected], target === REMOVE_ASSIGNMENT ? null : target); + setSelected(new Set()); + setTarget(''); + setNotice(t('assigned')); + await load(false); + onCustomersChanged(); + } catch (e) { + setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed')); + } finally { + setAssigning(false); + } + }; + + const created = async () => { + setCreating(false); + setNotice(t('created')); + await load(false); + onCustomersChanged(); + }; + + return ( +
+

{t('intro')}

+ +
+
+ + setSearch(e.target.value)} + /> +
+
+ + +
+ + + {canManage && !creating && ( + + )} +
+ + {canManage && creating && ( + void created()} + onCancel={() => setCreating(false)} + /> + )} + + {canManage && ( +
+ + + {t('selectedCount', { count: selected.size })} + + + + +
+ )} + + {notice && ( +

+ {notice} +

+ )} + {error && ( +

+ {error} +

+ )} + {loading && !data &&

{tc('loading')}

} + {data?.truncated &&

{tc('truncated')}

} + + {data && visible.length === 0 && ( +

+ {data.contacts.length === 0 ? t('empty') : t('emptyFiltered')} +

+ )} + + {grouped + ? groups.map((group) => ( + + + + )) + : visible.length > 0 && ( + + + + )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx b/apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx new file mode 100644 index 0000000..10a1706 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { SettingsSection } from '@/components/control-center/settings-section'; +import { INPUT_CLASS, PRIMARY_BUTTON, SECONDARY_BUTTON } from '@/components/domains/ui-classes'; +import { + createCustomer, + type DomainsCustomer, + DomainsRequestError, + deleteCustomer, + updateCustomer, +} from '@/lib/domains-api'; + +/** + * Reiter "Kunden" (D-H): Kunden, denen AutoDNS-Kontakte zugeordnet werden. + * Die eigene Firma ist ein Kunde mit Markierung (hoechstens einer). Anlegen, + * Aendern und Loeschen nur mit "Verwalten"; ein Kunde mit Zuordnungen laesst + * sich nicht loeschen (die API antwortet dann 409). + */ +export function CustomersTab({ + customers, + canManage, + onChanged, +}: { + customers: DomainsCustomer[]; + canManage: boolean; + onChanged: () => void; +}) { + const t = useTranslations('domains.customers'); + const tc = useTranslations('domains.common'); + const [newName, setNewName] = useState(''); + const [newOwn, setNewOwn] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [editOwn, setEditOwn] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const run = async (action: () => Promise, after?: () => void) => { + setBusy(true); + setError(null); + try { + await action(); + after?.(); + onChanged(); + } catch (e) { + setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed')); + } finally { + setBusy(false); + } + }; + + const create = () => + run( + () => createCustomer({ name: newName.trim(), isOwnCompany: newOwn }), + () => { + setNewName(''); + setNewOwn(false); + }, + ); + + const startEdit = (customer: DomainsCustomer) => { + setEditingId(customer.id); + setEditName(customer.name); + setEditOwn(customer.isOwnCompany); + setDeletingId(null); + setError(null); + }; + + const saveEdit = (id: string) => + run( + () => updateCustomer(id, { name: editName.trim(), isOwnCompany: editOwn }), + () => setEditingId(null), + ); + + const remove = (id: string) => + run( + () => deleteCustomer(id), + () => setDeletingId(null), + ); + + return ( +
+

{t('intro')}

+ + {canManage && ( + void create()} + > + {t('add')} + + } + > +
+
+ + setNewName(e.target.value)} + /> +
+ +
+
+ )} + + {error && ( +

+ {error} +

+ )} + + {customers.length === 0 ? ( +

{t('empty')}

+ ) : ( + +
    + {customers.map((customer) => ( +
  • + {editingId === customer.id ? ( +
    +
    + + setEditName(e.target.value)} + /> +
    + +
    + + +
    +
    + ) : ( +
    + {customer.name} + {customer.isOwnCompany && ( + + {t('ownCompany')} + + )} + + {t('contactCount', { count: customer.contactCount })} + + {canManage && ( + + + + + )} +
    + )} + {canManage && deletingId === customer.id && ( +
    +

    + {t('confirmDelete', { name: customer.name })} +

    +
    + + +
    +
    + )} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx b/apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx new file mode 100644 index 0000000..7aef0bb --- /dev/null +++ b/apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { SettingsSection } from '@/components/control-center/settings-section'; +import { + ALL_CUSTOMERS, + filterByCustomer, + groupByCustomer, + matchesText, + UNASSIGNED, +} from '@/components/domains/group-by-customer'; +import { + INPUT_CLASS, + SECONDARY_BUTTON, + TABLE_CELL, + TABLE_HEAD_CELL, + TABLE_HEAD_CLASS, +} from '@/components/domains/ui-classes'; +import { + type DomainsCustomer, + type DomainsDomain, + type DomainsDomainList, + DomainsRequestError, + listDomains, +} from '@/lib/domains-api'; + +/** Ablaufdatum als Datum (dd.mm.yyyy in Deutsch), unabhaengig von der Zeitzone des Browsers. */ +function formatDate(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', + timeZone: 'Europe/Berlin', + }).format(date); +} + +function DomainTable({ rows }: { rows: DomainsDomain[] }) { + const t = useTranslations('domains.domainList'); + const locale = useLocale(); + const statusLabel = (status: string | null) => { + if (!status) return '–'; + return t.has(`status.${status}`) ? t(`status.${status}`) : status; + }; + return ( + + + + + + + + + + + + {rows.map((domain) => ( + + + + + + + + ))} + +
{t('columns.domain')}{t('columns.customer')}{t('columns.owner')}{t('columns.expires')}{t('columns.status')}
{domain.name}{domain.customerName ?? t('unassigned')}{domain.ownerName ?? '–'}{formatDate(domain.expire, locale)} + {statusLabel(domain.status)} + {domain.cancelationPending && ( + + {t('cancelPending')} + + )} +
+ ); +} + +/** + * Reiter "Domains" (L-05): alle Domains des aktiven AutoDNS-Systems mit Kunde + * (ueber den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status — filterbar und + * nach Kunde gruppierbar. Nur lesend; sichtbar fuer alle mit "Benutzen". + */ +export function DomainsTab({ customers }: { customers: DomainsCustomer[] }) { + const t = useTranslations('domains.domainList'); + const tc = useTranslations('domains.common'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState(ALL_CUSTOMERS); + const [grouped, setGrouped] = useState(true); + + const load = useCallback( + async (refresh: boolean) => { + setLoading(true); + setError(null); + try { + setData(await listDomains({ refresh })); + } catch (e) { + setError(e instanceof DomainsRequestError ? e.message : tc('requestFailed')); + } finally { + setLoading(false); + } + }, + [tc], + ); + + useEffect(() => { + void load(false); + }, [load]); + + const visible = useMemo(() => { + if (!data) return []; + return filterByCustomer(data.domains, filter).filter((d) => + matchesText(d, search, [(x) => x.name, (x) => x.ownerName, (x) => x.customerName]), + ); + }, [data, filter, search]); + + const groups = useMemo( + () => (grouped ? groupByCustomer(visible, customers) : []), + [grouped, visible, customers], + ); + + return ( +
+
+
+ + setSearch(e.target.value)} + /> +
+
+ + +
+ + +
+ + {error && ( +

+ {error} +

+ )} + {loading && !data &&

{tc('loading')}

} + {data?.truncated &&

{tc('truncated')}

} + + {data && visible.length === 0 && ( +

+ {data.domains.length === 0 ? t('empty') : t('emptyFiltered')} +

+ )} + + {grouped + ? groups.map((group) => ( + + + + )) + : visible.length > 0 && ( + + + + )} +
+ ); +} 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 cb72680..aca5067 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 @@ -9,7 +9,13 @@ import { import { NextIntlClientProvider } from 'next-intl'; import type { ReactElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { DomainsSettings, DomainsStatus } from '@/lib/domains-api'; +import type { + DomainsContactList, + DomainsCustomer, + DomainsDomainList, + DomainsSettings, + DomainsStatus, +} from '@/lib/domains-api'; import de from '@/messages/de.json'; import DomainsPage from './page'; @@ -25,6 +31,12 @@ const mockGetStatus = vi.fn(); const mockGetSettings = vi.fn(); const mockSaveSettings = vi.fn(); const mockTestConnection = vi.fn(); +const mockListCustomers = vi.fn(); +const mockListContacts = vi.fn(); +const mockListDomains = vi.fn(); +const mockAssignContacts = vi.fn(); +const mockCreateCustomer = vi.fn(); +const mockDeleteCustomer = vi.fn(); vi.mock('@/lib/domains-api', async (importOriginal) => { const actual = await importOriginal(); @@ -34,6 +46,12 @@ vi.mock('@/lib/domains-api', async (importOriginal) => { getDomainsSettings: (...args: unknown[]) => mockGetSettings(...args), saveDomainsSettings: (...args: unknown[]) => mockSaveSettings(...args), testDomainsConnection: (...args: unknown[]) => mockTestConnection(...args), + listCustomers: (...args: unknown[]) => mockListCustomers(...args), + listContacts: (...args: unknown[]) => mockListContacts(...args), + listDomains: (...args: unknown[]) => mockListDomains(...args), + assignContacts: (...args: unknown[]) => mockAssignContacts(...args), + createCustomer: (...args: unknown[]) => mockCreateCustomer(...args), + deleteCustomer: (...args: unknown[]) => mockDeleteCustomer(...args), }; }); @@ -64,8 +82,109 @@ function settings(over: Partial = {}): DomainsSettings { }; } +const CUSTOMERS: DomainsCustomer[] = [ + { id: 'c-own', name: 'Unsere Firma', isOwnCompany: true, contactCount: 1 }, + { id: 'c-a', name: 'Alpha GmbH', isOwnCompany: false, contactCount: 2 }, +]; + +function domainList(): DomainsDomainList { + return { + environment: 'DEMO', + truncated: false, + fetchedAt: '2026-10-08T10:00:00.000Z', + domains: [ + { + name: 'frei.de', + expire: '2027-03-01T12:00:00.000Z', + status: 'ACTIVE', + cancelationPending: false, + ownerContactId: 5, + ownerName: 'Niemand', + customerId: null, + customerName: null, + }, + { + name: 'alpha.de', + expire: '2027-07-15T12:00:00.000Z', + status: 'LOCK', + cancelationPending: true, + ownerContactId: 6, + ownerName: 'Alpha GmbH', + customerId: 'c-a', + customerName: 'Alpha GmbH', + }, + { + name: 'eigen.de', + expire: null, + status: 'SONDERWERT', + cancelationPending: false, + ownerContactId: 7, + ownerName: 'Unsere Firma', + customerId: 'c-own', + customerName: 'Unsere Firma', + }, + ], + }; +} + +function contactList(): DomainsContactList { + return { + environment: 'DEMO', + truncated: false, + fetchedAt: '2026-10-08T10:00:00.000Z', + contacts: [ + { + id: 6, + type: 'ORG', + displayName: 'Alpha GmbH', + fname: 'Anna', + lname: 'Alpha', + organization: 'Alpha GmbH', + address: ['Weg 1'], + pcode: '10115', + city: 'Berlin', + country: 'DE', + email: 'anna@alpha.example', + phone: '+49 30 1', + alias: null, + customerId: 'c-a', + customerName: 'Alpha GmbH', + }, + { + id: 5, + type: 'PERSON', + displayName: 'Bert Beta', + fname: 'Bert', + lname: 'Beta', + organization: null, + address: [], + pcode: null, + city: 'Hamburg', + country: 'DE', + email: 'bert@beta.example', + phone: null, + alias: null, + customerId: null, + customerName: null, + }, + ], + }; +} + +/** Oeffnet den Reiter Einstellungen (Standardreiter ist Domains). */ +async function renderSettings() { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' })); +} + beforeEach(() => { mockCanManage = true; + mockListCustomers.mockReset().mockResolvedValue(CUSTOMERS); + mockListDomains.mockReset().mockResolvedValue(domainList()); + mockListContacts.mockReset().mockResolvedValue(contactList()); + mockAssignContacts.mockReset().mockResolvedValue({ assigned: 1, removed: 0 }); + mockCreateCustomer.mockReset().mockResolvedValue(CUSTOMERS[1]); + mockDeleteCustomer.mockReset().mockResolvedValue({ deleted: true }); mockGetStatus.mockReset().mockResolvedValue(status()); mockGetSettings.mockReset().mockResolvedValue(settings()); mockSaveSettings.mockReset().mockImplementation(async () => settings()); @@ -82,6 +201,9 @@ describe('Domains-Seite — Kopf und Reiter', () => { it('Verwalter sieht den Reiter Einstellungen und die Demo-Kennzeichnung', async () => { render(); expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Domains' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Kontakte' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Kunden' })).toBeTruthy(); expect((await screen.findByTestId('domains-environment-badge')).textContent).toBe( 'Demo-System (Testbetrieb)', ); @@ -129,7 +251,7 @@ describe('Domains-Seite — Kopf und Reiter', () => { describe('Einstellungen', () => { it('Passwortfelder beginnen leer, mit Hinweis auf das gespeicherte Passwort', async () => { - render(); + await renderSettings(); const demoPassword = (await screen.findByLabelText('Passwort', { selector: '#domains-demo-password', })) as HTMLInputElement; @@ -140,7 +262,7 @@ describe('Einstellungen', () => { }); it('der Live-Kontext ist mit 4 vorbelegt, der Demo-Kontext zeigt den gespeicherten Wert', async () => { - render(); + await renderSettings(); await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' }); expect((document.getElementById('domains-live-context') as HTMLInputElement).value).toBe('4'); expect((document.getElementById('domains-demo-context') as HTMLInputElement).value).toBe('1'); @@ -148,7 +270,7 @@ describe('Einstellungen', () => { it('Wechsel auf Live verlangt Bestaetigung; erst die bestaetigte Speicherung sendet confirmLive', async () => { mockSaveSettings.mockResolvedValue(settings({ environment: 'LIVE' })); - render(); + await renderSettings(); const liveRadio = await screen.findByRole('radio', { name: /Live-System \(kostenpflichtig\)/ }); fireEvent.click(liveRadio); @@ -176,7 +298,7 @@ describe('Einstellungen', () => { }); it('Speichern des Zugangs sendet nur die eigenen Felder; ohne Eingabe kein Passwort', async () => { - render(); + await renderSettings(); const user = (await screen.findByLabelText('Benutzername', { selector: '#domains-demo-user', })) as HTMLInputElement; @@ -195,7 +317,7 @@ describe('Einstellungen', () => { resolveTest = resolve; }), ); - render(); + await renderSettings(); await screen.findByLabelText('Passwort', { selector: '#domains-demo-password' }); const demoCard = document.getElementById('domains-demo') as HTMLElement; const button = within(demoCard).getByRole('button', { name: 'Verbindung testen' }); @@ -213,7 +335,7 @@ describe('Einstellungen', () => { }); it('der Verbindungstest ist bei ungespeicherten Aenderungen gesperrt', async () => { - render(); + await renderSettings(); const password = (await screen.findByLabelText('Passwort', { selector: '#domains-demo-password', })) as HTMLInputElement; @@ -227,7 +349,7 @@ describe('Einstellungen', () => { }); it('Nameserver: speichert die bereinigte Liste', async () => { - render(); + await renderSettings(); const first = (await screen.findByLabelText('Nameserver 1')) as HTMLInputElement; fireEvent.change(first, { target: { value: ' NS1.Example.com ' } }); fireEvent.change(screen.getByLabelText('Nameserver 2'), { @@ -241,3 +363,157 @@ describe('Einstellungen', () => { }); }); }); + +describe('Reiter Domains, Kontakte und Kunden', () => { + it('Benutzen-Benutzer sieht Domains, Kontakte, Kunden, aber weder Einstellungen noch Schreibknoepfe', async () => { + mockCanManage = false; + render(); + expect(await screen.findByRole('button', { name: 'Domains' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Kontakte' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Kunden' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Kontakte' })); + await screen.findByText('Bert Beta'); + expect(screen.queryByRole('button', { name: 'Neuer Kontakt' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Zuordnen' })).toBeNull(); + expect(screen.queryByRole('checkbox', { name: /auswählen/ })).toBeNull(); + // Lesen darf jeder: neu einlesen steht allen zur Verfuegung. + expect(screen.getByRole('button', { name: 'Aus AutoDNS neu einlesen' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Kunden' })); + await screen.findByText('Alpha GmbH'); + expect(screen.queryByRole('button', { name: 'Kunde anlegen' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Bearbeiten' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Löschen' })).toBeNull(); + }); + + it('Verwalter sieht die Schreibknoepfe', async () => { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' })); + expect(await screen.findByRole('button', { name: 'Neuer Kontakt' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Zuordnen' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Kunden' })); + expect(await screen.findByRole('button', { name: 'Kunde anlegen' })).toBeTruthy(); + expect(screen.getAllByRole('button', { name: 'Bearbeiten' })).toHaveLength(2); + }); + + it('Domainliste: Gruppen je Kunde, "Nicht zugeordnet" zuletzt, Datum und Statusbeschriftung', async () => { + render(); + await screen.findByText('alpha.de'); + const titles = Array.from(document.querySelectorAll('.cc-section-title')).map( + (h) => h.textContent, + ); + expect(titles).toEqual(['Unsere Firma', 'Alpha GmbH', 'Nicht zugeordnet']); + + const row = screen.getByText('alpha.de').closest('tr') as HTMLElement; + expect(within(row).getByText('15.07.2027')).toBeTruthy(); + expect(within(row).getByText(/Gesperrt/)).toBeTruthy(); + expect(within(row).getByText('Kündigung vorgemerkt')).toBeTruthy(); + + const frei = screen.getByText('frei.de').closest('tr') as HTMLElement; + expect(within(frei).getByText('01.03.2027')).toBeTruthy(); + expect(within(frei).getByText('Aktiv')).toBeTruthy(); + + // Unbekannter Status: Rohwert als Rueckfall, fehlendes Datum als Strich. + const eigen = screen.getByText('eigen.de').closest('tr') as HTMLElement; + expect(within(eigen).getByText('SONDERWERT')).toBeTruthy(); + }); + + it('Kundenfilter blendet die anderen Gruppen aus', async () => { + render(); + await screen.findByText('alpha.de'); + fireEvent.change(document.getElementById('domains-filter') as HTMLSelectElement, { + target: { value: 'c-a' }, + }); + expect(screen.getByText('alpha.de')).toBeTruthy(); + expect(screen.queryByText('frei.de')).toBeNull(); + expect(screen.queryByText('eigen.de')).toBeNull(); + + fireEvent.change(document.getElementById('domains-filter') as HTMLSelectElement, { + target: { value: 'unassigned' }, + }); + expect(screen.getByText('frei.de')).toBeTruthy(); + expect(screen.queryByText('alpha.de')).toBeNull(); + }); + + it('Textsuche und "Aus AutoDNS neu laden"', async () => { + render(); + await screen.findByText('alpha.de'); + fireEvent.change(document.getElementById('domains-search') as HTMLInputElement, { + target: { value: 'FREI' }, + }); + expect(screen.queryByText('alpha.de')).toBeNull(); + expect(screen.getByText('frei.de')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Aus AutoDNS neu laden' })); + await waitFor(() => expect(mockListDomains).toHaveBeenLastCalledWith({ refresh: true })); + }); + + it('zeigt den Hinweis bei abgeschnittener Liste und die Fehlermeldung der API', async () => { + mockListDomains.mockResolvedValueOnce({ ...domainList(), truncated: true }); + render(); + expect(await screen.findByText('Es werden die ersten 2000 Einträge angezeigt.')).toBeTruthy(); + }); + + it('Kontakte zuordnen: ausgewaehlte Kontakte gehen an den gewaehlten Kunden', async () => { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' })); + await screen.findByText('Bert Beta'); + fireEvent.click(screen.getByRole('checkbox', { name: 'Kontakt Bert Beta auswählen' })); + fireEvent.change(document.getElementById('contacts-assign-target') as HTMLSelectElement, { + target: { value: 'c-a' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Zuordnen' })); + await waitFor(() => expect(mockAssignContacts).toHaveBeenCalledTimes(1)); + expect(mockAssignContacts).toHaveBeenCalledWith([5], 'c-a'); + }); + + it('Zuordnung entfernen sendet null', async () => { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Kontakte' })); + await screen.findByText('Bert Beta'); + fireEvent.click(screen.getByRole('checkbox', { name: 'Kontakt Alpha GmbH auswählen' })); + fireEvent.change(document.getElementById('contacts-assign-target') as HTMLSelectElement, { + target: { value: '__remove__' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Zuordnen' })); + await waitFor(() => expect(mockAssignContacts).toHaveBeenCalledWith([6], null)); + }); + + it('Kunden: Loeschen verlangt eine Bestaetigung und zeigt den 409-Text der API', async () => { + const { DomainsRequestError } = await import('@/lib/domains-api'); + mockDeleteCustomer.mockRejectedValueOnce( + new DomainsRequestError(409, 'customerInUse', 'Diesem Kunden sind noch Kontakte zugeordnet.'), + ); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Kunden' })); + const buttons = await screen.findAllByRole('button', { name: 'Löschen' }); + fireEvent.click(buttons[1]); + const dialog = await screen.findByRole('alertdialog'); + expect(mockDeleteCustomer).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Endgültig löschen' })); + await waitFor(() => expect(mockDeleteCustomer).toHaveBeenCalledWith('c-a')); + expect(await screen.findByText('Diesem Kunden sind noch Kontakte zugeordnet.')).toBeTruthy(); + }); + + it('Kunden: neuer Kunde mit Markierung eigene Firma', async () => { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Kunden' })); + fireEvent.change(await screen.findByLabelText('Name', { selector: '#customer-new-name' }), { + target: { value: ' Neu AG ' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Das ist unsere eigene Firma' })); + fireEvent.click(screen.getByRole('button', { name: 'Kunde anlegen' })); + await waitFor(() => expect(mockCreateCustomer).toHaveBeenCalledTimes(1)); + expect(mockCreateCustomer).toHaveBeenCalledWith({ name: 'Neu AG', isOwnCompany: true }); + }); + + it('nicht eingerichtet: keine Liste, nur der Hinweis', async () => { + mockGetStatus.mockResolvedValue(status({ configured: false, demoConfigured: false })); + render(); + await screen.findByText('AutoDNS ist noch nicht eingerichtet.'); + expect(mockListDomains).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/domains/page.tsx b/apps/web/src/app/(portal)/modules/domains/page.tsx index a8878df..889d896 100644 --- a/apps/web/src/app/(portal)/modules/domains/page.tsx +++ b/apps/web/src/app/(portal)/modules/domains/page.tsx @@ -5,19 +5,29 @@ import { useCallback, useEffect, useState } from 'react'; import { TabBar } from '@/components/accounting/tab-bar'; import { SettingsSection } from '@/components/control-center/settings-section'; import { PageHeader } from '@/components/layout/page-header'; -import { type DomainsStatus, getDomainsStatus } from '@/lib/domains-api'; +import { + type DomainsCustomer, + type DomainsStatus, + getDomainsStatus, + listCustomers, +} from '@/lib/domains-api'; import { useCanManageModule } from '@/lib/use-module-capability'; +import { ContactsTab } from './components/ContactsTab'; +import { CustomersTab } from './components/CustomersTab'; +import { DomainsTab } from './components/DomainsTab'; import { EnvironmentBadge } from './components/EnvironmentBadge'; import { SettingsTab } from './components/SettingsTab'; -type TabId = 'settings'; +type TabId = 'domains' | 'contacts' | 'customers' | 'settings'; /** * Domains (quick-261008-dts): Domains bei AutoDNS registrieren, Kontakte und * Kunden zuordnen. Der Seitenkopf traegt dauerhaft die Kennzeichnung des - * aktiven Systems (Demo/Live). Der Reiter "Einstellungen" erscheint nur fuer + * 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 - * allein die API. + * allein die API. Die Kundenliste wird einmal hier geladen und nach unten + * gereicht (Filter, Zuordnung, Formular). */ export default function DomainsPage() { const t = useTranslations('domains'); @@ -25,7 +35,8 @@ export default function DomainsPage() { const [status, setStatus] = useState(null); const [statusError, setStatusError] = useState(false); - const [tab, setTab] = useState('settings'); + const [tab, setTab] = useState('domains'); + const [customers, setCustomers] = useState([]); const reloadStatus = useCallback(async () => { try { @@ -36,11 +47,24 @@ export default function DomainsPage() { } }, []); + const reloadCustomers = useCallback(async () => { + try { + setCustomers(await listCustomers()); + } catch { + // Die Kundenliste ist Beiwerk (Filter, Zuordnung); die Reiter melden eigene Fehler. + } + }, []); + useEffect(() => { void reloadStatus(); - }, [reloadStatus]); + void reloadCustomers(); + }, [reloadStatus, reloadCustomers]); - const tabs: { id: TabId; label: string }[] = []; + const tabs: { id: TabId; label: string }[] = [ + { id: 'domains', label: t('tabs.domains') }, + { id: 'contacts', label: t('tabs.contacts') }, + { id: 'customers', label: t('tabs.customers') }, + ]; if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') }); const activeTab = tabs.some((x) => x.id === tab) ? tab : tabs[0]?.id; @@ -76,7 +100,25 @@ export default function DomainsPage() { {tabs.length > 0 && activeTab && ( <> - {activeTab === 'settings' && } + {activeTab === 'domains' && status?.configured && } + {activeTab === 'contacts' && status?.configured && ( + + )} + {activeTab === 'customers' && ( + + )} + {activeTab === 'settings' && ( + { + void reloadStatus(); + void reloadCustomers(); + }} + /> + )} )} diff --git a/apps/web/src/components/domains/group-by-customer.test.ts b/apps/web/src/components/domains/group-by-customer.test.ts new file mode 100644 index 0000000..85967f7 --- /dev/null +++ b/apps/web/src/components/domains/group-by-customer.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { + ALL_CUSTOMERS, + filterByCustomer, + groupByCustomer, + matchesText, + UNASSIGNED, +} from './group-by-customer'; + +interface Item { + name: string; + customerId: string | null; + customerName: string | null; +} + +const customers = [ + { id: 'c-z', name: 'Zeller GmbH', isOwnCompany: false }, + { id: 'c-ae', name: 'Äpfel AG', isOwnCompany: false }, + { id: 'c-own', name: 'Unsere Firma', isOwnCompany: true }, + { id: 'c-b', name: 'Bauer', isOwnCompany: false }, +]; + +const items: Item[] = [ + { name: 'a.de', customerId: null, customerName: null }, + { name: 'b.de', customerId: 'c-z', customerName: 'Zeller GmbH' }, + { name: 'c.de', customerId: 'c-own', customerName: 'Unsere Firma' }, + { name: 'd.de', customerId: 'c-ae', customerName: 'Äpfel AG' }, + { name: 'e.de', customerId: 'c-b', customerName: 'Bauer' }, + { name: 'f.de', customerId: 'c-z', customerName: 'Zeller GmbH' }, +]; + +describe('groupByCustomer', () => { + it('eigene Firma zuerst, dann deutsch sortiert, "Nicht zugeordnet" zuletzt', () => { + const groups = groupByCustomer(items, customers); + expect(groups.map((g) => g.key)).toEqual(['c-own', 'c-ae', 'c-b', 'c-z', UNASSIGNED]); + expect(groups[0].isOwnCompany).toBe(true); + expect(groups[4].name).toBeNull(); + expect(groups.find((g) => g.key === 'c-z')?.items.map((i) => i.name)).toEqual(['b.de', 'f.de']); + }); + + it('laesst leere Gruppen weg und veraendert die Eingabe nicht', () => { + const copy = JSON.parse(JSON.stringify(items)); + const groups = groupByCustomer(items.slice(0, 1), customers); + expect(groups).toHaveLength(1); + expect(groups[0].key).toBe(UNASSIGNED); + groupByCustomer(items, customers); + expect(items).toEqual(copy); + }); + + it('ein Kunde, den die Kundenliste nicht kennt, behaelt seinen Namen aus dem Eintrag', () => { + const groups = groupByCustomer( + [{ name: 'x.de', customerId: 'c-weg', customerName: 'Gelöscht AG' }], + customers, + ); + expect(groups[0].name).toBe('Gelöscht AG'); + }); +}); + +describe('filterByCustomer', () => { + it('all, unassigned und eine Kunden-Id', () => { + expect(filterByCustomer(items, ALL_CUSTOMERS)).toHaveLength(6); + expect(filterByCustomer(items, UNASSIGNED).map((i) => i.name)).toEqual(['a.de']); + expect(filterByCustomer(items, 'c-z').map((i) => i.name)).toEqual(['b.de', 'f.de']); + expect(filterByCustomer(items, 'c-gibt-es-nicht')).toEqual([]); + }); + + it('gibt bei "all" eine Kopie zurueck, nicht die Eingabe', () => { + expect(filterByCustomer(items, ALL_CUSTOMERS)).not.toBe(items); + }); +}); + +describe('matchesText', () => { + const fields = [(i: Item) => i.name, (i: Item) => i.customerName]; + + it('ignoriert Gross-/Kleinschreibung und durchsucht alle Felder', () => { + expect(matchesText(items[1], 'ZELLER', fields)).toBe(true); + expect(matchesText(items[1], 'b.de', fields)).toBe(true); + expect(matchesText(items[1], 'xyz', fields)).toBe(false); + }); + + it('leere Suche trifft alles, null-Felder stoeren nicht', () => { + expect(matchesText(items[0], ' ', fields)).toBe(true); + expect(matchesText(items[0], 'zeller', fields)).toBe(false); + }); +}); diff --git a/apps/web/src/components/domains/group-by-customer.ts b/apps/web/src/components/domains/group-by-customer.ts new file mode 100644 index 0000000..b0aa302 --- /dev/null +++ b/apps/web/src/components/domains/group-by-customer.ts @@ -0,0 +1,82 @@ +/** + * Filtern, Suchen und Gruppieren nach Kunde (quick-261008-dts). Reine + * Hilfsfunktionen ohne React: Kontakte und Domains tragen beide + * `customerId`/`customerName`. Die Eingabelisten werden nie veraendert. + */ + +/** Schluessel fuer "keinem Kunden zugeordnet" (Filterwert und Gruppenschluessel). */ +export const UNASSIGNED = 'unassigned'; +/** Filterwert "alle Kunden". */ +export const ALL_CUSTOMERS = 'all'; + +export interface HasCustomer { + customerId: string | null; + customerName: string | null; +} + +export interface CustomerRef { + id: string; + name: string; + isOwnCompany: boolean; +} + +export interface CustomerGroup { + /** Kunden-Id oder `UNASSIGNED`. */ + key: string; + /** Anzeigename; `null` bei der Gruppe "Nicht zugeordnet" (Beschriftung kommt aus den Texten). */ + name: string | null; + isOwnCompany: boolean; + items: T[]; +} + +/** `'all'`, `'unassigned'` oder eine Kunden-Id. */ +export function filterByCustomer(items: T[], filter: string): T[] { + if (filter === ALL_CUSTOMERS) return [...items]; + if (filter === UNASSIGNED) return items.filter((item) => item.customerId === null); + return items.filter((item) => item.customerId === filter); +} + +/** + * Gruppen nach Kunde: eigene Firma zuerst, dann nach Name (deutsche + * Sortierung), "Nicht zugeordnet" zuletzt. Leere Gruppen entfallen. + */ +export function groupByCustomer( + items: T[], + customers: CustomerRef[], +): CustomerGroup[] { + const known = new Map(customers.map((c) => [c.id, c])); + const groups = new Map>(); + for (const item of items) { + const key = item.customerId ?? UNASSIGNED; + let group = groups.get(key); + if (!group) { + const customer = item.customerId ? known.get(item.customerId) : undefined; + group = { + key, + name: item.customerId ? (customer?.name ?? item.customerName ?? item.customerId) : null, + isOwnCompany: customer?.isOwnCompany ?? false, + items: [], + }; + groups.set(key, group); + } + group.items.push(item); + } + const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true }); + return [...groups.values()].sort((a, b) => { + if (a.key === UNASSIGNED) return 1; + if (b.key === UNASSIGNED) return -1; + if (a.isOwnCompany !== b.isOwnCompany) return a.isOwnCompany ? -1 : 1; + return collator.compare(a.name ?? '', b.name ?? ''); + }); +} + +/** Gross-/Kleinschreibung egal; leere Suche trifft alles. */ +export function matchesText( + item: T, + query: string, + fields: ((item: T) => string | null | undefined)[], +): boolean { + const needle = query.trim().toLowerCase(); + if (!needle) return true; + return fields.some((field) => (field(item) ?? '').toLowerCase().includes(needle)); +} diff --git a/apps/web/src/components/domains/ui-classes.ts b/apps/web/src/components/domains/ui-classes.ts new file mode 100644 index 0000000..3007376 --- /dev/null +++ b/apps/web/src/components/domains/ui-classes.ts @@ -0,0 +1,10 @@ +/** Gemeinsame Klassen der Domains-Seite (Muster `SettingsTab.tsx`). */ +export const INPUT_CLASS = + 'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'; +export const PRIMARY_BUTTON = + 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50'; +export const SECONDARY_BUTTON = + 'rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent transition-colors disabled:cursor-not-allowed disabled:opacity-50'; +export const TABLE_HEAD_CLASS = 'bg-muted/50 text-xs text-muted-foreground'; +export const TABLE_HEAD_CELL = 'px-3 py-2 font-medium'; +export const TABLE_CELL = 'px-3 py-2'; diff --git a/apps/web/src/lib/domains-api.ts b/apps/web/src/lib/domains-api.ts index 9696b2b..e788037 100644 --- a/apps/web/src/lib/domains-api.ts +++ b/apps/web/src/lib/domains-api.ts @@ -122,3 +122,125 @@ export function testDomainsConnection( json: { environment }, }); } + +// --- Kunden, Kontakte und Domains (quick-261008-dts, Aufgabe 2) ---------------- + +export interface DomainsCustomer { + id: string; + name: string; + isOwnCompany: boolean; + /** Zugeordnete Kontakte im AKTIVEN System. */ + contactCount: number; +} + +export interface DomainsCustomerInput { + name: string; + isOwnCompany?: boolean; +} + +export type DomainsContactType = 'PERSON' | 'ORG'; + +export interface DomainsContact { + /** AutoDNS-Kontakt-Nummer. */ + id: number; + type: string | null; + displayName: string; + fname: string | null; + lname: string | null; + organization: string | null; + address: string[]; + pcode: string | null; + city: string | null; + country: string | null; + email: string | null; + phone: string | null; + alias: string | null; + customerId: string | null; + customerName: string | null; +} + +export interface DomainsContactList { + environment: DomainsEnvironment; + contacts: DomainsContact[]; + /** Es werden nur die ersten 2000 Eintraege geliefert. */ + truncated: boolean; + fetchedAt: string; +} + +export interface DomainsDomain { + name: string; + /** ISO-Zeitpunkt oder null. */ + expire: string | null; + /** Rohwert von AutoDNS (`registryStatus`). */ + status: string | null; + cancelationPending: boolean; + ownerContactId: number | null; + ownerName: string | null; + customerId: string | null; + customerName: string | null; +} + +export interface DomainsDomainList { + environment: DomainsEnvironment; + domains: DomainsDomain[]; + truncated: boolean; + fetchedAt: string; +} + +export interface CreateDomainsContactInput { + type: DomainsContactType; + organization?: string; + fname: string; + lname: string; + /** Anschriftzeilen (Strasse und Hausnummer, optional eine zweite Zeile). */ + street: string[]; + pcode: string; + city: string; + /** Laendercode nach ISO 3166-1 (zwei Grossbuchstaben). */ + country: string; + email: string; + phone: string; + customerId?: string; +} + +export function listCustomers(): Promise { + return request('/customers'); +} + +export function createCustomer(input: DomainsCustomerInput): Promise { + return request('/customers', { method: 'POST', json: input }); +} + +export function updateCustomer(id: string, input: DomainsCustomerInput): Promise { + return request(`/customers/${encodeURIComponent(id)}`, { + method: 'PUT', + json: input, + }); +} + +export function deleteCustomer(id: string): Promise<{ deleted: true }> { + return request<{ deleted: true }>(`/customers/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export function listContacts(opts: { refresh?: boolean } = {}): Promise { + return request(`/contacts${opts.refresh ? '?refresh=1' : ''}`); +} + +export function createContact(input: CreateDomainsContactInput): Promise { + return request('/contacts', { method: 'POST', json: input }); +} + +/** `customerId: null` loest die Zuordnung. */ +export function assignContacts( + contactIds: number[], + customerId: string | null, +): Promise<{ assigned: number; removed: number }> { + return request<{ assigned: number; removed: number }>('/contacts/assign', { + method: 'POST', + json: { contactIds, customerId }, + }); +} + +export function listDomains(opts: { refresh?: boolean } = {}): Promise { + return request(`/domains${opts.refresh ? '?refresh=1' : ''}`); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index a738ecd..7a7d994 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -2058,6 +2058,9 @@ "notConfigured": "AutoDNS nicht eingerichtet" }, "tabs": { + "domains": "Domains", + "contacts": "Kontakte", + "customers": "Kunden", "settings": "Einstellungen" }, "notConfigured": { @@ -2104,6 +2107,105 @@ "remove": "Entfernen" } }, + "common": { + "customerFilter": "Kunde", + "allCustomers": "Alle Kunden", + "unassigned": "Nicht zugeordnet", + "groupByCustomer": "Nach Kunde gruppieren", + "loading": "Wird geladen …", + "truncated": "Es werden die ersten 2000 Einträge angezeigt.", + "requestFailed": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut." + }, + "domainList": { + "search": "Domain suchen", + "refresh": "Aus AutoDNS neu laden", + "empty": "Bei AutoDNS wurden keine Domains gefunden.", + "emptyFiltered": "Keine Domain entspricht Ihrer Auswahl.", + "unassigned": "Nicht zugeordnet", + "cancelPending": "Kündigung vorgemerkt", + "groupCount": "{count, plural, one {# Domain} other {# Domains}}", + "columns": { + "domain": "Domain", + "customer": "Kunde", + "owner": "Inhaber", + "expires": "Ablaufdatum", + "status": "Status" + }, + "status": { + "ACTIVE": "Aktiv", + "PENDING": "In Bearbeitung", + "HOLD": "Gesperrt (Registry)", + "LOCK": "Gesperrt" + } + }, + "contactList": { + "intro": "Hier erscheinen alle Kontakte, die bei AutoDNS im gewählten System vorhanden sind. Ordnen Sie sie einem Kunden zu, damit Domains beim richtigen Kunden auftauchen.", + "search": "Kontakt suchen", + "refresh": "Aus AutoDNS neu einlesen", + "newContact": "Neuer Kontakt", + "empty": "Bei AutoDNS wurden keine Kontakte gefunden.", + "emptyFiltered": "Kein Kontakt entspricht Ihrer Auswahl.", + "groupCount": "{count, plural, one {# Kontakt} other {# Kontakte}}", + "columns": { + "name": "Name", + "organization": "Organisation", + "city": "Ort", + "email": "E-Mail", + "customer": "Kunde" + }, + "selectContact": "Kontakt {name} auswählen", + "selectAllVisible": "Alle angezeigten auswählen", + "selectedCount": "{count, plural, =0 {Nichts ausgewählt} one {# ausgewählt} other {# ausgewählt}}", + "assignLabel": "Ausgewählte zuordnen", + "assignPlaceholder": "Kunde wählen …", + "removeAssignment": "Zuordnung entfernen", + "assign": "Zuordnen", + "assigned": "Die Zuordnung wurde gespeichert.", + "created": "Der Kontakt wurde bei AutoDNS angelegt." + }, + "contactForm": { + "title": "Neuer Kontakt", + "type": "Typ", + "person": "Person", + "org": "Firma oder Organisation", + "organization": "Organisation", + "fname": "Vorname", + "lname": "Nachname", + "street": "Straße und Hausnummer", + "street2": "Zweite Adresszeile (optional)", + "pcode": "PLZ", + "city": "Ort", + "country": "Land", + "phone": "Telefon", + "phoneHint": "Internationale Schreibweise, z. B. +49 30 123456", + "email": "E-Mail", + "customer": "Kunde (optional)", + "noCustomer": "Kein Kunde", + "save": "Speichern", + "saving": "Speichern …", + "cancel": "Abbrechen", + "errors": { + "required": "Bitte füllen Sie dieses Feld aus.", + "email": "Bitte geben Sie eine gültige E-Mail-Adresse an.", + "phone": "Bitte verwenden Sie die internationale Schreibweise, z. B. +49 30 123456." + } + }, + "customers": { + "intro": "Kunden fassen AutoDNS-Kontakte zusammen. Eine Domain erscheint beim Kunden ihres Inhabers. Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie.", + "addTitle": "Kunde anlegen", + "name": "Name", + "ownCompanyLabel": "Das ist unsere eigene Firma", + "ownCompany": "Eigene Firma", + "add": "Kunde anlegen", + "empty": "Es sind noch keine Kunden angelegt.", + "contactCount": "{count, plural, =0 {Keine Kontakte} one {# Kontakt} other {# Kontakte}}", + "edit": "Bearbeiten", + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "confirmDelete": "Kunde „{name}“ wirklich löschen?", + "confirmDeleteYes": "Endgültig löschen" + }, "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 cdfb289..719d8b1 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -2058,6 +2058,9 @@ "notConfigured": "AutoDNS not set up" }, "tabs": { + "domains": "Domains", + "contacts": "Contacts", + "customers": "Customers", "settings": "Settings" }, "notConfigured": { @@ -2104,6 +2107,105 @@ "remove": "Remove" } }, + "common": { + "customerFilter": "Customer", + "allCustomers": "All customers", + "unassigned": "Unassigned", + "groupByCustomer": "Group by customer", + "loading": "Loading …", + "truncated": "Only the first 2000 entries are shown.", + "requestFailed": "The request failed. Please try again." + }, + "domainList": { + "search": "Search domains", + "refresh": "Reload from AutoDNS", + "empty": "No domains were found at AutoDNS.", + "emptyFiltered": "No domain matches your selection.", + "unassigned": "Unassigned", + "cancelPending": "Cancellation pending", + "groupCount": "{count, plural, one {# domain} other {# domains}}", + "columns": { + "domain": "Domain", + "customer": "Customer", + "owner": "Owner", + "expires": "Expiry date", + "status": "Status" + }, + "status": { + "ACTIVE": "Active", + "PENDING": "In progress", + "HOLD": "On hold (registry)", + "LOCK": "Locked" + } + }, + "contactList": { + "intro": "All contacts that exist at AutoDNS in the selected system appear here. Assign them to a customer so that domains show up under the right customer.", + "search": "Search contacts", + "refresh": "Re-read from AutoDNS", + "newContact": "New contact", + "empty": "No contacts were found at AutoDNS.", + "emptyFiltered": "No contact matches your selection.", + "groupCount": "{count, plural, one {# contact} other {# contacts}}", + "columns": { + "name": "Name", + "organization": "Organisation", + "city": "City", + "email": "E-mail", + "customer": "Customer" + }, + "selectContact": "Select contact {name}", + "selectAllVisible": "Select all shown", + "selectedCount": "{count, plural, =0 {Nothing selected} other {# selected}}", + "assignLabel": "Assign selected to", + "assignPlaceholder": "Choose customer …", + "removeAssignment": "Remove assignment", + "assign": "Assign", + "assigned": "The assignment was saved.", + "created": "The contact was created at AutoDNS." + }, + "contactForm": { + "title": "New contact", + "type": "Type", + "person": "Person", + "org": "Company or organisation", + "organization": "Organisation", + "fname": "First name", + "lname": "Last name", + "street": "Street and house number", + "street2": "Second address line (optional)", + "pcode": "Postal code", + "city": "City", + "country": "Country", + "phone": "Phone", + "phoneHint": "International format, e.g. +49 30 123456", + "email": "E-mail", + "customer": "Customer (optional)", + "noCustomer": "No customer", + "save": "Save", + "saving": "Saving …", + "cancel": "Cancel", + "errors": { + "required": "Please fill in this field.", + "email": "Please enter a valid e-mail address.", + "phone": "Please use the international format, e.g. +49 30 123456." + } + }, + "customers": { + "intro": "Customers group AutoDNS contacts. A domain appears under the customer of its owner. Add your own company as a customer too and mark it.", + "addTitle": "Add customer", + "name": "Name", + "ownCompanyLabel": "This is our own company", + "ownCompany": "Own company", + "add": "Add customer", + "empty": "No customers have been added yet.", + "contactCount": "{count, plural, =0 {No contacts} one {# contact} other {# contacts}}", + "edit": "Edit", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "confirmDelete": "Really delete customer “{name}”?", + "confirmDeleteYes": "Delete permanently" + }, "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 47eb4f9..360c303 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -225,4 +225,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ 'aktuellen', 'Passworts', 'tessera', + // quick-261008-dts: Modul Domains, Kunden und Kontakte — korrektes Deutsch mit „ss“ + 'Adresszeile', + 'fassen', ]; diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index a36509a..c9aed88 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 | 2 | 0 | **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 | 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. | | 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** | **297** | **8** | **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** | **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 | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare) @@ -421,6 +421,11 @@ quick-261008-dts (Aufgabe 1): +1 `muss-mandantengebunden` (`domains-settings.ser 101 Paare, davon 60 `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 2): +2 `muss-mandantengebunden` (`domains-directory.service.ts`/`domainsCustomer` +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-261002-kxc (Aufgabe 3): Paarzahl unverändert (96); `listRecentAlerts` nutzt dieselben Paare `nextcloud-alert.service.ts`/`nextcloudAlertSubscription` und `/nextcloudInstance`. @@ -886,6 +891,8 @@ werden. | apps/api/src/kantine-datev/kantine-datev.service.ts | kantineDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — die drei Nummern der Kantinenabrechnung (Beraternummer, Mandantennummer, Lohnart), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002120000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der diese Einstellungen über alle Mandanten liest. Zwei mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `getSettings` (`findUnique`), `saveSettings` (`upsert`). Die hochgeladene Kantinen-CSV (Namen, Personalnummern) berührt die Datenbank nie. | | apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Einstellungen der Handelsware (Standard-Erlöskonto, Startwert Gegenkonto), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`getSettings` `findUnique`, `saveSettings` `upsert`) und einer über den Transaktionsparameter von `withTenantTransaction` (`export` liest die Einstellungen in derselben Transaktion wie die Kontenliste, `tx.handelswareDatevConfig.findUnique`). | | 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-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. |