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:
2026-10-02 15:23:34 +02:00
parent 6829c44464
commit faed0d760f
23 changed files with 1796 additions and 103 deletions
@@ -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())
);
+27
View File
@@ -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,
+39
View File
@@ -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);
+29
View File
@@ -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);
}
/**