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
|
updatedAt DateTime @updatedAt
|
||||||
activations TenantModuleActivation[]
|
activations TenantModuleActivation[]
|
||||||
grants ModuleGrant[]
|
grants ModuleGrant[]
|
||||||
|
categoryPlacements ModuleCategoryPlacement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model TenantModuleActivation {
|
model TenantModuleActivation {
|
||||||
@@ -849,7 +850,11 @@ model CustomModule {
|
|||||||
tenantId String
|
tenantId String
|
||||||
name String
|
name String
|
||||||
url 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
|
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
|
||||||
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
|
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
|
||||||
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
|
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
|
||||||
@@ -906,3 +911,39 @@ model WelcomeMailTemplate {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
|
||||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||||
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||||
|
import { ModuleCategoriesModule } from './module-categories/module-categories.module';
|
||||||
import { RemindersModule } from './reminders/reminders.module';
|
import { RemindersModule } from './reminders/reminders.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -62,6 +63,7 @@ import { RemindersModule } from './reminders/reminders.module';
|
|||||||
KantineDatevModule,
|
KantineDatevModule,
|
||||||
HandelswareDatevModule,
|
HandelswareDatevModule,
|
||||||
CustomModulesModule,
|
CustomModulesModule,
|
||||||
|
ModuleCategoriesModule,
|
||||||
RemindersModule,
|
RemindersModule,
|
||||||
],
|
],
|
||||||
providers: [
|
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 type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
|
||||||
import { ModuleAccessService } from './module-access.service';
|
import { ModuleAccessService } from './module-access.service';
|
||||||
import { ModuleRegistryService } from './module-registry.service';
|
import { ModuleRegistryService } from './module-registry.service';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export class ModuleRegistryController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly moduleRegistryService: ModuleRegistryService,
|
private readonly moduleRegistryService: ModuleRegistryService,
|
||||||
private readonly moduleAccessService: ModuleAccessService,
|
private readonly moduleAccessService: ModuleAccessService,
|
||||||
|
private readonly moduleCategoriesService: ModuleCategoriesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +62,10 @@ export class ModuleRegistryController {
|
|||||||
if (!tenantId || !userId || !role) {
|
if (!tenantId || !userId || !role) {
|
||||||
throw new ForbiddenException('No user context');
|
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 { Module } from '@nestjs/common';
|
||||||
|
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
|
||||||
import { ModuleAccessService } from './module-access.service';
|
import { ModuleAccessService } from './module-access.service';
|
||||||
import { ModuleRegistryController } from './module-registry.controller';
|
import { ModuleRegistryController } from './module-registry.controller';
|
||||||
import { ModuleRegistryService } from './module-registry.service';
|
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.
|
* Plan 15-05, Grant-Services Plan 15-03) can inject and use them.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ModuleCategoriesModule],
|
||||||
controllers: [ModuleRegistryController],
|
controllers: [ModuleRegistryController],
|
||||||
providers: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
|
providers: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
|
||||||
exports: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
|
exports: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||||
|
|
||||||
let mockPathname = '/';
|
let mockPathname = '/';
|
||||||
vi.mock('next/navigation', () => ({
|
vi.mock('next/navigation', () => ({
|
||||||
@@ -95,6 +96,7 @@ let mockCustomModules: {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
sortOrder?: number | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
@@ -102,10 +104,41 @@ vi.mock('@/lib/custom-modules-api', () => ({
|
|||||||
listCustomModules: vi.fn(() => Promise.resolve(mockCustomModules)),
|
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: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'Domain-Tools' },
|
||||||
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
{ id: 'm2', slug: 'converter', name: 'Converter', category: 'Utilities' },
|
||||||
];
|
];
|
||||||
|
const DEFAULT_ACTIVE_MODULES = [...mockActiveModules];
|
||||||
|
|
||||||
function stubFetch() {
|
function stubFetch() {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -121,6 +154,9 @@ afterEach(() => {
|
|||||||
mockRefreshKey = 0;
|
mockRefreshKey = 0;
|
||||||
mockCustomModules = [];
|
mockCustomModules = [];
|
||||||
mockCollapsed = false;
|
mockCollapsed = false;
|
||||||
|
mockCategories = categoriesFor(DEFAULT_CATEGORY_KEYS);
|
||||||
|
mockActiveModules = [...DEFAULT_ACTIVE_MODULES];
|
||||||
|
useModuleCategoryStore.setState({ categories: [], loaded: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
|
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
|
||||||
@@ -442,6 +478,67 @@ describe('Sidebar', () => {
|
|||||||
expect(screen.getByText('Wiki')).toBeInTheDocument();
|
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 () => {
|
it('renders the version badge below the navigation', async () => {
|
||||||
const Sidebar = await importSidebar();
|
const Sidebar = await importSidebar();
|
||||||
render(<Sidebar />);
|
render(<Sidebar />);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
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 { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||||
import { ModuleTile } from '@/components/modules/module-tile';
|
import { ModuleTile } from '@/components/modules/module-tile';
|
||||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
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 { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
|
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||||
import { useNavStore } from '@/lib/stores/nav-store';
|
import { useNavStore } from '@/lib/stores/nav-store';
|
||||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
@@ -34,6 +35,8 @@ interface SidebarModule {
|
|||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
/** Reihenfolge innerhalb der Kategorie (Administrator); `null`/fehlt = nicht sortiert. */
|
||||||
|
sortOrder?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +51,10 @@ interface SidebarEntry {
|
|||||||
href: string;
|
href: string;
|
||||||
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
|
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
|
||||||
tileSlug: string;
|
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. */
|
/** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */
|
||||||
extraActiveHref?: string;
|
extraActiveHref?: string;
|
||||||
}
|
}
|
||||||
@@ -60,6 +67,8 @@ function moduleEntry(mod: SidebarModule): SidebarEntry {
|
|||||||
category,
|
category,
|
||||||
href: `/modules/${category}/${mod.slug}`,
|
href: `/modules/${category}/${mod.slug}`,
|
||||||
tileSlug: mod.slug,
|
tileSlug: mod.slug,
|
||||||
|
sortOrder: mod.sortOrder ?? null,
|
||||||
|
custom: false,
|
||||||
extraActiveHref: `/modules/${mod.slug}`,
|
extraActiveHref: `/modules/${mod.slug}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -71,6 +80,8 @@ function customEntry(mod: CustomModule): SidebarEntry {
|
|||||||
category: mod.category || 'other',
|
category: mod.category || 'other',
|
||||||
href: `/modules/custom/${mod.id}`,
|
href: `/modules/custom/${mod.id}`,
|
||||||
tileSlug: 'custom',
|
tileSlug: 'custom',
|
||||||
|
sortOrder: mod.sortOrder ?? null,
|
||||||
|
custom: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +96,8 @@ export function Sidebar() {
|
|||||||
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
|
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
|
||||||
const [closedCategories, setClosedCategories] = useState<Set<string>>(new Set());
|
const [closedCategories, setClosedCategories] = useState<Set<string>>(new Set());
|
||||||
const categoryLabel = useCategoryLabel();
|
const categoryLabel = useCategoryLabel();
|
||||||
|
const orderedCategoryList = useModuleCategoryStore((s) => s.categories);
|
||||||
|
const loadCategories = useModuleCategoryStore((s) => s.load);
|
||||||
const setNavModules = useNavStore((s) => s.setModules);
|
const setNavModules = useNavStore((s) => s.setModules);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
|
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
|
||||||
@@ -148,8 +161,10 @@ export function Sidebar() {
|
|||||||
// silently fail
|
// 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
|
// Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene
|
||||||
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
|
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
|
||||||
@@ -166,9 +181,13 @@ export function Sidebar() {
|
|||||||
]);
|
]);
|
||||||
}, [activeModules, customModules, setNavModules]);
|
}, [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[]>(
|
const entries = useMemo<SidebarEntry[]>(
|
||||||
() => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)],
|
() =>
|
||||||
|
[...activeModules.map(moduleEntry), ...customModules.map(customEntry)].sort(
|
||||||
|
compareSidebarEntries,
|
||||||
|
),
|
||||||
[activeModules, customModules],
|
[activeModules, customModules],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -190,8 +209,9 @@ export function Sidebar() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
|
// Eintraege nach Kategorie gruppiert, Gruppen in der von Administratoren
|
||||||
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
|
// eingestellten Reihenfolge (Standard: „Eigene Module“ zuletzt); unbekannte
|
||||||
|
// Kennungen stehen zuletzt in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
|
||||||
const orderedCategories = useMemo(() => {
|
const orderedCategories = useMemo(() => {
|
||||||
const categories = new Map<string, SidebarEntry[]>();
|
const categories = new Map<string, SidebarEntry[]>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -200,9 +220,9 @@ export function Sidebar() {
|
|||||||
else categories.set(entry.category, [entry]);
|
else categories.set(entry.category, [entry]);
|
||||||
}
|
}
|
||||||
return Array.from(categories.entries()).sort(
|
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
|
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
|
||||||
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
|
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface CustomModule {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
category: 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). */
|
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
|
||||||
personal: boolean;
|
personal: boolean;
|
||||||
createdAt: string;
|
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 { useTranslations } from 'next-intl';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert den Anzeigenamen einer Modulkategorie in Alltagssprache
|
* 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 {
|
export function useCategoryLabel(): (slug: string) => string {
|
||||||
const t = useTranslations('moduleCategories');
|
const t = useTranslations('moduleCategories');
|
||||||
|
const categories = useModuleCategoryStore((s) => s.categories);
|
||||||
return useCallback(
|
return useCallback(
|
||||||
// `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung.
|
(slug: string) => {
|
||||||
(slug: string) => (typeof t.has === 'function' && t.has(slug) ? t(slug) : slug),
|
const stored = categories.find((c) => c.key === slug)?.name;
|
||||||
[t],
|
if (stored) return stored;
|
||||||
|
// `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung.
|
||||||
|
return typeof t.has === 'function' && t.has(slug) ? t(slug) : slug;
|
||||||
|
},
|
||||||
|
[t, categories],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 | 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/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/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 | 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/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. |
|
| 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