From 6c4bff6f6cf85867cdea54c1dbcc91d2c7fff028 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 2 Oct 2026 15:30:45 +0200 Subject: [PATCH] feat(nextcloud-status): Meldung in Tessera, Anleitung und Changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 1 + .../module-manage-handlers.spec.ts | 2 +- .../nextcloud-alert.service.spec.ts | 79 ++++++++- .../nextcloud-alert.service.ts | 76 +++++++++ .../nextcloud-status.controller.spec.ts | 18 +- .../nextcloud-status.controller.ts | 10 ++ apps/web/src/components/layout/app-shell.tsx | 5 + .../nextcloud-alert-notifier.test.tsx | 154 ++++++++++++++++++ .../nextcloud-alert-notifier.tsx | 131 +++++++++++++++ apps/web/src/lib/nextcloud-status-api.ts | 33 ++++ apps/web/src/messages/de.json | 10 ++ apps/web/src/messages/en.json | 10 ++ docs/anleitung-administration.md | 5 +- docs/anleitung-anwender.md | 7 + ...andantentrennung-zugriffsklassifikation.md | 9 +- 15 files changed, 542 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.test.tsx create mode 100644 apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b73c2..70f2108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Neu - Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe. +- Nextcloud-Status: Auf jeder Kachel gibt es jetzt eine Glocke „Benachrichtigen“, die jeder Benutzer mit Zugriff auf das Modul für sich ein- und ausschalten kann. Ist sie an, meldet Tessera per E-Mail und, solange Tessera geöffnet ist, als Benachrichtigung auf dem Bildschirm (in der Desktop-App als Windows-Benachrichtigung), wenn die Cloud eine Störung hat – nicht erreichbar, keine gültige Antwort, Wartungsmodus, ausstehende Datenbank-Aktualisierung oder abgelaufener Support – und wenn sie wieder in Ordnung ist. Pro Änderung kommt genau eine Nachricht, solange die Störung anhält, nicht jede Stunde neu. Ein einzelner fehlgeschlagener Abruf löst keine Meldung aus: Die Kachel zeigt weiter den letzten guten Stand mit dem Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“, und Tessera prüft nach etwa fünf Minuten erneut. Für die E-Mails muss der Mailversand eingerichtet sein und im Benutzerprofil eine E-Mail-Adresse stehen. Außerdem nennt die Kachel bei „Nicht erreichbar“ jetzt den Grund in Klartext, zum Beispiel „Zertifikat passt nicht zur Adresse“, „Adresse nicht gefunden“ oder „Zeitüberschreitung“, statt eines technischen Fehlercodes. - Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen. - Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe. - Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert. diff --git a/apps/api/src/module-registry/module-manage-handlers.spec.ts b/apps/api/src/module-registry/module-manage-handlers.spec.ts index a3fcef2..87d1463 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -78,7 +78,7 @@ describe('Umgestellte Handler (Verwalten)', () => { }, ); - it.each(['list', 'logo', 'subscribe', 'unsubscribe'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => { + it.each(['list', 'logo', 'subscribe', 'unsubscribe', 'recentAlerts'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => { const fn = handler(NextcloudStatusController, name); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); diff --git a/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts index c618767..2050655 100644 --- a/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts @@ -37,7 +37,7 @@ const ROW = { function makeService() { const prisma = { - nextcloudInstance: { findFirst: vi.fn(), updateMany: vi.fn() }, + nextcloudInstance: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() }, nextcloudAlertSubscription: { findMany: vi.fn().mockResolvedValue([]), upsert: vi.fn(), @@ -123,6 +123,83 @@ describe('NextcloudAlertService Abonnements', () => { }); }); +describe('NextcloudAlertService.listRecentAlerts', () => { + const T = (iso: string) => new Date(iso); + let ctx: ReturnType; + beforeEach(() => { + vi.mocked(forTenant).mockClear(); + ctx = makeService(); + }); + + const inst = (over: Record) => ({ + id: 'i1', + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + alertState: 'red', + alertReason: 'unreachable', + alertChangedAt: T('2026-10-02T12:00:00Z'), + ...over, + }); + + it('liefert nur Uebergaenge der letzten 24 Stunden nach dem Einschalten der Glocke, mit down/up', async () => { + ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([ + { instanceId: 'i1', createdAt: T('2026-10-02T10:00:00Z') }, + { instanceId: 'i2', createdAt: T('2026-10-02T13:00:00Z') }, // Uebergang lag davor + { instanceId: 'i3', createdAt: T('2026-10-02T09:00:00Z') }, + ]); + ctx.prisma.nextcloudInstance.findMany.mockResolvedValue([ + inst({ + id: 'i3', + alertState: 'ok', + alertReason: null, + alertChangedAt: T('2026-10-02T12:30:00Z'), + }), + inst({ id: 'i1' }), + inst({ id: 'i2', alertChangedAt: T('2026-10-02T11:00:00Z') }), + ]); + const now = T('2026-10-02T14:00:00Z'); + const { alerts } = await ctx.service.listRecentAlerts('t1', 'u1', now); + expect(forTenant).toHaveBeenCalledWith(ctx.prisma, 't1', 'u1'); + expect(ctx.prisma.nextcloudAlertSubscription.findMany.mock.calls[0][0].where).toEqual({ + tenantId: 't1', + userId: 'u1', + }); + const args = ctx.prisma.nextcloudInstance.findMany.mock.calls[0][0]; + expect(args.where).toEqual({ + tenantId: 't1', + id: { in: ['i1', 'i2', 'i3'] }, + alertChangedAt: { gte: T('2026-10-01T14:00:00Z') }, + }); + expect(args.select).not.toHaveProperty('logoData'); + expect(alerts).toEqual([ + { + instanceId: 'i1', + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + kind: 'down', + reason: 'unreachable', + changedAt: '2026-10-02T12:00:00.000Z', + }, + { + instanceId: 'i3', + customerName: 'Kunde A', + baseUrl: 'https://cloud.a.de', + kind: 'up', + reason: null, + changedAt: '2026-10-02T12:30:00.000Z', + }, + ]); + }); + + it('ohne Abonnements: leer, kein Zugriff auf die Clouds (fremde Abonnements erscheinen nie)', async () => { + ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([]); + expect(await ctx.service.listRecentAlerts('t1', 'u1', T('2026-10-02T14:00:00Z'))).toEqual({ + alerts: [], + }); + expect(ctx.prisma.nextcloudInstance.findMany).not.toHaveBeenCalled(); + }); +}); + describe('NextcloudAlertService.evaluateAfterCheck', () => { let ctx: ReturnType; beforeEach(() => { diff --git a/apps/api/src/nextcloud-status/nextcloud-alert.service.ts b/apps/api/src/nextcloud-status/nextcloud-alert.service.ts index 4d8f30b..cd1ec07 100644 --- a/apps/api/src/nextcloud-status/nextcloud-alert.service.ts +++ b/apps/api/src/nextcloud-status/nextcloud-alert.service.ts @@ -26,6 +26,20 @@ export interface AlertCheckedRow { 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). */ @@ -108,6 +122,68 @@ export class NextcloudAlertService { 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 diff --git a/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts b/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts index c8cc8b4..a5081c3 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts @@ -15,7 +15,13 @@ describe('NextcloudStatusController Metadaten', () => { expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard); }); - it.each(['list', 'logo', 'subscribe', 'unsubscribe'])('%s bleibt auf Benutzen-Ebene', (name) => { + it.each([ + 'list', + 'logo', + 'subscribe', + 'unsubscribe', + 'recentAlerts', + ])('%s bleibt auf Benutzen-Ebene', (name) => { expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, proto[name] as object)).toBeUndefined(); }); @@ -86,6 +92,16 @@ describe('NextcloudStatusController Glocke (quick-261002-kxc)', () => { expect(service.checkAllForTenant).toHaveBeenCalledWith('t1', 'u1'); }); + it('GET alerts: Benutzer aus dem Token, Mandant aus der Anfrage', async () => { + expect(pathOf('recentAlerts')).toBe('alerts'); + expect(methodOf('recentAlerts')).toBe(0); // RequestMethod.GET + const alerts = { listRecentAlerts: vi.fn().mockResolvedValue({ alerts: [] }) }; + const controller = new NextcloudStatusController({} as never, alerts as never); + const req = { tenantId: 't1', query: { userId: 'fremd' } } as never; + expect(await controller.recentAlerts(req, { id: 'u1' } as never)).toEqual({ alerts: [] }); + expect(alerts.listRecentAlerts).toHaveBeenCalledWith('t1', 'u1'); + }); + it('ohne Mandantenkontext -> 403', async () => { const controller = new NextcloudStatusController({} as never, {} as never); await expect(controller.subscribe({} as never, { id: 'u1' } as never, 'i1')).rejects.toThrow( diff --git a/apps/api/src/nextcloud-status/nextcloud-status.controller.ts b/apps/api/src/nextcloud-status/nextcloud-status.controller.ts index 40332f1..9b8a5d4 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.controller.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.controller.ts @@ -61,6 +61,16 @@ export class NextcloudStatusController { return this.service.listForTenant(this.requireTenantId(req), user.id); } + /** + * Letzte Uebergaenge der Clouds mit eingeschalteter Glocke des Aufrufers + * (Meldung in Tessera, quick-261002-kxc, L-04). Nur Klassen-`@UseModule`, + * kein Verwalten, keine Rolle; der Benutzer kommt aus dem Token. + */ + @Get('alerts') + async recentAlerts(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) { + return this.alerts.listRecentAlerts(this.requireTenantId(req), user.id); + } + @Post('instances') @ModuleManage('nextcloud-status') async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateNextcloudInstanceDto) { diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index ae0e9cf..865d07c 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { Header } from '@/components/layout/header'; import { Sidebar } from '@/components/layout/sidebar'; import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host'; +import { NextcloudAlertNotifier } from '@/components/nextcloud-status/nextcloud-alert-notifier'; import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host'; import { ReminderNotifier } from '@/components/reminders/reminder-notifier'; import { installErrorBuffer } from '@/lib/error-buffer'; @@ -47,6 +48,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */} + {/* Nextcloud-Status (quick-261002-kxc): Meldung bei Stoerung und + Wiederherstellung einer abonnierten Cloud, ebenfalls GLOBAL im Rahmen + (Windows-Benachrichtigung in der Desktop-App). Rendert nichts. */} + {/* Eigene Module offen halten (30.09.2026): die