70bb011daa
## 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" />
133 lines
4.6 KiB
TypeScript
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
|
|
}
|