feat(quick-261008-who): eigene Module beim Start vorladen — Durchstich

- Spalte User.customModulePreloadIds, PUT /custom-modules/:id/preload (Grenze 8, 404 fuer Fremdes)
- Vorlade-Planer und -Komponente, Speicher preload()/pinned, Ersatzgroesse im Behaelter
- E2E-Skript gegen die API

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 09:14:45 +02:00
parent 1bb869a7b5
commit ac104495b4
23 changed files with 1139 additions and 42 deletions
@@ -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[];
+6
View File
@@ -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?
@@ -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 });
});
});
@@ -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,
@@ -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<string, { customModulePreloadIds: string[] }>();
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();
});
});
@@ -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<T extends { ownerUserId: string | null }>(row: T) {
function toResponse<T extends { id: string; ownerUserId: string | null }>(
row: T,
preloadIds?: ReadonlySet<string>,
) {
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<T extends { ownerUserId: string | null }>(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) {
@@ -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<T extends object>(cls: new () => T, plain: Record<string, unknown>) {
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');
});
});
@@ -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;
}
+5 -3
View File
@@ -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;
}