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
+13 -3
View File
@@ -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));