Files
twenty/packages/twenty-server/src/engine/metadata-modules/view-group/controllers/view-group.controller.ts
T
Weiko 70bb011daa fix: map FlatEntityMaps and WorkspaceMigrationRunner exceptions to proper status codes on REST and GraphQL (#20494)
## Context

Calling `POST /rest/views` (and other metadata mutations) currently
returns a generic `500` for user-input failures:

Ex:
1. **Invalid `objectMetadataId`** —
`resolveEntityRelationUniversalIdentifiers` throws
`FlatEntityMapsException(RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND)`.
Should be `404`.
2. **Missing required field** (e.g. `icon`) — Postgres raises a `NOT
NULL` violation, wrapped as
`WorkspaceMigrationRunnerException(EXECUTION_FAILED)` carrying a
`QueryFailedError`. Should be `400`.

Neither was caught by `ViewRestApiExceptionFilter`, so both fell through
to `UnhandledExceptionFilter` and were emitted as `500`s without
reaching Sentry.
Same gap existed on most metadata GraphQL resolvers — only
`page-layout*` and `role` resolvers covered
`WorkspaceMigrationRunnerException` via
`WorkspaceMigrationGraphqlApiExceptionInterceptor`.

## Changes

### New filters

REST (`HttpExceptionHandlerService` + Sentry-aware):
- `FlatEntityMapsRestApiExceptionFilter` — maps
`RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND` / `ENTITY_NOT_FOUND` → `404`,
`ENTITY_ALREADY_EXISTS` → `409`, others → `500`.
- `WorkspaceMigrationRunnerRestApiExceptionFilter` — for
`EXECUTION_FAILED`, unwraps the underlying `metadata` /
`workspaceSchema` / `actionTranspilation` error; if it's a
`QueryFailedError` it gets remapped to `400` via
`HttpExceptionHandlerService`. `APPLICATION_NOT_FOUND` → `404`,
`DDL_LOCKED` → `503`, otherwise `500`.

GraphQL (graphql-errors + existing formatter):
- `FlatEntityMapsGraphqlApiExceptionFilter` — kept as the GraphQL-shaped
counterpart (`NotFoundError` / `InternalServerError`).
- `WorkspaceMigrationRunnerGraphqlApiExceptionFilter` — reuses
`workspaceMigrationRunnerExceptionFormatter` for parity with the
existing interceptor.

### Wiring

Filters are now declared **per controller / resolver** via `@UseFilters`
(no global `APP_FILTER` registration) so they participate in the normal
NestJS filter chain instead of being preempted by
`UnhandledExceptionFilter`.

REST:
- `view.controller.ts` — adds `FlatEntityMapsRestApiExceptionFilter` and
`WorkspaceMigrationRunnerRestApiExceptionFilter`.

GraphQL (14 resolvers, all that mutate flat entities):
- `FlatEntityMapsGraphqlApiExceptionFilter` added to: `view`,
`view-field`, `view-field-group`, `view-sort`, `view-group`,
`view-filter`, `view-filter-group`, `page-layout`, `page-layout-tab`,
`page-layout-widget`, `role`, `object-metadata`, `field-metadata`,
`index-metadata`.
- `WorkspaceMigrationRunnerGraphqlApiExceptionFilter` added to the same
list **except** the four already covered by
`WorkspaceMigrationGraphqlApiExceptionInterceptor` (`page-layout`,
`page-layout-tab`, `page-layout-widget`, `role`) — to avoid
double-handling.

## Why per-resolver / per-controller instead of global

Earlier attempt to register the filters globally via `APP_FILTER`
regressed: NestJS reverses the global filter list and
`selectExceptionFilterMetadata` is first-match-wins, so
`UnhandledExceptionFilter` (registered last via `app.useGlobalFilters`
in `main.ts`) ended up first in the iteration order and preempted every
domain-specific filter. The per-resolver / per-controller approach is
explicit and predictable.

## Before
<img width="953" height="450" alt="Screenshot 2026-05-12 at 15 31 40"
src="https://github.com/user-attachments/assets/3c3bc6a8-f6bc-4032-97d0-7243540cfb90"
/>


## After
<img width="1050" height="598" alt="Screenshot 2026-05-12 at 15 31 17"
src="https://github.com/user-attachments/assets/c66c9ce5-d1ea-4f1d-b2fe-07979e2261f7"
/>
<img width="1068" height="503" alt="Screenshot 2026-05-12 at 15 31 09"
src="https://github.com/user-attachments/assets/ddd9eed8-812b-47d6-96cb-b019b807991b"
/>
2026-05-12 16:01:50 +00:00

133 lines
4.6 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
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 { FlatEntityMapsRestApiExceptionFilter } from 'src/engine/metadata-modules/flat-entity/filters/flat-entity-maps-rest-api-exception.filter';
import { CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input';
import { UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input';
import { type ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto';
import {
generateViewGroupExceptionMessage,
generateViewGroupUserFriendlyExceptionMessage,
ViewGroupException,
ViewGroupExceptionCode,
ViewGroupExceptionMessageKey,
} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception';
import { ViewGroupRestApiExceptionFilter } from 'src/engine/metadata-modules/view-group/filters/view-group-rest-api-exception.filter';
import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service';
import { CreateViewGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-group-permission.guard';
import { DeleteViewGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-group-permission.guard';
import { UpdateViewGroupPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-group-permission.guard';
import { WorkspaceMigrationRunnerRestApiExceptionFilter } from 'src/engine/workspace-manager/workspace-migration/filters/workspace-migration-runner-rest-api-exception.filter';
@Controller('rest/metadata/viewGroups')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(
ViewGroupRestApiExceptionFilter,
FlatEntityMapsRestApiExceptionFilter,
WorkspaceMigrationRunnerRestApiExceptionFilter,
)
export class ViewGroupController {
constructor(private readonly viewGroupService: ViewGroupService) {}
@Get()
@UseGuards(NoPermissionGuard)
async findMany(
@AuthWorkspace() workspace: WorkspaceEntity,
@Query('viewId') viewId?: string,
): Promise<ViewGroupDTO[]> {
if (viewId) {
return this.viewGroupService.findByViewId(workspace.id, viewId);
}
return this.viewGroupService.findByWorkspaceId(workspace.id);
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewGroupDTO> {
const viewGroup = await this.viewGroupService.findById(id, workspace.id);
if (!isDefined(viewGroup)) {
throw new ViewGroupException(
generateViewGroupExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
id,
),
ViewGroupExceptionCode.VIEW_GROUP_NOT_FOUND,
{
userFriendlyMessage: generateViewGroupUserFriendlyExceptionMessage(
ViewGroupExceptionMessageKey.VIEW_GROUP_NOT_FOUND,
),
},
);
}
return viewGroup;
}
@Post()
@UseGuards(CreateViewGroupPermissionGuard)
async create(
@Body() input: CreateViewGroupInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewGroupDTO> {
return await this.viewGroupService.createOne({
createViewGroupInput: input,
workspaceId: workspace.id,
});
}
@Patch(':id')
@UseGuards(UpdateViewGroupPermissionGuard)
async update(
@Param('id') id: string,
@Body() input: UpdateViewGroupInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewGroupDTO> {
const updateInput = {
id,
update: input.update ?? input,
};
return await this.viewGroupService.updateOne({
updateViewGroupInput: updateInput,
workspaceId: workspace.id,
});
}
@Delete(':id')
@UseGuards(DeleteViewGroupPermissionGuard)
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<{ success: boolean }> {
const deletedViewGroup = await this.viewGroupService.deleteOne({
deleteViewGroupInput: { id },
workspaceId: workspace.id,
});
return { success: isDefined(deletedViewGroup) };
}
// TODO: the destroy endpoint will be implemented when we settle on a strategy
}