feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung
- Glocke je Kachel (persönlich, Benutzen-Ebene), Tabelle NextcloudAlertSubscription mit RLS - Zwei-Fehlschläge-Regel und gemeldeter Zustand auf der Zeile, Anspruch vor dem Mailversand - Mail (nur Deutsch) über MailService, bis zu drei Versuche, Zugriff beim Senden erneut geprüft - Fehlercodes in der Mail als lesbarer Hinweis Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user