feat(nextcloud-status): Meldung in Tessera, Anleitung und Changelog

- 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>
This commit is contained in:
2026-10-02 15:30:45 +02:00
parent 11a70c9ee9
commit 6c4bff6f6c
15 changed files with 542 additions and 8 deletions
@@ -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();
@@ -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<typeof makeService>;
beforeEach(() => {
vi.mocked(forTenant).mockClear();
ctx = makeService();
});
const inst = (over: Record<string, unknown>) => ({
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<typeof makeService>;
beforeEach(() => {
@@ -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
@@ -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(
@@ -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) {