feat(quick-261003-387): Modulkategorien je Organisation - Tabellen, Grundbestand, Lese-Endpunkt bis in die Seitenleiste

- Tabellen ModuleCategory und ModuleCategoryPlacement mit Zeilenschutz, Spalte CustomModule.sortOrder
- Dienst legt den Grundbestand beim ersten Lesen an, legt die wirksame Kategorie ueber Modullisten
- GET /module-categories, PATCH :key (nur Administratoren), /modules/active liefert wirksame Kategorie
- Seitenleiste ordnet Gruppen und Eintraege nach Kategorienspeicher, zeigt umbenannte Namen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 02:33:59 +02:00
parent 53a49a109f
commit 8ec116c75a
21 changed files with 1052 additions and 16 deletions
@@ -0,0 +1,89 @@
-- quick-261003-387 — Modulkategorien durch Administratoren bearbeitbar.
--
-- Zweck: zwei neue Tabellen und eine neue Spalte.
-- * "ModuleCategory": die Kategorien einer Organisation (Kennung, optionaler
-- eigener Name, Reihenfolge, Systemkennzeichen fuer "Eigene Module").
-- Die Kennung ist unveraenderlich, sie steht als URL-Segment in
-- /modules/<kennung>/<slug>.
-- * "ModuleCategoryPlacement": Zuordnung eines Marktplatz-Moduls zu einer
-- Kategorie der Organisation samt Reihenfolge. Die Spalte "Module"."category"
-- (fuer alle Organisationen gleich) bleibt unveraendert; die wirksame
-- Kategorie ist die Zuordnung, sonst diese Spalte.
-- * "CustomModule"."sortOrder": Reihenfolge gemeinsamer eigener Module
-- innerhalb ihrer Kategorie (NULL = noch nicht sortiert).
-- Es gibt keine Rueckfuellung in SQL: der Dienst legt den Grundbestand je
-- Organisation beim ersten Lesen an.
--
-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Kategorien sind gemeinsame Einstellungen der Organisation, nicht
-- persoenliche Daten eines Benutzers. Keine `system_read_policy`: es gibt
-- keinen Hintergrunddienst, der darueber liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- AlterTable
ALTER TABLE "CustomModule" ADD COLUMN "sortOrder" INTEGER;
-- CreateTable
CREATE TABLE "ModuleCategory" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModuleCategoryPlacement" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"moduleId" TEXT NOT NULL,
"categoryKey" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategoryPlacement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ModuleCategory_tenantId_idx" ON "ModuleCategory"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategory_tenantId_key_key" ON "ModuleCategory"("tenantId", "key");
-- CreateIndex
CREATE INDEX "ModuleCategoryPlacement_tenantId_idx" ON "ModuleCategoryPlacement"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategoryPlacement_tenantId_moduleId_key" ON "ModuleCategoryPlacement"("tenantId", "moduleId");
-- AddForeignKey
ALTER TABLE "ModuleCategoryPlacement" ADD CONSTRAINT "ModuleCategoryPlacement_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz
ALTER TABLE "ModuleCategory" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategory" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategory"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "ModuleCategoryPlacement" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategoryPlacement" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategoryPlacement"
USING ("tenantId" = current_tenant_id());
+42 -1
View File
@@ -120,6 +120,7 @@ model Module {
updatedAt DateTime @updatedAt
activations TenantModuleActivation[]
grants ModuleGrant[]
categoryPlacements ModuleCategoryPlacement[]
}
model TenantModuleActivation {
@@ -849,7 +850,11 @@ model CustomModule {
tenantId String
name String
url String
category String // eine der MODULE_CATEGORIES aus @tessera/shared
category String // Kennung einer ModuleCategory der Organisation
// quick-261003-387: Reihenfolge innerhalb der Kategorie (nur gemeinsame
// Eintraege; null = noch nicht sortiert, steht hinten). Persoenliche
// Eintraege bekommen nie eine sortOrder.
sortOrder Int?
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
@@ -906,3 +911,39 @@ model WelcomeMailTemplate {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// quick-261003-387: Modulkategorien je Organisation, durch Administratoren
// pflegbar. `key` ist unveraenderlich (URL-Segment /modules/<key>/<slug>),
// `name` null = Uebersetzung moduleCategories.<key>. isSystem nur fuer
// "custom-modules" (Eigene Module): umbenennbar und verschiebbar, nicht
// loeschbar.
model ModuleCategory {
id String @id @default(uuid())
tenantId String
key String
name String?
sortOrder Int @default(0)
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, key])
@@index([tenantId])
}
// quick-261003-387: Zuordnung eines Marktplatz-Moduls zu einer Kategorie der
// Organisation samt Reihenfolge. Wirksame Kategorie = Zuordnung, sonst
// Module.category (die Spalte selbst bleibt fuer alle gleich).
model ModuleCategoryPlacement {
id String @id @default(uuid())
tenantId String
moduleId String
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
categoryKey String
sortOrder Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, moduleId])
@@index([tenantId])
}
+2
View File
@@ -31,6 +31,7 @@ import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
import { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
import { ModuleCategoriesModule } from './module-categories/module-categories.module';
import { RemindersModule } from './reminders/reminders.module';
@Module({
@@ -62,6 +63,7 @@ import { RemindersModule } from './reminders/reminders.module';
KantineDatevModule,
HandelswareDatevModule,
CustomModulesModule,
ModuleCategoriesModule,
RemindersModule,
],
providers: [
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
const trimString = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** Neuer Anzeigename einer Kategorie (die Kennung bleibt unveraendert). */
export class RenameModuleCategoryDto {
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(60)
name!: string;
}
@@ -0,0 +1,42 @@
import 'reflect-metadata';
import { ForbiddenException } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleCategoriesController } from './module-categories.controller';
const proto = ModuleCategoriesController.prototype as any;
const ADMIN_ONLY = [Role.ADMIN, Role.SUPER_ADMIN];
describe('ModuleCategoriesController — Rollen (T-387-01)', () => {
it('haengt an Pfad module-categories', () => {
expect(Reflect.getMetadata('path', ModuleCategoriesController)).toBe('module-categories');
});
it('GET list traegt keine Rolle (jeder Angemeldete)', () => {
expect(Reflect.getMetadata(ROLES_KEY, proto.list)).toBeUndefined();
});
it('rename ist nur fuer Administratoren', () => {
expect(Reflect.getMetadata(ROLES_KEY, proto.rename)).toEqual(ADMIN_ONLY);
expect(Reflect.getMetadata(GUARDS_METADATA, proto.rename)).toContain(RolesGuard);
});
});
describe('ModuleCategoriesController — Organisation', () => {
it('reicht die Organisation aus der Anfrage weiter', async () => {
const service = { listCategories: vi.fn(async () => []), rename: vi.fn(async () => ({})) };
const controller = new ModuleCategoriesController(service as any);
await controller.list({ tenantId: 't1' } as any);
await controller.rename({ tenantId: 't1' } as any, 'fleet', { name: 'X' });
expect(service.listCategories).toHaveBeenCalledWith('t1');
expect(service.rename).toHaveBeenCalledWith('t1', 'fleet', 'X');
});
it('ohne Organisation -> 403', async () => {
const controller = new ModuleCategoriesController({} as any);
await expect(controller.list({} as any)).rejects.toBeInstanceOf(ForbiddenException);
});
});
@@ -0,0 +1,58 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
Patch,
Req,
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { RenameModuleCategoryDto } from './dto/module-category.dto';
import { ModuleCategoriesService } from './module-categories.service';
/**
* Modulkategorien (quick-261003-387).
*
* - GET /module-categories — alle angemeldeten Benutzer (Beschriftung und
* Reihenfolge der Seitenleiste); liefert nur Kennung, Name, Reihenfolge.
* - Verwaltung (Umbenennen, spaeter Anlegen/Sortieren/Zuordnen/Loeschen) nur
* ADMIN/SUPER_ADMIN, je Methode ueber @UseGuards(RolesGuard) + @Roles.
*
* ROUTEN-REIHENFOLGE: statische Routen (overview, order, assignment) MUESSEN
* vor den Routen mit `:key` stehen, sonst faengt `:key` sie ab (404-
* Shadowing, NestJS bildet in Deklarationsreihenfolge ab). Der Controller-
* Test haelt das fest.
*/
@Controller('module-categories')
export class ModuleCategoriesController {
constructor(private readonly service: ModuleCategoriesService) {}
private tenantIdOf(req: AuthenticatedRequest): string {
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Organisationskontext');
}
return tenantId;
}
@Get()
async list(@Req() req: AuthenticatedRequest) {
return this.service.listCategories(this.tenantIdOf(req));
}
@Patch(':key')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async rename(
@Req() req: AuthenticatedRequest,
@Param('key') key: string,
@Body() dto: RenameModuleCategoryDto,
) {
return this.service.rename(this.tenantIdOf(req), key, dto.name);
}
}
@@ -0,0 +1,138 @@
import { vi } from 'vitest';
/**
* Speicher-Attrappe fuer ModuleCategoriesService-Tests: bildet die wenigen
* Prisma-Aufrufe des Dienstes mit einfachen Gleichheits-/`in`-Filtern nach.
* Kein Produktionscode (nur von *.spec.ts importiert).
*/
// biome-ignore lint/suspicious/noExplicitAny: Attrappe fuer beliebige Tabellenzeilen
type Row = Record<string, any>;
function matches(row: Row, where: Row = {}): boolean {
return Object.entries(where).every(([field, cond]) => {
if (field === 'tenantId_moduleId') {
return row.tenantId === cond.tenantId && row.moduleId === cond.moduleId;
}
if (cond && typeof cond === 'object' && !(cond instanceof Date)) {
if ('in' in cond) return (cond.in as unknown[]).includes(row[field]);
if ('not' in cond) return row[field] !== cond.not;
}
return row[field] === cond;
});
}
function sortBy(list: Row[], orderBy?: Row | Row[]): Row[] {
const orders = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : [];
return [...list].sort((a, b) => {
for (const order of orders) {
const [field, dir] = Object.entries(order)[0] as [string, 'asc' | 'desc'];
if (a[field] === b[field]) continue;
const cmp = a[field] < b[field] ? -1 : 1;
return dir === 'desc' ? -cmp : cmp;
}
return 0;
});
}
function table(initial: Row[] = [], idPrefix = 'r') {
const rows: Row[] = [...initial];
let seq = 0;
const api = {
rows,
findMany: vi.fn(
async (args: { where?: Row; orderBy?: Row | Row[]; distinct?: string[] } = {}) => {
let list = sortBy(
rows.filter((r) => matches(r, args.where)),
args.orderBy,
);
const distinct = args.distinct;
if (distinct) {
const seen = new Set<string>();
list = list.filter((r) => {
const k = distinct.map((f) => r[f]).join('|');
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
return list.map((r) => ({ ...r }));
},
),
findFirst: vi.fn(async (args: { where?: Row } = {}) => {
const r = rows.find((row) => matches(row, args.where));
return r ? { ...r } : null;
}),
findUnique: vi.fn(async (args: { where: Row }) => {
const r = rows.find((row) => matches(row, args.where));
return r ? { ...r } : null;
}),
create: vi.fn(async ({ data }: { data: Row }) => {
const row = { id: `${idPrefix}-${++seq}`, ...data };
rows.push(row);
return { ...row };
}),
createMany: vi.fn(async ({ data }: { data: Row[]; skipDuplicates?: boolean }) => {
let count = 0;
for (const d of data) {
const dup = rows.some(
(r) =>
r.tenantId === d.tenantId &&
(d.key !== undefined ? r.key === d.key : r.moduleId === d.moduleId),
);
if (dup) continue;
rows.push({ id: `${idPrefix}-${++seq}`, ...d });
count++;
}
return { count };
}),
update: vi.fn(async ({ where, data }: { where: Row; data: Row }) => {
const r = rows.find((row) => matches(row, where));
if (!r) throw new Error('P2025');
Object.assign(r, data);
return { ...r };
}),
updateMany: vi.fn(async ({ where, data }: { where: Row; data: Row }) => {
const hit = rows.filter((row) => matches(row, where));
for (const r of hit) Object.assign(r, data);
return { count: hit.length };
}),
upsert: vi.fn(async ({ where, create, update }: { where: Row; create: Row; update: Row }) => {
const r = rows.find((row) => matches(row, where));
if (r) {
Object.assign(r, update);
return { ...r };
}
const row = { id: `${idPrefix}-${++seq}`, ...create };
rows.push(row);
return { ...row };
}),
delete: vi.fn(async ({ where }: { where: Row }) => {
const i = rows.findIndex((row) => matches(row, where));
if (i < 0) throw new Error('P2025');
const [gone] = rows.splice(i, 1);
return gone;
}),
deleteMany: vi.fn(async ({ where }: { where: Row }) => {
let count = 0;
for (let i = rows.length - 1; i >= 0; i--) {
if (matches(rows[i], where)) {
rows.splice(i, 1);
count++;
}
}
return { count };
}),
};
return api;
}
export function makeFakePrisma(
seed: { modules?: Row[]; placements?: Row[]; customModules?: Row[]; categories?: Row[] } = {},
) {
return {
module: table(seed.modules ?? [], 'm'),
moduleCategory: table(seed.categories ?? [], 'c'),
moduleCategoryPlacement: table(seed.placements ?? [], 'p'),
customModule: table(seed.customModules ?? [], 'cm'),
};
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesController } from './module-categories.controller';
import { ModuleCategoriesService } from './module-categories.service';
/**
* Modulkategorien je Organisation (quick-261003-387). `PrismaModule` ist
* global. Der Dienst wird exportiert, weil Modulliste, Freigaben-Matrix und
* eigene Module die wirksame Kategorie darueberlegen bzw. pruefen.
*/
@Module({
controllers: [ModuleCategoriesController],
providers: [ModuleCategoriesService],
exports: [ModuleCategoriesService],
})
export class ModuleCategoriesModule {}
@@ -0,0 +1,166 @@
import { NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
// `forTenant`/`withTenantTransaction` reichen den Klienten durch — die
// Mandantenbindung selbst prueft rls-access-inventory.spec.ts.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
withTenantTransaction: vi.fn(async (p: unknown, _t: string, fn: (tx: unknown) => unknown) =>
fn(p),
),
}));
import { makeFakePrisma } from './module-categories.fake-prisma';
import { ModuleCategoriesService } from './module-categories.service';
const DEFAULT_KEYS = [
'domain-tools',
'security-tools',
'fleet',
'infrastructure',
'procurement',
'accounting',
'custom-modules',
];
function setup(seed: Parameters<typeof makeFakePrisma>[0] = {}) {
const prisma = makeFakePrisma(seed);
return { prisma, service: new ModuleCategoriesService(prisma as any) };
}
describe('ModuleCategoriesService — Grundbestand (E-04)', () => {
it('legt fuer eine frische Organisation sieben Standardkategorien in Standardreihenfolge an', async () => {
const { service } = setup();
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).toEqual(DEFAULT_KEYS);
expect(list.every((c) => c.name === null)).toBe(true);
expect(list.map((c) => c.sortOrder)).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(list.filter((c) => c.isSystem).map((c) => c.key)).toEqual(['custom-modules']);
});
it('legt ein zweites Mal nichts doppelt an', async () => {
const { prisma, service } = setup();
await service.listCategories('t1');
await service.listCategories('t1');
expect(prisma.moduleCategory.rows).toHaveLength(7);
});
it('haengt eine bisher unbekannte, wirksam benutzte Kennung hinten an', async () => {
const { service } = setup({
modules: [{ id: 'm1', slug: 'neu', name: 'Neu', category: 'brandneu' }],
});
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).toEqual([...DEFAULT_KEYS, 'brandneu']);
expect(list[7].sortOrder).toBe(7);
});
it('nimmt Kennungen eigener Module (auch persoenlicher) auf', async () => {
const { service } = setup({
customModules: [
{ id: 'c1', tenantId: 't1', category: 'altlast', ownerUserId: 'u1', name: 'x' },
],
});
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).toContain('altlast');
});
it('nutzt die Zuordnung statt der Manifest-Kategorie fuer den Nachschub', async () => {
const { service } = setup({
modules: [{ id: 'm1', slug: 'a', name: 'A', category: 'fleet' }],
placements: [
{ id: 'p1', tenantId: 't1', moduleId: 'm1', categoryKey: 'zugeordnet', sortOrder: 0 },
],
categories: [
{
id: 'c1',
tenantId: 't1',
key: 'custom-modules',
name: null,
sortOrder: 0,
isSystem: true,
},
],
});
const list = await service.listCategories('t1');
// „fleet“ wird von keinem Modul wirksam benutzt und kommt nicht zurueck.
expect(list.map((c) => c.key)).toEqual(['custom-modules', 'zugeordnet']);
});
it('trennt Organisationen: t2 sieht die Zeilen von t1 nicht', async () => {
const { prisma, service } = setup();
await service.listCategories('t1');
await service.listCategories('t2');
expect(prisma.moduleCategory.rows.filter((r) => r.tenantId === 't2')).toHaveLength(7);
expect(prisma.moduleCategory.rows).toHaveLength(14);
});
});
describe('ModuleCategoriesService — applyToModules (E-07)', () => {
const modules = [
{ id: 'a', slug: 'a', name: 'Zeta', category: 'fleet' },
{ id: 'b', slug: 'b', name: 'Alpha', category: 'fleet' },
{ id: 'c', slug: 'c', name: 'Mitte', category: 'domain-tools' },
];
it('sortiert nach Kategoriereihenfolge, dann Name', async () => {
const { service } = setup({ modules });
const out = await service.applyToModules('t1', modules);
expect(out.map((m) => m.id)).toEqual(['c', 'b', 'a']);
expect(out[0].sortOrder).toBeNull();
});
it('Zuordnung schlaegt Manifest, aendert aber die Eingabe nicht', async () => {
const { service } = setup({
modules,
placements: [
{ id: 'p', tenantId: 't1', moduleId: 'c', categoryKey: 'accounting', sortOrder: 3 },
],
});
const out = await service.applyToModules('t1', modules);
expect(out.find((m) => m.id === 'c')).toMatchObject({ category: 'accounting', sortOrder: 3 });
expect(modules[2].category).toBe('domain-tools');
});
it('sortOrder aufsteigend, null zuletzt', async () => {
const { service } = setup({
modules,
placements: [{ id: 'p1', tenantId: 't1', moduleId: 'a', categoryKey: 'fleet', sortOrder: 0 }],
});
const out = await service.applyToModules('t1', modules);
expect(out.filter((m) => m.category === 'fleet').map((m) => m.id)).toEqual(['a', 'b']);
});
it('eine Zuordnung zu geloeschter Kategorie legt die Kennung wieder an und bleibt sortierbar', async () => {
const { service } = setup({
modules,
placements: [{ id: 'p', tenantId: 't1', moduleId: 'a', categoryKey: 'weg', sortOrder: 0 }],
});
const out = await service.applyToModules('t1', modules);
expect(out.find((m) => m.id === 'a')?.category).toBe('weg');
});
});
describe('ModuleCategoriesService — umbenennen', () => {
it('speichert den getrimmten Namen, Kennung bleibt', async () => {
const { service } = setup();
const res = await service.rename('t1', 'fleet', ' Fuhrpark ');
expect(res).toMatchObject({ key: 'fleet', name: 'Fuhrpark' });
});
it('erlaubt „Eigene Module“', async () => {
const { service } = setup();
const res = await service.rename('t1', 'custom-modules', 'Meine Sachen');
expect(res.name).toBe('Meine Sachen');
});
it('unbekannte Kennung -> 404', async () => {
const { service } = setup();
await expect(service.rename('t1', 'gibtsnicht', 'X')).rejects.toBeInstanceOf(NotFoundException);
});
it('leerer oder zu langer Name -> 400', async () => {
const { service } = setup();
await expect(service.rename('t1', 'fleet', ' ')).rejects.toThrow('1 bis 60');
await expect(service.rename('t1', 'fleet', 'x'.repeat(61))).rejects.toThrow('1 bis 60');
});
});
@@ -0,0 +1,203 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
/** Eine Kategorie, wie sie GET /module-categories liefert. */
export interface ModuleCategoryRow {
id: string;
key: string;
name: string | null;
sortOrder: number;
isSystem: boolean;
}
/** Zuordnung eines Marktplatz-Moduls (nur die Felder, die der Dienst braucht). */
interface PlacementRow {
moduleId: string;
categoryKey: string;
sortOrder: number;
}
/** Grundbestand und Zuordnungen einer Organisation nach `ensure`. */
export interface CategoryState {
categories: ModuleCategoryRow[];
placements: PlacementRow[];
}
const CATEGORY_SELECT = {
id: true,
key: true,
name: true,
sortOrder: true,
isSystem: true,
} as const;
/**
* Modulkategorien je Organisation (quick-261003-387).
*
* Modell (Entscheidungen E-01 bis E-08 aus dem Plan):
* - `ModuleCategory` — Kategorien der Organisation; `key` ist unveraenderlich
* (URL-Segment), `name` null = Uebersetzung `moduleCategories.<key>`.
* - `ModuleCategoryPlacement` — Zuordnung eines Marktplatz-Moduls. Die
* wirksame Kategorie ist die Zuordnung, sonst `Module.category`; die Spalte
* `Module.category` wird nie geaendert (sie gilt fuer alle Organisationen).
* - Gemeinsame eigene Module tragen ihre Kategorie und Reihenfolge direkt
* (`CustomModule.category`, `CustomModule.sortOrder`).
*
* Der Grundbestand wird ohne SQL-Rueckfuellung beim ersten Lesen angelegt
* (`ensure`): die Standardkategorien in der Reihenfolge von
* CUSTOM_MODULE_CATEGORIES, danach hinten angehaengt jede wirksam benutzte
* Kennung, die noch fehlt.
*
* RLS-BINDUNG: je Methode ein eigener `forTenant`-Klient, zusaetzlich steht
* `tenantId` in jedem `where` (Anwendungspruefung, solange der RLS-Schalter
* aus ist). Der globale Modulkatalog wird ueber den ungebundenen Klienten
* gelesen — dieselbe Begruendung wie in `module-access.service.ts`: die Tabelle
* "Module" traegt heute keinen Zeilenschutz, eine Bindung waere heute
* wirkungslos; sie wuerde erst katastrophal, WENN diese Tabelle eine Regel
* bekaeme (dann verschwaende der Katalog fuer jede Organisation). Diese
* Bedingung steht hier als Bedingung, nicht als heute beobachtbare Tatsache.
*/
@Injectable()
export class ModuleCategoriesService {
constructor(private readonly prisma: PrismaService) {}
/**
* Grundbestand sicherstellen und Zustand liefern (E-04). Legt beim ersten
* Lesen die Standardkategorien an und ergaenzt jede wirksam benutzte
* Kennung (Manifest-Kategorie eines Moduls ohne Zuordnung, Kategorie
* eigener Module), die noch fehlt, hinten. Eine geloeschte Standardkategorie
* kommt nur zurueck, wenn ein Modul sie wirksam benutzt.
*/
async ensure(tenantId: string): Promise<CategoryState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
let categories: ModuleCategoryRow[] = await tenantPrisma.moduleCategory.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { key: 'asc' }],
select: CATEGORY_SELECT,
});
if (categories.length === 0) {
await tenantPrisma.moduleCategory.createMany({
data: CUSTOM_MODULE_CATEGORIES.map((key, index) => ({
tenantId,
key,
name: null,
sortOrder: index,
isSystem: key === CUSTOM_MODULE_CATEGORY,
})),
skipDuplicates: true,
});
categories = await tenantPrisma.moduleCategory.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { key: 'asc' }],
select: CATEGORY_SELECT,
});
}
const placements: PlacementRow[] = await tenantPrisma.moduleCategoryPlacement.findMany({
where: { tenantId },
select: { moduleId: true, categoryKey: true, sortOrder: true },
});
const modules = await this.prisma.module.findMany({
select: { id: true, category: true },
});
const customRows = await tenantPrisma.customModule.findMany({
where: { tenantId },
select: { category: true },
distinct: ['category'],
});
const placementByModule = new Map(placements.map((p) => [p.moduleId, p.categoryKey]));
const used = new Set<string>();
for (const m of modules) used.add(placementByModule.get(m.id) ?? m.category);
for (const c of customRows) used.add(c.category);
const known = new Set(categories.map((c) => c.key));
const missing = [...used].filter((key) => !known.has(key)).sort();
if (missing.length > 0) {
const base = categories.reduce((max, c) => Math.max(max, c.sortOrder), -1) + 1;
await tenantPrisma.moduleCategory.createMany({
data: missing.map((key, index) => ({
tenantId,
key,
name: null,
sortOrder: base + index,
isSystem: false,
})),
skipDuplicates: true,
});
categories = await tenantPrisma.moduleCategory.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { key: 'asc' }],
select: CATEGORY_SELECT,
});
}
return { categories, placements };
}
/** Alle Kategorien der Organisation in ihrer Reihenfolge. */
async listCategories(tenantId: string): Promise<ModuleCategoryRow[]> {
const { categories } = await this.ensure(tenantId);
return categories;
}
/**
* Wirksame Kategorie und Reihenfolge ueber eine Modulliste legen (E-07):
* jedes Modul bekommt `category` = wirksame Kennung (Zuordnung schlaegt
* Manifest) und `sortOrder` (Zahl oder null). Sortiert nach Reihenfolge
* der Kategorie, dann `sortOrder` (null zuletzt), dann Name. Eine
* unbekannte Kategorie sortiert zuletzt.
*/
async applyToModules<T extends { id: string; category: string; name: string }>(
tenantId: string,
modules: T[],
): Promise<Array<Omit<T, 'category'> & { category: string; sortOrder: number | null }>> {
const { categories, placements } = await this.ensure(tenantId);
const rank = new Map(categories.map((c, index) => [c.key, index]));
const placementByModule = new Map(placements.map((p) => [p.moduleId, p]));
const result = modules.map((module) => {
const placement = placementByModule.get(module.id);
return {
...module,
category: placement?.categoryKey ?? module.category,
sortOrder: placement?.sortOrder ?? null,
};
});
return result.sort((a, b) => {
const ra = rank.get(a.category) ?? Number.MAX_SAFE_INTEGER;
const rb = rank.get(b.category) ?? Number.MAX_SAFE_INTEGER;
if (ra !== rb) return ra - rb;
if (a.sortOrder !== b.sortOrder) {
if (a.sortOrder === null) return 1;
if (b.sortOrder === null) return -1;
return a.sortOrder - b.sortOrder;
}
return a.name.localeCompare(b.name);
});
}
/** Anzeigename aendern (1-60 Zeichen, getrimmt); die Kennung bleibt. */
async rename(tenantId: string, key: string, name: string): Promise<ModuleCategoryRow> {
const trimmed = name.trim();
if (trimmed.length < 1 || trimmed.length > 60) {
throw new BadRequestException('Der Name muss 1 bis 60 Zeichen lang sein');
}
const { categories } = await this.ensure(tenantId);
const existing = categories.find((c) => c.key === key);
if (!existing) {
throw new NotFoundException('Kategorie nicht gefunden');
}
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.moduleCategory.update({
where: { id: existing.id, tenantId },
data: { name: trimmed },
select: CATEGORY_SELECT,
});
}
}
@@ -11,6 +11,7 @@ import { Role } from '@prisma/client';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
import { ModuleAccessService } from './module-access.service';
import { ModuleRegistryService } from './module-registry.service';
@@ -32,6 +33,7 @@ export class ModuleRegistryController {
constructor(
private readonly moduleRegistryService: ModuleRegistryService,
private readonly moduleAccessService: ModuleAccessService,
private readonly moduleCategoriesService: ModuleCategoriesService,
) {}
/**
@@ -60,7 +62,10 @@ export class ModuleRegistryController {
if (!tenantId || !userId || !role) {
throw new ForbiddenException('No user context');
}
return this.moduleAccessService.findAccessibleModules(tenantId, userId, role);
const modules = await this.moduleAccessService.findAccessibleModules(tenantId, userId, role);
// quick-261003-387: wirksame Kategorie + Reihenfolge der Organisation
// (Zuordnung schlaegt Manifest-Kategorie), sortiert fuer die Seitenleiste.
return this.moduleCategoriesService.applyToModules(tenantId, modules);
}
/**
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
import { ModuleAccessService } from './module-access.service';
import { ModuleRegistryController } from './module-registry.controller';
import { ModuleRegistryService } from './module-registry.service';
@@ -19,6 +20,7 @@ import { ModuleGuard } from './module.guard';
* Plan 15-05, Grant-Services Plan 15-03) can inject and use them.
*/
@Module({
imports: [ModuleCategoriesModule],
controllers: [ModuleRegistryController],
providers: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
exports: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
@@ -1,6 +1,7 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
let mockPathname = '/';
vi.mock('next/navigation', () => ({
@@ -95,6 +96,7 @@ let mockCustomModules: {
name: string;
url: string;
category: string;
sortOrder?: number | null;
createdAt: string;
updatedAt: string;
}[] = [];
@@ -102,10 +104,41 @@ vi.mock('@/lib/custom-modules-api', () => ({
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
}));
const mockActiveModules = [
// Kategorien (quick-261003-387): eigener Client; Standard = die Kategorien der
// Testdaten, „Eigene Module“ zuletzt. Der Kategorienspeicher ist echt.
interface MockCategory {
id: string;
key: string;
name: string | null;
sortOrder: number;
isSystem: boolean;
}
function categoriesFor(keys: string[], names: Record<string, string> = {}): MockCategory[] {
return keys.map((key, index) => ({
id: `c-${key}`,
key,
name: names[key] ?? null,
sortOrder: index,
isSystem: key === 'custom-modules',
}));
}
const DEFAULT_CATEGORY_KEYS = ['Domain-Tools', 'Utilities', 'infrastructure', 'fleet', 'custom-modules'];
let mockCategories: MockCategory[] = categoriesFor(DEFAULT_CATEGORY_KEYS);
vi.mock('@/lib/module-categories-api', () => ({
listModuleCategories: vi.fn(() => Promise.resolve(mockCategories)),
}));
let mockActiveModules: {
id: string;
slug: string;
name: string;
category: string;
sortOrder?: number | null;
}[] = [
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
];
const DEFAULT_ACTIVE_MODULES = [...mockActiveModules];
function stubFetch() {
vi.stubGlobal(
@@ -121,6 +154,9 @@ afterEach(() => {
mockRefreshKey = 0;
mockCustomModules = [];
mockCollapsed = false;
mockCategories = categoriesFor(DEFAULT_CATEGORY_KEYS);
mockActiveModules = [...DEFAULT_ACTIVE_MODULES];
useModuleCategoryStore.setState({ categories: [], loaded: false });
});
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
@@ -442,6 +478,67 @@ describe('Sidebar', () => {
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
it('Gruppen folgen der Reihenfolge aus dem Kategorienspeicher', async () => {
mockCategories = categoriesFor(['Utilities', 'Domain-Tools', 'custom-modules']);
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
});
const groups = () =>
Array.from(
screen
.getByRole('group', { name: 'Kategorien' })
.querySelectorAll(':scope > ul > li > button span.truncate'),
).map((n) => n.textContent);
await waitFor(() => {
expect(groups()).toEqual(['Utilities', 'Domain-Tools']);
});
});
it('eine umbenannte Kategorie zeigt den gespeicherten Namen', async () => {
mockCategories = categoriesFor(DEFAULT_CATEGORY_KEYS, { 'Domain-Tools': 'Meine Domains' });
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Meine Domains')).toBeInTheDocument();
});
expect(screen.queryByText('Domain-Tools')).not.toBeInTheDocument();
// Eine nicht umbenannte Kategorie behaelt ihre Beschriftung.
expect(screen.getByText('Utilities')).toBeInTheDocument();
});
it('Eintraege innerhalb einer Gruppe folgen sortOrder, null zuletzt, eingebaut vor eigenem', async () => {
mockActiveModules = [
{ id: 'a', slug: 'aaa', name: 'Aaa', category: 'fleet', sortOrder: null },
{ id: 'b', slug: 'bbb', name: 'Bbb', category: 'fleet', sortOrder: 2 },
{ id: 'c', slug: 'ccc', name: 'Ccc', category: 'fleet', sortOrder: 1 },
];
mockCustomModules = [
customModule({ id: 'cm-1', category: 'fleet', name: 'Eigenes A', sortOrder: 2 }),
customModule({ id: 'cm-2', category: 'fleet', name: 'Eigenes B', sortOrder: null }),
];
const Sidebar = await importSidebar();
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Eigenes B')).toBeInTheDocument();
});
const items = screen
.getByText('fleet')
.closest('li')
?.querySelectorAll('a span.truncate');
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
'Ccc',
'Bbb',
'Eigenes A',
'Aaa',
'Eigenes B',
]);
});
it('renders the version badge below the navigation', async () => {
const Sidebar = await importSidebar();
render(<Sidebar />);
+29 -9
View File
@@ -1,6 +1,5 @@
'use client';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
@@ -11,7 +10,9 @@ import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { ModuleTile } from '@/components/modules/module-tile';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { categoryRank, compareSidebarEntries } from '@/lib/module-category-order';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
import { useNavStore } from '@/lib/stores/nav-store';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useCategoryLabel } from '@/lib/use-category-label';
@@ -34,6 +35,8 @@ interface SidebarModule {
slug: string;
name: string;
category: string;
/** Reihenfolge innerhalb der Kategorie (Administrator); `null`/fehlt = nicht sortiert. */
sortOrder?: number | null;
}
/**
@@ -48,6 +51,10 @@ interface SidebarEntry {
href: string;
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
tileSlug: string;
/** Reihenfolge innerhalb der Kategorie; `null` = nicht sortiert, steht hinten. */
sortOrder: number | null;
/** `true` = eigenes Modul (steht bei Gleichstand hinter eingebauten). */
custom: boolean;
/** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */
extraActiveHref?: string;
}
@@ -60,6 +67,8 @@ function moduleEntry(mod: SidebarModule): SidebarEntry {
category,
href: `/modules/${category}/${mod.slug}`,
tileSlug: mod.slug,
sortOrder: mod.sortOrder ?? null,
custom: false,
extraActiveHref: `/modules/${mod.slug}`,
};
}
@@ -71,6 +80,8 @@ function customEntry(mod: CustomModule): SidebarEntry {
category: mod.category || 'other',
href: `/modules/custom/${mod.id}`,
tileSlug: 'custom',
sortOrder: mod.sortOrder ?? null,
custom: true,
};
}
@@ -85,6 +96,8 @@ export function Sidebar() {
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
const [closedCategories, setClosedCategories] = useState<Set<string>>(new Set());
const categoryLabel = useCategoryLabel();
const orderedCategoryList = useModuleCategoryStore((s) => s.categories);
const loadCategories = useModuleCategoryStore((s) => s.load);
const setNavModules = useNavStore((s) => s.setModules);
const pathname = usePathname();
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
@@ -148,8 +161,10 @@ export function Sidebar() {
// silently fail
}
};
await Promise.all([loadBuiltIn(), loadCustom()]);
}, []);
// Kategorien (quick-261003-387) ebenfalls im selben Takt: Reihenfolge und
// Namen stammen aus dem geteilten Kategorienspeicher.
await Promise.all([loadBuiltIn(), loadCustom(), loadCategories()]);
}, [loadCategories]);
// Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
@@ -166,9 +181,13 @@ export function Sidebar() {
]);
}, [activeModules, customModules, setNavModules]);
// Eingebaute Module stehen innerhalb einer Kategorie vor eigenen.
// Reihenfolge innerhalb einer Kategorie: sortOrder aufsteigend (null zuletzt),
// bei Gleichstand eingebaute Module vor eigenen, dann nach Name.
const entries = useMemo<SidebarEntry[]>(
() => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)],
() =>
[...activeModules.map(moduleEntry), ...customModules.map(customEntry)].sort(
compareSidebarEntries,
),
[activeModules, customModules],
);
@@ -190,8 +209,9 @@ export function Sidebar() {
});
};
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
// Eintraege nach Kategorie gruppiert, Gruppen in der von Administratoren
// eingestellten Reihenfolge (Standard: „Eigene Module“ zuletzt); unbekannte
// Kennungen stehen zuletzt in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
const orderedCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
@@ -200,9 +220,9 @@ export function Sidebar() {
else categories.set(entry.category, [entry]);
}
return Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
([a], [b]) => categoryRank(orderedCategoryList, a) - categoryRank(orderedCategoryList, b),
);
}, [entries]);
}, [entries, orderedCategoryList]);
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
+2
View File
@@ -13,6 +13,8 @@ export interface CustomModule {
name: string;
url: string;
category: string;
/** Reihenfolge innerhalb der Kategorie (nur gemeinsame Eintraege); `null` = nicht sortiert. */
sortOrder?: number | null;
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
personal: boolean;
createdAt: string;
+25
View File
@@ -0,0 +1,25 @@
/**
* Modulkategorien — API-Client (quick-261003-387). Konsumiert
* `/module-categories`. Muster `custom-modules-api.ts`: `credentials:
* 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export interface ModuleCategoryInfo {
id: string;
/** Unveraenderliche Kennung (URL-Segment /modules/<key>/<slug>). */
key: string;
/** Eigener Name; `null` = Uebersetzung `moduleCategories.<key>`. */
name: string | null;
sortOrder: number;
/** `true` nur fuer „Eigene Module“: umbenennbar, nicht loeschbar. */
isSystem: boolean;
}
/** Alle Kategorien der Organisation in ihrer Reihenfolge (jeder Angemeldete). */
export async function listModuleCategories(): Promise<ModuleCategoryInfo[]> {
const res = await fetch(`${API_URL}/module-categories`, { credentials: 'include' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
return res.json();
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { categoryRank, compareSidebarEntries } from './module-category-order';
describe('categoryRank', () => {
const cats = [{ key: 'b' }, { key: 'a' }];
it('liefert den Index in der eingestellten Reihenfolge', () => {
expect(categoryRank(cats, 'b')).toBe(0);
expect(categoryRank(cats, 'a')).toBe(1);
});
it('stellt unbekannte Kennungen hinter alle bekannten', () => {
expect(categoryRank(cats, 'x')).toBe(2);
expect(categoryRank([], 'x')).toBe(0);
});
});
describe('compareSidebarEntries', () => {
const e = (name: string, sortOrder: number | null, custom = false) => ({
name,
sortOrder,
custom,
});
const sorted = (list: ReturnType<typeof e>[]) =>
[...list].sort(compareSidebarEntries).map((x) => x.name);
it('sortOrder aufsteigend, null zuletzt', () => {
expect(sorted([e('c', null), e('b', 2), e('a', 1)])).toEqual(['a', 'b', 'c']);
});
it('bei Gleichstand stehen eingebaute Module vor eigenen', () => {
expect(sorted([e('Aaa', null, true), e('Zzz', null, false)])).toEqual(['Zzz', 'Aaa']);
expect(sorted([e('Aaa', 1, true), e('Zzz', 1, false)])).toEqual(['Zzz', 'Aaa']);
});
it('danach nach Name', () => {
expect(sorted([e('b', null), e('a', null)])).toEqual(['a', 'b']);
});
it('ein sortierter Eintrag steht vor einem unsortierten, auch wenn der unsortierte eingebaut ist', () => {
expect(sorted([e('Eingebaut', null, false), e('Eigenes', 0, true)])).toEqual([
'Eigenes',
'Eingebaut',
]);
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Reihenfolge von Kategorien und Seitenleisten-Eintraegen (quick-261003-387,
* E-08). Reine Funktionen — der Stand kommt aus dem Kategorienspeicher.
*/
/** Rang einer Kategorie in der eingestellten Reihenfolge; unbekannte zuletzt (alle gleich). */
export function categoryRank(categories: ReadonlyArray<{ key: string }>, key: string): number {
const index = categories.findIndex((c) => c.key === key);
return index === -1 ? categories.length : index;
}
export interface SortableEntry {
name: string;
sortOrder: number | null;
/** `true` = eigenes Modul. */
custom: boolean;
}
/**
* Reihenfolge innerhalb einer Kategorie: `sortOrder` aufsteigend, `null`
* zuletzt; bei Gleichstand eingebaute Module vor eigenen, dann nach Name.
*/
export function compareSidebarEntries(a: SortableEntry, b: SortableEntry): number {
if (a.sortOrder !== b.sortOrder) {
if (a.sortOrder === null) return 1;
if (b.sortOrder === null) return -1;
return a.sortOrder - b.sortOrder;
}
if (a.custom !== b.custom) return a.custom ? 1 : -1;
return a.name.localeCompare(b.name);
}
@@ -0,0 +1,32 @@
import { create } from 'zustand';
import { listModuleCategories, type ModuleCategoryInfo } from '@/lib/module-categories-api';
/**
* Geteilter Kategorienstand (quick-261003-387): Beschriftung, Reihenfolge und
* Auswahlfelder lesen alle denselben Stand. Geladen wird von der Seitenleiste
* (sie ist im Portal immer da) und von Seiten, die die Reihenfolge brauchen
* (`ensureLoaded`). Ein Fehler laesst den bisherigen Stand stehen — wie beim
* Modulabruf der Seitenleiste.
*/
interface ModuleCategoryState {
categories: ModuleCategoryInfo[];
loaded: boolean;
load: () => Promise<void>;
ensureLoaded: () => Promise<void>;
}
export const useModuleCategoryStore = create<ModuleCategoryState>()((set, get) => ({
categories: [],
loaded: false,
load: async () => {
try {
const categories = await listModuleCategories();
set({ categories, loaded: true });
} catch {
// silently fail
}
},
ensureLoaded: async () => {
if (!get().loaded) await get().load();
},
}));
+13 -4
View File
@@ -2,16 +2,25 @@
import { useTranslations } from 'next-intl';
import { useCallback } from 'react';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
/**
* Liefert den Anzeigenamen einer Modulkategorie in Alltagssprache
* (`domain-tools` → „Domains“). Unbekannte Kategorien behalten ihre Kennung.
* (`domain-tools` → „Domains“). Reihenfolge der Quellen (quick-261003-387):
* ein von Administratoren gespeicherter Name, dann die Uebersetzung, dann
* die Kennung selbst. Der Hook abonniert den Kategorienspeicher, damit
* Beschriftungen nach dem Laden oder Umbenennen neu gezeichnet werden.
*/
export function useCategoryLabel(): (slug: string) => string {
const t = useTranslations('moduleCategories');
const categories = useModuleCategoryStore((s) => s.categories);
return useCallback(
// `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung.
(slug: string) => (typeof t.has === 'function' && t.has(slug) ? t(slug) : slug),
[t],
(slug: string) => {
const stored = categories.find((c) => c.key === slug)?.name;
if (stored) return stored;
// `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung.
return typeof t.has === 'function' && t.has(slug) ? t(slug) : slug;
},
[t, categories],
);
}