From 6698ef1a92b5df2a563ee67e0f013d581adfea46 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 2 Oct 2026 14:54:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(nextcloud-status):=20Clouds=20verwalten,?= =?UTF-8?q?=20Logos,=20st=C3=BCndliche=20Pr=C3=BCfung=20und=20Sortierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten - Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden - Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt - Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst Co-Authored-By: Claude Opus 5.5 (1M context) --- .../module-manage-handlers.spec.ts | 14 + .../nextcloud-logo-rules.spec.ts | 37 +++ .../nextcloud-status/nextcloud-logo-rules.ts | 21 ++ ...nextcloud-status-scheduler.service.spec.ts | 111 +++++++ .../nextcloud-status-scheduler.service.ts | 117 +++++++ .../nextcloud-status.controller.spec.ts | 30 +- .../nextcloud-status.controller.ts | 96 +++++- .../nextcloud-status.module.ts | 3 +- .../nextcloud-status.service.spec.ts | 197 +++++++++++- .../nextcloud-status.service.ts | 203 +++++++++++- .../src/prisma/rls-access-inventory.spec.ts | 10 + .../components/CloudForm.test.tsx | 202 ++++++++++++ .../nextcloud-status/components/CloudForm.tsx | 297 ++++++++++++++++++ .../nextcloud-status/components/CloudTile.tsx | 67 +++- .../nextcloud-status-page.test.tsx | 122 ++++++- .../modules/nextcloud-status/page.tsx | 175 ++++++++++- .../nextcloud-status/sort-clouds.test.ts | 107 +++++++ .../nextcloud-status/sort-clouds.ts | 83 +++++ apps/web/src/lib/nextcloud-status-api.ts | 90 ++++++ apps/web/src/messages/de.json | 44 +++ apps/web/src/messages/en.json | 44 +++ apps/web/src/messages/umlaut-dictionary.ts | 2 + ...andantentrennung-zugriffsklassifikation.md | 19 +- 23 files changed, 2061 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts create mode 100644 apps/api/src/nextcloud-status/nextcloud-logo-rules.ts create mode 100644 apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts create mode 100644 apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx create mode 100644 apps/web/src/components/nextcloud-status/sort-clouds.test.ts create mode 100644 apps/web/src/components/nextcloud-status/sort-clouds.ts 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 45586a0..8d392da 100644 --- a/apps/api/src/module-registry/module-manage-handlers.spec.ts +++ b/apps/api/src/module-registry/module-manage-handlers.spec.ts @@ -7,6 +7,7 @@ import { DkvController } from '../dkv/dkv.controller'; import { ModuleGrantsController } from '../groups/module-grants.controller'; import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller'; import { KantineDatevController } from '../kantine-datev/kantine-datev.controller'; +import { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller'; import { ProxmoxController } from '../proxmox/proxmox.controller'; import { TendersController } from '../tenders/tenders.controller'; import { ModuleRegistryController } from './module-registry.controller'; @@ -70,6 +71,19 @@ describe('Umgestellte Handler (Verwalten)', () => { expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); }); + it.each(['create', 'update', 'remove', 'checkAll', 'checkOne', 'uploadLogo', 'removeLogo'])( + 'NextcloudStatusController.%s verlangt Verwalten für nextcloud-status', + (name) => { + expectManage(NextcloudStatusController, name, 'nextcloud-status'); + }, + ); + + it.each(['list', 'logo'])('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(); + }); + it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => { expectManage(KantineDatevController, 'saveSettings', 'kantine-datev'); expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev'); diff --git a/apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts b/apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts new file mode 100644 index 0000000..3c0a473 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { checkLogoUpload, NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules'; + +const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const JPEG = [0xff, 0xd8, 0xff, 0xe0]; +const GIF = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; +const WEBP = [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]; + +describe('checkLogoUpload', () => { + it.each([ + ['PNG', PNG, 'image/png'], + ['JPEG', JPEG, 'image/jpeg'], + ['GIF', GIF, 'image/gif'], + ['WebP', WEBP, 'image/webp'], + ])('erkennt %s', (_name, bytes, mime) => { + expect(checkLogoUpload(Buffer.from([...bytes, 1, 2, 3]))).toBe(mime); + }); + + it('lehnt SVG-Text, PDF, leere Dateien und umbenanntes HTML ab', () => { + expect( + checkLogoUpload(Buffer.from('')), + ).toBeNull(); + expect(checkLogoUpload(Buffer.from('%PDF-1.7 ...'))).toBeNull(); + expect(checkLogoUpload(Buffer.alloc(0))).toBeNull(); + expect(checkLogoUpload(Buffer.from(''))).toBeNull(); + }); + + it('genau 1 MiB ist erlaubt, ein Byte mehr nicht', () => { + const ok = Buffer.concat([ + Buffer.from(PNG), + Buffer.alloc(NEXTCLOUD_LOGO_MAX_BYTES - PNG.length), + ]); + expect(ok.length).toBe(NEXTCLOUD_LOGO_MAX_BYTES); + expect(checkLogoUpload(ok)).toBe('image/png'); + expect(checkLogoUpload(Buffer.concat([ok, Buffer.from([0])]))).toBeNull(); + }); +}); diff --git a/apps/api/src/nextcloud-status/nextcloud-logo-rules.ts b/apps/api/src/nextcloud-status/nextcloud-logo-rules.ts new file mode 100644 index 0000000..8d30150 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-logo-rules.ts @@ -0,0 +1,21 @@ +import { type DashboardImageMime, detectImageMime } from '../dashboard/dashboard-image-rules'; + +/** + * Regeln fuer hochgeladene Cloud-Logos (quick-261002-k67, L-02, D-A). + * Kein Nest, kein Prisma — direkt an den Bytes testbar. + * + * Erlaubt sind PNG, JPEG, GIF und WebP bis 1 MiB. KEIN SVG: ein SVG kann + * Skript tragen und wuerde, direkt im Tab geoeffnet, unter der Adresse von + * Tessera laufen. Der Typ kommt aus den Magic Bytes, nie aus dem vom Browser + * behaupteten `mimetype` oder der Dateiendung (Muster T-PI9-01); der erkannte + * Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-k67-02). + */ + +/** Hoechstgroesse: 1 MiB (multer `limits.fileSize` an der Route + zweites Netz im Dienst). */ +export const NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024; + +/** Erkannter Bildtyp oder `null`, wenn leer, zu gross oder kein erlaubtes Bildformat. */ +export function checkLogoUpload(buffer: Uint8Array): DashboardImageMime | null { + if (buffer.length === 0 || buffer.length > NEXTCLOUD_LOGO_MAX_BYTES) return null; + return detectImageMime(buffer); +} diff --git a/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts new file mode 100644 index 0000000..73f08b3 --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + NEXTCLOUD_CRON, + NEXTCLOUD_JOB_NAME, + NextcloudStatusSchedulerService, +} from './nextcloud-status-scheduler.service'; + +function makeFakeRegistry() { + const jobs = new Map(); + return { + __jobs: jobs, + addCronJob: vi.fn((name: string, job: any) => { + if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`); + jobs.set(name, job); + }), + }; +} + +function makeScheduler(rows: { id: string; tenantId: string }[] = [], failFor: string[] = []) { + const registry = makeFakeRegistry(); + const calls: Array<[string, string]> = []; + const service = { + loadAllInstancesForScheduler: vi.fn(async () => rows), + checkInstance: vi.fn(async (tenantId: string, id: string) => { + calls.push([tenantId, id]); + if (failFor.includes(id)) throw new Error(`boom ${id}`); + }), + }; + const release = { refresh: vi.fn(async () => undefined) }; + const scheduler = new NextcloudStatusSchedulerService( + registry as any, + service as any, + release as any, + ); + const logger = (scheduler as any).logger; + const logSpy = vi.spyOn(logger, 'log').mockImplementation(() => undefined); + const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + return { registry, service, release, scheduler, calls, logSpy, errorSpy, warnSpy }; +} + +describe('NextcloudStatusSchedulerService', () => { + it('registriert genau einen stuendlichen Auftrag ohne Datenbankzugriff und startet ihn', async () => { + const { registry, service, release, scheduler } = makeScheduler(); + await scheduler.onApplicationBootstrap(); + expect(registry.addCronJob).toHaveBeenCalledTimes(1); + expect(registry.__jobs.has(NEXTCLOUD_JOB_NAME)).toBe(true); + const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME); + expect(job.cronTime.source).toBe(NEXTCLOUD_CRON); + expect(NEXTCLOUD_CRON).toBe('0 * * * *'); + expect(job.isActive ?? job.running).toBeTruthy(); + expect(service.loadAllInstancesForScheduler).not.toHaveBeenCalled(); + expect(release.refresh).toHaveBeenCalledTimes(1); + job.stop(); + }); + + it('ein Fehler beim Start wird protokolliert und nicht weitergeworfen', async () => { + const { registry, scheduler, errorSpy } = makeScheduler(); + registry.addCronJob.mockImplementation(() => { + throw new Error('registry kaputt'); + }); + await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('tick prueft jede Cloud an ihren eigenen Mandanten gebunden', async () => { + const rows = [ + { id: 'a1', tenantId: 'tA' }, + { id: 'b1', tenantId: 'tB' }, + { id: 'a2', tenantId: 'tA' }, + ]; + const { scheduler, calls } = makeScheduler(rows); + await scheduler.tick(); + expect(calls).toHaveLength(3); + expect(calls).toContainEqual(['tA', 'a1']); + expect(calls).toContainEqual(['tA', 'a2']); + expect(calls).toContainEqual(['tB', 'b1']); + }); + + it('eine fehlerhafte Cloud stoppt die anderen nicht', async () => { + const rows = [ + { id: 'x', tenantId: 't' }, + { id: 'y', tenantId: 't' }, + { id: 'z', tenantId: 't' }, + ]; + const { scheduler, calls, errorSpy } = makeScheduler(rows, ['x']); + await scheduler.tick(); + expect(calls.map((c) => c[1]).sort()).toEqual(['x', 'y', 'z']); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('ein Durchlauf, waehrend der vorige noch laeuft, wird uebersprungen', async () => { + const { scheduler, service, warnSpy } = makeScheduler([{ id: 'a', tenantId: 't' }]); + let release!: () => void; + service.checkInstance.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const first = scheduler.tick(); + await vi.waitFor(() => expect(service.checkInstance).toHaveBeenCalledTimes(1)); + await scheduler.tick(); + expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalled(); + release(); + await first; + await scheduler.tick(); + expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts new file mode 100644 index 0000000..3ffa02e --- /dev/null +++ b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts @@ -0,0 +1,117 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { NextcloudReleaseService } from './nextcloud-release.service'; +import { + CHECK_CONCURRENCY, + NextcloudStatusService, + runWithConcurrency, +} from './nextcloud-status.service'; + +/** + * CronJob constructor — resolved at runtime via require() because `cron` is + * a transitive dependency of @nestjs/schedule (not a direct api dep under + * pnpm strict isolation, so `import { CronJob } from 'cron'` fails + * type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares + * it as a peer dep. Reuses the exact ProxmoxSchedulerService resolution + * workaround verbatim. + */ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + require('cron').CronJob as new ( + cronTime: string, + onTick: () => void, + ) => { start(): void }; + +/** Name des Auftrags in der Registry. */ +export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll'; +/** Jede volle Stunde (L-08). */ +export const NEXTCLOUD_CRON = '0 * * * *'; + +/** + * NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds + * (quick-261002-k67, L-08, D-C). + * + * Lebenszyklus: `OnApplicationBootstrap`, NICHT `OnModuleInit` — die + * Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht + * festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis zum + * zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso. + * + * Ein einziger globaler Auftrag statt je einem je Mandant: das Intervall ist + * fest (stuendlich), es gibt keine Einstellung je Zeile. Der Auftrag wird + * beim Start OHNE Datenbankzugriff registriert — eine frische Datenbank kann + * daher nie ohne Auftrag enden, auch wenn beim Start noch keine Cloud + * eingetragen ist. Jeder Durchlauf liest Kennung und Mandant aller Clouds + * (ein einziger Systemkontext-Aufruf) und prueft dann jede Cloud an ihren + * eigenen Mandanten gebunden, mit hoechstens vier gleichzeitig. Ein + * Ueberlappungsschutz ueberspringt einen Durchlauf, solange der vorige laeuft. + */ +@Injectable() +export class NextcloudStatusSchedulerService implements OnApplicationBootstrap { + private readonly logger = new Logger(NextcloudStatusSchedulerService.name); + private running = false; + + constructor( + private readonly schedulerRegistry: SchedulerRegistry, + private readonly service: NextcloudStatusService, + private readonly release: NextcloudReleaseService, + ) {} + + /** + * Registriert und startet den Auftrag und stoesst das Aufwaermen der + * Vergleichsdaten an (ohne zu warten). Ein Fehler wird gefangen und + * protokolliert, nie weitergeworfen — die Anwendung startet trotzdem. + */ + async onApplicationBootstrap(): Promise { + try { + const job = new CronJobClass(NEXTCLOUD_CRON, () => { + this.tick().catch((err) => + this.logger.error(`Nextcloud poll tick failed: ${(err as Error).message}`), + ); + }); + // Cast noetig — dasselbe Muster wie ProxmoxSchedulerService. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService + this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any); + job.start(); + this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`); + void this.release.refresh().catch(() => undefined); + } catch (err) { + this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`); + } + } + + /** Ein Durchlauf ueber alle Clouds aller Mandanten. */ + async tick(): Promise { + if (this.running) { + this.logger.warn('Nextcloud poll tick skipped — previous run still active'); + return; + } + this.running = true; + try { + const rows = await this.service.loadAllInstancesForScheduler(); + // Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt. + const byTenant = new Map(); + for (const row of rows) { + const ids = byTenant.get(row.tenantId) ?? []; + ids.push(row.id); + byTenant.set(row.tenantId, ids); + } + const work: { tenantId: string; id: string }[] = []; + for (const [tenantId, ids] of byTenant) { + for (const id of ids) work.push({ tenantId, id }); + } + await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => { + try { + await this.service.checkInstance(tenantId, id); + } catch (err) { + this.logger.error( + `Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`, + ); + } + }); + } finally { + this.running = false; + } + } +} 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 f16ac92..bffded8 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts @@ -15,15 +15,37 @@ describe('NextcloudStatusController Metadaten', () => { expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard); }); - it('list bleibt auf Benutzen-Ebene', () => { - expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.list as object)).toBeUndefined(); - expect(Reflect.getMetadata(ROLES_KEY, proto.list as object)).toBeUndefined(); + it.each(['list', 'logo'])('%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(); }); - it.each(['create', 'checkOne'])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => { + it.each([ + 'create', + 'update', + 'remove', + 'checkAll', + 'checkOne', + 'uploadLogo', + 'removeLogo', + ])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => { const fn = proto[name] as object; expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBe(true); expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn)).toBe('nextcloud-status'); expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); }); + + it('die statische Route POST instances/check steht vor jedem Handler mit :id (kein 404-Shadowing)', () => { + const names = Object.getOwnPropertyNames(NextcloudStatusController.prototype).filter( + (n) => n !== 'constructor' && typeof proto[n] === 'function', + ); + const pathOf = (n: string) => Reflect.getMetadata('path', proto[n] as object) as string; + expect(pathOf('checkAll')).toBe('instances/check'); + const checkIndex = names.indexOf('checkAll'); + const idHandlers = names.filter((n) => (pathOf(n) ?? '').includes(':id')); + expect(idHandlers.length).toBeGreaterThan(0); + for (const name of idHandlers) { + expect(checkIndex, `checkAll vor ${name}`).toBeLessThan(names.indexOf(name)); + } + }); }); diff --git a/apps/api/src/nextcloud-status/nextcloud-status.controller.ts b/apps/api/src/nextcloud-status/nextcloud-status.controller.ts index 5a432a3..661e252 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.controller.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.controller.ts @@ -1,7 +1,26 @@ -import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common'; -import type { AuthenticatedRequest } from '../auth/types/auth-user'; +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + Post, + Put, + Req, + Res, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; +import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user'; import { ModuleManage, UseModule } from '../module-registry/module.guard'; -import { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto'; +import { + CreateNextcloudInstanceDto, + UpdateNextcloudInstanceDto, +} from './dto/nextcloud-instance.dto'; +import { NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules'; import { NextcloudStatusService } from './nextcloud-status.service'; /** @@ -9,14 +28,15 @@ import { NextcloudStatusService } from './nextcloud-status.service'; * Freigabe (Vorbild `proxmox.controller.ts`). `tenantId` kommt ausschliesslich * aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus Body oder Query. * - * Rechte (L-09): Lesen (`GET instances`, Logo-Abruf) steht jedem Benutzer mit - * Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich + * Rechte (L-09): Lesen (`GET instances`, `GET instances/:id/logo`) steht jedem + * Benutzer mit Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich * `@ModuleManage('nextcloud-status')` — Administratoren und Benutzer mit der * Freigabestufe Verwalten. Auf Verwalten-Handlern steht NIE ein * Rollen-Decorator, der globale RolesGuard wuerde Verwalter sonst aussperren. * * Routenreihenfolge: statische Pfade (`instances/check`) stehen VOR allen - * Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing). + * Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing, + * T-k67-08; die Reihenfolge ist im Controller-Spec festgeschrieben). */ @Controller('modules/nextcloud-status') @UseModule('nextcloud-status') @@ -42,9 +62,73 @@ export class NextcloudStatusController { return this.service.createInstance(this.requireTenantId(req), dto); } + /** "Jetzt prüfen" fuer die ganze Liste — statisch, steht vor allen `:id`-Routen. */ + @Post('instances/check') + @ModuleManage('nextcloud-status') + async checkAll(@Req() req: AuthenticatedRequest) { + return this.service.checkAllForTenant(this.requireTenantId(req)); + } + + @Put('instances/:id') + @ModuleManage('nextcloud-status') + async update( + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @Body() dto: UpdateNextcloudInstanceDto, + ) { + return this.service.updateInstance(this.requireTenantId(req), id, dto); + } + + @Delete('instances/:id') + @ModuleManage('nextcloud-status') + async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + const deleted = await this.service.deleteInstance(this.requireTenantId(req), id); + return { deleted }; + } + @Post('instances/:id/check') @ModuleManage('nextcloud-status') async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) { return this.service.checkInstance(this.requireTenantId(req), id); } + + /** + * Logo-Abruf fuer jeden Benutzer mit Modulzugriff (die Kachel laedt es per + * ). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private + * Zwischenspeicherung (Adresse traegt clientseitig `?v=`), + * `nosniff` und eine Sandbox-CSP — Muster `favorites.controller.ts` `getIcon` + * (T-k67-02). + */ + @Get('instances/:id/logo') + async logo(@Req() req: AuthenticatedRequest, @Param('id') id: string, @Res() res: Response) { + const { data, mime } = await this.service.getLogo(this.requireTenantId(req), id); + res.setHeader('Content-Type', mime); + res.setHeader('Cache-Control', 'private, max-age=86400'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); + res.send(data); + } + + /** + * Logo hochladen: Groessengrenze JE ROUTE (multers `LIMIT_FILE_SIZE` wird von + * Nest auf 413 abgebildet); Typ und Besitz pruefen im Dienst. + */ + @Post('instances/:id/logo') + @ModuleManage('nextcloud-status') + @UseInterceptors( + FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } }), + ) + async uploadLogo( + @Req() req: AuthenticatedRequest, + @Param('id') id: string, + @UploadedFile() file?: UploadedFileLike, + ) { + return this.service.uploadLogo(this.requireTenantId(req), id, file); + } + + @Delete('instances/:id/logo') + @ModuleManage('nextcloud-status') + async removeLogo(@Req() req: AuthenticatedRequest, @Param('id') id: string) { + return this.service.removeLogo(this.requireTenantId(req), id); + } } diff --git a/apps/api/src/nextcloud-status/nextcloud-status.module.ts b/apps/api/src/nextcloud-status/nextcloud-status.module.ts index bd1e82d..7d146e7 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.module.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.module.ts @@ -5,6 +5,7 @@ import { NextcloudReleaseService } from './nextcloud-release.service'; import { NextcloudStatusController } from './nextcloud-status.controller'; import { seedNextcloudStatusModule } from './nextcloud-status.seed'; import { NextcloudStatusService } from './nextcloud-status.service'; +import { NextcloudStatusSchedulerService } from './nextcloud-status-scheduler.service'; /** * NestJS module for the Nextcloud-Status feature (quick-261002-k67). @@ -13,7 +14,7 @@ import { NextcloudStatusService } from './nextcloud-status.service'; @Module({ imports: [ModuleRegistryModule], controllers: [NextcloudStatusController], - providers: [NextcloudStatusService, NextcloudReleaseService], + providers: [NextcloudStatusService, NextcloudReleaseService, NextcloudStatusSchedulerService], }) export class NextcloudStatusModule implements OnModuleInit { private readonly logger = new Logger(NextcloudStatusModule.name); diff --git a/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts b/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts index 1e87961..c7c4f4e 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts @@ -10,7 +10,7 @@ vi.mock('./nextcloud-status-fetch', async (importOriginal) => { return { ...actual, fetchNextcloudStatus: vi.fn() }; }); -import { forTenant } from '../prisma/prisma-tenant.extension'; +import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; import type { NextcloudReference } from './nextcloud-rating'; import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service'; import { fetchNextcloudStatus } from './nextcloud-status-fetch'; @@ -177,4 +177,199 @@ describe('NextcloudStatusService', () => { await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException); expect(fetchNextcloudStatus).not.toHaveBeenCalled(); }); + describe('Schreibwege (Aufgabe 2)', () => { + const OK_RESULT = { + reachable: true, + maintenance: false, + needsDbUpgrade: false, + versionString: '35.0.1', + edition: null, + productName: null, + errorKind: null, + errorDetail: null, + }; + + beforeEach(() => { + prisma.nextcloudInstance.delete = vi.fn(); + prisma.nextcloudInstance.findFirst.mockResolvedValue({ + id: 'i1', + baseUrl: 'https://cloud.a.de', + }); + prisma.nextcloudInstance.update.mockResolvedValue(makeRow()); + vi.mocked(fetchNextcloudStatus).mockResolvedValue(OK_RESULT); + }); + + it('updateInstance: nur Name -> kein Neuabruf', async () => { + await service.updateInstance('t1', 'i1', { customerName: ' Neu ' }); + expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({ + customerName: 'Neu', + }); + expect(fetchNextcloudStatus).not.toHaveBeenCalled(); + }); + + it('updateInstance: neue Adresse wird normalisiert und neu geprueft, unveraenderte nicht', async () => { + await service.updateInstance('t1', 'i1', { baseUrl: 'https://neu.example.de/index.php/' }); + expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({ + baseUrl: 'https://neu.example.de', + }); + expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1); + + vi.mocked(fetchNextcloudStatus).mockClear(); + await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de/' }); + expect(fetchNextcloudStatus).not.toHaveBeenCalled(); + }); + + it('updateInstance: ungueltige Adresse -> BadRequest', async () => { + await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow( + BadRequestException, + ); + }); + + it('updateInstance: nicht leere Logo-Adresse ersetzt den Upload, leere entfernt nur die Adresse', async () => { + await service.updateInstance('t1', 'i1', { logoUrl: 'https://logo.example.de/a.png' }); + expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({ + logoUrl: 'https://logo.example.de/a.png', + logoData: null, + logoMime: null, + logoVersion: { increment: 1 }, + }); + await service.updateInstance('t1', 'i1', { logoUrl: '' }); + expect(prisma.nextcloudInstance.update.mock.calls[1][0].data).toEqual({ + logoUrl: null, + logoVersion: { increment: 1 }, + }); + }); + + it('uploadLogo speichert Bytes und erkannten Typ, entfernt die Adresse und erhoeht die Version', async () => { + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]); + await service.uploadLogo('t1', 'i1', { + buffer: png, + originalname: 'x.exe', + mimetype: 'text/html', + size: png.length, + }); + const data = prisma.nextcloudInstance.update.mock.calls[0][0].data; + expect(Buffer.from(data.logoData)).toEqual(png); + expect(data).toMatchObject({ + logoMime: 'image/png', + logoUrl: null, + logoVersion: { increment: 1 }, + }); + expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT); + }); + + it('uploadLogo lehnt Nicht-Bilder, fehlende Dateien und fremde Kennungen ab', async () => { + const html = Buffer.from(''); + await expect( + service.uploadLogo('t1', 'i1', { + buffer: html, + originalname: 'a.png', + mimetype: 'image/png', + size: html.length, + }), + ).rejects.toThrow( + 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.', + ); + await expect(service.uploadLogo('t1', 'i1', undefined)).rejects.toThrow(BadRequestException); + prisma.nextcloudInstance.findFirst.mockResolvedValue(null); + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + await expect( + service.uploadLogo('t1', 'fremd', { + buffer: png, + originalname: 'a.png', + mimetype: 'image/png', + size: png.length, + }), + ).rejects.toThrow(NotFoundException); + expect(prisma.nextcloudInstance.update).not.toHaveBeenCalled(); + }); + + it('removeLogo loescht Bytes und Typ und erhoeht die Version', async () => { + await service.removeLogo('t1', 'i1'); + expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({ + logoData: null, + logoMime: null, + logoVersion: { increment: 1 }, + }); + }); + + it('getLogo liefert Bytes und Typ, ohne Logo NotFound', async () => { + prisma.nextcloudInstance.findFirst.mockResolvedValue({ + logoData: new Uint8Array([1, 2, 3]), + logoMime: 'image/png', + }); + const logo = await service.getLogo('t1', 'i1'); + expect(logo.mime).toBe('image/png'); + expect([...logo.data]).toEqual([1, 2, 3]); + expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({ + id: 'i1', + tenantId: 't1', + }); + prisma.nextcloudInstance.findFirst.mockResolvedValue({ logoData: null, logoMime: null }); + await expect(service.getLogo('t1', 'i1')).rejects.toThrow(NotFoundException); + prisma.nextcloudInstance.findFirst.mockResolvedValue(null); + await expect(service.getLogo('t1', 'x')).rejects.toThrow(NotFoundException); + }); + + it('deleteInstance loescht die Zeile; fremde Kennung -> NotFound', async () => { + expect(await service.deleteInstance('t1', 'i1')).toBe(true); + expect(prisma.nextcloudInstance.delete).toHaveBeenCalledWith({ where: { id: 'i1' } }); + prisma.nextcloudInstance.findFirst.mockResolvedValue(null); + await expect(service.deleteInstance('t1', 'fremd')).rejects.toThrow(NotFoundException); + expect(prisma.nextcloudInstance.delete).toHaveBeenCalledTimes(1); + }); + + it('checkAllForTenant prueft alle mit hoechstens vier gleichzeitig und liefert die frische Liste', async () => { + const ids = Array.from({ length: 10 }, (_, i) => `i${i}`); + prisma.nextcloudInstance.findMany.mockImplementation( + async (args: { select: Record }) => + args.select.id && Object.keys(args.select).length === 1 + ? ids.map((id) => ({ id })) + : [makeRow()], + ); + prisma.nextcloudInstance.findFirst.mockImplementation( + async ({ where }: { where: { id: string } }) => ({ + id: where.id, + baseUrl: 'https://cloud.a.de', + }), + ); + let inFlight = 0; + let peak = 0; + vi.mocked(fetchNextcloudStatus).mockImplementation(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + return OK_RESULT; + }); + const result = await service.checkAllForTenant('t1'); + expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10); + expect(peak).toBeLessThanOrEqual(4); + expect(peak).toBeGreaterThan(1); + expect(result.instances).toHaveLength(1); + }); + + it('checkAllForTenant: eine fehlerhafte Cloud stoppt die anderen nicht', async () => { + prisma.nextcloudInstance.findMany.mockImplementation( + async (args: { select: Record }) => + Object.keys(args.select).length === 1 ? [{ id: 'a' }, { id: 'b' }] : [], + ); + prisma.nextcloudInstance.findFirst.mockImplementation( + async ({ where }: { where: { id: string } }) => + where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' }, + ); + await service.checkAllForTenant('t1'); + expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1); + }); + + it('loadAllInstancesForScheduler waehlt nur Kennung und Mandant ueber forSystem', async () => { + prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]); + const rows = await service.loadAllInstancesForScheduler(); + expect(forSystem).toHaveBeenCalledWith(prisma); + expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({ + select: { id: true, tenantId: true }, + }); + expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]); + }); + }); }); diff --git a/apps/api/src/nextcloud-status/nextcloud-status.service.ts b/apps/api/src/nextcloud-status/nextcloud-status.service.ts index 757ca9b..99f4f72 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.service.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.service.ts @@ -1,7 +1,12 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { UploadedFileLike } from '../auth/types/auth-user'; import { PrismaService } from '../prisma/prisma.service'; -import { forTenant } from '../prisma/prisma-tenant.extension'; -import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto'; +import { forSystem, forTenant } from '../prisma/prisma-tenant.extension'; +import type { + CreateNextcloudInstanceDto, + UpdateNextcloudInstanceDto, +} from './dto/nextcloud-instance.dto'; +import { checkLogoUpload } from './nextcloud-logo-rules'; import { type NextcloudRating, type NextcloudReference, @@ -76,10 +81,34 @@ export interface NextcloudListView { 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, @@ -178,4 +207,172 @@ export class NextcloudStatusService { }); return this.toView(updated as PublicRow, await this.release.getReference()); } + /** + * 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; + } + } + + 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): 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); + } + + /** + * 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`). + */ + async loadAllInstancesForScheduler(): Promise<{ id: string; tenantId: string }[]> { + const systemPrisma = forSystem(this.prisma); + return systemPrisma.nextcloudInstance.findMany({ + select: { id: true, tenantId: true }, + }); + } } diff --git a/apps/api/src/prisma/rls-access-inventory.spec.ts b/apps/api/src/prisma/rls-access-inventory.spec.ts index 7f9a0c1..5ef2d2d 100644 --- a/apps/api/src/prisma/rls-access-inventory.spec.ts +++ b/apps/api/src/prisma/rls-access-inventory.spec.ts @@ -189,10 +189,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set(['apps/api/src/tenders/backfill * gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist * `system_read_policy ... FOR SELECT` auf "Reminder" (Migration * 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe. + * + * quick-261002-k67 (Aufgabe 2): eine siebte Datei kommt hinzu — + * `nextcloud-status/nextcloud-status.service.ts`, EIN Aufruf: + * `loadAllInstancesForScheduler()` liest fuer den stuendlichen Planer nur + * Kennung und Mandant ALLER Clouds (`select: { id, tenantId }`, nie Logo-Bytes). + * Geprueft und geschrieben wird danach je Cloud gebunden ueber + * `forTenant(prisma, tenantId)`. Die passende Regel ist + * `system_read_policy ... FOR SELECT` auf "NextcloudInstance" (Migration + * 20261002150000). Summe neu: 7 Dateien, 8 Aufrufe. */ const FORSYSTEM_ALLOWED_CALL_SITES = new Map([ ['apps/api/src/dkv/dkv.service.ts', 1], ['apps/api/src/ldap/ldap-config.service.ts', 2], + ['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1], ['apps/api/src/proxmox/proxmox.service.ts', 1], ['apps/api/src/reminders/reminder-mail.scheduler.ts', 1], ['apps/api/src/tenders/tender-digest.scheduler.ts', 1], diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx new file mode 100644 index 0000000..ba54bbc --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx @@ -0,0 +1,202 @@ +import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NextcloudInstance } from '@/lib/nextcloud-status-api'; +import de from '@/messages/de.json'; +import { CloudForm } from './CloudForm'; + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +const api = { + createInstance: vi.fn(), + updateInstance: vi.fn(), + deleteInstance: vi.fn(), + uploadLogo: vi.fn(), + removeLogo: vi.fn(), +}; + +vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createInstance: (...a: unknown[]) => api.createInstance(...a), + updateInstance: (...a: unknown[]) => api.updateInstance(...a), + deleteInstance: (...a: unknown[]) => api.deleteInstance(...a), + uploadLogo: (...a: unknown[]) => api.uploadLogo(...a), + removeLogo: (...a: unknown[]) => api.removeLogo(...a), + }; +}); + +const EXISTING: NextcloudInstance = { + id: 'i1', + customerName: 'Kunde A', + baseUrl: 'https://a.example.de', + logoUrl: null, + hasUploadedLogo: false, + logoVersion: 0, + status: { + checkedAt: null, + reachable: true, + maintenance: false, + needsDbUpgrade: false, + versionString: '35.0.1', + edition: null, + errorKind: null, + errorDetail: null, + }, + rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 }, +}; + +function setup(instance: NextcloudInstance | null = null) { + const props = { onClose: vi.fn(), onSaved: vi.fn(), onDeleted: vi.fn() }; + render(); + return props; +} + +describe('CloudForm', () => { + beforeEach(() => { + for (const fn of Object.values(api)) fn.mockReset(); + }); + afterEach(() => cleanup()); + + it('verlangt Kundenname und Adresse', async () => { + setup(); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Bitte füllen Sie Kundenname und Adresse aus.', + ); + expect(api.createInstance).not.toHaveBeenCalled(); + }); + + it('legt eine Cloud an', async () => { + api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' }); + const props = setup(); + fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: ' Neu GmbH ' } }); + fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { + target: { value: 'https://cloud.neu.de' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + await waitFor(() => expect(props.onSaved).toHaveBeenCalled()); + expect(api.createInstance).toHaveBeenCalledWith({ + customerName: 'Neu GmbH', + baseUrl: 'https://cloud.neu.de', + }); + expect(api.uploadLogo).not.toHaveBeenCalled(); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('lädt nach dem Anlegen das gewählte Logo hoch', async () => { + api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' }); + api.uploadLogo.mockResolvedValue({ ...EXISTING, id: 'neu', hasUploadedLogo: true }); + const props = setup(); + fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } }); + fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { + target: { value: 'https://cloud.neu.de' }, + }); + fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' })); + const file = new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' }); + fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), { + target: { files: [file] }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + await waitFor(() => expect(api.uploadLogo).toHaveBeenCalledWith('neu', file)); + expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ hasUploadedLogo: true })); + }); + + it('sendet eine https-Bildadresse und weist darauf hin', async () => { + api.createInstance.mockResolvedValue(EXISTING); + setup(); + fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } }); + fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { + target: { value: 'https://cloud.neu.de' }, + }); + fireEvent.click( + screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="radio"]' }), + ); + expect(screen.getByText(/muss mit https:\/\/ beginnen/)).toBeInTheDocument(); + fireEvent.change( + screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="url"]' }), + { + target: { value: 'https://logo.example.de/a.png' }, + }, + ); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + await waitFor(() => + expect(api.createInstance).toHaveBeenCalledWith({ + customerName: 'Neu', + baseUrl: 'https://cloud.neu.de', + logoUrl: 'https://logo.example.de/a.png', + }), + ); + }); + + it('lehnt eine zu große Datei sofort ab', async () => { + setup(); + fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' })); + const big = new File([new Uint8Array(1024 * 1024 + 1)], 'groß.png', { type: 'image/png' }); + fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), { + target: { files: [big] }, + }); + expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.'); + }); + + it('ändert eine vorhandene Cloud', async () => { + api.updateInstance.mockResolvedValue(EXISTING); + const props = setup(EXISTING); + expect(screen.getByLabelText('Kundenname')).toHaveValue('Kunde A'); + fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Kunde B' } }); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + await waitFor(() => + expect(api.updateInstance).toHaveBeenCalledWith('i1', { + customerName: 'Kunde B', + baseUrl: 'https://a.example.de', + }), + ); + expect(props.onSaved).toHaveBeenCalled(); + }); + + it('entfernt ein hochgeladenes Logo über „Kein Logo“', async () => { + const withLogo = { ...EXISTING, hasUploadedLogo: true, logoVersion: 2 }; + api.updateInstance.mockResolvedValue(withLogo); + api.removeLogo.mockResolvedValue(EXISTING); + setup(withLogo); + fireEvent.click(screen.getByRole('button', { name: 'Logo entfernen' })); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + await waitFor(() => expect(api.removeLogo).toHaveBeenCalledWith('i1')); + }); + + it('Entfernen fragt erst nach und ruft dann deleteInstance', async () => { + api.deleteInstance.mockResolvedValue(undefined); + const props = setup(EXISTING); + fireEvent.click(screen.getByRole('button', { name: 'Cloud entfernen' })); + expect( + screen.getByText('Möchten Sie die Cloud „Kunde A“ wirklich entfernen?'), + ).toBeInTheDocument(); + expect(api.deleteInstance).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Endgültig entfernen' })); + await waitFor(() => expect(api.deleteInstance).toHaveBeenCalledWith('i1')); + expect(props.onDeleted).toHaveBeenCalledWith('i1'); + }); + + it('zeigt die Fehlermeldung der API im Formular', async () => { + api.createInstance.mockRejectedValue( + new Error('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'), + ); + const props = setup(); + fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } }); + fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { target: { value: 'ftp://x' } }); + fireEvent.click(screen.getByRole('button', { name: 'Speichern' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Bitte geben Sie eine gültige Adresse', + ); + expect(props.onSaved).not.toHaveBeenCalled(); + expect(props.onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx new file mode 100644 index 0000000..5abdaab --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx @@ -0,0 +1,297 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { + createInstance, + deleteInstance, + logoSrc, + type NextcloudInstance, + removeLogo, + updateInstance, + uploadLogo, +} from '@/lib/nextcloud-status-api'; + +type LogoMode = 'none' | 'upload' | 'url'; + +/** Obergrenze fuer ein Logo (wie die API: 1 MiB). */ +const LOGO_MAX_BYTES = 1024 * 1024; + +interface CloudFormProps { + /** `null` = neue Cloud anlegen, sonst bearbeiten. */ + instance: NextcloudInstance | null; + onClose: () => void; + /** Liefert die gespeicherte Cloud (mit frischer Pruefung und Bewertung). */ + onSaved: (instance: NextcloudInstance) => void; + onDeleted: (id: string) => void; +} + +function initialMode(instance: NextcloudInstance | null): LogoMode { + if (instance?.hasUploadedLogo) return 'upload'; + if (instance?.logoUrl) return 'url'; + return 'none'; +} + +/** + * Formular „Cloud hinzufuegen / bearbeiten“ (quick-261002-k67, L-02, L-09) — + * Aufbau wie `CustomModuleFormModal`. Nur fuer Verwalter erreichbar (die Seite + * zeigt die Zugaenge nicht fuer Benutzer ohne Verwalten); bindend ist allein + * der ModuleGuard der API. Das Logo ist entweder ein Upload (die API prueft den + * Typ an den Bytes) oder eine https-Bildadresse, die der Browser selbst laedt. + * Anlegen wartet auf die erste Pruefung („Wird geprueft …“). + */ +export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormProps) { + const t = useTranslations('nextcloudStatus.form'); + const [customerName, setCustomerName] = useState(instance?.customerName ?? ''); + const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? ''); + const [mode, setMode] = useState(initialMode(instance)); + const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? ''); + const [file, setFile] = useState(null); + const [saving, setSaving] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [error, setError] = useState(null); + + const currentLogo = + instance && (instance.hasUploadedLogo || instance.logoUrl) ? logoSrc(instance) : null; + + const handleFile = (e: React.ChangeEvent) => { + const picked = e.target.files?.[0] ?? null; + if (picked && picked.size > LOGO_MAX_BYTES) { + setError(t('logoFileTooLarge')); + setFile(null); + return; + } + setError(null); + setFile(picked); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const name = customerName.trim(); + const url = baseUrl.trim(); + if (!name || !url) { + setError(t('required')); + return; + } + setSaving(true); + try { + let saved: NextcloudInstance; + if (instance) { + const payload: { customerName: string; baseUrl: string; logoUrl?: string } = { + customerName: name, + baseUrl: url, + }; + if (mode === 'url') payload.logoUrl = logoUrl.trim(); + else if (mode === 'none' && instance.logoUrl) payload.logoUrl = ''; + saved = await updateInstance(instance.id, payload); + if (mode === 'none' && instance.hasUploadedLogo) saved = await removeLogo(instance.id); + if (mode === 'upload' && file) saved = await uploadLogo(instance.id, file); + } else { + saved = await createInstance({ + customerName: name, + baseUrl: url, + ...(mode === 'url' && logoUrl.trim() ? { logoUrl: logoUrl.trim() } : {}), + }); + if (mode === 'upload' && file) saved = await uploadLogo(saved.id, file); + } + onSaved(saved); + onClose(); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t('saveError')); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (!instance) return; + setError(null); + setSaving(true); + try { + await deleteInstance(instance.id); + onDeleted(instance.id); + onClose(); + } catch (err) { + setError(err instanceof Error && err.message ? err.message : t('deleteError')); + setConfirmDelete(false); + } finally { + setSaving(false); + } + }; + + const input = 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm'; + + return ( +
+
+

+ {instance ? t('editTitle') : t('addTitle')} +

+ + {confirmDelete && instance ? ( +
+

+ {t('deleteConfirm', { name: instance.customerName })} +

+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+ ) : ( +
+
+ + setCustomerName(e.target.value)} + className={input} + /> +
+ +
+ + setBaseUrl(e.target.value)} + className={input} + /> +

{t('baseUrlHint')}

+
+ +
+ {t('logo')} + {currentLogo && mode !== 'none' && ( +
+ {/* biome-ignore lint/performance/noImgElement: Vorschau einer externen oder hochgeladenen Adresse */} + {t('logoCurrent')} + +
+ )} +
+ {(['none', 'upload', 'url'] as const).map((value) => ( + + ))} +
+ {mode === 'upload' && ( +
+ +

{t('logoFileHint')}

+
+ )} + {mode === 'url' && ( +
+ setLogoUrl(e.target.value)} + className={input} + /> +

{t('logoUrlHint')}

+
+ )} +
+ + {error && ( +
+ {error} +
+ )} + +
+
+ {instance && ( + + )} +
+
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx index e530f9c..4dbfa8f 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx @@ -17,6 +17,12 @@ interface CloudTileProps { instance: NextcloudInstance; /** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */ now?: number; + /** Verwalten-Ebene: zeigt die Knoepfe Pruefen und Bearbeiten (nur Anzeige, bindend ist die API). */ + canManage?: boolean; + /** Eine Pruefung dieser Kachel laeuft (Drehsymbol, Knopf gesperrt). */ + checking?: boolean; + onCheck?: () => void; + onEdit?: () => void; } /** @@ -26,7 +32,14 @@ interface CloudTileProps { * Zeitpunkt der letzten Pruefung. Die Logo-Adresse kommt vom Verwalter und * wird vom Browser geladen — ohne Referer (`referrerPolicy`, T-k67-05). */ -export function CloudTile({ instance, now = Date.now() }: CloudTileProps) { +export function CloudTile({ + instance, + now = Date.now(), + canManage = false, + checking = false, + onCheck, + onEdit, +}: CloudTileProps) { const t = useTranslations('nextcloudStatus'); const locale = useLocale(); const [logoFailed, setLogoFailed] = useState(false); @@ -79,6 +92,58 @@ export function CloudTile({ instance, now = Date.now() }: CloudTileProps) { {instance.baseUrl} + {canManage && ( +
+ + +
+ )}
diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx index 8d71513..4252fe3 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx @@ -1,4 +1,11 @@ -import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render as rtlRender, + screen, + waitFor, + within, +} from '@testing-library/react'; import { NextIntlClientProvider } from 'next-intl'; import type { ReactElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -15,12 +22,29 @@ function render(ui: ReactElement) { } const mockListInstances = vi.fn(); +const mockCheckAll = vi.fn(); +const mockCheckOne = vi.fn(); vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, listInstances: (...args: unknown[]) => mockListInstances(...args) }; + return { + ...actual, + listInstances: (...args: unknown[]) => mockListInstances(...args), + checkAll: (...args: unknown[]) => mockCheckAll(...args), + checkOne: (...args: unknown[]) => mockCheckOne(...args), + }; }); +let mockCanManage: boolean | null = false; +vi.mock('@/lib/use-module-capability', () => ({ + useCanManageModule: () => mockCanManage, +})); + +vi.mock('@/lib/stores/auth-store', () => ({ + useAuthStore: (selector: (state: { user: { id: string } }) => unknown) => + selector({ user: { id: 'u1' } }), +})); + function makeInstance( over: Partial & { id: string; customerName: string }, ): NextcloudInstance { @@ -101,6 +125,10 @@ const LIST: NextcloudList = { describe('NextcloudStatusPage', () => { beforeEach(() => { mockListInstances.mockReset(); + mockCheckAll.mockReset(); + mockCheckOne.mockReset(); + mockCanManage = false; + window.localStorage.clear(); }); afterEach(() => cleanup()); @@ -167,4 +195,94 @@ describe('NextcloudStatusPage', () => { const tile = await screen.findByTestId('cloud-tile'); expect(within(tile).getByText('KG')).toBeInTheDocument(); }); + describe('Rechte und Sortierung', () => { + const tileNames = () => + screen.getAllByTestId('cloud-tile').map((t) => t.getAttribute('aria-label')?.split(':')[0]); + + it('Benutzen-Ebene: Kacheln und Sortierung ja, Verwalten-Knöpfe nein', async () => { + mockCanManage = false; + mockListInstances.mockResolvedValue(LIST); + render(); + await screen.findAllByTestId('cloud-tile'); + expect(screen.getByLabelText('Sortieren nach')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cloud hinzufügen' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Jetzt prüfen' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Cloud bearbeiten' })).toBeNull(); + }); + + it('Verwalter sehen alle Knöpfe', async () => { + mockCanManage = true; + mockListInstances.mockResolvedValue(LIST); + render(); + await screen.findAllByTestId('cloud-tile'); + expect(screen.getByRole('button', { name: 'Cloud hinzufügen' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Jetzt prüfen' })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toHaveLength(4); + expect(screen.getAllByRole('button', { name: 'Cloud bearbeiten' })).toHaveLength(4); + }); + + it('Status-Sortierung stellt Rot nach vorn und merkt die Auswahl', async () => { + mockListInstances.mockResolvedValue(LIST); + render(); + await screen.findAllByTestId('cloud-tile'); + expect(tileNames()).toEqual(['Kunde Gelb', 'Kunde Grau', 'Kunde Grün', 'Kunde Rot']); + + fireEvent.change(screen.getByLabelText('Sortieren nach'), { target: { value: 'status' } }); + expect(tileNames()).toEqual(['Kunde Rot', 'Kunde Gelb', 'Kunde Grau', 'Kunde Grün']); + expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status'); + }); + + it('wendet eine gemerkte Sortierung beim Laden an', async () => { + window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'status'); + mockListInstances.mockResolvedValue(LIST); + render(); + await screen.findAllByTestId('cloud-tile'); + await waitFor(() => expect(tileNames()[0]).toBe('Kunde Rot')); + expect((screen.getByLabelText('Sortieren nach') as HTMLSelectElement).value).toBe('status'); + }); + + it('„Jetzt prüfen“ ruft checkAll und ersetzt die Liste', async () => { + mockCanManage = true; + mockListInstances.mockResolvedValue(LIST); + mockCheckAll.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] }); + render(); + await screen.findAllByTestId('cloud-tile'); + fireEvent.click(screen.getByRole('button', { name: 'Jetzt prüfen' })); + await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(1)); + expect(mockCheckAll).toHaveBeenCalledTimes(1); + }); + + it('Kachel-Prüfung ersetzt nur diese Kachel', async () => { + mockCanManage = true; + mockListInstances.mockResolvedValue(LIST); + mockCheckOne.mockResolvedValue({ + ...LIST.instances[2], + customerName: 'Kunde Rot', + rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 }, + }); + render(); + const tiles = await screen.findAllByTestId('cloud-tile'); + const red = tiles.find((t) => + t.getAttribute('aria-label')?.startsWith('Kunde Rot'), + ) as HTMLElement; + fireEvent.click(within(red).getByRole('button', { name: 'Diese Cloud jetzt prüfen' })); + await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c')); + await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(4)); + await waitFor(() => + expect( + screen.getAllByTestId('cloud-tile').find((t) => t.textContent?.includes('Kunde Rot')), + ).toHaveAttribute('data-level', 'green'), + ); + }); + + it('„Cloud hinzufügen“ öffnet das Formular', async () => { + mockCanManage = true; + mockListInstances.mockResolvedValue(LIST); + render(); + await screen.findAllByTestId('cloud-tile'); + fireEvent.click(screen.getByRole('button', { name: 'Cloud hinzufügen' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx index 3d2e397..6a791d3 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx @@ -1,10 +1,26 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { PageHeader } from '@/components/layout/page-header'; +import { + readSortPreference, + SORT_KEYS, + type SortKey, + sortClouds, + writeSortPreference, +} from '@/components/nextcloud-status/sort-clouds'; import { WELL } from '@/components/proxmox/status-styles'; -import { listInstances, type NextcloudList } from '@/lib/nextcloud-status-api'; +import { + checkAll, + checkOne, + listInstances, + type NextcloudInstance, + type NextcloudList, +} from '@/lib/nextcloud-status-api'; +import { useAuthStore } from '@/lib/stores/auth-store'; +import { useCanManageModule } from '@/lib/use-module-capability'; +import { CloudForm } from './components/CloudForm'; import { CloudTile } from './components/CloudTile'; /** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */ @@ -25,16 +41,50 @@ function SkeletonTile() { ); } +function RefreshIcon({ spinning }: { spinning: boolean }) { + return ( + + ); +} + /** * Modulseite Nextcloud-Status (quick-261002-k67): eine Kachel je Cloud mit * Ampel, installierter Version und Zeitpunkt der letzten Pruefung. Die * Bewertung berechnet die API beim Lesen (D-B) — diese Seite zeigt nur an. + * + * Sortierung (L-07) steht jedem Benutzer offen und wird je Benutzer im + * Browser gemerkt. Hinzufuegen, Bearbeiten, Pruefen („Jetzt pruefen“, je + * Kachel) nur mit Verwalten (`useCanManageModule`, L-09); die Knoepfe sind + * reine Anzeige — bindend ist `@ModuleManage` an der API. */ export default function NextcloudStatusPage() { const t = useTranslations('nextcloudStatus'); + const userId = useAuthStore((s) => s.user?.id ?? null); + const canManage = useCanManageModule('nextcloud-status') === true; + const [data, setData] = useState(null); const [error, setError] = useState(null); const [now, setNow] = useState(() => Date.now()); + const [sortKey, setSortKey] = useState('name'); + const [checkingAll, setCheckingAll] = useState(false); + const [checkingIds, setCheckingIds] = useState>(new Set()); + // `undefined` = Formular zu, `null` = neue Cloud, sonst bearbeiten. + const [editing, setEditing] = useState(undefined); const reload = useCallback(() => { listInstances() @@ -54,11 +104,82 @@ export default function NextcloudStatusPage() { return () => window.clearInterval(id); }, []); + // Gemerkte Sortierung dieses Benutzers anwenden, sobald seine Kennung feststeht. + useEffect(() => { + if (userId) setSortKey(readSortPreference(userId)); + }, [userId]); + + const handleSort = (key: SortKey) => { + setSortKey(key); + if (userId) writeSortPreference(userId, key); + }; + + const handleCheckAll = async () => { + setCheckingAll(true); + try { + const list = await checkAll(); + setData(list); + setNow(Date.now()); + } catch { + setError(t('loadError')); + } finally { + setCheckingAll(false); + } + }; + + const handleCheckOne = async (id: string) => { + setCheckingIds((prev) => new Set(prev).add(id)); + try { + const updated = await checkOne(id); + setData((prev) => + prev + ? { ...prev, instances: prev.instances.map((i) => (i.id === id ? updated : i)) } + : prev, + ); + setNow(Date.now()); + } catch { + /* die Kachel behaelt ihren bisherigen Stand */ + } finally { + setCheckingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }; + + // Nach Anlegen/Aendern/Loeschen die Liste neu laden: die Reihenfolge und die + // neueste Version kommen so wieder aus einer Quelle. + const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]); const newest = data?.reference.newestVersion ?? null; return (
- + + + {data !== null && data.instances.length > 0 && ( + + )} + + ) : undefined + } + /> {error && (

@@ -79,26 +200,62 @@ export default function NextcloudStatusPage() { )} {!error && data !== null && ( -

- {newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')} -

+
+

+ {newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')} +

+ {data.instances.length > 0 && ( + + )} +
)} {!error && data !== null && data.instances.length === 0 && (

{t('empty')}

+ {canManage &&

{t('emptyManage')}

}
)} - {!error && data !== null && data.instances.length > 0 && ( + {!error && data !== null && sorted.length > 0 && (
    - {data.instances.map((instance) => ( + {sorted.map((instance) => (
  • - + handleCheckOne(instance.id)} + onEdit={() => setEditing(instance)} + />
  • ))}
)} + + {editing !== undefined && ( + setEditing(undefined)} + onSaved={() => reload()} + onDeleted={() => reload()} + /> + )}
); } diff --git a/apps/web/src/components/nextcloud-status/sort-clouds.test.ts b/apps/web/src/components/nextcloud-status/sort-clouds.test.ts new file mode 100644 index 0000000..63f770f --- /dev/null +++ b/apps/web/src/components/nextcloud-status/sort-clouds.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api'; +import { readSortPreference, sortClouds, writeSortPreference } from './sort-clouds'; + +function make( + name: string, + level: RatingLevel = 'green', + version: string | null = '35.0.1', + eolDate: string | null = null, +): NextcloudInstance { + return { + id: name, + customerName: name, + baseUrl: `https://${name}.de`, + logoUrl: null, + hasUploadedLogo: false, + logoVersion: 0, + status: { + checkedAt: null, + reachable: true, + maintenance: false, + needsDbUpgrade: false, + versionString: version, + edition: null, + errorKind: null, + errorDetail: null, + }, + rating: { level, reason: 'current', updateTo: null, eolDate, cycle: null }, + }; +} + +const names = (list: NextcloudInstance[]) => list.map((i) => i.customerName); + +describe('sortClouds', () => { + it('name: A-Z mit deutscher Sortierung, ohne Beachtung der Gross-/Kleinschreibung', () => { + const list = [make('zebra'), make('Ärger'), make('Anton'), make('berta')]; + expect(names(sortClouds(list, 'name'))).toEqual(['Anton', 'Ärger', 'berta', 'zebra']); + }); + + it('status: rot, gelb, grau, gruen, bei Gleichstand nach Name', () => { + const list = [ + make('g2', 'green'), + make('r2', 'red'), + make('u1', 'unknown'), + make('y1', 'yellow'), + make('r1', 'red'), + make('g1', 'green'), + ]; + expect(names(sortClouds(list, 'status'))).toEqual(['r1', 'r2', 'y1', 'u1', 'g1', 'g2']); + }); + + it('version: aelteste zuerst, ohne Version zuletzt', () => { + const list = [ + make('a', 'green', '34.0.10'), + make('b', 'green', null), + make('c', 'green', '33.0.9'), + make('d', 'green', '34.0.2'), + ]; + expect(names(sortClouds(list, 'version'))).toEqual(['c', 'd', 'a', 'b']); + }); + + it('eol: frueheste zuerst, ohne Datum zuletzt', () => { + const list = [ + make('a', 'green', '1.0.0', '2027-06-30'), + make('b'), + make('c', 'green', '1.0.0', '2026-09-30'), + ]; + expect(names(sortClouds(list, 'eol'))).toEqual(['c', 'a', 'b']); + }); + + it('veraendert die Eingabe nicht', () => { + const list = [make('b'), make('a')]; + const copy = [...list]; + sortClouds(list, 'name'); + expect(list).toEqual(copy); + }); +}); + +describe('Sortier-Vorliebe', () => { + afterEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('merkt die Auswahl je Benutzer unter eigenem Schluessel', () => { + writeSortPreference('u1', 'status'); + expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status'); + expect(readSortPreference('u1')).toBe('status'); + expect(readSortPreference('u2')).toBe('name'); + }); + + it('ein unbekannter gespeicherter Wert ergibt name', () => { + window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'quatsch'); + expect(readSortPreference('u1')).toBe('name'); + }); + + it('ein gesperrter Speicher wirft nie', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('gesperrt'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('gesperrt'); + }); + expect(readSortPreference('u1')).toBe('name'); + expect(() => writeSortPreference('u1', 'eol')).not.toThrow(); + }); +}); diff --git a/apps/web/src/components/nextcloud-status/sort-clouds.ts b/apps/web/src/components/nextcloud-status/sort-clouds.ts new file mode 100644 index 0000000..b8011ee --- /dev/null +++ b/apps/web/src/components/nextcloud-status/sort-clouds.ts @@ -0,0 +1,83 @@ +import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api'; + +/** + * Sortierung der Cloud-Kacheln (quick-261002-k67, L-07, D-G) — reine + * Funktionen, kein React. + */ +export type SortKey = 'name' | 'status' | 'version' | 'eol'; + +export const SORT_KEYS: readonly SortKey[] = ['name', 'status', 'version', 'eol']; + +/** Rot zuerst, dann Gelb, Grau, Gruen. */ +const LEVEL_ORDER: Record = { red: 0, yellow: 1, unknown: 2, green: 3 }; + +function byName(a: NextcloudInstance, b: NextcloudInstance): number { + return a.customerName.localeCompare(b.customerName, 'de', { sensitivity: 'base' }); +} + +function versionTriple(raw: string | null): [number, number, number] | null { + if (!raw) return null; + const m = /^(\d+)\.(\d+)\.(\d+)/.exec(raw.trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +function compareVersions(a: NextcloudInstance, b: NextcloudInstance): number { + const va = versionTriple(a.status.versionString); + const vb = versionTriple(b.status.versionString); + if (!va && !vb) return 0; + if (!va) return 1; // unbekannt zuletzt + if (!vb) return -1; + for (let i = 0; i < 3; i++) { + if (va[i] !== vb[i]) return va[i] - vb[i]; + } + return 0; +} + +function compareEol(a: NextcloudInstance, b: NextcloudInstance): number { + const ea = a.rating.eolDate; + const eb = b.rating.eolDate; + if (!ea && !eb) return 0; + if (!ea) return 1; // unbekannt zuletzt + if (!eb) return -1; + return ea < eb ? -1 : ea > eb ? 1 : 0; +} + +/** Liefert eine neue, sortierte Liste; die Eingabe bleibt unveraendert. Gleiche Werte nach Kundenname. */ +export function sortClouds(items: NextcloudInstance[], key: SortKey): NextcloudInstance[] { + const compare = (a: NextcloudInstance, b: NextcloudInstance): number => { + switch (key) { + case 'status': + return LEVEL_ORDER[a.rating.level] - LEVEL_ORDER[b.rating.level]; + case 'version': + return compareVersions(a, b); + case 'eol': + return compareEol(a, b); + default: + return 0; + } + }; + return [...items].sort((a, b) => compare(a, b) || byName(a, b)); +} + +function storageKey(userId: string): string { + return `tessera:nextcloud-status:sort:${userId}`; +} + +/** Gemerkte Sortierung dieses Benutzers; unbekannter Wert oder gesperrter Speicher ergeben 'name'. */ +export function readSortPreference(userId: string): SortKey { + try { + const stored = window.localStorage.getItem(storageKey(userId)); + return (SORT_KEYS as readonly string[]).includes(stored ?? '') ? (stored as SortKey) : 'name'; + } catch { + return 'name'; + } +} + +/** Merkt die Sortierung; ein gesperrter Speicher wird still uebergangen. */ +export function writeSortPreference(userId: string, key: SortKey): void { + try { + window.localStorage.setItem(storageKey(userId), key); + } catch { + /* Speicher gesperrt oder voll — die Auswahl gilt dann nur fuer diese Sitzung */ + } +} diff --git a/apps/web/src/lib/nextcloud-status-api.ts b/apps/web/src/lib/nextcloud-status-api.ts index 85fce54..2413f2c 100644 --- a/apps/web/src/lib/nextcloud-status-api.ts +++ b/apps/web/src/lib/nextcloud-status-api.ts @@ -84,3 +84,93 @@ export function logoSrc( } return instance.logoUrl ?? null; } + +/** Liest die NestJS-Fehlermeldung aus dem Antwortkoerper, faellt sonst auf einen Standardtext zurueck. */ +async function readErrorMessage(res: Response, fallback: string): Promise { + try { + const body = await res.json(); + if (typeof body?.message === 'string') return body.message; + if (Array.isArray(body?.message) && body.message.length > 0) return String(body.message[0]); + } catch { + /* Antwort war kein JSON — Standardtext bleibt */ + } + return fallback; +} + +export interface NextcloudInstancePayload { + customerName: string; + baseUrl: string; + /** Leere Zeichenkette = Logo-Adresse entfernen (nur beim Aendern). */ + logoUrl?: string; +} + +const BASE = `${API_URL}/modules/nextcloud-status/instances`; + +/** POST /instances — Cloud anlegen und sofort pruefen (nur Verwalten). */ +export async function createInstance( + payload: NextcloudInstancePayload, +): Promise { + const res = await fetch(BASE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to create nextcloud instance')); + return res.json(); +} + +/** PUT /instances/:id — Cloud aendern (nur Verwalten). */ +export async function updateInstance( + id: string, + payload: Partial, +): Promise { + const res = await fetch(`${BASE}/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to update nextcloud instance')); + return res.json(); +} + +/** DELETE /instances/:id — Cloud entfernen (nur Verwalten). */ +export async function deleteInstance(id: string): Promise { + const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', credentials: 'include' }); + if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to delete nextcloud instance')); +} + +/** POST /instances/check — "Jetzt pruefen" fuer die ganze Liste, liefert die frische Liste. */ +export async function checkAll(): Promise { + const res = await fetch(`${BASE}/check`, { method: 'POST', credentials: 'include' }); + if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instances')); + return res.json(); +} + +/** POST /instances/:id/check — eine Cloud sofort pruefen, liefert die aktualisierte Kachel. */ +export async function checkOne(id: string): Promise { + const res = await fetch(`${BASE}/${id}/check`, { method: 'POST', credentials: 'include' }); + if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instance')); + return res.json(); +} + +/** POST /instances/:id/logo — Logo hochladen (Formularfeld `logo`, nur Verwalten). */ +export async function uploadLogo(id: string, file: File): Promise { + const form = new FormData(); + form.append('logo', file); + const res = await fetch(`${BASE}/${id}/logo`, { + method: 'POST', + credentials: 'include', + body: form, + }); + if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to upload logo')); + return res.json(); +} + +/** DELETE /instances/:id/logo — hochgeladenes Logo entfernen (nur Verwalten). */ +export async function removeLogo(id: string): Promise { + const res = await fetch(`${BASE}/${id}/logo`, { method: 'DELETE', credentials: 'include' }); + if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to remove logo')); + return res.json(); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index b154af4..f66c2c0 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1851,6 +1851,50 @@ "noReference": "Bewertung nicht möglich", "versionUnknown": "Version unbekannt", "notChecked": "Noch nicht geprüft" + }, + "sort": { + "label": "Sortieren nach", + "name": "Kundenname", + "status": "Status", + "version": "Version", + "eol": "Support-Ende" + }, + "actions": { + "add": "Cloud hinzufügen", + "checkAll": "Jetzt prüfen", + "checking": "Wird geprüft …" + }, + "cardActions": { + "refresh": "Diese Cloud jetzt prüfen", + "edit": "Cloud bearbeiten" + }, + "form": { + "addTitle": "Cloud hinzufügen", + "editTitle": "Cloud bearbeiten", + "customerName": "Kundenname", + "baseUrl": "Adresse der Cloud", + "baseUrlPlaceholder": "https://cloud.example.com", + "baseUrlHint": "Tessera ruft nur die öffentliche Statusseite dieser Adresse ab. Zugangsdaten werden nicht benötigt.", + "logo": "Logo", + "logoNone": "Kein Logo", + "logoUpload": "Bild hochladen", + "logoUrl": "Bildadresse (https)", + "logoUrlPlaceholder": "https://www.example.com/logo.png", + "logoUrlHint": "Die Adresse muss mit https:// beginnen. Das Bild wird direkt von Ihrem Browser geladen.", + "logoFileHint": "PNG, JPEG, GIF oder WebP, höchstens 1 MB.", + "logoFileTooLarge": "Die Datei ist größer als 1 MB.", + "logoCurrent": "Aktuelles Logo", + "logoRemove": "Logo entfernen", + "save": "Speichern", + "saving": "Wird geprüft …", + "cancel": "Abbrechen", + "delete": "Cloud entfernen", + "deleteConfirm": "Möchten Sie die Cloud „{name}“ wirklich entfernen?", + "deleteConfirmYes": "Endgültig entfernen", + "required": "Bitte füllen Sie Kundenname und Adresse aus.", + "saveError": "Die Cloud konnte nicht gespeichert werden.", + "deleteError": "Die Cloud konnte nicht entfernt werden.", + "close": "Dialog schließen" } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 772c9c0..f267064 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1851,6 +1851,50 @@ "noReference": "Rating not possible", "versionUnknown": "Version unknown", "notChecked": "Not checked yet" + }, + "sort": { + "label": "Sort by", + "name": "Customer name", + "status": "Status", + "version": "Version", + "eol": "End of support" + }, + "actions": { + "add": "Add cloud", + "checkAll": "Check now", + "checking": "Checking …" + }, + "cardActions": { + "refresh": "Check this cloud now", + "edit": "Edit cloud" + }, + "form": { + "addTitle": "Add cloud", + "editTitle": "Edit cloud", + "customerName": "Customer name", + "baseUrl": "Cloud address", + "baseUrlPlaceholder": "https://cloud.example.com", + "baseUrlHint": "Tessera only reads the public status page of this address. No login data is needed.", + "logo": "Logo", + "logoNone": "No logo", + "logoUpload": "Upload image", + "logoUrl": "Image address (https)", + "logoUrlPlaceholder": "https://www.example.com/logo.png", + "logoUrlHint": "The address must start with https://. The image is loaded directly by your browser.", + "logoFileHint": "PNG, JPEG, GIF or WebP, at most 1 MB.", + "logoFileTooLarge": "The file is larger than 1 MB.", + "logoCurrent": "Current logo", + "logoRemove": "Remove logo", + "save": "Save", + "saving": "Checking …", + "cancel": "Cancel", + "delete": "Remove cloud", + "deleteConfirm": "Do you really want to remove the cloud “{name}”?", + "deleteConfirmYes": "Remove permanently", + "required": "Please fill in customer name and address.", + "saveError": "The cloud could not be saved.", + "deleteError": "The cloud could not be removed.", + "close": "Close dialog" } } } diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index 24b45b0..e428d95 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -106,6 +106,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ // quick-261002-k67: Nextcloud-Status (korrektes Deutsch) 'Neueste', 'ausstehend', + 'Statusseite', + 'Bildadresse', // quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch) 'Aussteller', 'Betriebssystemen', diff --git a/docs/mandantentrennung-zugriffsklassifikation.md b/docs/mandantentrennung-zugriffsklassifikation.md index befa258..9f69bc1 100644 --- a/docs/mandantentrennung-zugriffsklassifikation.md +++ b/docs/mandantentrennung-zugriffsklassifikation.md @@ -180,8 +180,8 @@ Spalten sind mit der Schleife aus dem Gate von 260914-eym nachgerechnet | reminders | 0 | 12 | 1 | **quick-260929-if2 (Aufgabe 3):** nachgemessen mit der Gate-Schleife: 0/12/1 — +5 gebunden, +1 System. `reminders.service.ts` +1 gebunden (`getEmailAvailability`: `user.findFirst` für die eigene E-Mail-Adresse, an Mandant und Benutzer gebunden). NEU `reminder-mail.scheduler.ts`: +4 gebunden je Kandidatenzeile (`reminder.updateMany` als Anspruch, `reminder.findFirst`, `user.findFirst` für die Adresse des Besitzers, `reminder.updateMany` als Freigabe bei Transportfehler; alle über `forTenant(prisma, c.tenantId)` ohne Benutzer) und +1 System (`systemPrisma.reminder.findMany`, die Kandidatenabfrage über alle Mandanten, nur skalarer Select). Vorher: **quick-260929-if2 (Aufgabe 2):** nachgemessen mit der Gate-Schleife: 0/7/0 — +4 gebunden: `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst`, ein Treffer für alle drei; fremde und unbekannte Kennungen sind dort ununterscheidbar 404, D-05). Vorher: **quick-260929-if2 (Aufgabe 1, Tracer):** neu, drei gebundene Rohtreffer in `reminders.service.ts`, nachgemessen mit der Gate-Schleife: 0/3/0 — `list` (`findMany`), `create` (`count` fuer die Grenze von 100 und `create`). Persönliche Erinnerungen je Benutzer, jede Methode bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`). Kein ungebundener Zugriff, kein Systemkontext in diesem Bereich (der E-Mail-Planer folgt in Aufgabe 3). | | kantine-datev | 0 | 2 | 0 | **quick-261002-fm5:** neu, zwei gebundene Rohtreffer in `kantine-datev.service.ts` (`getSettings` `findUnique`, `saveSettings` `upsert`), nachgemessen mit der Gate-Schleife: 0/2/0. Kein ungebundener Zugriff, kein Systemkontext. | | handelsware-datev | 0 | 8 | 0 | **quick-261002-fm5:** neu, acht gebundene Rohtreffer über `tenantPrisma` in `handelsware-datev.service.ts` (`handelswareDatevConfig` 2, `handelswareKonto` 6), nachgemessen mit der Gate-Schleife: 0/8/0. Dazu fünf Zugriffe über den Transaktionsparameter `tx` von `withTenantTransaction` (`handelswareDatevConfig` 1, `handelswareKonto` 4), die diese einfache Rohtrefferzählung strukturell nicht sieht (siehe Hinweis zu `groups` oben) — die Bestandsaufnahme unten führt sie. Kein ungebundener Zugriff, kein Systemkontext. | -| nextcloud-status | 0 | 4 | 0 | **quick-261002-k67 (Aufgabe 1):** neu, vier gebundene Rohtreffer über `tenantPrisma` in `nextcloud-status.service.ts` (`nextcloudInstance`: `listForTenant` `findMany`, `createInstance` `create`, `checkInstance` `findFirst` und `update`), nachgemessen mit der Gate-Schleife: 0/4/0. Kein ungebundener Zugriff, kein Systemkontext. | -| **Summe** | **61** | **254** | **7** | **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | +| nextcloud-status | 0 | 14 | 1 | **quick-261002-k67 (Aufgabe 2, Endstand):** 4→14 gebunden, 0→1 System — Schreibwege und Planer: `updateInstance` (`findFirst`, `update`), `deleteInstance` (`findFirst`, `delete`), `uploadLogo` (`findFirst`, `update`), `getLogo` (`findFirst`), `removeLogo` (`findFirst`, `update`), `listInstanceIdsForTenant` (`findMany`) bringen zehn weitere gebundene Rohtreffer, je Methode ein Klient. Der eine System-Rohtreffer ist `loadAllInstancesForScheduler()` (`systemPrisma.nextcloudInstance.findMany`, nur `select: { id, tenantId }`, der einzige `forSystem()`-Aufruf des Moduls, Erlaubnisliste in `rls-access-inventory.spec.ts`). Nachgemessen mit der Gate-Schleife: 0/14/1. Vorher: **quick-261002-k67 (Aufgabe 1):** neu, vier gebundene Rohtreffer in `nextcloud-status.service.ts` (`listForTenant` `findMany`, `createInstance` `create`, `checkInstance` `findFirst` und `update`). | +| **Summe** | **61** | **264** | **8** | **quick-261002-k67 (Aufgabe 2):** Gebunden +10 und System +1 = `nextcloud-status` (siehe dortige Zeile), Ungebunden unverändert: 61/264/8, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-k67 (Aufgabe 1):** Gebunden +4 = `nextcloud-status` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/254/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 2):** Gebunden +8 = `handelsware-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/250/7, nachgemessen mit der Gate-Schleife. Vorher: **quick-261002-fm5 (Aufgabe 1):** Gebunden +2 = `kantine-datev` (neu, siehe dortige Zeile), Ungebunden und System unverändert: 61/242/7, nachgemessen mit der Gate-Schleife. Vorher: **Willkommensmail-Vorlage:** Gebunden +5 = `user` (siehe dortige Zeile), Ungebunden und System unverändert: 61/240/7. Vorher 61/235/7 — **Nachgemessen quick-260929-if2 (Aufgabe 3):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/235/7. Gegenüber der bisherigen Zeile (61/230/6): Gebunden +5 und System +1 = `reminders` (siehe dortige Zeile), Ungebunden unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 2):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/230/6. Gegenüber der bisherigen Zeile (61/226/6): Gebunden +4 = `reminders` +4 (siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-if2 (Aufgabe 1):** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/226/6. Gegenüber der bisherigen Zeile (61/223/6): Gebunden +3 = `reminders` +3 (neu, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-dzu:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/223/6. Gegenüber der bisherigen Zeile (61/224/6): Gebunden −1 = `custom-modules` −1 (7→6, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **Nachgemessen quick-260929-9wc:** mit der Gate-Schleife (`for d in apps/api/src/*/`, nur .ts ohne spec), nicht abgeschrieben: 61/224/6. Gegenueber der bisherigen Zeile (61/216/6): Gebunden +8 = `user` +1 (Drift aus quick-260928-ujj, siehe dortige Zeile; gemessen war schon vorher 61/217/6) und `custom-modules` +7 (neu, siehe dortige Zeile), Ungebunden/System unveraendert. Vorher: **quick-260925-bow:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/216/6. Gegenüber der bisherigen Zeile (61/213/6): Gebunden +3 = `user` +3 (die zwei Selbstbedienungswege des „Was ist neu“-Fensters in `user.controller.ts`, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260924-m4n:** nachgerechnet mit der Gate-Schleife (`for d in apps/api/src/*/`), nicht abgeschrieben: 61/213/6. Gegenüber der bisherigen Zeile (61/208/7): Gebunden +5 = `favorites` +4 (Drift aus quick-260923-lrr nachgeholt) und `dashboard` +1 (Drift +3 nachgeholt, diese Änderung −2; siehe dortige Zeilen), System −1 (`dashboard`, Bootstrap-Umzug der Bilderrahmen-Bilder entfernt). Vorher: **quick-260923-dhh (Aufgabe 5, Endstand):** Gebunden 204→208 (`proxmox` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 4):** Gebunden 201→204 (`proxmox` +3, siehe dortige Zeile), System 6→7 (`proxmox` +1) — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-dhh (Aufgabe 1):** Gebunden 197→201 (`proxmox` neu, +4, siehe dortige Zeile), Ungebunden/System unverändert. Vorher: **quick-260923-ad9 (Task 5, Endstand nach Task 2):** Gebunden 193→197 (`dashboard` +4, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. Vorher: **quick-260923-ad9 (Task 1):** Gebunden 190→193 (`dashboard` +3, siehe dortige Zeile), Ungebunden/System unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260922-hk4:** Gebunden 187→190, System 5→6 (beides `dashboard`, siehe dortige Zeile), Ungebunden unverändert — nachgerechnet mit derselben Gate-Schleife, nicht abgeschrieben. **260921-pi9:** Gebunden 179→187, nachgerechnet mit der Gate-Schleife: +6 in `dashboard` (Bilderrahmen), +1 in `settings` (Zeile war seit 260914-m97 um eins zu niedrig), +1 fuer `bug-reports` (Zeile seit 260914-m97 vorhanden, in der Summe aber nie mitgezaehlt) — die Summe stimmt damit wieder mit den Bereichszeilen ueberein. **260914-eym:** Ungebunden 68→61 (`tenders` −2, `ldap` −3, `dkv` −1, `settings` −1), Gebunden 178→179 (`ldap` +1), System 5 (`dkv` 1, `ldap` 2, `tenders` 2) — nachgerechnet mit der Gate-Schleife, nicht abgeschrieben. Vorgeschichte: Ungebunden: war 118 nach 260910-das, dann 108 nach 260910-exd (module-registry 17→7), dann 107 nach 260910-jab (`tenders` 36→35, `listForUser` gebunden), dann 95 nach 260910-krx (`dashboard` 13→1), dann 83 nach 260911-cwh (`calendar` 12→0), unverändert nach 260911-e2s (`tenant` bleibt bei 8 ungebundenen Rohtreffern), dann 78 nach 260911-fh9 (`auth` 8→3), jetzt 68 nach 260911-gwh (`favorites` 7→0, `settings` 4→1). Gebunden: war 124, dann 134 nach 260910-exd (zusätzlich 10 in `module-registry`), dann 135 nach 260910-jab (zusätzlich 1 in `tenders`), dann 147 nach 260910-krx (zusätzlich 12 in `dashboard`), dann 159 nach 260911-cwh (zusätzlich 12 in `calendar`), dann 162 nach 260911-e2s (zusätzlich 3 in `tenant`), dann 167 nach 260911-fh9 (zusätzlich 5 in `auth`), jetzt 178 nach 260911-gwh (zusätzlich 8 in `favorites`, 3 in `settings`). Dies ist der ENDSTAND der Etappe 2: jeder verbleibende ungebundene Rohtreffer ist einer der in diesem Dokument benannten, bewusst ungebundenen Fälle. Diese Übersicht ist eine Buchführungshilfe; **autoritativ ist die Fundstellentabelle unten**, die `rls-access-inventory.spec.ts` bei jedem Lauf gegen den Quelltext prüft | ## Klassen-Verteilung (nach (Datei, Modell)-Fundstellen, 89 Paare) @@ -401,6 +401,9 @@ quick-261002-k67 (Aufgabe 1): +1 `muss-mandantengebunden` (`nextcloud-status.ser 93 Paare, davon 53 `muss-mandantengebunden`, 22 `keine-mandantengebundene-tabelle`, 16 `beides`, 2 `bewusst-uebergreifend` — nachgezaehlt mit `grep -cE '^\| apps/api/src/'` gegen die Bestandsaufnahme. +quick-261002-k67 (Aufgabe 2): Paarzahl unverändert (93); der Stand des Paars `nextcloud-status.service.ts`/`nextcloudInstance` +wechselt von `gebunden` auf `system-gebunden` (siehe oben). + ## Der Hintergrunddienst als Falle — sechs Fälle Ein Planer, der über alle Mandanten iteriert, liest zu Recht übergreifend — @@ -684,6 +687,16 @@ geloest durch die drei SECURITY-DEFINER-Funktionen, nicht durch die Bauform Regel sähe der Planer nach dem Scharfschalten keine Erinnerung und verstummte (zu-wenig-statt-zu-viel). `FORSYSTEM_ALLOWED_CALL_SITES` pinnt genau einen Aufruf in dieser Datei (neue Summe: 6 Dateien, 7 Aufrufe). +- **Nachtrag quick-261002-k67 — neuer Fall: `nextcloud-status-scheduler.service.ts` + (stündliche Cloud-Prüfung).** EIN globaler Auftrag `nextcloud-status-poll` + (`0 * * * *`), beim Start ohne Datenbankzugriff registriert. Jeder Durchlauf + liest über `forSystem()` (`NextcloudStatusService.loadAllInstancesForScheduler`, + `nextcloudInstance.findMany`, `system_read_policy ... FOR SELECT` auf + "NextcloudInstance", Migration 20261002150000) nur `id` und `tenantId` aller + Clouds, prüft dann jede Cloud über `checkInstance(tenantId, id)` an ihren + eigenen Mandanten gebunden (höchstens vier gleichzeitig) und schreibt nie im + Systemkontext. `FORSYSTEM_ALLOWED_CALL_SITES` pinnt genau einen Aufruf in + `nextcloud-status.service.ts` (neue Summe: 7 Dateien, 8 Aufrufe). ## Bestandsaufnahme @@ -842,7 +855,7 @@ werden. | apps/api/src/kantine-datev/kantine-datev.service.ts | kantineDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — die drei Nummern der Kantinenabrechnung (Beraternummer, Mandantennummer, Lohnart), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002120000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der diese Einstellungen über alle Mandanten liest. Zwei mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `getSettings` (`findUnique`), `saveSettings` (`upsert`). Die hochgeladene Kantinen-CSV (Namen, Personalnummern) berührt die Datenbank nie. | | apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareDatevConfig | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Einstellungen der Handelsware (Standard-Erlöskonto, Startwert Gegenkonto), eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000) — Einstellungen des Mandanten, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst. Zwei Rohtreffer über `const tenantPrisma = forTenant(this.prisma, tenantId)` (`getSettings` `findUnique`, `saveSettings` `upsert`) und einer über den Transaktionsparameter von `withTenantTransaction` (`export` liest die Einstellungen in derselben Transaktion wie die Kontenliste, `tx.handelswareDatevConfig.findUnique`). | | apps/api/src/handelsware-datev/handelsware-datev.service.ts | handelswareKonto | muss-mandantengebunden | gebunden | **quick-261002-fm5:** neu — Kontenliste der Handelsware (Produktname → Gegenkonto, Erlöskonto), mehrere Zeilen je Mandant, Name je Mandant eindeutig. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002130000, Form aus `ProxmoxServer`), keine `system_read_policy`. Sechs Rohtreffer über `tenantPrisma` (`listAccounts` `findMany`, `createAccount` `create`, `updateAccount` `findFirst` UND `update`, `deleteAccount` `findFirst` UND `delete`) und vier über den Transaktionsparameter von `withTenantTransaction` (`importAccountsCsv` `deleteMany` UND `createMany` als EINE Transaktion; `export` `findMany` UND `createMany` — berechnet die Zuordnung neu und speichert neue Konten in derselben Transaktion, in der die Datei entsteht). `updateAccount`/`deleteAccount` prüfen die Kennung zusätzlich mit `where: { id, tenantId }` und antworten mit 404 (zweites Netz, solange der RLS-Schalter aus ist). | -| apps/api/src/nextcloud-status/nextcloud-status.service.ts | nextcloudInstance | muss-mandantengebunden | gebunden | **quick-261002-k67:** neu — die vom Verwalter eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Versionstext, Fehlerart) auf derselben Zeile. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002150000, Form aus `ProxmoxServer`) — gemeinsame Daten der Organisation, nicht persönliche Daten eines Benutzers. Zusätzlich eine `system_read_policy … FOR SELECT` für den stündlichen Hintergrunddienst (Aufgabe 2). Vier mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `listForTenant` (`findMany`), `createInstance` (`create`), `checkInstance` (`findFirst` und `update`, derselbe Klient). Alle Abfragen wählen die Logo-Bytes per `select` gar nicht erst aus (T-k67-07); über `where: { id, tenantId }` liefert eine fremde Kennung 404. | +| apps/api/src/nextcloud-status/nextcloud-status.service.ts | nextcloudInstance | muss-mandantengebunden | system-gebunden | **quick-261002-k67:** neu — die vom Verwalter eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Versionstext, Fehlerart) auf derselben Zeile. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261002150000, Form aus `ProxmoxServer`) — gemeinsame Daten der Organisation, nicht persönliche Daten eines Benutzers. Stand `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadAllInstancesForScheduler()` liest für den stündlichen Planer `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "NextcloudInstance", Migration 20261002150000) nur Kennung und Mandant ALLER Clouds (`select: { id, tenantId }`, nie Adresse oder Logo-Bytes). Geprüft und geschrieben wird danach je Cloud mandantengebunden (`checkInstance`, `where: { id, tenantId }`). Vierzehn mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `listForTenant`, `createInstance`, `checkInstance` (zwei), `updateInstance` (zwei), `deleteInstance` (zwei), `uploadLogo` (zwei), `getLogo`, `removeLogo` (zwei), `listInstanceIdsForTenant`; dazu der eine System-Rohtreffer. Alle Abfragen außer `getLogo` wählen die Logo-Bytes per `select` gar nicht erst aus (T-k67-07); über `where: { id, tenantId }` liefert eine fremde Kennung 404. | ## Was diese Etappe NICHT entscheidet