diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 0a16893..005e57a 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -9,6 +9,7 @@ import { routes } from './app.routes'; import { csrfInterceptor } from './core/csrf.interceptor'; import { sessionExpiryInterceptor } from './core/session-expiry.interceptor'; import { titleStrategyProvider } from './core/title.strategy'; +import { backendErrorToastInterceptor } from './core/backend-error-toast.interceptor'; registerLocaleData(localeDe); @@ -16,7 +17,9 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), { provide: LOCALE_ID, useValue: 'de-DE' }, - provideHttpClient(withInterceptors([csrfInterceptor, sessionExpiryInterceptor])), + provideHttpClient( + withInterceptors([csrfInterceptor, sessionExpiryInterceptor, backendErrorToastInterceptor]), + ), provideRouter(routes, withComponentInputBinding()), titleStrategyProvider, ], diff --git a/apps/frontend/src/app/core/backend-error-toast.interceptor.spec.ts b/apps/frontend/src/app/core/backend-error-toast.interceptor.spec.ts new file mode 100644 index 0000000..76f5342 --- /dev/null +++ b/apps/frontend/src/app/core/backend-error-toast.interceptor.spec.ts @@ -0,0 +1,60 @@ +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ToastService } from '../shared/ui'; +import { backendErrorToastInterceptor } from './backend-error-toast.interceptor'; + +describe('backendErrorToastInterceptor', () => { + it('shows the backend message, validation details and request ID', () => { + const show = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([backendErrorToastInterceptor])), + provideHttpClientTesting(), + { provide: ToastService, useValue: { show } }, + ], + }); + const http = TestBed.inject(HttpClient); + const controller = TestBed.inject(HttpTestingController); + + http.post('/api/projects/project-1/rooms', {}).subscribe({ error: () => undefined }); + controller.expectOne('/api/projects/project-1/rooms').flush( + { + status: 400, + code: 'VALIDATION_FAILED', + message: 'Die Eingaben sind ungültig.', + requestId: 'request-123', + validation: [{ field: 'name', messages: ['Name fehlt.'] }], + }, + { status: 400, statusText: 'Bad Request' }, + ); + + expect(show).toHaveBeenCalledWith({ + tone: 'danger', + title: 'Anfrage fehlgeschlagen', + message: 'Die Eingaben sind ungültig. name: Name fehlt.', + requestId: 'request-123', + }); + controller.verify(); + }); + + it('does not show an error for the expected anonymous session check', () => { + const show = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([backendErrorToastInterceptor])), + provideHttpClientTesting(), + { provide: ToastService, useValue: { show } }, + ], + }); + const http = TestBed.inject(HttpClient); + const controller = TestBed.inject(HttpTestingController); + + http.get('/api/auth/me').subscribe({ error: () => undefined }); + controller.expectOne('/api/auth/me').flush({}, { status: 401, statusText: 'Unauthorized' }); + + expect(show).not.toHaveBeenCalled(); + controller.verify(); + }); +}); diff --git a/apps/frontend/src/app/core/backend-error-toast.interceptor.ts b/apps/frontend/src/app/core/backend-error-toast.interceptor.ts new file mode 100644 index 0000000..fd1e00d --- /dev/null +++ b/apps/frontend/src/app/core/backend-error-toast.interceptor.ts @@ -0,0 +1,58 @@ +import { HttpErrorResponse, type HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import type { ApiErrorBody } from '@boilerplate/api-client'; +import { catchError, throwError } from 'rxjs'; +import { ToastService } from '../shared/ui'; + +export const backendErrorToastInterceptor: HttpInterceptorFn = (request, next) => { + const toasts = inject(ToastService); + return next(request).pipe( + catchError((error: unknown) => { + if (error instanceof HttpErrorResponse && isBackendRequest(request.url)) { + const body = apiErrorBody(error.error); + if (!isExpectedAnonymousCheck(request.method, request.url, error.status)) { + toasts.show({ + tone: 'danger', + title: error.status >= 500 ? 'Serverfehler' : 'Anfrage fehlgeschlagen', + message: messageFor(error, body), + ...(body?.requestId ? { requestId: body.requestId } : {}), + }); + } + } + return throwError(() => error); + }), + ); +}; + +function isBackendRequest(url: string): boolean { + return new URL(url, document.baseURI).pathname.startsWith('/api/'); +} + +function isExpectedAnonymousCheck(method: string, url: string, status: number): boolean { + return ( + method === 'GET' && status === 401 && new URL(url, document.baseURI).pathname === '/api/auth/me' + ); +} + +function apiErrorBody(value: unknown): ApiErrorBody | undefined { + if ( + typeof value !== 'object' || + value === null || + !('message' in value) || + typeof value.message !== 'string' || + !('requestId' in value) || + typeof value.requestId !== 'string' + ) { + return undefined; + } + return value as ApiErrorBody; +} + +function messageFor(error: HttpErrorResponse, body: ApiErrorBody | undefined): string { + if (error.status === 0) return 'Das Backend ist derzeit nicht erreichbar.'; + const details = body?.validation + ?.flatMap((entry) => entry.messages.map((message) => `${entry.field}: ${message}`)) + .join(' · '); + if (body && details) return `${body.message} ${details}`; + return body?.message ?? 'Die Anfrage konnte nicht verarbeitet werden.'; +}