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)
This commit is contained in:
2026-08-06 16:00:18 +02:00
parent 253da91ba9
commit f71e614f7f
2 changed files with 69 additions and 6 deletions
@@ -42,8 +42,8 @@ function makeFakePrisma() {
} }
return { return {
__seedGroup(group: { id: string; tenantId: string; name: string }) { __seedGroup(group: { id: string; tenantId: string; name: string; internalName?: string | null }) {
groups.set(group.id, group); groups.set(group.id, { internalName: null, ...group });
}, },
__seedUser(user: { id: string; tenantId: string }) { __seedUser(user: { id: string; tenantId: string }) {
users.set(user.id, user); users.set(user.id, user);
@@ -160,7 +160,7 @@ function makeFakePrisma() {
groupId, groupId,
userId, userId,
source: membershipSources.get(`${groupId}::${userId}`) ?? 'MANUAL', 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; 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)', () => { describe('ModuleGrantsService — Logging (D-23)', () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
+13 -3
View File
@@ -212,6 +212,16 @@ export class ModuleGrantsService {
* Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein * Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein
* Direkt-Grant) und behält dafür je Eintrag exakt die Form * Direkt-Grant) und behält dafür je Eintrag exakt die Form
* { module, viaGroups, direct }. * { 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) { async getUserAccess(tenantId: string, userId: string) {
await this.assertTargetBelongsToTenant(tenantId, undefined, userId); await this.assertTargetBelongsToTenant(tenantId, undefined, userId);
@@ -237,7 +247,7 @@ export class ModuleGrantsService {
// läuft der Mandantenfilter über die Relation `group: { tenantId }`. // läuft der Mandantenfilter über die Relation `group: { tenantId }`.
this.prisma.groupMembership.findMany({ this.prisma.groupMembership.findMany({
where: { userId, group: { tenantId } }, 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[]) { for (const g of groupGrants as any[]) {
if (!g.group) continue; if (!g.group) continue;
const names = groupNamesByModule.get(g.moduleId) ?? []; const names = groupNamesByModule.get(g.moduleId) ?? [];
names.push(g.group.name); names.push(g.group.internalName ?? g.group.name);
groupNamesByModule.set(g.moduleId, names); groupNamesByModule.set(g.moduleId, names);
} }
@@ -261,7 +271,7 @@ export class ModuleGrantsService {
.filter((m) => m.group) .filter((m) => m.group)
.map((m) => ({ .map((m) => ({
id: m.group.id as string, 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, source: m.source as string,
})) }))
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));