From 1c32543f58ffce8d7f8e72133d53841960f9c9fc Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 4 Aug 2026 18:41:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(15-03):=20GET=20/modules/catalog=20?= =?UTF-8?q?=E2=80=94=20beide=20Statusflags=20in=20einer=20Antwort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModuleAccessService.getCatalogFlags(tenantId, userId, role) liefert je aktivem Modul isActiveForTenant + hasAccess in einer Auflösung - ModuleRegistryController.findCatalog (GET /modules/catalog), erreichbar für jeden authentifizierten Benutzer wie GET /modules (D-08) - ADMIN/SUPER_ADMIN: hasAccess immer wahr für aktive Module (D-03) - 4 neue Tests für getCatalogFlags --- .../module-access.service.spec.ts | 45 +++++++++++++++++++ .../module-registry/module-access.service.ts | 38 ++++++++++++++++ .../module-registry.controller.ts | 31 +++++++++++++ 3 files changed, 114 insertions(+) diff --git a/apps/api/src/module-registry/module-access.service.spec.ts b/apps/api/src/module-registry/module-access.service.spec.ts index 1568705..7853732 100644 --- a/apps/api/src/module-registry/module-access.service.spec.ts +++ b/apps/api/src/module-registry/module-access.service.spec.ts @@ -215,3 +215,48 @@ describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', () expect(prisma.module.findMany).not.toHaveBeenCalled(); }); }); + +describe('ModuleAccessService.getCatalogFlags — Marketplace-Katalog (D-08)', () => { + it('aktives und freigegebenes Modul: isActiveForTenant und hasAccess beide wahr', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + directGrants: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + const flags = await service.getCatalogFlags('t1', 'user-1', 'USER'); + + expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: true }); + }); + + it('aktives, aber nicht freigegebenes Modul: isActiveForTenant wahr, hasAccess falsch', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + const flags = await service.getCatalogFlags('t1', 'user-1', 'USER'); + + expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: false }); + }); + + it('nicht aktiviertes Modul erscheint nicht in der Flag-Map (Controller mappt fehlenden Eintrag auf beide Flags falsch)', async () => { + const prisma = makeFakePrisma({ activations: [] }); + const service = new ModuleAccessService(prisma as any); + + const flags = await service.getCatalogFlags('t1', 'user-1', 'USER'); + + expect(flags.has('mod-1')).toBe(false); + }); + + it('D-03: als ADMIN ist jedes aktive Modul zugänglich, auch ohne Grant', async () => { + const prisma = makeFakePrisma({ + activations: [{ moduleId: 'mod-1' }], + }); + const service = new ModuleAccessService(prisma as any); + + const flags = await service.getCatalogFlags('t1', 'admin-1', 'ADMIN'); + + expect(flags.get('mod-1')).toEqual({ isActiveForTenant: true, hasAccess: true }); + }); +}); diff --git a/apps/api/src/module-registry/module-access.service.ts b/apps/api/src/module-registry/module-access.service.ts index 4e461dd..84898cf 100644 --- a/apps/api/src/module-registry/module-access.service.ts +++ b/apps/api/src/module-registry/module-access.service.ts @@ -92,4 +92,42 @@ export class ModuleAccessService { orderBy: { name: 'asc' }, }); } + + /** + * Liefert je mandantenweit aktivem Modul zwei Flags in einer Antwort: + * `isActiveForTenant` (immer `true` — die Map enthält nur aktive Module) + * und `hasAccess` aus derselben `getAccessibleModuleIds`-Auflösung wie + * Guard und Sidebar. Bedient `GET /modules/catalog` (D-08): der + * Marketplace zeigt auch nicht freigegebene Module weiter, gekennzeichnet + * statt ausgeblendet — beide Flags kommen bewusst in einer Antwort, damit + * keine Karte kurzzeitig ohne Sperrhinweis klickbar erscheint. Ein nicht + * aktiviertes Modul erscheint schlicht nicht in der zurückgegebenen Map; + * der Controller mappt einen fehlenden Eintrag auf beide Flags `false`. + * + * Für ADMIN/SUPER_ADMIN ist `hasAccess` bei jedem aktiven Modul wahr, + * weil `getAccessibleModuleIds` den Rollen-Kurzschluss anwendet (D-03) — + * das Sperr-Badge erscheint für sie nie. + */ + async getCatalogFlags( + tenantId: string, + userId: string, + role: Role, + ): Promise> { + const [activations, accessibleIds] = await Promise.all([ + this.prisma.tenantModuleActivation.findMany({ + where: { tenantId, isActive: true }, + select: { moduleId: true }, + }), + this.getAccessibleModuleIds(tenantId, userId, role), + ]); + + const flags = new Map(); + for (const activation of activations) { + flags.set(activation.moduleId, { + isActiveForTenant: true, + hasAccess: accessibleIds.has(activation.moduleId), + }); + } + return flags; + } } diff --git a/apps/api/src/module-registry/module-registry.controller.ts b/apps/api/src/module-registry/module-registry.controller.ts index 40f3cd0..901dcaf 100644 --- a/apps/api/src/module-registry/module-registry.controller.ts +++ b/apps/api/src/module-registry/module-registry.controller.ts @@ -63,6 +63,37 @@ export class ModuleRegistryController { return this.moduleAccessService.findAccessibleModules(tenantId, userId, role); } + /** + * GET /modules/catalog + * Liefert je registriertem Modul den vollständigen Datensatz plus zwei + * Flags: isActiveForTenant (TenantModuleActivation) und hasAccess + * (ModuleAccessService.getAccessibleModuleIds) — beide in einer Antwort + * (D-08), damit der Marketplace für einen USER zwischen "nicht + * aktiviert" und "aktiviert, aber nicht freigegeben" unterscheiden kann, + * ohne dass eine Karte kurzzeitig ohne Sperrhinweis klickbar erscheint. + * Erreichbar für jeden authentifizierten Benutzer, wie GET /modules. + */ + @Get('catalog') + async findCatalog(@Req() req: Request) { + const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; + const userId = (req as any).user?.id; + const role = (req as any).user?.role; + if (!tenantId || !userId || !role) { + throw new ForbiddenException('No user context'); + } + + const [modules, flags] = await Promise.all([ + this.moduleRegistryService.findAll(), + this.moduleAccessService.getCatalogFlags(tenantId, userId, role), + ]); + + return modules.map((module) => ({ + ...module, + isActiveForTenant: flags.get(module.id)?.isActiveForTenant ?? false, + hasAccess: flags.get(module.id)?.hasAccess ?? false, + })); + } + /** * POST /modules/:moduleId/activate * Activates a module for the current tenant.