Added auth functionality, initial work with views and field types
This commit is contained in:
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { AppBuilderService } from './app-builder.service';
|
||||
import { RuntimeAppController } from './runtime-app.controller';
|
||||
import { SetupAppController } from './setup-app.controller';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
|
||||
@Module({
|
||||
imports: [TenantModule],
|
||||
providers: [AppBuilderService],
|
||||
controllers: [RuntimeAppController, SetupAppController],
|
||||
exports: [AppBuilderService],
|
||||
|
||||
@@ -1,44 +1,26 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
import { App } from '../models/app.model';
|
||||
import { AppPage } from '../models/app-page.model';
|
||||
import { ObjectDefinition } from '../models/object-definition.model';
|
||||
|
||||
@Injectable()
|
||||
export class AppBuilderService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
// Runtime endpoints
|
||||
async getApps(tenantId: string, userId: string) {
|
||||
// For now, return all active apps for the tenant
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
// For now, return all apps
|
||||
// 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' },
|
||||
});
|
||||
return App.query(knex).withGraphFetched('pages').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' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const app = await App.query(knex)
|
||||
.findOne({ slug })
|
||||
.withGraphFetched('pages');
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException(`App ${slug} not found`);
|
||||
@@ -53,23 +35,12 @@ export class AppBuilderService {
|
||||
pageSlug: string,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
const page = await AppPage.query(knex).findOne({
|
||||
appId: app.id,
|
||||
slug: pageSlug,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
@@ -81,31 +52,15 @@ export class AppBuilderService {
|
||||
|
||||
// Setup endpoints
|
||||
async getAllApps(tenantId: string) {
|
||||
return this.prisma.app.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
pages: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { label: 'asc' },
|
||||
});
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
return App.query(knex).withGraphFetched('pages').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' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const app = await App.query(knex)
|
||||
.findOne({ slug })
|
||||
.withGraphFetched('pages');
|
||||
|
||||
if (!app) {
|
||||
throw new NotFoundException(`App ${slug} not found`);
|
||||
@@ -120,14 +75,12 @@ export class AppBuilderService {
|
||||
slug: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
},
|
||||
) {
|
||||
return this.prisma.app.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...data,
|
||||
},
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
return App.query(knex).insert({
|
||||
...data,
|
||||
displayOrder: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,16 +90,12 @@ export class AppBuilderService {
|
||||
data: {
|
||||
label?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const app = await this.getAppForSetup(tenantId, slug);
|
||||
|
||||
return this.prisma.app.update({
|
||||
where: { id: app.id },
|
||||
data,
|
||||
});
|
||||
return App.query(knex).patchAndFetchById(app.id, data);
|
||||
}
|
||||
|
||||
async createPage(
|
||||
@@ -157,37 +106,19 @@ export class AppBuilderService {
|
||||
label: string;
|
||||
type: string;
|
||||
objectApiName?: string;
|
||||
config?: any;
|
||||
sortOrder?: number;
|
||||
},
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
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,
|
||||
},
|
||||
return AppPage.query(knex).insert({
|
||||
appId: app.id,
|
||||
slug: data.slug,
|
||||
label: data.label,
|
||||
type: data.type,
|
||||
objectApiName: data.objectApiName,
|
||||
displayOrder: data.sortOrder || 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,44 +130,24 @@ export class AppBuilderService {
|
||||
label?: string;
|
||||
type?: string;
|
||||
objectApiName?: string;
|
||||
config?: any;
|
||||
sortOrder?: number;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const app = await this.getAppForSetup(tenantId, appSlug);
|
||||
|
||||
const page = await this.prisma.appPage.findFirst({
|
||||
where: {
|
||||
appId: app.id,
|
||||
slug: pageSlug,
|
||||
},
|
||||
const page = await AppPage.query(knex).findOne({
|
||||
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,
|
||||
},
|
||||
return AppPage.query(knex).patchAndFetchById(page.id, {
|
||||
...data,
|
||||
displayOrder: data.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,11 +59,6 @@ export class SetupAppController {
|
||||
@Param('pageSlug') pageSlug: string,
|
||||
@Body() data: any,
|
||||
) {
|
||||
return this.appBuilderService.updatePage(
|
||||
tenantId,
|
||||
appSlug,
|
||||
pageSlug,
|
||||
data,
|
||||
);
|
||||
return this.appBuilderService.updatePage(tenantId, appSlug, pageSlug, data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +79,12 @@ export class AuthController {
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('logout')
|
||||
async logout() {
|
||||
// For stateless JWT, logout is handled on client-side
|
||||
// This endpoint exists for consistency and potential future enhancements
|
||||
return { message: 'Logged out successfully' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
TenantModule,
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private tenantDbService: TenantDatabaseService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
@@ -15,34 +15,29 @@ export class AuthService {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
const user = await tenantDb('users')
|
||||
.where({ email })
|
||||
.first();
|
||||
|
||||
if (user && (await bcrypt.compare(password, user.password))) {
|
||||
const { password, ...result } = user;
|
||||
return result;
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await bcrypt.compare(password, user.password)) {
|
||||
// Load user roles and permissions
|
||||
const userRoles = await tenantDb('user_roles')
|
||||
.where({ userId: user.id })
|
||||
.join('roles', 'user_roles.roleId', 'roles.id')
|
||||
.select('roles.*');
|
||||
|
||||
const { password: _, ...result } = user;
|
||||
return {
|
||||
...result,
|
||||
tenantId,
|
||||
userRoles,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -52,7 +47,6 @@ export class AuthService {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
tenantId: user.tenantId,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -62,7 +56,6 @@ export class AuthService {
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
tenantId: user.tenantId,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -74,18 +67,24 @@ export class AuthService {
|
||||
firstName?: string,
|
||||
lastName?: string,
|
||||
) {
|
||||
const tenantDb = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
tenantId,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
firstName,
|
||||
lastName,
|
||||
},
|
||||
const [userId] = await tenantDb('users').insert({
|
||||
email,
|
||||
password: hashedPassword,
|
||||
firstName,
|
||||
lastName,
|
||||
isActive: true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
const user = await tenantDb('users')
|
||||
.where({ id: userId })
|
||||
.first();
|
||||
|
||||
const { password: _, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
|
||||
23
backend/src/models/account.model.ts
Normal file
23
backend/src/models/account.model.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export class Account extends BaseModel {
|
||||
static tableName = 'accounts';
|
||||
|
||||
id!: string;
|
||||
name!: string;
|
||||
website?: string;
|
||||
phone?: string;
|
||||
industry?: string;
|
||||
ownerId?: string;
|
||||
|
||||
static relationMappings = {
|
||||
owner: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: 'user.model',
|
||||
join: {
|
||||
from: 'accounts.ownerId',
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
25
backend/src/models/app-page.model.ts
Normal file
25
backend/src/models/app-page.model.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { BaseModel } from './base.model';
|
||||
import { App } from './app.model';
|
||||
|
||||
export class AppPage extends BaseModel {
|
||||
static tableName = 'app_pages';
|
||||
|
||||
id!: string;
|
||||
appId!: string;
|
||||
slug!: string;
|
||||
label!: string;
|
||||
type!: string;
|
||||
objectApiName?: string;
|
||||
displayOrder!: number;
|
||||
|
||||
static relationMappings = {
|
||||
app: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: App,
|
||||
join: {
|
||||
from: 'app_pages.appId',
|
||||
to: 'apps.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
23
backend/src/models/app.model.ts
Normal file
23
backend/src/models/app.model.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { BaseModel } from './base.model';
|
||||
import { AppPage } from './app-page.model';
|
||||
|
||||
export class App extends BaseModel {
|
||||
static tableName = 'apps';
|
||||
|
||||
id!: string;
|
||||
slug!: string;
|
||||
label!: string;
|
||||
description?: string;
|
||||
displayOrder!: number;
|
||||
|
||||
static relationMappings = {
|
||||
pages: {
|
||||
relation: BaseModel.HasManyRelation,
|
||||
modelClass: AppPage,
|
||||
join: {
|
||||
from: 'apps.id',
|
||||
to: 'app_pages.appId',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
18
backend/src/models/base.model.ts
Normal file
18
backend/src/models/base.model.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
|
||||
|
||||
export class BaseModel extends Model {
|
||||
static columnNameMappers = snakeCaseMappers();
|
||||
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
$beforeInsert(queryContext: QueryContext) {
|
||||
this.createdAt = new Date();
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
78
backend/src/models/field-definition.model.ts
Normal file
78
backend/src/models/field-definition.model.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export interface FieldOption {
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface ValidationRule {
|
||||
type: 'required' | 'min' | 'max' | 'email' | 'url' | 'pattern' | 'custom';
|
||||
value?: any;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface UIMetadata {
|
||||
// Display properties
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
|
||||
// View visibility
|
||||
showOnList?: boolean;
|
||||
showOnDetail?: boolean;
|
||||
showOnEdit?: boolean;
|
||||
sortable?: boolean;
|
||||
|
||||
// Field type specific options
|
||||
options?: FieldOption[]; // For select, multi-select
|
||||
rows?: number; // For textarea
|
||||
min?: number; // For number, date
|
||||
max?: number; // For number, date
|
||||
step?: number; // For number
|
||||
accept?: string; // For file/image
|
||||
relationDisplayField?: string; // Which field to display for relations
|
||||
|
||||
// Formatting
|
||||
format?: string; // Date format, number format, etc.
|
||||
prefix?: string; // Currency symbol, etc.
|
||||
suffix?: string;
|
||||
|
||||
// Validation
|
||||
validationRules?: ValidationRule[];
|
||||
|
||||
// Advanced
|
||||
dependsOn?: string[]; // Field dependencies
|
||||
computedValue?: string; // Formula for computed fields
|
||||
}
|
||||
|
||||
export class FieldDefinition extends BaseModel {
|
||||
static tableName = 'field_definitions';
|
||||
|
||||
id!: string;
|
||||
objectDefinitionId!: string;
|
||||
apiName!: string;
|
||||
label!: string;
|
||||
type!: string;
|
||||
length?: number;
|
||||
precision?: number;
|
||||
scale?: number;
|
||||
referenceObject?: string;
|
||||
defaultValue?: string;
|
||||
description?: string;
|
||||
isRequired!: boolean;
|
||||
isUnique!: boolean;
|
||||
isSystem!: boolean;
|
||||
isCustom!: boolean;
|
||||
displayOrder!: number;
|
||||
uiMetadata?: UIMetadata;
|
||||
|
||||
static relationMappings = {
|
||||
objectDefinition: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: 'object-definition.model',
|
||||
join: {
|
||||
from: 'field_definitions.objectDefinitionId',
|
||||
to: 'object_definitions.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
46
backend/src/models/object-definition.model.ts
Normal file
46
backend/src/models/object-definition.model.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export class ObjectDefinition extends BaseModel {
|
||||
static tableName = 'object_definitions';
|
||||
|
||||
id: string;
|
||||
apiName: string;
|
||||
label: string;
|
||||
pluralLabel?: string;
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
isCustom: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['apiName', 'label'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
apiName: { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
pluralLabel: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
isSystem: { type: 'boolean' },
|
||||
isCustom: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { FieldDefinition } = require('./field-definition.model');
|
||||
|
||||
return {
|
||||
fields: {
|
||||
relation: BaseModel.HasManyRelation,
|
||||
modelClass: FieldDefinition,
|
||||
join: {
|
||||
from: 'object_definitions.id',
|
||||
to: 'field_definitions.objectDefinitionId',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
25
backend/src/models/permission.model.ts
Normal file
25
backend/src/models/permission.model.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export class Permission extends BaseModel {
|
||||
static tableName = 'permissions';
|
||||
|
||||
id!: string;
|
||||
name!: string;
|
||||
guardName!: string;
|
||||
description?: string;
|
||||
|
||||
static relationMappings = {
|
||||
roles: {
|
||||
relation: BaseModel.ManyToManyRelation,
|
||||
modelClass: 'role.model',
|
||||
join: {
|
||||
from: 'permissions.id',
|
||||
through: {
|
||||
from: 'role_permissions.permissionId',
|
||||
to: 'role_permissions.roleId',
|
||||
},
|
||||
to: 'roles.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
28
backend/src/models/role-permission.model.ts
Normal file
28
backend/src/models/role-permission.model.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export class RolePermission extends BaseModel {
|
||||
static tableName = 'role_permissions';
|
||||
|
||||
id!: string;
|
||||
roleId!: string;
|
||||
permissionId!: string;
|
||||
|
||||
static relationMappings = {
|
||||
role: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: 'role.model',
|
||||
join: {
|
||||
from: 'role_permissions.roleId',
|
||||
to: 'roles.id',
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: 'permission.model',
|
||||
join: {
|
||||
from: 'role_permissions.permissionId',
|
||||
to: 'permissions.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
66
backend/src/models/role.model.ts
Normal file
66
backend/src/models/role.model.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export class Role extends BaseModel {
|
||||
static tableName = 'roles';
|
||||
|
||||
id: string;
|
||||
name: string;
|
||||
guardName: string;
|
||||
description?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
guardName: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { RolePermission } = require('./role-permission.model');
|
||||
const { Permission } = require('./permission.model');
|
||||
const { User } = require('./user.model');
|
||||
|
||||
return {
|
||||
rolePermissions: {
|
||||
relation: BaseModel.HasManyRelation,
|
||||
modelClass: RolePermission,
|
||||
join: {
|
||||
from: 'roles.id',
|
||||
to: 'role_permissions.roleId',
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
relation: BaseModel.ManyToManyRelation,
|
||||
modelClass: Permission,
|
||||
join: {
|
||||
from: 'roles.id',
|
||||
through: {
|
||||
from: 'role_permissions.roleId',
|
||||
to: 'role_permissions.permissionId',
|
||||
},
|
||||
to: 'permissions.id',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
relation: BaseModel.ManyToManyRelation,
|
||||
modelClass: User,
|
||||
join: {
|
||||
from: 'roles.id',
|
||||
through: {
|
||||
from: 'user_roles.roleId',
|
||||
to: 'user_roles.userId',
|
||||
},
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
28
backend/src/models/user-role.model.ts
Normal file
28
backend/src/models/user-role.model.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export class UserRole extends BaseModel {
|
||||
static tableName = 'user_roles';
|
||||
|
||||
id!: string;
|
||||
userId!: string;
|
||||
roleId!: string;
|
||||
|
||||
static relationMappings = {
|
||||
user: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: 'user.model',
|
||||
join: {
|
||||
from: 'user_roles.userId',
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
role: {
|
||||
relation: BaseModel.BelongsToOneRelation,
|
||||
modelClass: 'role.model',
|
||||
join: {
|
||||
from: 'user_roles.roleId',
|
||||
to: 'roles.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
57
backend/src/models/user.model.ts
Normal file
57
backend/src/models/user.model.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { BaseModel } from './base.model';
|
||||
|
||||
export class User extends BaseModel {
|
||||
static tableName = 'users';
|
||||
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
static get jsonSchema() {
|
||||
return {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string' },
|
||||
firstName: { type: 'string' },
|
||||
lastName: { type: 'string' },
|
||||
isActive: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
const { UserRole } = require('./user-role.model');
|
||||
const { Role } = require('./role.model');
|
||||
|
||||
return {
|
||||
userRoles: {
|
||||
relation: BaseModel.HasManyRelation,
|
||||
modelClass: UserRole,
|
||||
join: {
|
||||
from: 'users.id',
|
||||
to: 'user_roles.userId',
|
||||
},
|
||||
},
|
||||
roles: {
|
||||
relation: BaseModel.ManyToManyRelation,
|
||||
modelClass: Role,
|
||||
join: {
|
||||
from: 'users.id',
|
||||
through: {
|
||||
from: 'user_roles.userId',
|
||||
to: 'user_roles.roleId',
|
||||
},
|
||||
to: 'roles.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
295
backend/src/object/field-mapper.service.ts
Normal file
295
backend/src/object/field-mapper.service.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { FieldDefinition } from '../models/field-definition.model';
|
||||
|
||||
export interface FieldConfigDTO {
|
||||
id: string;
|
||||
apiName: string;
|
||||
label: string;
|
||||
type: string;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
defaultValue?: any;
|
||||
isRequired?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
showOnList?: boolean;
|
||||
showOnDetail?: boolean;
|
||||
showOnEdit?: boolean;
|
||||
sortable?: boolean;
|
||||
options?: Array<{ label: string; value: any }>;
|
||||
rows?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
accept?: string;
|
||||
relationObject?: string;
|
||||
relationDisplayField?: string;
|
||||
format?: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
validationRules?: Array<{
|
||||
type: string;
|
||||
value?: any;
|
||||
message?: string;
|
||||
}>;
|
||||
dependsOn?: string[];
|
||||
computedValue?: string;
|
||||
}
|
||||
|
||||
export interface ObjectDefinitionDTO {
|
||||
id: string;
|
||||
apiName: string;
|
||||
label: string;
|
||||
pluralLabel?: string;
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
fields: FieldConfigDTO[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FieldMapperService {
|
||||
/**
|
||||
* Convert a field definition from the database to a frontend-friendly FieldConfig
|
||||
*/
|
||||
mapFieldToDTO(field: any): FieldConfigDTO {
|
||||
const uiMetadata = field.uiMetadata || {};
|
||||
|
||||
return {
|
||||
id: field.id,
|
||||
apiName: field.apiName,
|
||||
label: field.label,
|
||||
type: this.mapFieldType(field.type),
|
||||
|
||||
// Display properties
|
||||
placeholder: uiMetadata.placeholder || field.description,
|
||||
helpText: uiMetadata.helpText || field.description,
|
||||
defaultValue: field.defaultValue,
|
||||
|
||||
// Validation
|
||||
isRequired: field.isRequired || false,
|
||||
isReadOnly: field.isSystem || uiMetadata.isReadOnly || false,
|
||||
|
||||
// View visibility
|
||||
showOnList: uiMetadata.showOnList !== false,
|
||||
showOnDetail: uiMetadata.showOnDetail !== false,
|
||||
showOnEdit: uiMetadata.showOnEdit !== false && !field.isSystem,
|
||||
sortable: uiMetadata.sortable !== false,
|
||||
|
||||
// Field type specific options
|
||||
options: uiMetadata.options,
|
||||
rows: uiMetadata.rows,
|
||||
min: uiMetadata.min,
|
||||
max: uiMetadata.max,
|
||||
step: uiMetadata.step,
|
||||
accept: uiMetadata.accept,
|
||||
relationObject: field.referenceObject,
|
||||
relationDisplayField: uiMetadata.relationDisplayField,
|
||||
|
||||
// Formatting
|
||||
format: uiMetadata.format,
|
||||
prefix: uiMetadata.prefix,
|
||||
suffix: uiMetadata.suffix,
|
||||
|
||||
// Validation rules
|
||||
validationRules: this.buildValidationRules(field, uiMetadata),
|
||||
|
||||
// Advanced
|
||||
dependsOn: uiMetadata.dependsOn,
|
||||
computedValue: uiMetadata.computedValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database field type to frontend FieldType enum
|
||||
*/
|
||||
private mapFieldType(dbType: string): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'string': 'text',
|
||||
'text': 'textarea',
|
||||
'integer': 'number',
|
||||
'decimal': 'number',
|
||||
'boolean': 'boolean',
|
||||
'date': 'date',
|
||||
'datetime': 'datetime',
|
||||
'time': 'time',
|
||||
'email': 'email',
|
||||
'url': 'url',
|
||||
'phone': 'text',
|
||||
'picklist': 'select',
|
||||
'multipicklist': 'multiSelect',
|
||||
'lookup': 'belongsTo',
|
||||
'master-detail': 'belongsTo',
|
||||
'currency': 'currency',
|
||||
'percent': 'number',
|
||||
'textarea': 'textarea',
|
||||
'richtext': 'markdown',
|
||||
'file': 'file',
|
||||
'image': 'image',
|
||||
'json': 'json',
|
||||
};
|
||||
|
||||
return typeMap[dbType.toLowerCase()] || 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build validation rules array
|
||||
*/
|
||||
private buildValidationRules(field: any, uiMetadata: any): Array<any> {
|
||||
const rules = uiMetadata.validationRules || [];
|
||||
|
||||
// Add required rule if field is required and not already in rules
|
||||
if (field.isRequired && !rules.some(r => r.type === 'required')) {
|
||||
rules.unshift({
|
||||
type: 'required',
|
||||
message: `${field.label} is required`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add length validation for string fields
|
||||
if (field.length && field.type === 'string') {
|
||||
rules.push({
|
||||
type: 'max',
|
||||
value: field.length,
|
||||
message: `${field.label} must not exceed ${field.length} characters`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add email validation
|
||||
if (field.type === 'email' && !rules.some(r => r.type === 'email')) {
|
||||
rules.push({
|
||||
type: 'email',
|
||||
message: `${field.label} must be a valid email address`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add URL validation
|
||||
if (field.type === 'url' && !rules.some(r => r.type === 'url')) {
|
||||
rules.push({
|
||||
type: 'url',
|
||||
message: `${field.label} must be a valid URL`,
|
||||
});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert object definition with fields to DTO
|
||||
*/
|
||||
mapObjectDefinitionToDTO(objectDef: any): ObjectDefinitionDTO {
|
||||
return {
|
||||
id: objectDef.id,
|
||||
apiName: objectDef.apiName,
|
||||
label: objectDef.label,
|
||||
pluralLabel: objectDef.pluralLabel,
|
||||
description: objectDef.description,
|
||||
isSystem: objectDef.isSystem || false,
|
||||
fields: (objectDef.fields || [])
|
||||
.filter((f: any) => f.isActive !== false)
|
||||
.sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0))
|
||||
.map((f: any) => this.mapFieldToDTO(f)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate default UI metadata for a field type
|
||||
*/
|
||||
generateDefaultUIMetadata(fieldType: string): any {
|
||||
const defaults: Record<string, any> = {
|
||||
text: {
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
},
|
||||
textarea: {
|
||||
showOnList: false,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: false,
|
||||
rows: 4,
|
||||
},
|
||||
number: {
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
},
|
||||
currency: {
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
prefix: '$',
|
||||
step: 0.01,
|
||||
},
|
||||
boolean: {
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
},
|
||||
date: {
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
format: 'yyyy-MM-dd',
|
||||
},
|
||||
datetime: {
|
||||
showOnList: false,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
format: 'yyyy-MM-dd HH:mm:ss',
|
||||
},
|
||||
email: {
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
validationRules: [{ type: 'email' }],
|
||||
},
|
||||
url: {
|
||||
showOnList: false,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: false,
|
||||
validationRules: [{ type: 'url' }],
|
||||
},
|
||||
select: {
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
options: [],
|
||||
},
|
||||
multiSelect: {
|
||||
showOnList: false,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: false,
|
||||
options: [],
|
||||
},
|
||||
image: {
|
||||
showOnList: false,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: false,
|
||||
accept: 'image/*',
|
||||
},
|
||||
file: {
|
||||
showOnList: false,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: false,
|
||||
},
|
||||
};
|
||||
|
||||
return defaults[fieldType] || {
|
||||
showOnList: true,
|
||||
showOnDetail: true,
|
||||
showOnEdit: true,
|
||||
sortable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { ObjectService } from './object.service';
|
||||
import { RuntimeObjectController } from './runtime-object.controller';
|
||||
import { SetupObjectController } from './setup-object.controller';
|
||||
import { SchemaManagementService } from './schema-management.service';
|
||||
import { FieldMapperService } from './field-mapper.service';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
|
||||
@Module({
|
||||
providers: [ObjectService],
|
||||
imports: [TenantModule],
|
||||
providers: [ObjectService, SchemaManagementService, FieldMapperService],
|
||||
controllers: [RuntimeObjectController, SetupObjectController],
|
||||
exports: [ObjectService],
|
||||
exports: [ObjectService, SchemaManagementService, FieldMapperService],
|
||||
})
|
||||
export class ObjectModule {}
|
||||
|
||||
@@ -1,42 +1,38 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(private tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
// Setup endpoints - Object metadata management
|
||||
async getObjectDefinitions(tenantId: string) {
|
||||
return this.prisma.objectDefinition.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
orderBy: { label: 'asc' },
|
||||
});
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
return knex('object_definitions')
|
||||
.select('*')
|
||||
.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' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
const obj = await knex('object_definitions')
|
||||
.where({ apiName })
|
||||
.first();
|
||||
|
||||
if (!obj) {
|
||||
throw new NotFoundException(`Object ${apiName} not found`);
|
||||
}
|
||||
|
||||
return obj;
|
||||
// Get fields for this object
|
||||
const fields = await knex('field_definitions')
|
||||
.where({ objectDefinitionId: obj.id })
|
||||
.orderBy('label', 'asc');
|
||||
|
||||
return {
|
||||
...obj,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
async createObjectDefinition(
|
||||
@@ -49,13 +45,15 @@ export class ObjectService {
|
||||
isSystem?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.prisma.objectDefinition.create({
|
||||
data: {
|
||||
tenantId,
|
||||
...data,
|
||||
tableName: `custom_${data.apiName.toLowerCase()}`,
|
||||
},
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const [id] = await knex('object_definitions').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return knex('object_definitions').where({ id }).first();
|
||||
}
|
||||
|
||||
async createFieldDefinition(
|
||||
@@ -68,20 +66,22 @@ export class ObjectService {
|
||||
description?: string;
|
||||
isRequired?: boolean;
|
||||
isUnique?: boolean;
|
||||
isLookup?: boolean;
|
||||
referenceTo?: string;
|
||||
referenceObject?: string;
|
||||
defaultValue?: string;
|
||||
options?: any;
|
||||
},
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
const obj = await this.getObjectDefinition(tenantId, objectApiName);
|
||||
|
||||
return this.prisma.fieldDefinition.create({
|
||||
data: {
|
||||
objectId: obj.id,
|
||||
...data,
|
||||
},
|
||||
const [id] = await knex('field_definitions').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
objectDefinitionId: obj.id,
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return knex('field_definitions').where({ id }).first();
|
||||
}
|
||||
|
||||
// Runtime endpoints - CRUD operations
|
||||
@@ -91,19 +91,16 @@ export class ObjectService {
|
||||
userId: string,
|
||||
filters?: any,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
// For demonstration, using Account as example static object
|
||||
if (objectApiName === 'Account') {
|
||||
return this.prisma.account.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
ownerId: userId, // Basic sharing rule
|
||||
...filters,
|
||||
},
|
||||
});
|
||||
return knex('accounts')
|
||||
.where({ ownerId: userId })
|
||||
.where(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`);
|
||||
}
|
||||
|
||||
@@ -113,14 +110,12 @@ export class ObjectService {
|
||||
recordId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
const record = await this.prisma.account.findFirst({
|
||||
where: {
|
||||
id: recordId,
|
||||
tenantId,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
const record = await knex('accounts')
|
||||
.where({ id: recordId, ownerId: userId })
|
||||
.first();
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('Record not found');
|
||||
@@ -138,14 +133,18 @@ export class ObjectService {
|
||||
data: any,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
return this.prisma.account.create({
|
||||
data: {
|
||||
tenantId,
|
||||
ownerId: userId,
|
||||
...data,
|
||||
},
|
||||
const [id] = await knex('accounts').insert({
|
||||
id: knex.raw('(UUID())'),
|
||||
ownerId: userId,
|
||||
...data,
|
||||
created_at: knex.fn.now(),
|
||||
updated_at: knex.fn.now(),
|
||||
});
|
||||
|
||||
return knex('accounts').where({ id }).first();
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
@@ -158,14 +157,17 @@ export class ObjectService {
|
||||
data: any,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
// Verify ownership
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
|
||||
return this.prisma.account.update({
|
||||
where: { id: recordId },
|
||||
data,
|
||||
});
|
||||
await knex('accounts')
|
||||
.where({ id: recordId })
|
||||
.update({ ...data, updated_at: knex.fn.now() });
|
||||
|
||||
return knex('accounts').where({ id: recordId }).first();
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
@@ -177,13 +179,15 @@ export class ObjectService {
|
||||
recordId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const knex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
if (objectApiName === 'Account') {
|
||||
// Verify ownership
|
||||
await this.getRecord(tenantId, objectApiName, recordId, userId);
|
||||
|
||||
return this.prisma.account.delete({
|
||||
where: { id: recordId },
|
||||
});
|
||||
await knex('accounts').where({ id: recordId }).delete();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
throw new Error(`Runtime queries for ${objectApiName} not yet implemented`);
|
||||
|
||||
216
backend/src/object/schema-management.service.ts
Normal file
216
backend/src/object/schema-management.service.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Knex } from 'knex';
|
||||
import { ObjectDefinition } from '../models/object-definition.model';
|
||||
import { FieldDefinition } from '../models/field-definition.model';
|
||||
|
||||
@Injectable()
|
||||
export class SchemaManagementService {
|
||||
private readonly logger = new Logger(SchemaManagementService.name);
|
||||
|
||||
/**
|
||||
* Create a physical table for an object definition
|
||||
*/
|
||||
async createObjectTable(
|
||||
knex: Knex,
|
||||
objectDefinition: ObjectDefinition,
|
||||
fields: FieldDefinition[],
|
||||
) {
|
||||
const tableName = this.getTableName(objectDefinition.apiName);
|
||||
|
||||
// Check if table already exists
|
||||
const exists = await knex.schema.hasTable(tableName);
|
||||
if (exists) {
|
||||
throw new Error(`Table ${tableName} already exists`);
|
||||
}
|
||||
|
||||
await knex.schema.createTable(tableName, (table) => {
|
||||
// Standard fields
|
||||
table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
|
||||
table.timestamps(true, true);
|
||||
|
||||
// Custom fields from field definitions
|
||||
for (const field of fields) {
|
||||
this.addFieldColumn(table, field);
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log(`Created table: ${tableName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new field to an existing object table
|
||||
*/
|
||||
async addFieldToTable(
|
||||
knex: Knex,
|
||||
objectApiName: string,
|
||||
field: FieldDefinition,
|
||||
) {
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
|
||||
await knex.schema.alterTable(tableName, (table) => {
|
||||
this.addFieldColumn(table, field);
|
||||
});
|
||||
|
||||
this.logger.log(`Added field ${field.apiName} to table ${tableName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a field from an existing object table
|
||||
*/
|
||||
async removeFieldFromTable(
|
||||
knex: Knex,
|
||||
objectApiName: string,
|
||||
fieldApiName: string,
|
||||
) {
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
|
||||
await knex.schema.alterTable(tableName, (table) => {
|
||||
table.dropColumn(fieldApiName);
|
||||
});
|
||||
|
||||
this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an object table
|
||||
*/
|
||||
async dropObjectTable(knex: Knex, objectApiName: string) {
|
||||
const tableName = this.getTableName(objectApiName);
|
||||
|
||||
await knex.schema.dropTableIfExists(tableName);
|
||||
|
||||
this.logger.log(`Dropped table: ${tableName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a field column to a table builder
|
||||
*/
|
||||
private addFieldColumn(
|
||||
table: Knex.CreateTableBuilder | Knex.AlterTableBuilder,
|
||||
field: FieldDefinition,
|
||||
) {
|
||||
const columnName = field.apiName;
|
||||
|
||||
let column: Knex.ColumnBuilder;
|
||||
|
||||
switch (field.type) {
|
||||
case 'String':
|
||||
column = table.string(columnName, field.length || 255);
|
||||
break;
|
||||
|
||||
case 'Text':
|
||||
column = table.text(columnName);
|
||||
break;
|
||||
|
||||
case 'Number':
|
||||
if (field.scale && field.scale > 0) {
|
||||
column = table.decimal(
|
||||
columnName,
|
||||
field.precision || 10,
|
||||
field.scale,
|
||||
);
|
||||
} else {
|
||||
column = table.integer(columnName);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Boolean':
|
||||
column = table.boolean(columnName).defaultTo(false);
|
||||
break;
|
||||
|
||||
case 'Date':
|
||||
column = table.date(columnName);
|
||||
break;
|
||||
|
||||
case 'DateTime':
|
||||
column = table.datetime(columnName);
|
||||
break;
|
||||
|
||||
case 'Reference':
|
||||
column = table.uuid(columnName);
|
||||
if (field.referenceObject) {
|
||||
const refTableName = this.getTableName(field.referenceObject);
|
||||
column.references('id').inTable(refTableName).onDelete('SET NULL');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Email':
|
||||
column = table.string(columnName, 255);
|
||||
break;
|
||||
|
||||
case 'Phone':
|
||||
column = table.string(columnName, 50);
|
||||
break;
|
||||
|
||||
case 'Url':
|
||||
column = table.string(columnName, 255);
|
||||
break;
|
||||
|
||||
case 'Json':
|
||||
column = table.json(columnName);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported field type: ${field.type}`);
|
||||
}
|
||||
|
||||
if (field.isRequired) {
|
||||
column.notNullable();
|
||||
} else {
|
||||
column.nullable();
|
||||
}
|
||||
|
||||
if (field.isUnique) {
|
||||
column.unique();
|
||||
}
|
||||
|
||||
if (field.defaultValue) {
|
||||
column.defaultTo(field.defaultValue);
|
||||
}
|
||||
|
||||
return column;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert object API name to table name (convert to snake_case, pluralize)
|
||||
*/
|
||||
private getTableName(apiName: string): string {
|
||||
// Convert PascalCase to snake_case
|
||||
const snakeCase = apiName
|
||||
.replace(/([A-Z])/g, '_$1')
|
||||
.toLowerCase()
|
||||
.replace(/^_/, '');
|
||||
|
||||
// Simple pluralization (append 's' if not already plural)
|
||||
// In production, use a proper pluralization library
|
||||
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate field definition before creating column
|
||||
*/
|
||||
validateFieldDefinition(field: FieldDefinition) {
|
||||
if (!field.apiName || !field.label || !field.type) {
|
||||
throw new Error('Field must have apiName, label, and type');
|
||||
}
|
||||
|
||||
// Validate field name (alphanumeric + underscore, starts with letter)
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(field.apiName)) {
|
||||
throw new Error(`Invalid field name: ${field.apiName}`);
|
||||
}
|
||||
|
||||
// Validate reference field has referenceObject
|
||||
if (field.type === 'Reference' && !field.referenceObject) {
|
||||
throw new Error('Reference field must specify referenceObject');
|
||||
}
|
||||
|
||||
// Validate numeric fields
|
||||
if (field.type === 'Number') {
|
||||
if (field.scale && field.scale > 0 && !field.precision) {
|
||||
throw new Error('Decimal fields must specify precision');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,17 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ObjectService } from './object.service';
|
||||
import { FieldMapperService } from './field-mapper.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) {}
|
||||
constructor(
|
||||
private objectService: ObjectService,
|
||||
private fieldMapperService: FieldMapperService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getObjectDefinitions(@TenantId() tenantId: string) {
|
||||
@@ -28,6 +32,18 @@ export class SetupObjectController {
|
||||
return this.objectService.getObjectDefinition(tenantId, objectApiName);
|
||||
}
|
||||
|
||||
@Get(':objectApiName/ui-config')
|
||||
async getObjectUIConfig(
|
||||
@TenantId() tenantId: string,
|
||||
@Param('objectApiName') objectApiName: string,
|
||||
) {
|
||||
const objectDef = await this.objectService.getObjectDefinition(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
);
|
||||
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createObjectDefinition(
|
||||
@TenantId() tenantId: string,
|
||||
|
||||
16
backend/src/prisma/central-prisma.service.ts
Normal file
16
backend/src/prisma/central-prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PrismaClient as CentralPrismaClient } from '.prisma/central';
|
||||
|
||||
let centralPrisma: CentralPrismaClient;
|
||||
|
||||
export function getCentralPrisma(): CentralPrismaClient {
|
||||
if (!centralPrisma) {
|
||||
centralPrisma = new CentralPrismaClient();
|
||||
}
|
||||
return centralPrisma;
|
||||
}
|
||||
|
||||
export async function disconnectCentral() {
|
||||
if (centralPrisma) {
|
||||
await centralPrisma.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient } from '.prisma/tenant';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
|
||||
132
backend/src/tenant/tenant-database.service.ts
Normal file
132
backend/src/tenant/tenant-database.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Knex, knex } from 'knex';
|
||||
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class TenantDatabaseService {
|
||||
private readonly logger = new Logger(TenantDatabaseService.name);
|
||||
private tenantConnections: Map<string, Knex> = new Map();
|
||||
|
||||
async getTenantKnex(tenantIdOrSlug: string): Promise<Knex> {
|
||||
if (this.tenantConnections.has(tenantIdOrSlug)) {
|
||||
return this.tenantConnections.get(tenantIdOrSlug);
|
||||
}
|
||||
|
||||
const centralPrisma = getCentralPrisma();
|
||||
|
||||
// Try to find tenant by ID first, then by slug
|
||||
let tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { id: tenantIdOrSlug },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { slug: tenantIdOrSlug },
|
||||
});
|
||||
}
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant ${tenantIdOrSlug} not found`);
|
||||
}
|
||||
|
||||
if (tenant.status !== 'active') {
|
||||
throw new Error(`Tenant ${tenantIdOrSlug} is not active`);
|
||||
}
|
||||
|
||||
// Decrypt password
|
||||
const decryptedPassword = this.decryptPassword(tenant.dbPassword);
|
||||
|
||||
const tenantKnex = knex({
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: tenant.dbHost,
|
||||
port: tenant.dbPort,
|
||||
user: tenant.dbUsername,
|
||||
password: decryptedPassword,
|
||||
database: tenant.dbName,
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
await tenantKnex.raw('SELECT 1');
|
||||
this.logger.log(`Connected to tenant database: ${tenant.dbName}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to connect to tenant database: ${tenant.dbName}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.tenantConnections.set(tenantIdOrSlug, tenantKnex);
|
||||
return tenantKnex;
|
||||
}
|
||||
|
||||
async getTenantByDomain(domain: string): Promise<any> {
|
||||
const centralPrisma = getCentralPrisma();
|
||||
const domainRecord = await centralPrisma.domain.findUnique({
|
||||
where: { domain },
|
||||
include: { tenant: true },
|
||||
});
|
||||
|
||||
if (!domainRecord) {
|
||||
throw new Error(`Domain ${domain} not found`);
|
||||
}
|
||||
|
||||
if (domainRecord.tenant.status !== 'active') {
|
||||
throw new Error(`Tenant for domain ${domain} is not active`);
|
||||
}
|
||||
|
||||
return domainRecord.tenant;
|
||||
}
|
||||
|
||||
async disconnectTenant(tenantId: string) {
|
||||
const connection = this.tenantConnections.get(tenantId);
|
||||
if (connection) {
|
||||
await connection.destroy();
|
||||
this.tenantConnections.delete(tenantId);
|
||||
this.logger.log(`Disconnected tenant: ${tenantId}`);
|
||||
}
|
||||
}
|
||||
|
||||
removeTenantConnection(tenantId: string) {
|
||||
this.tenantConnections.delete(tenantId);
|
||||
this.logger.log(`Removed tenant connection from cache: ${tenantId}`);
|
||||
}
|
||||
|
||||
async disconnectAll() {
|
||||
for (const [tenantId, connection] of this.tenantConnections.entries()) {
|
||||
await connection.destroy();
|
||||
}
|
||||
this.tenantConnections.clear();
|
||||
this.logger.log('Disconnected all tenant connections');
|
||||
}
|
||||
|
||||
encryptPassword(password: string): string {
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||
let encrypted = cipher.update(password, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
private decryptPassword(encryptedPassword: string): string {
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
|
||||
const parts = encryptedPassword.split(':');
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const encrypted = parts[1];
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
36
backend/src/tenant/tenant-provisioning.controller.ts
Normal file
36
backend/src/tenant/tenant-provisioning.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||
|
||||
@Controller('setup/tenants')
|
||||
export class TenantProvisioningController {
|
||||
constructor(
|
||||
private readonly provisioningService: TenantProvisioningService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async createTenant(
|
||||
@Body()
|
||||
data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
primaryDomain: string;
|
||||
dbHost?: string;
|
||||
dbPort?: number;
|
||||
},
|
||||
) {
|
||||
return this.provisioningService.provisionTenant(data);
|
||||
}
|
||||
|
||||
@Delete(':tenantId')
|
||||
async deleteTenant(@Param('tenantId') tenantId: string) {
|
||||
await this.provisioningService.deprovisionTenant(tenantId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
344
backend/src/tenant/tenant-provisioning.service.ts
Normal file
344
backend/src/tenant/tenant-provisioning.service.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TenantDatabaseService } from './tenant-database.service';
|
||||
import * as knex from 'knex';
|
||||
import * as crypto from 'crypto';
|
||||
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class TenantProvisioningService {
|
||||
private readonly logger = new Logger(TenantProvisioningService.name);
|
||||
|
||||
constructor(private readonly tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
/**
|
||||
* Provision a new tenant with database and default data
|
||||
*/
|
||||
async provisionTenant(data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
primaryDomain: string;
|
||||
dbHost?: string;
|
||||
dbPort?: number;
|
||||
}) {
|
||||
const dbHost = data.dbHost || process.env.DB_HOST || 'platform-db';
|
||||
const dbPort = data.dbPort || parseInt(process.env.DB_PORT || '3306');
|
||||
const dbName = `tenant_${data.slug}`;
|
||||
const dbUsername = `tenant_${data.slug}_user`;
|
||||
const dbPassword = this.generateSecurePassword();
|
||||
|
||||
this.logger.log(`Provisioning tenant: ${data.name} (${data.slug})`);
|
||||
|
||||
try {
|
||||
// Step 1: Create MySQL database and user
|
||||
await this.createTenantDatabase(
|
||||
dbHost,
|
||||
dbPort,
|
||||
dbName,
|
||||
dbUsername,
|
||||
dbPassword,
|
||||
);
|
||||
|
||||
// Step 2: Run migrations on new tenant database
|
||||
await this.runTenantMigrations(
|
||||
dbHost,
|
||||
dbPort,
|
||||
dbName,
|
||||
dbUsername,
|
||||
dbPassword,
|
||||
);
|
||||
|
||||
// Step 3: Store tenant info in central database
|
||||
const centralPrisma = getCentralPrisma();
|
||||
const tenant = await centralPrisma.tenant.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
dbHost,
|
||||
dbPort,
|
||||
dbName,
|
||||
dbUsername,
|
||||
dbPassword: this.tenantDbService.encryptPassword(dbPassword),
|
||||
status: 'active',
|
||||
domains: {
|
||||
create: {
|
||||
domain: data.primaryDomain,
|
||||
isPrimary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
domains: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Tenant provisioned successfully: ${tenant.id}`);
|
||||
|
||||
// Step 4: Seed default data (admin user, default roles, etc.)
|
||||
await this.seedDefaultData(tenant.id);
|
||||
|
||||
return {
|
||||
tenantId: tenant.id,
|
||||
dbName,
|
||||
dbUsername,
|
||||
dbPassword, // Return for initial setup, should be stored securely
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to provision tenant: ${data.slug}`, error);
|
||||
// Attempt cleanup
|
||||
await this.rollbackProvisioning(dbHost, dbPort, dbName, dbUsername).catch(
|
||||
(cleanupError) => {
|
||||
this.logger.error(
|
||||
'Failed to cleanup after provisioning error',
|
||||
cleanupError,
|
||||
);
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MySQL database and user
|
||||
*/
|
||||
private async createTenantDatabase(
|
||||
host: string,
|
||||
port: number,
|
||||
dbName: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
// Connect as root to create database and user
|
||||
const rootKnex = knex.default({
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host,
|
||||
port,
|
||||
user: process.env.DB_ROOT_USER || 'root',
|
||||
password: process.env.DB_ROOT_PASSWORD || 'root',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Create database
|
||||
await rootKnex.raw(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`,
|
||||
);
|
||||
this.logger.log(`Database created: ${dbName}`);
|
||||
|
||||
// Create user and grant privileges
|
||||
await rootKnex.raw(
|
||||
`CREATE USER IF NOT EXISTS '${username}'@'%' IDENTIFIED BY '${password}'`,
|
||||
);
|
||||
await rootKnex.raw(
|
||||
`GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${username}'@'%'`,
|
||||
);
|
||||
await rootKnex.raw('FLUSH PRIVILEGES');
|
||||
this.logger.log(`User created: ${username}`);
|
||||
} finally {
|
||||
await rootKnex.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Knex migrations on tenant database
|
||||
*/
|
||||
private async runTenantMigrations(
|
||||
host: string,
|
||||
port: number,
|
||||
dbName: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const tenantKnex = knex.default({
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host,
|
||||
port,
|
||||
database: dbName,
|
||||
user: username,
|
||||
password,
|
||||
},
|
||||
migrations: {
|
||||
directory: './migrations/tenant',
|
||||
tableName: 'knex_migrations',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await tenantKnex.migrate.latest();
|
||||
this.logger.log(`Migrations completed for database: ${dbName}`);
|
||||
} finally {
|
||||
await tenantKnex.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed default data for new tenant
|
||||
*/
|
||||
private async seedDefaultData(tenantId: string) {
|
||||
const tenantKnex = await this.tenantDbService.getTenantKnex(tenantId);
|
||||
|
||||
try {
|
||||
// Create default roles
|
||||
const adminRoleId = crypto.randomUUID();
|
||||
await tenantKnex('roles').insert({
|
||||
id: adminRoleId,
|
||||
name: 'Admin',
|
||||
guardName: 'api',
|
||||
description: 'Full system administrator access',
|
||||
created_at: tenantKnex.fn.now(),
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
|
||||
const userRoleId = crypto.randomUUID();
|
||||
await tenantKnex('roles').insert({
|
||||
id: userRoleId,
|
||||
name: 'User',
|
||||
guardName: 'api',
|
||||
description: 'Standard user access',
|
||||
created_at: tenantKnex.fn.now(),
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
|
||||
// Create default permissions
|
||||
const permissions = [
|
||||
{ name: 'manage_users', description: 'Manage users' },
|
||||
{ name: 'manage_roles', description: 'Manage roles and permissions' },
|
||||
{ name: 'manage_apps', description: 'Manage applications' },
|
||||
{ name: 'manage_objects', description: 'Manage object definitions' },
|
||||
{ name: 'view_data', description: 'View data' },
|
||||
{ name: 'create_data', description: 'Create data' },
|
||||
{ name: 'edit_data', description: 'Edit data' },
|
||||
{ name: 'delete_data', description: 'Delete data' },
|
||||
];
|
||||
|
||||
for (const perm of permissions) {
|
||||
await tenantKnex('permissions').insert({
|
||||
id: crypto.randomUUID(),
|
||||
name: perm.name,
|
||||
guardName: 'api',
|
||||
description: perm.description,
|
||||
created_at: tenantKnex.fn.now(),
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Grant all permissions to Admin role
|
||||
const allPermissions = await tenantKnex('permissions').select('id');
|
||||
for (const perm of allPermissions) {
|
||||
await tenantKnex('role_permissions').insert({
|
||||
id: crypto.randomUUID(),
|
||||
roleId: adminRoleId,
|
||||
permissionId: perm.id,
|
||||
created_at: tenantKnex.fn.now(),
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Grant view/create/edit permissions to User role
|
||||
const userPermissions = await tenantKnex('permissions')
|
||||
.whereIn('name', ['view_data', 'create_data', 'edit_data'])
|
||||
.select('id');
|
||||
for (const perm of userPermissions) {
|
||||
await tenantKnex('role_permissions').insert({
|
||||
id: crypto.randomUUID(),
|
||||
roleId: userRoleId,
|
||||
permissionId: perm.id,
|
||||
created_at: tenantKnex.fn.now(),
|
||||
updated_at: tenantKnex.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`Default data seeded for tenant: ${tenantId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to seed default data for tenant: ${tenantId}`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback provisioning in case of error
|
||||
*/
|
||||
private async rollbackProvisioning(
|
||||
host: string,
|
||||
port: number,
|
||||
dbName: string,
|
||||
username: string,
|
||||
) {
|
||||
const rootKnex = knex.default({
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host,
|
||||
port,
|
||||
user: process.env.DB_ROOT_USER || 'root',
|
||||
password: process.env.DB_ROOT_PASSWORD || 'root',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await rootKnex.raw(`DROP DATABASE IF EXISTS \`${dbName}\``);
|
||||
await rootKnex.raw(`DROP USER IF EXISTS '${username}'@'%'`);
|
||||
this.logger.log(`Rolled back provisioning for database: ${dbName}`);
|
||||
} finally {
|
||||
await rootKnex.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate secure random password
|
||||
*/
|
||||
private generateSecurePassword(): string {
|
||||
return crypto.randomBytes(32).toString('base64').slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprovision a tenant (delete database and central record)
|
||||
*/
|
||||
async deprovisionTenant(tenantId: string) {
|
||||
const centralPrisma = getCentralPrisma();
|
||||
const tenant = await centralPrisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant not found: ${tenantId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete tenant database
|
||||
const rootKnex = knex.default({
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: tenant.dbHost,
|
||||
port: tenant.dbPort,
|
||||
user: process.env.DB_ROOT_USER || 'root',
|
||||
password: process.env.DB_ROOT_PASSWORD || 'root',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await rootKnex.raw(`DROP DATABASE IF EXISTS \`${tenant.dbName}\``);
|
||||
await rootKnex.raw(`DROP USER IF EXISTS '${tenant.dbUsername}'@'%'`);
|
||||
this.logger.log(`Database deleted: ${tenant.dbName}`);
|
||||
} finally {
|
||||
await rootKnex.destroy();
|
||||
}
|
||||
|
||||
// Delete tenant from central database
|
||||
await centralPrisma.tenant.delete({
|
||||
where: { id: tenantId },
|
||||
});
|
||||
|
||||
// Remove from connection cache
|
||||
this.tenantDbService.removeTenantConnection(tenantId);
|
||||
|
||||
this.logger.log(`Tenant deprovisioned: ${tenantId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to deprovision tenant: ${tenantId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,88 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { TenantDatabaseService } from './tenant-database.service';
|
||||
|
||||
@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;
|
||||
private readonly logger = new Logger(TenantMiddleware.name);
|
||||
|
||||
constructor(private readonly tenantDbService: TenantDatabaseService) {}
|
||||
|
||||
async use(
|
||||
req: FastifyRequest['raw'],
|
||||
res: FastifyReply['raw'],
|
||||
next: () => void,
|
||||
) {
|
||||
try {
|
||||
// Extract subdomain from hostname
|
||||
const host = req.headers.host || '';
|
||||
const hostname = host.split(':')[0]; // Remove port if present
|
||||
const parts = hostname.split('.');
|
||||
|
||||
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}`);
|
||||
|
||||
// For local development, accept x-tenant-id header
|
||||
let tenantId = req.headers['x-tenant-id'] as string;
|
||||
let subdomain: string | null = null;
|
||||
|
||||
this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`);
|
||||
|
||||
// If x-tenant-id is explicitly provided, use it directly
|
||||
if (tenantId) {
|
||||
this.logger.log(`Using explicit x-tenant-id: ${tenantId}`);
|
||||
(req as any).tenantId = tenantId;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co")
|
||||
// For production domains with 3+ parts, extract first part as subdomain
|
||||
if (parts.length >= 3) {
|
||||
subdomain = parts[0];
|
||||
// Ignore www subdomain
|
||||
if (subdomain === 'www') {
|
||||
subdomain = null;
|
||||
}
|
||||
}
|
||||
// For development (e.g., tenant1.localhost), also check 2 parts
|
||||
else if (parts.length === 2 && parts[1] === 'localhost') {
|
||||
subdomain = parts[0];
|
||||
}
|
||||
|
||||
this.logger.log(`Extracted subdomain: ${subdomain}`);
|
||||
|
||||
// Get tenant by subdomain if available
|
||||
if (subdomain) {
|
||||
try {
|
||||
const tenant = await this.tenantDbService.getTenantByDomain(subdomain);
|
||||
if (tenant) {
|
||||
tenantId = tenant.id;
|
||||
this.logger.log(
|
||||
`Tenant identified: ${tenant.name} (${tenant.id}) from subdomain: ${subdomain}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`No tenant found for subdomain: ${subdomain}`, error.message);
|
||||
// Fall back to using subdomain as tenantId directly if domain lookup fails
|
||||
tenantId = subdomain;
|
||||
this.logger.log(`Using subdomain as tenantId fallback: ${tenantId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tenantId) {
|
||||
// Attach tenant info to request object
|
||||
(req as any).tenantId = tenantId;
|
||||
if (subdomain) {
|
||||
(req as any).subdomain = subdomain;
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`No tenant identified from host: ${hostname}`);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
this.logger.error('Error in tenant middleware', error);
|
||||
next();
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
||||
import { TenantMiddleware } from './tenant.middleware';
|
||||
import { TenantDatabaseService } from './tenant-database.service';
|
||||
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [TenantProvisioningController],
|
||||
providers: [
|
||||
TenantDatabaseService,
|
||||
TenantProvisioningService,
|
||||
TenantMiddleware,
|
||||
],
|
||||
exports: [TenantDatabaseService, TenantProvisioningService],
|
||||
})
|
||||
export class TenantModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(TenantMiddleware).forRoutes('*');
|
||||
|
||||
Reference in New Issue
Block a user