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:
5
apps/api/.env.example
Normal file
5
apps/api/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sdi_saas?schema=public"
|
||||
CORS_ORIGIN=http://localhost:3001
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
26
apps/api/Dockerfile
Normal file
26
apps/api/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN npm install -g pnpm@8.15.0
|
||||
|
||||
FROM base AS dependencies
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
COPY packages/database/package.json ./packages/database/
|
||||
COPY packages/shared-types/package.json ./packages/shared-types/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm --filter @sdi/database db:generate
|
||||
RUN pnpm --filter @sdi/api build
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=build /app/packages ./packages
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/api/dist/main.js"]
|
||||
8
apps/api/nest-cli.json
Normal file
8
apps/api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
50
apps/api/package.json
Normal file
50
apps/api/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@sdi/api",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/swagger": "^7.2.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@sdi/database": "workspace:*",
|
||||
"@sdi/shared-types": "workspace:*",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
32
apps/api/src/app.module.ts
Normal file
32
apps/api/src/app.module.ts
Normal 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 {}
|
||||
9
apps/api/src/database/database.module.ts
Normal file
9
apps/api/src/database/database.module.ts
Normal 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 {}
|
||||
13
apps/api/src/database/prisma.service.ts
Normal file
13
apps/api/src/database/prisma.service.ts
Normal 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
43
apps/api/src/main.ts
Normal 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();
|
||||
8
apps/api/src/modules/audit/audit.module.ts
Normal file
8
apps/api/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
48
apps/api/src/modules/audit/audit.service.ts
Normal file
48
apps/api/src/modules/audit/audit.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
4
apps/api/src/modules/auth/auth.module.ts
Normal file
4
apps/api/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class AuthModule {}
|
||||
28
apps/api/src/modules/endpoints/endpoints.controller.ts
Normal file
28
apps/api/src/modules/endpoints/endpoints.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/endpoints/endpoints.module.ts
Normal file
10
apps/api/src/modules/endpoints/endpoints.module.ts
Normal 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 {}
|
||||
26
apps/api/src/modules/endpoints/endpoints.service.ts
Normal file
26
apps/api/src/modules/endpoints/endpoints.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
34
apps/api/src/modules/orders/dto/create-order.dto.ts
Normal file
34
apps/api/src/modules/orders/dto/create-order.dto.ts
Normal 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;
|
||||
}
|
||||
42
apps/api/src/modules/orders/orders.controller.ts
Normal file
42
apps/api/src/modules/orders/orders.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/modules/orders/orders.module.ts
Normal file
12
apps/api/src/modules/orders/orders.module.ts
Normal 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 {}
|
||||
119
apps/api/src/modules/orders/orders.service.ts
Normal file
119
apps/api/src/modules/orders/orders.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
apps/api/src/modules/providers/providers.controller.ts
Normal file
21
apps/api/src/modules/providers/providers.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/providers/providers.module.ts
Normal file
10
apps/api/src/modules/providers/providers.module.ts
Normal 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 {}
|
||||
28
apps/api/src/modules/providers/providers.service.ts
Normal file
28
apps/api/src/modules/providers/providers.service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
24
apps/api/src/modules/quotes/dto/create-quote.dto.ts
Normal file
24
apps/api/src/modules/quotes/dto/create-quote.dto.ts
Normal 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;
|
||||
}
|
||||
31
apps/api/src/modules/quotes/quotes.controller.ts
Normal file
31
apps/api/src/modules/quotes/quotes.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/quotes/quotes.module.ts
Normal file
10
apps/api/src/modules/quotes/quotes.module.ts
Normal 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 {}
|
||||
49
apps/api/src/modules/quotes/quotes.service.ts
Normal file
49
apps/api/src/modules/quotes/quotes.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
38
apps/api/src/modules/services/services.controller.ts
Normal file
38
apps/api/src/modules/services/services.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/services/services.module.ts
Normal file
10
apps/api/src/modules/services/services.module.ts
Normal 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 {}
|
||||
76
apps/api/src/modules/services/services.service.ts
Normal file
76
apps/api/src/modules/services/services.service.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
17
apps/api/src/modules/tenants/dto/create-tenant.dto.ts
Normal file
17
apps/api/src/modules/tenants/dto/create-tenant.dto.ts
Normal 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;
|
||||
}
|
||||
34
apps/api/src/modules/tenants/tenants.controller.ts
Normal file
34
apps/api/src/modules/tenants/tenants.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/tenants/tenants.module.ts
Normal file
10
apps/api/src/modules/tenants/tenants.module.ts
Normal 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 {}
|
||||
52
apps/api/src/modules/tenants/tenants.service.ts
Normal file
52
apps/api/src/modules/tenants/tenants.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
23
apps/api/src/modules/users/users.controller.ts
Normal file
23
apps/api/src/modules/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/users/users.module.ts
Normal file
10
apps/api/src/modules/users/users.module.ts
Normal 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 {}
|
||||
21
apps/api/src/modules/users/users.service.ts
Normal file
21
apps/api/src/modules/users/users.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
28
apps/api/tsconfig.json
Normal file
28
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@sdi/database": ["../../packages/database/src"],
|
||||
"@sdi/shared-types": ["../../packages/shared-types/src"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
3
apps/worker/.env.example
Normal file
3
apps/worker/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
NODE_ENV=development
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/sdi_saas?schema=public"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
25
apps/worker/Dockerfile
Normal file
25
apps/worker/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN npm install -g pnpm@8.15.0
|
||||
|
||||
FROM base AS dependencies
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY apps/worker/package.json ./apps/worker/
|
||||
COPY packages/database/package.json ./packages/database/
|
||||
COPY packages/shared-types/package.json ./packages/shared-types/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm --filter @sdi/database db:generate
|
||||
RUN pnpm --filter @sdi/worker build
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/worker/dist ./apps/worker/dist
|
||||
COPY --from=build /app/packages ./packages
|
||||
COPY apps/worker/package.json ./apps/worker/
|
||||
CMD ["node", "apps/worker/dist/main.js"]
|
||||
20
apps/worker/package.json
Normal file
20
apps/worker/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@sdi/worker",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sdi/database": "workspace:*",
|
||||
"@sdi/shared-types": "workspace:*",
|
||||
"bullmq": "^5.1.0",
|
||||
"ioredis": "^5.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
24
apps/worker/src/adapters/adapter-registry.ts
Normal file
24
apps/worker/src/adapters/adapter-registry.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ProviderAdapter } from '@sdi/shared-types';
|
||||
import { AwsAdapter } from './aws-adapter';
|
||||
import { AzureAdapter } from './azure-adapter';
|
||||
|
||||
export class AdapterRegistry {
|
||||
private adapters: Map<string, ProviderAdapter> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.adapters.set('aws', new AwsAdapter());
|
||||
this.adapters.set('azure', new AzureAdapter());
|
||||
}
|
||||
|
||||
get(providerType: string): ProviderAdapter {
|
||||
const adapter = this.adapters.get(providerType);
|
||||
if (!adapter) {
|
||||
throw new Error(`No adapter found for provider type: ${providerType}`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
register(providerType: string, adapter: ProviderAdapter) {
|
||||
this.adapters.set(providerType, adapter);
|
||||
}
|
||||
}
|
||||
96
apps/worker/src/adapters/aws-adapter.ts
Normal file
96
apps/worker/src/adapters/aws-adapter.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
ProviderAdapter,
|
||||
ServiceIntent,
|
||||
ValidationResult,
|
||||
QuoteResult,
|
||||
ProvisionRequest,
|
||||
ProvisionResponse,
|
||||
ModifyRequest,
|
||||
ModifyResponse,
|
||||
ActionResult,
|
||||
ServiceStatus,
|
||||
} from '@sdi/shared-types';
|
||||
|
||||
export class AwsAdapter implements ProviderAdapter {
|
||||
async validate(payload: ServiceIntent): Promise<ValidationResult> {
|
||||
// TODO: Implement AWS Direct Connect validation
|
||||
// - Check if source/target endpoints are valid
|
||||
// - Verify bandwidth is supported
|
||||
// - Check location availability
|
||||
|
||||
console.log('AWS: Validating service intent', payload);
|
||||
|
||||
// Mock validation
|
||||
return {
|
||||
ok: true,
|
||||
warnings: ['AWS adapter is in mock mode'],
|
||||
};
|
||||
}
|
||||
|
||||
async quote(payload: ServiceIntent): Promise<QuoteResult> {
|
||||
// TODO: Implement AWS pricing calculation
|
||||
// - Get Direct Connect pricing for bandwidth
|
||||
// - Calculate port hours
|
||||
// - Add data transfer costs
|
||||
|
||||
console.log('AWS: Generating quote', payload);
|
||||
|
||||
return {
|
||||
monthlyRecurring: payload.bandwidthMbps * 0.05,
|
||||
setupFee: 500,
|
||||
currency: 'USD',
|
||||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
async provision(payload: ProvisionRequest): Promise<ProvisionResponse> {
|
||||
// TODO: Implement AWS Direct Connect provisioning
|
||||
// - Create connection via AWS SDK
|
||||
// - Configure virtual interface
|
||||
// - Set up BGP peering
|
||||
|
||||
console.log('AWS: Provisioning service', payload);
|
||||
|
||||
// Mock provisioning
|
||||
const externalServiceId = `dx-${Date.now()}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
externalServiceId,
|
||||
metadata: {
|
||||
connectionId: externalServiceId,
|
||||
vlan: 100,
|
||||
bgpAsn: 65000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(externalId: string): Promise<ServiceStatus> {
|
||||
// TODO: Implement status check via AWS SDK
|
||||
console.log('AWS: Getting status for', externalId);
|
||||
return 'active';
|
||||
}
|
||||
|
||||
async modify(payload: ModifyRequest): Promise<ModifyResponse> {
|
||||
// TODO: Implement bandwidth modification
|
||||
console.log('AWS: Modifying service', payload);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async suspend(externalId: string): Promise<ActionResult> {
|
||||
// TODO: Implement service suspension
|
||||
console.log('AWS: Suspending service', externalId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async terminate(externalId: string): Promise<ActionResult> {
|
||||
// TODO: Implement connection deletion via AWS SDK
|
||||
console.log('AWS: Terminating service', externalId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async syncInventory(): Promise<void> {
|
||||
// TODO: Sync AWS Direct Connect inventory
|
||||
console.log('AWS: Syncing inventory');
|
||||
}
|
||||
}
|
||||
94
apps/worker/src/adapters/azure-adapter.ts
Normal file
94
apps/worker/src/adapters/azure-adapter.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
ProviderAdapter,
|
||||
ServiceIntent,
|
||||
ValidationResult,
|
||||
QuoteResult,
|
||||
ProvisionRequest,
|
||||
ProvisionResponse,
|
||||
ModifyRequest,
|
||||
ModifyResponse,
|
||||
ActionResult,
|
||||
ServiceStatus,
|
||||
} from '@sdi/shared-types';
|
||||
|
||||
export class AzureAdapter implements ProviderAdapter {
|
||||
async validate(payload: ServiceIntent): Promise<ValidationResult> {
|
||||
// TODO: Implement Azure ExpressRoute validation
|
||||
// - Check peering location availability
|
||||
// - Verify SKU and bandwidth tier
|
||||
// - Validate circuit configuration
|
||||
|
||||
console.log('Azure: Validating service intent', payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
warnings: ['Azure adapter is in mock mode'],
|
||||
};
|
||||
}
|
||||
|
||||
async quote(payload: ServiceIntent): Promise<QuoteResult> {
|
||||
// TODO: Implement Azure ExpressRoute pricing
|
||||
// - Get circuit pricing by SKU
|
||||
// - Calculate metered vs unlimited data
|
||||
// - Add premium add-on costs if needed
|
||||
|
||||
console.log('Azure: Generating quote', payload);
|
||||
|
||||
return {
|
||||
monthlyRecurring: payload.bandwidthMbps * 0.06,
|
||||
setupFee: 600,
|
||||
currency: 'USD',
|
||||
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
async provision(payload: ProvisionRequest): Promise<ProvisionResponse> {
|
||||
// TODO: Implement Azure ExpressRoute provisioning
|
||||
// - Create ExpressRoute circuit
|
||||
// - Configure peering (private/Microsoft)
|
||||
// - Set up route filters
|
||||
|
||||
console.log('Azure: Provisioning service', payload);
|
||||
|
||||
const externalServiceId = `er-${Date.now()}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
externalServiceId,
|
||||
metadata: {
|
||||
circuitId: externalServiceId,
|
||||
serviceKey: `sk-${Date.now()}`,
|
||||
peeringLocation: 'mock-location',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(externalId: string): Promise<ServiceStatus> {
|
||||
// TODO: Check circuit provisioning state via Azure SDK
|
||||
console.log('Azure: Getting status for', externalId);
|
||||
return 'active';
|
||||
}
|
||||
|
||||
async modify(payload: ModifyRequest): Promise<ModifyResponse> {
|
||||
// TODO: Implement circuit modification
|
||||
console.log('Azure: Modifying service', payload);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async suspend(externalId: string): Promise<ActionResult> {
|
||||
// TODO: Implement circuit suspension
|
||||
console.log('Azure: Suspending service', externalId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async terminate(externalId: string): Promise<ActionResult> {
|
||||
// TODO: Delete ExpressRoute circuit via Azure SDK
|
||||
console.log('Azure: Terminating service', externalId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async syncInventory(): Promise<void> {
|
||||
// TODO: Sync Azure ExpressRoute inventory
|
||||
console.log('Azure: Syncing inventory');
|
||||
}
|
||||
}
|
||||
62
apps/worker/src/main.ts
Normal file
62
apps/worker/src/main.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Worker, Queue } from 'bullmq';
|
||||
import { PrismaClient } from '@sdi/database';
|
||||
import { ProvisioningOrchestrator } from './orchestration/provisioning-orchestrator';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const connection = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const orchestrator = new ProvisioningOrchestrator(prisma);
|
||||
|
||||
// Provisioning queue worker
|
||||
const provisioningWorker = new Worker(
|
||||
'provisioning',
|
||||
async (job) => {
|
||||
console.log(`Processing job ${job.id}: ${job.name}`);
|
||||
|
||||
switch (job.name) {
|
||||
case 'provision-order':
|
||||
await orchestrator.provisionOrder(job.data.orderId);
|
||||
break;
|
||||
case 'modify-service':
|
||||
await orchestrator.modifyService(job.data.serviceId, job.data.changes);
|
||||
break;
|
||||
case 'suspend-service':
|
||||
await orchestrator.suspendService(job.data.serviceId);
|
||||
break;
|
||||
case 'terminate-service':
|
||||
await orchestrator.terminateService(job.data.serviceId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${job.name}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 5,
|
||||
limiter: {
|
||||
max: 10,
|
||||
duration: 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
provisioningWorker.on('completed', (job) => {
|
||||
console.log(`✓ Job ${job.id} completed successfully`);
|
||||
});
|
||||
|
||||
provisioningWorker.on('failed', (job, err) => {
|
||||
console.error(`✗ Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, closing worker...');
|
||||
await provisioningWorker.close();
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
console.log('🔧 Worker started and listening for jobs...');
|
||||
168
apps/worker/src/orchestration/provisioning-orchestrator.ts
Normal file
168
apps/worker/src/orchestration/provisioning-orchestrator.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { PrismaClient } from '@sdi/database';
|
||||
import { ServiceOrderStatus } from '@sdi/shared-types';
|
||||
import { AdapterRegistry } from '../adapters/adapter-registry';
|
||||
|
||||
export class ProvisioningOrchestrator {
|
||||
private adapterRegistry: AdapterRegistry;
|
||||
|
||||
constructor(private prisma: PrismaClient) {
|
||||
this.adapterRegistry = new AdapterRegistry();
|
||||
}
|
||||
|
||||
async provisionOrder(orderId: string) {
|
||||
const order = await this.prisma.serviceOrder.findUnique({
|
||||
where: { id: orderId },
|
||||
include: {
|
||||
sourceEndpoint: true,
|
||||
targetEndpoint: true,
|
||||
provider: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new Error(`Order ${orderId} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Update status to validating
|
||||
await this.updateOrderStatus(orderId, 'validating');
|
||||
|
||||
// Get provider adapter
|
||||
const adapter = this.adapterRegistry.get(order.provider.type);
|
||||
|
||||
// Validate service intent
|
||||
const validation = await adapter.validate({
|
||||
sourceEndpointId: order.sourceEndpointId,
|
||||
targetEndpointId: order.targetEndpointId,
|
||||
bandwidthMbps: order.bandwidthMbps,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
await this.updateOrderStatus(orderId, 'failed');
|
||||
await this.logAudit(orderId, 'validation_failed', { errors: validation.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update status to provisioning
|
||||
await this.updateOrderStatus(orderId, 'provisioning');
|
||||
|
||||
// Provision service
|
||||
const result = await adapter.provision({
|
||||
sourceEndpointId: order.sourceEndpointId,
|
||||
targetEndpointId: order.targetEndpointId,
|
||||
bandwidthMbps: order.bandwidthMbps,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
await this.updateOrderStatus(orderId, 'failed');
|
||||
await this.logAudit(orderId, 'provision_failed', { error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update order with external reference
|
||||
await this.prisma.serviceOrder.update({
|
||||
where: { id: orderId },
|
||||
data: { externalReference: result.externalServiceId },
|
||||
});
|
||||
|
||||
// Create service record
|
||||
const service = await this.prisma.service.create({
|
||||
data: {
|
||||
orderId,
|
||||
tenantId: order.tenantId,
|
||||
status: 'active',
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update order status to active
|
||||
await this.updateOrderStatus(orderId, 'active');
|
||||
await this.logAudit(orderId, 'service_active', { serviceId: service.id });
|
||||
|
||||
console.log(`✓ Order ${orderId} provisioned successfully`);
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to provision order ${orderId}:`, error);
|
||||
await this.updateOrderStatus(orderId, 'failed');
|
||||
await this.logAudit(orderId, 'provision_error', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async modifyService(serviceId: string, changes: { bandwidthMbps?: number }) {
|
||||
const service = await this.prisma.service.findUnique({
|
||||
where: { id: serviceId },
|
||||
include: { order: { include: { provider: true } } },
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service ${serviceId} not found`);
|
||||
}
|
||||
|
||||
const adapter = this.adapterRegistry.get(service.order.provider.type);
|
||||
const result = await adapter.modify({
|
||||
externalServiceId: service.order.externalReference!,
|
||||
bandwidthMbps: changes.bandwidthMbps,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
await this.logAudit(serviceId, 'service_modified', changes);
|
||||
}
|
||||
}
|
||||
|
||||
async suspendService(serviceId: string) {
|
||||
const service = await this.prisma.service.findUnique({
|
||||
where: { id: serviceId },
|
||||
include: { order: { include: { provider: true } } },
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service ${serviceId} not found`);
|
||||
}
|
||||
|
||||
const adapter = this.adapterRegistry.get(service.order.provider.type);
|
||||
await adapter.suspend(service.order.externalReference!);
|
||||
|
||||
await this.prisma.service.update({
|
||||
where: { id: serviceId },
|
||||
data: { status: 'suspended', suspendedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async terminateService(serviceId: string) {
|
||||
const service = await this.prisma.service.findUnique({
|
||||
where: { id: serviceId },
|
||||
include: { order: { include: { provider: true } } },
|
||||
});
|
||||
|
||||
if (!service) {
|
||||
throw new Error(`Service ${serviceId} not found`);
|
||||
}
|
||||
|
||||
const adapter = this.adapterRegistry.get(service.order.provider.type);
|
||||
await adapter.terminate(service.order.externalReference!);
|
||||
|
||||
await this.prisma.service.update({
|
||||
where: { id: serviceId },
|
||||
data: { status: 'terminated', terminatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async updateOrderStatus(orderId: string, status: ServiceOrderStatus) {
|
||||
await this.prisma.serviceOrder.update({
|
||||
where: { id: orderId },
|
||||
data: { status, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
private async logAudit(aggregateId: string, eventType: string, payload: any) {
|
||||
await this.prisma.auditEvent.create({
|
||||
data: {
|
||||
aggregateType: 'order',
|
||||
aggregateId,
|
||||
eventType,
|
||||
actorType: 'system',
|
||||
payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
13
apps/worker/tsconfig.json
Normal file
13
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"paths": {
|
||||
"@sdi/database": ["../../packages/database/src"],
|
||||
"@sdi/shared-types": ["../../packages/shared-types/src"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user