import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import type { UploadedFileLike } from '../auth/types/auth-user'; import { PrismaService } from '../prisma/prisma.service'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; 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, type NextcloudReference, newestVersion, rateNextcloud, } from './nextcloud-rating'; import { NextcloudReleaseService } from './nextcloud-release.service'; import { fetchNextcloudStatus, normalizeCloudUrl } from './nextcloud-status-fetch'; /** * Alle Spalten ausser den Logo-Bytes (T-k67-07): Listen- und * Pruefabfragen holen `logoData` nie aus der Datenbank, nur der * Logo-Abruf. `logoMime` ist genau dann gesetzt, wenn ein Upload vorliegt. */ export const PUBLIC_SELECT = { id: true, tenantId: true, customerName: true, baseUrl: true, logoUrl: true, logoMime: true, logoVersion: true, lastCheckedAt: true, reachable: true, maintenance: true, needsDbUpgrade: true, versionString: true, edition: true, errorKind: true, errorDetail: true, consecutiveFailures: true, } as const; type PublicRow = { id: string; customerName: string; baseUrl: string; logoUrl: string | null; logoMime: string | null; logoVersion: number; lastCheckedAt: Date | null; reachable: boolean | null; maintenance: boolean | null; needsDbUpgrade: boolean | null; versionString: string | null; edition: string | null; errorKind: string | null; errorDetail: string | null; consecutiveFailures: number; }; export interface NextcloudInstanceView { id: string; customerName: string; baseUrl: string; logoUrl: string | null; hasUploadedLogo: boolean; logoVersion: number; status: { checkedAt: string | null; reachable: boolean | null; maintenance: boolean | null; needsDbUpgrade: boolean | null; versionString: string | null; edition: string | null; errorKind: string | null; errorDetail: string | null; /** * Genau ein Fehlschlag in Folge: die Kachel zeigt den letzten guten Stand * mit Hinweis, die Wiederholung folgt in wenigen Minuten (L-03). */ pendingRetry: boolean; }; 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 { instances: NextcloudInstanceView[]; reference: { newestVersion: string | null; fetchedAt: string | null }; } /** Hoechstzahl gleichzeitiger Pruefungen (Sammelpruefung und stuendlicher Durchlauf). */ export const CHECK_CONCURRENCY = 4; /** * Arbeitet `items` mit hoechstens `limit` gleichzeitig ab. `fn` darf werfen — * der Aufrufer faengt je Eintrag selbst, ein Fehler stoppt die anderen nicht. */ export async function runWithConcurrency( items: T[], limit: number, fn: (item: T) => Promise, ): Promise { let next = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) { const item = items[next++]; await fn(item); } }); await Promise.all(workers); } const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'; @Injectable() export class NextcloudStatusService { private readonly logger = new Logger(NextcloudStatusService.name); constructor( private readonly prisma: PrismaService, private readonly release: NextcloudReleaseService, private readonly alerts: NextcloudAlertService, ) {} private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView { const status = { pendingRetry: row.consecutiveFailures === 1, checkedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null, reachable: row.reachable, maintenance: row.maintenance, needsDbUpgrade: row.needsDbUpgrade, versionString: row.versionString, edition: row.edition, errorKind: row.errorKind, errorDetail: row.errorDetail, }; return { id: row.id, customerName: row.customerName, baseUrl: row.baseUrl, logoUrl: row.logoUrl, hasUploadedLogo: row.logoMime !== null, logoVersion: row.logoVersion, status, rating: rateNextcloud(status, reference, new Date()), }; } /** * 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 { const tenantPrisma = forTenant(this.prisma, tenantId); 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), subscribed: subscribed.has((row as PublicRow).id), })), reference: { newestVersion: newestVersion(reference), fetchedAt: reference?.fetchedAt ?? null, }, }; } /** Legt eine Cloud an und fuehrt sofort die erste Pruefung aus. */ async createInstance( tenantId: string, dto: CreateNextcloudInstanceDto, ): Promise { const baseUrl = normalizeCloudUrl(dto.baseUrl); if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE); const tenantPrisma = forTenant(this.prisma, tenantId); const created = await tenantPrisma.nextcloudInstance.create({ data: { tenantId, customerName: dto.customerName.trim(), baseUrl, logoUrl: dto.logoUrl ? dto.logoUrl : null, }, select: { id: true }, }); return this.checkInstance(tenantId, created.id); } /** * 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 { const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.nextcloudInstance.findFirst({ where: { id, tenantId }, select: { id: true, baseUrl: true, consecutiveFailures: true }, }); if (!existing) throw new NotFoundException('Cloud nicht gefunden'); const startedAt = Date.now(); 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: plan.data, select: { ...PUBLIC_SELECT, alertState: true }, }); const view = this.toView(updated as PublicRow, await this.release.getReference()); const outcome = result.reachable ? `Version ${result.versionString ?? '?'}${result.maintenance ? ', Wartungsmodus' : ''}` : `Fehler ${result.errorKind}${result.errorDetail ? ` (${result.errorDetail})` : ''}`; this.logger.log( `Pruefung "${updated.customerName}" ${existing.baseUrl}: ${outcome}, Ampel ${view.rating.level}, ${Date.now() - startedAt} ms`, ); 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 * nicht leere Logo-Adresse ersetzt ein hochgeladenes Logo, eine leere * entfernt nur die Adresse (D-A). */ async updateInstance( tenantId: string, id: string, dto: UpdateNextcloudInstanceDto, ): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.nextcloudInstance.findFirst({ where: { id, tenantId }, select: { id: true, baseUrl: true }, }); if (!existing) throw new NotFoundException('Cloud nicht gefunden'); const data: Record = {}; if (dto.customerName !== undefined) data.customerName = dto.customerName.trim(); let urlChanged = false; if (dto.baseUrl !== undefined) { const baseUrl = normalizeCloudUrl(dto.baseUrl); if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE); if (baseUrl !== existing.baseUrl) { data.baseUrl = baseUrl; urlChanged = true; // Neue Adresse: der alte Pruefstand gilt nicht mehr (D-K8). `alertState` // bleibt bewusst unberuehrt — wird eine kaputte Adresse korrigiert und // antwortet die neue, geht "wieder in Ordnung" an die Abonnenten. Object.assign(data, { reachable: null, maintenance: null, needsDbUpgrade: null, versionString: null, edition: null, productName: null, errorKind: null, errorDetail: null, lastCheckedAt: null, consecutiveFailures: 0, firstFailureAt: null, }); } } if (dto.logoUrl !== undefined) { if (dto.logoUrl) { data.logoUrl = dto.logoUrl; data.logoData = null; data.logoMime = null; } else { data.logoUrl = null; } data.logoVersion = { increment: 1 }; } const updated = await tenantPrisma.nextcloudInstance.update({ where: { id }, data, select: PUBLIC_SELECT, }); if (urlChanged) return this.checkInstance(tenantId, id); return this.toView(updated as PublicRow, await this.release.getReference()); } /** Loescht eine Cloud. Fremde oder unbekannte Kennung: 404. */ async deleteInstance(tenantId: string, id: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.nextcloudInstance.findFirst({ where: { id, tenantId }, select: { id: true }, }); if (!existing) throw new NotFoundException('Cloud nicht gefunden'); await tenantPrisma.nextcloudInstance.delete({ where: { id } }); return true; } /** * Speichert ein hochgeladenes Logo. Der Typ kommt aus den Magic Bytes * (`checkLogoUpload`), nie aus dem Mimetype des Browsers; eine vorhandene * Logo-Adresse wird entfernt (Upload und Adresse schliessen sich aus). */ async uploadLogo( tenantId: string, id: string, file: UploadedFileLike | undefined, ): Promise { const mime = file ? checkLogoUpload(file.buffer) : null; if (!file || !mime) { throw new BadRequestException( 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.', ); } const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.nextcloudInstance.findFirst({ where: { id, tenantId }, select: { id: true }, }); if (!existing) throw new NotFoundException('Cloud nicht gefunden'); const updated = await tenantPrisma.nextcloudInstance.update({ where: { id }, data: { logoData: new Uint8Array(file.buffer), logoMime: mime, logoUrl: null, logoVersion: { increment: 1 }, }, select: PUBLIC_SELECT, }); return this.toView(updated as PublicRow, await this.release.getReference()); } /** Liefert die Bytes des hochgeladenen Logos — die einzige Abfrage, die `logoData` auswaehlt. */ async getLogo(tenantId: string, id: string): Promise<{ data: Buffer; mime: string }> { const tenantPrisma = forTenant(this.prisma, tenantId); const row = await tenantPrisma.nextcloudInstance.findFirst({ where: { id, tenantId }, select: { logoData: true, logoMime: true }, }); if (!row?.logoData || !row.logoMime) throw new NotFoundException('Kein Logo vorhanden'); return { data: Buffer.from(row.logoData), mime: row.logoMime }; } /** Entfernt ein hochgeladenes Logo (die Kachel faellt auf Initialen zurueck). */ async removeLogo(tenantId: string, id: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const existing = await tenantPrisma.nextcloudInstance.findFirst({ where: { id, tenantId }, select: { id: true }, }); if (!existing) throw new NotFoundException('Cloud nicht gefunden'); const updated = await tenantPrisma.nextcloudInstance.update({ where: { id }, data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } }, select: PUBLIC_SELECT, }); return this.toView(updated as PublicRow, await this.release.getReference()); } /** Kennungen aller Clouds des Mandanten. */ async listInstanceIdsForTenant(tenantId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId); const rows = await tenantPrisma.nextcloudInstance.findMany({ where: { tenantId }, select: { id: true }, }); return rows.map((r: { id: string }) => r.id); } /** * "Jetzt pruefen" fuer die ganze Liste: alle Clouds des Mandanten mit * hoechstens vier gleichzeitig, danach die frische Liste. Eine fehlerhafte * Cloud stoppt die anderen nicht. */ async checkAllForTenant(tenantId: string, userId: string): Promise { const ids = await this.listInstanceIdsForTenant(tenantId); await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => { try { await this.checkInstance(tenantId, id); } catch (err) { this.logger.warn( `Nextcloud-Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`, ); } }); return this.listForTenant(tenantId, userId); } /** * Startpfad des stuendlichen Planers — der EINZIGE Systemkontext-Aufruf * dieses Moduls (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`; * Leserecht ueber `system_read_policy ... FOR SELECT` der Migration * 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder * Adressen. Geprueft und geschrieben wird danach je Cloud an ihren eigenen * Mandanten gebunden (`checkInstance`). Mit `retryDueBefore` (Wiederholung * nach dem ersten Fehlschlag, quick-261002-kxc) filtert dieselbe Abfrage auf * Clouds mit genau einem Fehlschlag, der vor diesem Zeitpunkt lag. */ async loadAllInstancesForScheduler(filter?: { retryDueBefore: Date; }): Promise<{ id: string; tenantId: string }[]> { const systemPrisma = forSystem(this.prisma); return systemPrisma.nextcloudInstance.findMany({ // Wiederholungsauftrag (D-K3): nur Clouds mit genau einem Fehlschlag, dessen // Zeitpunkt lange genug zurueckliegt — derselbe Systemlesezugriff, kein neuer. ...(filter ? { where: { consecutiveFailures: 1, firstFailureAt: { lte: filter.retryDueBefore }, }, } : {}), select: { id: true, tenantId: true }, }); } }