diff --git a/.planning/quick/261008-who-eigene-module-beim-start-vorladen/e2e/e2e-preload-api.sh b/.planning/quick/261008-who-eigene-module-beim-start-vorladen/e2e/e2e-preload-api.sh new file mode 100755 index 0000000..859f77e --- /dev/null +++ b/.planning/quick/261008-who-eigene-module-beim-start-vorladen/e2e/e2e-preload-api.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# quick-261008-who: Vorladen-Schalter gegen den laufenden Stack pruefen (API). +set -euo pipefail + +API=http://localhost:3001 +JAR=$(mktemp) +CREATED=() +cleanup() { + for id in "${CREATED[@]:-}"; do + [ -n "$id" ] && curl -s -o /dev/null -b "$JAR" -X DELETE "$API/custom-modules/$id" || true + done + rm -f "$JAR" "$JAR.out" +} +trap cleanup EXIT + +fail() { echo "FEHLER: $*" >&2; exit 1; } + +for _ in $(seq 1 90); do + curl -sf "$API/health" >/dev/null 2>&1 && break + sleep 2 +done +curl -sf "$API/health" >/dev/null || fail "API nicht erreichbar" + +code=$(curl -s -o /dev/null -w '%{http_code}' -c "$JAR" -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin123"}' "$API/auth/login") +case "$code" in 200|201) ;; *) fail "Anmeldung: HTTP $code" ;; esac + +CAT=$(curl -sf -b "$JAR" "$API/module-categories" | python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["key"])') + +# Altlasten aus abgebrochenen Laeufen entfernen +for id in $(curl -sf -b "$JAR" "$API/custom-modules" | python3 -c 'import sys,json; [print(m["id"]) for m in json.load(sys.stdin) if m["name"].startswith("Probe who ")]'); do + curl -s -o /dev/null -b "$JAR" -X DELETE "$API/custom-modules/$id" +done + +IDS=() +for n in 1 2 3 4 5 6 7 8 9; do + body=$(printf '{"name":"Probe who %s","url":"https://example.com/?who=%s","category":"%s"}' "$n" "$n" "$CAT") + id=$(curl -sf -b "$JAR" -H 'Content-Type: application/json' -d "$body" "$API/custom-modules" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])') + IDS+=("$id") + CREATED+=("$id") +done + +put() { # id json -> Statuscode, Antwort in $JAR.out + curl -s -o "$JAR.out" -w '%{http_code}' -b "$JAR" -X PUT -H 'Content-Type: application/json' -d "$2" "$API/custom-modules/$1/preload" +} + +for i in 0 1 2 3 4 5 6 7; do + c=$(put "${IDS[$i]}" '{"preload":true}') + [ "$c" = 200 ] || fail "preload true Nr. $((i + 1)): HTTP $c" + grep -q '"preload":true' "$JAR.out" || fail "Antwort Nr. $((i + 1)) ohne preload:true" +done + +c=$(put "${IDS[8]}" '{"preload":true}') +[ "$c" = 409 ] || fail "neunter Versuch: erwartet 409, HTTP $c" + +LIST=$(curl -sf -b "$JAR" "$API/custom-modules") +echo "$LIST" | python3 -c ' +import sys, json +ids = sys.argv[1:] +by = {m["id"]: m for m in json.load(sys.stdin)} +assert by[ids[0]]["preload"] is True, "Nr. 1 nicht vorgeladen" +assert by[ids[8]]["preload"] is False, "Nr. 9 faelschlich vorgeladen" +' "${IDS[@]}" || fail "Liste zeigt falsche preload-Werte" + +c=$(put "${IDS[0]}" '{"preload":false}') +[ "$c" = 200 ] || fail "preload false Nr. 1: HTTP $c" +c=$(put "${IDS[8]}" '{"preload":true}') +[ "$c" = 200 ] || fail "Nr. 9 nach Freigabe: HTTP $c" + +c=$(put "${IDS[1]}" '{"preload":"ja"}') +[ "$c" = 400 ] || fail "preload \"ja\": erwartet 400, HTTP $c" + +c=$(put "$(python3 -c 'import uuid; print(uuid.uuid4())')" '{"preload":true}') +[ "$c" = 404 ] || fail "zufaellige Kennung: erwartet 404, HTTP $c" + +for id in "${IDS[@]}"; do + curl -sf -o /dev/null -b "$JAR" -X DELETE "$API/custom-modules/$id" || fail "Loeschen $id" +done +CREATED=() +LEFT=$(curl -sf -b "$JAR" "$API/custom-modules" | python3 -c 'import sys,json; print(sum(1 for m in json.load(sys.stdin) if m["name"].startswith("Probe who ")))') +[ "$LEFT" = 0 ] || fail "$LEFT Probe-Module uebrig" +rm -f "$JAR.out" + +echo "e2e preload api ok" diff --git a/apps/api/prisma/migrations/20261008210000_user_custom_module_preload/migration.sql b/apps/api/prisma/migrations/20261008210000_user_custom_module_preload/migration.sql new file mode 100644 index 0000000..36e7d6e --- /dev/null +++ b/apps/api/prisma/migrations/20261008210000_user_custom_module_preload/migration.sql @@ -0,0 +1,2 @@ +-- quick-261008-who: Kennungen eigener Module, die der Benutzer beim Start vorladen laesst. +ALTER TABLE "User" ADD COLUMN "customModulePreloadIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a407e5a..3d06a82 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -50,6 +50,12 @@ model User { // sonst das durch parseDashboardBackground (@tessera/shared) normalisierte // Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund" dashboardBackground Json? + // quick-261008-who: Kennungen eigener Module (CustomModule.id), die dieser + // Benutzer beim Start vorladen laesst. Bewusst eine Spalte der Benutzerzeile + // statt einer Tabelle (Zeilenschutz der Benutzerzeile gilt schon). Tote + // Kennungen (geloeschte oder nicht mehr sichtbare Module) werden beim Lesen + // geschnitten und beim naechsten Schreiben entfernt. + customModulePreloadIds String[] @default([]) // Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten // Versands; null = nie gesendet welcomeMailSentAt DateTime? diff --git a/apps/api/src/custom-modules/custom-modules.controller.spec.ts b/apps/api/src/custom-modules/custom-modules.controller.spec.ts index e66119e..ae70af2 100644 --- a/apps/api/src/custom-modules/custom-modules.controller.spec.ts +++ b/apps/api/src/custom-modules/custom-modules.controller.spec.ts @@ -1,9 +1,13 @@ import 'reflect-metadata'; -import { ForbiddenException, ValidationPipe } from '@nestjs/common'; +import { ForbiddenException, RequestMethod, ValidationPipe } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { CustomModulesController } from './custom-modules.controller'; -import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto'; +import { + CreateCustomModuleDto, + SetCustomModulePreloadDto, + UpdateCustomModuleDto, +} from './dto/custom-module.dto'; function makeService() { return { @@ -12,6 +16,7 @@ function makeService() { create: vi.fn(async (..._args: unknown[]) => ({})), update: vi.fn(async (..._args: unknown[]) => ({})), remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })), + setPreload: vi.fn(async (..._args: unknown[]) => ({})), }; } @@ -29,6 +34,7 @@ describe('CustomModulesController — Rollen (quick-260929-dzu)', () => { 'create', 'update', 'remove', + 'setPreload', ])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => { expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined(); }); @@ -105,3 +111,25 @@ describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () expect(listIdx).toBeLessThan(idIdx); }); }); + +describe('CustomModulesController — Vorladen (quick-261008-who)', () => { + it('PUT :id/preload mit Metadaten, reicht Mandant und Benutzer durch', async () => { + expect(Reflect.getMetadata('path', proto.setPreload)).toBe(':id/preload'); + expect(Reflect.getMetadata('method', proto.setPreload)).toBe(RequestMethod.PUT); + const service = makeService(); + const controller = new CustomModulesController(service as any); + await controller.setPreload(req('t1'), user, 'x', { preload: true }); + expect(service.setPreload).toHaveBeenCalledWith('t1', user, 'x', true); + await expect(controller.setPreload(req(), user, 'x', { preload: true })).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it('das DTO lehnt fehlendes oder nicht-boolesches preload ab', async () => { + const pipe = new ValidationPipe({ whitelist: true, transform: true }); + const meta = { type: 'body' as const, metatype: SetCustomModulePreloadDto }; + await expect(pipe.transform({}, meta)).rejects.toBeDefined(); + await expect(pipe.transform({ preload: 'ja' }, meta)).rejects.toBeDefined(); + await expect(pipe.transform({ preload: true }, meta)).resolves.toMatchObject({ preload: true }); + }); +}); diff --git a/apps/api/src/custom-modules/custom-modules.controller.ts b/apps/api/src/custom-modules/custom-modules.controller.ts index a8e0d27..4d4374b 100644 --- a/apps/api/src/custom-modules/custom-modules.controller.ts +++ b/apps/api/src/custom-modules/custom-modules.controller.ts @@ -7,12 +7,17 @@ import { Param, Patch, Post, + Put, Req, } from '@nestjs/common'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user'; import { CustomModulesService } from './custom-modules.service'; -import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto'; +import { + CreateCustomModuleDto, + SetCustomModulePreloadDto, + UpdateCustomModuleDto, +} from './dto/custom-module.dto'; /** * Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder @@ -56,6 +61,21 @@ export class CustomModulesController { return this.service.getOne(this.requireTenantId(req), user, id); } + /** + * quick-261008-who: „Beim Start vorladen“ fuer den Aufrufer schalten. PUT mit + * zwei Pfadsegmenten — kann `@Get(':id')` nicht verschatten. Kein `@Roles`: + * jeder Angemeldete schaltet die eigene Wahl. + */ + @Put(':id/preload') + async setPreload( + @Req() req: AuthenticatedRequest, + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: SetCustomModulePreloadDto, + ) { + return this.service.setPreload(this.requireTenantId(req), user, id, dto.preload); + } + @Post() async create( @Req() req: AuthenticatedRequest, diff --git a/apps/api/src/custom-modules/custom-modules.service.spec.ts b/apps/api/src/custom-modules/custom-modules.service.spec.ts index d182260..3510b37 100644 --- a/apps/api/src/custom-modules/custom-modules.service.spec.ts +++ b/apps/api/src/custom-modules/custom-modules.service.spec.ts @@ -1,4 +1,9 @@ -import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { Role } from '@prisma/client'; import { describe, expect, it, vi } from 'vitest'; @@ -43,7 +48,18 @@ function makeFakePrisma() { rows.delete(where.id); }), }; - return { customModule, rows }; + // quick-261008-who: Benutzerzeilen mit der Vorlade-Wahl (nur die eigene Zeile). + const users = new Map(); + const user = { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => { + return users.get(where.id) ?? { customModulePreloadIds: [] }; + }), + update: vi.fn(async ({ where, data }: { where: { id: string }; data: any }) => { + users.set(where.id, { customModulePreloadIds: data.customModulePreloadIds }); + return users.get(where.id); + }), + }; + return { customModule, user, users, rows }; } const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const }; @@ -62,7 +78,11 @@ function setup() { if (!KNOWN_CATEGORIES.has(key)) throw new BadRequestException('Unbekannte Kategorie'); }), }; - return { prisma, categories, service: new CustomModulesService(prisma as any, categories as any) }; + return { + prisma, + categories, + service: new CustomModulesService(prisma as any, categories as any), + }; } describe('CustomModulesService — anlegen', () => { @@ -308,15 +328,114 @@ describe('CustomModulesService — RLS-Bindung', () => { await service.getOne('t1', userA, mine.id); await service.update('t1', userA, mine.id, { name: 'a' }); await service.update('t1', admin, shared.id, { name: 'b' }); - const calls = vi.mocked(forTenant).mock.calls; + // Die Vorlade-Wahl liegt in der Benutzerzeile und wird ohne Benutzerkontext + // gelesen (Muster dashboardBackground) — diese Aufrufe hier ausblenden. + const calls = vi.mocked(forTenant).mock.calls.filter((c) => c.length === 3 || c === undefined); // create shared: ohne Benutzer - expect(calls[0]).toEqual([prisma, 't1']); + expect(vi.mocked(forTenant).mock.calls[0]).toEqual([prisma, 't1']); // create personal + list + getOne + (update personal: Laden + Schreiben) + expect(calls[0]).toEqual([prisma, 't1', 'ua']); expect(calls[1]).toEqual([prisma, 't1', 'ua']); expect(calls[2]).toEqual([prisma, 't1', 'ua']); - expect(calls[3]).toEqual([prisma, 't1', 'ua']); // update shared als Admin: Laden mit Benutzer, Schreiben ohne - expect(calls[calls.length - 2]).toEqual([prisma, 't1', 'admin1']); - expect(calls[calls.length - 1]).toEqual([prisma, 't1']); + const all = vi.mocked(forTenant).mock.calls; + expect(all[all.length - 2]).toEqual([prisma, 't1', 'admin1']); + expect(all[all.length - 1]).toEqual([prisma, 't1']); + }); +}); + +describe('CustomModulesService — Vorladen (quick-261008-who)', () => { + it('list: preload nur fuer Module aus der eigenen Zeile, Benutzer B bleibt unberuehrt', async () => { + const { prisma, service } = setup(); + const shared: any = await service.create('t1', admin, { + ...dto, + name: 'Gemeinsam', + shared: true, + }); + await service.setPreload('t1', userA, shared.id, true); + const resA: any[] = await service.list('t1', userA); + expect(resA.find((r) => r.id === shared.id).preload).toBe(true); + const resB: any[] = await service.list('t1', userB); + expect(resB.find((r) => r.id === shared.id).preload).toBe(false); + for (const call of prisma.user.findUnique.mock.calls) { + expect(call[0].where).toHaveProperty('id'); + } + expect(prisma.user.findUnique.mock.calls.at(-1)?.[0].where).toEqual({ id: 'ub' }); + }); + + it('getOne liefert preload fuer den Aufrufer', async () => { + const { service } = setup(); + const mine: any = await service.create('t1', userA, dto); + expect(((await service.getOne('t1', userA, mine.id)) as any).preload).toBe(false); + await service.setPreload('t1', userA, mine.id, true); + expect(((await service.getOne('t1', userA, mine.id)) as any).preload).toBe(true); + }); + + it('setPreload(true) schreibt nur die eigene Zeile, ohne Doppelte, Antwort mit Zaehler', async () => { + const { prisma, service } = setup(); + const mine: any = await service.create('t1', userA, dto); + const first = await service.setPreload('t1', userA, mine.id, true); + const second = await service.setPreload('t1', userA, mine.id, true); + expect(first).toEqual({ id: mine.id, preload: true, preloadCount: 1 }); + expect(second).toEqual({ id: mine.id, preload: true, preloadCount: 1 }); + for (const call of prisma.user.update.mock.calls) { + expect(call[0].where).toEqual({ id: 'ua' }); + } + expect(prisma.users.get('ua')?.customModulePreloadIds).toEqual([mine.id]); + }); + + it('setPreload bereinigt tote Kennungen beim Schreiben', async () => { + const { prisma, service } = setup(); + const mine: any = await service.create('t1', userA, dto); + prisma.users.set('ua', { customModulePreloadIds: ['geloescht-1', 'geloescht-2'] }); + await service.setPreload('t1', userA, mine.id, true); + expect(prisma.users.get('ua')?.customModulePreloadIds).toEqual([mine.id]); + }); + + it('setPreload(true) bei acht sichtbaren vorgeladenen -> 409 ohne Schreibzugriff', async () => { + const { prisma, service } = setup(); + const ids: string[] = []; + for (let i = 0; i < 9; i++) { + const m: any = await service.create('t1', userA, { ...dto, name: `M${i}` }); + ids.push(m.id); + } + for (const id of ids.slice(0, 8)) await service.setPreload('t1', userA, id, true); + prisma.user.update.mockClear(); + await expect(service.setPreload('t1', userA, ids[8], true)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('tote Kennungen zaehlen bei der Grenze nicht mit', async () => { + const { prisma, service } = setup(); + const mine: any = await service.create('t1', userA, dto); + prisma.users.set('ua', { + customModulePreloadIds: Array.from({ length: 8 }, (_, i) => `tot-${i}`), + }); + await expect(service.setPreload('t1', userA, mine.id, true)).resolves.toMatchObject({ + preloadCount: 1, + }); + }); + + it('setPreload(false) entfernt die Kennung', async () => { + const { prisma, service } = setup(); + const mine: any = await service.create('t1', userA, dto); + await service.setPreload('t1', userA, mine.id, true); + const res = await service.setPreload('t1', userA, mine.id, false); + expect(res).toEqual({ id: mine.id, preload: false, preloadCount: 0 }); + expect(prisma.users.get('ua')?.customModulePreloadIds).toEqual([]); + }); + + it('fremde persoenliche oder unbekannte Kennung -> 404 ohne Schreibzugriff', async () => { + const { prisma, service } = setup(); + const mine: any = await service.create('t1', userA, dto); + await expect(service.setPreload('t1', userB, mine.id, true)).rejects.toBeInstanceOf( + NotFoundException, + ); + await expect(service.setPreload('t1', userA, 'nope', true)).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(prisma.user.update).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/custom-modules/custom-modules.service.ts b/apps/api/src/custom-modules/custom-modules.service.ts index 36526db..fe494c2 100644 --- a/apps/api/src/custom-modules/custom-modules.service.ts +++ b/apps/api/src/custom-modules/custom-modules.service.ts @@ -1,5 +1,11 @@ -import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { Role } from '@prisma/client'; +import { CUSTOM_MODULE_PRELOAD_MAX } from '@tessera/shared'; import { ModuleCategoriesService } from '../module-categories/module-categories.service'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; @@ -28,9 +34,17 @@ function isAdmin(caller: CustomModuleCaller): boolean { } /** Aus der Zeile die Antwort machen: `personal` statt der Besitzer-Kennung. */ -function toResponse(row: T) { +function toResponse( + row: T, + preloadIds?: ReadonlySet, +) { const { ownerUserId, ...rest } = row; - return { ...rest, personal: ownerUserId !== null }; + // `preload` nur dort, wo die Wahl des Aufrufers gelesen wurde (list/getOne). + return { + ...rest, + personal: ownerUserId !== null, + ...(preloadIds ? { preload: preloadIds.has(row.id) } : {}), + }; } /** @@ -55,6 +69,15 @@ function toResponse(row: T) { * Rollenpruefung (Administrator) sitzt vorher im Dienst. Zusaetzlich pruefen * alle Methoden `row.tenantId` und `row.ownerUserId` in der Anwendung, solange * der RLS-Schalter aus ist. + * + * VORLADEN (quick-261008-who): Welche Module ein Benutzer beim Start vorladen + * laesst, steht in SEINER Benutzerzeile (`User.customModulePreloadIds`, Muster + * `dashboardBackground`) — nicht am Modul, damit die Wahl eines Benutzers nie + * auf einen anderen wirkt. Gelesen und geschrieben wird nur die eigene Zeile + * (`where: { id: caller.id }`, kein Kennungsparameter). `list`/`getOne` melden + * `preload` nur fuer Module, die der Aufrufer sieht; tote Kennungen + * (geloeschte oder entzogene Module) werden dabei geschnitten und beim + * naechsten Schreiben entfernt. Grenze: `CUSTOM_MODULE_PRELOAD_MAX` (409). */ @Injectable() export class CustomModulesService { @@ -71,14 +94,67 @@ export class CustomModulesService { orderBy: { name: 'asc' }, select: CUSTOM_MODULE_SELECT, }); - return rows.map(toResponse); + const preloadIds = await this.readPreloadIds(tenantId, caller); + return rows.map((row) => toResponse(row, preloadIds)); } async getOne(tenantId: string, caller: CustomModuleCaller, id: string) { const tenantPrisma = forTenant(this.prisma, tenantId, caller.id); const row = await this.loadVisible(tenantPrisma, tenantId, caller, id); const { tenantId: _omit, ...result } = row; - return toResponse(result); + const preloadIds = await this.readPreloadIds(tenantId, caller); + return toResponse(result, preloadIds); + } + + /** + * „Beim Start vorladen“ fuer den Aufrufer ein- oder ausschalten. 404 fuer + * fremde oder fehlende Kennungen (ohne Schreibzugriff), 409 ab + * `CUSTOM_MODULE_PRELOAD_MAX` sichtbaren vorgeladenen Modulen. Geschrieben + * wird die bereinigte Liste (nur sichtbare Module, ohne Doppelte). + */ + async setPreload(tenantId: string, caller: CustomModuleCaller, id: string, preload: boolean) { + const tenantPrisma = forTenant(this.prisma, tenantId, caller.id); + await this.loadVisible(tenantPrisma, tenantId, caller, id); + const visible = await tenantPrisma.customModule.findMany({ + where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] }, + select: { id: true }, + }); + const visibleIds = new Set(visible.map((row) => row.id)); + const stored = await this.readPreloadIds(tenantId, caller); + const current = [...stored].filter((storedId) => visibleIds.has(storedId)); + + let next: string[]; + if (preload) { + if (current.includes(id)) { + next = current; + } else if (current.length >= CUSTOM_MODULE_PRELOAD_MAX) { + throw new ConflictException( + `Es können höchstens ${CUSTOM_MODULE_PRELOAD_MAX} eigene Module beim Start vorgeladen werden`, + ); + } else { + next = [...current, id]; + } + } else { + next = current.filter((storedId) => storedId !== id); + } + + // Nur die eigene Zeile (T-WHO-01): kein Kennungsparameter vom Client. + const userPrisma = forTenant(this.prisma, tenantId); + await userPrisma.user.update({ + where: { id: caller.id }, + data: { customModulePreloadIds: next }, + }); + return { id, preload, preloadCount: next.length }; + } + + /** Gespeicherte Vorlade-Kennungen des Aufrufers (nur seine eigene Zeile). */ + private async readPreloadIds(tenantId: string, caller: CustomModuleCaller) { + const userPrisma = forTenant(this.prisma, tenantId); + const user = await userPrisma.user.findUnique({ + where: { id: caller.id }, + select: { customModulePreloadIds: true }, + }); + return new Set(user?.customModulePreloadIds ?? []); } async create(tenantId: string, caller: CustomModuleCaller, dto: CreateCustomModuleDto) { diff --git a/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts b/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts index 46c7495..047540a 100644 --- a/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts +++ b/apps/api/src/custom-modules/dto/custom-module.dto.spec.ts @@ -2,7 +2,11 @@ import 'reflect-metadata'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { describe, expect, it } from 'vitest'; -import { CreateCustomModuleDto, UpdateCustomModuleDto } from './custom-module.dto'; +import { + CreateCustomModuleDto, + SetCustomModulePreloadDto, + UpdateCustomModuleDto, +} from './custom-module.dto'; async function errorsFor(cls: new () => T, plain: Record) { const dto = plainToInstance(cls, plain); @@ -31,21 +35,25 @@ describe('CreateCustomModuleDto', () => { // quick-261003-387: das DTO prueft nur das Format der Kennung; ob die // Organisation sie fuehrt, entscheidet der Dienst (400, siehe Dienst-Spec). - it.each(['', 'Gross', 'mit leerzeichen', '-fuehrend', 'x'.repeat(61)])( - 'lehnt die Kategorie-Kennung %j ab', - async (category) => { - expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).toContain('category'); - }, - ); + it.each([ + '', + 'Gross', + 'mit leerzeichen', + '-fuehrend', + 'x'.repeat(61), + ])('lehnt die Kategorie-Kennung %j ab', async (category) => { + expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).toContain('category'); + }); - it.each(['fleet', 'custom-modules', 'werkzeuge-tools'])( - 'akzeptiert die Kategorie-Kennung %s', - async (category) => { - expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).not.toContain( - 'category', - ); - }, - ); + it.each([ + 'fleet', + 'custom-modules', + 'werkzeuge-tools', + ])('akzeptiert die Kategorie-Kennung %s', async (category) => { + expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).not.toContain( + 'category', + ); + }); it.each(['', ' '])('lehnt den Namen %j ab', async (name) => { expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name'); @@ -73,7 +81,9 @@ describe('UpdateCustomModuleDto', () => { it('prueft jedes gesetzte Feld gleich', async () => { expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url'); - expect(await errorsFor(UpdateCustomModuleDto, { category: 'Nicht Gueltig' })).toContain('category'); + expect(await errorsFor(UpdateCustomModuleDto, { category: 'Nicht Gueltig' })).toContain( + 'category', + ); expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name'); }); @@ -85,3 +95,16 @@ describe('UpdateCustomModuleDto', () => { expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field); }); }); + +describe('SetCustomModulePreloadDto (quick-261008-who)', () => { + it('akzeptiert true und false', async () => { + expect(await errorsFor(SetCustomModulePreloadDto, { preload: true })).toEqual([]); + expect(await errorsFor(SetCustomModulePreloadDto, { preload: false })).toEqual([]); + }); + + it('lehnt fehlendes oder nicht-boolesches preload ab', async () => { + expect(await errorsFor(SetCustomModulePreloadDto, {})).toContain('preload'); + expect(await errorsFor(SetCustomModulePreloadDto, { preload: 'ja' })).toContain('preload'); + expect(await errorsFor(SetCustomModulePreloadDto, { preload: 1 })).toContain('preload'); + }); +}); diff --git a/apps/api/src/custom-modules/dto/custom-module.dto.ts b/apps/api/src/custom-modules/dto/custom-module.dto.ts index af6fe5f..3e447db 100644 --- a/apps/api/src/custom-modules/dto/custom-module.dto.ts +++ b/apps/api/src/custom-modules/dto/custom-module.dto.ts @@ -90,3 +90,9 @@ export class UpdateCustomModuleDto extends PartialType( OmitType(CreateCustomModuleDto, ['shared'] as const), { skipNullProperties: false }, ) {} + +/** quick-261008-who: Koerper von `PUT /custom-modules/:id/preload`. */ +export class SetCustomModulePreloadDto { + @IsBoolean() + preload!: boolean; +} diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts index 65b769c..0538964 100644 --- a/apps/api/src/user/user.controller.ts +++ b/apps/api/src/user/user.controller.ts @@ -262,7 +262,8 @@ export class UserController { throw new ForbiddenException('Cannot access users from other tenants'); } - const { passwordHash, ...result } = user; + // quick-261008-who (T-WHO-02): die persoenliche Vorlade-Wahl verlaesst die Antwort nicht. + const { passwordHash, customModulePreloadIds, ...result } = user; return result; } @@ -293,7 +294,8 @@ export class UserController { tenantId, }); - const { passwordHash, ...result } = user; + // quick-261008-who (T-WHO-02): die persoenliche Vorlade-Wahl verlaesst die Antwort nicht. + const { passwordHash, customModulePreloadIds, ...result } = user; return result; } @@ -351,7 +353,7 @@ export class UserController { isActive: dto.isActive, }); - const { passwordHash, ...result } = updated; + const { passwordHash, customModulePreloadIds, ...result } = updated; return result; } diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 865d07c..2ef0624 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { Header } from '@/components/layout/header'; import { Sidebar } from '@/components/layout/sidebar'; import { CustomModuleFrameHost } from '@/components/modules/custom-module-frame-host'; +import { CustomModulePreloader } from '@/components/modules/custom-module-preloader'; import { NextcloudAlertNotifier } from '@/components/nextcloud-status/nextcloud-alert-notifier'; import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host'; import { ReminderNotifier } from '@/components/reminders/reminder-notifier'; @@ -34,6 +35,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { {/* Seitenrahmen seit quick-260916-bwo halbiert: p-3 (12 px) statt vorher 24 px (Stufe 6), gilt fuer alle Seiten. */}
s leben hier im Rahmen und ueberstehen so das Wegnavigieren; die Modulseite zeigt nur einen Platzhalter, ueber den der Behaelter das aktive Modul legt. */} + {/* Vorladen (quick-261008-who): laedt die vom Benutzer gewaehlten Module + kurz NACH dem Start unsichtbar in den Behaelter. Rendert nichts. */} + ); diff --git a/apps/web/src/components/modules/custom-module-frame-host.test.tsx b/apps/web/src/components/modules/custom-module-frame-host.test.tsx new file mode 100644 index 0000000..b0cd971 --- /dev/null +++ b/apps/web/src/components/modules/custom-module-frame-host.test.tsx @@ -0,0 +1,138 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +const { mockGetCustomModule } = vi.hoisted(() => ({ mockGetCustomModule: vi.fn() })); +vi.mock('@/lib/custom-modules-api', async () => { + const actual = await vi.importActual( + '@/lib/custom-modules-api', + ); + return { ...actual, getCustomModule: mockGetCustomModule }; +}); + +import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store'; +import { CustomModuleFrameHost } from './custom-module-frame-host'; +import { CustomModuleView } from './custom-module-view'; + +function Portal({ route }: { route: string | null }) { + return ( + <> +
{route ? :

Dashboard

}
+ + + ); +} + +beforeEach(() => { + mockGetCustomModule.mockReset(); + useCustomModuleCacheStore.getState().clear(); + // Inhaltsbereich: 1000 x 700 an (200, 60), Innenabstand 12 px. + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (this.hasAttribute('data-app-shell-main')) { + return { + top: 60, + left: 200, + width: 1000, + height: 700, + right: 1200, + bottom: 760, + x: 200, + y: 60, + toJSON: () => ({}), + }; + } + return { + top: 0, + left: 0, + width: 0, + height: 0, + right: 0, + bottom: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }); + vi.spyOn(window, 'getComputedStyle').mockImplementation( + () => + ({ + paddingLeft: '12px', + paddingRight: '12px', + paddingTop: '12px', + paddingBottom: '12px', + }) as CSSStyleDeclaration, + ); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('CustomModuleFrameHost — Vorladen (quick-261008-who)', () => { + it('ein vorgeladener Rahmen ist versteckt, mit Sandbox und hat eine Ersatzgroesse', async () => { + render(); + act(() => { + useCustomModuleCacheStore + .getState() + .preload({ id: 'a', name: 'Wiki', url: 'https://a.test/' }); + }); + + const frames = await screen.findAllByTestId('custom-module-frame'); + expect(frames).toHaveLength(1); + const frame = frames[0] as HTMLIFrameElement; + expect(frame.style.visibility).toBe('hidden'); + expect(frame.style.pointerEvents).toBe('none'); + expect(frame).toHaveAttribute('aria-hidden', 'true'); + expect(frame.getAttribute('sandbox')).toBe(XFRAME_SANDBOX); + expect(frame.getAttribute('allow')).toBe(''); + expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer'); + await waitFor(() => expect(frame.style.width).toBe('976px')); + expect(Number.parseFloat(frame.style.height)).toBeGreaterThanOrEqual(320); + expect(frame.style.left).toBe('212px'); + expect(frame.style.top).toBe('72px'); + }); + + it('der erste Klick uebernimmt denselben Knoten ohne Neuladen', async () => { + mockGetCustomModule.mockResolvedValue({ + id: 'a', + name: 'Wiki', + url: 'https://a.test/', + category: 'infrastructure', + personal: true, + createdAt: '2026-10-08T00:00:00.000Z', + updatedAt: '2026-10-08T00:00:00.000Z', + }); + const view = render(); + act(() => { + useCustomModuleCacheStore + .getState() + .preload({ id: 'a', name: 'Wiki', url: 'https://a.test/' }); + }); + const before = (await screen.findAllByTestId('custom-module-frame'))[0]; + const srcBefore = before.getAttribute('src'); + + view.rerender(); + await waitFor(() => + expect( + (screen.getAllByTestId('custom-module-frame')[0] as HTMLElement).style.visibility, + ).toBe('visible'), + ); + const frames = screen.getAllByTestId('custom-module-frame'); + expect(frames).toHaveLength(1); + expect(frames[0]).toBe(before); + expect(frames[0].getAttribute('src')).toBe(srcBefore); + }); + + it('eine http-Adresse wird nie als Rahmen vorgeladen', () => { + render(); + act(() => { + useCustomModuleCacheStore.getState().preload({ id: 'x', name: 'X', url: 'http://x.test/' }); + }); + expect(screen.queryAllByTestId('custom-module-frame')).toHaveLength(0); + }); +}); diff --git a/apps/web/src/components/modules/custom-module-frame-host.tsx b/apps/web/src/components/modules/custom-module-frame-host.tsx index d291d18..f5f5b6f 100644 --- a/apps/web/src/components/modules/custom-module-frame-host.tsx +++ b/apps/web/src/components/modules/custom-module-frame-host.tsx @@ -46,6 +46,15 @@ interface FrameRect { * Sandbox und Attribute wie bisher in der Modulansicht bzw. im XFrame-Widget * (`XFRAME_SANDBOX`, `allow=""`, kein Referrer); ein