diff --git a/backend/src/common/filters/global-exception.filter.ts b/backend/src/common/filters/global-exception.filter.ts new file mode 100644 index 00000000..49994634 --- /dev/null +++ b/backend/src/common/filters/global-exception.filter.ts @@ -0,0 +1,82 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Response } from 'express'; + +interface ErrorResponse { + statusCode: number; + message: string; + timestamp: string; + path?: string; +} + +/** + * Global exception filter som formaterar alla errors konsistent + * Returnerar tydliga, svenska felmeddelanden utan att läcka känslig info + */ +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + + catch(exception: any, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const path = request.url; + + let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Ett internt serverfel inträffade.'; + + // Hantera HTTP-exceptions (t.ex. NotFoundException) + if (exception instanceof HttpException) { + statusCode = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'object' && 'message' in exceptionResponse) { + message = (exceptionResponse as any).message || message; + } else if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } + } else if (exception instanceof Error) { + // Hantera vanliga Error-instanser + message = exception.message || message; + statusCode = HttpStatus.BAD_REQUEST; + + // Log interna fel för debugging + this.logger.error(`Error: ${exception.message}`, exception.stack); + } + + // Säkerställ att felmeddelanden är användarvänliga (på svenska) + if (message.includes('Unknown unit')) { + message = 'Okänd enhet. Kontrollera enheten och försök igen.'; + } else if (message.includes('Cannot convert between incompatible unit types')) { + message = 'Kan inte konvertera mellan dessa enhetstyper.'; + } else if (message.includes('Inventory item with id') && message.includes('not found')) { + message = 'Hemmavarorna hittades inte.'; + } else if (message.includes('Product with id') && message.includes('not found')) { + message = 'Produkten hittades inte.'; + } else if (message.includes('Recipe with id') && message.includes('not found')) { + message = 'Receptet hittades inte.'; + } else if (message.includes('sourceProductId och targetProductId kan inte vara samma')) { + message = 'Du kan inte slå samman en produkt med sig själv.'; + } + + const errorResponse: ErrorResponse = { + statusCode, + message, + timestamp: new Date().toISOString(), + path, + }; + + this.logger.warn( + `${request.method} ${path} - Status: ${statusCode}, Message: ${message}`, + ); + + response.status(statusCode).json(errorResponse); + } +} diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts index dd5cdd95..b8661d45 100644 --- a/backend/src/health/health.controller.ts +++ b/backend/src/health/health.controller.ts @@ -1,36 +1,41 @@ -import { Controller, Get } from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; +import { Controller, Get, HttpCode, Res } from '@nestjs/common'; +import { Response } from 'express'; +import { HealthService } from './health.service'; @Controller('health') export class HealthController { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly healthService: HealthService) {} + /** + * Övergripande hälsostatus för tjänsten + * Returnerar 200 om allt är bra, 503 om något är fel + */ @Get() - getHealth() { - return { - status: 'ok', - service: 'recipe-api', - timestamp: new Date().toISOString(), - }; + async getHealth(@Res() res: Response) { + const health = await this.healthService.getOverallHealth(); + res.status(health.statusCode).json({ + status: health.status, + service: health.service, + timestamp: health.timestamp, + uptime: health.uptime, + checks: health.checks, + }); } + /** + * Databasspecific hälsokontroll + * Returnerar 200 om databasen är tillgänglig, 503 om inte + */ @Get('db') - async getDatabaseHealth() { - try { - await this.prisma.$queryRaw`SELECT 1`; - - return { - status: 'ok', - database: 'connected', - timestamp: new Date().toISOString(), - }; - } catch (error) { - return { - status: 'error', - database: 'not reachable', - message: error instanceof Error ? error.message : 'unknown error', - timestamp: new Date().toISOString(), - }; - } + async getDatabaseHealth(@Res() res: Response) { + const dbHealth = await this.healthService.getDatabaseHealth(); + res.status(dbHealth.statusCode).json({ + status: dbHealth.status, + database: dbHealth.database, + responseTime: `${dbHealth.responseTime}ms`, + timestamp: dbHealth.timestamp, + details: dbHealth.details, + }); } } + diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts index 7476abed..64e9aecd 100644 --- a/backend/src/health/health.module.ts +++ b/backend/src/health/health.module.ts @@ -1,7 +1,11 @@ import { Module } from '@nestjs/common'; import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; +import { PrismaModule } from '../prisma/prisma.module'; @Module({ + imports: [PrismaModule], controllers: [HealthController], + providers: [HealthService], }) export class HealthModule {} diff --git a/backend/src/health/health.service.ts b/backend/src/health/health.service.ts new file mode 100644 index 00000000..76c62b2b --- /dev/null +++ b/backend/src/health/health.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface HealthStatus { + status: 'healthy' | 'degraded' | 'unhealthy'; + service: string; + timestamp: string; + uptime: number; + checks: { + database: { + status: 'ok' | 'error'; + responseTime: number; + details?: string; + }; + }; +} + +@Injectable() +export class HealthService { + private readonly startTime = Date.now(); + + constructor(private readonly prisma: PrismaService) {} + + async getOverallHealth(): Promise<{ + status: 'healthy' | 'degraded' | 'unhealthy'; + statusCode: number; + service: string; + timestamp: string; + uptime: number; + checks: { + database: { + status: 'ok' | 'error'; + responseTime: number; + details?: string; + }; + }; + }> { + const timestamp = new Date().toISOString(); + const uptime = Date.now() - this.startTime; + + // Testa databaskopplingen + const dbStart = Date.now(); + let dbStatus: 'ok' | 'error' = 'ok'; + let dbResponseTime = 0; + let dbDetails: string | undefined; + + try { + await this.prisma.$queryRaw`SELECT 1`; + dbResponseTime = Date.now() - dbStart; + } catch (error) { + dbStatus = 'error'; + dbResponseTime = Date.now() - dbStart; + dbDetails = error instanceof Error ? error.message : 'Unknown database error'; + } + + // Bestäm övergripande hälsa + let overallStatus: 'healthy' | 'degraded' | 'unhealthy' = 'healthy'; + if (dbStatus === 'error') { + overallStatus = 'unhealthy'; + } + + const statusCode = overallStatus === 'unhealthy' ? 503 : 200; + + return { + status: overallStatus, + statusCode, + service: 'recipe-api', + timestamp, + uptime, + checks: { + database: { + status: dbStatus, + responseTime: dbResponseTime, + details: dbDetails, + }, + }, + }; + } + + async getDatabaseHealth(): Promise<{ + status: 'ok' | 'error'; + database: string; + responseTime: number; + timestamp: string; + details?: string; + statusCode: number; + }> { + const timestamp = new Date().toISOString(); + const startTime = Date.now(); + + try { + await this.prisma.$queryRaw`SELECT 1`; + const responseTime = Date.now() - startTime; + + return { + status: 'ok', + database: 'connected', + responseTime, + timestamp, + statusCode: 200, + }; + } catch (error) { + const responseTime = Date.now() - startTime; + const details = error instanceof Error ? error.message : 'Unknown database error'; + + return { + status: 'error', + database: 'not reachable', + responseTime, + timestamp, + details, + statusCode: 503, + }; + } + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 7c948e99..372d4024 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,11 +1,16 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); + + // Registrera global exception filter + app.useGlobalFilters(new GlobalExceptionFilter()); + app.useGlobalPipes( new ValidationPipe({ whitelist: true, diff --git a/frontend/app/inventory/InventoryConsumptionHistory.tsx b/frontend/app/inventory/InventoryConsumptionHistory.tsx index b57a61f8..01d25786 100644 --- a/frontend/app/inventory/InventoryConsumptionHistory.tsx +++ b/frontend/app/inventory/InventoryConsumptionHistory.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useTransition } from 'react'; +import { parseErrorResponse } from '../../lib/error-handler'; import type { InventoryConsumption } from '../../features/inventory/types'; type Props = { @@ -28,15 +29,16 @@ export default function InventoryConsumptionHistory({ id }: Props) { }); if (!res.ok) { - const text = await res.text(); - throw new Error(text || 'Kunde inte hämta historik.'); + const errorMessage = await parseErrorResponse(res); + throw new Error(errorMessage); } const data: InventoryConsumption[] = await res.json(); setHistory(data); setIsOpen(true); } catch (err) { - setError(err instanceof Error ? err.message : 'Okänt fel'); + const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.'; + setError(message); } }); }; diff --git a/frontend/app/recipes/RecipePreview.tsx b/frontend/app/recipes/RecipePreview.tsx index 46ce4579..5ff61fb3 100644 --- a/frontend/app/recipes/RecipePreview.tsx +++ b/frontend/app/recipes/RecipePreview.tsx @@ -2,6 +2,7 @@ import { useState, useTransition } from 'react'; import Link from 'next/link'; +import { parseErrorResponse } from '../../lib/error-handler'; import type { Recipe, RecipeInventoryPreview, @@ -74,14 +75,15 @@ export default function RecipePreview({ recipes }: Props) { }); if (!res.ok) { - const text = await res.text(); - throw new Error(text || 'Kunde inte hämta recept-preview.'); + const errorMessage = await parseErrorResponse(res); + throw new Error(errorMessage); } const data: RecipeInventoryPreview = await res.json(); setPreview(data); } catch (err) { - setError(err instanceof Error ? err.message : 'Okänt fel'); + const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.'; + setError(message); } }); }; diff --git a/frontend/app/recipes/[id]/edit/page.tsx b/frontend/app/recipes/[id]/edit/page.tsx index cfbe26d8..226482ed 100644 --- a/frontend/app/recipes/[id]/edit/page.tsx +++ b/frontend/app/recipes/[id]/edit/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { fetchJson } from '../../../../lib/api'; +import { parseErrorResponse } from '../../../../lib/error-handler'; import type { Product, Recipe } from '../../../../features/inventory/types'; export default function EditRecipePage() { @@ -99,12 +100,14 @@ export default function EditRecipePage() { }); if (!response.ok) { - throw new Error('Kunde inte uppdatera receptet'); + const errorMessage = await parseErrorResponse(response); + throw new Error(errorMessage); } router.push('/recipes'); } catch (err) { - setError((err as Error).message); + const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade. Försök igen.'; + setError(message); } finally { setIsSaving(false); } diff --git a/frontend/app/recipes/create/CreateRecipePage.tsx b/frontend/app/recipes/create/CreateRecipePage.tsx index 095f9b97..9d25fedb 100644 --- a/frontend/app/recipes/create/CreateRecipePage.tsx +++ b/frontend/app/recipes/create/CreateRecipePage.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { fetchJson } from '../../../lib/api'; +import { parseErrorResponse } from '../../../lib/error-handler'; import type { Product } from '../../../features/inventory/types'; export default function CreateRecipePage() { @@ -64,12 +65,14 @@ export default function CreateRecipePage() { }); if (!response.ok) { - throw new Error('Kunde inte spara receptet'); + const errorMessage = await parseErrorResponse(response); + throw new Error(errorMessage); } router.push('/recipes'); } catch (err) { - setError((err as Error).message); + const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade. Försök igen.'; + setError(message); } finally { setIsLoading(false); } diff --git a/frontend/lib/error-handler.ts b/frontend/lib/error-handler.ts new file mode 100644 index 00000000..b3c16759 --- /dev/null +++ b/frontend/lib/error-handler.ts @@ -0,0 +1,69 @@ +/** + * Utility för att parse HTTP-responses och extrahera tydliga felmeddelanden + */ +export async function parseErrorResponse(response: Response): Promise { + const status = response.status; + + try { + const data = await response.json(); + + // Om backend skickade ett felmeddelande + if (data.message) { + return data.message; + } + if (data.error) { + return data.error; + } + if (data.details) { + return data.details; + } + } catch { + // Inte JSON, försök text + try { + const text = await response.text(); + if (text && text.length < 200) { + return text; + } + } catch { + // Inget text-innehål + } + } + + // Fallback baserat på HTTP-status + const defaultMessages: Record = { + 400: 'Ogiltiga data. Kontrollera dina inmatningar.', + 401: 'Du är inte autentiserad. Logga in.', + 403: 'Du har inte behörighet till detta.', + 404: 'Resursen hittades inte.', + 409: 'Konflikten med befintlig data.', + 422: 'Valideringen misslyckades. Kontrollera dina inmatningar.', + 500: 'Serverfel. Försök igen senare.', + 503: 'Tjänsten är inte tillgänglig.', + }; + + return defaultMessages[status] || `Fel (${status}). Försök igen senare.`; +} + +/** + * Hämta data från API med bra felhantering + */ +export async function fetchWithErrorHandling( + url: string, + options?: RequestInit, +): Promise { + try { + const response = await fetch(url, options); + + if (!response.ok) { + const errorMessage = await parseErrorResponse(response); + throw new Error(errorMessage); + } + + return await response.json(); + } catch (err) { + if (err instanceof Error) { + throw err; + } + throw new Error('Ett okänt fel inträffade.'); + } +}