Neo platform - First Version
This commit is contained in:
11
backend/src/app-builder/app-builder.module.ts
Normal file
11
backend/src/app-builder/app-builder.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppBuilderService } from './app-builder.service';
|
||||
import { RuntimeAppController } from './runtime-app.controller';
|
||||
import { SetupAppController } from './setup-app.controller';
|
||||
|
||||
@Module({
|
||||
providers: [AppBuilderService],
|
||||
controllers: [RuntimeAppController, SetupAppController],
|
||||
exports: [AppBuilderService],
|
||||
})
|
||||
export class AppBuilderModule {}
|
||||
242
backend/src/app-builder/app-builder.service.ts
Normal file
242
backend/src/app-builder/app-builder.service.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppBuilderService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// Runtime endpoints
|
||||
async getApps(tenantId: string, userId: string) {
|
||||
// For now, return all active apps for the tenant
|
||||
// In production, you'd filter by user permissions
|
||||
return this.prisma.app.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
pages: {
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { label: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getApp(tenantId: string, slug: string, userId: string) {
|
||||
const app = await this.prisma.app.findUnique({
|
||||
where: {
|
||||
tenantId_slug: {
|
||||
tenantId,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
pages: {
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException(`App ${slug} not found`);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async getPage(
|
||||
tenantId: string,
|
||||
appSlug: string,
|
||||
pageSlug: string,
|
||||
userId: string,
|
||||
) {
|
||||
const app = await this.getApp(tenantId, appSlug, userId);
|
||||
|
||||
const page = await this.prisma.appPage.findFirst({
|
||||
where: {
|
||||
appId: app.id,
|
||||
slug: pageSlug,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
object: {
|
||||
include: {
|
||||
fields: {
|
||||
where: { isActive: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException(`Page ${pageSlug} not found`);
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
// Setup endpoints
|
||||
async getAllApps(tenantId: string) {
|
||||
return this.prisma.app.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
pages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { label: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getAppForSetup(tenantId: string, slug: string) {
|
||||
const app = await this.prisma.app.findUnique({
|
||||
where: {
|
||||
tenantId_slug: {
|
||||
tenantId,
|
||||
slug,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
pages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException(`App ${slug} not found`);
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async createApp(
|
||||
tenantId: string,
|
||||
data: {
|
||||
slug: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
},
|
||||
) {
|
||||
return this.prisma.app.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateApp(
|
||||
tenantId: string,
|
||||
slug: string,
|
||||
data: {
|
||||
label?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const app = await this.getAppForSetup(tenantId, slug);
|
||||
|
||||
return this.prisma.app.update({
|
||||
where: { id: app.id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async createPage(
|
||||
tenantId: string,
|
||||
appSlug: string,
|
||||
data: {
|
||||
slug: string;
|
||||
label: string;
|
||||
type: string;
|
||||
objectApiName?: string;
|
||||
config?: any;
|
||||
sortOrder?: number;
|
||||
},
|
||||
) {
|
||||
const app = await this.getAppForSetup(tenantId, appSlug);
|
||||
|
||||
// If objectApiName is provided, find the object
|
||||
let objectId: string | undefined;
|
||||
if (data.objectApiName) {
|
||||
const obj = await this.prisma.objectDefinition.findUnique({
|
||||
where: {
|
||||
tenantId_apiName: {
|
||||
tenantId,
|
||||
apiName: data.objectApiName,
|
||||
},
|
||||
},
|
||||
});
|
||||
objectId = obj?.id;
|
||||
}
|
||||
|
||||
return this.prisma.appPage.create({
|
||||
data: {
|
||||
appId: app.id,
|
||||
slug: data.slug,
|
||||
label: data.label,
|
||||
type: data.type,
|
||||
objectApiName: data.objectApiName,
|
||||
objectId,
|
||||
config: data.config,
|
||||
sortOrder: data.sortOrder || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updatePage(
|
||||
tenantId: string,
|
||||
appSlug: string,
|
||||
pageSlug: string,
|
||||
data: {
|
||||
label?: string;
|
||||
type?: string;
|
||||
objectApiName?: string;
|
||||
config?: any;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const app = await this.getAppForSetup(tenantId, appSlug);
|
||||
|
||||
const page = await this.prisma.appPage.findFirst({
|
||||
where: {
|
||||
appId: app.id,
|
||||
slug: pageSlug,
|
||||
},
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException(`Page ${pageSlug} not found`);
|
||||
}
|
||||
|
||||
// If objectApiName is provided, find the object
|
||||
let objectId: string | undefined;
|
||||
if (data.objectApiName) {
|
||||
const obj = await this.prisma.objectDefinition.findUnique({
|
||||
where: {
|
||||
tenantId_apiName: {
|
||||
tenantId,
|
||||
apiName: data.objectApiName,
|
||||
},
|
||||
},
|
||||
});
|
||||
objectId = obj?.id;
|
||||
}
|
||||
|
||||
return this.prisma.appPage.update({
|
||||
where: { id: page.id },
|
||||
data: {
|
||||
...data,
|
||||
objectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
45
backend/src/app-builder/runtime-app.controller.ts
Normal file
45
backend/src/app-builder/runtime-app.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AppBuilderService } from './app-builder.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
|
||||
@Controller('runtime/apps')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class RuntimeAppController {
|
||||
constructor(private appBuilderService: AppBuilderService) {}
|
||||
|
||||
@Get()
|
||||
async getApps(@TenantId() tenantId: string, @CurrentUser() user: any) {
|
||||
return this.appBuilderService.getApps(tenantId, user.userId);
|
||||
}
|
||||
|
||||
@Get(':appSlug')
|
||||
async getApp(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('appSlug') appSlug: string,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
return this.appBuilderService.getApp(tenantId, appSlug, user.userId);
|
||||
}
|
||||
|
||||
@Get(':appSlug/pages/:pageSlug')
|
||||
async getPage(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('appSlug') appSlug: string,
|
||||
@Param('pageSlug') pageSlug: string,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
return this.appBuilderService.getPage(
|
||||
tenantId,
|
||||
appSlug,
|
||||
pageSlug,
|
||||
user.userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
69
backend/src/app-builder/setup-app.controller.ts
Normal file
69
backend/src/app-builder/setup-app.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AppBuilderService } from './app-builder.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
|
||||
@Controller('setup/apps')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SetupAppController {
|
||||
constructor(private appBuilderService: AppBuilderService) {}
|
||||
|
||||
@Get()
|
||||
async getAllApps(@TenantId() tenantId: string) {
|
||||
return this.appBuilderService.getAllApps(tenantId);
|
||||
}
|
||||
|
||||
@Get(':appSlug')
|
||||
async getApp(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('appSlug') appSlug: string,
|
||||
) {
|
||||
return this.appBuilderService.getAppForSetup(tenantId, appSlug);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createApp(@TenantId() tenantId: string, @Body() data: any) {
|
||||
return this.appBuilderService.createApp(tenantId, data);
|
||||
}
|
||||
|
||||
@Put(':appSlug')
|
||||
async updateApp(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('appSlug') appSlug: string,
|
||||
@Body() data: any,
|
||||
) {
|
||||
return this.appBuilderService.updateApp(tenantId, appSlug, data);
|
||||
}
|
||||
|
||||
@Post(':appSlug/pages')
|
||||
async createPage(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('appSlug') appSlug: string,
|
||||
@Body() data: any,
|
||||
) {
|
||||
return this.appBuilderService.createPage(tenantId, appSlug, data);
|
||||
}
|
||||
|
||||
@Put(':appSlug/pages/:pageSlug')
|
||||
async updatePage(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('appSlug') appSlug: string,
|
||||
@Param('pageSlug') pageSlug: string,
|
||||
@Body() data: any,
|
||||
) {
|
||||
return this.appBuilderService.updatePage(
|
||||
tenantId,
|
||||
appSlug,
|
||||
pageSlug,
|
||||
data,
|
||||
);
|
||||
}
|
||||
}
|
||||
23
backend/src/app.module.ts
Normal file
23
backend/src/app.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { TenantModule } from './tenant/tenant.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { RbacModule } from './rbac/rbac.module';
|
||||
import { ObjectModule } from './object/object.module';
|
||||
import { AppBuilderModule } from './app-builder/app-builder.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
PrismaModule,
|
||||
TenantModule,
|
||||
AuthModule,
|
||||
RbacModule,
|
||||
ObjectModule,
|
||||
AppBuilderModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
82
backend/src/auth/auth.controller.ts
Normal file
82
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UnauthorizedException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import { AuthService } from './auth.service';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
|
||||
class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
|
||||
class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('login')
|
||||
async login(@TenantId() tenantId: string, @Body() loginDto: LoginDto) {
|
||||
if (!tenantId) {
|
||||
throw new UnauthorizedException('Tenant ID is required');
|
||||
}
|
||||
|
||||
const user = await this.authService.validateUser(
|
||||
tenantId,
|
||||
loginDto.email,
|
||||
loginDto.password,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
return this.authService.login(user);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
async register(
|
||||
@TenantId() tenantId: string,
|
||||
@Body() registerDto: RegisterDto,
|
||||
) {
|
||||
if (!tenantId) {
|
||||
throw new UnauthorizedException('Tenant ID is required');
|
||||
}
|
||||
|
||||
const user = await this.authService.register(
|
||||
tenantId,
|
||||
registerDto.email,
|
||||
registerDto.password,
|
||||
registerDto.firstName,
|
||||
registerDto.lastName,
|
||||
);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
24
backend/src/auth/auth.module.ts
Normal file
24
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET', 'devsecret'),
|
||||
signOptions: { expiresIn: '24h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
92
backend/src/auth/auth.service.ts
Normal file
92
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
async validateUser(
|
||||
tenantId: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<any> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
tenantId_email: {
|
||||
tenantId,
|
||||
email,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
userRoles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (user && (await bcrypt.compare(password, user.password))) {
|
||||
const { password, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async login(user: any) {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
tenantId: user.tenantId,
|
||||
};
|
||||
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
tenantId: user.tenantId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async register(
|
||||
tenantId: string,
|
||||
email: string,
|
||||
password: string,
|
||||
firstName?: string,
|
||||
lastName?: string,
|
||||
) {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
firstName,
|
||||
lastName,
|
||||
},
|
||||
});
|
||||
|
||||
const { password: _, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
8
backend/src/auth/current-user.decorator.ts
Normal file
8
backend/src/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
5
backend/src/auth/jwt-auth.guard.ts
Normal file
5
backend/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
30
backend/src/auth/jwt.strategy.ts
Normal file
30
backend/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: (request: FastifyRequest) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_SECRET', 'devsecret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
return {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
tenantId: payload.tenantId,
|
||||
};
|
||||
}
|
||||
}
|
||||
39
backend/src/main.ts
Normal file
39
backend/src/main.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
FastifyAdapter,
|
||||
NestFastifyApplication,
|
||||
} from '@nestjs/platform-fastify';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter(),
|
||||
);
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
|
||||
console.log(`🚀 Application is running on: http://localhost:${port}/api`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
11
backend/src/object/object.module.ts
Normal file
11
backend/src/object/object.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ObjectService } from './object.service';
|
||||
import { RuntimeObjectController } from './runtime-object.controller';
|
||||
import { SetupObjectController } from './setup-object.controller';
|
||||
|
||||
@Module({
|
||||
providers: [ObjectService],
|
||||
controllers: [RuntimeObjectController, SetupObjectController],
|
||||
exports: [ObjectService],
|
||||
})
|
||||
export class ObjectModule {}
|
||||
191
backend/src/object/object.service.ts
Normal file
191
backend/src/object/object.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// Setup endpoints - Object metadata management
|
||||
async getObjectDefinitions(tenantId: string) {
|
||||
return this.prisma.objectDefinition.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
orderBy: { label: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getObjectDefinition(tenantId: string, apiName: string) {
|
||||
const obj = await this.prisma.objectDefinition.findUnique({
|
||||
where: {
|
||||
tenantId_apiName: {
|
||||
tenantId,
|
||||
apiName,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
fields: {
|
||||
where: { isActive: true },
|
||||
orderBy: { label: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!obj) {
|
||||
throw new NotFoundException(`Object ${apiName} not found`);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
async createObjectDefinition(
|
||||
tenantId: string,
|
||||
data: {
|
||||
apiName: string;
|
||||
label: string;
|
||||
pluralLabel?: string;
|
||||
description?: string;
|
||||
isSystem?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.prisma.objectDefinition.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...data,
|
||||
tableName: `custom_${data.apiName.toLowerCase()}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createFieldDefinition(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
data: {
|
||||
apiName: string;
|
||||
label: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
isRequired?: boolean;
|
||||
isUnique?: boolean;
|
||||
isLookup?: boolean;
|
||||
referenceTo?: string;
|
||||
defaultValue?: string;
|
||||
options?: any;
|
||||
},
|
||||
) {
|
||||
const obj = await this.getObjectDefinition(tenantId, objectApiName);
|
||||
|
||||
return this.prisma.fieldDefinition.create({
|
||||
data: {
|
||||
objectId: obj.id,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Runtime endpoints - CRUD operations
|
||||
async getRecords(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
userId: string,
|
||||
filters?: any,
|
||||
) {
|
||||
// For demonstration, using Account as example static object
|
||||
if (objectApiName === 'Account') {
|
||||
return this.prisma.account.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
ownerId: userId, // Basic sharing rule
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// For custom objects, you'd need dynamic query building
|
||||
// This is a simplified version
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
}
|
||||
|
||||
async getRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
recordId: string,
|
||||
userId: string,
|
||||
) {
|
||||
if (objectApiName === 'Account') {
|
||||
const record = await this.prisma.account.findFirst({
|
||||
where: {
|
||||
id: recordId,
|
||||
tenantId,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('Record not found');
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
}
|
||||
|
||||
async createRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
data: any,
|
||||
userId: string,
|
||||
) {
|
||||
if (objectApiName === 'Account') {
|
||||
return this.prisma.account.create({
|
||||
data: {
|
||||
tenantId,
|
||||
ownerId: userId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
}
|
||||
|
||||
async updateRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
recordId: string,
|
||||
data: any,
|
||||
userId: string,
|
||||
) {
|
||||
if (objectApiName === 'Account') {
|
||||
// Verify ownership
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
|
||||
return this.prisma.account.update({
|
||||
where: { id: recordId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
}
|
||||
|
||||
async deleteRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
recordId: string,
|
||||
userId: string,
|
||||
) {
|
||||
if (objectApiName === 'Account') {
|
||||
// Verify ownership
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
|
||||
return this.prisma.account.delete({
|
||||
where: { id: recordId },
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
}
|
||||
}
|
||||
98
backend/src/object/runtime-object.controller.ts
Normal file
98
backend/src/object/runtime-object.controller.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ObjectService } from './object.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
|
||||
@Controller('runtime/objects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class RuntimeObjectController {
|
||||
constructor(private objectService: ObjectService) {}
|
||||
|
||||
@Get(':objectApiName/records')
|
||||
async getRecords(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@CurrentUser() user: any,
|
||||
@Query() query: any,
|
||||
) {
|
||||
return this.objectService.getRecords(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
user.userId,
|
||||
query,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':objectApiName/records/:id')
|
||||
async getRecord(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
return this.objectService.getRecord(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
id,
|
||||
user.userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':objectApiName/records')
|
||||
async createRecord(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Body() data: any,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
return this.objectService.createRecord(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
data,
|
||||
user.userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':objectApiName/records/:id')
|
||||
async updateRecord(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Param('id') id: string,
|
||||
@Body() data: any,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
return this.objectService.updateRecord(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
id,
|
||||
data,
|
||||
user.userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':objectApiName/records/:id')
|
||||
async deleteRecord(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: any,
|
||||
) {
|
||||
return this.objectService.deleteRecord(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
id,
|
||||
user.userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
51
backend/src/object/setup-object.controller.ts
Normal file
51
backend/src/object/setup-object.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ObjectService } from './object.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
|
||||
@Controller('setup/objects')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SetupObjectController {
|
||||
constructor(private objectService: ObjectService) {}
|
||||
|
||||
@Get()
|
||||
async getObjectDefinitions(@TenantId() tenantId: string) {
|
||||
return this.objectService.getObjectDefinitions(tenantId);
|
||||
}
|
||||
|
||||
@Get(':objectApiName')
|
||||
async getObjectDefinition(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
) {
|
||||
return this.objectService.getObjectDefinition(tenantId, objectApiName);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createObjectDefinition(
|
||||
@TenantId() tenantId: string,
|
||||
@Body() data: any,
|
||||
) {
|
||||
return this.objectService.createObjectDefinition(tenantId, data);
|
||||
}
|
||||
|
||||
@Post(':objectApiName/fields')
|
||||
async createFieldDefinition(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
@Body() data: any,
|
||||
) {
|
||||
return this.objectService.createFieldDefinition(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
data,
|
||||
);
|
||||
}
|
||||
}
|
||||
9
backend/src/prisma/prisma.module.ts
Normal file
9
backend/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
16
backend/src/prisma/prisma.service.ts
Normal file
16
backend/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
35
backend/src/rbac/permissions.guard.ts
Normal file
35
backend/src/rbac/permissions.guard.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { RbacService } from './rbac.service';
|
||||
import { PERMISSIONS_KEY } from './rbac.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private rbacService: RbacService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredPermissions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.rbacService.hasAllPermissions(
|
||||
user.userId,
|
||||
requiredPermissions,
|
||||
);
|
||||
}
|
||||
}
|
||||
8
backend/src/rbac/rbac.decorator.ts
Normal file
8
backend/src/rbac/rbac.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const Permissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
8
backend/src/rbac/rbac.module.ts
Normal file
8
backend/src/rbac/rbac.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
@Module({
|
||||
providers: [RbacService],
|
||||
exports: [RbacService],
|
||||
})
|
||||
export class RbacModule {}
|
||||
103
backend/src/rbac/rbac.service.ts
Normal file
103
backend/src/rbac/rbac.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class RbacService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getUserPermissions(userId: string): Promise<string[]> {
|
||||
const userRoles = await this.prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
rolePermissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const permissions = new Set<string>();
|
||||
userRoles.forEach((userRole) => {
|
||||
userRole.role.rolePermissions.forEach((rp) => {
|
||||
permissions.add(rp.permission.name);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
async getUserRoles(userId: string): Promise<string[]> {
|
||||
const userRoles = await this.prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
return userRoles.map((ur) => ur.role.name);
|
||||
}
|
||||
|
||||
async hasPermission(userId: string, permission: string): Promise<boolean> {
|
||||
const permissions = await this.getUserPermissions(userId);
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
async hasRole(userId: string, role: string): Promise<boolean> {
|
||||
const roles = await this.getUserRoles(userId);
|
||||
return roles.includes(role);
|
||||
}
|
||||
|
||||
async hasAnyPermission(
|
||||
userId: string,
|
||||
permissions: string[],
|
||||
): Promise<boolean> {
|
||||
const userPermissions = await this.getUserPermissions(userId);
|
||||
return permissions.some((p) => userPermissions.includes(p));
|
||||
}
|
||||
|
||||
async hasAllPermissions(
|
||||
userId: string,
|
||||
permissions: string[],
|
||||
): Promise<boolean> {
|
||||
const userPermissions = await this.getUserPermissions(userId);
|
||||
return permissions.every((p) => userPermissions.includes(p));
|
||||
}
|
||||
|
||||
async assignRole(userId: string, roleId: string) {
|
||||
return this.prisma.userRole.create({
|
||||
data: {
|
||||
userId,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeRole(userId: string, roleId: string) {
|
||||
return this.prisma.userRole.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async syncPermissionsToRole(roleId: string, permissionIds: string[]) {
|
||||
// Remove existing permissions
|
||||
await this.prisma.rolePermission.deleteMany({
|
||||
where: { roleId },
|
||||
});
|
||||
|
||||
// Add new permissions
|
||||
await this.prisma.rolePermission.createMany({
|
||||
data: permissionIds.map((permissionId) => ({
|
||||
roleId,
|
||||
permissionId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
33
backend/src/rbac/roles.guard.ts
Normal file
33
backend/src/rbac/roles.guard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { RbacService } from './rbac.service';
|
||||
import { ROLES_KEY } from './rbac.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private rbacService: RbacService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userRoles = await this.rbacService.getUserRoles(user.userId);
|
||||
return requiredRoles.some((role) => userRoles.includes(role));
|
||||
}
|
||||
}
|
||||
8
backend/src/tenant/tenant.decorator.ts
Normal file
8
backend/src/tenant/tenant.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const TenantId = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.raw.tenantId;
|
||||
},
|
||||
);
|
||||
16
backend/src/tenant/tenant.middleware.ts
Normal file
16
backend/src/tenant/tenant.middleware.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class TenantMiddleware implements NestMiddleware {
|
||||
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
|
||||
const tenantId = req.headers['x-tenant-id'] as string;
|
||||
|
||||
if (tenantId) {
|
||||
// Attach tenantId to request object
|
||||
(req as any).tenantId = tenantId;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
9
backend/src/tenant/tenant.module.ts
Normal file
9
backend/src/tenant/tenant.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
||||
import { TenantMiddleware } from './tenant.middleware';
|
||||
|
||||
@Module({})
|
||||
export class TenantModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(TenantMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user