feat(02-01): UserModule, TenantModule, admin seed, and app.module wiring

- Create UserService with findByUsername (unscoped), create, update, deactivate, delete
- Create AdminSeedService that seeds Super-Admin from Docker ENV on bootstrap (D-05/D-07/D-13)
- Create TenantService with findAll, findById, create, update
- Create TenantMiddleware extracting tenantId from JWT with Super-Admin tenant switching (D-08/D-10)
- Wire PrismaModule, AuthModule, UserModule, TenantModule into AppModule
- Register JwtAuthGuard and RolesGuard as global APP_GUARD providers
- Apply TenantMiddleware to all routes via NestModule.configure
- Add @Public() decorator to HealthController for unauthenticated access
This commit is contained in:
2026-06-18 13:28:04 +02:00
parent 6190f3dd39
commit 4b05627f3d
8 changed files with 285 additions and 2 deletions
+54
View File
@@ -0,0 +1,54 @@
import {
ForbiddenException,
Injectable,
NestMiddleware,
} from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
/**
* Extracts tenantId from the authenticated user's JWT claim and creates
* a tenant-scoped Prisma client for the request.
*
* Super-Admin can switch tenant context via x-tenant-id header (D-10).
* Per D-08: Tenant context from JWT, no URL-based routing.
*/
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private prisma: PrismaService) {}
use(req: Request, res: Response, next: NextFunction) {
const user = (req as any).user;
// No user means public route (e.g., login, health) - skip tenant context
if (!user) {
return next();
}
// Determine tenant ID
let tenantId: string | undefined = user.tenantId;
// Super-Admin can switch tenant via header
if (user.role === 'SUPER_ADMIN' && req.headers['x-tenant-id']) {
tenantId = req.headers['x-tenant-id'] as string;
}
// Non-Super-Admin users MUST have a tenant
if (!tenantId && user.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('No tenant context');
}
// Attach tenant-scoped Prisma client
if (tenantId) {
(req as any).tenantPrisma = forTenant(this.prisma, tenantId);
(req as any).tenantId = tenantId;
} else {
// Super-Admin without tenant header gets unscoped access
(req as any).tenantPrisma = this.prisma;
(req as any).tenantId = null;
}
next();
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TenantService } from './tenant.service';
@Module({
providers: [TenantService],
exports: [TenantService],
})
export class TenantModule {}
+23
View File
@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class TenantService {
constructor(private prisma: PrismaService) {}
async findAll() {
return this.prisma.tenant.findMany();
}
async findById(id: string) {
return this.prisma.tenant.findUnique({ where: { id } });
}
async create(data: { name: string; slug: string }) {
return this.prisma.tenant.create({ data });
}
async update(id: string, data: { name?: string; slug?: string; isActive?: boolean }) {
return this.prisma.tenant.update({ where: { id }, data });
}
}