From f71e614f7ffb58e121e6f7e3bfe587b34dbf30d1 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 6 Aug 2026 16:00:18 +0200 Subject: [PATCH] feat(16-02): display name with fallback in user-detail projections (D-04, UI-SPEC Surface Contract 6) - ModuleGrantsService.getUserAccess() now selects internalName on the membership query's group projection (already present via `include: { group: true }` on the grant query) - Both display points (viaGroups names, membership chips' name field) use internalName ?? name; groups[] sorting now runs over the displayed name as a result, distinct from GroupsService.listForTenant() which still sorts by the raw name column - 3 new test cases: fallback set/unset, sort-by-displayed-name - No apps/web/ changes (verified via git diff --name-only) --- .../src/groups/module-grants.service.spec.ts | 59 ++++++++++++++++++- apps/api/src/groups/module-grants.service.ts | 16 ++++- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/api/src/groups/module-grants.service.spec.ts b/apps/api/src/groups/module-grants.service.spec.ts index de666fb..e6afad2 100644 --- a/apps/api/src/groups/module-grants.service.spec.ts +++ b/apps/api/src/groups/module-grants.service.spec.ts @@ -42,8 +42,8 @@ function makeFakePrisma() { } return { - __seedGroup(group: { id: string; tenantId: string; name: string }) { - groups.set(group.id, group); + __seedGroup(group: { id: string; tenantId: string; name: string; internalName?: string | null }) { + groups.set(group.id, { internalName: null, ...group }); }, __seedUser(user: { id: string; tenantId: string }) { users.set(user.id, user); @@ -160,7 +160,7 @@ function makeFakePrisma() { groupId, userId, source: membershipSources.get(`${groupId}::${userId}`) ?? 'MANUAL', - group: { id: group.id, name: group.name }, + group: { id: group.id, name: group.name, internalName: group.internalName ?? null }, }); } return rows; @@ -505,6 +505,59 @@ describe('ModuleGrantsService.getUserAccess', () => { }); }); +describe('ModuleGrantsService.getUserAccess — Anzeigename mit Fallback (D-04)', () => { + it('gesetzter internalName: beide Projektionen (groups[].name, modules[].viaGroups) liefern den internen Namen', async () => { + const prisma = makeFakePrisma(); + prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'AD-Rohname', internalName: 'Vertrieb' }); + prisma.__seedUser({ id: 'u1', tenantId: 't1' }); + prisma.__seedActivation({ + tenantId: 't1', + moduleId: 'mod-1', + isActive: true, + module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' }, + }); + prisma.__seedMembership('g1', 'u1'); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + const result = await service.getUserAccess('t1', 'u1'); + + expect(result.groups).toEqual([{ id: 'g1', name: 'Vertrieb', source: 'MANUAL' }]); + expect(result.modules[0].viaGroups).toEqual(['Vertrieb']); + }); + + it('kein internalName (null): beide Projektionen fallen auf name zurueck', async () => { + const prisma = makeFakePrisma(); + seedBase(prisma); + prisma.__seedMembership('g1', 'u1'); + const service = new ModuleGrantsService(prisma as any); + await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }); + + const result = await service.getUserAccess('t1', 'u1'); + + expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]); + expect(result.modules[0].viaGroups).toEqual(['Gruppe A']); + }); + + it('Sortierung laeuft ueber den angezeigten Namen, nicht ueber die Datenbankspalte name', async () => { + const prisma = makeFakePrisma(); + prisma.__seedUser({ id: 'u1', tenantId: 't1' }); + // g1 traegt in der DB den Namen "Zebra-AD", zeigt aber "Alpha-Anzeige" an; + // g2 traegt "Beta" ohne internalName. Alphabetisch nach ANGEZEIGTEM Namen + // muesste g1 (Alpha-Anzeige) vor g2 (Beta) stehen, waehrend eine Sortierung + // ueber die rohe name-Spalte g2 (Beta) vor g1 (Zebra-AD) haette gestellt. + prisma.__seedGroup({ id: 'g1', tenantId: 't1', name: 'Zebra-AD', internalName: 'Alpha-Anzeige' }); + prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Beta' }); + prisma.__seedMembership('g1', 'u1'); + prisma.__seedMembership('g2', 'u1'); + const service = new ModuleGrantsService(prisma as any); + + const result = await service.getUserAccess('t1', 'u1'); + + expect(result.groups.map((g: any) => g.name)).toEqual(['Alpha-Anzeige', 'Beta']); + }); +}); + describe('ModuleGrantsService — Logging (D-23)', () => { afterEach(() => { vi.restoreAllMocks(); diff --git a/apps/api/src/groups/module-grants.service.ts b/apps/api/src/groups/module-grants.service.ts index 91983fd..c76fac9 100644 --- a/apps/api/src/groups/module-grants.service.ts +++ b/apps/api/src/groups/module-grants.service.ts @@ -212,6 +212,16 @@ export class ModuleGrantsService { * Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein * Direkt-Grant) und behält dafür je Eintrag exakt die Form * { module, viaGroups, direct }. + * + * Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide + * Projektionsstellen (viaGroups-Namen, groups[].name) liefern + * `group.internalName ?? group.name` — Nullish, nicht Oder, damit ein + * bereits auf null normalisierter Leerstring (Task 2) nicht versehentlich + * über eine Truthiness-Prüfung nachträglich anders interpretiert wird. + * Die Sortierung von `groups` läuft danach automatisch über den + * angezeigten Namen, weil sie auf demselben Feld (`name`) aufsetzt — + * anders als GroupsService.listForTenant(), das weiterhin über die + * Datenbankspalte `name` sortiert. */ async getUserAccess(tenantId: string, userId: string) { await this.assertTargetBelongsToTenant(tenantId, undefined, userId); @@ -237,7 +247,7 @@ export class ModuleGrantsService { // läuft der Mandantenfilter über die Relation `group: { tenantId }`. this.prisma.groupMembership.findMany({ where: { userId, group: { tenantId } }, - include: { group: { select: { id: true, name: true } } }, + include: { group: { select: { id: true, name: true, internalName: true } } }, }), ]); @@ -246,7 +256,7 @@ export class ModuleGrantsService { for (const g of groupGrants as any[]) { if (!g.group) continue; const names = groupNamesByModule.get(g.moduleId) ?? []; - names.push(g.group.name); + names.push(g.group.internalName ?? g.group.name); groupNamesByModule.set(g.moduleId, names); } @@ -261,7 +271,7 @@ export class ModuleGrantsService { .filter((m) => m.group) .map((m) => ({ id: m.group.id as string, - name: m.group.name as string, + name: (m.group.internalName ?? m.group.name) as string, source: m.source as string, })) .sort((a, b) => a.name.localeCompare(b.name));