feat(ee): add multi-organization workspaces feature

- Add `user_tenants` system DB migration for many-to-many user-to-org relationship
- Add backfill migration to populate existing users into join table
- Add `UserTenant` Objection.js system model and register globally
- Enforce org membership validation in `TenancyGlobalGuard` (security)
- Add `modules/ee/Workspaces` with full CRUD: create, list, delete, build-status
- Add `CreateUserTenantOnSignupSubscriber` for backward-compatible signup flow
- Register `WorkspacesModule` in `AppModule`

API endpoints:
  GET  /workspaces              - list all orgs user belongs to
  POST /workspaces              - create new org (async build)
  GET  /workspaces/build/:jobId - poll build job status
  DELETE /workspaces/:orgId     - delete org (owner only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ahmed Bouhuolia
2026-03-20 16:49:54 +02:00
parent cfbfc0b746
commit e968cf646c
15 changed files with 477 additions and 11 deletions

View File

@@ -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<WorkspaceDto[]> {
const userId = this.cls.get<number>('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<CreateWorkspaceResponseDto> {
const userId = this.cls.get<number>('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<void> {
const userId = this.cls.get<number>('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);
}
}