feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite

- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000
- API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten
- Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“
- MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 07:25:33 +02:00
parent 643b1a2caa
commit b9d87be360
23 changed files with 1270 additions and 40 deletions
@@ -0,0 +1,85 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
const CUSTOM_MODULE_SELECT = {
id: true,
name: true,
url: true,
category: true,
createdAt: true,
updatedAt: true,
};
/**
* Eigene Module (quick-260929-9wc): vom Administrator angelegte
* Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt
* ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je
* Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen
* getOne/update/remove `row.tenantId` — zweites Netz, solange der
* RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte
* id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz.
*/
@Injectable()
export class CustomModulesService {
constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.customModule.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
select: CUSTOM_MODULE_SELECT,
});
}
async getOne(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.customModule.findUnique({
where: { id },
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
});
if (!row || row.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
const { tenantId: _omit, ...result } = row;
return result;
}
async create(tenantId: string, dto: CreateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.customModule.create({
data: { tenantId, name: dto.name, url: dto.url, category: dto.category },
select: CUSTOM_MODULE_SELECT,
});
}
async update(tenantId: string, id: string, dto: UpdateCustomModuleDto) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
if (!existing || existing.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
const data: { name?: string; url?: string; category?: string } = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.url !== undefined) data.url = dto.url;
if (dto.category !== undefined) data.category = dto.category;
return tenantPrisma.customModule.update({
where: { id },
data,
select: CUSTOM_MODULE_SELECT,
});
}
async remove(tenantId: string, id: string) {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
if (!existing || existing.tenantId !== tenantId) {
throw new NotFoundException('Eigenes Modul nicht gefunden');
}
await tenantPrisma.customModule.delete({ where: { id } });
return { deleted: true };
}
}