refactor(domains): Einstellung Standard-Nameserver entfernt, Doku angepasst (h3t)

- Spalte DomainsConfig.defaultNameServers per Migration 20261008160000 entfernt
- Feld in Einstellungen/Status (API, DTO, Typen) und Karte im Web entfernt
- Anleitungen und CHANGELOG: Nameserver kommen aus AutoDNS

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:37:21 +02:00
parent 1b20b84e2f
commit 2176f8ecce
17 changed files with 22 additions and 241 deletions
+2 -2
View File
@@ -7,10 +7,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Neu ### Neu
- Neues Modul „Domains“ in der Gruppe „Domains“: Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen. Ein Administrator aktiviert das Modul im Marktplatz; wer es nutzen soll, bekommt zusätzlich die Freigabe. Mit „Benutzen“ sehen Sie die Domainliste, die Kontakte, die Kunden und die Aufträge; mit „Verwalten“ (und als Administrator) registrieren Sie Domains, legen Kontakte und Kunden an und richten die Anbindung ein. - Neues Modul „Domains“ in der Gruppe „Domains“: Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen. Ein Administrator aktiviert das Modul im Marktplatz; wer es nutzen soll, bekommt zusätzlich die Freigabe. Mit „Benutzen“ sehen Sie die Domainliste, die Kontakte, die Kunden und die Aufträge; mit „Verwalten“ (und als Administrator) registrieren Sie Domains, legen Kontakte und Kunden an und richten die Anbindung ein.
- Domains, Anbindung an AutoDNS: Unter „Einstellungen“ tragen Sie Benutzername, Passwort und Kontext getrennt für das Demo-System (zum Ausprobieren) und das Live-System (echte, kostenpflichtige Registrierungen) ein. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt. Neue Installationen starten im Demo-System; der Wechsel auf das Live-System verlangt eine ausdrückliche Bestätigung, und eine Kennzeichnung oben auf der Seite zeigt jederzeit, welches System gerade gilt. „Verbindung testen“ prüft den Zugang mit einem einzigen Aufruf. Dazu kommen die Standard-Nameserver, die bei jeder Registrierung vorausgefüllt werden. - Domains, Anbindung an AutoDNS: Unter „Einstellungen“ tragen Sie Benutzername, Passwort und Kontext getrennt für das Demo-System (zum Ausprobieren) und das Live-System (echte, kostenpflichtige Registrierungen) ein. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt. Neue Installationen starten im Demo-System; der Wechsel auf das Live-System verlangt eine ausdrückliche Bestätigung, und eine Kennzeichnung oben auf der Seite zeigt jederzeit, welches System gerade gilt. „Verbindung testen“ prüft den Zugang mit einem einzigen Aufruf.
- Domains, Kontakte und Kunden: Alle Kontakte, die bei AutoDNS vorhanden sind, erscheinen automatisch in Tessera („Aus AutoDNS neu einlesen“ holt sie auf Wunsch sofort neu; das dürfen Benutzer mit der Freigabe „Verwalten“). Neue Kontakte (Person oder Organisation) legen Sie über ein Formular an. Jeden Kontakt – einzeln oder viele auf einmal – ordnen Sie einem Kunden zu; Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie. Die Listen lassen sich nach Kunde filtern und gruppieren. - Domains, Kontakte und Kunden: Alle Kontakte, die bei AutoDNS vorhanden sind, erscheinen automatisch in Tessera („Aus AutoDNS neu einlesen“ holt sie auf Wunsch sofort neu; das dürfen Benutzer mit der Freigabe „Verwalten“). Neue Kontakte (Person oder Organisation) legen Sie über ein Formular an. Jeden Kontakt – einzeln oder viele auf einmal – ordnen Sie einem Kunden zu; Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie. Die Listen lassen sich nach Kunde filtern und gruppieren.
- Domains, Domainliste: Alle Domains aus AutoDNS mit Kunde (über den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status, ebenfalls filterbar und nach Kunde gruppierbar. - Domains, Domainliste: Alle Domains aus AutoDNS mit Kunde (über den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status, ebenfalls filterbar und nach Kunde gruppierbar.
- Domains, Registrieren: Verfügbarkeit prüfen, Kontakte und Nameserver wählen, Zusammenfassung mit System und Preis ansehen und mit „Jetzt verbindlich registrieren“ bestätigen. Tessera schützt vor Doppelbestellungen: Jede Registrierung wird höchstens ein einziges Mal an AutoDNS geschickt, auch bei Doppelklick oder zwei geöffneten Fenstern, und nie automatisch wiederholt. Ist unklar, ob AutoDNS die Bestellung angenommen hat, steht der Auftrag auf „Ergebnis ungeklärt“, bis Tessera ihn mit AutoDNS abgeglichen hat. Den Stand jeder Registrierung sehen Sie im Reiter „Aufträge“; er wird beim Öffnen und danach alle 30 Sekunden mit AutoDNS abgeglichen. - Domains, Registrieren: Verfügbarkeit prüfen, Kontakte wählen (die Nameserver übernimmt Tessera unverändert und in derselben Reihenfolge aus den Standardwerten in AutoDNS; sind dort keine hinterlegt, ist die Registrierung gesperrt), Zusammenfassung mit System und Preis ansehen und mit „Jetzt verbindlich registrieren“ bestätigen. Tessera schützt vor Doppelbestellungen: Jede Registrierung wird höchstens ein einziges Mal an AutoDNS geschickt, auch bei Doppelklick oder zwei geöffneten Fenstern, und nie automatisch wiederholt. Ist unklar, ob AutoDNS die Bestellung angenommen hat, steht der Auftrag auf „Ergebnis ungeklärt“, bis Tessera ihn mit AutoDNS abgeglichen hat. Den Stand jeder Registrierung sehen Sie im Reiter „Aufträge“; er wird beim Öffnen und danach alle 30 Sekunden mit AutoDNS abgeglichen.
### Geändert ### Geändert
@@ -0,0 +1,3 @@
-- quick-261008-h3t: Die Standard-Nameserver kommen aus dem AutoDNS-Benutzerprofil.
-- Die Spalte wird nicht mehr gelesen, ihre Werte sind wertlos.
ALTER TABLE "DomainsConfig" DROP COLUMN "defaultNameServers";
-1
View File
@@ -876,7 +876,6 @@ model DomainsConfig {
liveUser String? liveUser String?
liveEncryptedPassword String? liveEncryptedPassword String?
liveContext Int? liveContext Int?
defaultNameServers String[] @default([])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -20,7 +20,6 @@ interface Row {
liveUser: string | null; liveUser: string | null;
liveEncryptedPassword: string | null; liveEncryptedPassword: string | null;
liveContext: number | null; liveContext: number | null;
defaultNameServers: string[];
updatedAt: Date; updatedAt: Date;
} }
@@ -33,7 +32,6 @@ function emptyRow(over: Partial<Row> = {}): Row {
liveUser: null, liveUser: null,
liveEncryptedPassword: null, liveEncryptedPassword: null,
liveContext: null, liveContext: null,
defaultNameServers: [],
updatedAt: new Date('2026-10-08T10:00:00Z'), updatedAt: new Date('2026-10-08T10:00:00Z'),
...over, ...over,
}; };
@@ -79,7 +77,6 @@ describe('DomainsSettingsService — Lesen', () => {
environment: 'DEMO', environment: 'DEMO',
demo: { user: null, hasPassword: false, context: null }, demo: { user: null, hasPassword: false, context: null },
live: { user: null, hasPassword: false, context: null }, live: { user: null, hasPassword: false, context: null },
defaultNameServers: [],
configured: { demo: false, live: false }, configured: { demo: false, live: false },
}); });
}); });
@@ -91,10 +88,15 @@ describe('DomainsSettingsService — Lesen', () => {
configured: true, configured: true,
demoConfigured: true, demoConfigured: true,
liveConfigured: false, liveConfigured: false,
defaultNameServers: [],
}); });
}); });
it('Einstellungen und Status tragen keine Nameserver (h3t: sie kommen aus AutoDNS)', async () => {
const { service } = makeService(configuredDemo());
expect(Object.keys(await service.getSettings('t1')).join()).not.toMatch(/nameserver/i);
expect(Object.keys(await service.getStatus('t1')).join()).not.toMatch(/nameserver/i);
});
it('aktives LIVE ohne Live-Zugang gilt als nicht eingerichtet', async () => { it('aktives LIVE ohne Live-Zugang gilt als nicht eingerichtet', async () => {
const { service } = makeService({ ...configuredDemo(), environment: 'LIVE' }); const { service } = makeService({ ...configuredDemo(), environment: 'LIVE' });
const status = await service.getStatus('t1'); const status = await service.getStatus('t1');
@@ -156,31 +158,6 @@ describe('DomainsSettingsService — Speichern', () => {
await expect(service.saveSettings('t1', { environment: 'LIVE' })).resolves.toBeDefined(); await expect(service.saveSettings('t1', { environment: 'LIVE' })).resolves.toBeDefined();
await expect(service.saveSettings('t1', { environment: 'DEMO' })).resolves.toBeDefined(); await expect(service.saveSettings('t1', { environment: 'DEMO' })).resolves.toBeDefined();
}); });
it('normalisiert Nameserver und prueft Anzahl und Form', async () => {
const { service, upsert } = makeService(null);
await service.saveSettings('t1', {
defaultNameServers: [' NS1.Example.COM ', 'ns2.example.com'],
});
expect(upsert.mock.calls[0][0].update.defaultNameServers).toEqual([
'ns1.example.com',
'ns2.example.com',
]);
await expect(
service.saveSettings('t1', { defaultNameServers: ['ns1.example.com'] }),
).rejects.toMatchObject({ response: { code: 'tooFewNameServers' } });
await expect(
service.saveSettings('t1', {
defaultNameServers: Array.from({ length: 7 }, (_, i) => `ns${i}.example.com`),
}),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.saveSettings('t1', { defaultNameServers: ['ns1.example.com', 'kein rechner!'] }),
).rejects.toMatchObject({ response: { code: 'invalidNameServer' } });
// Leeren ist erlaubt
await service.saveSettings('t1', { defaultNameServers: [] });
expect(upsert.mock.calls.at(-1)?.[0].update.defaultNameServers).toEqual([]);
});
}); });
describe('DomainsSettingsService — Zugang', () => { describe('DomainsSettingsService — Zugang', () => {
@@ -15,7 +15,6 @@ import {
type AutodnsRateLimiter, type AutodnsRateLimiter,
autodnsRequest, autodnsRequest,
} from './autodns-client'; } from './autodns-client';
import { HOSTNAME_PATTERN } from './domain-name';
import { import {
AUTODNS_AUTH_MESSAGE, AUTODNS_AUTH_MESSAGE,
type DomainsConnectionTestResult, type DomainsConnectionTestResult,
@@ -38,9 +37,6 @@ const DECRYPT_FAILED = {
'Das gespeicherte AutoDNS-Passwort ließ sich nicht entschlüsseln. Bitte tragen Sie es in den Einstellungen neu ein.', 'Das gespeicherte AutoDNS-Passwort ließ sich nicht entschlüsseln. Bitte tragen Sie es in den Einstellungen neu ein.',
}; };
const MIN_NAMESERVERS = 2;
const MAX_NAMESERVERS = 6;
interface ConfigRow { interface ConfigRow {
environment: DomainsEnvironment; environment: DomainsEnvironment;
demoUser: string | null; demoUser: string | null;
@@ -49,7 +45,6 @@ interface ConfigRow {
liveUser: string | null; liveUser: string | null;
liveEncryptedPassword: string | null; liveEncryptedPassword: string | null;
liveContext: number | null; liveContext: number | null;
defaultNameServers: string[];
updatedAt: Date; updatedAt: Date;
} }
@@ -67,7 +62,8 @@ function isConfigured(user: string | null, password: string | null, context: num
/** /**
* Einstellungen des Moduls Domains (quick-261008-dts): getrennte, verschluesselt * Einstellungen des Moduls Domains (quick-261008-dts): getrennte, verschluesselt
* abgelegte Zugaenge fuer das Demo- und das Live-System, Umschalter, * abgelegte Zugaenge fuer das Demo- und das Live-System, Umschalter,
* Standard-Nameserver und der Verbindungstest. Das Passwort verlaesst diesen * der Verbindungstest. Standard-Nameserver gibt es hier nicht: die liest
* `DomainsOrdersService` bei jeder Registrierung aus dem AutoDNS-Benutzerprofil. Das Passwort verlaesst diesen
* Dienst nie in Richtung Client; Antworten tragen nur `hasPassword`. * Dienst nie in Richtung Client; Antworten tragen nur `hasPassword`.
* Gesamter Zugriff auf `domainsConfig` liegt ausschliesslich hier. * Gesamter Zugriff auf `domainsConfig` liegt ausschliesslich hier.
*/ */
@@ -124,7 +120,6 @@ export class DomainsSettingsService {
environment: row?.environment ?? 'DEMO', environment: row?.environment ?? 'DEMO',
demo: this.envView(row, 'DEMO'), demo: this.envView(row, 'DEMO'),
live: this.envView(row, 'LIVE'), live: this.envView(row, 'LIVE'),
defaultNameServers: row?.defaultNameServers ?? [],
configured: { demo: demoConfigured, live: liveConfigured }, configured: { demo: demoConfigured, live: liveConfigured },
}; };
} }
@@ -140,38 +135,11 @@ export class DomainsSettingsService {
configured: view.environment === 'DEMO' ? view.configured.demo : view.configured.live, configured: view.environment === 'DEMO' ? view.configured.demo : view.configured.live,
demoConfigured: view.configured.demo, demoConfigured: view.configured.demo,
liveConfigured: view.configured.live, liveConfigured: view.configured.live,
defaultNameServers: view.defaultNameServers,
}; };
} }
// --- Speichern ------------------------------------------------------------- // --- Speichern -------------------------------------------------------------
private normalizeNameServers(raw: string[]): string[] {
const list = raw.map((n) => n.trim().toLowerCase()).filter((n) => n.length > 0);
if (list.length === 0) return [];
if (list.length > MAX_NAMESERVERS) {
throw new BadRequestException({
code: 'tooManyNameServers',
message: 'Höchstens sechs Nameserver sind möglich.',
});
}
for (const name of list) {
if (!HOSTNAME_PATTERN.test(name)) {
throw new BadRequestException({
code: 'invalidNameServer',
message: `Der Nameserver „${name}“ ist kein gültiger Rechnername.`,
});
}
}
if (list.length < MIN_NAMESERVERS) {
throw new BadRequestException({
code: 'tooFewNameServers',
message: 'Bitte geben Sie mindestens zwei Nameserver an.',
});
}
return list;
}
async saveSettings(tenantId: string, dto: SaveDomainsSettingsDto): Promise<DomainsSettingsView> { async saveSettings(tenantId: string, dto: SaveDomainsSettingsDto): Promise<DomainsSettingsView> {
const current = await this.loadRow(tenantId); const current = await this.loadRow(tenantId);
@@ -196,9 +164,6 @@ export class DomainsSettingsService {
// Leeres oder fehlendes Passwort = gespeichertes bleibt (Muster LDAP). // Leeres oder fehlendes Passwort = gespeichertes bleibt (Muster LDAP).
if (dto.demoPassword) data.demoEncryptedPassword = this.crypto.encrypt(dto.demoPassword); if (dto.demoPassword) data.demoEncryptedPassword = this.crypto.encrypt(dto.demoPassword);
if (dto.livePassword) data.liveEncryptedPassword = this.crypto.encrypt(dto.livePassword); if (dto.livePassword) data.liveEncryptedPassword = this.crypto.encrypt(dto.livePassword);
if (dto.defaultNameServers !== undefined) {
data.defaultNameServers = this.normalizeNameServers(dto.defaultNameServers);
}
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId);
const saved = await tenantPrisma.domainsConfig.upsert({ const saved = await tenantPrisma.domainsConfig.upsert({
@@ -246,11 +211,6 @@ export class DomainsSettingsService {
return { environment, credentials, configVersion: row.updatedAt.getTime() }; return { environment, credentials, configVersion: row.updatedAt.getTime() };
} }
/** Standard-Nameserver fuer das Registrierungsformular. */
async getDefaultNameServers(tenantId: string): Promise<string[]> {
return (await this.loadRow(tenantId))?.defaultNameServers ?? [];
}
// --- Verbindungstest ------------------------------------------------------- // --- Verbindungstest -------------------------------------------------------
/** /**
-2
View File
@@ -16,7 +16,6 @@ export interface DomainsSettingsView {
environment: DomainsEnvironment; environment: DomainsEnvironment;
demo: DomainsEnvironmentView; demo: DomainsEnvironmentView;
live: DomainsEnvironmentView; live: DomainsEnvironmentView;
defaultNameServers: string[];
configured: { demo: boolean; live: boolean }; configured: { demo: boolean; live: boolean };
} }
@@ -26,7 +25,6 @@ export interface DomainsStatusView {
configured: boolean; configured: boolean;
demoConfigured: boolean; demoConfigured: boolean;
liveConfigured: boolean; liveConfigured: boolean;
defaultNameServers: string[];
} }
export interface DomainsConnectionTestResult { export interface DomainsConnectionTestResult {
@@ -17,7 +17,6 @@ describe('SaveDomainsSettingsDto — null ist keine fehlende Angabe', () => {
environment: 'DEMO', environment: 'DEMO',
demoUser: 'u', demoUser: 'u',
demoPassword: '', demoPassword: '',
defaultNameServers: ['ns1.example.com', 'ns2.example.com'],
}), }),
).toEqual([]); ).toEqual([]);
}); });
@@ -29,7 +28,6 @@ describe('SaveDomainsSettingsDto — null ist keine fehlende Angabe', () => {
'demoPassword', 'demoPassword',
'liveUser', 'liveUser',
'livePassword', 'livePassword',
'defaultNameServers',
])('%s: null -> Validierungsfehler (400) statt TypeError im Dienst', async (field) => { ])('%s: null -> Validierungsfehler (400) statt TypeError im Dienst', async (field) => {
expect(await errorsOf({ [field]: null })).toEqual([field]); expect(await errorsOf({ [field]: null })).toEqual([field]);
}); });
@@ -1,15 +1,4 @@
import { import { IsBoolean, IsIn, IsInt, IsString, Max, MaxLength, Min, ValidateIf } from 'class-validator';
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsString,
Max,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
/** /**
* Teilspeichern: ein Feld darf FEHLEN (`undefined`), aber nicht `null` sein — * Teilspeichern: ein Feld darf FEHLEN (`undefined`), aber nicht `null` sein —
@@ -69,13 +58,6 @@ export class SaveDomainsSettingsDto {
@Min(1) @Min(1)
@Max(2147483647) @Max(2147483647)
liveContext?: number | null; liveContext?: number | null;
@ValidateIf(isPresent)
@IsArray()
@ArrayMaxSize(6)
@IsString({ each: true })
@MaxLength(253, { each: true })
defaultNameServers?: string[];
} }
export class TestDomainsConnectionDto { export class TestDomainsConnectionDto {
@@ -48,7 +48,6 @@ function status(over: Partial<DomainsStatus> = {}): DomainsStatus {
configured: true, configured: true,
demoConfigured: true, demoConfigured: true,
liveConfigured: true, liveConfigured: true,
defaultNameServers: [],
...over, ...over,
}; };
} }
@@ -24,8 +24,6 @@ const SECONDARY_BUTTON =
/** Der Live-Kontext von AutoDNS ist in der Regel 4; das Demo-System hat keinen Vorschlag. */ /** Der Live-Kontext von AutoDNS ist in der Regel 4; das Demo-System hat keinen Vorschlag. */
const LIVE_DEFAULT_CONTEXT = 4; const LIVE_DEFAULT_CONTEXT = 4;
const MIN_NAMESERVER_ROWS = 2;
const MAX_NAMESERVER_ROWS = 6;
type Message = { kind: 'ok' | 'error'; text: string }; type Message = { kind: 'ok' | 'error'; text: string };
@@ -349,111 +347,6 @@ function AccessCard({
); );
} }
// --- Standard-Nameserver --------------------------------------------------------
function padNameServers(list: string[]): string[] {
const rows = [...list];
while (rows.length < MIN_NAMESERVER_ROWS) rows.push('');
return rows;
}
function NameServersCard({
settings,
onSaved,
}: {
settings: DomainsSettings;
onSaved: (s: DomainsSettings) => void;
}) {
const t = useTranslations('domains.settings');
const errorText = useErrorText();
const [rows, setRows] = useState<string[]>(padNameServers(settings.defaultNameServers));
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
useEffect(() => {
setRows(padNameServers(settings.defaultNameServers));
}, [settings.defaultNameServers]);
const cleaned = rows.map((r) => r.trim().toLowerCase()).filter((r) => r.length > 0);
const dirty = cleaned.join('\n') !== settings.defaultNameServers.join('\n');
const save = async () => {
setSaving(true);
setMessage(null);
try {
onSaved(await saveDomainsSettings({ defaultNameServers: cleaned }));
setMessage({ kind: 'ok', text: t('saved') });
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setSaving(false);
}
};
return (
<SettingsSection
id="domains-nameservers"
title={t('nameServers.title')}
description={t('nameServers.description')}
footer={
<>
<div className="mr-auto">
<MessageLine message={message} />
</div>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={saving || !dirty}
onClick={save}
>
{saving ? t('saving') : t('save')}
</button>
</>
}
>
<div className="space-y-3">
{rows.map((value, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: Zeilen sind positionsgebunden und haben keine Kennung
<div key={index} className="flex items-end gap-2">
<div className="flex-1 space-y-1">
<label
htmlFor={`domains-ns-${index}`}
className="text-sm font-medium text-foreground"
>
{t('nameServers.label', { number: index + 1 })}
</label>
<input
id={`domains-ns-${index}`}
type="text"
autoComplete="off"
maxLength={253}
placeholder={`ns${index + 1}.example.com`}
value={value}
onChange={(e) => setRows(rows.map((r, i) => (i === index ? e.target.value : r)))}
className={INPUT_CLASS}
/>
</div>
{rows.length > MIN_NAMESERVER_ROWS && (
<button
type="button"
className={SECONDARY_BUTTON}
onClick={() => setRows(rows.filter((_, i) => i !== index))}
>
{t('nameServers.remove')}
</button>
)}
</div>
))}
{rows.length < MAX_NAMESERVER_ROWS && (
<button type="button" className={SECONDARY_BUTTON} onClick={() => setRows([...rows, ''])}>
{t('nameServers.add')}
</button>
)}
</div>
</SettingsSection>
);
}
// --- Reiter --------------------------------------------------------------------- // --- Reiter ---------------------------------------------------------------------
/** /**
@@ -503,7 +396,6 @@ export function SettingsTab({ onChanged }: { onChanged: () => void }) {
<SystemCard settings={settings} onSaved={handleSaved} /> <SystemCard settings={settings} onSaved={handleSaved} />
<AccessCard environment="DEMO" access={settings.demo} onSaved={handleSaved} /> <AccessCard environment="DEMO" access={settings.demo} onSaved={handleSaved} />
<AccessCard environment="LIVE" access={settings.live} onSaved={handleSaved} /> <AccessCard environment="LIVE" access={settings.live} onSaved={handleSaved} />
<NameServersCard settings={settings} onSaved={handleSaved} />
</div> </div>
); );
} }
@@ -70,7 +70,6 @@ function status(over: Partial<DomainsStatus> = {}): DomainsStatus {
configured: true, configured: true,
demoConfigured: true, demoConfigured: true,
liveConfigured: false, liveConfigured: false,
defaultNameServers: [],
...over, ...over,
}; };
} }
@@ -80,7 +79,6 @@ function settings(over: Partial<DomainsSettings> = {}): DomainsSettings {
environment: 'DEMO', environment: 'DEMO',
demo: { user: 'api-user', hasPassword: true, context: 1 }, demo: { user: 'api-user', hasPassword: true, context: 1 },
live: { user: null, hasPassword: false, context: null }, live: { user: null, hasPassword: false, context: null },
defaultNameServers: [],
configured: { demo: true, live: false }, configured: { demo: true, live: false },
...over, ...over,
}; };
@@ -354,19 +352,11 @@ describe('Einstellungen', () => {
expect(within(demoCard).getByText('Bitte zuerst speichern')).toBeTruthy(); expect(within(demoCard).getByText('Bitte zuerst speichern')).toBeTruthy();
}); });
it('Nameserver: speichert die bereinigte Liste', async () => { it('Einstellungen zeigen keine Karte "Standard-Nameserver" mehr (h3t)', async () => {
await renderSettings(); await renderSettings();
const first = (await screen.findByLabelText('Nameserver 1')) as HTMLInputElement; await screen.findByRole('radio', { name: /Live-System \(kostenpflichtig\)/ });
fireEvent.change(first, { target: { value: ' NS1.Example.com ' } }); expect(screen.queryByText('Standard-Nameserver')).toBeNull();
fireEvent.change(screen.getByLabelText('Nameserver 2'), { expect(document.getElementById('domains-nameservers')).toBeNull();
target: { value: 'ns2.example.com' },
});
const card = document.getElementById('domains-nameservers') as HTMLElement;
fireEvent.click(within(card).getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalledTimes(1));
expect(mockSaveSettings).toHaveBeenCalledWith({
defaultNameServers: ['ns1.example.com', 'ns2.example.com'],
});
}); });
}); });
-3
View File
@@ -16,7 +16,6 @@ export interface DomainsStatus {
configured: boolean; configured: boolean;
demoConfigured: boolean; demoConfigured: boolean;
liveConfigured: boolean; liveConfigured: boolean;
defaultNameServers: string[];
} }
export interface DomainsEnvironmentAccess { export interface DomainsEnvironmentAccess {
@@ -29,7 +28,6 @@ export interface DomainsSettings {
environment: DomainsEnvironment; environment: DomainsEnvironment;
demo: DomainsEnvironmentAccess; demo: DomainsEnvironmentAccess;
live: DomainsEnvironmentAccess; live: DomainsEnvironmentAccess;
defaultNameServers: string[];
configured: { demo: boolean; live: boolean }; configured: { demo: boolean; live: boolean };
} }
@@ -43,7 +41,6 @@ export interface SaveDomainsSettingsInput {
liveUser?: string; liveUser?: string;
livePassword?: string; livePassword?: string;
liveContext?: number | null; liveContext?: number | null;
defaultNameServers?: string[];
} }
export interface ConnectionTestResult { export interface ConnectionTestResult {
-7
View File
@@ -2100,13 +2100,6 @@
"test": "Verbindung testen", "test": "Verbindung testen",
"testing": "Verbindung wird getestet …", "testing": "Verbindung wird getestet …",
"saveFirst": "Bitte zuerst speichern" "saveFirst": "Bitte zuerst speichern"
},
"nameServers": {
"title": "Standard-Nameserver",
"description": "Diese Nameserver werden bei jeder Registrierung vorausgefüllt. Sie müssen bei Ihrem Anbieter bereits eingerichtet sein, sonst scheitert die Registrierung.",
"label": "Nameserver {number}",
"add": "Nameserver hinzufügen",
"remove": "Entfernen"
} }
}, },
"common": { "common": {
-7
View File
@@ -2100,13 +2100,6 @@
"test": "Test connection", "test": "Test connection",
"testing": "Testing connection …", "testing": "Testing connection …",
"saveFirst": "Please save first" "saveFirst": "Please save first"
},
"nameServers": {
"title": "Default name servers",
"description": "These name servers are prefilled for every registration. They must already be set up with your provider, otherwise the registration fails.",
"label": "Name server {number}",
"add": "Add name server",
"remove": "Remove"
} }
}, },
"common": { "common": {
+2 -2
View File
@@ -360,11 +360,11 @@ Das Modul „Nextcloud-Status“ (Gruppe Infrastruktur) aktivieren Sie wie jedes
Das Modul „Domains“ (Gruppe Domains) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Anbindung einrichten, Kunden und Kontakte anlegen und Domains registrieren** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“ – vergeben Sie diese Stufe nur an Personen, denen Sie Bestellungen auf Ihre Kosten zutrauen, denn eine Registrierung im Live-System ist verbindlich und kostenpflichtig. Das Modul „Domains“ (Gruppe Domains) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Anbindung einrichten, Kunden und Kontakte anlegen und Domains registrieren** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“ – vergeben Sie diese Stufe nur an Personen, denen Sie Bestellungen auf Ihre Kosten zutrauen, denn eine Registrierung im Live-System ist verbindlich und kostenpflichtig.
- **Eigener AutoDNS-Benutzer:** Legen Sie bei AutoDNS einen eigenen Benutzer für Tessera an, **ohne Zwei-Faktor-Anmeldung** – Tessera meldet sich mit Benutzername, Passwort und der Kontextnummer an, einen Zusatzcode kann es nicht eingeben. Der Benutzer braucht die Rechte, Domains und Kontakte zu lesen sowie Kontakte anzulegen und Domains zu registrieren. - **Eigener AutoDNS-Benutzer:** Legen Sie bei AutoDNS einen eigenen Benutzer für Tessera an, **ohne Zwei-Faktor-Anmeldung** – Tessera meldet sich mit Benutzername, Passwort und der Kontextnummer an, einen Zusatzcode kann es nicht eingeben. Der Benutzer braucht die Rechte, Domains und Kontakte zu lesen sowie Kontakte anzulegen und Domains zu registrieren; außerdem muss er sein eigenes Benutzerprofil lesen dürfen (daraus liest Tessera die Standard-Nameserver).
- **Kontext je System:** Tragen Sie unter Domains → Einstellungen für das Demo-System und das Live-System je Benutzername, Passwort und Kontext ein. Beim Live-System ist der Kontext in der Regel **4** (das Feld ist damit vorbelegt). Für das Demo-System nennt InterNetX den Wert; er steht nicht fest und ist deshalb frei eintragbar. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; lassen Sie das Feld später leer, um es zu behalten. - **Kontext je System:** Tragen Sie unter Domains → Einstellungen für das Demo-System und das Live-System je Benutzername, Passwort und Kontext ein. Beim Live-System ist der Kontext in der Regel **4** (das Feld ist damit vorbelegt). Für das Demo-System nennt InterNetX den Wert; er steht nicht fest und ist deshalb frei eintragbar. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; lassen Sie das Feld später leer, um es zu behalten.
- **Mit dem Demo-System beginnen:** Neue Installationen laufen im Demo-System, dort entstehen keine echten Registrierungen. Probieren Sie zuerst dort alles aus. Der Wechsel auf das Live-System (Einstellungen → System) verlangt eine ausdrückliche Bestätigung; danach zeigt die Kennzeichnung oben auf der Seite „Live-System – Registrierungen kosten Geld“. Für jedes System gelten eigene Zugangsdaten, eigene Kontakt-Zuordnungen und eigene Aufträge – ein Entwurf aus dem Demo-System kann nie im Live-System bestellt werden. - **Mit dem Demo-System beginnen:** Neue Installationen laufen im Demo-System, dort entstehen keine echten Registrierungen. Probieren Sie zuerst dort alles aus. Der Wechsel auf das Live-System (Einstellungen → System) verlangt eine ausdrückliche Bestätigung; danach zeigt die Kennzeichnung oben auf der Seite „Live-System – Registrierungen kosten Geld“. Für jedes System gelten eigene Zugangsdaten, eigene Kontakt-Zuordnungen und eigene Aufträge – ein Entwurf aus dem Demo-System kann nie im Live-System bestellt werden.
- **Verbindung testen:** Der Knopf schickt genau einen Anmeldeversuch an AutoDNS. Klicken Sie bei einer Fehlermeldung nicht wiederholt: **Mehrere falsche Anmeldungen hintereinander können den AutoDNS-Benutzer sperren.** Prüfen Sie erst Benutzername, Passwort und Kontext, speichern Sie, und testen Sie dann einmal. - **Verbindung testen:** Der Knopf schickt genau einen Anmeldeversuch an AutoDNS. Klicken Sie bei einer Fehlermeldung nicht wiederholt: **Mehrere falsche Anmeldungen hintereinander können den AutoDNS-Benutzer sperren.** Prüfen Sie erst Benutzername, Passwort und Kontext, speichern Sie, und testen Sie dann einmal.
- **Standard-Nameserver:** Unter „Standard-Nameserver“ (zwei bis sechs) hinterlegen Sie die Nameserver, die bei jeder Registrierung vorausgefüllt werden. Sie **müssen bei Ihrem Anbieter bereits eingerichtet sein**: Für `.de`-Domains prüft die Registry (DENIC) die Nameserver bei der Registrierung, und eine Registrierung mit nicht eingerichteten Nameservern scheitert („Fehlgeschlagen“). - **Nameserver kommen aus AutoDNS:** Tessera hat keine eigene Nameserver-Einstellung. Bei jeder Registrierung liest es die Standard-Nameserver aus dem AutoDNS-Benutzerprofil des in den Einstellungen eingetragenen AutoDNS-Benutzers (auch von einem übergeordneten Benutzer geerbte Werte) und verwendet sie unverändert und in der dort hinterlegten Reihenfolge. Hinterlegen Sie dort mindestens zwei Nameserver. Sie **müssen eingerichtet sein**: Für `.de`-Domains prüft die Registry (DENIC) die Nameserver bei der Registrierung, und eine Registrierung mit nicht eingerichteten Nameservern scheitert („Fehlgeschlagen“). Findet Tessera keine Nameserver oder kann es sie nicht lesen, zeigt der Reiter „Registrieren“ einen Hinweis, und die Registrierung ist nicht möglich.
- **Netzwerk:** Der API-Container braucht ausgehenden Zugriff (https) auf `api.autodns.com` (Live) und `api.demo.autodns.com` (Demo). Die Adressen sind fest eingebaut und nicht änderbar; Zertifikate werden geprüft, Weiterleitungen nicht befolgt. - **Netzwerk:** Der API-Container braucht ausgehenden Zugriff (https) auf `api.autodns.com` (Live) und `api.demo.autodns.com` (Demo). Die Adressen sind fest eingebaut und nicht änderbar; Zertifikate werden geprüft, Weiterleitungen nicht befolgt.
- **Abfragegrenze:** AutoDNS erlaubt etwa drei Anfragen pro Sekunde und IP-Adresse. Tessera hält das ein (höchstens eine Anfrage alle 350 Millisekunden, Listen seitenweise, ein Zwischenspeicher von einer Minute) und wiederholt nie selbstständig eine Anfrage. Listen zeigen höchstens die ersten 2000 Einträge. - **Abfragegrenze:** AutoDNS erlaubt etwa drei Anfragen pro Sekunde und IP-Adresse. Tessera hält das ein (höchstens eine Anfrage alle 350 Millisekunden, Listen seitenweise, ein Zwischenspeicher von einer Minute) und wiederholt nie selbstständig eine Anfrage. Listen zeigen höchstens die ersten 2000 Einträge.
- **Keine Doppelbestellung:** Jede Registrierung wird höchstens einmal an AutoDNS geschickt. Bei Zeitüberschreitung oder Verbindungsabbruch steht der Auftrag auf „Ergebnis ungeklärt“ und wird beim nächsten Abgleich (Reiter „Aufträge“) mit AutoDNS verglichen. Ein unklarer Auftrag lässt sich erst verwerfen, nachdem dieser Abgleich ohne Fund durchgelaufen ist. - **Keine Doppelbestellung:** Jede Registrierung wird höchstens einmal an AutoDNS geschickt. Bei Zeitüberschreitung oder Verbindungsabbruch steht der Auftrag auf „Ergebnis ungeklärt“ und wird beim nächsten Abgleich (Reiter „Aufträge“) mit AutoDNS verglichen. Ein unklarer Auftrag lässt sich erst verwerfen, nachdem dieser Abgleich ohne Fund durchgelaufen ist.
+1 -1
View File
@@ -174,7 +174,7 @@ Mit dem Modul „Domains“ (Gruppe **Domains**) verwalten Sie Ihre Domains bei
**Eine Domain registrieren** (Reiter „Registrieren“, nur mit „Verwalten“): **Eine Domain registrieren** (Reiter „Registrieren“, nur mit „Verwalten“):
1. Domain eingeben und **„Verfügbarkeit prüfen“**. Nur „frei“ lässt sich registrieren; der Preis für ein Jahr wird angezeigt, sofern AutoDNS ihn nennt. 1. Domain eingeben und **„Verfügbarkeit prüfen“**. Nur „frei“ lässt sich registrieren; der Preis für ein Jahr wird angezeigt, sofern AutoDNS ihn nennt.
2. Für **Inhaber, Admin-Kontakt, technischen Kontakt und Zonenkontakt** je einen Kontakt wählen. Admin-, Technik- und Zonenkontakt sind vorausgewählt (Admin wie der Inhaber, Technik und Zone wie der erste Kontakt Ihrer eigenen Firma, sonst wie der Inhaber) und lassen sich ändern. Die **Nameserver** sind aus den Einstellungen vorausgefüllt; es sind zwei bis sechs nötig. 2. Für **Inhaber, Admin-Kontakt, technischen Kontakt und Zonenkontakt** je einen Kontakt wählen. Admin-, Technik- und Zonenkontakt sind vorausgewählt (Admin wie der Inhaber, Technik und Zone wie der erste Kontakt Ihrer eigenen Firma, sonst wie der Inhaber) und lassen sich ändern. Darunter sehen Sie – nur zur Kontrolle – die in AutoDNS als Standard hinterlegten **Nameserver**, in der dort hinterlegten Reihenfolge. Sie lassen sich nur in AutoDNS ändern. Sind dort keine hinterlegt, erscheint ein Hinweis, und die Registrierung ist gesperrt.
3. **„Zusammenfassung anzeigen“** und alles prüfen: System, Domain, Laufzeit (ein Jahr), Preis, Kontakte, Nameserver. 3. **„Zusammenfassung anzeigen“** und alles prüfen: System, Domain, Laufzeit (ein Jahr), Preis, Kontakte, Nameserver.
4. Das Häkchen zur Bestätigung setzen und **„Jetzt verbindlich registrieren“** klicken. Im Live-System kostet das Geld. 4. Das Häkchen zur Bestätigung setzen und **„Jetzt verbindlich registrieren“** klicken. Im Live-System kostet das Geld.
@@ -898,7 +898,7 @@ werden.
| 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 | 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-directory.service.ts | domainsContactAssignment | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 2):** neu — Zuordnung AutoDNS-Kontakt → Kunde. Das System (Demo/Live) ist Teil des Schlüssels (`@@unique([tenantId, environment, autodnsContactId])`), weil Kontakt-Nummern beider Systeme zwangsläufig kollidieren; jede Abfrage filtert auf das AKTIVE System. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000). Bewusst KEINE `system_read_policy`. Fünf Rohtreffer über `tenantPrisma` (`findMany`, `count`, zweimal `upsert`, `deleteMany`); der Fremdschlüssel auf den Kunden steht auf `Restrict`, ein Kunde mit Zuordnungen wird nicht gelöscht. Kein `include`/relationales `select`. |
| apps/api/src/domains/domains-orders.service.ts | domainsOrder | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 3):** neu — Registrierungsaufträge des Moduls Domains (Entwurf, Anspruch, Ergebnis, Prüfspur mit Benutzer und Zeitpunkt der Bestätigung). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Organisationsdaten. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Stand wird beim Öffnen des Reiters abgeglichen. Fünfzehn Rohtreffer über `tenantPrisma`, je Methode ein eigener Klient; jeder `where` trägt `tenantId`, fremde Nummern enden als 404. Der Anspruch ist ein `updateMany` mit `status: 'DRAFT'` und dem aktiven System in der Bedingung; erst bei Zähler 1 geht `POST /domain` hinaus (Geld, höchstens einmal). Die Eindeutigkeit offener Aufträge je Domain und System sichert die Spalte `openKey` (`@@unique([tenantId, environment, openKey])`). Kein `include`/relationales `select`. | | apps/api/src/domains/domains-orders.service.ts | domainsOrder | muss-mandantengebunden | gebunden | **quick-261008-dts (Aufgabe 3):** neu — Registrierungsaufträge des Moduls Domains (Entwurf, Anspruch, Ergebnis, Prüfspur mit Benutzer und Zeitpunkt der Bestätigung). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Organisationsdaten. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Stand wird beim Öffnen des Reiters abgeglichen. Fünfzehn Rohtreffer über `tenantPrisma`, je Methode ein eigener Klient; jeder `where` trägt `tenantId`, fremde Nummern enden als 404. Der Anspruch ist ein `updateMany` mit `status: 'DRAFT'` und dem aktiven System in der Bedingung; erst bei Zähler 1 geht `POST /domain` hinaus (Geld, höchstens einmal). Die Eindeutigkeit offener Aufträge je Domain und System sichert die Spalte `openKey` (`@@unique([tenantId, environment, openKey])`). Kein `include`/relationales `select`. |
| apps/api/src/domains/domains-settings.service.ts | domainsConfig | muss-mandantengebunden | gebunden | **quick-261008-dts:** neu — Einstellungen des Moduls Domains (AutoDNS), eine Zeile je Organisation (Singleton): aktives System (Demo/Live), getrennte Zugänge je System mit AES-verschlüsseltem Passwort (CryptoService, nie an den Client zurückgegeben), Standard-Nameserver. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261008120000) — Einstellungen der Organisation, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst, der Auftragsstatus wird beim Öffnen der Seite abgefragt. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`loadRow` `findUnique`, `saveSettings` `upsert`); `where` trägt `tenantId`. | | apps/api/src/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). Spalte für Standard-Nameserver mit quick-261008-h3t entfernt (Migration 20261008160000). `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 | 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. | | 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. |
| apps/api/src/nextcloud-status/nextcloud-alert.service.ts | user | muss-mandantengebunden | gebunden | **quick-261002-kxc:** `notifySubscribers` liest die Empfänger der Abonnements (`user.findMany` mit `where: { tenantId, id: { in } }`, nur skalare Felder E-Mail, Rolle, `isActive`), gebunden an den Mandanten der Cloud. Konto-Aktivität und Modulzugriff werden beim Senden erneut geprüft (nicht erst beim Einschalten der Glocke). | | apps/api/src/nextcloud-status/nextcloud-alert.service.ts | user | muss-mandantengebunden | gebunden | **quick-261002-kxc:** `notifySubscribers` liest die Empfänger der Abonnements (`user.findMany` mit `where: { tenantId, id: { in } }`, nur skalare Felder E-Mail, Rolle, `isActive`), gebunden an den Mandanten der Cloud. Konto-Aktivität und Modulzugriff werden beim Senden erneut geprüft (nicht erst beim Einschalten der Glocke). |