WIP - central operations
This commit is contained in:
114
backend/src/models/central.model.ts
Normal file
114
backend/src/models/central.model.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Model, ModelOptions, QueryContext } from 'objection';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
/**
|
||||
* Central database models using Objection.js
|
||||
* These models work with the central database (not tenant databases)
|
||||
*/
|
||||
|
||||
export class CentralTenant extends Model {
|
||||
static tableName = 'tenants';
|
||||
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
dbHost: string;
|
||||
dbPort: number;
|
||||
dbName: string;
|
||||
dbUsername: string;
|
||||
dbPassword: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
domains?: CentralDomain[];
|
||||
|
||||
$beforeInsert(queryContext: QueryContext) {
|
||||
this.id = this.id || randomUUID();
|
||||
// Auto-generate slug from name if not provided
|
||||
if (!this.slug && this.name) {
|
||||
this.slug = this.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
this.createdAt = new Date();
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
return {
|
||||
domains: {
|
||||
relation: Model.HasManyRelation,
|
||||
modelClass: CentralDomain,
|
||||
join: {
|
||||
from: 'tenants.id',
|
||||
to: 'domains.tenantId',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class CentralDomain extends Model {
|
||||
static tableName = 'domains';
|
||||
|
||||
id: string;
|
||||
domain: string;
|
||||
tenantId: string;
|
||||
isPrimary: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
tenant?: CentralTenant;
|
||||
|
||||
$beforeInsert(queryContext: QueryContext) {
|
||||
this.id = this.id || randomUUID();
|
||||
this.createdAt = new Date();
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
static get relationMappings() {
|
||||
return {
|
||||
tenant: {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass: CentralTenant,
|
||||
join: {
|
||||
from: 'domains.tenantId',
|
||||
to: 'tenants.id',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class CentralUser extends Model {
|
||||
static tableName = 'users';
|
||||
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
$beforeInsert(queryContext: QueryContext) {
|
||||
this.id = this.id || randomUUID();
|
||||
this.createdAt = new Date();
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
|
||||
$beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
257
backend/src/tenant/central-admin.controller.ts
Normal file
257
backend/src/tenant/central-admin.controller.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
UnauthorizedException,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model';
|
||||
import { getCentralKnex, initCentralModels } from './central-database.service';
|
||||
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
/**
|
||||
* Controller for managing central database entities (tenants, domains, users)
|
||||
* Only accessible when logged in as central admin
|
||||
*/
|
||||
@Controller('central')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CentralAdminController {
|
||||
constructor(
|
||||
private readonly provisioningService: TenantProvisioningService,
|
||||
) {
|
||||
// Initialize central models on controller creation
|
||||
initCentralModels();
|
||||
}
|
||||
|
||||
private checkCentralAdmin(req: any) {
|
||||
const subdomain = req.raw?.subdomain;
|
||||
const centralSubdomains = (process.env.CENTRAL_SUBDOMAINS || 'central,admin').split(',');
|
||||
|
||||
if (!subdomain || !centralSubdomains.includes(subdomain)) {
|
||||
throw new UnauthorizedException('This endpoint is only accessible to central administrators');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== TENANTS ====================
|
||||
|
||||
@Get('tenants')
|
||||
async getTenants(@Req() req: any) {
|
||||
this.checkCentralAdmin(req);
|
||||
return CentralTenant.query().withGraphFetched('domains');
|
||||
}
|
||||
|
||||
@Get('tenants/:id')
|
||||
async getTenant(@Req() req: any, @Param('id') id: string) {
|
||||
this.checkCentralAdmin(req);
|
||||
return CentralTenant.query()
|
||||
.findById(id)
|
||||
.withGraphFetched('domains');
|
||||
}
|
||||
|
||||
@Post('tenants')
|
||||
async createTenant(
|
||||
@Req() req: any,
|
||||
@Body() data: {
|
||||
name: string;
|
||||
slug?: string;
|
||||
primaryDomain: string;
|
||||
dbHost?: string;
|
||||
dbPort?: number;
|
||||
},
|
||||
) {
|
||||
this.checkCentralAdmin(req);
|
||||
|
||||
// Use the provisioning service to create tenant with database and migrations
|
||||
const result = await this.provisioningService.provisionTenant({
|
||||
name: data.name,
|
||||
slug: data.slug || data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''),
|
||||
primaryDomain: data.primaryDomain,
|
||||
dbHost: data.dbHost,
|
||||
dbPort: data.dbPort,
|
||||
});
|
||||
|
||||
// Return the created tenant
|
||||
return CentralTenant.query()
|
||||
.findById(result.tenantId)
|
||||
.withGraphFetched('domains');
|
||||
}
|
||||
|
||||
@Put('tenants/:id')
|
||||
async updateTenant(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() data: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
dbHost?: string;
|
||||
dbPort?: number;
|
||||
dbName?: string;
|
||||
dbUsername?: string;
|
||||
status?: string;
|
||||
},
|
||||
) {
|
||||
this.checkCentralAdmin(req);
|
||||
return CentralTenant.query()
|
||||
.patchAndFetchById(id, data);
|
||||
}
|
||||
|
||||
@Delete('tenants/:id')
|
||||
async deleteTenant(@Req() req: any, @Param('id') id: string) {
|
||||
this.checkCentralAdmin(req);
|
||||
await CentralTenant.query().deleteById(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ==================== DOMAINS ====================
|
||||
|
||||
@Get('domains')
|
||||
async getDomains(@Req() req: any) {
|
||||
this.checkCentralAdmin(req);
|
||||
return CentralDomain.query().withGraphFetched('tenant');
|
||||
}
|
||||
|
||||
@Get('domains/:id')
|
||||
async getDomain(@Req() req: any, @Param('id') id: string) {
|
||||
this.checkCentralAdmin(req);
|
||||
return CentralDomain.query()
|
||||
.findById(id)
|
||||
.withGraphFetched('tenant');
|
||||
}
|
||||
|
||||
@Post('domains')
|
||||
async createDomain(
|
||||
@Req() req: any,
|
||||
@Body() data: {
|
||||
domain: string;
|
||||
tenantId: string;
|
||||
isPrimary?: boolean;
|
||||
},
|
||||
) {
|
||||
this.checkCentralAdmin(req);
|
||||
return CentralDomain.query().insert({
|
||||
domain: data.domain,
|
||||
tenantId: data.tenantId,
|
||||
isPrimary: data.isPrimary || false,
|
||||
});
|
||||
}
|
||||
|
||||
@Put('domains/:id')
|
||||
async updateDomain(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() data: {
|
||||
domain?: string;
|
||||
tenantId?: string;
|
||||
isPrimary?: boolean;
|
||||
},
|
||||
) {
|
||||
this.checkCentralAdmin(req);
|
||||
return CentralDomain.query()
|
||||
.patchAndFetchById(id, data);
|
||||
}
|
||||
|
||||
@Delete('domains/:id')
|
||||
async deleteDomain(@Req() req: any, @Param('id') id: string) {
|
||||
this.checkCentralAdmin(req);
|
||||
await CentralDomain.query().deleteById(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ==================== USERS (Central Admin Users) ====================
|
||||
|
||||
@Get('users')
|
||||
async getUsers(@Req() req: any) {
|
||||
this.checkCentralAdmin(req);
|
||||
const users = await CentralUser.query();
|
||||
// Remove password from response
|
||||
return users.map(({ password, ...user }) => user);
|
||||
}
|
||||
|
||||
@Get('users/:id')
|
||||
async getUser(@Req() req: any, @Param('id') id: string) {
|
||||
this.checkCentralAdmin(req);
|
||||
const user = await CentralUser.query().findById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
async createUser(
|
||||
@Req() req: any,
|
||||
@Body() data: {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role?: string;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
this.checkCentralAdmin(req);
|
||||
|
||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||
|
||||
const user = await CentralUser.query().insert({
|
||||
email: data.email,
|
||||
password: hashedPassword,
|
||||
firstName: data.firstName || null,
|
||||
lastName: data.lastName || null,
|
||||
role: data.role || 'admin',
|
||||
isActive: data.isActive !== undefined ? data.isActive : true,
|
||||
});
|
||||
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
@Put('users/:id')
|
||||
async updateUser(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() data: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role?: string;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
this.checkCentralAdmin(req);
|
||||
|
||||
const updateData: any = { ...data };
|
||||
|
||||
// Hash password if provided
|
||||
if (data.password) {
|
||||
updateData.password = await bcrypt.hash(data.password, 10);
|
||||
} else {
|
||||
// Remove password from update if not provided
|
||||
delete updateData.password;
|
||||
}
|
||||
|
||||
const user = await CentralUser.query()
|
||||
.patchAndFetchById(id, updateData);
|
||||
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
return userWithoutPassword;
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
async deleteUser(@Req() req: any, @Param('id') id: string) {
|
||||
this.checkCentralAdmin(req);
|
||||
await CentralUser.query().deleteById(id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
43
backend/src/tenant/central-database.service.ts
Normal file
43
backend/src/tenant/central-database.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import Knex from 'knex';
|
||||
import { Model } from 'objection';
|
||||
import { CentralTenant, CentralDomain, CentralUser } from '../models/central.model';
|
||||
|
||||
let centralKnex: Knex.Knex | null = null;
|
||||
|
||||
/**
|
||||
* Get or create a Knex instance for the central database
|
||||
* This is used for Objection models that work with central entities
|
||||
*/
|
||||
export function getCentralKnex(): Knex.Knex {
|
||||
if (!centralKnex) {
|
||||
const centralDbUrl = process.env.CENTRAL_DATABASE_URL;
|
||||
|
||||
if (!centralDbUrl) {
|
||||
throw new Error('CENTRAL_DATABASE_URL environment variable is not set');
|
||||
}
|
||||
|
||||
centralKnex = Knex({
|
||||
client: 'mysql2',
|
||||
connection: centralDbUrl,
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// Bind Objection models to this knex instance
|
||||
Model.knex(centralKnex);
|
||||
}
|
||||
|
||||
return centralKnex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize central models with the knex instance
|
||||
*/
|
||||
export function initCentralModels() {
|
||||
const knex = getCentralKnex();
|
||||
CentralTenant.knex(knex);
|
||||
CentralDomain.knex(knex);
|
||||
CentralUser.knex(knex);
|
||||
}
|
||||
@@ -3,11 +3,12 @@ import { TenantMiddleware } from './tenant.middleware';
|
||||
import { TenantDatabaseService } from './tenant-database.service';
|
||||
import { TenantProvisioningService } from './tenant-provisioning.service';
|
||||
import { TenantProvisioningController } from './tenant-provisioning.controller';
|
||||
import { CentralAdminController } from './central-admin.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [TenantProvisioningController],
|
||||
controllers: [TenantProvisioningController, CentralAdminController],
|
||||
providers: [
|
||||
TenantDatabaseService,
|
||||
TenantProvisioningService,
|
||||
|
||||
Reference in New Issue
Block a user