diff --git a/packages/server/src/database/system/migrations/20260320000001_create_user_tenants_table.js b/packages/server/src/database/system/migrations/20260320000001_create_user_tenants_table.js new file mode 100644 index 000000000..c8f03a29a --- /dev/null +++ b/packages/server/src/database/system/migrations/20260320000001_create_user_tenants_table.js @@ -0,0 +1,26 @@ +exports.up = function (knex) { + return knex.schema.createTable('user_tenants', (table) => { + table.bigIncrements('id'); + table + .integer('user_id') + .unsigned() + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table + .bigInteger('tenant_id') + .unsigned() + .notNullable() + .references('id') + .inTable('tenants') + .onDelete('CASCADE'); + table.string('role', 20).notNullable().defaultTo('owner'); + table.timestamps(); + table.unique(['user_id', 'tenant_id']); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('user_tenants'); +}; diff --git a/packages/server/src/database/system/migrations/20260320000002_backfill_user_tenants_from_users.js b/packages/server/src/database/system/migrations/20260320000002_backfill_user_tenants_from_users.js new file mode 100644 index 000000000..0356c86f4 --- /dev/null +++ b/packages/server/src/database/system/migrations/20260320000002_backfill_user_tenants_from_users.js @@ -0,0 +1,13 @@ +exports.up = function (knex) { + return knex.raw(` + INSERT IGNORE INTO user_tenants (user_id, tenant_id, role, created_at, updated_at) + SELECT id, tenant_id, 'owner', NOW(), NOW() + FROM users + WHERE tenant_id IS NOT NULL + `); +}; + +exports.down = function () { + // Cannot safely reverse a backfill. + return Promise.resolve(); +}; diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 8ae530926..9ad24e57d 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -81,6 +81,7 @@ import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module'; import { RolesModule } from '../Roles/Roles.module'; import { SubscriptionModule } from '../Subscription/Subscription.module'; import { OrganizationModule } from '../Organization/Organization.module'; +import { WorkspacesModule } from '../ee/Workspaces/Workspaces.module'; import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module'; import { PaymentServicesModule } from '../PaymentServices/PaymentServices.module'; import { AuthModule } from '../Auth/Auth.module'; @@ -243,6 +244,7 @@ import { AppThrottleModule } from './AppThrottle.module'; RolesModule, SubscriptionModule, OrganizationModule, + WorkspacesModule, TenantDBManagerModule, PaymentServicesModule, LoopsModule, diff --git a/packages/server/src/modules/System/SystemModels/SystemModels.module.ts b/packages/server/src/modules/System/SystemModels/SystemModels.module.ts index 5340a8bdc..958ca2717 100644 --- a/packages/server/src/modules/System/SystemModels/SystemModels.module.ts +++ b/packages/server/src/modules/System/SystemModels/SystemModels.module.ts @@ -7,9 +7,10 @@ import { SystemKnexConnection } from '../SystemDB/SystemDB.constants'; import { SystemModelsConnection } from './SystemModels.constants'; import { SystemUser } from '../models/SystemUser'; import { TenantMetadata } from '../models/TenantMetadataModel'; +import { UserTenant } from '../models/UserTenant.model'; import { TenantRepository } from '../repositories/Tenant.repository'; -const models = [SystemUser, PlanSubscription, TenantModel, TenantMetadata]; +const models = [SystemUser, PlanSubscription, TenantModel, TenantMetadata, UserTenant]; const modelProviders = models.map((model) => { return { diff --git a/packages/server/src/modules/System/models/UserTenant.model.ts b/packages/server/src/modules/System/models/UserTenant.model.ts new file mode 100644 index 000000000..e3bee18c5 --- /dev/null +++ b/packages/server/src/modules/System/models/UserTenant.model.ts @@ -0,0 +1,34 @@ +import { Model } from 'objection'; +import { BaseModel } from '@/models/Model'; +import { TenantModel } from './TenantModel'; + +export type UserTenantRole = 'owner' | 'member'; + +export class UserTenant extends BaseModel { + public userId: number; + public tenantId: number; + public role: UserTenantRole; + public tenant: TenantModel; + + static get tableName() { + return 'user_tenants'; + } + + static get relationMappings() { + const { SystemUser } = require('./SystemUser'); + const { TenantModel } = require('./TenantModel'); + + return { + user: { + relation: Model.BelongsToOneRelation, + modelClass: SystemUser, + join: { from: 'user_tenants.userId', to: 'users.id' }, + }, + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: TenantModel, + join: { from: 'user_tenants.tenantId', to: 'tenants.id' }, + }, + }; + } +} diff --git a/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts b/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts index 04310a100..2ee3c8d59 100644 --- a/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts +++ b/packages/server/src/modules/Tenancy/TenancyGlobal.guard.ts @@ -1,14 +1,17 @@ -import { isEmpty } from 'lodash'; import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, SetMetadata, + Inject, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { ClsService } from 'nestjs-cls'; import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants'; import { getAuthApiKey } from '../Auth/Auth.utils'; +import { UserTenant } from '../System/models/UserTenant.model'; +import { TenantModel } from '../System/models/TenantModel'; export const IS_TENANT_AGNOSTIC = 'IS_TENANT_AGNOSTIC'; @@ -16,33 +19,61 @@ export const TenantAgnosticRoute = () => SetMetadata(IS_TENANT_AGNOSTIC, true); @Injectable() export class TenancyGlobalGuard implements CanActivate { - constructor(private reflector: Reflector) {} + constructor( + private reflector: Reflector, + private readonly cls: ClsService, + + @Inject(UserTenant.name) + private readonly userTenantModel: typeof UserTenant, + + @Inject(TenantModel.name) + private readonly tenantModel: typeof TenantModel, + ) {} /** - * Validates the organization ID in the request headers. - * @param {ExecutionContext} context - * @returns {boolean} + * Validates the organization ID in the request headers and enforces + * that the authenticated user is a member of the requested organization. */ - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const organizationId = request.headers['organization-id']; const authorization = request.headers['authorization']?.trim(); const isAuthApiKey = !!getAuthApiKey(authorization || ''); - const isPublic = this.reflector.getAllAndOverride( - IS_PUBLIC_ROUTE, - [context.getHandler(), context.getClass()], - ); + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_ROUTE, [ + context.getHandler(), + context.getClass(), + ]); const isTenantAgnostic = this.reflector.getAllAndOverride( IS_TENANT_AGNOSTIC, [context.getHandler(), context.getClass()], ); + if (isPublic || isTenantAgnostic || isAuthApiKey) { return true; } if (!organizationId) { throw new UnauthorizedException('Organization ID is required.'); } + + // Validate that the authenticated user is a member of the requested organization. + const userId = this.cls.get('userId'); + + const tenant = await this.tenantModel.query().findOne({ organizationId }); + if (!tenant) { + throw new UnauthorizedException('Organization not found.'); + } + + const membership = await this.userTenantModel + .query() + .findOne({ userId, tenantId: tenant.id }); + + if (!membership) { + throw new UnauthorizedException( + 'You do not have access to this organization.', + ); + } + return true; } } diff --git a/packages/server/src/modules/ee/Workspaces/Workspaces.controller.ts b/packages/server/src/modules/ee/Workspaces/Workspaces.controller.ts new file mode 100644 index 000000000..39059c834 --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/Workspaces.controller.ts @@ -0,0 +1,95 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ClsService } from 'nestjs-cls'; +import { TenantAgnosticRoute } from '@/modules/Tenancy/TenancyGlobal.guard'; +import { IgnoreUserVerifiedRoute } from '@/modules/Auth/guards/EnsureUserVerified.guard'; +import { IgnoreTenantInitializedRoute } from '@/modules/Tenancy/EnsureTenantIsInitialized.guard'; +import { IgnoreTenantSeededRoute } from '@/modules/Tenancy/EnsureTenantIsSeeded.guards'; +import { IgnoreTenantModelsInitialize } from '@/modules/Tenancy/TenancyInitializeModels.guard'; +import { CreateWorkspaceService } from './commands/CreateWorkspace.service'; +import { DeleteWorkspaceService } from './commands/DeleteWorkspace.service'; +import { GetWorkspacesService } from './queries/GetWorkspaces.service'; +import { GetWorkspaceBuildJobService } from './queries/GetWorkspaceBuildJob.service'; +import { CreateWorkspaceDto } from './dtos/CreateWorkspace.dto'; +import { + CreateWorkspaceResponseDto, + WorkspaceDto, +} from './dtos/WorkspaceResponse.dto'; + +@ApiTags('Workspaces') +@Controller('workspaces') +export class WorkspacesController { + constructor( + private readonly createWorkspaceService: CreateWorkspaceService, + private readonly deleteWorkspaceService: DeleteWorkspaceService, + private readonly getWorkspacesService: GetWorkspacesService, + private readonly getWorkspaceBuildJobService: GetWorkspaceBuildJobService, + private readonly cls: ClsService, + ) {} + + /** + * Lists all organizations the authenticated user belongs to. + * No `organization-id` header required. + */ + @Get() + @TenantAgnosticRoute() + @IgnoreUserVerifiedRoute() + @ApiOperation({ summary: 'List workspaces the authenticated user belongs to' }) + async listWorkspaces(): Promise { + const userId = this.cls.get('userId'); + return this.getWorkspacesService.getWorkspaces(userId); + } + + /** + * Creates a new workspace (organization) for the authenticated user. + * The organization database is built asynchronously via a background job. + * No `organization-id` header required. + */ + @Post() + @TenantAgnosticRoute() + @IgnoreUserVerifiedRoute() + @HttpCode(200) + @ApiOperation({ summary: 'Create a new workspace' }) + async createWorkspace( + @Body() dto: CreateWorkspaceDto, + ): Promise { + const userId = this.cls.get('userId'); + return this.createWorkspaceService.createWorkspace(userId, dto); + } + + /** + * Deletes a workspace. Only the workspace owner is permitted to delete it. + * Requires `organization-id` header (must match the path param). + */ + @Delete(':organizationId') + @IgnoreTenantInitializedRoute() + @IgnoreTenantSeededRoute() + @IgnoreTenantModelsInitialize() + @HttpCode(200) + @ApiOperation({ summary: 'Delete a workspace (owner only)' }) + async deleteWorkspace( + @Param('organizationId') organizationId: string, + ): Promise { + const userId = this.cls.get('userId'); + await this.deleteWorkspaceService.deleteWorkspace(userId, organizationId); + } + + /** + * Polls the build job status for a workspace being provisioned. + * No `organization-id` header required. + */ + @Get('build/:buildJobId') + @TenantAgnosticRoute() + @ApiOperation({ summary: 'Get workspace build job status' }) + async buildJobStatus(@Param('buildJobId') buildJobId: string) { + return this.getWorkspaceBuildJobService.getJobDetails(buildJobId); + } +} diff --git a/packages/server/src/modules/ee/Workspaces/Workspaces.module.ts b/packages/server/src/modules/ee/Workspaces/Workspaces.module.ts new file mode 100644 index 000000000..547d5c6a6 --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/Workspaces.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { WorkspacesController } from './Workspaces.controller'; +import { CreateWorkspaceService } from './commands/CreateWorkspace.service'; +import { DeleteWorkspaceService } from './commands/DeleteWorkspace.service'; +import { GetWorkspacesService } from './queries/GetWorkspaces.service'; +import { GetWorkspaceBuildJobService } from './queries/GetWorkspaceBuildJob.service'; +import { CreateUserTenantOnSignupSubscriber } from './subscribers/CreateUserTenantOnSignup.subscriber'; +import { OrganizationBuildQueue } from '@/modules/Organization/Organization.types'; +import { InjectSystemModel } from '@/modules/System/SystemModels/SystemModels.module'; +import { UserTenant } from '@/modules/System/models/UserTenant.model'; +import { TenantDBManagerModule } from '@/modules/TenantDBManager/TenantDBManager.module'; +import { GetBuildOrganizationBuildJob } from '@/modules/Organization/commands/GetBuildOrganizationJob.service'; +import { TenantRepository } from '@/modules/System/repositories/Tenant.repository'; + +@Module({ + imports: [ + BullModule.registerQueue({ name: OrganizationBuildQueue }), + TenantDBManagerModule, + ], + controllers: [WorkspacesController], + providers: [ + InjectSystemModel(UserTenant), + TenantRepository, + CreateWorkspaceService, + DeleteWorkspaceService, + GetWorkspacesService, + GetWorkspaceBuildJobService, + CreateUserTenantOnSignupSubscriber, + GetBuildOrganizationBuildJob, + ], +}) +export class WorkspacesModule {} diff --git a/packages/server/src/modules/ee/Workspaces/commands/CreateWorkspace.service.ts b/packages/server/src/modules/ee/Workspaces/commands/CreateWorkspace.service.ts new file mode 100644 index 000000000..bcb3a4e0d --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/commands/CreateWorkspace.service.ts @@ -0,0 +1,70 @@ +import { Queue } from 'bullmq'; +import { Inject, Injectable } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { UserTenant } from '@/modules/System/models/UserTenant.model'; +import { TenantRepository } from '@/modules/System/repositories/Tenant.repository'; +import { + OrganizationBuildQueue, + OrganizationBuildQueueJob, + OrganizationBuildQueueJobPayload, +} from '@/modules/Organization/Organization.types'; +import { transformBuildDto } from '@/modules/Organization/Organization.utils'; +import { CreateWorkspaceDto } from '../dtos/CreateWorkspace.dto'; +import { CreateWorkspaceResponseDto } from '../dtos/WorkspaceResponse.dto'; + +@Injectable() +export class CreateWorkspaceService { + constructor( + @Inject(UserTenant.name) + private readonly userTenantModel: typeof UserTenant, + + private readonly tenantRepository: TenantRepository, + + @InjectQueue(OrganizationBuildQueue) + private readonly organizationBuildQueue: Queue, + ) {} + + /** + * Creates a new workspace (organization) for the authenticated user. + * - Creates a new tenant row with a unique organizationId. + * - Links the user as owner via user_tenants. + * - Saves organization metadata. + * - Enqueues the tenant database build job. + */ + async createWorkspace( + userId: number, + dto: CreateWorkspaceDto, + ): Promise { + // Create the new tenant row. + const tenant = await this.tenantRepository.createWithUniqueOrgId(); + + // Link the authenticated user as the owner of this new workspace. + await this.userTenantModel.query().insert({ + userId, + tenantId: tenant.id, + role: 'owner', + }); + + // Transform and persist the organization metadata. + const transformedDto = transformBuildDto(dto); + await this.tenantRepository.saveMetadata(tenant.id, transformedDto); + + // Enqueue the build job using the same queue and processor as the existing flow. + const jobMeta = await this.organizationBuildQueue.add( + OrganizationBuildQueueJob, + { + organizationId: tenant.organizationId, + userId, + buildDto: transformedDto, + } as OrganizationBuildQueueJobPayload, + ); + + // Mark the tenant as currently building. + await this.tenantRepository.markAsBuilding(jobMeta.id).findById(tenant.id); + + return { + organizationId: tenant.organizationId, + jobId: jobMeta.id, + }; + } +} diff --git a/packages/server/src/modules/ee/Workspaces/commands/DeleteWorkspace.service.ts b/packages/server/src/modules/ee/Workspaces/commands/DeleteWorkspace.service.ts new file mode 100644 index 000000000..b593be138 --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/commands/DeleteWorkspace.service.ts @@ -0,0 +1,51 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { UserTenant } from '@/modules/System/models/UserTenant.model'; +import { TenantModel } from '@/modules/System/models/TenantModel'; +import { TenantRepository } from '@/modules/System/repositories/Tenant.repository'; +import { TenantDBManager } from '@/modules/TenantDBManager/TenantDBManager'; + +const ERRORS = { + WORKSPACE_NOT_FOUND: 'WORKSPACE.NOT_FOUND', + NOT_WORKSPACE_OWNER: 'NOT.WORKSPACE.OWNER', +}; + +@Injectable() +export class DeleteWorkspaceService { + constructor( + @Inject(UserTenant.name) + private readonly userTenantModel: typeof UserTenant, + + @Inject(TenantModel.name) + private readonly tenantModel: typeof TenantModel, + + private readonly tenantRepository: TenantRepository, + private readonly tenantDBManager: TenantDBManager, + ) {} + + /** + * Deletes a workspace (organization). Only the owner of the workspace + * is permitted to delete it. + * - Drops the physical tenant database. + * - Deletes the tenant row (cascades to user_tenants). + */ + async deleteWorkspace(userId: number, organizationId: string): Promise { + const tenant = await this.tenantModel.query().findOne({ organizationId }); + + if (!tenant) { + throw new ServiceError(ERRORS.WORKSPACE_NOT_FOUND); + } + const membership = await this.userTenantModel + .query() + .findOne({ userId, tenantId: tenant.id }); + + if (!membership || membership.role !== 'owner') { + throw new ServiceError(ERRORS.NOT_WORKSPACE_OWNER); + } + // Drop the physical tenant database if it exists. + await this.tenantDBManager.dropDatabaseIfExists(); + + // Delete the tenant row — cascades to user_tenants via FK. + await this.tenantModel.query().deleteById(tenant.id); + } +} diff --git a/packages/server/src/modules/ee/Workspaces/dtos/CreateWorkspace.dto.ts b/packages/server/src/modules/ee/Workspaces/dtos/CreateWorkspace.dto.ts new file mode 100644 index 000000000..331f2a5fa --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/dtos/CreateWorkspace.dto.ts @@ -0,0 +1,3 @@ +import { BuildOrganizationDto } from '@/modules/Organization/dtos/Organization.dto'; + +export class CreateWorkspaceDto extends BuildOrganizationDto {} diff --git a/packages/server/src/modules/ee/Workspaces/dtos/WorkspaceResponse.dto.ts b/packages/server/src/modules/ee/Workspaces/dtos/WorkspaceResponse.dto.ts new file mode 100644 index 000000000..0d041e1a2 --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/dtos/WorkspaceResponse.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class WorkspaceMetadataDto { + @ApiProperty() name: string; + @ApiProperty() baseCurrency: string; + @ApiPropertyOptional() industry?: string; + @ApiPropertyOptional() location?: string; + @ApiPropertyOptional() timezone?: string; + @ApiPropertyOptional() language?: string; +} + +export class WorkspaceDto { + @ApiProperty() organizationId: string; + @ApiProperty() isReady: boolean; + @ApiProperty() isBuildRunning: boolean; + @ApiPropertyOptional() buildJobId?: string; + @ApiProperty() role: 'owner' | 'member'; + @ApiPropertyOptional({ type: WorkspaceMetadataDto }) + metadata?: WorkspaceMetadataDto; +} + +export class CreateWorkspaceResponseDto { + @ApiProperty() organizationId: string; + @ApiProperty() jobId: string; +} diff --git a/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaceBuildJob.service.ts b/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaceBuildJob.service.ts new file mode 100644 index 000000000..b248374e9 --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaceBuildJob.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { GetBuildOrganizationBuildJob } from '@/modules/Organization/commands/GetBuildOrganizationJob.service'; + +@Injectable() +export class GetWorkspaceBuildJobService { + constructor( + private readonly getBuildJobService: GetBuildOrganizationBuildJob, + ) {} + + /** + * Returns the current status of a workspace build job. + */ + getJobDetails(buildJobId: string) { + return this.getBuildJobService.getJobDetails(buildJobId); + } +} diff --git a/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaces.service.ts b/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaces.service.ts new file mode 100644 index 000000000..fe26fec27 --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/queries/GetWorkspaces.service.ts @@ -0,0 +1,40 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { UserTenant } from '@/modules/System/models/UserTenant.model'; +import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto'; + +@Injectable() +export class GetWorkspacesService { + constructor( + @Inject(UserTenant.name) + private readonly userTenantModel: typeof UserTenant, + ) {} + + /** + * Returns all workspaces (organizations) the given user belongs to, + * including their metadata and build status. + */ + async getWorkspaces(userId: number): Promise { + const memberships = await this.userTenantModel + .query() + .where('userId', userId) + .withGraphFetched('tenant.metadata'); + + return memberships.map((m) => ({ + organizationId: m.tenant.organizationId, + isReady: m.tenant.isReady, + isBuildRunning: m.tenant.isBuildRunning, + buildJobId: m.tenant.buildJobId ?? undefined, + role: m.role, + metadata: m.tenant.metadata + ? { + name: m.tenant.metadata.name, + baseCurrency: m.tenant.metadata.baseCurrency, + industry: m.tenant.metadata.industry, + location: m.tenant.metadata.location, + timezone: m.tenant.metadata.timezone, + language: m.tenant.metadata.language, + } + : undefined, + })); + } +} diff --git a/packages/server/src/modules/ee/Workspaces/subscribers/CreateUserTenantOnSignup.subscriber.ts b/packages/server/src/modules/ee/Workspaces/subscribers/CreateUserTenantOnSignup.subscriber.ts new file mode 100644 index 000000000..af1e5a748 --- /dev/null +++ b/packages/server/src/modules/ee/Workspaces/subscribers/CreateUserTenantOnSignup.subscriber.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { IAuthSignedUpEventPayload } from '@/modules/Auth/Auth.interfaces'; +import { UserTenant } from '@/modules/System/models/UserTenant.model'; + +@Injectable() +export class CreateUserTenantOnSignupSubscriber { + constructor( + @Inject(UserTenant.name) + private readonly userTenantModel: typeof UserTenant, + ) {} + + /** + * On user sign-up, create a user_tenants record linking the new user + * to their new organization as the owner. + */ + @OnEvent(events.auth.signUp) + async handleSignUp({ user, tenant }: IAuthSignedUpEventPayload): Promise { + await this.userTenantModel.query().insert({ + userId: user.id, + tenantId: tenant.id, + role: 'owner', + }); + } +}