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],
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
}));
|
||||
@@ -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(
|
||||
(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.
|
||||
(slug: string) => (typeof t.has === 'function' && t.has(slug) ? t(slug) : slug),
|
||||
[t],
|
||||
return typeof t.has === 'function' && t.has(slug) ? t(slug) : slug;
|
||||
},
|
||||
[t, categories],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -863,6 +863,10 @@ werden.
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServer | muss-mandantengebunden | system-gebunden | **quick-260923-dhh, Aufgabe 4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `loadActiveServersForScheduler()` liest beim Start des Planers `const systemPrisma = forSystem(this.prisma);` (ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht ueber `system_read_policy … FOR SELECT` auf "ProxmoxServer", Migration 20260923140000) — der Planer muss die aktiven Server ALLER Mandanten sehen, um je Mandant einen Cron-Auftrag zu registrieren (Muster `DkvSchedulerService`). GESCHRIEBEN wird auch dort nur je Zeile gebunden. Sechs mandantengebundene Zugriffe blieben nach Aufgabe 4 bestehen: `createServer` (`proxmoxServer.create`), `listWithStatus` (`findMany`), `pollServer` (`findUnique`, mit `include: { status: true }` fuer die Zehn-Sekunden-Sperre), `testConnection` (`findUnique`), `listActiveServerIdsForTenant` (`findMany`), `loadActiveServersForTenantScheduling` (`findMany` auf `proxmoxServer`, `select: { pollIntervalMin: true }`). **Aufgabe 5** ergaenzt vier weitere: `updateServer` (`findUnique` UND `update`) und `deleteServer` (`findUnique` UND `delete`), je ein Klient je Methode — macht zehn mandantengebundene `proxmoxServer`-Rohtreffer insgesamt, plus der eine System-Rohtreffer aus Aufgabe 4. Vorher (Aufgabe 1): vom Administrator eingetragene Proxmox-Server (PVE/PBS/PMG), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, Form aus `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. `listWithStatus` waehlt die beiden Geheimnisfelder (`encryptedTokenSecret`/`encryptedPassword`) per `select` gar nicht erst aus (T-DHH-01). |
|
||||
| apps/api/src/proxmox/proxmox.service.ts | proxmoxServerStatus | muss-mandantengebunden | gebunden | quick-260923-dhh, Aufgabe 1/4 — Zwischenlager je Server (D-05), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260923140000, dieselbe Form wie `proxmoxServer`). `pollServer` schreibt ueber `tenantPrisma.proxmoxServerStatus.upsert()`, DENSELBEN Klienten wie das Lesen des Servers in derselben Methode; dieselbe Methode liest zusaetzlich `include: { status: true }` fuer die Zehn-Sekunden-Sperre (Aufgabe 4, T-DHH-06) — ebenfalls ueber den gebundenen Klienten. Bewusst KEINE `system_read_policy` auf dieser Tabelle (anders als `proxmoxServer`) — der Planer-Startpfad liest nur die Serverzeilen, das Zwischenlager wird ausschliesslich je Mandant gebunden geschrieben, ein Systemlesezugriff hat keinen Aufrufer. |
|
||||
| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** neu — vom Administrator angelegte Seitenleisten-Eintraege („Eigene Module“, Name, https-Adresse, Kategorie), fuer alle Benutzer des Mandanten sichtbar. `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20260929120000, Form aus `ProxmoxServer`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der eigene Module ueber alle Mandanten liest. Sieben mandantengebundene Rohtreffer, je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`): `list` (`findMany` mit `where: { tenantId }`), `getOne` (`findUnique`), `create`, `update` (`findUnique` UND `update`), `remove` (`findUnique` UND `delete`). `getOne`/`update`/`remove` pruefen zusaetzlich `row.tenantId !== tenantId` und antworten mit 404 — zweites Netz, solange der RLS-Schalter aus ist (Muster `dashboardImage`). **quick-260929-dzu — persönliche Einträge:** neue Spalte `ownerUserId` (NULL = gemeinsam, gesetzt = persönlich, nur für den Besitzer sichtbar). Klasse und Stand unverändert (`muss-mandantengebunden`, `gebunden`); der Zeilenschutz bekommt die Benutzerdimension nach dem Muster `SearchProvider` (Migration 20260929130000): vier nach Befehl getrennte Regeln — Lesen: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId` NULL ODER eigene Zeile), Schreiben (INSERT/UPDATE/DELETE): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Persönliche Zugriffe binden mit Benutzer (`forTenant(prisma, tenantId, user.id)`); das Schreiben GEMEINSAMER Einträge bindet bewusst OHNE Benutzer, weil die Regel einem Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — davor prüft der Dienst die Rolle (nur Administrator, sonst 403). Fremde persönliche Einträge sind für jeden anderen Benutzer, auch Administratoren, ununterscheidbar 404. Sechs mandantengebundene Rohtreffer (siehe Bereichszeile). |
|
||||
| apps/api/src/module-categories/module-categories.service.ts | moduleCategory | muss-mandantengebunden | gebunden | **quick-261003-387:** neu — die Modulkategorien einer Organisation (Kennung, optionaler eigener Name, Reihenfolge, Systemkennzeichen für „Eigene Module“). `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` OHNE Benutzerdimension (Migration 20261003120000, Form aus `NextcloudInstance`) — gemeinsame Einstellungen der Organisation, nicht persönliche Daten eines Benutzers. Bewusst KEINE `system_read_policy`: kein Hintergrunddienst liest sie. Je Methode ein eigener Klient (`const tenantPrisma = forTenant(this.prisma, tenantId)`), mehrschrittige Schreibwege über `withTenantTransaction`; zusätzlich steht `tenantId` in jedem `where`. |
|
||||
| apps/api/src/module-categories/module-categories.service.ts | moduleCategoryPlacement | muss-mandantengebunden | gebunden | **quick-261003-387:** neu — Zuordnung eines Marktplatz-Moduls zu einer Kategorie der Organisation samt Reihenfolge (`tenantId`, `moduleId`, `categoryKey`, `sortOrder`). Regel `tenant_isolation_policy` ohne Benutzerdimension (Migration 20261003120000). Die Spalte `Module.category` bleibt unverändert; wirksam ist die Zuordnung, sonst die Manifest-Kategorie. Alle Zugriffe an den Organisationsklienten gebunden, `where` trägt `tenantId`. |
|
||||
| apps/api/src/module-categories/module-categories.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-261003-387:** der Kategorien-Dienst liest die Kennungen eigener Module (auch persönlicher, nur das Feld `category`, damit keine Kennung verloren geht) und schreibt für GEMEINSAME eigene Module Kategorie und `sortOrder` (`where: { id, tenantId, ownerUserId: null }`). Beim Löschen einer Kategorie ziehen auch persönliche Einträge mit um — nur das Feld `category`, ohne ihren Inhalt zu lesen. Klient ohne Benutzer (die Regel aus Migration 20260929130000 lässt ihm alle Zeilen der Organisation); die Administrator-Prüfung sitzt im Controller (`@Roles`). Persönliche Einträge erscheinen in der Verwaltungsübersicht nur als Anzahl. |
|
||||
| apps/api/src/module-categories/module-categories.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | **quick-261003-387:** Modulkatalog ist plattformweit, kein `tenantId`; der Kategorien-Dienst liest ihn (Kennung, Slug, Name, Manifest-Kategorie) über den ungebundenen Klienten — dieselbe Begründung wie `module-access.service.ts` (260910-exd, Befund E): keine Regel auf der Tabelle, eine Bindung wäre heute wirkungslos, katastrophal erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. |
|
||||
| apps/api/src/reminders/reminders.service.ts | reminder | muss-mandantengebunden | gebunden | **quick-260929-if2:** neu — persönliche, einmalige Erinnerungen des Dashboard-Widgets „Erinnerungen“ (Titel, Beschreibung, Fälligkeit). `tenantId`- und `userId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension (Migration 20260929140000, Form aus `DashboardImage`): Mandant UND (kein Benutzer gesetzt ODER eigene Zeile). Jede Methode bindet mit Mandant UND Benutzer (`const tenantPrisma = forTenant(this.prisma, tenantId, userId)`), jedes `where` trägt zusätzlich `tenantId` und `userId` (Anwendungspruefung, solange der RLS-Schalter aus ist). Fremde oder unbekannte Kennungen sind ununterscheidbar 404, nie 403 (D-05). Sieben mandantengebundene Rohtreffer: `list` (`findMany`), `create` (`count` und `create`), `update` (`update`), `snooze` (`update`), `remove` (`delete`) und die gemeinsame Besitzprüfung `loadOwn` (`findFirst` mit `where: { id, tenantId, userId }`, für `update`/`snooze`/`remove`). Das Verschieben setzt `emailSentAt` und `emailAttempts` zurück (D-03). |
|
||||
| apps/api/src/reminders/reminders.service.ts | user | muss-mandantengebunden | gebunden | quick-260929-if2 (Aufgabe 3): `getEmailAvailability` liest die eigene E-Mail-Adresse des Aufrufers (`user.findFirst` mit `where: { id: userId, tenantId }`), gebunden mit Mandant UND Benutzer über denselben Klienten wie die übrigen Zugriffe der Methode. Grundlage für den Schalter „zusätzlich per E-Mail“ und die 400-Antwort bei `emailEnabled` ohne Adresse. |
|
||||
| apps/api/src/reminders/reminder-mail.scheduler.ts | reminder | beides | system-gebunden | quick-260929-if2 (Aufgabe 3): der E-Mail-Planer der Erinnerungen ist ein globales 30-Sekunden-Intervall über ALLE Mandanten (bewusst übergreifend, siehe Dateikopf). Die Kandidatenabfrage (`findMany`, nur skalarer Select, `take 200`) liest über `forSystem()` (`system_read_policy ... FOR SELECT`, Migration 20260929140000, nur lesend); Anspruch (`updateMany`), Laden (`findFirst`) und Freigabe (`updateMany`) laufen je Kandidatenzeile über `forTenant(prisma, c.tenantId)`. Der Anspruch ist atomar (`emailSentAt: null` und unveränderte `dueAt` in der Bedingung), damit mehrere Instanzen nie doppelt senden. |
|
||||
|
||||
Reference in New Issue
Block a user