feat(15-03): GET /modules/catalog — beide Statusflags in einer Antwort

- 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
This commit is contained in:
2026-08-04 18:41:23 +02:00
parent 072fb7f62f
commit 1c32543f58
3 changed files with 114 additions and 0 deletions
@@ -215,3 +215,48 @@ describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', ()
expect(prisma.module.findMany).not.toHaveBeenCalled(); 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 });
});
});
@@ -92,4 +92,42 @@ export class ModuleAccessService {
orderBy: { name: 'asc' }, 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<Map<string, { isActiveForTenant: boolean; hasAccess: boolean }>> {
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<string, { isActiveForTenant: boolean; hasAccess: boolean }>();
for (const activation of activations) {
flags.set(activation.moduleId, {
isActiveForTenant: true,
hasAccess: accessibleIds.has(activation.moduleId),
});
}
return flags;
}
} }
@@ -63,6 +63,37 @@ export class ModuleRegistryController {
return this.moduleAccessService.findAccessibleModules(tenantId, userId, role); 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 * POST /modules/:moduleId/activate
* Activates a module for the current tenant. * Activates a module for the current tenant.