diff --git a/apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql b/apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql new file mode 100644 index 0000000..b9ddbf3 --- /dev/null +++ b/apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql @@ -0,0 +1,68 @@ +-- quick-261002-kxc — Nextcloud-Status: persoenliche Benachrichtigung. +-- +-- Zweck: (1) neue Tabelle "NextcloudAlertSubscription" — wer fuer welche +-- Cloud die Glocke eingeschaltet hat (je Benutzer und Cloud hoechstens eine +-- Zeile); (2) fuenf neue Spalten an "NextcloudInstance": Zaehler und Zeitpunkt +-- der aufeinanderfolgenden Fehlschlaege (Zwei-Fehlschlaege-Regel) und der +-- zuletzt gemeldete Zustand ('ok' | 'red') samt Grund und Zeitpunkt. Der +-- gemeldete Zustand wird VOR dem Mailversand per bedingtem Update beansprucht, +-- damit mehrere API-Instanzen oder ein Neustart nie doppelt melden. +-- Bestehende Zeilen starten als 'ok' ohne Fehlschlaege. +-- +-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status und +-- 20260929140000_reminder). +-- +-- Zeilenschutz: das Abonnement ist ein persoenliches Datum, deshalb +-- `tenant_isolation_policy` MIT Benutzerdimension — exakt wie "Reminder" +-- (ohne gesetzten Benutzer gilt nur der Mandant, mit Benutzer zusaetzlich +-- "userId"). Keine `system_read_policy`: die Tabelle wird nie im +-- Systemkontext gelesen, jede Abfrage laeuft an den Mandanten gebunden. Die +-- neuen Spalten von "NextcloudInstance" fallen unter deren bestehende Regeln. +-- +-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber +-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu +-- tun. +-- +-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst, +-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter +-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md). + +-- AlterTable +ALTER TABLE "NextcloudInstance" + ADD COLUMN "consecutiveFailures" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "firstFailureAt" TIMESTAMP(3), + ADD COLUMN "alertState" TEXT NOT NULL DEFAULT 'ok', + ADD COLUMN "alertReason" TEXT, + ADD COLUMN "alertChangedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "NextcloudAlertSubscription" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "instanceId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "NextcloudAlertSubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "NextcloudAlertSubscription_instanceId_userId_key" ON "NextcloudAlertSubscription"("instanceId", "userId"); + +-- CreateIndex +CREATE INDEX "NextcloudAlertSubscription_tenantId_userId_idx" ON "NextcloudAlertSubscription"("tenantId", "userId"); + +-- AddForeignKey +ALTER TABLE "NextcloudAlertSubscription" ADD CONSTRAINT "NextcloudAlertSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NextcloudAlertSubscription" ADD CONSTRAINT "NextcloudAlertSubscription_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "NextcloudInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Zeilenschutz: Mandant UND Benutzer (Muster "Reminder") +ALTER TABLE "NextcloudAlertSubscription" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "NextcloudAlertSubscription" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation_policy ON "NextcloudAlertSubscription" + USING ( + "tenantId" = current_tenant_id() + AND (current_user_id() IS NULL OR "userId" = current_user_id()) + ); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f8ff789..40add73 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -58,6 +58,7 @@ model User { moduleGrants ModuleGrant[] customModules CustomModule[] reminders Reminder[] + nextcloudAlertSubscriptions NextcloudAlertSubscription[] @@index([tenantId]) @@index([username]) @@ -807,12 +808,38 @@ model NextcloudInstance { productName String? errorKind String? errorDetail String? + // quick-261002-kxc: Zwei-Fehlschlaege-Regel und zuletzt gemeldeter Zustand. + // consecutiveFailures/firstFailureAt: aufeinanderfolgende fehlgeschlagene + // Abrufe (der erste aendert den gespeicherten Zustand nicht). + consecutiveFailures Int @default(0) + firstFailureAt DateTime? + // zuletzt gemeldeter Zustand: 'ok' | 'red' (Anspruch vor dem Mailversand) + alertState String @default("ok") + alertReason String? + alertChangedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + subscriptions NextcloudAlertSubscription[] @@index([tenantId]) } +// quick-261002-kxc: persoenliche Benachrichtigung (Glocke) je Benutzer und +// Nextcloud-Cloud. Zeilenschutz MIT Benutzerdimension wie "Reminder"; faellt +// Benutzer oder Cloud weg, faellt das Abonnement mit. +model NextcloudAlertSubscription { + id String @id @default(uuid()) + tenantId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + instanceId String + instance NextcloudInstance @relation(fields: [instanceId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([instanceId, userId]) + @@index([tenantId, userId]) +} + // Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten- // Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle // Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId, diff --git a/apps/api/src/mail/mail.service.spec.ts b/apps/api/src/mail/mail.service.spec.ts index 43b0aec..a5b5601 100644 --- a/apps/api/src/mail/mail.service.spec.ts +++ b/apps/api/src/mail/mail.service.spec.ts @@ -319,6 +319,45 @@ describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)' }); }); +describe('MailService.sendNextcloudAlertEmail (quick-261002-kxc, L-04/L-05)', () => { + const input = { + kind: 'down' as const, + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + rating: { level: 'red' as const, reason: 'unreachable' as const, updateTo: null, eolDate: null, cycle: null }, + errorKind: 'network', + errorDetail: 'ECONNREFUSED', + at: new Date('2026-10-02T12:30:00.000Z'), + }; + + function make() { + return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any); + } + + it('sendet Betreff, Text mit Berliner Zeit und Modul-Link ueber den Transport des Mandanten; true bei Erfolg', async () => { + const ok = await make().sendNextcloudAlertEmail('t1', 'alice@a.example.invalid', input); + expect(ok).toBe(true); + const sent = mockSendMail.mock.calls[0][0] as any; + expect(sent.to).toBe('alice@a.example.invalid'); + expect(sent.from).toBe('noreply@a.example.invalid'); + expect(sent.subject).toBe('Nextcloud Kunde A: nicht erreichbar'); + expect(sent.html).toBeUndefined(); + expect(sent.text).toContain('14:30 Uhr'); + expect(sent.text).toContain('Verbindung abgelehnt'); + expect(sent.text).toContain('http://localhost:3000/modules/nextcloud-status'); + expect(mockClose).toHaveBeenCalledTimes(1); + }); + + it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => { + mockSendMail = vi.fn(async () => { + throw new Error('SMTP down'); + }); + const ok = await make().sendNextcloudAlertEmail('t1', 'a@a.example.invalid', input); + expect(ok).toBe(false); + expect(mockClose).toHaveBeenCalledTimes(1); + }); +}); + describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => { it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => { const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any); diff --git a/apps/api/src/mail/mail.service.ts b/apps/api/src/mail/mail.service.ts index ca26adf..e3de6a3 100644 --- a/apps/api/src/mail/mail.service.ts +++ b/apps/api/src/mail/mail.service.ts @@ -4,6 +4,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as nodemailer from 'nodemailer'; import type SMTPTransport from 'nodemailer/lib/smtp-transport'; +import { + buildNextcloudAlertMail, + type NextcloudAlertMailInput, +} from '../nextcloud-status/nextcloud-alert-mail'; import { SettingsService } from '../settings/settings.service'; /** @@ -406,4 +410,29 @@ export class MailService { return false; } } + + /** + * Benachrichtigung des Moduls Nextcloud-Status (quick-261002-kxc, L-04/L-05): + * Stoerung oder "wieder in Ordnung" einer Cloud. Text und Betreff baut der + * reine Baustein `buildNextcloudAlertMail` (nur Deutsch, wie die + * Erinnerungsmails). Liefert true bei Erfolg, false (und ein Protokolleintrag) + * bei Transportfehlern — der Aufrufer entscheidet ueber Wiederholungen. + */ + async sendNextcloudAlertEmail( + tenantId: string, + to: string, + input: NextcloudAlertMailInput, + ): Promise { + const { subject, text } = buildNextcloudAlertMail(input, this.appUrl); + try { + await this.deliver(tenantId, { to, subject, text }, 'NextcloudAlert'); + return true; + } catch (error) { + this.logger.error( + `Failed to send NextcloudAlert email to ${to}`, + error instanceof Error ? error.stack : String(error), + ); + return false; + } + } } 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 8d392da..a3fcef2 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -78,7 +78,7 @@ describe('Umgestellte Handler (Verwalten)', () => { }, ); - it.each(['list', 'logo'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => { + it.each(['list', 'logo', 'subscribe', 'unsubscribe'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => { const fn = handler(NextcloudStatusController, name); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); diff --git a/apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts b/apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts new file mode 100644 index 0000000..be1cbb0 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { buildNextcloudAlertMail, describeCheckError } from './nextcloud-alert-mail'; +import type { NextcloudRating } from './nextcloud-rating'; + +const APP = 'https://tessera.example.invalid'; +const AT = new Date('2026-10-02T12:30:00.000Z'); // 14:30 Uhr Berliner Zeit + +function rating( + reason: NextcloudRating['reason'], + extra: Partial = {}, +): NextcloudRating { + const level = ['current', 'update-available', 'eol-soon'].includes(reason) + ? reason === 'current' + ? 'green' + : 'yellow' + : 'red'; + return { level, reason, updateTo: null, eolDate: null, cycle: null, ...extra }; +} + +function down(over: Partial[0]> = {}) { + return buildNextcloudAlertMail( + { + kind: 'down', + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + rating: rating('unreachable'), + errorKind: 'network', + errorDetail: 'ECONNREFUSED', + at: AT, + ...over, + }, + APP, + ); +} + +describe('describeCheckError', () => { + it.each([ + ['tls', 'ERR_TLS_CERT_ALTNAME_INVALID', 'Zertifikat passt nicht zur Adresse'], + ['tls', 'HOSTNAME_MISMATCH', 'Zertifikat passt nicht zur Adresse'], + ['tls', 'CERT_HAS_EXPIRED', 'Zertifikat abgelaufen'], + ['tls', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'Zertifikat nicht vertrauenswürdig'], + ['tls', 'SELF_SIGNED_CERT_IN_CHAIN', 'Zertifikat nicht vertrauenswürdig'], + ['tls', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'Zertifikat nicht vertrauenswürdig'], + ['network', 'ENOTFOUND', 'Adresse nicht gefunden'], + ['network', 'EAI_AGAIN', 'Adresse nicht gefunden'], + ['network', 'ECONNREFUSED', 'Verbindung abgelehnt'], + ['timeout', null, 'Zeitüberschreitung'], + ['network', 'ETIMEDOUT', 'Zeitüberschreitung'], + ['http-status', 'HTTP 502', 'Server antwortet mit Fehler 502'], + ['network', null, 'Verbindungsfehler'], + ['network', 'ECONNRESET', 'Verbindungsfehler'], + ['tls', 'WAS_AUCH_IMMER', 'Verbindungsfehler'], + [null, null, 'Verbindungsfehler'], + ] as const)('%s / %s -> %s', (kind, detail, expected) => { + expect(describeCheckError(kind, detail)).toBe(expected); + }); +}); + +describe('buildNextcloudAlertMail', () => { + it('Betreff nicht erreichbar wortgleich', () => { + expect(down().subject).toBe('Nextcloud Kunde A: nicht erreichbar'); + }); + + it('Betreff wieder in Ordnung wortgleich', () => { + const mail = buildNextcloudAlertMail( + { + kind: 'up', + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + rating: rating('current'), + errorKind: null, + errorDetail: null, + at: AT, + }, + APP, + ); + expect(mail.subject).toBe('Nextcloud Kunde A: wieder in Ordnung'); + }); + + it.each([ + ['invalid-response', 'keine gültige Antwort'], + ['maintenance', 'im Wartungsmodus'], + ['needs-db-upgrade', 'Datenbank-Aktualisierung ausstehend'], + ['eol-passed', 'Support abgelaufen'], + ] as const)('Betreff je Grund: %s', (reason, wording) => { + expect(down({ rating: rating(reason) }).subject).toBe(`Nextcloud Kunde A: ${wording}`); + }); + + it('Text enthaelt Grund, Adresse, Zeit in Berliner Zeit und Link zum Modul', () => { + const { text } = down(); + expect(text.startsWith('Guten Tag,')).toBe(true); + expect(text).toContain('Grund: Nicht erreichbar (Verbindung abgelehnt)'); + expect(text).toContain('Adresse: https://cloud.a.de'); + expect(text).toContain('14:30 Uhr'); + expect(text).toContain(`Zum Modul: ${APP}/modules/nextcloud-status`); + expect(text).toContain('Benachrichtigen'); + expect(text).not.toContain('ECONNREFUSED'); + }); + + it('Gründe im Text: Wartung, Datenbank, Support mit Datum', () => { + expect(down({ rating: rating('maintenance') }).text).toContain( + 'Grund: Wartungsmodus eingeschaltet', + ); + expect(down({ rating: rating('needs-db-upgrade') }).text).toContain( + 'Grund: Datenbank-Aktualisierung ausstehend', + ); + expect(down({ rating: rating('invalid-response') }).text).toContain( + 'Grund: Keine gültige Nextcloud-Antwort', + ); + expect(down({ rating: rating('eol-passed', { eolDate: '2026-09-30' }) }).text).toContain( + 'Grund: Support abgelaufen seit 30.09.2026', + ); + expect(down({ rating: rating('eol-passed') }).text).toContain('Grund: Support abgelaufen'); + }); + + it('wieder in Ordnung nennt den aktuellen Stand', () => { + const base = { + kind: 'up' as const, + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + errorKind: null, + errorDetail: null, + at: AT, + }; + expect(buildNextcloudAlertMail({ ...base, rating: rating('current') }, APP).text).toContain( + 'Aktueller Stand: Aktuell', + ); + expect( + buildNextcloudAlertMail( + { ...base, rating: rating('update-available', { updateTo: '35.0.2' }) }, + APP, + ).text, + ).toContain('Aktueller Stand: Update auf 35.0.2 verfügbar'); + expect( + buildNextcloudAlertMail( + { ...base, rating: rating('eol-soon', { eolDate: '2026-12-31' }) }, + APP, + ).text, + ).toContain('Aktueller Stand: Support endet am 31.12.2026'); + }); + + it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen (Header-Einschleusung)', () => { + const { subject } = down({ + customerName: `Kunde\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`, + }); + expect(subject).not.toMatch(/[\r\n]/); + expect(subject.length).toBe(150); + expect(subject.startsWith('Nextcloud Kunde Bcc:')).toBe(true); + }); + + it('kein Wort fuer Mandant/Tenant in Betreff oder Text', () => { + const mails = [ + down(), + down({ rating: rating('maintenance') }), + down({ rating: rating('eol-passed', { eolDate: '2026-09-30' }) }), + buildNextcloudAlertMail( + { + kind: 'up', + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + rating: rating('current'), + errorKind: null, + errorDetail: null, + at: AT, + }, + APP, + ), + ]; + for (const mail of mails) { + expect(`${mail.subject}\n${mail.text}`).not.toMatch(/mandant|tenant/i); + } + }); +}); diff --git a/apps/api/src/nextcloud-status/nextcloud-alert-mail.ts b/apps/api/src/nextcloud-status/nextcloud-alert-mail.ts new file mode 100644 index 0000000..e1be2b1 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-alert-mail.ts @@ -0,0 +1,145 @@ +/** + * nextcloud-alert-mail — reiner Baustein der Benachrichtigungsmail + * (quick-261002-kxc, L-05, D-K6). Kein Nest, kein Zugriff auf die Uhr oder + * den Versand: Betreff und Klartext entstehen aus den Eingaben. Die Mail ist + * wie die Erinnerungsmails nur Deutsch, Zeit in Europe/Berlin. + */ + +import type { NextcloudRating } from './nextcloud-rating'; + +export interface NextcloudAlertMailInput { + kind: 'down' | 'up'; + customerName: string; + baseUrl: string; + rating: NextcloudRating; + errorKind: string | null; + errorDetail: string | null; + at: Date; +} + +const SUBJECT_MAX = 150; + +const CERT_NAME_CODES = new Set(['ERR_TLS_CERT_ALTNAME_INVALID', 'HOSTNAME_MISMATCH']); +const CERT_EXPIRED_CODES = new Set(['CERT_HAS_EXPIRED']); +const CERT_UNTRUSTED_CODES = new Set([ + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'CERT_UNTRUSTED', + 'CERT_NOT_YET_VALID', + 'CERT_REVOKED', +]); +const DNS_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN']); +const TIMEOUT_CODES = new Set(['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT']); + +/** + * Uebersetzt Fehlerart und Kurzkennung eines fehlgeschlagenen Abrufs in einen + * lesbaren deutschen Hinweis. Die Rohkennung bleibt in der Datenbank; die + * Kachel (Web) bildet dieselben Regeln ab. + */ +export function describeCheckError( + errorKind: string | null | undefined, + errorDetail: string | null | undefined, +): string { + const detail = errorDetail ?? ''; + if (errorKind === 'tls') { + if (CERT_NAME_CODES.has(detail)) return 'Zertifikat passt nicht zur Adresse'; + if (CERT_EXPIRED_CODES.has(detail)) return 'Zertifikat abgelaufen'; + if (CERT_UNTRUSTED_CODES.has(detail)) return 'Zertifikat nicht vertrauenswürdig'; + return 'Verbindungsfehler'; + } + if (errorKind === 'timeout') return 'Zeitüberschreitung'; + if (errorKind === 'http-status') { + const match = /^HTTP (\d{3})$/.exec(detail); + return match ? `Server antwortet mit Fehler ${match[1]}` : 'Verbindungsfehler'; + } + if (errorKind === 'network') { + if (DNS_CODES.has(detail)) return 'Adresse nicht gefunden'; + if (detail === 'ECONNREFUSED') return 'Verbindung abgelehnt'; + if (TIMEOUT_CODES.has(detail)) return 'Zeitüberschreitung'; + } + return 'Verbindungsfehler'; +} + +function subjectWording(input: NextcloudAlertMailInput): string { + if (input.kind === 'up') return 'wieder in Ordnung'; + switch (input.rating.reason) { + case 'invalid-response': + return 'keine gültige Antwort'; + case 'maintenance': + return 'im Wartungsmodus'; + case 'needs-db-upgrade': + return 'Datenbank-Aktualisierung ausstehend'; + case 'eol-passed': + return 'Support abgelaufen'; + default: + return 'nicht erreichbar'; + } +} + +/** 'YYYY-MM-DD' als 'TT.MM.JJJJ'. */ +function germanDay(day: string): string { + const [y, m, d] = day.split('-'); + return `${d}.${m}.${y}`; +} + +function reasonLine(input: NextcloudAlertMailInput): string { + const { rating } = input; + if (input.kind === 'up') { + if (rating.reason === 'update-available' && rating.updateTo) { + return `Aktueller Stand: Update auf ${rating.updateTo} verfügbar`; + } + if (rating.reason === 'eol-soon' && rating.eolDate) { + return `Aktueller Stand: Support endet am ${germanDay(rating.eolDate)}`; + } + return 'Aktueller Stand: Aktuell'; + } + switch (rating.reason) { + case 'invalid-response': + return 'Grund: Keine gültige Nextcloud-Antwort'; + case 'maintenance': + return 'Grund: Wartungsmodus eingeschaltet'; + case 'needs-db-upgrade': + return 'Grund: Datenbank-Aktualisierung ausstehend'; + case 'eol-passed': + return rating.eolDate + ? `Grund: Support abgelaufen seit ${germanDay(rating.eolDate)}` + : 'Grund: Support abgelaufen'; + default: + return `Grund: Nicht erreichbar (${describeCheckError(input.errorKind, input.errorDetail)})`; + } +} + +export function buildNextcloudAlertMail( + input: NextcloudAlertMailInput, + appUrl: string, +): { subject: string; text: string } { + const subject = `Nextcloud ${input.customerName}: ${subjectWording(input)}` + .replace(/[\r\n]+/g, ' ') + .slice(0, SUBJECT_MAX); + const when = `${new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', + dateStyle: 'full', + timeStyle: 'short', + }).format(input.at)} Uhr`; + const name = input.customerName.replace(/[\r\n]+/g, ' '); + const sentence = + input.kind === 'down' + ? `die Nextcloud "${name}" hat seit ${when} ein Problem.` + : `die Nextcloud "${name}" ist seit ${when} wieder in Ordnung.`; + const lines = [ + 'Guten Tag,', + '', + sentence, + '', + reasonLine(input), + `Adresse: ${input.baseUrl}`, + `Zeitpunkt: ${when}`, + '', + `Zum Modul: ${appUrl}/modules/nextcloud-status`, + '', + 'Sie erhalten diese Nachricht, weil für diese Cloud "Benachrichtigen" eingeschaltet ist. Mit der Glocke auf der Kachel können Sie das jederzeit ausschalten.', + ]; + return { subject, text: lines.join('\n') }; +} diff --git a/apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts b/apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts new file mode 100644 index 0000000..df65e5f --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest'; +import { + decideAlert, + FAILURES_FOR_RED, + planStatusWrite, + RETRY_DELAY_MS, +} from './nextcloud-alert-rules'; +import { type NextcloudReference, rateNextcloud } from './nextcloud-rating'; +import type { NextcloudCheckResult } from './nextcloud-status-fetch'; + +const NOW = new Date('2026-10-02T12:00:00Z'); +const REFERENCE: NextcloudReference = { + fetchedAt: '2026-10-02T10:00:00.000Z', + cycles: [{ cycle: 35, eol: '2099-09-30', latest: '35.0.1' }], +}; + +const OK: NextcloudCheckResult = { + reachable: true, + maintenance: false, + needsDbUpgrade: false, + versionString: '35.0.1', + edition: 'enterprise', + productName: 'Nextcloud', + errorKind: null, + errorDetail: null, +}; +const FAILED: NextcloudCheckResult = { + reachable: false, + maintenance: null, + needsDbUpgrade: null, + versionString: null, + edition: null, + productName: null, + errorKind: 'network', + errorDetail: 'ECONNREFUSED', +}; + +describe('Konstanten', () => { + it('zwei Fehlschlaege fuer Rot, Wiederholung nach fuenf Minuten', () => { + expect(FAILURES_FOR_RED).toBe(2); + expect(RETRY_DELAY_MS).toBe(5 * 60 * 1000); + }); +}); + +describe('decideAlert', () => { + it.each([ + ['ok', 'red', 'down'], + ['red', 'red', null], + ['red', 'green', 'up'], + ['red', 'yellow', 'up'], + ['red', 'unknown', null], + ['ok', 'green', null], + ['ok', 'yellow', null], + ['ok', 'unknown', null], + ] as const)('%s + %s -> %s', (prev, level, expected) => { + expect(decideAlert(prev, level)).toBe(expected); + }); +}); + +describe('planStatusWrite', () => { + it('erster Fehlschlag: nur Zaehler und Zeitpunkt, kein Statusfeld, kein lastCheckedAt', () => { + const plan = planStatusWrite(0, FAILED, NOW); + expect(plan.outcome).toBe('pending'); + expect(plan.data).toEqual({ consecutiveFailures: 1, firstFailureAt: NOW }); + expect(plan.data).not.toHaveProperty('lastCheckedAt'); + expect(plan.data).not.toHaveProperty('reachable'); + }); + + it('zweiter Fehlschlag: bestaetigt, schreibt die Fehlerfelder', () => { + const plan = planStatusWrite(1, FAILED, NOW); + expect(plan.outcome).toBe('confirmed'); + expect(plan.data).toMatchObject({ + reachable: false, + errorKind: 'network', + errorDetail: 'ECONNREFUSED', + lastCheckedAt: NOW, + consecutiveFailures: 2, + }); + expect(plan.data).not.toHaveProperty('firstFailureAt'); + }); + + it('weitere Fehlschlaege einer roten Cloud lassen den Zaehler bei 2', () => { + const plan = planStatusWrite(2, FAILED, NOW); + expect(plan.outcome).toBe('confirmed'); + expect(plan.data.consecutiveFailures).toBe(2); + }); + + it('jeder Erfolg setzt Zaehler und Zeitpunkt zurueck und schreibt alle Statusfelder', () => { + for (const prev of [0, 1, 2]) { + const plan = planStatusWrite(prev, OK, NOW); + expect(plan.outcome).toBe('ok'); + expect(plan.data).toMatchObject({ + reachable: true, + maintenance: false, + needsDbUpgrade: false, + versionString: '35.0.1', + edition: 'enterprise', + productName: 'Nextcloud', + errorKind: null, + errorDetail: null, + lastCheckedAt: NOW, + consecutiveFailures: 0, + firstFailureAt: null, + }); + } + }); + + it('Wartungsmodus ist ein Erfolg der Pruefung (sofort rot ueber die Bewertung)', () => { + const plan = planStatusWrite(0, { ...OK, maintenance: true }, NOW); + expect(plan.outcome).toBe('ok'); + expect(plan.data.maintenance).toBe(true); + }); +}); + +/** Zustand der Zeile nach dem Schreiben, als Bewertungseingabe. */ +function rateStored(stored: Record) { + return rateNextcloud( + { + checkedAt: (stored.lastCheckedAt as Date) ?? null, + reachable: (stored.reachable as boolean | null) ?? null, + maintenance: (stored.maintenance as boolean | null) ?? null, + needsDbUpgrade: (stored.needsDbUpgrade as boolean | null) ?? null, + versionString: (stored.versionString as string | null) ?? null, + errorKind: (stored.errorKind as string | null) ?? null, + }, + REFERENCE, + NOW, + ); +} + +describe('Zusammenspiel mit der Bewertung', () => { + const greenStored = { + lastCheckedAt: new Date('2026-10-02T11:00:00Z'), + reachable: true, + maintenance: false, + needsDbUpgrade: false, + versionString: '35.0.1', + errorKind: null, + }; + + it('gruene Cloud + ein Fehlschlag: bleibt gruen, keine Meldung', () => { + const plan = planStatusWrite(0, FAILED, NOW); + const rating = rateStored({ ...greenStored, ...plan.data }); + expect(rating.level).toBe('green'); + expect(decideAlert('ok', rating.level)).toBeNull(); + }); + + it('gruene Cloud + zweiter Fehlschlag: rot -> down', () => { + const plan = planStatusWrite(1, FAILED, NOW); + const rating = rateStored({ ...greenStored, ...plan.data }); + expect(rating).toMatchObject({ level: 'red', reason: 'unreachable' }); + expect(decideAlert('ok', rating.level)).toBe('down'); + }); + + it('gruene Cloud + Wartungsantwort: sofort down', () => { + const plan = planStatusWrite(0, { ...OK, maintenance: true }, NOW); + const rating = rateStored({ ...greenStored, ...plan.data }); + expect(rating).toMatchObject({ level: 'red', reason: 'maintenance' }); + expect(decideAlert('ok', rating.level)).toBe('down'); + }); + + it('rote Cloud + gruene Antwort: up', () => { + const plan = planStatusWrite(2, OK, NOW); + const rating = rateStored({ ...greenStored, ...plan.data }); + expect(rating.level).toBe('green'); + expect(decideAlert('red', rating.level)).toBe('up'); + }); + + it('nie geprueft + ein Fehlschlag: bleibt grau, keine Meldung', () => { + const plan = planStatusWrite(0, FAILED, NOW); + const rating = rateStored({ ...plan.data, lastCheckedAt: null }); + expect(rating.level).toBe('unknown'); + expect(decideAlert('ok', rating.level)).toBeNull(); + }); +}); diff --git a/apps/api/src/nextcloud-status/nextcloud-alert-rules.ts b/apps/api/src/nextcloud-status/nextcloud-alert-rules.ts new file mode 100644 index 0000000..b07dcdc --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-alert-rules.ts @@ -0,0 +1,92 @@ +/** + * nextcloud-alert-rules — reine Regeln der Benachrichtigung + * (quick-261002-kxc, L-02, L-03, D-K1, D-K2). Kein Nest, kein Prisma, kein + * Zugriff auf die Uhr: das Datum wird hereingereicht. + * + * ZWEI-FEHLSCHLAEGE-REGEL: ein einzelner fehlgeschlagener Abruf kann eine + * kurze Stoerung sein (Neustart hinter dem Proxy, Netzwackler). Deshalb gilt + * eine Cloud erst nach zwei aufeinanderfolgenden Fehlschlaegen als "nicht + * erreichbar". Der ERSTE Fehlschlag schreibt nur den Zaehler und den + * Zeitpunkt, alle Statusfelder und `lastCheckedAt` bleiben unangetastet — die + * Bewertung wird aus den gespeicherten Feldern berechnet und zeigt dadurch + * weiter den letzten guten Stand. Wartungsmodus, ausstehende + * Datenbankaktualisierung und abgelaufener Support kommen aus einer + * erfolgreichen Antwort bzw. dem Datum und zaehlen sofort. + * + * UEBERGANG: `decideAlert` vergleicht den zuletzt gemeldeten Zustand + * ('ok' | 'red') mit der Ampel. Grau aendert nichts. + */ + +import type { RatingLevel } from './nextcloud-rating'; +import type { NextcloudCheckResult } from './nextcloud-status-fetch'; + +/** Anzahl aufeinanderfolgender Fehlschlaege, ab der "nicht erreichbar" gilt. */ +export const FAILURES_FOR_RED = 2; +/** Wartezeit bis zur Wiederholung nach dem ersten Fehlschlag. */ +export const RETRY_DELAY_MS = 5 * 60 * 1000; + +/** Zuletzt gemeldeter Zustand einer Cloud. */ +export type AlertState = 'ok' | 'red'; + +/** 'down' = eben rot geworden, 'up' = wieder in Ordnung, null = nichts zu melden. */ +export type AlertTransition = 'down' | 'up' | null; + +export function decideAlert(prev: AlertState, level: RatingLevel): AlertTransition { + if (level === 'unknown') return null; + if (prev === 'ok' && level === 'red') return 'down'; + if (prev === 'red' && level !== 'red') return 'up'; + return null; +} + +export interface StatusWritePlan { + /** 'ok' = Abruf gelungen, 'pending' = erster Fehlschlag, 'confirmed' = bestaetigter Fehlschlag. */ + outcome: 'ok' | 'pending' | 'confirmed'; + data: Record; +} + +export function planStatusWrite( + prevFailures: number, + result: NextcloudCheckResult, + now: Date, +): StatusWritePlan { + if (result.reachable) { + return { + outcome: 'ok', + data: { + reachable: result.reachable, + maintenance: result.maintenance, + needsDbUpgrade: result.needsDbUpgrade, + versionString: result.versionString, + edition: result.edition, + productName: result.productName, + errorKind: result.errorKind, + errorDetail: result.errorDetail, + lastCheckedAt: now, + consecutiveFailures: 0, + firstFailureAt: null, + }, + }; + } + + const failures = prevFailures + 1; + if (failures < FAILURES_FOR_RED) { + // Erster Fehlschlag: Statusfelder und lastCheckedAt bleiben unberuehrt (D-K2). + return { outcome: 'pending', data: { consecutiveFailures: failures, firstFailureAt: now } }; + } + return { + outcome: 'confirmed', + data: { + reachable: result.reachable, + maintenance: result.maintenance, + needsDbUpgrade: result.needsDbUpgrade, + versionString: result.versionString, + edition: result.edition, + productName: result.productName, + errorKind: result.errorKind, + errorDetail: result.errorDetail, + lastCheckedAt: now, + // Gedeckelt: eine dauerhaft rote Cloud zaehlt nicht endlos weiter. + consecutiveFailures: FAILURES_FOR_RED, + }, + }; +} diff --git a/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts new file mode 100644 index 0000000..24bf0c1 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts @@ -0,0 +1,316 @@ +import { NotFoundException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../prisma/prisma-tenant.extension', () => ({ + forTenant: vi.fn((p: unknown) => p), + forSystem: vi.fn((p: unknown) => p), +})); + +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { ALERT_MAIL_RETRY_MS, NextcloudAlertService } from './nextcloud-alert.service'; +import type { NextcloudRating } from './nextcloud-rating'; + +const NOW = new Date('2026-10-02T12:30:00Z'); +const RED: NextcloudRating = { + level: 'red', + reason: 'unreachable', + updateTo: null, + eolDate: null, + cycle: null, +}; +const GREEN: NextcloudRating = { + level: 'green', + reason: 'current', + updateTo: null, + eolDate: null, + cycle: 35, +}; +const ROW = { + id: 'i1', + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + errorKind: 'network', + errorDetail: 'ECONNREFUSED', + alertState: 'ok', +}; + +function makeService() { + const prisma = { + nextcloudInstance: { findFirst: vi.fn(), updateMany: vi.fn() }, + nextcloudAlertSubscription: { + findMany: vi.fn().mockResolvedValue([]), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + user: { findMany: vi.fn().mockResolvedValue([]) }, + }; + const mail = { sendNextcloudAlertEmail: vi.fn().mockResolvedValue(true) }; + const settings = { getSmtpConfig: vi.fn().mockResolvedValue({ host: 'smtp.example.invalid' }) }; + const moduleAccess = { + getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['mod1', 'USE']])), + }; + const moduleRegistry = { findBySlug: vi.fn().mockResolvedValue({ id: 'mod1' }) }; + const service = new NextcloudAlertService( + prisma as never, + mail as never, + settings as never, + moduleAccess as never, + moduleRegistry as never, + ); + const sleep = vi.fn().mockResolvedValue(undefined); + service.sleep = sleep; + const logSpy = vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined); + const warnSpy = vi.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined); + vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + return { prisma, mail, settings, moduleAccess, moduleRegistry, service, sleep, logSpy, warnSpy }; +} + +function withSubscriber(ctx: ReturnType, user: Record = {}) { + ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 1 }); + ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([{ userId: 'u1' }]); + ctx.prisma.user.findMany.mockResolvedValue([ + { id: 'u1', email: 'u1@example.invalid', role: 'USER', isActive: true, ...user }, + ]); +} + +describe('NextcloudAlertService Abonnements', () => { + let ctx: ReturnType; + beforeEach(() => { + vi.mocked(forTenant).mockClear(); + ctx = makeService(); + }); + + it('subscribe ist idempotent (upsert) und bindet Benutzer und Mandant ueber den Klienten', async () => { + ctx.prisma.nextcloudInstance.findFirst.mockResolvedValue({ id: 'i1' }); + expect(await ctx.service.subscribe('t1', 'u1', 'i1')).toEqual({ subscribed: true }); + expect(await ctx.service.subscribe('t1', 'u1', 'i1')).toEqual({ subscribed: true }); + expect(forTenant).toHaveBeenCalledWith(ctx.prisma, 't1', 'u1'); + expect(ctx.prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({ + id: 'i1', + tenantId: 't1', + }); + const args = ctx.prisma.nextcloudAlertSubscription.upsert.mock.calls[0][0]; + expect(args.where).toEqual({ instanceId_userId: { instanceId: 'i1', userId: 'u1' } }); + expect(args.create).toEqual({ tenantId: 't1', userId: 'u1', instanceId: 'i1' }); + expect(args.update).toEqual({}); + }); + + it('subscribe fuer unbekannte oder fremde Cloud -> 404 und keine Zeile', async () => { + ctx.prisma.nextcloudInstance.findFirst.mockResolvedValue(null); + await expect(ctx.service.subscribe('t1', 'u1', 'fremd')).rejects.toThrow(NotFoundException); + expect(ctx.prisma.nextcloudAlertSubscription.upsert).not.toHaveBeenCalled(); + }); + + it('unsubscribe loescht nur die Zeile des Aufrufers', async () => { + expect(await ctx.service.unsubscribe('t1', 'u1', 'i1')).toEqual({ subscribed: false }); + expect(ctx.prisma.nextcloudAlertSubscription.deleteMany).toHaveBeenCalledWith({ + where: { tenantId: 't1', userId: 'u1', instanceId: 'i1' }, + }); + }); + + it('subscribedInstanceIds liefert nur die Kennungen des Aufrufers', async () => { + ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([ + { instanceId: 'a' }, + { instanceId: 'b' }, + ]); + const ids = await ctx.service.subscribedInstanceIds('t1', 'u1'); + expect([...ids]).toEqual(['a', 'b']); + expect(ctx.prisma.nextcloudAlertSubscription.findMany.mock.calls[0][0].where).toEqual({ + tenantId: 't1', + userId: 'u1', + }); + }); +}); + +describe('NextcloudAlertService.evaluateAfterCheck', () => { + let ctx: ReturnType; + beforeEach(() => { + ctx = makeService(); + }); + + it('Anspruch gewonnen (count 1): Mail an den berechtigten Abonnenten, Zustand wird auf rot gesetzt', async () => { + withSubscriber(ctx); + const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW); + expect(result.kind).toBe('down'); + await result.delivery; + expect(ctx.prisma.nextcloudInstance.updateMany).toHaveBeenCalledWith({ + where: { id: 'i1', tenantId: 't1', alertState: 'ok' }, + data: { alertState: 'red', alertReason: 'unreachable', alertChangedAt: NOW }, + }); + expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1); + expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledWith( + 't1', + 'u1@example.invalid', + expect.objectContaining({ kind: 'down', customerName: 'Kunde A', rating: RED, at: NOW }), + ); + }); + + it('Anspruch verloren (count 0): keine Mail, auch kein Abonnentenlesen', async () => { + withSubscriber(ctx); + ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 0 }); + const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW); + expect(result).toEqual({ kind: null, delivery: null }); + expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled(); + expect(ctx.prisma.nextcloudAlertSubscription.findMany).not.toHaveBeenCalled(); + }); + + it('rot -> rot: kein Anspruch, keine Mail', async () => { + const result = await ctx.service.evaluateAfterCheck( + 't1', + { ...ROW, alertState: 'red' }, + RED, + NOW, + ); + expect(result).toEqual({ kind: null, delivery: null }); + expect(ctx.prisma.nextcloudInstance.updateMany).not.toHaveBeenCalled(); + }); + + it('gruen -> gruen und grau aendern nichts', async () => { + await ctx.service.evaluateAfterCheck('t1', ROW, GREEN, NOW); + await ctx.service.evaluateAfterCheck( + 't1', + { ...ROW, alertState: 'red' }, + { ...GREEN, level: 'unknown', reason: 'not-checked' }, + NOW, + ); + expect(ctx.prisma.nextcloudInstance.updateMany).not.toHaveBeenCalled(); + }); + + it('rot -> gruen: genau eine "wieder in Ordnung"-Mail, Grund wird geleert', async () => { + withSubscriber(ctx); + const result = await ctx.service.evaluateAfterCheck( + 't1', + { ...ROW, alertState: 'red' }, + GREEN, + NOW, + ); + expect(result.kind).toBe('up'); + await result.delivery; + expect(ctx.prisma.nextcloudInstance.updateMany).toHaveBeenCalledWith({ + where: { id: 'i1', tenantId: 't1', alertState: 'red' }, + data: { alertState: 'ok', alertReason: null, alertChangedAt: NOW }, + }); + expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1); + expect(ctx.mail.sendNextcloudAlertEmail.mock.calls[0][2]).toMatchObject({ + kind: 'up', + rating: GREEN, + }); + }); + + it('der Pruefpfad wartet nicht auf den Versand (Hintergrund)', async () => { + withSubscriber(ctx); + let release!: (v: boolean) => void; + ctx.mail.sendNextcloudAlertEmail.mockImplementation( + () => new Promise((resolve) => (release = resolve)), + ); + const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW); + expect(result.kind).toBe('down'); + await vi.waitFor(() => expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1)); + release(true); + await result.delivery; + }); +}); + +describe('NextcloudAlertService Empfaenger (L-06)', () => { + let ctx: ReturnType; + beforeEach(() => { + ctx = makeService(); + }); + + async function run() { + const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW); + await result.delivery; + } + + it('deaktivierter Benutzer: uebersprungen und protokolliert', async () => { + withSubscriber(ctx, { isActive: false }); + await run(); + expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled(); + expect(ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('Benutzer deaktiviert'))).toBe( + true, + ); + }); + + it('ohne E-Mail-Adresse: uebersprungen und protokolliert', async () => { + withSubscriber(ctx, { email: null }); + await run(); + expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled(); + expect(ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('keine E-Mail-Adresse'))).toBe( + true, + ); + }); + + it('Modulzugriff inzwischen entzogen: uebersprungen und protokolliert', async () => { + withSubscriber(ctx); + ctx.moduleAccess.getModuleAccessLevels.mockResolvedValue(new Map()); + await run(); + expect(ctx.moduleAccess.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'u1', 'USER'); + expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled(); + expect(ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('kein Modulzugriff'))).toBe( + true, + ); + }); + + it('ohne SMTP-Einrichtung: uebersprungen und protokolliert, kein Zugriffs-Lookup', async () => { + withSubscriber(ctx); + ctx.settings.getSmtpConfig.mockResolvedValue(null); + await run(); + expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled(); + expect( + ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('kein E-Mail-Versand eingerichtet')), + ).toBe(true); + }); + + it('ein Empfaenger ohne Zugriff haelt den anderen nicht auf', async () => { + ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 1 }); + ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([ + { userId: 'u1' }, + { userId: 'u2' }, + ]); + ctx.prisma.user.findMany.mockResolvedValue([ + { id: 'u1', email: 'u1@example.invalid', role: 'USER', isActive: true }, + { id: 'u2', email: 'u2@example.invalid', role: 'ADMIN', isActive: true }, + ]); + ctx.moduleAccess.getModuleAccessLevels.mockImplementation(async (_t: string, id: string) => + id === 'u1' ? new Map() : new Map([['mod1', 'MANAGE']]), + ); + await run(); + expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1); + expect(ctx.mail.sendNextcloudAlertEmail.mock.calls[0][1]).toBe('u2@example.invalid'); + }); + + it('false, false, dann true: genau drei Aufrufe, je 60 s Abstand', async () => { + withSubscriber(ctx); + ctx.mail.sendNextcloudAlertEmail + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + await run(); + expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(3); + expect(ctx.sleep).toHaveBeenCalledTimes(2); + expect(ctx.sleep).toHaveBeenCalledWith(ALERT_MAIL_RETRY_MS); + }); + + it('dreimal false: drei Aufrufe, dann Schluss', async () => { + withSubscriber(ctx); + ctx.mail.sendNextcloudAlertEmail.mockResolvedValue(false); + await run(); + expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(3); + expect(ctx.sleep).toHaveBeenCalledTimes(2); + expect(ctx.warnSpy).toHaveBeenCalled(); + }); + + it('Erfolg beim ersten Versuch: kein Warten, ein Aufruf', async () => { + withSubscriber(ctx); + await run(); + expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1); + expect(ctx.sleep).not.toHaveBeenCalled(); + }); + + it('keine Abonnenten: nichts zu tun', async () => { + ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 1 }); + await run(); + expect(ctx.prisma.user.findMany).not.toHaveBeenCalled(); + expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/nextcloud-status/nextcloud-alert.service.ts b/apps/api/src/nextcloud-status/nextcloud-alert.service.ts new file mode 100644 index 0000000..4d8f30b --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-alert.service.ts @@ -0,0 +1,217 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { Role } from '@prisma/client'; +import { MailService } from '../mail/mail.service'; +import { ModuleAccessService } from '../module-registry/module-access.service'; +import { ModuleRegistryService } from '../module-registry/module-registry.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { forTenant } from '../prisma/prisma-tenant.extension'; +import { SettingsService } from '../settings/settings.service'; +import { type AlertState, type AlertTransition, decideAlert } from './nextcloud-alert-rules'; +import type { NextcloudRating } from './nextcloud-rating'; + +/** Slug des Moduls — Grundlage der Zugriffspruefung beim Versand (L-06). */ +const MODULE_SLUG = 'nextcloud-status'; +/** Hoechstzahl der Versuche je Empfaenger (L-04). */ +export const ALERT_MAIL_MAX_ATTEMPTS = 3; +/** Abstand zwischen zwei Versuchen (D-K5). */ +export const ALERT_MAIL_RETRY_MS = 60_000; + +/** Die Spalten einer Cloud, die fuer die Entscheidung und den Mailtext noetig sind. */ +export interface AlertCheckedRow { + id: string; + customerName: string; + baseUrl: string; + errorKind: string | null; + errorDetail: string | null; + alertState: string; +} + +export interface AlertEvaluation { + kind: AlertTransition; + /** Der laufende Versand (nur fuer Tests zum Abwarten; der Pruefpfad wartet nie darauf). */ + delivery: Promise | null; +} + +/** + * NextcloudAlertService — persoenliche Benachrichtigung (quick-261002-kxc). + * + * WARUM DER ANSPRUCH VOR DEM SENDEN STEHT (L-02, T-kxc-04): ein `updateMany` + * setzt den gemeldeten Zustand NUR, wo er noch der bisherige ist. Nur wer die + * Zeile mit `count === 1` bekommt, meldet. Zwei gleichzeitige Pruefungen, + * mehrere API-Instanzen oder ein Neustart mitten im Durchlauf verschicken so + * nie doppelt — derselbe Gedanke wie in `ReminderMailScheduler`. Der Zustand + * steht auf der Zeile, nicht im Speicher. + * + * VERSAND IM HINTERGRUND (D-K5, T-kxc-07): der Pruefpfad wartet nur auf den + * Anspruch, nie auf SMTP. Je Empfaenger bis zu drei Versuche im Abstand von + * 60 Sekunden, im Prozess. Ein Neustart zwischen den Versuchen verwirft die + * restlichen — bewusst hingenommen: eine Statusmail nach einem Neustart hat + * wenig Wert, Kachel und Meldung in Tessera zeigen den Zustand ohnehin. + * Ueberspringen (kein Versand eingerichtet, keine Adresse, deaktiviert, kein + * Modulzugriff) wird nur protokolliert und nie wiederholt. + */ +@Injectable() +export class NextcloudAlertService { + private readonly logger = new Logger(NextcloudAlertService.name); + + /** Ueberschreibbar, damit Tests nicht echte Minuten warten. */ + sleep: (ms: number) => Promise = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + constructor( + private readonly prisma: PrismaService, + private readonly mail: MailService, + private readonly settings: SettingsService, + private readonly moduleAccess: ModuleAccessService, + private readonly moduleRegistry: ModuleRegistryService, + ) {} + + /** Schaltet die Glocke ein. Idempotent; fremde oder unbekannte Cloud: 404. */ + async subscribe( + tenantId: string, + userId: string, + instanceId: string, + ): Promise<{ subscribed: true }> { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + const instance = await tenantPrisma.nextcloudInstance.findFirst({ + where: { id: instanceId, tenantId }, + select: { id: true }, + }); + if (!instance) throw new NotFoundException('Cloud nicht gefunden'); + await tenantPrisma.nextcloudAlertSubscription.upsert({ + where: { instanceId_userId: { instanceId, userId } }, + create: { tenantId, userId, instanceId }, + update: {}, + }); + return { subscribed: true }; + } + + /** Schaltet die Glocke aus. Loescht nur die Zeile des Aufrufers. */ + async unsubscribe( + tenantId: string, + userId: string, + instanceId: string, + ): Promise<{ subscribed: false }> { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + await tenantPrisma.nextcloudAlertSubscription.deleteMany({ + where: { tenantId, userId, instanceId }, + }); + return { subscribed: false }; + } + + /** Kennungen der Clouds, fuer die der Benutzer die Glocke eingeschaltet hat. */ + async subscribedInstanceIds(tenantId: string, userId: string): Promise> { + const tenantPrisma = forTenant(this.prisma, tenantId, userId); + const rows: { instanceId: string }[] = await tenantPrisma.nextcloudAlertSubscription.findMany({ + where: { tenantId, userId }, + select: { instanceId: true }, + }); + return new Set(rows.map((r) => r.instanceId)); + } + + /** + * Entscheidet nach einer Pruefung, ob eine Meldung faellig ist, beansprucht + * den Uebergang und startet den Versand im Hintergrund. Wartet nur auf den + * Anspruch. + */ + async evaluateAfterCheck( + tenantId: string, + row: AlertCheckedRow, + rating: NextcloudRating, + now: Date = new Date(), + ): Promise { + const prev: AlertState = row.alertState === 'red' ? 'red' : 'ok'; + const kind = decideAlert(prev, rating.level); + if (kind === null) return { kind: null, delivery: null }; + + const next: AlertState = kind === 'down' ? 'red' : 'ok'; + const tenantPrisma = forTenant(this.prisma, tenantId); + // Anspruch VOR dem Versand: nur wer count === 1 bekommt, meldet. + const claim = await tenantPrisma.nextcloudInstance.updateMany({ + where: { id: row.id, tenantId, alertState: prev }, + data: { + alertState: next, + alertReason: kind === 'down' ? rating.reason : null, + alertChangedAt: now, + }, + }); + if (claim.count !== 1) return { kind: null, delivery: null }; + + const delivery = this.notifySubscribers(tenantId, row, kind, rating, now).catch((err) => + this.logger.error( + `Nextcloud-Benachrichtigung für Cloud ${row.id} fehlgeschlagen: ${(err as Error).message}`, + ), + ); + return { kind, delivery }; + } + + private async notifySubscribers( + tenantId: string, + row: AlertCheckedRow, + kind: 'down' | 'up', + rating: NextcloudRating, + now: Date, + ): Promise { + const tenantPrisma = forTenant(this.prisma, tenantId); + const subs: { userId: string }[] = await tenantPrisma.nextcloudAlertSubscription.findMany({ + where: { tenantId, instanceId: row.id }, + select: { userId: true }, + }); + if (subs.length === 0) return; + + const users: { id: string; email: string | null; role: Role; isActive: boolean }[] = + await tenantPrisma.user.findMany({ + where: { tenantId, id: { in: subs.map((s) => s.userId) } }, + select: { id: true, email: true, role: true, isActive: true }, + }); + const smtp = await this.settings.getSmtpConfig(tenantId); + if (smtp === null) { + this.logger.log( + `Nextcloud-Meldung für Cloud ${row.id} übersprungen (kein E-Mail-Versand eingerichtet)`, + ); + return; + } + const module = await this.moduleRegistry.findBySlug(MODULE_SLUG); + + const deliverTo = async (user: (typeof users)[number]): Promise => { + // Zugriff und Konto werden JETZT geprueft, nicht beim Einschalten der Glocke (L-06). + if (!user.isActive) return this.skip(row.id, user.id, 'Benutzer deaktiviert'); + if (!user.email) return this.skip(row.id, user.id, 'keine E-Mail-Adresse'); + const levels = await this.moduleAccess.getModuleAccessLevels(tenantId, user.id, user.role); + if (!module || !levels.has(module.id)) { + return this.skip(row.id, user.id, 'kein Modulzugriff'); + } + for (let attempt = 1; attempt <= ALERT_MAIL_MAX_ATTEMPTS; attempt++) { + const ok = await this.mail.sendNextcloudAlertEmail(tenantId, user.email, { + kind, + customerName: row.customerName, + baseUrl: row.baseUrl, + rating, + errorKind: row.errorKind, + errorDetail: row.errorDetail, + at: now, + }); + if (ok) return; + if (attempt < ALERT_MAIL_MAX_ATTEMPTS) await this.sleep(ALERT_MAIL_RETRY_MS); + } + this.logger.warn( + `Nextcloud-Meldung für Cloud ${row.id} an Benutzer ${user.id} nach ${ALERT_MAIL_MAX_ATTEMPTS} Versuchen nicht zugestellt`, + ); + }; + + await Promise.all( + users.map((user) => + deliverTo(user).catch((err) => + this.logger.error( + `Nextcloud-Meldung für Cloud ${row.id} an Benutzer ${user.id} fehlgeschlagen: ${(err as Error).message}`, + ), + ), + ), + ); + } + + private skip(instanceId: string, userId: string, reason: string): void { + this.logger.log( + `Nextcloud-Meldung für Cloud ${instanceId} an Benutzer ${userId} übersprungen (${reason})`, + ); + } +} diff --git a/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts b/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts index bffded8..c8cc8b4 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; import { GUARDS_METADATA } from '@nestjs/common/constants'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from '../module-registry/module.guard'; import { NextcloudStatusController } from './nextcloud-status.controller'; @@ -15,7 +15,7 @@ describe('NextcloudStatusController Metadaten', () => { expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard); }); - it.each(['list', 'logo'])('%s bleibt auf Benutzen-Ebene', (name) => { + it.each(['list', 'logo', 'subscribe', 'unsubscribe'])('%s bleibt auf Benutzen-Ebene', (name) => { expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, proto[name] as object)).toBeUndefined(); }); @@ -49,3 +49,47 @@ describe('NextcloudStatusController Metadaten', () => { } }); }); + +describe('NextcloudStatusController Glocke (quick-261002-kxc)', () => { + const pathOf = (n: string) => Reflect.getMetadata('path', proto[n] as object) as string; + const methodOf = (n: string) => Reflect.getMetadata('method', proto[n] as object) as number; + + it('POST und DELETE instances/:id/subscription', () => { + expect(pathOf('subscribe')).toBe('instances/:id/subscription'); + expect(pathOf('unsubscribe')).toBe('instances/:id/subscription'); + // RequestMethod.POST = 1, DELETE = 3 + expect(methodOf('subscribe')).toBe(1); + expect(methodOf('unsubscribe')).toBe(3); + }); + + it('Benutzer kommt aus dem Token, Mandant aus der Anfrage — nie aus Body oder Pfad', async () => { + const service = { + listForTenant: vi.fn().mockResolvedValue({ instances: [] }), + checkAllForTenant: vi.fn().mockResolvedValue({ instances: [] }), + }; + const alerts = { + subscribe: vi.fn().mockResolvedValue({ subscribed: true }), + unsubscribe: vi.fn().mockResolvedValue({ subscribed: false }), + }; + const controller = new NextcloudStatusController(service as never, alerts as never); + const req = { tenantId: 't1', body: { userId: 'fremd', tenantId: 'fremd' } } as never; + const user = { id: 'u1' } as never; + + expect(await controller.subscribe(req, user, 'i1')).toEqual({ subscribed: true }); + expect(alerts.subscribe).toHaveBeenCalledWith('t1', 'u1', 'i1'); + expect(await controller.unsubscribe(req, user, 'i1')).toEqual({ subscribed: false }); + expect(alerts.unsubscribe).toHaveBeenCalledWith('t1', 'u1', 'i1'); + + await controller.list(req, user); + expect(service.listForTenant).toHaveBeenCalledWith('t1', 'u1'); + await controller.checkAll(req, user); + expect(service.checkAllForTenant).toHaveBeenCalledWith('t1', 'u1'); + }); + + it('ohne Mandantenkontext -> 403', async () => { + const controller = new NextcloudStatusController({} as never, {} as never); + await expect(controller.subscribe({} as never, { id: 'u1' } as never, 'i1')).rejects.toThrow( + 'Kein Mandantenkontext', + ); + }); +}); diff --git a/apps/api/src/nextcloud-status/nextcloud-status.controller.ts b/apps/api/src/nextcloud-status/nextcloud-status.controller.ts index 661e252..40332f1 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.controller.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.controller.ts @@ -14,12 +14,14 @@ import { } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; -import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedRequest, AuthUser, UploadedFileLike } from '../auth/types/auth-user'; import { ModuleManage, UseModule } from '../module-registry/module.guard'; import { CreateNextcloudInstanceDto, UpdateNextcloudInstanceDto, } from './dto/nextcloud-instance.dto'; +import { NextcloudAlertService } from './nextcloud-alert.service'; import { NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules'; import { NextcloudStatusService } from './nextcloud-status.service'; @@ -41,7 +43,10 @@ import { NextcloudStatusService } from './nextcloud-status.service'; @Controller('modules/nextcloud-status') @UseModule('nextcloud-status') export class NextcloudStatusController { - constructor(private readonly service: NextcloudStatusService) {} + constructor( + private readonly service: NextcloudStatusService, + private readonly alerts: NextcloudAlertService, + ) {} private requireTenantId(req: AuthenticatedRequest): string { const tenantId = req.tenantId; @@ -52,8 +57,8 @@ export class NextcloudStatusController { } @Get('instances') - async list(@Req() req: AuthenticatedRequest) { - return this.service.listForTenant(this.requireTenantId(req)); + async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) { + return this.service.listForTenant(this.requireTenantId(req), user.id); } @Post('instances') @@ -65,8 +70,8 @@ export class NextcloudStatusController { /** "Jetzt prüfen" fuer die ganze Liste — statisch, steht vor allen `:id`-Routen. */ @Post('instances/check') @ModuleManage('nextcloud-status') - async checkAll(@Req() req: AuthenticatedRequest) { - return this.service.checkAllForTenant(this.requireTenantId(req)); + async checkAll(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) { + return this.service.checkAllForTenant(this.requireTenantId(req), user.id); } @Put('instances/:id') @@ -92,6 +97,32 @@ export class NextcloudStatusController { return this.service.checkInstance(this.requireTenantId(req), id); } + /** + * Glocke "Benachrichtigen" einschalten (quick-261002-kxc, L-01, T-kxc-01, + * T-kxc-08): fuer jeden Benutzer mit Modulzugriff, bewusst OHNE + * `@ModuleManage` und ohne Rollen-Decorator. Der Benutzer kommt aus dem + * JWT, nie aus dem Body; die Kennung der Cloud wird im Dienst gegen den + * Mandanten geprueft (404 sonst). + */ + @Post('instances/:id/subscription') + async subscribe( + @Req() req: AuthenticatedRequest, + @CurrentUser() user: AuthUser, + @Param('id') id: string, + ) { + return this.alerts.subscribe(this.requireTenantId(req), user.id, id); + } + + /** Glocke ausschalten — wie `subscribe`, nur die eigene Zeile. */ + @Delete('instances/:id/subscription') + async unsubscribe( + @Req() req: AuthenticatedRequest, + @CurrentUser() user: AuthUser, + @Param('id') id: string, + ) { + return this.alerts.unsubscribe(this.requireTenantId(req), user.id, id); + } + /** * Logo-Abruf fuer jeden Benutzer mit Modulzugriff (die Kachel laedt es per * ). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private diff --git a/apps/api/src/nextcloud-status/nextcloud-status.module.ts b/apps/api/src/nextcloud-status/nextcloud-status.module.ts index 7d146e7..fb75ded 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.module.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.module.ts @@ -1,6 +1,9 @@ import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { MailModule } from '../mail/mail.module'; import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { ModuleRegistryService } from '../module-registry/module-registry.service'; +import { SettingsModule } from '../settings/settings.module'; +import { NextcloudAlertService } from './nextcloud-alert.service'; import { NextcloudReleaseService } from './nextcloud-release.service'; import { NextcloudStatusController } from './nextcloud-status.controller'; import { seedNextcloudStatusModule } from './nextcloud-status.seed'; @@ -12,9 +15,14 @@ import { NextcloudStatusSchedulerService } from './nextcloud-status-scheduler.se * Vorbild `ProxmoxModule`: seeds itself into the module registry on startup. */ @Module({ - imports: [ModuleRegistryModule], + imports: [ModuleRegistryModule, MailModule, SettingsModule], controllers: [NextcloudStatusController], - providers: [NextcloudStatusService, NextcloudReleaseService, NextcloudStatusSchedulerService], + providers: [ + NextcloudStatusService, + NextcloudReleaseService, + NextcloudAlertService, + NextcloudStatusSchedulerService, + ], }) export class NextcloudStatusModule implements OnModuleInit { private readonly logger = new Logger(NextcloudStatusModule.name); diff --git a/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts index c7c4f4e..23fa9c4 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts @@ -44,6 +44,10 @@ function makeRow(over: Record = {}) { describe('NextcloudStatusService', () => { let prisma: { nextcloudInstance: Record> }; let release: { getReference: ReturnType }; + let alerts: { + subscribedInstanceIds: ReturnType; + evaluateAfterCheck: ReturnType; + }; let service: NextcloudStatusService; beforeEach(() => { @@ -58,7 +62,11 @@ describe('NextcloudStatusService', () => { }, }; release = { getReference: vi.fn().mockResolvedValue(REFERENCE) }; - service = new NextcloudStatusService(prisma as never, release as never); + alerts = { + subscribedInstanceIds: vi.fn().mockResolvedValue(new Set()), + evaluateAfterCheck: vi.fn().mockResolvedValue({ kind: null, delivery: null }), + }; + service = new NextcloudStatusService(prisma as never, release as never, alerts as never); }); it('listForTenant waehlt keine Logo-Bytes und liefert Bewertung und neueste Version', async () => { @@ -66,7 +74,7 @@ describe('NextcloudStatusService', () => { makeRow(), makeRow({ id: 'i2', logoMime: 'image/png', logoVersion: 3 }), ]); - const result = await service.listForTenant('t1'); + const result = await service.listForTenant('t1', 'u1'); const args = prisma.nextcloudInstance.findMany.mock.calls[0][0]; expect(args.select).toBe(PUBLIC_SELECT); expect(args.select).not.toHaveProperty('logoData'); @@ -83,7 +91,7 @@ describe('NextcloudStatusService', () => { it('listForTenant ohne Vergleichsdaten bewertet grau', async () => { release.getReference.mockResolvedValue(null); prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow()]); - const result = await service.listForTenant('t1'); + const result = await service.listForTenant('t1', 'u1'); expect(result.reference).toEqual({ newestVersion: null, fetchedAt: null }); expect(result.instances[0].rating.level).toBe('unknown'); }); @@ -129,27 +137,31 @@ describe('NextcloudStatusService', () => { expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled(); }); - it('checkInstance schreibt alle Statusspalten', async () => { + const FAILED_RESULT = { + reachable: false, + maintenance: null, + needsDbUpgrade: null, + versionString: null, + edition: null, + productName: null, + errorKind: 'http-status' as const, + errorDetail: 'HTTP 503', + }; + + it('checkInstance: zweiter Fehlschlag schreibt alle Statusspalten und den Zaehler 2', async () => { prisma.nextcloudInstance.findFirst.mockResolvedValue({ id: 'i1', baseUrl: 'https://cloud.a.de', + consecutiveFailures: 1, }); - vi.mocked(fetchNextcloudStatus).mockResolvedValue({ - reachable: false, - maintenance: null, - needsDbUpgrade: null, - versionString: null, - edition: null, - productName: null, - errorKind: 'http-status', - errorDetail: 'HTTP 503', - }); + vi.mocked(fetchNextcloudStatus).mockResolvedValue(FAILED_RESULT); prisma.nextcloudInstance.update.mockResolvedValue( makeRow({ reachable: false, versionString: null, errorKind: 'http-status', errorDetail: 'HTTP 503', + alertState: 'ok', }), ); const view = await service.checkInstance('t1', 'i1'); @@ -157,8 +169,8 @@ describe('NextcloudStatusService', () => { id: 'i1', tenantId: 't1', }); - const data = prisma.nextcloudInstance.update.mock.calls[0][0].data; - expect(data).toMatchObject({ + const args = prisma.nextcloudInstance.update.mock.calls[0][0]; + expect(args.data).toMatchObject({ reachable: false, maintenance: null, needsDbUpgrade: null, @@ -166,10 +178,62 @@ describe('NextcloudStatusService', () => { edition: null, errorKind: 'http-status', errorDetail: 'HTTP 503', + consecutiveFailures: 2, }); - expect(data.lastCheckedAt).toBeInstanceOf(Date); - expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT); + expect(args.data.lastCheckedAt).toBeInstanceOf(Date); + expect(args.select).toMatchObject({ ...PUBLIC_SELECT, alertState: true }); + expect(args.select).not.toHaveProperty('logoData'); expect(view.rating).toMatchObject({ level: 'red', reason: 'unreachable' }); + // Die Meldung wird nach jeder Pruefung entschieden, mit dem Stand der Zeile + expect(alerts.evaluateAfterCheck).toHaveBeenCalledTimes(1); + const [tenant, row, rating] = alerts.evaluateAfterCheck.mock.calls[0]; + expect(tenant).toBe('t1'); + expect(row).toMatchObject({ id: 'i1', alertState: 'ok', errorKind: 'http-status' }); + expect(rating).toMatchObject({ level: 'red', reason: 'unreachable' }); + }); + + it('checkInstance: erster Fehlschlag schreibt nur Zaehler und Zeitpunkt, die Kachel behaelt den guten Stand', async () => { + prisma.nextcloudInstance.findFirst.mockResolvedValue({ + id: 'i1', + baseUrl: 'https://cloud.a.de', + consecutiveFailures: 0, + }); + vi.mocked(fetchNextcloudStatus).mockResolvedValue(FAILED_RESULT); + // Zeile bleibt im Zustand "gruen", nur der Zaehler steht auf 1 + prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'ok' })); + const view = await service.checkInstance('t1', 'i1'); + const data = prisma.nextcloudInstance.update.mock.calls[0][0].data; + expect(Object.keys(data).sort()).toEqual(['consecutiveFailures', 'firstFailureAt']); + expect(data.consecutiveFailures).toBe(1); + expect(view.rating).toMatchObject({ level: 'green', reason: 'current' }); + }); + + it('checkInstance: eine Stoerung der Meldung verwirft das Pruefergebnis nicht', async () => { + prisma.nextcloudInstance.findFirst.mockResolvedValue({ + id: 'i1', + baseUrl: 'https://cloud.a.de', + consecutiveFailures: 0, + }); + vi.mocked(fetchNextcloudStatus).mockResolvedValue({ + ...FAILED_RESULT, + reachable: true, + } as never); + prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'ok' })); + alerts.evaluateAfterCheck.mockRejectedValue(new Error('db weg')); + vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined); + const view = await service.checkInstance('t1', 'i1'); + expect(view.id).toBe('i1'); + }); + + it('listForTenant liefert subscribed je Cloud nur fuer die Abonnements des Benutzers', async () => { + prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow(), makeRow({ id: 'i2' })]); + alerts.subscribedInstanceIds.mockResolvedValue(new Set(['i2'])); + const result = await service.listForTenant('t1', 'u1'); + expect(alerts.subscribedInstanceIds).toHaveBeenCalledWith('t1', 'u1'); + expect(result.instances.map((i) => [i.id, i.subscribed])).toEqual([ + ['i1', false], + ['i2', true], + ]); }); it('checkInstance fuer unbekannte Kennung -> NotFound, kein Abruf', async () => { @@ -342,7 +406,7 @@ describe('NextcloudStatusService', () => { inFlight--; return OK_RESULT; }); - const result = await service.checkAllForTenant('t1'); + const result = await service.checkAllForTenant('t1', 'u1'); expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10); expect(peak).toBeLessThanOrEqual(4); expect(peak).toBeGreaterThan(1); @@ -358,7 +422,7 @@ describe('NextcloudStatusService', () => { async ({ where }: { where: { id: string } }) => where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' }, ); - await service.checkAllForTenant('t1'); + await service.checkAllForTenant('t1', 'u1'); expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1); }); diff --git a/apps/api/src/nextcloud-status/nextcloud-status.service.ts b/apps/api/src/nextcloud-status/nextcloud-status.service.ts index 99f4f72..da08626 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.service.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.service.ts @@ -6,6 +6,8 @@ import type { CreateNextcloudInstanceDto, UpdateNextcloudInstanceDto, } from './dto/nextcloud-instance.dto'; +import { NextcloudAlertService } from './nextcloud-alert.service'; +import { planStatusWrite } from './nextcloud-alert-rules'; import { checkLogoUpload } from './nextcloud-logo-rules'; import { type NextcloudRating, @@ -74,6 +76,12 @@ export interface NextcloudInstanceView { errorDetail: string | null; }; rating: NextcloudRating; + /** + * Glocke des anfragenden Benutzers (quick-261002-kxc, D-K10). Nur in den + * Listenantworten gesetzt; Einzelantworten lassen das Feld weg — die Seite + * behaelt dann den Stand der Kachel. + */ + subscribed?: boolean; } export interface NextcloudListView { @@ -112,6 +120,7 @@ export class NextcloudStatusService { constructor( private readonly prisma: PrismaService, private readonly release: NextcloudReleaseService, + private readonly alerts: NextcloudAlertService, ) {} private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView { @@ -137,19 +146,26 @@ export class NextcloudStatusService { }; } - /** Alle Clouds des Mandanten samt Bewertung zum Lesezeitpunkt (D-B). */ - async listForTenant(tenantId: string): Promise { + /** + * Alle Clouds des Mandanten samt Bewertung zum Lesezeitpunkt (D-B) und dem + * Glockenstand des anfragenden Benutzers (D-K10). + */ + async listForTenant(tenantId: string, userId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); - const [rows, reference] = await Promise.all([ + const [rows, reference, subscribed] = await Promise.all([ tenantPrisma.nextcloudInstance.findMany({ where: { tenantId }, orderBy: { customerName: 'asc' }, select: PUBLIC_SELECT, }), this.release.getReference(), + this.alerts.subscribedInstanceIds(tenantId, userId), ]); return { - instances: rows.map((row) => this.toView(row as PublicRow, reference)), + instances: rows.map((row) => ({ + ...this.toView(row as PublicRow, reference), + subscribed: subscribed.has((row as PublicRow).id), + })), reference: { newestVersion: newestVersion(reference), fetchedAt: reference?.fetchedAt ?? null, @@ -180,33 +196,53 @@ export class NextcloudStatusService { /** * Prueft eine Cloud (nur `status.php`, siehe `fetchNextcloudStatus`) und * schreibt das Ergebnis an die Zeile. Fremde oder unbekannte Kennung: 404. + * + * Einziger Schreibweg fuer jede Pruefung (stuendlich, Wiederholung, "Jetzt + * pruefen", Anlegen, Adressaenderung). `planStatusWrite` setzt die + * Zwei-Fehlschlaege-Regel um (ein erster Fehlschlag laesst den gespeicherten + * Zustand unveraendert), danach entscheidet `evaluateAfterCheck` ueber eine + * Meldung — es wartet nur auf den Anspruch, nie auf den Mailversand. */ async checkInstance(tenantId: string, id: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.nextcloudInstance.findFirst({ where: { id, tenantId }, - select: { id: true, baseUrl: true }, + select: { id: true, baseUrl: true, consecutiveFailures: true }, }); if (!existing) throw new NotFoundException('Cloud nicht gefunden'); const result = await fetchNextcloudStatus(existing.baseUrl); + const now = new Date(); + const plan = planStatusWrite(existing.consecutiveFailures, result, now); const updated = await tenantPrisma.nextcloudInstance.update({ where: { id }, - data: { - reachable: result.reachable, - maintenance: result.maintenance, - needsDbUpgrade: result.needsDbUpgrade, - versionString: result.versionString, - edition: result.edition, - productName: result.productName, - errorKind: result.errorKind, - errorDetail: result.errorDetail, - lastCheckedAt: new Date(), - }, - select: PUBLIC_SELECT, + data: plan.data, + select: { ...PUBLIC_SELECT, alertState: true }, }); - return this.toView(updated as PublicRow, await this.release.getReference()); + const view = this.toView(updated as PublicRow, await this.release.getReference()); + try { + await this.alerts.evaluateAfterCheck( + tenantId, + { + id, + customerName: updated.customerName, + baseUrl: updated.baseUrl, + errorKind: updated.errorKind, + errorDetail: updated.errorDetail, + alertState: updated.alertState, + }, + view.rating, + now, + ); + } catch (err) { + // Eine Stoerung der Meldung darf das Pruefergebnis nicht verwerfen. + this.logger.error( + `Nextcloud-Meldung nach Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`, + ); + } + return view; } + /** * Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird * normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine @@ -347,7 +383,7 @@ export class NextcloudStatusService { * hoechstens vier gleichzeitig, danach die frische Liste. Eine fehlerhafte * Cloud stoppt die anderen nicht. */ - async checkAllForTenant(tenantId: string): Promise { + async checkAllForTenant(tenantId: string, userId: string): Promise { const ids = await this.listInstanceIdsForTenant(tenantId); await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => { try { @@ -358,7 +394,7 @@ export class NextcloudStatusService { ); } }); - return this.listForTenant(tenantId); + return this.listForTenant(tenantId, userId); } /** diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx index 4dbfa8f..5730106 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx @@ -23,6 +23,11 @@ interface CloudTileProps { checking?: boolean; onCheck?: () => void; onEdit?: () => void; + /** Glocke "Benachrichtigen" (persoenlich, fuer jeden mit Modulzugriff, quick-261002-kxc). */ + subscribed?: boolean; + /** Das Umschalten der Glocke laeuft (Knopf gesperrt). */ + toggling?: boolean; + onToggleSubscription?: () => void; } /** @@ -39,6 +44,9 @@ export function CloudTile({ checking = false, onCheck, onEdit, + subscribed = false, + toggling = false, + onToggleSubscription, }: CloudTileProps) { const t = useTranslations('nextcloudStatus'); const locale = useLocale(); @@ -92,58 +100,86 @@ export function CloudTile({ {instance.baseUrl} - {canManage && ( -
- + {canManage && ( + <> + - + -
- )} + + + + )} +
diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx index 4252fe3..a1f4d1c 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx @@ -24,6 +24,9 @@ function render(ui: ReactElement) { const mockListInstances = vi.fn(); const mockCheckAll = vi.fn(); const mockCheckOne = vi.fn(); +const mockSubscribe = vi.fn(); +const mockUnsubscribe = vi.fn(); +const mockRequestPermission = vi.fn(); vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => { const actual = await importOriginal(); @@ -32,9 +35,15 @@ vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => { listInstances: (...args: unknown[]) => mockListInstances(...args), checkAll: (...args: unknown[]) => mockCheckAll(...args), checkOne: (...args: unknown[]) => mockCheckOne(...args), + subscribe: (...args: unknown[]) => mockSubscribe(...args), + unsubscribe: (...args: unknown[]) => mockUnsubscribe(...args), }; }); +vi.mock('@/lib/reminder-notify', () => ({ + requestBrowserPermissionOnce: () => mockRequestPermission(), +})); + let mockCanManage: boolean | null = false; vi.mock('@/lib/use-module-capability', () => ({ useCanManageModule: () => mockCanManage, @@ -127,6 +136,9 @@ describe('NextcloudStatusPage', () => { mockListInstances.mockReset(); mockCheckAll.mockReset(); mockCheckOne.mockReset(); + mockSubscribe.mockReset().mockResolvedValue(undefined); + mockUnsubscribe.mockReset().mockResolvedValue(undefined); + mockRequestPermission.mockReset(); mockCanManage = false; window.localStorage.clear(); }); @@ -285,4 +297,85 @@ describe('NextcloudStatusPage', () => { expect(screen.getByRole('dialog')).toBeInTheDocument(); }); }); + + describe('Glocke „Benachrichtigen“ (quick-261002-kxc)', () => { + const tileOf = (name: string) => + screen + .getAllByTestId('cloud-tile') + .find((t) => t.getAttribute('aria-label')?.startsWith(name)) as HTMLElement; + + const withBells = (subscribedIds: string[]): NextcloudList => ({ + ...LIST, + instances: LIST.instances.map((i) => ({ ...i, subscribed: subscribedIds.includes(i.id) })), + }); + + it('ist auf der Benutzen-Ebene an jeder Kachel sichtbar und zeigt den gemerkten Stand', async () => { + mockCanManage = false; + mockListInstances.mockResolvedValue(withBells(['b'])); + render(); + await screen.findAllByTestId('cloud-tile'); + expect(screen.getAllByRole('button', { name: 'Benachrichtigen' })).toHaveLength(4); + expect( + within(tileOf('Kunde Gelb')).getByRole('button', { name: 'Benachrichtigen' }), + ).toHaveAttribute('aria-pressed', 'true'); + expect( + within(tileOf('Kunde Grün')).getByRole('button', { name: 'Benachrichtigen' }), + ).toHaveAttribute('aria-pressed', 'false'); + }); + + it('Klick schaltet ein (subscribe, Browser-Erlaubnis einmalig angefragt) und wieder aus (unsubscribe)', async () => { + mockListInstances.mockResolvedValue(withBells([])); + render(); + await screen.findAllByTestId('cloud-tile'); + const bell = () => + within(tileOf('Kunde Grün')).getByRole('button', { name: 'Benachrichtigen' }); + + fireEvent.click(bell()); + await waitFor(() => expect(mockSubscribe).toHaveBeenCalledWith('a')); + await waitFor(() => expect(bell()).toHaveAttribute('aria-pressed', 'true')); + expect(mockRequestPermission).toHaveBeenCalledTimes(1); + expect(mockUnsubscribe).not.toHaveBeenCalled(); + + fireEvent.click(bell()); + await waitFor(() => expect(mockUnsubscribe).toHaveBeenCalledWith('a')); + await waitFor(() => expect(bell()).toHaveAttribute('aria-pressed', 'false')); + expect(mockRequestPermission).toHaveBeenCalledTimes(1); + }); + + it('bei einem Fehler wird zurückgenommen und der Hinweis gezeigt', async () => { + mockListInstances.mockResolvedValue(withBells([])); + mockSubscribe.mockRejectedValue(new Error('nein')); + render(); + await screen.findAllByTestId('cloud-tile'); + const bell = () => + within(tileOf('Kunde Grün')).getByRole('button', { name: 'Benachrichtigen' }); + fireEvent.click(bell()); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Die Benachrichtigung konnte nicht geändert werden.', + ); + expect(bell()).toHaveAttribute('aria-pressed', 'false'); + expect(mockRequestPermission).not.toHaveBeenCalled(); + }); + + it('die Prüfung einer einzelnen Kachel behält den Stand der Glocke', async () => { + mockCanManage = true; + mockListInstances.mockResolvedValue(withBells(['c'])); + // Einzelantwort ohne `subscribed` + mockCheckOne.mockResolvedValue({ ...LIST.instances[2] }); + render(); + await screen.findAllByTestId('cloud-tile'); + fireEvent.click( + within(tileOf('Kunde Rot')).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }), + ); + await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c')); + await waitFor(() => + expect( + within(tileOf('Kunde Rot')).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }), + ).not.toBeDisabled(), + ); + expect( + within(tileOf('Kunde Rot')).getByRole('button', { name: 'Benachrichtigen' }), + ).toHaveAttribute('aria-pressed', 'true'); + }); + }); }); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx index 6a791d3..97c443a 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx @@ -17,7 +17,10 @@ import { listInstances, type NextcloudInstance, type NextcloudList, + subscribe, + unsubscribe, } from '@/lib/nextcloud-status-api'; +import { requestBrowserPermissionOnce } from '@/lib/reminder-notify'; import { useAuthStore } from '@/lib/stores/auth-store'; import { useCanManageModule } from '@/lib/use-module-capability'; import { CloudForm } from './components/CloudForm'; @@ -83,6 +86,8 @@ export default function NextcloudStatusPage() { const [sortKey, setSortKey] = useState('name'); const [checkingAll, setCheckingAll] = useState(false); const [checkingIds, setCheckingIds] = useState>(new Set()); + const [togglingIds, setTogglingIds] = useState>(new Set()); + const [bellError, setBellError] = useState(false); // `undefined` = Formular zu, `null` = neue Cloud, sonst bearbeiten. const [editing, setEditing] = useState(undefined); @@ -133,7 +138,13 @@ export default function NextcloudStatusPage() { const updated = await checkOne(id); setData((prev) => prev - ? { ...prev, instances: prev.instances.map((i) => (i.id === id ? updated : i)) } + ? { + ...prev, + // Die Einzelantwort traegt keine Glocke: der Stand der Kachel bleibt (D-K10). + instances: prev.instances.map((i) => + i.id === id ? { ...updated, subscribed: i.subscribed } : i, + ), + } : prev, ); setNow(Date.now()); @@ -148,6 +159,42 @@ export default function NextcloudStatusPage() { } }; + const setSubscribed = (id: string, value: boolean) => + setData((prev) => + prev + ? { + ...prev, + instances: prev.instances.map((i) => (i.id === id ? { ...i, subscribed: value } : i)), + } + : prev, + ); + + // Glocke umschalten: sofort anzeigen, bei einem Fehler zuruecknehmen und den Hinweis zeigen. + const handleToggleSubscription = async (instance: NextcloudInstance) => { + const turnOn = !instance.subscribed; + setBellError(false); + setTogglingIds((prev) => new Set(prev).add(instance.id)); + setSubscribed(instance.id, turnOn); + try { + if (turnOn) { + await subscribe(instance.id); + // Beim ersten Einschalten fragt der Browser einmalig nach der Erlaubnis fuer Meldungen. + requestBrowserPermissionOnce(); + } else { + await unsubscribe(instance.id); + } + } catch { + setSubscribed(instance.id, !turnOn); + setBellError(true); + } finally { + setTogglingIds((prev) => { + const next = new Set(prev); + next.delete(instance.id); + return next; + }); + } + }; + // Nach Anlegen/Aendern/Loeschen die Liste neu laden: die Reihenfolge und die // neueste Version kommen so wieder aus einer Quelle. const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]); @@ -187,6 +234,15 @@ export default function NextcloudStatusPage() {

)} + {bellError && ( +

+ {t('bell.error')} +

+ )} + {!error && data === null && (
handleCheckOne(instance.id)} onEdit={() => setEditing(instance)} + subscribed={instance.subscribed === true} + toggling={togglingIds.has(instance.id)} + onToggleSubscription={() => handleToggleSubscription(instance)} /> ))} diff --git a/apps/web/src/lib/nextcloud-status-api.ts b/apps/web/src/lib/nextcloud-status-api.ts index 2413f2c..a08ecc5 100644 --- a/apps/web/src/lib/nextcloud-status-api.ts +++ b/apps/web/src/lib/nextcloud-status-api.ts @@ -55,6 +55,12 @@ export interface NextcloudInstance { logoVersion: number; status: NextcloudInstanceStatus; rating: NextcloudRating; + /** + * Glocke des angemeldeten Benutzers (quick-261002-kxc). Nur die Listen- + * antworten tragen das Feld; fehlt es (Einzelantwort), gilt der bisherige + * Stand der Kachel. Ohne Angabe = aus. + */ + subscribed?: boolean; } export interface NextcloudList { @@ -174,3 +180,18 @@ export async function removeLogo(id: string): Promise { if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to remove logo')); return res.json(); } + +/** POST /instances/:id/subscription — Glocke "Benachrichtigen" einschalten (jeder mit Modulzugriff). */ +export async function subscribe(id: string): Promise { + const res = await fetch(`${BASE}/${id}/subscription`, { method: 'POST', credentials: 'include' }); + if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to subscribe')); +} + +/** DELETE /instances/:id/subscription — Glocke ausschalten. */ +export async function unsubscribe(id: string): Promise { + const res = await fetch(`${BASE}/${id}/subscription`, { + method: 'DELETE', + credentials: 'include', + }); + if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to unsubscribe')); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index b3e60c8..7db77e6 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1872,6 +1872,12 @@ "refresh": "Diese Cloud jetzt prüfen", "edit": "Cloud bearbeiten" }, + "bell": { + "label": "Benachrichtigen", + "titleOn": "Benachrichtigung ist eingeschaltet – Klick schaltet sie aus", + "titleOff": "Bei Störung und Wiederherstellung benachrichtigen", + "error": "Die Benachrichtigung konnte nicht geändert werden. Bitte versuchen Sie es erneut." + }, "form": { "addTitle": "Cloud hinzufügen", "editTitle": "Cloud bearbeiten", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 80d541f..a9394ea 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1872,6 +1872,12 @@ "refresh": "Check this cloud now", "edit": "Edit cloud" }, + "bell": { + "label": "Notify me", + "titleOn": "Notifications are on – click to turn them off", + "titleOff": "Notify me about outages and recovery", + "error": "The notification setting could not be changed. Please try again." + }, "form": { "addTitle": "Add cloud", "editTitle": "Edit cloud", diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index 9f69bc1..0e5e9a2 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -180,8 +180,8 @@ 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. | -| nextcloud-status | 0 | 14 | 1 | **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** | **61** | **264** | **8** | **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 | +| nextcloud-status | 0 | 21 | 1 | **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** | **61** | **271** | **8** | **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) @@ -404,6 +404,11 @@ quick-261002-k67 (Aufgabe 1): +1 `muss-mandantengebunden` (`nextcloud-status.ser quick-261002-k67 (Aufgabe 2): Paarzahl unverändert (93); der Stand des Paars `nextcloud-status.service.ts`/`nextcloudInstance` wechselt von `gebunden` auf `system-gebunden` (siehe oben). +quick-261002-kxc (Aufgabe 1): +3 `muss-mandantengebunden` (`nextcloud-alert.service.ts`/`nextcloudAlertSubscription`, +`/nextcloudInstance` und `/user`, alle `gebunden`): 96 Paare, davon 56 `muss-mandantengebunden`, +22 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit +`grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. + ## Der Hintergrunddienst als Falle — sechs Fälle Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend — @@ -855,6 +860,9 @@ 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/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 }`. | +| 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-status.service.ts | nextcloudInstance | muss-mandantengebunden | system-gebunden | **quick-261002-k67:** neu — die vom Verwalter eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Versionstext, Fehlerart) auf derselben Zeile. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002150000, Form aus `ProxmoxServer`) — gemeinsame Daten der Organisation, nicht persönliche Daten eines Benutzers. Stand `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadAllInstancesForScheduler()` liest für den stündlichen Planer `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "NextcloudInstance", Migration 20261002150000) nur Kennung und Mandant ALLER Clouds (`select: { id, tenantId }`, nie Adresse oder Logo-Bytes). Geprüft und geschrieben wird danach je Cloud mandantengebunden (`checkInstance`, `where: { id, tenantId }`). Vierzehn mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `listForTenant`, `createInstance`, `checkInstance` (zwei), `updateInstance` (zwei), `deleteInstance` (zwei), `uploadLogo` (zwei), `getLogo`, `removeLogo` (zwei), `listInstanceIdsForTenant`; dazu der eine System-Rohtreffer. Alle Abfragen außer `getLogo` wählen die Logo-Bytes per `select` gar nicht erst aus (T-k67-07); über `where: { id, tenantId }` liefert eine fremde Kennung 404. | ## Was diese Etappe NICHT entscheidet