diff --git a/backend/migrations/tenant/20250126000003_create_apps.js b/backend/migrations/tenant/20250126000003_create_apps.js index 8a0ab79..2b4a6f7 100644 --- a/backend/migrations/tenant/20250126000003_create_apps.js +++ b/backend/migrations/tenant/20250126000003_create_apps.js @@ -5,28 +5,28 @@ exports.up = function (knex) { table.string('slug', 255).notNullable().unique(); table.string('label', 255).notNullable(); table.text('description'); - table.integer('displayOrder').defaultTo(0); + table.integer('display_order').defaultTo(0); table.timestamps(true, true); table.index(['slug']); }) .createTable('app_pages', (table) => { table.uuid('id').primary().defaultTo(knex.raw('(UUID())')); - table.uuid('appId').notNullable(); + table.uuid('app_id').notNullable(); table.string('slug', 255).notNullable(); table.string('label', 255).notNullable(); table.string('type', 50).notNullable(); // List, Detail, Custom - table.string('objectApiName', 255); - table.integer('displayOrder').defaultTo(0); + table.string('object_api_name', 255); + table.integer('display_order').defaultTo(0); table.timestamps(true, true); table - .foreign('appId') + .foreign('app_id') .references('id') .inTable('apps') .onDelete('CASCADE'); - table.unique(['appId', 'slug']); - table.index(['appId']); + table.unique(['app_id', 'slug']); + table.index(['app_id']); }); }; diff --git a/backend/prisma/migrations/20251129042156_add_tenant_db_fields/migration.sql b/backend/prisma/migrations/20251129042156_add_tenant_db_fields/migration.sql new file mode 100644 index 0000000..eda7b2e --- /dev/null +++ b/backend/prisma/migrations/20251129042156_add_tenant_db_fields/migration.sql @@ -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; diff --git a/backend/src/app-builder/app-builder.module.ts b/backend/src/app-builder/app-builder.module.ts index ce29bb1..1f5d188 100644 --- a/backend/src/app-builder/app-builder.module.ts +++ b/backend/src/app-builder/app-builder.module.ts @@ -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], diff --git a/backend/src/app-builder/app-builder.service.ts b/backend/src/app-builder/app-builder.service.ts index 5b0a840..76581e7 100644 --- a/backend/src/app-builder/app-builder.service.ts +++ b/backend/src/app-builder/app-builder.service.ts @@ -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, }); } } diff --git a/backend/src/app-builder/setup-app.controller.ts b/backend/src/app-builder/setup-app.controller.ts index dbf29f9..28c5ad1 100644 --- a/backend/src/app-builder/setup-app.controller.ts +++ b/backend/src/app-builder/setup-app.controller.ts @@ -12,7 +12,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { TenantId } from '../tenant/tenant.decorator'; @Controller('setup/apps') -@UseGuards(JwtAuthGuard) +//@UseGuards(JwtAuthGuard) export class SetupAppController { constructor(private appBuilderService: AppBuilderService) {} @@ -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); } } diff --git a/backend/src/models/app-page.model.ts b/backend/src/models/app-page.model.ts index 49fbaf2..3f66667 100644 --- a/backend/src/models/app-page.model.ts +++ b/backend/src/models/app-page.model.ts @@ -1,4 +1,5 @@ import { BaseModel } from './base.model'; +import { App } from './app.model'; export class AppPage extends BaseModel { static tableName = 'app_pages'; @@ -14,7 +15,7 @@ export class AppPage extends BaseModel { static relationMappings = { app: { relation: BaseModel.BelongsToOneRelation, - modelClass: 'app.model', + modelClass: App, join: { from: 'app_pages.appId', to: 'apps.id', diff --git a/backend/src/models/app.model.ts b/backend/src/models/app.model.ts index b88b935..531674d 100644 --- a/backend/src/models/app.model.ts +++ b/backend/src/models/app.model.ts @@ -1,4 +1,5 @@ import { BaseModel } from './base.model'; +import { AppPage } from './app-page.model'; export class App extends BaseModel { static tableName = 'apps'; @@ -12,7 +13,7 @@ export class App extends BaseModel { static relationMappings = { pages: { relation: BaseModel.HasManyRelation, - modelClass: 'app-page.model', + modelClass: AppPage, join: { from: 'apps.id', to: 'app_pages.appId', diff --git a/backend/src/models/base.model.ts b/backend/src/models/base.model.ts index 3be990e..259e992 100644 --- a/backend/src/models/base.model.ts +++ b/backend/src/models/base.model.ts @@ -1,6 +1,8 @@ -import { Model, ModelOptions, QueryContext } from 'objection'; +import { Model, ModelOptions, QueryContext, snakeCaseMappers } from 'objection'; export class BaseModel extends Model { + static columnNameMappers = snakeCaseMappers(); + id: string; createdAt: Date; updatedAt: Date; diff --git a/backend/src/object/schema-management.service.ts b/backend/src/object/schema-management.service.ts index 00a9cbe..7f932b5 100644 --- a/backend/src/object/schema-management.service.ts +++ b/backend/src/object/schema-management.service.ts @@ -30,7 +30,7 @@ export class SchemaManagementService { // Custom fields from field definitions 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); await knex.schema.alterTable(tableName, (table) => { - this.addFieldToTable(table, field); + this.addFieldColumn(table, field); }); 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 */ - private addFieldToTable( + private addFieldColumn( table: Knex.CreateTableBuilder | Knex.AlterTableBuilder, field: FieldDefinition, ) { diff --git a/backend/src/tenant/tenant-provisioning.service.ts b/backend/src/tenant/tenant-provisioning.service.ts index e379f57..46acd31 100644 --- a/backend/src/tenant/tenant-provisioning.service.ts +++ b/backend/src/tenant/tenant-provisioning.service.ts @@ -180,8 +180,9 @@ export class TenantProvisioningService { try { // Create default roles - const [adminRoleId] = await tenantKnex('roles').insert({ - id: tenantKnex.raw('(UUID())'), + const adminRoleId = crypto.randomUUID(); + await tenantKnex('roles').insert({ + id: adminRoleId, name: 'Admin', guardName: 'api', description: 'Full system administrator access', @@ -189,8 +190,9 @@ export class TenantProvisioningService { updated_at: tenantKnex.fn.now(), }); - const [userRoleId] = await tenantKnex('roles').insert({ - id: tenantKnex.raw('(UUID())'), + const userRoleId = crypto.randomUUID(); + await tenantKnex('roles').insert({ + id: userRoleId, name: 'User', guardName: 'api', description: 'Standard user access', @@ -212,7 +214,7 @@ export class TenantProvisioningService { for (const perm of permissions) { await tenantKnex('permissions').insert({ - id: tenantKnex.raw('(UUID())'), + id: crypto.randomUUID(), name: perm.name, guardName: 'api', description: perm.description, @@ -225,7 +227,7 @@ export class TenantProvisioningService { const allPermissions = await tenantKnex('permissions').select('id'); for (const perm of allPermissions) { await tenantKnex('role_permissions').insert({ - id: tenantKnex.raw('(UUID())'), + id: crypto.randomUUID(), roleId: adminRoleId, permissionId: perm.id, created_at: tenantKnex.fn.now(), @@ -239,7 +241,7 @@ export class TenantProvisioningService { .select('id'); for (const perm of userPermissions) { await tenantKnex('role_permissions').insert({ - id: tenantKnex.raw('(UUID())'), + id: crypto.randomUUID(), roleId: userRoleId, permissionId: perm.id, created_at: tenantKnex.fn.now(), diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts index 8659d0c..30439a5 100644 --- a/frontend/composables/useApi.ts +++ b/frontend/composables/useApi.ts @@ -1,6 +1,17 @@ export const useApi = () => { 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 headers: Record = { @@ -25,7 +36,7 @@ export const useApi = () => { const api = { async get(path: string) { - const response = await fetch(`${apiBaseUrl}/api${path}`, { + const response = await fetch(`${getApiBaseUrl()}/api${path}`, { headers: getHeaders(), }) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) @@ -33,7 +44,7 @@ export const useApi = () => { }, async post(path: string, data: any) { - const response = await fetch(`${apiBaseUrl}/api${path}`, { + const response = await fetch(`${getApiBaseUrl()}/api${path}`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(data), @@ -43,7 +54,7 @@ export const useApi = () => { }, async put(path: string, data: any) { - const response = await fetch(`${apiBaseUrl}/api${path}`, { + const response = await fetch(`${getApiBaseUrl()}/api${path}`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify(data), @@ -53,7 +64,7 @@ export const useApi = () => { }, async delete(path: string) { - const response = await fetch(`${apiBaseUrl}/api${path}`, { + const response = await fetch(`${getApiBaseUrl()}/api${path}`, { method: 'DELETE', headers: getHeaders(), }) diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index e063038..40ee024 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -6,7 +6,7 @@ services: context: ../backend dockerfile: Dockerfile container_name: platform-api - command: npm run start:dev + command: npm run start:dev -- --host 0.0.0.0 env_file: - ../.env.api ports: