feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung

- Glocke je Kachel (persönlich, Benutzen-Ebene), Tabelle NextcloudAlertSubscription mit RLS
- Zwei-Fehlschläge-Regel und gemeldeter Zustand auf der Zeile, Anspruch vor dem Mailversand
- Mail (nur Deutsch) über MailService, bis zu drei Versuche, Zugriff beim Senden erneut geprüft
- Fehlercodes in der Mail als lesbarer Hinweis

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:23:34 +02:00
parent 6829c44464
commit faed0d760f
23 changed files with 1796 additions and 103 deletions
@@ -1,6 +1,6 @@
import 'reflect-metadata';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from '../module-registry/module.guard';
import { NextcloudStatusController } from './nextcloud-status.controller';
@@ -15,7 +15,7 @@ describe('NextcloudStatusController Metadaten', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard);
});
it.each(['list', 'logo'])('%s bleibt auf Benutzen-Ebene', (name) => {
it.each(['list', 'logo', 'subscribe', 'unsubscribe'])('%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();
});
@@ -49,3 +49,47 @@ describe('NextcloudStatusController Metadaten', () => {
}
});
});
describe('NextcloudStatusController Glocke (quick-261002-kxc)', () => {
const pathOf = (n: string) => Reflect.getMetadata('path', proto[n] as object) as string;
const methodOf = (n: string) => Reflect.getMetadata('method', proto[n] as object) as number;
it('POST und DELETE instances/:id/subscription', () => {
expect(pathOf('subscribe')).toBe('instances/:id/subscription');
expect(pathOf('unsubscribe')).toBe('instances/:id/subscription');
// RequestMethod.POST = 1, DELETE = 3
expect(methodOf('subscribe')).toBe(1);
expect(methodOf('unsubscribe')).toBe(3);
});
it('Benutzer kommt aus dem Token, Mandant aus der Anfrage — nie aus Body oder Pfad', async () => {
const service = {
listForTenant: vi.fn().mockResolvedValue({ instances: [] }),
checkAllForTenant: vi.fn().mockResolvedValue({ instances: [] }),
};
const alerts = {
subscribe: vi.fn().mockResolvedValue({ subscribed: true }),
unsubscribe: vi.fn().mockResolvedValue({ subscribed: false }),
};
const controller = new NextcloudStatusController(service as never, alerts as never);
const req = { tenantId: 't1', body: { userId: 'fremd', tenantId: 'fremd' } } as never;
const user = { id: 'u1' } as never;
expect(await controller.subscribe(req, user, 'i1')).toEqual({ subscribed: true });
expect(alerts.subscribe).toHaveBeenCalledWith('t1', 'u1', 'i1');
expect(await controller.unsubscribe(req, user, 'i1')).toEqual({ subscribed: false });
expect(alerts.unsubscribe).toHaveBeenCalledWith('t1', 'u1', 'i1');
await controller.list(req, user);
expect(service.listForTenant).toHaveBeenCalledWith('t1', 'u1');
await controller.checkAll(req, user);
expect(service.checkAllForTenant).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(
'Kein Mandantenkontext',
);
});
});