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[] moduleGrants ModuleGrant[]
customModules CustomModule[] customModules CustomModule[]
reminders Reminder[] reminders Reminder[]
nextcloudAlertSubscriptions NextcloudAlertSubscription[]
@@index([tenantId]) @@index([tenantId])
@@index([username]) @@index([username])
@@ -807,12 +808,38 @@ model NextcloudInstance {
productName String? productName String?
errorKind String? errorKind String?
errorDetail 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
subscriptions NextcloudAlertSubscription[]
@@index([tenantId]) @@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- // Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle // Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId, // 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)', () => { describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => { 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); 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 path from 'node:path';
import * as nodemailer from 'nodemailer'; import * as nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport'; import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import {
buildNextcloudAlertMail,
type NextcloudAlertMailInput,
} from '../nextcloud-status/nextcloud-alert-mail';
import { SettingsService } from '../settings/settings.service'; import { SettingsService } from '../settings/settings.service';
/** /**
@@ -406,4 +410,29 @@ export class MailService {
return false; 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); const fn = handler(NextcloudStatusController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined(); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_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 'reflect-metadata';
import { GUARDS_METADATA } from '@nestjs/common/constants'; 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 { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from '../module-registry/module.guard'; import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from '../module-registry/module.guard';
import { NextcloudStatusController } from './nextcloud-status.controller'; import { NextcloudStatusController } from './nextcloud-status.controller';
@@ -15,7 +15,7 @@ describe('NextcloudStatusController Metadaten', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard); 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(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_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'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from '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 { ModuleManage, UseModule } from '../module-registry/module.guard';
import { import {
CreateNextcloudInstanceDto, CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto, UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto'; } from './dto/nextcloud-instance.dto';
import { NextcloudAlertService } from './nextcloud-alert.service';
import { NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules'; import { NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
import { NextcloudStatusService } from './nextcloud-status.service'; import { NextcloudStatusService } from './nextcloud-status.service';
@@ -41,7 +43,10 @@ import { NextcloudStatusService } from './nextcloud-status.service';
@Controller('modules/nextcloud-status') @Controller('modules/nextcloud-status')
@UseModule('nextcloud-status') @UseModule('nextcloud-status')
export class NextcloudStatusController { export class NextcloudStatusController {
constructor(private readonly service: NextcloudStatusService) {} constructor(
private readonly service: NextcloudStatusService,
private readonly alerts: NextcloudAlertService,
) {}
private requireTenantId(req: AuthenticatedRequest): string { private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId; const tenantId = req.tenantId;
@@ -52,8 +57,8 @@ export class NextcloudStatusController {
} }
@Get('instances') @Get('instances')
async list(@Req() req: AuthenticatedRequest) { async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
return this.service.listForTenant(this.requireTenantId(req)); return this.service.listForTenant(this.requireTenantId(req), user.id);
} }
@Post('instances') @Post('instances')
@@ -65,8 +70,8 @@ export class NextcloudStatusController {
/** "Jetzt prüfen" fuer die ganze Liste — statisch, steht vor allen `:id`-Routen. */ /** "Jetzt prüfen" fuer die ganze Liste — statisch, steht vor allen `:id`-Routen. */
@Post('instances/check') @Post('instances/check')
@ModuleManage('nextcloud-status') @ModuleManage('nextcloud-status')
async checkAll(@Req() req: AuthenticatedRequest) { async checkAll(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
return this.service.checkAllForTenant(this.requireTenantId(req)); return this.service.checkAllForTenant(this.requireTenantId(req), user.id);
} }
@Put('instances/:id') @Put('instances/:id')
@@ -92,6 +97,32 @@ export class NextcloudStatusController {
return this.service.checkInstance(this.requireTenantId(req), id); 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 * Logo-Abruf fuer jeden Benutzer mit Modulzugriff (die Kachel laedt es per
* <img>). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private * <img>). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private
@@ -1,6 +1,9 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { MailModule } from '../mail/mail.module';
import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service'; 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 { NextcloudReleaseService } from './nextcloud-release.service';
import { NextcloudStatusController } from './nextcloud-status.controller'; import { NextcloudStatusController } from './nextcloud-status.controller';
import { seedNextcloudStatusModule } from './nextcloud-status.seed'; 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. * Vorbild `ProxmoxModule`: seeds itself into the module registry on startup.
*/ */
@Module({ @Module({
imports: [ModuleRegistryModule], imports: [ModuleRegistryModule, MailModule, SettingsModule],
controllers: [NextcloudStatusController], controllers: [NextcloudStatusController],
providers: [NextcloudStatusService, NextcloudReleaseService, NextcloudStatusSchedulerService], providers: [
NextcloudStatusService,
NextcloudReleaseService,
NextcloudAlertService,
NextcloudStatusSchedulerService,
],
}) })
export class NextcloudStatusModule implements OnModuleInit { export class NextcloudStatusModule implements OnModuleInit {
private readonly logger = new Logger(NextcloudStatusModule.name); private readonly logger = new Logger(NextcloudStatusModule.name);
@@ -44,6 +44,10 @@ function makeRow(over: Record<string, unknown> = {}) {
describe('NextcloudStatusService', () => { describe('NextcloudStatusService', () => {
let prisma: { nextcloudInstance: Record<string, ReturnType<typeof vi.fn>> }; let prisma: { nextcloudInstance: Record<string, ReturnType<typeof vi.fn>> };
let release: { getReference: 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; let service: NextcloudStatusService;
beforeEach(() => { beforeEach(() => {
@@ -58,7 +62,11 @@ describe('NextcloudStatusService', () => {
}, },
}; };
release = { getReference: vi.fn().mockResolvedValue(REFERENCE) }; 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 () => { it('listForTenant waehlt keine Logo-Bytes und liefert Bewertung und neueste Version', async () => {
@@ -66,7 +74,7 @@ describe('NextcloudStatusService', () => {
makeRow(), makeRow(),
makeRow({ id: 'i2', logoMime: 'image/png', logoVersion: 3 }), 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]; const args = prisma.nextcloudInstance.findMany.mock.calls[0][0];
expect(args.select).toBe(PUBLIC_SELECT); expect(args.select).toBe(PUBLIC_SELECT);
expect(args.select).not.toHaveProperty('logoData'); expect(args.select).not.toHaveProperty('logoData');
@@ -83,7 +91,7 @@ describe('NextcloudStatusService', () => {
it('listForTenant ohne Vergleichsdaten bewertet grau', async () => { it('listForTenant ohne Vergleichsdaten bewertet grau', async () => {
release.getReference.mockResolvedValue(null); release.getReference.mockResolvedValue(null);
prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow()]); 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.reference).toEqual({ newestVersion: null, fetchedAt: null });
expect(result.instances[0].rating.level).toBe('unknown'); expect(result.instances[0].rating.level).toBe('unknown');
}); });
@@ -129,27 +137,31 @@ describe('NextcloudStatusService', () => {
expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled(); expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled();
}); });
it('checkInstance schreibt alle Statusspalten', async () => { const FAILED_RESULT = {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
});
vi.mocked(fetchNextcloudStatus).mockResolvedValue({
reachable: false, reachable: false,
maintenance: null, maintenance: null,
needsDbUpgrade: null, needsDbUpgrade: null,
versionString: null, versionString: null,
edition: null, edition: null,
productName: null, productName: null,
errorKind: 'http-status', errorKind: 'http-status' as const,
errorDetail: 'HTTP 503', 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(FAILED_RESULT);
prisma.nextcloudInstance.update.mockResolvedValue( prisma.nextcloudInstance.update.mockResolvedValue(
makeRow({ makeRow({
reachable: false, reachable: false,
versionString: null, versionString: null,
errorKind: 'http-status', errorKind: 'http-status',
errorDetail: 'HTTP 503', errorDetail: 'HTTP 503',
alertState: 'ok',
}), }),
); );
const view = await service.checkInstance('t1', 'i1'); const view = await service.checkInstance('t1', 'i1');
@@ -157,8 +169,8 @@ describe('NextcloudStatusService', () => {
id: 'i1', id: 'i1',
tenantId: 't1', tenantId: 't1',
}); });
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data; const args = prisma.nextcloudInstance.update.mock.calls[0][0];
expect(data).toMatchObject({ expect(args.data).toMatchObject({
reachable: false, reachable: false,
maintenance: null, maintenance: null,
needsDbUpgrade: null, needsDbUpgrade: null,
@@ -166,10 +178,62 @@ describe('NextcloudStatusService', () => {
edition: null, edition: null,
errorKind: 'http-status', errorKind: 'http-status',
errorDetail: 'HTTP 503', errorDetail: 'HTTP 503',
consecutiveFailures: 2,
}); });
expect(data.lastCheckedAt).toBeInstanceOf(Date); expect(args.data.lastCheckedAt).toBeInstanceOf(Date);
expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT); expect(args.select).toMatchObject({ ...PUBLIC_SELECT, alertState: true });
expect(args.select).not.toHaveProperty('logoData');
expect(view.rating).toMatchObject({ level: 'red', reason: 'unreachable' }); 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 () => { it('checkInstance fuer unbekannte Kennung -> NotFound, kein Abruf', async () => {
@@ -342,7 +406,7 @@ describe('NextcloudStatusService', () => {
inFlight--; inFlight--;
return OK_RESULT; return OK_RESULT;
}); });
const result = await service.checkAllForTenant('t1'); const result = await service.checkAllForTenant('t1', 'u1');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10); expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10);
expect(peak).toBeLessThanOrEqual(4); expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThan(1); expect(peak).toBeGreaterThan(1);
@@ -358,7 +422,7 @@ describe('NextcloudStatusService', () => {
async ({ where }: { where: { id: string } }) => async ({ where }: { where: { id: string } }) =>
where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' }, 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); expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
}); });
@@ -6,6 +6,8 @@ import type {
CreateNextcloudInstanceDto, CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto, UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto'; } from './dto/nextcloud-instance.dto';
import { NextcloudAlertService } from './nextcloud-alert.service';
import { planStatusWrite } from './nextcloud-alert-rules';
import { checkLogoUpload } from './nextcloud-logo-rules'; import { checkLogoUpload } from './nextcloud-logo-rules';
import { import {
type NextcloudRating, type NextcloudRating,
@@ -74,6 +76,12 @@ export interface NextcloudInstanceView {
errorDetail: string | null; errorDetail: string | null;
}; };
rating: NextcloudRating; 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 { export interface NextcloudListView {
@@ -112,6 +120,7 @@ export class NextcloudStatusService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly release: NextcloudReleaseService, private readonly release: NextcloudReleaseService,
private readonly alerts: NextcloudAlertService,
) {} ) {}
private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView { 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 tenantPrisma = forTenant(this.prisma, tenantId);
const [rows, reference] = await Promise.all([ const [rows, reference, subscribed] = await Promise.all([
tenantPrisma.nextcloudInstance.findMany({ tenantPrisma.nextcloudInstance.findMany({
where: { tenantId }, where: { tenantId },
orderBy: { customerName: 'asc' }, orderBy: { customerName: 'asc' },
select: PUBLIC_SELECT, select: PUBLIC_SELECT,
}), }),
this.release.getReference(), this.release.getReference(),
this.alerts.subscribedInstanceIds(tenantId, userId),
]); ]);
return { 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: { reference: {
newestVersion: newestVersion(reference), newestVersion: newestVersion(reference),
fetchedAt: reference?.fetchedAt ?? null, fetchedAt: reference?.fetchedAt ?? null,
@@ -180,33 +196,53 @@ export class NextcloudStatusService {
/** /**
* Prueft eine Cloud (nur `status.php`, siehe `fetchNextcloudStatus`) und * Prueft eine Cloud (nur `status.php`, siehe `fetchNextcloudStatus`) und
* schreibt das Ergebnis an die Zeile. Fremde oder unbekannte Kennung: 404. * 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> { async checkInstance(tenantId: string, id: string): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId); const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({ const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId }, where: { id, tenantId },
select: { id: true, baseUrl: true }, select: { id: true, baseUrl: true, consecutiveFailures: true },
}); });
if (!existing) throw new NotFoundException('Cloud nicht gefunden'); if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const result = await fetchNextcloudStatus(existing.baseUrl); const result = await fetchNextcloudStatus(existing.baseUrl);
const now = new Date();
const plan = planStatusWrite(existing.consecutiveFailures, result, now);
const updated = await tenantPrisma.nextcloudInstance.update({ const updated = await tenantPrisma.nextcloudInstance.update({
where: { id }, where: { id },
data: { data: plan.data,
reachable: result.reachable, select: { ...PUBLIC_SELECT, alertState: true },
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,
}); });
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 * Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird
* normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine * 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 * hoechstens vier gleichzeitig, danach die frische Liste. Eine fehlerhafte
* Cloud stoppt die anderen nicht. * 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); const ids = await this.listInstanceIdsForTenant(tenantId);
await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => { await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => {
try { 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; checking?: boolean;
onCheck?: () => void; onCheck?: () => void;
onEdit?: () => 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, checking = false,
onCheck, onCheck,
onEdit, onEdit,
subscribed = false,
toggling = false,
onToggleSubscription,
}: CloudTileProps) { }: CloudTileProps) {
const t = useTranslations('nextcloudStatus'); const t = useTranslations('nextcloudStatus');
const locale = useLocale(); const locale = useLocale();
@@ -92,8 +100,35 @@ export function CloudTile({
{instance.baseUrl} {instance.baseUrl}
</a> </a>
</div> </div>
{canManage && (
<div className="flex shrink-0 items-center gap-1"> <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"
>
<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 <button
type="button" type="button"
onClick={onCheck} onClick={onCheck}
@@ -142,9 +177,10 @@ export function CloudTile({
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" /> <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg> </svg>
</button> </button>
</div> </>
)} )}
</div> </div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span <span
@@ -24,6 +24,9 @@ function render(ui: ReactElement) {
const mockListInstances = vi.fn(); const mockListInstances = vi.fn();
const mockCheckAll = vi.fn(); const mockCheckAll = vi.fn();
const mockCheckOne = 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) => { vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>(); 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), listInstances: (...args: unknown[]) => mockListInstances(...args),
checkAll: (...args: unknown[]) => mockCheckAll(...args), checkAll: (...args: unknown[]) => mockCheckAll(...args),
checkOne: (...args: unknown[]) => mockCheckOne(...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; let mockCanManage: boolean | null = false;
vi.mock('@/lib/use-module-capability', () => ({ vi.mock('@/lib/use-module-capability', () => ({
useCanManageModule: () => mockCanManage, useCanManageModule: () => mockCanManage,
@@ -127,6 +136,9 @@ describe('NextcloudStatusPage', () => {
mockListInstances.mockReset(); mockListInstances.mockReset();
mockCheckAll.mockReset(); mockCheckAll.mockReset();
mockCheckOne.mockReset(); mockCheckOne.mockReset();
mockSubscribe.mockReset().mockResolvedValue(undefined);
mockUnsubscribe.mockReset().mockResolvedValue(undefined);
mockRequestPermission.mockReset();
mockCanManage = false; mockCanManage = false;
window.localStorage.clear(); window.localStorage.clear();
}); });
@@ -285,4 +297,85 @@ describe('NextcloudStatusPage', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument(); 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, listInstances,
type NextcloudInstance, type NextcloudInstance,
type NextcloudList, type NextcloudList,
subscribe,
unsubscribe,
} from '@/lib/nextcloud-status-api'; } from '@/lib/nextcloud-status-api';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability'; import { useCanManageModule } from '@/lib/use-module-capability';
import { CloudForm } from './components/CloudForm'; import { CloudForm } from './components/CloudForm';
@@ -83,6 +86,8 @@ export default function NextcloudStatusPage() {
const [sortKey, setSortKey] = useState<SortKey>('name'); const [sortKey, setSortKey] = useState<SortKey>('name');
const [checkingAll, setCheckingAll] = useState(false); const [checkingAll, setCheckingAll] = useState(false);
const [checkingIds, setCheckingIds] = useState<ReadonlySet<string>>(new Set()); 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. // `undefined` = Formular zu, `null` = neue Cloud, sonst bearbeiten.
const [editing, setEditing] = useState<NextcloudInstance | null | undefined>(undefined); const [editing, setEditing] = useState<NextcloudInstance | null | undefined>(undefined);
@@ -133,7 +138,13 @@ export default function NextcloudStatusPage() {
const updated = await checkOne(id); const updated = await checkOne(id);
setData((prev) => setData((prev) =>
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, : prev,
); );
setNow(Date.now()); 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 // Nach Anlegen/Aendern/Loeschen die Liste neu laden: die Reihenfolge und die
// neueste Version kommen so wieder aus einer Quelle. // neueste Version kommen so wieder aus einer Quelle.
const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]); const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]);
@@ -187,6 +234,15 @@ export default function NextcloudStatusPage() {
</p> </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 && ( {!error && data === null && (
<div <div
aria-busy="true" aria-busy="true"
@@ -242,6 +298,9 @@ export default function NextcloudStatusPage() {
checking={checkingAll || checkingIds.has(instance.id)} checking={checkingAll || checkingIds.has(instance.id)}
onCheck={() => handleCheckOne(instance.id)} onCheck={() => handleCheckOne(instance.id)}
onEdit={() => setEditing(instance)} onEdit={() => setEditing(instance)}
subscribed={instance.subscribed === true}
toggling={togglingIds.has(instance.id)}
onToggleSubscription={() => handleToggleSubscription(instance)}
/> />
</li> </li>
))} ))}
+21
View File
@@ -55,6 +55,12 @@ export interface NextcloudInstance {
logoVersion: number; logoVersion: number;
status: NextcloudInstanceStatus; status: NextcloudInstanceStatus;
rating: NextcloudRating; 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 { 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')); if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to remove logo'));
return res.json(); 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'));
}
+6
View File
@@ -1872,6 +1872,12 @@
"refresh": "Diese Cloud jetzt prüfen", "refresh": "Diese Cloud jetzt prüfen",
"edit": "Cloud bearbeiten" "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": { "form": {
"addTitle": "Cloud hinzufügen", "addTitle": "Cloud hinzufügen",
"editTitle": "Cloud bearbeiten", "editTitle": "Cloud bearbeiten",
+6
View File
@@ -1872,6 +1872,12 @@
"refresh": "Check this cloud now", "refresh": "Check this cloud now",
"edit": "Edit cloud" "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": { "form": {
"addTitle": "Add cloud", "addTitle": "Add cloud",
"editTitle": "Edit cloud", "editTitle": "Edit cloud",
File diff suppressed because one or more lines are too long