feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung
- Glocke je Kachel (persönlich, Benutzen-Ebene), Tabelle NextcloudAlertSubscription mit RLS - Zwei-Fehlschläge-Regel und gemeldeter Zustand auf der Zeile, Anspruch vor dem Mailversand - Mail (nur Deutsch) über MailService, bis zu drei Versuche, Zugriff beim Senden erneut geprüft - Fehlercodes in der Mail als lesbarer Hinweis Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> = {},
|
||||
): 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<Parameters<typeof buildNextcloudAlertMail>[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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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') };
|
||||
}
|
||||
@@ -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<string, unknown>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<typeof makeService>, user: Record<string, unknown> = {}) {
|
||||
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<typeof makeService>;
|
||||
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<typeof makeService>;
|
||||
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<boolean>((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<typeof makeService>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void> | 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<void> = (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<Set<string>> {
|
||||
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<AlertEvaluation> {
|
||||
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<void> {
|
||||
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<void> => {
|
||||
// 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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
* <img>). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -44,6 +44,10 @@ function makeRow(over: Record<string, unknown> = {}) {
|
||||
describe('NextcloudStatusService', () => {
|
||||
let prisma: { nextcloudInstance: Record<string, ReturnType<typeof vi.fn>> };
|
||||
let release: { getReference: ReturnType<typeof vi.fn> };
|
||||
let alerts: {
|
||||
subscribedInstanceIds: ReturnType<typeof vi.fn>;
|
||||
evaluateAfterCheck: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
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<string>()),
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<NextcloudListView> {
|
||||
/**
|
||||
* 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<NextcloudListView> {
|
||||
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<NextcloudInstanceView> {
|
||||
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<NextcloudListView> {
|
||||
async checkAllForTenant(tenantId: string, userId: string): Promise<NextcloudListView> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
</a>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCheck}
|
||||
disabled={checking}
|
||||
aria-label={t('cardActions.refresh')}
|
||||
title={t('cardActions.refresh')}
|
||||
className="btn btn-subtle"
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSubscription}
|
||||
disabled={toggling}
|
||||
aria-pressed={subscribed}
|
||||
aria-label={t('bell.label')}
|
||||
title={subscribed ? t('bell.titleOn') : t('bell.titleOff')}
|
||||
data-testid="bell-toggle"
|
||||
className={`btn btn-subtle ${subscribed ? 'text-primary' : ''}`}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill={subscribed ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={checking ? 'animate-spin motion-reduce:animate-none' : ''}
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
</button>
|
||||
{canManage && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCheck}
|
||||
disabled={checking}
|
||||
aria-label={t('cardActions.refresh')}
|
||||
title={t('cardActions.refresh')}
|
||||
className="btn btn-subtle"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||
<polyline points="21 3 21 8 16 8" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={t('cardActions.edit')}
|
||||
title={t('cardActions.edit')}
|
||||
className="btn btn-subtle"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={checking ? 'animate-spin motion-reduce:animate-none' : ''}
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
|
||||
<polyline points="21 3 21 8 16 8" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={t('cardActions.edit')}
|
||||
title={t('cardActions.edit')}
|
||||
className="btn btn-subtle"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
|
||||
@@ -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<typeof import('@/lib/nextcloud-status-api')>();
|
||||
@@ -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(<NextcloudStatusPage />);
|
||||
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(<NextcloudStatusPage />);
|
||||
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(<NextcloudStatusPage />);
|
||||
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(<NextcloudStatusPage />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<SortKey>('name');
|
||||
const [checkingAll, setCheckingAll] = useState(false);
|
||||
const [checkingIds, setCheckingIds] = useState<ReadonlySet<string>>(new Set());
|
||||
const [togglingIds, setTogglingIds] = useState<ReadonlySet<string>>(new Set());
|
||||
const [bellError, setBellError] = useState(false);
|
||||
// `undefined` = Formular zu, `null` = neue Cloud, sonst bearbeiten.
|
||||
const [editing, setEditing] = useState<NextcloudInstance | null | undefined>(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() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{bellError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground"
|
||||
>
|
||||
{t('bell.error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && data === null && (
|
||||
<div
|
||||
aria-busy="true"
|
||||
@@ -242,6 +298,9 @@ export default function NextcloudStatusPage() {
|
||||
checking={checkingAll || checkingIds.has(instance.id)}
|
||||
onCheck={() => handleCheckOne(instance.id)}
|
||||
onEdit={() => setEditing(instance)}
|
||||
subscribed={instance.subscribed === true}
|
||||
toggling={togglingIds.has(instance.id)}
|
||||
onToggleSubscription={() => handleToggleSubscription(instance)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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<NextcloudInstance> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const res = await fetch(`${BASE}/${id}/subscription`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to unsubscribe'));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user