Files
tessera-ctl/apps/api/src/tenant/tenant.controller.ts
T
schalli c8de72e762 feat(260911-e2s): Benutzerzaehler im TenantController binden (Fan-out je Mandant)
findAll/findOne/remove zaehlen Benutzer je Mandant jetzt ueber drei
gebundene Aufrufstellen (tenantPrisma.user.count mit where: { tenantId
}, in remove zusaetzlich isActive: true) statt ueber den
Relationszaehler, der nach dem Scharfschalten unbemerkt unter der
Regel von User gelaufen waere (260911-e2s, Aufgabe 1, Pruefungen 5-7).
Fan-out-Muster aus UserService.findAllForPlatformAdmin uebernommen; die
vier tenant-Zugriffe bleiben ungebunden (Tenant ohne Regel). Antwortform,
Meldungen und Statuscodes unveraendert.

tenant.controller.spec.ts legt die Testlage aus dem Nichts an (20
Faelle): Zwei-Klienten-Nachweis ueber __makeBoundClient, Rollen-
Metadaten-Test (Klasse SUPER_ADMIN, kein Handler ueberschreibt), Wachhund
gegen mehrfache Klientenerzeugung. Falsifizierungsnachweis durchgefuehrt:
der probeweise ungebundene Zaehler in findOne macht 2 Faelle rot mit
"Cannot read properties of undefined (reading 'count')" — die dkv-Form
der Falsifizierung, nicht nur eine falsche Zahl —, danach zurueckgenommen.

Klassifikation und Entwicklungsanleitung nachgezogen: 64 Paare (ein
neues, tenant.controller.ts/user), Uebersichtszeile 8/3, Klassen-
Verteilung 32 muss-mandantengebunden, Erkennungsluecke fuer
Relationseinbindungen im Kopf der Bestandsaufnahme benannt, "Zwei
belegte Befunde" und "Was diese Etappe NICHT entscheidet" (erster
Punkt aufgeloest). Beide Dokument-Falsifizierungsnachweise durchgefuehrt
(falsche Klasse macht rls-access-inventory.spec.ts rot, falsche
Uebersichtszahl macht das herleitende Gate rot), zurueckgenommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
2026-09-11 10:58:27 +02:00

178 lines
4.9 KiB
TypeScript

import {
BadRequestException,
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { TenantService } from './tenant.service';
/**
* Tenant CRUD controller.
* D-10: Only Super-Admin can manage tenants.
* T-02-09: Tenant deletion blocked if active users exist.
*
* User counts (260911-e2s, Aufgabe 3): `findAll`/`findOne`/`remove` used to
* read the user count through a relation include on the four unbound
* tenant reads below. Prisma renders that as a single statement with a
* LEFT JOIN into the protected `User` table — which carries a row-level
* security rule. After the switch flips, an unbound relation count reads
* zero for every tenant (measured, 260911-e2s Aufgabe 1, Pruefungen 5-7),
* which would make the platform-admin tenant list show zero users
* everywhere and let the delete gate below pass through with active users
* still present. Fixed by the fan-out pattern `UserService
* .findAllForPlatformAdmin` already uses: read tenants unbound, then bind
* ONE user count per tenant via the tenant-binding helper. The explicit
* `where: { tenantId }` on each bound count is TODAY (role runs with
* BYPASSRLS, WINDOWS #18) the only filter actually in effect.
*
* The four tenant reads/writes below stay unbound on purpose — `Tenant`
* carries no row-level security rule in any shipped migration (measured,
* 260911-e2s Aufgabe 1, Pruefungen 1/2); nothing on `Tenant` itself needs
* binding.
*
* This controller keeps talking to Prisma directly rather than going
* through a service method (same pattern as `user.controller.ts`) — a
* deliberate choice, not an oversight; see
* docs/mandantentrennung-zugriffsklassifikation.md, "(n5)".
*/
@Controller('tenants')
@UseGuards(RolesGuard)
@Roles(Role.SUPER_ADMIN)
export class TenantController {
constructor(
private readonly tenantService: TenantService,
private readonly prisma: PrismaService,
) {}
/**
* GET /tenants
* Returns all tenants with user count.
*/
@Get()
async findAll() {
const tenants = await this.prisma.tenant.findMany({
orderBy: { name: 'asc' },
});
const results: any[] = [];
for (const tenant of tenants) {
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
const userCount = await tenantPrisma.user.count({
where: { tenantId: tenant.id },
});
results.push({
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
isActive: tenant.isActive,
createdAt: tenant.createdAt,
userCount,
});
}
return results;
}
/**
* GET /tenants/:id
* Returns single tenant with user count.
*/
@Get(':id')
async findOne(@Param('id') id: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
});
if (!tenant) {
throw new NotFoundException('Tenant not found');
}
const tenantPrisma = forTenant(this.prisma, id) as any;
const userCount = await tenantPrisma.user.count({
where: { tenantId: id },
});
return {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
isActive: tenant.isActive,
createdAt: tenant.createdAt,
userCount,
};
}
/**
* POST /tenants
* Create a new tenant.
*/
@Post()
async create(@Body() dto: CreateTenantDto) {
return this.tenantService.create({
name: dto.name,
slug: dto.slug,
});
}
/**
* PATCH /tenants/:id
* Update tenant name or isActive.
*/
@Patch(':id')
async update(
@Param('id') id: string,
@Body() dto: { name?: string; isActive?: boolean },
) {
const existing = await this.tenantService.findById(id);
if (!existing) {
throw new NotFoundException('Tenant not found');
}
return this.tenantService.update(id, {
name: dto.name,
isActive: dto.isActive,
});
}
/**
* DELETE /tenants/:id
* T-02-09: Only delete if no active users exist.
*/
@Delete(':id')
async remove(@Param('id') id: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
});
if (!tenant) {
throw new NotFoundException('Tenant not found');
}
const tenantPrisma = forTenant(this.prisma, id) as any;
const activeUserCount = await tenantPrisma.user.count({
where: { tenantId: id, isActive: true },
});
if (activeUserCount > 0) {
throw new BadRequestException(
'Cannot delete tenant with active users. Deactivate or reassign users first.',
);
}
await this.prisma.tenant.delete({ where: { id } });
return { message: 'Tenant deleted' };
}
}