[DASHBOARDS] Dashboard duplication (#16291)

## Description

- Created a Dashboard duplication action
- Created a new duplication custom resolver
- Created the service using the v2 of the API
- Created the integration tests following the v2 methodology

## Video QA


https://github.com/user-attachments/assets/e409951a-5946-4da0-91a0-1f7d2ecadb08
This commit is contained in:
Raphaël Bosi
2025-12-11 12:15:15 +01:00
committed by GitHub
parent 6c728fff78
commit 5af2d37253
39 changed files with 1542 additions and 79 deletions
@@ -0,0 +1,30 @@
import { Controller, Param, Post, UseFilters, UseGuards } from '@nestjs/common';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DuplicatedDashboardDTO } from 'src/modules/dashboard/dtos/duplicated-dashboard.dto';
import { DashboardRestApiExceptionFilter } from 'src/modules/dashboard/filters/dashboard-rest-api-exception.filter';
import { DashboardDuplicationService } from 'src/modules/dashboard/services/dashboard-duplication.service';
@Controller('rest/dashboards')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UseFilters(DashboardRestApiExceptionFilter)
export class DashboardController {
constructor(
private readonly dashboardDuplicationService: DashboardDuplicationService,
) {}
@Post(':id/duplicate')
async duplicate(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<DuplicatedDashboardDTO> {
return this.dashboardDuplicationService.duplicateDashboard(
id,
workspace.id,
);
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { DashboardController } from 'src/modules/dashboard/controllers/dashboard.controller';
import { DashboardResolver } from 'src/modules/dashboard/resolvers/dashboard.resolver';
import { DashboardDuplicationService } from 'src/modules/dashboard/services/dashboard-duplication.service';
@Module({
imports: [
AuthModule,
PageLayoutModule,
TwentyORMModule,
WorkspaceCacheStorageModule,
],
controllers: [DashboardController],
providers: [DashboardDuplicationService, DashboardResolver],
exports: [DashboardDuplicationService],
})
export class DashboardModule {}
@@ -0,0 +1,24 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('DuplicatedDashboard')
export class DuplicatedDashboardDTO {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String, { nullable: true })
title: string | null;
@Field(() => UUIDScalarType, { nullable: true })
pageLayoutId: string | null;
@Field(() => Number)
position: number;
@Field(() => String)
createdAt: string;
@Field(() => String)
updatedAt: string;
}
@@ -0,0 +1,33 @@
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum DashboardExceptionCode {
DASHBOARD_NOT_FOUND = 'DASHBOARD_NOT_FOUND',
DASHBOARD_DUPLICATION_FAILED = 'DASHBOARD_DUPLICATION_FAILED',
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
}
export enum DashboardExceptionMessageKey {
DASHBOARD_NOT_FOUND = 'DASHBOARD_NOT_FOUND',
DASHBOARD_DUPLICATION_FAILED = 'DASHBOARD_DUPLICATION_FAILED',
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
}
export class DashboardException extends CustomException<DashboardExceptionCode> {}
export const generateDashboardExceptionMessage = (
key: DashboardExceptionMessageKey,
value?: string,
): string => {
switch (key) {
case DashboardExceptionMessageKey.DASHBOARD_NOT_FOUND:
return `Dashboard with ID "${value}" not found`;
case DashboardExceptionMessageKey.DASHBOARD_DUPLICATION_FAILED:
return `Failed to duplicate dashboard: ${value}`;
case DashboardExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND:
return `Page layout for dashboard "${value}" not found`;
default:
assertUnreachable(key);
}
};
@@ -0,0 +1,47 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
Injectable,
} from '@nestjs/common';
import { type Response } from 'express';
import { assertUnreachable } from 'twenty-shared/utils';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
DashboardException,
DashboardExceptionCode,
} from 'src/modules/dashboard/exceptions/dashboard.exception';
import { type CustomException } from 'src/utils/custom-exception';
@Injectable()
@Catch(DashboardException)
export class DashboardRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: DashboardException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case DashboardExceptionCode.DASHBOARD_NOT_FOUND:
case DashboardExceptionCode.PAGE_LAYOUT_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case DashboardExceptionCode.DASHBOARD_DUPLICATION_FAILED:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
500,
);
default:
assertUnreachable(exception.code);
}
}
}
@@ -0,0 +1,38 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { DuplicatedDashboardDTO } from 'src/modules/dashboard/dtos/duplicated-dashboard.dto';
import { DashboardDuplicationService } from 'src/modules/dashboard/services/dashboard-duplication.service';
import { DashboardGraphqlApiExceptionFilter } from 'src/modules/dashboard/utils/dashboard-graphql-api-exception.filter';
@Resolver()
@UseFilters(
DashboardGraphqlApiExceptionFilter,
PageLayoutGraphqlApiExceptionFilter,
)
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
export class DashboardResolver {
constructor(
private readonly dashboardDuplicationService: DashboardDuplicationService,
) {}
@Mutation(() => DuplicatedDashboardDTO)
@UseGuards(NoPermissionGuard)
async duplicateDashboard(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<DuplicatedDashboardDTO> {
return this.dashboardDuplicationService.duplicateDashboard(
id,
workspace.id,
);
}
}
@@ -0,0 +1,125 @@
import { Injectable, Logger } from '@nestjs/common';
import { appendCopySuffix, isDefined } from 'twenty-shared/utils';
import { PageLayoutDuplicationService } from 'src/engine/metadata-modules/page-layout/services/page-layout-duplication.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { DuplicatedDashboardDTO } from 'src/modules/dashboard/dtos/duplicated-dashboard.dto';
import {
DashboardException,
DashboardExceptionCode,
DashboardExceptionMessageKey,
generateDashboardExceptionMessage,
} from 'src/modules/dashboard/exceptions/dashboard.exception';
import { DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity';
@Injectable()
export class DashboardDuplicationService {
private readonly logger = new Logger(DashboardDuplicationService.name);
constructor(
private readonly pageLayoutDuplicationService: PageLayoutDuplicationService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async duplicateDashboard(
dashboardId: string,
workspaceId: string,
): Promise<DuplicatedDashboardDTO> {
const dashboardRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<DashboardWorkspaceEntity>(
workspaceId,
'dashboard',
{ shouldBypassPermissionChecks: true },
);
const originalDashboard = await dashboardRepository.findOne({
where: { id: dashboardId },
});
if (!isDefined(originalDashboard)) {
throw new DashboardException(
generateDashboardExceptionMessage(
DashboardExceptionMessageKey.DASHBOARD_NOT_FOUND,
dashboardId,
),
DashboardExceptionCode.DASHBOARD_NOT_FOUND,
);
}
if (!isDefined(originalDashboard.pageLayoutId)) {
throw new DashboardException(
generateDashboardExceptionMessage(
DashboardExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
dashboardId,
),
DashboardExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
try {
const newPageLayout = await this.pageLayoutDuplicationService.duplicate({
pageLayoutId: originalDashboard.pageLayoutId,
workspaceId,
});
const newDashboard = await this.createDuplicatedDashboard(
originalDashboard,
newPageLayout.id,
dashboardRepository,
);
return {
id: newDashboard.id,
title: newDashboard.title,
pageLayoutId: newDashboard.pageLayoutId,
position: newDashboard.position,
createdAt: newDashboard.createdAt,
updatedAt: newDashboard.updatedAt,
};
} catch (error) {
this.logger.error(
`Failed to duplicate dashboard ${dashboardId}: ${error.message}`,
error.stack,
);
throw error;
}
}
private async createDuplicatedDashboard(
originalDashboard: DashboardWorkspaceEntity,
newPageLayoutId: string,
dashboardRepository: Awaited<
ReturnType<
typeof this.twentyORMGlobalManager.getRepositoryForWorkspace<DashboardWorkspaceEntity>
>
>,
): Promise<DashboardWorkspaceEntity> {
const newTitle = appendCopySuffix(originalDashboard.title ?? '');
const insertResult = await dashboardRepository.insert({
title: newTitle,
pageLayoutId: newPageLayoutId,
position: originalDashboard.position,
});
const newDashboardId = insertResult.identifiers[0].id;
const newDashboard = await dashboardRepository.findOne({
where: { id: newDashboardId },
});
if (!isDefined(newDashboard)) {
throw new DashboardException(
generateDashboardExceptionMessage(
DashboardExceptionMessageKey.DASHBOARD_DUPLICATION_FAILED,
'Failed to retrieve created dashboard',
),
DashboardExceptionCode.DASHBOARD_DUPLICATION_FAILED,
);
}
return newDashboard;
}
}
@@ -0,0 +1,28 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
InternalServerError,
NotFoundError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
DashboardException,
DashboardExceptionCode,
} from 'src/modules/dashboard/exceptions/dashboard.exception';
export const dashboardGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof DashboardException) {
switch (error.code) {
case DashboardExceptionCode.DASHBOARD_NOT_FOUND:
throw new NotFoundError(error.message);
case DashboardExceptionCode.PAGE_LAYOUT_NOT_FOUND:
throw new NotFoundError(error.message);
case DashboardExceptionCode.DASHBOARD_DUPLICATION_FAILED:
throw new InternalServerError(error.message);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -0,0 +1,12 @@
import { ArgumentsHost, Catch } from '@nestjs/common';
import { GqlExceptionFilter } from '@nestjs/graphql';
import { DashboardException } from 'src/modules/dashboard/exceptions/dashboard.exception';
import { dashboardGraphqlApiExceptionHandler } from 'src/modules/dashboard/utils/dashboard-graphql-api-exception-handler.util';
@Catch(DashboardException)
export class DashboardGraphqlApiExceptionFilter implements GqlExceptionFilter {
catch(exception: DashboardException, _host: ArgumentsHost) {
return dashboardGraphqlApiExceptionHandler(exception);
}
}