Initial commit: SDI SaaS Platform foundation

- Complete monorepo structure with pnpm workspaces
- Prisma database schema with 20+ entities
- NestJS API with 9 core modules
- BullMQ orchestration worker
- AWS and Azure provider adapters
- Docker Compose infrastructure
- Complete documentation
This commit is contained in:
austindebest
2026-04-20 00:00:59 +01:00
commit d62468adf9
69 changed files with 10136 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { UsersModule } from './modules/users/users.module';
import { ProvidersModule } from './modules/providers/providers.module';
import { EndpointsModule } from './modules/endpoints/endpoints.module';
import { QuotesModule } from './modules/quotes/quotes.module';
import { OrdersModule } from './modules/orders/orders.module';
import { ServicesModule } from './modules/services/services.module';
import { AuditModule } from './modules/audit/audit.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
DatabaseModule,
AuthModule,
TenantsModule,
UsersModule,
ProvidersModule,
EndpointsModule,
QuotesModule,
OrdersModule,
ServicesModule,
AuditModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,13 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@sdi/database';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

43
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,43 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// CORS
app.enableCors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true,
});
// API prefix
app.setGlobalPrefix('api/v1');
// Swagger documentation
const config = new DocumentBuilder()
.setTitle('SDI SaaS Platform API')
.setDescription('Software-Defined Interconnection Platform API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = process.env.PORT || 3000;
await app.listen(port);
console.log(`🚀 API server running on http://localhost:${port}`);
console.log(`📚 API docs available at http://localhost:${port}/api/docs`);
}
bootstrap();

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
interface AuditLogData {
aggregateType: string;
aggregateId: string;
eventType: string;
actorId?: string;
actorType?: string;
payload: Record<string, any>;
ipAddress?: string;
userAgent?: string;
}
@Injectable()
export class AuditService {
constructor(private prisma: PrismaService) {}
async log(data: AuditLogData) {
return this.prisma.auditEvent.create({
data: {
aggregateType: data.aggregateType,
aggregateId: data.aggregateId,
eventType: data.eventType,
actorId: data.actorId,
actorType: data.actorType,
payload: data.payload,
ipAddress: data.ipAddress,
userAgent: data.userAgent,
},
});
}
async findByAggregate(aggregateType: string, aggregateId: string) {
return this.prisma.auditEvent.findMany({
where: { aggregateType, aggregateId },
orderBy: { createdAt: 'desc' },
});
}
async findByEventType(eventType: string, limit = 100) {
return this.prisma.auditEvent.findMany({
where: { eventType },
orderBy: { createdAt: 'desc' },
take: limit,
});
}
}

View File

@@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class AuthModule {}

View File

@@ -0,0 +1,28 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { EndpointsService } from './endpoints.service';
@ApiTags('endpoints')
@Controller('endpoints')
export class EndpointsController {
constructor(private readonly endpointsService: EndpointsService) {}
@Get()
@ApiOperation({ summary: 'Get all endpoints' })
@ApiQuery({ name: 'providerId', required: false })
@ApiQuery({ name: 'kind', required: false })
@ApiQuery({ name: 'region', required: false })
findAll(
@Query('providerId') providerId?: string,
@Query('kind') kind?: string,
@Query('region') region?: string,
) {
return this.endpointsService.findAll({ providerId, kind, region });
}
@Get(':id')
@ApiOperation({ summary: 'Get endpoint by ID' })
findOne(@Param('id') id: string) {
return this.endpointsService.findOne(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { EndpointsController } from './endpoints.controller';
import { EndpointsService } from './endpoints.service';
@Module({
controllers: [EndpointsController],
providers: [EndpointsService],
exports: [EndpointsService],
})
export class EndpointsModule {}

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
@Injectable()
export class EndpointsService {
constructor(private prisma: PrismaService) {}
async findAll(filters?: { providerId?: string; kind?: string; region?: string }) {
return this.prisma.endpoint.findMany({
where: {
...(filters?.providerId && { providerId: filters.providerId }),
...(filters?.kind && { kind: filters.kind }),
...(filters?.region && { region: filters.region }),
status: 'available',
},
include: { provider: true },
});
}
async findOne(id: string) {
return this.prisma.endpoint.findUnique({
where: { id },
include: { provider: true },
});
}
}

View File

@@ -0,0 +1,34 @@
import { IsString, IsNotEmpty, IsInt, Min, IsOptional, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateOrderDto {
@ApiProperty()
@IsUUID()
@IsNotEmpty()
productOfferingId: string;
@ApiProperty()
@IsUUID()
@IsNotEmpty()
providerId: string;
@ApiProperty()
@IsUUID()
@IsNotEmpty()
sourceEndpointId: string;
@ApiProperty()
@IsUUID()
@IsNotEmpty()
targetEndpointId: string;
@ApiProperty({ example: 1000 })
@IsInt()
@Min(1)
bandwidthMbps: number;
@ApiProperty({ required: false })
@IsUUID()
@IsOptional()
quoteId?: string;
}

View File

@@ -0,0 +1,42 @@
import { Controller, Get, Post, Body, Param, Query, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
@ApiTags('orders')
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
@ApiOperation({ summary: 'Create a new service order' })
create(@Body() createOrderDto: CreateOrderDto) {
// TODO: Extract tenantId and userId from JWT token
const tenantId = 'mock-tenant-id';
const userId = 'mock-user-id';
return this.ordersService.create(tenantId, userId, createOrderDto);
}
@Get()
@ApiOperation({ summary: 'Get all orders for tenant' })
@ApiQuery({ name: 'status', required: false })
findAll(@Query('status') status?: string) {
const tenantId = 'mock-tenant-id';
return this.ordersService.findAll(tenantId, { status });
}
@Get(':id')
@ApiOperation({ summary: 'Get order by ID' })
findOne(@Param('id') id: string) {
const tenantId = 'mock-tenant-id';
return this.ordersService.findOne(id, tenantId);
}
@Delete(':id')
@ApiOperation({ summary: 'Cancel an order' })
cancel(@Param('id') id: string) {
const tenantId = 'mock-tenant-id';
const userId = 'mock-user-id';
return this.ordersService.cancel(id, tenantId, userId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { AuditModule } from '../audit/audit.module';
@Module({
imports: [AuditModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}

View File

@@ -0,0 +1,119 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { AuditService } from '../audit/audit.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { ServiceOrderStatus } from '@sdi/shared-types';
@Injectable()
export class OrdersService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
) {}
async create(tenantId: string, userId: string, data: CreateOrderDto) {
// Validate endpoints exist
const [sourceEndpoint, targetEndpoint] = await Promise.all([
this.prisma.endpoint.findUnique({ where: { id: data.sourceEndpointId } }),
this.prisma.endpoint.findUnique({ where: { id: data.targetEndpointId } }),
]);
if (!sourceEndpoint || !targetEndpoint) {
throw new BadRequestException('Invalid endpoint IDs');
}
const order = await this.prisma.serviceOrder.create({
data: {
tenantId,
userId,
productOfferingId: data.productOfferingId,
providerId: data.providerId,
sourceEndpointId: data.sourceEndpointId,
targetEndpointId: data.targetEndpointId,
bandwidthMbps: data.bandwidthMbps,
status: 'draft',
quoteId: data.quoteId,
},
include: {
sourceEndpoint: true,
targetEndpoint: true,
productOffering: true,
},
});
await this.auditService.log({
aggregateType: 'order',
aggregateId: order.id,
eventType: 'order.created',
actorId: userId,
actorType: 'user',
payload: { orderId: order.id, status: 'draft' },
});
return order;
}
async findAll(tenantId: string, filters?: { status?: string }) {
return this.prisma.serviceOrder.findMany({
where: {
tenantId,
...(filters?.status && { status: filters.status }),
},
include: {
sourceEndpoint: true,
targetEndpoint: true,
productOffering: true,
service: true,
},
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string, tenantId: string) {
const order = await this.prisma.serviceOrder.findFirst({
where: { id, tenantId },
include: {
sourceEndpoint: true,
targetEndpoint: true,
productOffering: true,
provider: true,
service: true,
provisioningTasks: { orderBy: { createdAt: 'desc' } },
},
});
if (!order) {
throw new NotFoundException(`Order ${id} not found`);
}
return order;
}
async updateStatus(orderId: string, status: ServiceOrderStatus, userId?: string) {
const order = await this.prisma.serviceOrder.update({
where: { id: orderId },
data: { status, updatedAt: new Date() },
});
await this.auditService.log({
aggregateType: 'order',
aggregateId: orderId,
eventType: 'order.status_changed',
actorId: userId,
actorType: userId ? 'user' : 'system',
payload: { orderId, status },
});
return order;
}
async cancel(id: string, tenantId: string, userId: string) {
const order = await this.findOne(id, tenantId);
if (!['draft', 'submitted', 'quoted'].includes(order.status)) {
throw new BadRequestException('Order cannot be cancelled in current status');
}
return this.updateStatus(id, 'terminated', userId);
}
}

View File

@@ -0,0 +1,21 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ProvidersService } from './providers.service';
@ApiTags('providers')
@Controller('providers')
export class ProvidersController {
constructor(private readonly providersService: ProvidersService) {}
@Get()
@ApiOperation({ summary: 'Get all providers' })
findAll() {
return this.providersService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get provider by ID' })
findOne(@Param('id') id: string) {
return this.providersService.findOne(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ProvidersController } from './providers.controller';
import { ProvidersService } from './providers.service';
@Module({
controllers: [ProvidersController],
providers: [ProvidersService],
exports: [ProvidersService],
})
export class ProvidersModule {}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
@Injectable()
export class ProvidersService {
constructor(private prisma: PrismaService) {}
async findAll() {
return this.prisma.provider.findMany({
include: {
endpoints: true,
productOfferings: true,
_count: { select: { orders: true } },
},
});
}
async findOne(id: string) {
return this.prisma.provider.findUnique({
where: { id },
include: {
endpoints: true,
productOfferings: true,
accounts: true,
},
});
}
}

View File

@@ -0,0 +1,24 @@
import { IsUUID, IsNotEmpty, IsInt, Min } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateQuoteDto {
@ApiProperty()
@IsUUID()
@IsNotEmpty()
productOfferingId: string;
@ApiProperty()
@IsUUID()
@IsNotEmpty()
sourceEndpointId: string;
@ApiProperty()
@IsUUID()
@IsNotEmpty()
targetEndpointId: string;
@ApiProperty({ example: 1000 })
@IsInt()
@Min(1)
bandwidthMbps: number;
}

View File

@@ -0,0 +1,31 @@
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { QuotesService } from './quotes.service';
import { CreateQuoteDto } from './dto/create-quote.dto';
@ApiTags('quotes')
@Controller('quotes')
export class QuotesController {
constructor(private readonly quotesService: QuotesService) {}
@Post()
@ApiOperation({ summary: 'Create a new quote' })
create(@Body() createQuoteDto: CreateQuoteDto) {
const tenantId = 'mock-tenant-id';
return this.quotesService.create(tenantId, createQuoteDto);
}
@Get()
@ApiOperation({ summary: 'Get all quotes' })
findAll() {
const tenantId = 'mock-tenant-id';
return this.quotesService.findAll(tenantId);
}
@Get(':id')
@ApiOperation({ summary: 'Get quote by ID' })
findOne(@Param('id') id: string) {
const tenantId = 'mock-tenant-id';
return this.quotesService.findOne(id, tenantId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { QuotesController } from './quotes.controller';
import { QuotesService } from './quotes.service';
@Module({
controllers: [QuotesController],
providers: [QuotesService],
exports: [QuotesService],
})
export class QuotesModule {}

View File

@@ -0,0 +1,49 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { CreateQuoteDto } from './dto/create-quote.dto';
@Injectable()
export class QuotesService {
constructor(private prisma: PrismaService) {}
async create(tenantId: string, data: CreateQuoteDto) {
// Simple pricing calculation - in production, use pricing engine
const monthlyRecurring = data.bandwidthMbps * 0.05; // $0.05 per Mbps
const setupFee = 500; // Flat setup fee
const validUntil = new Date();
validUntil.setDate(validUntil.getDate() + 30); // Valid for 30 days
return this.prisma.quote.create({
data: {
tenantId,
productOfferingId: data.productOfferingId,
sourceEndpointId: data.sourceEndpointId,
targetEndpointId: data.targetEndpointId,
bandwidthMbps: data.bandwidthMbps,
monthlyRecurring,
setupFee,
validUntil,
status: 'valid',
},
});
}
async findAll(tenantId: string) {
return this.prisma.quote.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string, tenantId: string) {
const quote = await this.prisma.quote.findFirst({
where: { id, tenantId },
});
if (!quote) {
throw new NotFoundException(`Quote ${id} not found`);
}
return quote;
}
}

View File

@@ -0,0 +1,38 @@
import { Controller, Get, Post, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { ServicesService } from './services.service';
@ApiTags('services')
@Controller('services')
export class ServicesController {
constructor(private readonly servicesService: ServicesService) {}
@Get()
@ApiOperation({ summary: 'Get all services' })
@ApiQuery({ name: 'status', required: false })
findAll(@Query('status') status?: string) {
const tenantId = 'mock-tenant-id';
return this.servicesService.findAll(tenantId, { status });
}
@Get(':id')
@ApiOperation({ summary: 'Get service by ID' })
findOne(@Param('id') id: string) {
const tenantId = 'mock-tenant-id';
return this.servicesService.findOne(id, tenantId);
}
@Post(':id/suspend')
@ApiOperation({ summary: 'Suspend a service' })
suspend(@Param('id') id: string) {
const tenantId = 'mock-tenant-id';
return this.servicesService.suspend(id, tenantId);
}
@Post(':id/terminate')
@ApiOperation({ summary: 'Terminate a service' })
terminate(@Param('id') id: string) {
const tenantId = 'mock-tenant-id';
return this.servicesService.terminate(id, tenantId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ServicesController } from './services.controller';
import { ServicesService } from './services.service';
@Module({
controllers: [ServicesController],
providers: [ServicesService],
exports: [ServicesService],
})
export class ServicesModule {}

View File

@@ -0,0 +1,76 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
@Injectable()
export class ServicesService {
constructor(private prisma: PrismaService) {}
async findAll(tenantId: string, filters?: { status?: string }) {
return this.prisma.service.findMany({
where: {
tenantId,
...(filters?.status && { status: filters.status }),
},
include: {
order: {
include: {
sourceEndpoint: true,
targetEndpoint: true,
productOffering: true,
},
},
usageRecords: { take: 10, orderBy: { createdAt: 'desc' } },
},
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string, tenantId: string) {
const service = await this.prisma.service.findFirst({
where: { id, tenantId },
include: {
order: {
include: {
sourceEndpoint: true,
targetEndpoint: true,
productOffering: true,
provider: true,
provisioningTasks: { orderBy: { createdAt: 'desc' } },
},
},
usageRecords: { orderBy: { createdAt: 'desc' } },
inventoryRecords: true,
},
});
if (!service) {
throw new NotFoundException(`Service ${id} not found`);
}
return service;
}
async suspend(id: string, tenantId: string) {
const service = await this.findOne(id, tenantId);
return this.prisma.service.update({
where: { id },
data: {
status: 'suspended',
suspendedAt: new Date(),
},
});
}
async terminate(id: string, tenantId: string) {
const service = await this.findOne(id, tenantId);
return this.prisma.service.update({
where: { id },
data: {
status: 'terminated',
terminatedAt: new Date(),
},
});
}
}

View File

@@ -0,0 +1,17 @@
import { IsString, IsNotEmpty, Matches } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateTenantDto {
@ApiProperty({ example: 'Acme Corporation' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ example: 'acme-corp' })
@IsString()
@IsNotEmpty()
@Matches(/^[a-z0-9-]+$/, {
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
})
slug: string;
}

View File

@@ -0,0 +1,34 @@
import { Controller, Get, Post, Body, Param, Patch } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { TenantsService } from './tenants.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
@ApiTags('tenants')
@Controller('tenants')
export class TenantsController {
constructor(private readonly tenantsService: TenantsService) {}
@Post()
@ApiOperation({ summary: 'Create a new tenant' })
create(@Body() createTenantDto: CreateTenantDto) {
return this.tenantsService.create(createTenantDto);
}
@Get()
@ApiOperation({ summary: 'Get all tenants' })
findAll() {
return this.tenantsService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get tenant by ID' })
findOne(@Param('id') id: string) {
return this.tenantsService.findOne(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update tenant' })
update(@Param('id') id: string, @Body() updateTenantDto: Partial<CreateTenantDto>) {
return this.tenantsService.update(id, updateTenantDto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TenantsController } from './tenants.controller';
import { TenantsService } from './tenants.service';
@Module({
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}

View File

@@ -0,0 +1,52 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { CreateTenantDto } from './dto/create-tenant.dto';
@Injectable()
export class TenantsService {
constructor(private prisma: PrismaService) {}
async create(data: CreateTenantDto) {
return this.prisma.tenant.create({
data: {
name: data.name,
slug: data.slug,
status: 'active',
},
});
}
async findAll() {
return this.prisma.tenant.findMany({
include: {
_count: {
select: { users: true, orders: true, services: true },
},
},
});
}
async findOne(id: string) {
const tenant = await this.prisma.tenant.findUnique({
where: { id },
include: {
users: true,
orders: { take: 10, orderBy: { createdAt: 'desc' } },
services: { take: 10, orderBy: { createdAt: 'desc' } },
},
});
if (!tenant) {
throw new NotFoundException(`Tenant ${id} not found`);
}
return tenant;
}
async update(id: string, data: Partial<CreateTenantDto>) {
return this.prisma.tenant.update({
where: { id },
data,
});
}
}

View File

@@ -0,0 +1,23 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { UsersService } from './users.service';
@ApiTags('users')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
@ApiOperation({ summary: 'Get all users' })
findAll() {
const tenantId = 'mock-tenant-id';
return this.usersService.findAll(tenantId);
}
@Get(':id')
@ApiOperation({ summary: 'Get user by ID' })
findOne(@Param('id') id: string) {
const tenantId = 'mock-tenant-id';
return this.usersService.findOne(id, tenantId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async findAll(tenantId: string) {
return this.prisma.user.findMany({
where: { tenantId },
include: { roles: true },
});
}
async findOne(id: string, tenantId: string) {
return this.prisma.user.findFirst({
where: { id, tenantId },
include: { roles: true, orders: true },
});
}
}