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