WIP - additional fixes for multitenant

This commit is contained in:
Francisco Gaona
2025-11-30 10:09:21 +01:00
parent 57f27d28cd
commit 5a80f33078
12 changed files with 206 additions and 165 deletions

View File

@@ -5,28 +5,28 @@ exports.up = function (knex) {
table.string('slug', 255).notNullable().unique(); table.string('slug', 255).notNullable().unique();
table.string('label', 255).notNullable(); table.string('label', 255).notNullable();
table.text('description'); table.text('description');
table.integer('displayOrder').defaultTo(0); table.integer('display_order').defaultTo(0);
table.timestamps(true, true); table.timestamps(true, true);
table.index(['slug']); table.index(['slug']);
}) })
.createTable('app_pages', (table) => { .createTable('app_pages', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('(UUID())')); table.uuid('id').primary().defaultTo(knex.raw('(UUID())'));
table.uuid('appId').notNullable(); table.uuid('app_id').notNullable();
table.string('slug', 255).notNullable(); table.string('slug', 255).notNullable();
table.string('label', 255).notNullable(); table.string('label', 255).notNullable();
table.string('type', 50).notNullable(); // List, Detail, Custom table.string('type', 50).notNullable(); // List, Detail, Custom
table.string('objectApiName', 255); table.string('object_api_name', 255);
table.integer('displayOrder').defaultTo(0); table.integer('display_order').defaultTo(0);
table.timestamps(true, true); table.timestamps(true, true);
table table
.foreign('appId') .foreign('app_id')
.references('id') .references('id')
.inTable('apps') .inTable('apps')
.onDelete('CASCADE'); .onDelete('CASCADE');
table.unique(['appId', 'slug']); table.unique(['app_id', 'slug']);
table.index(['appId']); table.index(['app_id']);
}); });
}; };

View File

@@ -0,0 +1,116 @@
/*
Warnings:
- You are about to drop the column `isActive` on the `tenants` table. All the data in the column will be lost.
- You are about to drop the `accounts` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `app_pages` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `apps` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `field_definitions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `object_definitions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `permissions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `role_permissions` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `roles` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `user_roles` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `users` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `dbHost` to the `tenants` table without a default value. This is not possible if the table is not empty.
- Added the required column `dbName` to the `tenants` table without a default value. This is not possible if the table is not empty.
- Added the required column `dbPassword` to the `tenants` table without a default value. This is not possible if the table is not empty.
- Added the required column `dbUsername` to the `tenants` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_ownerId_fkey`;
-- DropForeignKey
ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_appId_fkey`;
-- DropForeignKey
ALTER TABLE `app_pages` DROP FOREIGN KEY `app_pages_objectId_fkey`;
-- DropForeignKey
ALTER TABLE `apps` DROP FOREIGN KEY `apps_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `field_definitions` DROP FOREIGN KEY `field_definitions_objectId_fkey`;
-- DropForeignKey
ALTER TABLE `object_definitions` DROP FOREIGN KEY `object_definitions_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `permissions` DROP FOREIGN KEY `permissions_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_permissionId_fkey`;
-- DropForeignKey
ALTER TABLE `role_permissions` DROP FOREIGN KEY `role_permissions_roleId_fkey`;
-- DropForeignKey
ALTER TABLE `roles` DROP FOREIGN KEY `roles_tenantId_fkey`;
-- DropForeignKey
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_roleId_fkey`;
-- DropForeignKey
ALTER TABLE `user_roles` DROP FOREIGN KEY `user_roles_userId_fkey`;
-- DropForeignKey
ALTER TABLE `users` DROP FOREIGN KEY `users_tenantId_fkey`;
-- AlterTable
ALTER TABLE `tenants` DROP COLUMN `isActive`,
ADD COLUMN `dbHost` VARCHAR(191) NOT NULL,
ADD COLUMN `dbName` VARCHAR(191) NOT NULL,
ADD COLUMN `dbPassword` VARCHAR(191) NOT NULL,
ADD COLUMN `dbPort` INTEGER NOT NULL DEFAULT 3306,
ADD COLUMN `dbUsername` VARCHAR(191) NOT NULL,
ADD COLUMN `status` VARCHAR(191) NOT NULL DEFAULT 'active';
-- DropTable
DROP TABLE `accounts`;
-- DropTable
DROP TABLE `app_pages`;
-- DropTable
DROP TABLE `apps`;
-- DropTable
DROP TABLE `field_definitions`;
-- DropTable
DROP TABLE `object_definitions`;
-- DropTable
DROP TABLE `permissions`;
-- DropTable
DROP TABLE `role_permissions`;
-- DropTable
DROP TABLE `roles`;
-- DropTable
DROP TABLE `user_roles`;
-- DropTable
DROP TABLE `users`;
-- CreateTable
CREATE TABLE `domains` (
`id` VARCHAR(191) NOT NULL,
`domain` VARCHAR(191) NOT NULL,
`tenantId` VARCHAR(191) NOT NULL,
`isPrimary` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `domains_domain_key`(`domain`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `domains` ADD CONSTRAINT `domains_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { AppBuilderService } from './app-builder.service'; import { AppBuilderService } from './app-builder.service';
import { RuntimeAppController } from './runtime-app.controller'; import { RuntimeAppController } from './runtime-app.controller';
import { SetupAppController } from './setup-app.controller'; import { SetupAppController } from './setup-app.controller';
import { TenantModule } from '../tenant/tenant.module';
@Module({ @Module({
imports: [TenantModule],
providers: [AppBuilderService], providers: [AppBuilderService],
controllers: [RuntimeAppController, SetupAppController], controllers: [RuntimeAppController, SetupAppController],
exports: [AppBuilderService], exports: [AppBuilderService],

View File

@@ -1,44 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common'; 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() @Injectable()
export class AppBuilderService { export class AppBuilderService {
constructor(private prisma: PrismaService) {} constructor(private tenantDbService: TenantDatabaseService) {}
// Runtime endpoints // Runtime endpoints
async getApps(tenantId: string, userId: string) { 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 // In production, you'd filter by user permissions
return this.prisma.app.findMany({ return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
where: {
tenantId,
isActive: true,
},
include: {
pages: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
},
orderBy: { label: 'asc' },
});
} }
async getApp(tenantId: string, slug: string, userId: string) { async getApp(tenantId: string, slug: string, userId: string) {
const app = await this.prisma.app.findUnique({ const knex = await this.tenantDbService.getTenantKnex(tenantId);
where: { const app = await App.query(knex)
tenantId_slug: { .findOne({ slug })
tenantId, .withGraphFetched('pages');
slug,
},
},
include: {
pages: {
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
},
},
});
if (!app) { if (!app) {
throw new NotFoundException(`App ${slug} not found`); throw new NotFoundException(`App ${slug} not found`);
@@ -53,23 +35,12 @@ export class AppBuilderService {
pageSlug: string, pageSlug: string,
userId: string, userId: string,
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getApp(tenantId, appSlug, userId); const app = await this.getApp(tenantId, appSlug, userId);
const page = await this.prisma.appPage.findFirst({ const page = await AppPage.query(knex).findOne({
where: {
appId: app.id, appId: app.id,
slug: pageSlug, slug: pageSlug,
isActive: true,
},
include: {
object: {
include: {
fields: {
where: { isActive: true },
},
},
},
},
}); });
if (!page) { if (!page) {
@@ -81,31 +52,15 @@ export class AppBuilderService {
// Setup endpoints // Setup endpoints
async getAllApps(tenantId: string) { async getAllApps(tenantId: string) {
return this.prisma.app.findMany({ const knex = await this.tenantDbService.getTenantKnex(tenantId);
where: { tenantId }, return App.query(knex).withGraphFetched('pages').orderBy('label', 'asc');
include: {
pages: {
orderBy: { sortOrder: 'asc' },
},
},
orderBy: { label: 'asc' },
});
} }
async getAppForSetup(tenantId: string, slug: string) { async getAppForSetup(tenantId: string, slug: string) {
const app = await this.prisma.app.findUnique({ const knex = await this.tenantDbService.getTenantKnex(tenantId);
where: { const app = await App.query(knex)
tenantId_slug: { .findOne({ slug })
tenantId, .withGraphFetched('pages');
slug,
},
},
include: {
pages: {
orderBy: { sortOrder: 'asc' },
},
},
});
if (!app) { if (!app) {
throw new NotFoundException(`App ${slug} not found`); throw new NotFoundException(`App ${slug} not found`);
@@ -120,14 +75,12 @@ export class AppBuilderService {
slug: string; slug: string;
label: string; label: string;
description?: string; description?: string;
icon?: string;
}, },
) { ) {
return this.prisma.app.create({ const knex = await this.tenantDbService.getTenantKnex(tenantId);
data: { return App.query(knex).insert({
tenantId,
...data, ...data,
}, displayOrder: 0,
}); });
} }
@@ -137,16 +90,12 @@ export class AppBuilderService {
data: { data: {
label?: string; label?: string;
description?: string; description?: string;
icon?: string;
isActive?: boolean;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getAppForSetup(tenantId, slug); const app = await this.getAppForSetup(tenantId, slug);
return this.prisma.app.update({ return App.query(knex).patchAndFetchById(app.id, data);
where: { id: app.id },
data,
});
} }
async createPage( async createPage(
@@ -157,37 +106,19 @@ export class AppBuilderService {
label: string; label: string;
type: string; type: string;
objectApiName?: string; objectApiName?: string;
config?: any;
sortOrder?: number; sortOrder?: number;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug); const app = await this.getAppForSetup(tenantId, appSlug);
// If objectApiName is provided, find the object return AppPage.query(knex).insert({
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, appId: app.id,
slug: data.slug, slug: data.slug,
label: data.label, label: data.label,
type: data.type, type: data.type,
objectApiName: data.objectApiName, objectApiName: data.objectApiName,
objectId, displayOrder: data.sortOrder || 0,
config: data.config,
sortOrder: data.sortOrder || 0,
},
}); });
} }
@@ -199,44 +130,24 @@ export class AppBuilderService {
label?: string; label?: string;
type?: string; type?: string;
objectApiName?: string; objectApiName?: string;
config?: any;
sortOrder?: number; sortOrder?: number;
isActive?: boolean;
}, },
) { ) {
const knex = await this.tenantDbService.getTenantKnex(tenantId);
const app = await this.getAppForSetup(tenantId, appSlug); const app = await this.getAppForSetup(tenantId, appSlug);
const page = await this.prisma.appPage.findFirst({ const page = await AppPage.query(knex).findOne({
where: {
appId: app.id, appId: app.id,
slug: pageSlug, slug: pageSlug,
},
}); });
if (!page) { if (!page) {
throw new NotFoundException(`Page ${pageSlug} not found`); throw new NotFoundException(`Page ${pageSlug} not found`);
} }
// If objectApiName is provided, find the object return AppPage.query(knex).patchAndFetchById(page.id, {
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, ...data,
objectId, displayOrder: data.sortOrder,
},
}); });
} }
} }

View File

@@ -12,7 +12,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { TenantId } from '../tenant/tenant.decorator'; import { TenantId } from '../tenant/tenant.decorator';
@Controller('setup/apps') @Controller('setup/apps')
@UseGuards(JwtAuthGuard) //@UseGuards(JwtAuthGuard)
export class SetupAppController { export class SetupAppController {
constructor(private appBuilderService: AppBuilderService) {} constructor(private appBuilderService: AppBuilderService) {}
@@ -59,11 +59,6 @@ export class SetupAppController {
@Param('pageSlug') pageSlug: string, @Param('pageSlug') pageSlug: string,
@Body() data: any, @Body() data: any,
) { ) {
return this.appBuilderService.updatePage( return this.appBuilderService.updatePage(tenantId, appSlug, pageSlug, data);
tenantId,
appSlug,
pageSlug,
data,
);
} }
} }

View File

@@ -1,4 +1,5 @@
import { BaseModel } from './base.model'; import { BaseModel } from './base.model';
import { App } from './app.model';
export class AppPage extends BaseModel { export class AppPage extends BaseModel {
static tableName = 'app_pages'; static tableName = 'app_pages';
@@ -14,7 +15,7 @@ export class AppPage extends BaseModel {
static relationMappings = { static relationMappings = {
app: { app: {
relation: BaseModel.BelongsToOneRelation, relation: BaseModel.BelongsToOneRelation,
modelClass: 'app.model', modelClass: App,
join: { join: {
from: 'app_pages.appId', from: 'app_pages.appId',
to: 'apps.id', to: 'apps.id',

View File

@@ -1,4 +1,5 @@
import { BaseModel } from './base.model'; import { BaseModel } from './base.model';
import { AppPage } from './app-page.model';
export class App extends BaseModel { export class App extends BaseModel {
static tableName = 'apps'; static tableName = 'apps';
@@ -12,7 +13,7 @@ export class App extends BaseModel {
static relationMappings = { static relationMappings = {
pages: { pages: {
relation: BaseModel.HasManyRelation, relation: BaseModel.HasManyRelation,
modelClass: 'app-page.model', modelClass: AppPage,
join: { join: {
from: 'apps.id', from: 'apps.id',
to: 'app_pages.appId', to: 'app_pages.appId',

View File

@@ -1,6 +1,8 @@
import { Model, ModelOptions, QueryContext } from 'objection'; import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection';
export class BaseModel extends Model { export class BaseModel extends Model {
static columnNameMappers = snakeCaseMappers();
id: string; id: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View File

@@ -30,7 +30,7 @@ export class SchemaManagementService {
// Custom fields from field definitions // Custom fields from field definitions
for (const field of fields) { for (const field of fields) {
this.addFieldToTable(table, field); this.addFieldColumn(table, field);
} }
}); });
@@ -48,7 +48,7 @@ export class SchemaManagementService {
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(objectApiName);
await knex.schema.alterTable(tableName, (table) => { await knex.schema.alterTable(tableName, (table) => {
this.addFieldToTable(table, field); this.addFieldColumn(table, field);
}); });
this.logger.log(`Added field ${field.apiName} to table ${tableName}`); this.logger.log(`Added field ${field.apiName} to table ${tableName}`);
@@ -85,7 +85,7 @@ export class SchemaManagementService {
/** /**
* Add a field column to a table builder * Add a field column to a table builder
*/ */
private addFieldToTable( private addFieldColumn(
table: Knex.CreateTableBuilder | Knex.AlterTableBuilder, table: Knex.CreateTableBuilder | Knex.AlterTableBuilder,
field: FieldDefinition, field: FieldDefinition,
) { ) {

View File

@@ -180,8 +180,9 @@ export class TenantProvisioningService {
try { try {
// Create default roles // Create default roles
const [adminRoleId] = await tenantKnex('roles').insert({ const adminRoleId = crypto.randomUUID();
id: tenantKnex.raw('(UUID())'), await tenantKnex('roles').insert({
id: adminRoleId,
name: 'Admin', name: 'Admin',
guardName: 'api', guardName: 'api',
description: 'Full system administrator access', description: 'Full system administrator access',
@@ -189,8 +190,9 @@ export class TenantProvisioningService {
updated_at: tenantKnex.fn.now(), updated_at: tenantKnex.fn.now(),
}); });
const [userRoleId] = await tenantKnex('roles').insert({ const userRoleId = crypto.randomUUID();
id: tenantKnex.raw('(UUID())'), await tenantKnex('roles').insert({
id: userRoleId,
name: 'User', name: 'User',
guardName: 'api', guardName: 'api',
description: 'Standard user access', description: 'Standard user access',
@@ -212,7 +214,7 @@ export class TenantProvisioningService {
for (const perm of permissions) { for (const perm of permissions) {
await tenantKnex('permissions').insert({ await tenantKnex('permissions').insert({
id: tenantKnex.raw('(UUID())'), id: crypto.randomUUID(),
name: perm.name, name: perm.name,
guardName: 'api', guardName: 'api',
description: perm.description, description: perm.description,
@@ -225,7 +227,7 @@ export class TenantProvisioningService {
const allPermissions = await tenantKnex('permissions').select('id'); const allPermissions = await tenantKnex('permissions').select('id');
for (const perm of allPermissions) { for (const perm of allPermissions) {
await tenantKnex('role_permissions').insert({ await tenantKnex('role_permissions').insert({
id: tenantKnex.raw('(UUID())'), id: crypto.randomUUID(),
roleId: adminRoleId, roleId: adminRoleId,
permissionId: perm.id, permissionId: perm.id,
created_at: tenantKnex.fn.now(), created_at: tenantKnex.fn.now(),
@@ -239,7 +241,7 @@ export class TenantProvisioningService {
.select('id'); .select('id');
for (const perm of userPermissions) { for (const perm of userPermissions) {
await tenantKnex('role_permissions').insert({ await tenantKnex('role_permissions').insert({
id: tenantKnex.raw('(UUID())'), id: crypto.randomUUID(),
roleId: userRoleId, roleId: userRoleId,
permissionId: perm.id, permissionId: perm.id,
created_at: tenantKnex.fn.now(), created_at: tenantKnex.fn.now(),

View File

@@ -1,6 +1,17 @@
export const useApi = () => { export const useApi = () => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const apiBaseUrl = config.public.apiBaseUrl
// Use current domain for API calls (same subdomain routing)
const getApiBaseUrl = () => {
if (import.meta.client) {
// In browser, use current hostname but with port 3000 for API
const currentHost = window.location.hostname
const protocol = window.location.protocol
return `${protocol}//${currentHost}:3000`
}
// Fallback for SSR
return config.public.apiBaseUrl
}
const getHeaders = () => { const getHeaders = () => {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -25,7 +36,7 @@ export const useApi = () => {
const api = { const api = {
async get(path: string) { async get(path: string) {
const response = await fetch(`${apiBaseUrl}/api${path}`, { const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
headers: getHeaders(), headers: getHeaders(),
}) })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
@@ -33,7 +44,7 @@ export const useApi = () => {
}, },
async post(path: string, data: any) { async post(path: string, data: any) {
const response = await fetch(`${apiBaseUrl}/api${path}`, { const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -43,7 +54,7 @@ export const useApi = () => {
}, },
async put(path: string, data: any) { async put(path: string, data: any) {
const response = await fetch(`${apiBaseUrl}/api${path}`, { const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -53,7 +64,7 @@ export const useApi = () => {
}, },
async delete(path: string) { async delete(path: string) {
const response = await fetch(`${apiBaseUrl}/api${path}`, { const response = await fetch(`${getApiBaseUrl()}/api${path}`, {
method: 'DELETE', method: 'DELETE',
headers: getHeaders(), headers: getHeaders(),
}) })

View File

@@ -6,7 +6,7 @@ services:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: platform-api container_name: platform-api
command: npm run start:dev command: npm run start:dev -- --host 0.0.0.0
env_file: env_file:
- ../.env.api - ../.env.api
ports: ports: