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:
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user