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,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})`,
);
}
}