From 7aa4fd2f2c9559ba11aa65263587063a41d41654 Mon Sep 17 00:00:00 2001 From: Daniel von Atzigen Date: Tue, 15 Oct 2024 14:22:50 +0200 Subject: [PATCH] Add custom error for duplicate workgroup names --- apps/client-asset-sg/src/app/i18n/de.ts | 3 ++ apps/client-asset-sg/src/app/i18n/en.ts | 3 ++ apps/client-asset-sg/src/app/i18n/fr.ts | 3 ++ apps/client-asset-sg/src/app/i18n/it.ts | 3 ++ apps/client-asset-sg/src/app/i18n/rm.ts | 5 +- .../prisma.exception-filter.ts | 37 +++++++++++++++ apps/server-asset-sg/src/main.ts | 2 + libs/admin/src/lib/state/admin.effects.ts | 47 +++++++++++++++---- .../auth/src/lib/services/auth.interceptor.ts | 4 ++ .../src/lib/features/alert/alert.model.ts | 4 +- .../features/alert/alert/alert.component.html | 2 +- .../features/alert/alert/alert.component.ts | 9 +++- 12 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 apps/server-asset-sg/src/core/exception-filters/prisma.exception-filter.ts diff --git a/apps/client-asset-sg/src/app/i18n/de.ts b/apps/client-asset-sg/src/app/i18n/de.ts index c2288d06..a51cc8a6 100644 --- a/apps/client-asset-sg/src/app/i18n/de.ts +++ b/apps/client-asset-sg/src/app/i18n/de.ts @@ -19,6 +19,9 @@ export const deAppTranslations = { datePlaceholder: 'JJJJ-MM-TT', workgroup: { title: 'Arbeitsgruppe', + errors: { + nameTaken: "Der Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.", + }, }, menuBar: { filters: 'Filter', diff --git a/apps/client-asset-sg/src/app/i18n/en.ts b/apps/client-asset-sg/src/app/i18n/en.ts index 2d1f8889..0f0ca502 100644 --- a/apps/client-asset-sg/src/app/i18n/en.ts +++ b/apps/client-asset-sg/src/app/i18n/en.ts @@ -21,6 +21,9 @@ export const enAppTranslations: AppTranslations = { datePlaceholder: 'YYYY-MM-DD', workgroup: { title: 'Workgroup', + errors: { + nameTaken: "The name '{{name}}' is already taken by another workgroup.", + }, }, menuBar: { filters: 'Filters', diff --git a/apps/client-asset-sg/src/app/i18n/fr.ts b/apps/client-asset-sg/src/app/i18n/fr.ts index 620368a9..3b7aad88 100644 --- a/apps/client-asset-sg/src/app/i18n/fr.ts +++ b/apps/client-asset-sg/src/app/i18n/fr.ts @@ -21,6 +21,9 @@ export const frAppTranslations: AppTranslations = { datePlaceholder: 'AAAA-MM-JJ', workgroup: { title: 'groupe de travail', + errors: { + nameTaken: "Le nom '{{name}}' est déjà utilisé par un autre groupe de travail.", + }, }, menuBar: { filters: 'Filtres', diff --git a/apps/client-asset-sg/src/app/i18n/it.ts b/apps/client-asset-sg/src/app/i18n/it.ts index e4a3c4ad..83ace1e9 100644 --- a/apps/client-asset-sg/src/app/i18n/it.ts +++ b/apps/client-asset-sg/src/app/i18n/it.ts @@ -21,6 +21,9 @@ export const itAppTranslations: AppTranslations = { datePlaceholder: 'AAAA-MM-GG', workgroup: { title: 'IT Arbeitsgruppe', + errors: { + nameTaken: "IT Der Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.", + }, }, menuBar: { filters: 'IT Filter', diff --git a/apps/client-asset-sg/src/app/i18n/rm.ts b/apps/client-asset-sg/src/app/i18n/rm.ts index f9b9832b..f6dcb65e 100644 --- a/apps/client-asset-sg/src/app/i18n/rm.ts +++ b/apps/client-asset-sg/src/app/i18n/rm.ts @@ -20,7 +20,10 @@ export const rmAppTranslations: AppTranslations = { close: 'RM Schliessen', datePlaceholder: 'AAAA-MM-GG', workgroup: { - title: 'IT Arbeitsgruppe', + title: 'RM Arbeitsgruppe', + errors: { + nameTaken: "RM Name '{{name}}' wird bereits von einer anderen Arbeitsgruppe verwendet.", + }, }, menuBar: { filters: 'RM Filter', diff --git a/apps/server-asset-sg/src/core/exception-filters/prisma.exception-filter.ts b/apps/server-asset-sg/src/core/exception-filters/prisma.exception-filter.ts new file mode 100644 index 00000000..85f02a28 --- /dev/null +++ b/apps/server-asset-sg/src/core/exception-filters/prisma.exception-filter.ts @@ -0,0 +1,37 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Response } from 'express'; + +@Catch(Prisma.PrismaClientKnownRequestError) +export class PrismaExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(); + + catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + // Unique constraint failed. + // See https://www.prisma.io/docs/orm/reference/error-reference#p2002. + if (exception.code === 'P2002') { + // We assume that the unique constraint was caused by the user passing a duplicate value to a unique field. + return response.status(HttpStatus.UNPROCESSABLE_ENTITY).json({ + message: 'Unique constraint failed', + details: { + fields: (exception.meta as { target: string[] }).target, + }, + }); + } + + this.logger.error(exception.stack); + const isDevelopment = process.env.NODE_ENV === 'development'; + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + message: 'Internal Server Error', + details: isDevelopment + ? { + code: exception.code, + message: exception.message, + } + : undefined, + }); + } +} diff --git a/apps/server-asset-sg/src/main.ts b/apps/server-asset-sg/src/main.ts index db70eb6c..164c4f8a 100644 --- a/apps/server-asset-sg/src/main.ts +++ b/apps/server-asset-sg/src/main.ts @@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { PrismaExceptionFilter } from '@/core/exception-filters/prisma.exception-filter'; export * from 'fp-ts'; export * from '@prisma/client'; @@ -13,6 +14,7 @@ async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); app.setGlobalPrefix(API_PREFIX); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })); + app.useGlobalFilters(new PrismaExceptionFilter()); await app.listen(API_PORT); Logger.log(`🚀 application is running on: http://localhost:${API_PORT}/${API_PREFIX}`); } diff --git a/libs/admin/src/lib/state/admin.effects.ts b/libs/admin/src/lib/state/admin.effects.ts index aeaed170..bd48c5f9 100644 --- a/libs/admin/src/lib/state/admin.effects.ts +++ b/libs/admin/src/lib/state/admin.effects.ts @@ -1,10 +1,13 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { CURRENT_LANG } from '@asset-sg/client-shared'; -import { User, Workgroup } from '@asset-sg/shared/v2'; +import { AlertType, CURRENT_LANG, showAlert } from '@asset-sg/client-shared'; +import { User, Workgroup, WorkgroupData } from '@asset-sg/shared/v2'; import { UntilDestroy } from '@ngneat/until-destroy'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { first, map, switchMap, withLatestFrom } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { catchError, EMPTY, first, map, OperatorFunction, switchMap, withLatestFrom } from 'rxjs'; import { AdminService } from '../services/admin.service'; import * as actions from './admin.actions'; @@ -16,7 +19,9 @@ export class AdminEffects { private readonly adminService = inject(AdminService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); + private readonly store = inject(Store); private readonly currentLang$ = inject(CURRENT_LANG); + private readonly translate = inject(TranslateService); public findUser$ = createEffect(() => this.actions$.pipe( @@ -46,7 +51,9 @@ export class AdminEffects { public createWorkgroup$ = createEffect(() => this.actions$.pipe( ofType(actions.createWorkgroup), - switchMap(({ workgroup }) => this.adminService.createWorkgroup(workgroup)), + switchMap(({ workgroup }) => + this.adminService.createWorkgroup(workgroup).pipe(this.catchWorkgroupError(workgroup)) + ), withLatestFrom(this.currentLang$), map(([workgroup, currentLang]) => { void this.router.navigate([`/${currentLang}/admin/workgroups/${workgroup.id}`], { relativeTo: this.route }); @@ -59,10 +66,9 @@ export class AdminEffects { this.actions$.pipe( ofType(actions.updateWorkgroup), switchMap(({ workgroupId, workgroup }) => - this.adminService - .updateWorkgroup(workgroupId, workgroup) - .pipe(map((workgroup: Workgroup) => actions.setWorkgroup({ workgroup }))) - ) + this.adminService.updateWorkgroup(workgroupId, workgroup).pipe(this.catchWorkgroupError(workgroup)) + ), + map((workgroup: Workgroup) => actions.setWorkgroup({ workgroup })) ) ); @@ -83,4 +89,29 @@ export class AdminEffects { ) ) ); + + private readonly catchWorkgroupError = (data: WorkgroupData): OperatorFunction => + catchError((error) => { + if (!(error instanceof HttpErrorResponse)) { + throw error; + } + if ( + error.status === 422 && + error.error.message === 'Unique constraint failed' && + error.error.details.fields.includes('name') + ) { + this.store.dispatch( + showAlert({ + alert: { + id: `duplicate-workgroup-name`, + text: this.translate.get('workgroup.errors.nameTaken', { name: data.name }), + type: AlertType.Error, + isPersistent: false, + }, + }) + ); + return EMPTY; + } + throw error; + }); } diff --git a/libs/auth/src/lib/services/auth.interceptor.ts b/libs/auth/src/lib/services/auth.interceptor.ts index 7de10370..b669ae87 100644 --- a/libs/auth/src/lib/services/auth.interceptor.ts +++ b/libs/auth/src/lib/services/auth.interceptor.ts @@ -100,6 +100,10 @@ export class AuthInterceptor implements HttpInterceptor, OnDestroy { ); break; default: { + if (error.status < 500) { + throw error; + } + // In some requests, the error is returned as Blob, // which we then need to manually parse to JSON. const text = diff --git a/libs/client-shared/src/lib/features/alert/alert.model.ts b/libs/client-shared/src/lib/features/alert/alert.model.ts index 06f0bd06..1187e5f8 100644 --- a/libs/client-shared/src/lib/features/alert/alert.model.ts +++ b/libs/client-shared/src/lib/features/alert/alert.model.ts @@ -1,3 +1,5 @@ +import { Observable } from 'rxjs'; + export type AlertId = string; /** @@ -20,7 +22,7 @@ export interface Alert { /** * The display text. */ - text: string; + text: string | Observable; /** * Whether the alert stays until manually closed by the user, diff --git a/libs/client-shared/src/lib/features/alert/alert/alert.component.html b/libs/client-shared/src/lib/features/alert/alert/alert.component.html index 83f7d885..d3645b42 100644 --- a/libs/client-shared/src/lib/features/alert/alert/alert.component.html +++ b/libs/client-shared/src/lib/features/alert/alert/alert.component.html @@ -1,4 +1,4 @@ -

{{ alert.text }}

+

{{ text$ | async }}

diff --git a/libs/client-shared/src/lib/features/alert/alert/alert.component.ts b/libs/client-shared/src/lib/features/alert/alert/alert.component.ts index c4dbec79..f59a7a7f 100644 --- a/libs/client-shared/src/lib/features/alert/alert/alert.component.ts +++ b/libs/client-shared/src/lib/features/alert/alert/alert.component.ts @@ -1,6 +1,6 @@ -import { Component, HostBinding, HostListener, Input, OnDestroy, OnInit, inject } from '@angular/core'; +import { Component, HostBinding, HostListener, inject, Input, OnDestroy, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; -import { Subscription, asyncScheduler, interval } from 'rxjs'; +import { asyncScheduler, interval, Observable, of, Subscription } from 'rxjs'; import { hideAlert } from '../alert.actions'; import { Alert, AlertType } from '../alert.model'; @@ -50,6 +50,11 @@ export class AlertComponent implements OnInit, OnDestroy { this.remove(); } + get text$(): Observable { + const { text } = this.alert; + return typeof text === 'string' ? of(text) : text; + } + get icon(): string { switch (this.alert.type) { case AlertType.Success: