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 | 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 = (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> { 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 { 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 { 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 => { // 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})`, ); } }