6c4bff6f6c
- Endpunkt für letzte Übergänge der abonnierten Clouds, globaler Melder im Portalrahmen (Desktop: Windows-Benachrichtigung) - Anleitung für Anwender und Administration, Changelog Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
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;
|
|
}
|
|
|
|
/** Eine Meldung fuer die Anzeige in Tessera (D-K7). */
|
|
export interface RecentAlert {
|
|
instanceId: string;
|
|
customerName: string;
|
|
baseUrl: string;
|
|
kind: 'down' | 'up';
|
|
/** Rote Grundkennung (z. B. 'unreachable'); nur bei `down`. */
|
|
reason: string | null;
|
|
changedAt: string;
|
|
}
|
|
|
|
/** Zeitraum, in dem ein Uebergang noch als Meldung in Tessera erscheint. */
|
|
export const RECENT_ALERT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* Letzte Uebergaenge der Clouds, fuer die DER AUFRUFER die Glocke eingeschaltet
|
|
* hat (quick-261002-kxc, L-04, D-K7, T-kxc-02): je Cloud der juengste
|
|
* Uebergang der letzten 24 Stunden, sofern er NACH dem Einschalten der Glocke
|
|
* lag. Der Browser zeigt daraus je (Cloud, Zeitpunkt) hoechstens einmal eine
|
|
* Meldung. Nur skalare Felder, die die Modulseite ohnehin zeigt.
|
|
*/
|
|
async listRecentAlerts(
|
|
tenantId: string,
|
|
userId: string,
|
|
now: Date = new Date(),
|
|
): Promise<{ alerts: RecentAlert[] }> {
|
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
|
const subs: { instanceId: string; createdAt: Date }[] =
|
|
await tenantPrisma.nextcloudAlertSubscription.findMany({
|
|
where: { tenantId, userId },
|
|
select: { instanceId: true, createdAt: true },
|
|
});
|
|
if (subs.length === 0) return { alerts: [] };
|
|
|
|
const since = new Date(now.getTime() - RECENT_ALERT_WINDOW_MS);
|
|
const instances: {
|
|
id: string;
|
|
customerName: string;
|
|
baseUrl: string;
|
|
alertState: string;
|
|
alertReason: string | null;
|
|
alertChangedAt: Date | null;
|
|
}[] = await tenantPrisma.nextcloudInstance.findMany({
|
|
where: {
|
|
tenantId,
|
|
id: { in: subs.map((s) => s.instanceId) },
|
|
alertChangedAt: { gte: since },
|
|
},
|
|
select: {
|
|
id: true,
|
|
customerName: true,
|
|
baseUrl: true,
|
|
alertState: true,
|
|
alertReason: true,
|
|
alertChangedAt: true,
|
|
},
|
|
});
|
|
|
|
const subscribedAt = new Map(subs.map((s) => [s.instanceId, s.createdAt]));
|
|
const alerts: RecentAlert[] = [];
|
|
for (const i of instances) {
|
|
const subscribed = subscribedAt.get(i.id);
|
|
if (!i.alertChangedAt || !subscribed || i.alertChangedAt < subscribed) continue;
|
|
alerts.push({
|
|
instanceId: i.id,
|
|
customerName: i.customerName,
|
|
baseUrl: i.baseUrl,
|
|
kind: i.alertState === 'red' ? 'down' : 'up',
|
|
reason: i.alertState === 'red' ? i.alertReason : null,
|
|
changedAt: i.alertChangedAt.toISOString(),
|
|
});
|
|
}
|
|
alerts.sort((a, b) => a.changedAt.localeCompare(b.changedAt));
|
|
return { alerts };
|
|
}
|
|
|
|
/**
|
|
* 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})`,
|
|
);
|
|
}
|
|
}
|