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
@@ -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);
}
/**