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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 02:33:59 +02:00
parent 53a49a109f
commit 8ec116c75a
21 changed files with 1052 additions and 16 deletions
@@ -0,0 +1,89 @@
-- quick-261003-387 — Modulkategorien durch Administratoren bearbeitbar.
--
-- Zweck: zwei neue Tabellen und eine neue Spalte.
-- * "ModuleCategory": die Kategorien einer Organisation (Kennung, optionaler
-- eigener Name, Reihenfolge, Systemkennzeichen fuer "Eigene Module").
-- Die Kennung ist unveraenderlich, sie steht als URL-Segment in
-- /modules/<kennung>/<slug>.
-- * "ModuleCategoryPlacement": Zuordnung eines Marktplatz-Moduls zu einer
-- Kategorie der Organisation samt Reihenfolge. Die Spalte "Module"."category"
-- (fuer alle Organisationen gleich) bleibt unveraendert; die wirksame
-- Kategorie ist die Zuordnung, sonst diese Spalte.
-- * "CustomModule"."sortOrder": Reihenfolge gemeinsamer eigener Module
-- innerhalb ihrer Kategorie (NULL = noch nicht sortiert).
-- Es gibt keine Rueckfuellung in SQL: der Dienst legt den Grundbestand je
-- Organisation beim ersten Lesen an.
--
-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Kategorien sind gemeinsame Einstellungen der Organisation, nicht
-- persoenliche Daten eines Benutzers. Keine `system_read_policy`: es gibt
-- keinen Hintergrunddienst, der darueber liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- AlterTable
ALTER TABLE "CustomModule" ADD COLUMN "sortOrder" INTEGER;
-- CreateTable
CREATE TABLE "ModuleCategory" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModuleCategoryPlacement" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"moduleId" TEXT NOT NULL,
"categoryKey" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategoryPlacement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ModuleCategory_tenantId_idx" ON "ModuleCategory"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategory_tenantId_key_key" ON "ModuleCategory"("tenantId", "key");
-- CreateIndex
CREATE INDEX "ModuleCategoryPlacement_tenantId_idx" ON "ModuleCategoryPlacement"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategoryPlacement_tenantId_moduleId_key" ON "ModuleCategoryPlacement"("tenantId", "moduleId");
-- AddForeignKey
ALTER TABLE "ModuleCategoryPlacement" ADD CONSTRAINT "ModuleCategoryPlacement_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz
ALTER TABLE "ModuleCategory" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategory" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategory"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "ModuleCategoryPlacement" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategoryPlacement" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategoryPlacement"
USING ("tenantId" = current_tenant_id());
+42 -1
View File
@@ -120,6 +120,7 @@ model Module {
updatedAt DateTime @updatedAt 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])
}
+2
View File
@@ -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 />);
+29 -9
View File
@@ -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.
+2
View File
@@ -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;
+25
View File
@@ -0,0 +1,25 @@
/**
* Modulkategorien — API-Client (quick-261003-387). Konsumiert
* `/module-categories`. Muster `custom-modules-api.ts`: `credentials:
* 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export interface ModuleCategoryInfo {
id: string;
/** Unveraenderliche Kennung (URL-Segment /modules/<key>/<slug>). */
key: string;
/** Eigener Name; `null` = Uebersetzung `moduleCategories.<key>`. */
name: string | null;
sortOrder: number;
/** `true` nur fuer „Eigene Module“: umbenennbar, nicht loeschbar. */
isSystem: boolean;
}
/** Alle Kategorien der Organisation in ihrer Reihenfolge (jeder Angemeldete). */
export async function listModuleCategories(): Promise<ModuleCategoryInfo[]> {
const res = await fetch(`${API_URL}/module-categories`, { credentials: 'include' });
if (!res.ok) throw new Error(`Request failed (${res.status})`);
return res.json();
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { categoryRank, compareSidebarEntries } from './module-category-order';
describe('categoryRank', () => {
const cats = [{ key: 'b' }, { key: 'a' }];
it('liefert den Index in der eingestellten Reihenfolge', () => {
expect(categoryRank(cats, 'b')).toBe(0);
expect(categoryRank(cats, 'a')).toBe(1);
});
it('stellt unbekannte Kennungen hinter alle bekannten', () => {
expect(categoryRank(cats, 'x')).toBe(2);
expect(categoryRank([], 'x')).toBe(0);
});
});
describe('compareSidebarEntries', () => {
const e = (name: string, sortOrder: number | null, custom = false) => ({
name,
sortOrder,
custom,
});
const sorted = (list: ReturnType<typeof e>[]) =>
[...list].sort(compareSidebarEntries).map((x) => x.name);
it('sortOrder aufsteigend, null zuletzt', () => {
expect(sorted([e('c', null), e('b', 2), e('a', 1)])).toEqual(['a', 'b', 'c']);
});
it('bei Gleichstand stehen eingebaute Module vor eigenen', () => {
expect(sorted([e('Aaa', null, true), e('Zzz', null, false)])).toEqual(['Zzz', 'Aaa']);
expect(sorted([e('Aaa', 1, true), e('Zzz', 1, false)])).toEqual(['Zzz', 'Aaa']);
});
it('danach nach Name', () => {
expect(sorted([e('b', null), e('a', null)])).toEqual(['a', 'b']);
});
it('ein sortierter Eintrag steht vor einem unsortierten, auch wenn der unsortierte eingebaut ist', () => {
expect(sorted([e('Eingebaut', null, false), e('Eigenes', 0, true)])).toEqual([
'Eigenes',
'Eingebaut',
]);
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Reihenfolge von Kategorien und Seitenleisten-Eintraegen (quick-261003-387,
* E-08). Reine Funktionen — der Stand kommt aus dem Kategorienspeicher.
*/
/** Rang einer Kategorie in der eingestellten Reihenfolge; unbekannte zuletzt (alle gleich). */
export function categoryRank(categories: ReadonlyArray<{ key: string }>, key: string): number {
const index = categories.findIndex((c) => c.key === key);
return index === -1 ? categories.length : index;
}
export interface SortableEntry {
name: string;
sortOrder: number | null;
/** `true` = eigenes Modul. */
custom: boolean;
}
/**
* Reihenfolge innerhalb einer Kategorie: `sortOrder` aufsteigend, `null`
* zuletzt; bei Gleichstand eingebaute Module vor eigenen, dann nach Name.
*/
export function compareSidebarEntries(a: SortableEntry, b: SortableEntry): number {
if (a.sortOrder !== b.sortOrder) {
if (a.sortOrder === null) return 1;
if (b.sortOrder === null) return -1;
return a.sortOrder - b.sortOrder;
}
if (a.custom !== b.custom) return a.custom ? 1 : -1;
return a.name.localeCompare(b.name);
}
@@ -0,0 +1,32 @@
import { create } from 'zustand';
import { listModuleCategories, type ModuleCategoryInfo } from '@/lib/module-categories-api';
/**
* Geteilter Kategorienstand (quick-261003-387): Beschriftung, Reihenfolge und
* Auswahlfelder lesen alle denselben Stand. Geladen wird von der Seitenleiste
* (sie ist im Portal immer da) und von Seiten, die die Reihenfolge brauchen
* (`ensureLoaded`). Ein Fehler laesst den bisherigen Stand stehen — wie beim
* Modulabruf der Seitenleiste.
*/
interface ModuleCategoryState {
categories: ModuleCategoryInfo[];
loaded: boolean;
load: () => Promise<void>;
ensureLoaded: () => Promise<void>;
}
export const useModuleCategoryStore = create<ModuleCategoryState>()((set, get) => ({
categories: [],
loaded: false,
load: async () => {
try {
const categories = await listModuleCategories();
set({ categories, loaded: true });
} catch {
// silently fail
}
},
ensureLoaded: async () => {
if (!get().loaded) await get().load();
},
}));
+12 -3
View File
@@ -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(
(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. // `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung.
(slug: string) => (typeof t.has === 'function' && t.has(slug) ? t(slug) : slug), return typeof t.has === 'function' && t.has(slug) ? t(slug) : slug;
[t], },
[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. |