diff --git a/mnemo_cards_admin/src/api/cards.ts b/mnemo_cards_admin/src/api/cards.ts index e7aa012..5619f7d 100644 --- a/mnemo_cards_admin/src/api/cards.ts +++ b/mnemo_cards_admin/src/api/cards.ts @@ -1,5 +1,18 @@ import { adminApiClient } from './client' import type { GameCardDto, PaginatedResponse } from '@/types/models' +import type { AxiosError } from 'axios' + +export class CardsApiError extends Error { + constructor( + message: string, + public statusCode?: number, + public field?: string, + public originalError?: unknown + ) { + super(message) + this.name = 'CardsApiError' + } +} export const cardsApi = { // Get all cards with pagination and search @@ -8,31 +21,91 @@ export const cardsApi = { limit?: number search?: string }): Promise> => { - const response = await adminApiClient.get('/api/v2/admin/cards', { - params: { - page: params?.page || 1, - limit: params?.limit || 20, - search: params?.search, - }, - }) - return response.data + try { + const response = await adminApiClient.get('/api/v2/admin/cards', { + params: { + page: params?.page || 1, + limit: params?.limit || 20, + search: params?.search, + }, + }) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }> + throw new CardsApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + 'Failed to load cards', + axiosError.response?.status, + axiosError.response?.data?.field, + error + ) + } }, // Get a specific card by ID getCard: async (cardId: number): Promise => { - const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}`) - return response.data + try { + const response = await adminApiClient.get(`/api/v2/admin/cards/${cardId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string }> + throw new CardsApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to load card ${cardId}`, + axiosError.response?.status, + undefined, + error + ) + } }, // Create or update a card upsertCard: async (card: GameCardDto): Promise<{ success: boolean; card: GameCardDto }> => { - const response = await adminApiClient.post('/api/v2/admin/cards', card) - return response.data + try { + const response = await adminApiClient.post('/api/v2/admin/cards', card) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }> + const isUpdate = card.id && card.id > 0 + const operation = isUpdate ? 'update card' : 'create card' + const message = axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to ${operation}` + + let fullMessage = message + if (axiosError.response?.data?.details) { + fullMessage += `. ${axiosError.response.data.details}` + } + if (axiosError.response?.data?.field) { + fullMessage += ` (Field: ${axiosError.response.data.field})` + } + + throw new CardsApiError( + fullMessage, + axiosError.response?.status, + axiosError.response?.data?.field, + error + ) + } }, // Delete a card by ID deleteCard: async (cardId: number): Promise<{ success: boolean; message: string }> => { - const response = await adminApiClient.delete(`/api/v2/admin/cards/${cardId}`) - return response.data + try { + const response = await adminApiClient.delete(`/api/v2/admin/cards/${cardId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string }> + throw new CardsApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to delete card ${cardId}`, + axiosError.response?.status, + undefined, + error + ) + } }, } diff --git a/mnemo_cards_admin/src/api/packs.ts b/mnemo_cards_admin/src/api/packs.ts index a98b9e2..22b9212 100644 --- a/mnemo_cards_admin/src/api/packs.ts +++ b/mnemo_cards_admin/src/api/packs.ts @@ -1,5 +1,18 @@ import { adminApiClient } from './client' import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models' +import type { AxiosError } from 'axios' + +export class PacksApiError extends Error { + constructor( + message: string, + public statusCode?: number, + public field?: string, + public originalError?: unknown + ) { + super(message) + this.name = 'PacksApiError' + } +} export const packsApi = { // Get all packs with pagination and search @@ -9,32 +22,132 @@ export const packsApi = { search?: string showDisabled?: boolean }): Promise> => { - const response = await adminApiClient.get('/api/v2/admin/packs', { - params: { - page: params?.page || 1, - limit: params?.limit || 20, - search: params?.search, - showDisabled: params?.showDisabled, - }, - }) - return response.data + try { + const response = await adminApiClient.get('/api/v2/admin/packs', { + params: { + page: params?.page || 1, + limit: params?.limit || 20, + search: params?.search, + showDisabled: params?.showDisabled, + }, + }) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }> + + // Check for validation errors (limit too high, etc.) + if (axiosError.response?.status === 400) { + const errorData = axiosError.response.data + if (errorData?.message?.includes('Limit')) { + throw new PacksApiError( + `Invalid limit: ${errorData.message}. Maximum allowed limit is 100.`, + axiosError.response.status, + 'limit', + error + ) + } + } + + throw new PacksApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + 'Failed to load packs', + axiosError.response?.status, + axiosError.response?.data?.field, + error + ) + } }, // Get pack details by ID for editing getPack: async (packId: string): Promise => { - const response = await adminApiClient.get(`/api/v2/admin/packs/${packId}`) - return response.data + try { + const response = await adminApiClient.get(`/api/v2/admin/packs/${packId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string }> + + if (axiosError.response?.status === 404) { + throw new PacksApiError( + `Pack not found: The pack with ID "${packId}" does not exist or has been deleted.`, + axiosError.response.status, + undefined, + error + ) + } + + throw new PacksApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to load pack ${packId}`, + axiosError.response?.status, + undefined, + error + ) + } }, // Create or update pack upsertPack: async (pack: EditCardPackDto): Promise<{ success: boolean; pack: EditCardPackDto }> => { - const response = await adminApiClient.post('/api/v2/admin/packs', pack) - return response.data + try { + const response = await adminApiClient.post('/api/v2/admin/packs', pack) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }> + const isUpdate = pack.id && pack.id.length > 0 + const operation = isUpdate ? 'update pack' : 'create pack' + const baseMessage = axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to ${operation}` + + let fullMessage = baseMessage + if (axiosError.response?.data?.details) { + fullMessage += `. ${axiosError.response.data.details}` + } + + // Special handling for common errors + if (axiosError.response?.status === 404 && isUpdate) { + fullMessage = `Pack not found: The pack you're trying to update (ID: ${pack.id}) does not exist.` + } + + if (axiosError.response?.data?.field) { + fullMessage += ` (Field: ${axiosError.response.data.field})` + } + + throw new PacksApiError( + fullMessage, + axiosError.response?.status, + axiosError.response?.data?.field, + error + ) + } }, // Delete pack deletePack: async (packId: string): Promise<{ success: boolean; message: string }> => { - const response = await adminApiClient.delete(`/api/v2/admin/packs/${packId}`) - return response.data + try { + const response = await adminApiClient.delete(`/api/v2/admin/packs/${packId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string }> + + if (axiosError.response?.status === 404) { + throw new PacksApiError( + `Pack not found: The pack with ID "${packId}" does not exist and cannot be deleted.`, + axiosError.response.status, + undefined, + error + ) + } + + throw new PacksApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to delete pack ${packId}`, + axiosError.response?.status, + undefined, + error + ) + } }, } diff --git a/mnemo_cards_admin/src/components/BulkCardEditor.tsx b/mnemo_cards_admin/src/components/BulkCardEditor.tsx index 64229b1..d737fbf 100644 --- a/mnemo_cards_admin/src/components/BulkCardEditor.tsx +++ b/mnemo_cards_admin/src/components/BulkCardEditor.tsx @@ -1,16 +1,16 @@ import { useState, useEffect } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { cardsApi } from '@/api/cards' +import { cardsApi, CardsApiError } from '@/api/cards' +import { packsApi, PacksApiError } from '@/api/packs' +import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils' import type { GameCardDto } from '@/types/models' -import type { AxiosError } from 'axios' import { Button } from './ui/button' import { Input } from './ui/input' import { Label } from './ui/label' import { Textarea } from './ui/textarea' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card' import { Badge } from './ui/badge' -import { packsApi } from '@/api/packs' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select' import { ChevronLeft, ChevronRight, Save, X } from 'lucide-react' @@ -69,8 +69,11 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP } }) .catch((error) => { + const errorMessage = error instanceof PacksApiError + ? error.message + : formatApiError(error) console.error('Failed to load packs:', error) - toast.error('Failed to load packs') + toast.error(`Failed to load packs: ${errorMessage}`) // Initialize with empty array so component doesn't crash setPacksData([]) }) @@ -123,8 +126,11 @@ export function BulkCardEditor({ images, onComplete, onCancel }: BulkCardEditorP toast.success('Card saved successfully') }, onError: (error: unknown) => { - const axiosError = error as AxiosError<{ message?: string }> - toast.error(axiosError.response?.data?.message || 'Failed to save card') + const errorMessage = error instanceof CardsApiError + ? error.message + : getDetailedErrorMessage(error, 'save', 'card') + toast.error(errorMessage) + console.error('Error saving card:', error) }, }) diff --git a/mnemo_cards_admin/src/lib/error-utils.ts b/mnemo_cards_admin/src/lib/error-utils.ts new file mode 100644 index 0000000..9d79f6e --- /dev/null +++ b/mnemo_cards_admin/src/lib/error-utils.ts @@ -0,0 +1,121 @@ +import type { AxiosError } from 'axios' + +export interface ApiErrorResponse { + error?: string + message?: string + details?: string + field?: string + code?: string +} + +/** + * Formats an API error into a user-friendly message + */ +export function formatApiError(error: unknown): string { + const axiosError = error as AxiosError + + // Network error (no response from server) + if (!axiosError.response) { + if (axiosError.code === 'ECONNABORTED') { + return 'Request timeout: The server took too long to respond. Please check your connection and try again.' + } + if (axiosError.message?.includes('Network Error')) { + return 'Network error: Unable to connect to the server. Please check your internet connection.' + } + return 'Connection error: Unable to reach the server. Please check your connection and try again.' + } + + const status = axiosError.response.status + const data = axiosError.response.data + + // Get error message from response + let message = data?.message || data?.error || 'An unknown error occurred' + + // Add more context based on status code + switch (status) { + case 400: + if (data?.field) { + return `Validation error: ${message} (field: ${data.field})` + } + if (data?.details) { + return `Bad request: ${message}. ${data.details}` + } + return `Invalid request: ${message}` + + case 401: + return 'Authentication required: Please log in again.' + + case 403: + return 'Access denied: You do not have permission to perform this action.' + + case 404: + // Make 404 errors more specific based on the endpoint + const url = axiosError.config?.url || '' + if (url.includes('/cards/')) { + return 'Card not found: The requested card does not exist or has been deleted.' + } + if (url.includes('/packs/')) { + return 'Pack not found: The requested pack does not exist or has been deleted.' + } + if (url.includes('/users/')) { + return 'User not found: The requested user does not exist.' + } + return `Resource not found: ${message}` + + case 409: + return `Conflict: ${message}. The resource may already exist or be in use.` + + case 422: + if (data?.details) { + return `Validation failed: ${message}. ${data.details}` + } + return `Validation error: ${message}` + + case 429: + return 'Too many requests: Please wait a moment and try again.' + + case 500: + return `Server error: ${message}. Please try again later or contact support if the problem persists.` + + case 502: + return 'Bad gateway: The server is temporarily unavailable. Please try again later.' + + case 503: + return 'Service unavailable: The server is temporarily down for maintenance. Please try again later.' + + case 504: + return 'Gateway timeout: The server took too long to respond. Please try again.' + + default: + return `${message} (Error ${status})` + } +} + +/** + * Gets a detailed error message with additional context for specific operations + */ +export function getDetailedErrorMessage( + error: unknown, + operation: string, + resource?: string +): string { + const baseMessage = formatApiError(error) + const axiosError = error as AxiosError + + // Add operation context + let contextMessage = baseMessage + + if (operation && resource) { + contextMessage = `Failed to ${operation} ${resource}: ${baseMessage}` + } else if (operation) { + contextMessage = `Failed to ${operation}: ${baseMessage}` + } + + // Add field-specific errors if available + if (axiosError.response?.data?.field) { + const field = axiosError.response.data.field + contextMessage += ` (Field: ${field})` + } + + return contextMessage +} diff --git a/mnemo_cards_admin/src/pages/CardsPage.tsx b/mnemo_cards_admin/src/pages/CardsPage.tsx index 3251110..1638758 100644 --- a/mnemo_cards_admin/src/pages/CardsPage.tsx +++ b/mnemo_cards_admin/src/pages/CardsPage.tsx @@ -1,9 +1,9 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { cardsApi } from '@/api/cards' +import { cardsApi, CardsApiError } from '@/api/cards' +import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils' import type { GameCardDto, PaginatedResponse } from '@/types/models' -import type { AxiosError } from 'axios' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -79,6 +79,13 @@ export default function CardsPage() { const { data, isLoading, error } = useQuery>({ queryKey: ['cards', page, search], queryFn: () => cardsApi.getCards({ page, limit, search }), + retry: (failureCount, error) => { + // Don't retry on client errors (4xx) + if (error instanceof CardsApiError && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + return false + } + return failureCount < 2 + }, }) // Mutations @@ -90,8 +97,11 @@ export default function CardsPage() { closeDialog() }, onError: (error: unknown) => { - const axiosError = error as AxiosError<{ message?: string }> - toast.error(axiosError.response?.data?.message || 'Failed to create card') + const errorMessage = error instanceof CardsApiError + ? error.message + : getDetailedErrorMessage(error, 'create', 'card') + toast.error(errorMessage) + console.error('Error creating card:', error) }, }) @@ -103,8 +113,11 @@ export default function CardsPage() { closeDialog() }, onError: (error: unknown) => { - const axiosError = error as AxiosError<{ message?: string }> - toast.error(axiosError.response?.data?.message || 'Failed to update card') + const errorMessage = error instanceof CardsApiError + ? error.message + : getDetailedErrorMessage(error, 'update', 'card') + toast.error(errorMessage) + console.error('Error updating card:', error) }, }) @@ -117,8 +130,11 @@ export default function CardsPage() { setCardToDelete(null) }, onError: (error: unknown) => { - const axiosError = error as AxiosError<{ message?: string }> - toast.error(axiosError.response?.data?.message || 'Failed to delete card') + const errorMessage = error instanceof CardsApiError + ? error.message + : getDetailedErrorMessage(error, 'delete', 'card') + toast.error(errorMessage) + console.error('Error deleting card:', error) }, }) @@ -210,6 +226,9 @@ export default function CardsPage() { } if (error) { + const errorMessage = error instanceof CardsApiError + ? error.message + : formatApiError(error) return (
@@ -218,7 +237,18 @@ export default function CardsPage() {
-

Failed to load cards. Please try again later.

+
+

Failed to load cards

+

{errorMessage}

+ +
diff --git a/mnemo_cards_admin/src/pages/PacksPage.tsx b/mnemo_cards_admin/src/pages/PacksPage.tsx index 96a66ed..1efda7b 100644 --- a/mnemo_cards_admin/src/pages/PacksPage.tsx +++ b/mnemo_cards_admin/src/pages/PacksPage.tsx @@ -1,9 +1,9 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { packsApi } from '@/api/packs' +import { packsApi, PacksApiError } from '@/api/packs' +import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils' import type { EditCardPackDto, CardPackPreviewDto, PaginatedResponse } from '@/types/models' -import type { AxiosError } from 'axios' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -78,6 +78,13 @@ export default function PacksPage() { const { data, isLoading, error } = useQuery>({ queryKey: ['packs', page, search, showDisabled], queryFn: () => packsApi.getPacks({ page, limit, search, showDisabled }), + retry: (failureCount, error) => { + // Don't retry on client errors (4xx) + if (error instanceof PacksApiError && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + return false + } + return failureCount < 2 + }, }) // Mutations @@ -89,8 +96,11 @@ export default function PacksPage() { closeDialog() }, onError: (error: unknown) => { - const axiosError = error as AxiosError<{ message?: string }> - toast.error(axiosError.response?.data?.message || 'Failed to create pack') + const errorMessage = error instanceof PacksApiError + ? error.message + : getDetailedErrorMessage(error, 'create', 'pack') + toast.error(errorMessage) + console.error('Error creating pack:', error) }, }) @@ -102,8 +112,11 @@ export default function PacksPage() { closeDialog() }, onError: (error: unknown) => { - const axiosError = error as AxiosError<{ message?: string }> - toast.error(axiosError.response?.data?.message || 'Failed to update pack') + const errorMessage = error instanceof PacksApiError + ? error.message + : getDetailedErrorMessage(error, 'update', 'pack') + toast.error(errorMessage) + console.error('Error updating pack:', error) }, }) @@ -116,8 +129,11 @@ export default function PacksPage() { setPackToDelete(null) }, onError: (error: unknown) => { - const axiosError = error as AxiosError<{ message?: string }> - toast.error(axiosError.response?.data?.message || 'Failed to delete pack') + const errorMessage = error instanceof PacksApiError + ? error.message + : getDetailedErrorMessage(error, 'delete', 'pack') + toast.error(errorMessage) + console.error('Error deleting pack:', error) }, }) @@ -169,8 +185,12 @@ export default function PacksPage() { setCardsToAdd([]) setCardsToRemove([]) setIsDialogOpen(true) - } catch { - toast.error('Failed to load pack details') + } catch (error) { + const errorMessage = error instanceof PacksApiError + ? error.message + : getDetailedErrorMessage(error, 'load', `pack "${pack.id}"`) + toast.error(errorMessage) + console.error('Error loading pack details:', error) } } @@ -196,7 +216,8 @@ export default function PacksPage() { } const packData: EditCardPackDto = { - id: selectedPack?.id || '', + // Only include id for updates, not for new packs + ...(selectedPack && { id: selectedPack.id }), title: formData.title.trim(), subtitle: formData.subtitle.trim() || undefined, description: formData.description.trim() || undefined, @@ -238,6 +259,9 @@ export default function PacksPage() { } if (error) { + const errorMessage = error instanceof PacksApiError + ? error.message + : formatApiError(error) return (
@@ -246,7 +270,18 @@ export default function PacksPage() {
-

Failed to load packs. Please try again later.

+
+

Failed to load packs

+

{errorMessage}

+ +
diff --git a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart index 1014238..baa9634 100644 --- a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart @@ -49,13 +49,23 @@ class AdminCardsApiV2 { // Validate pagination if (page < 1) { return Response.badRequest( - body: json.encode({'error': 'Page must be greater than 0'}), + body: json.encode({ + 'error': 'Invalid page parameter', + 'message': 'Page must be greater than 0. Received: $page', + 'field': 'page', + 'details': 'Page numbers start from 1. Please provide a valid page number.', + }), headers: {'Content-Type': 'application/json'}, ); } if (limit < 1 || limit > 100) { return Response.badRequest( - body: json.encode({'error': 'Limit must be between 1 and 100'}), + body: json.encode({ + 'error': 'Invalid limit parameter', + 'message': 'Limit must be between 1 and 100. Received: $limit', + 'field': 'limit', + 'details': 'The maximum number of items per page is 100. Please use a value between 1 and 100.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -109,7 +119,11 @@ class AdminCardsApiV2 { } catch (e, s) { print('Error in getAllCards: $e\n$s'); return Response.internalServerError( - body: json.encode({'error': e.toString()}), + body: json.encode({ + 'error': 'Internal server error', + 'message': 'Failed to retrieve cards', + 'details': 'An unexpected error occurred while fetching cards. Please try again later or contact support if the problem persists.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -122,7 +136,12 @@ class AdminCardsApiV2 { try { if (cardId.isEmpty) { return Response.badRequest( - body: json.encode({'error': 'Invalid card ID'}), + body: json.encode({ + 'error': 'Invalid card ID', + 'message': 'Card ID cannot be empty', + 'field': 'cardId', + 'details': 'Please provide a valid card ID to retrieve card details.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -130,7 +149,11 @@ class AdminCardsApiV2 { final card = await _db.packDao.getCardById(cardId); if (card == null) { return Response.notFound( - json.encode({'error': 'Card not found'}), + body: json.encode({ + 'error': 'Card not found', + 'message': 'The requested card does not exist or has been deleted', + 'details': 'Card with ID "$cardId" was not found in the database. Please verify the card ID and try again.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -152,9 +175,14 @@ class AdminCardsApiV2 { }), headers: {'Content-Type': 'application/json'}, ); - } catch (e) { + } catch (e, s) { + print('Error in getCard: $e\n$s'); return Response.internalServerError( - body: json.encode({'error': e.toString()}), + body: json.encode({ + 'error': 'Internal server error', + 'message': 'Failed to retrieve card', + 'details': 'An unexpected error occurred while fetching card with ID "$cardId". Please try again later or contact support if the problem persists.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -168,13 +196,52 @@ class AdminCardsApiV2 { final body = await request.readAsString(); final data = json.decode(body) as Map; - // Check if this is an update (has valid id) + // Validate required fields for new cards final cardIdParam = data['id']; - if (cardIdParam != null && cardIdParam != -1) { + final isUpdate = cardIdParam != null && cardIdParam != -1; + + if (!isUpdate) { + // Validate required fields for new cards + if (data['original'] == null || (data['original'] as String).trim().isEmpty) { + return Response.badRequest( + body: json.encode({ + 'error': 'Validation error', + 'message': 'Original text is required', + 'field': 'original', + 'details': 'Please provide the original word or phrase for the card.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + + if (data['translation'] == null || (data['translation'] as String).trim().isEmpty) { + return Response.badRequest( + body: json.encode({ + 'error': 'Validation error', + 'message': 'Translation is required', + 'field': 'translation', + 'details': 'Please provide the translation for the card.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + } + + // Check if this is an update (has valid id) + if (isUpdate) { final cardId = cardIdParam.toString(); final existing = await _db.packDao.getCardById(cardId); - if (existing != null) { - // Update existing card + if (existing == null) { + return Response.notFound( + body: json.encode({ + 'error': 'Card not found', + 'message': 'The card you are trying to update does not exist', + 'details': 'Card with ID "$cardId" was not found. The card may have been deleted or the ID may be incorrect.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + // Update existing card final updated = existing.copyWith( original: data['original'] ?? existing.original, translation: data['translation'] ?? existing.translation, @@ -226,7 +293,12 @@ class AdminCardsApiV2 { final created = await _db.packDao.getCardById(cardId); if (created == null) { return Response.internalServerError( - body: json.encode({'error': 'Failed to retrieve created card', 'success': false}), + body: json.encode({ + 'error': 'Database error', + 'message': 'Failed to retrieve created card', + 'details': 'Card was created successfully but could not be retrieved from the database. The card ID is: $cardId', + 'success': false, + }), headers: {'Content-Type': 'application/json'}, ); } @@ -252,8 +324,26 @@ class AdminCardsApiV2 { ); } catch (e, s) { print('Error in createCard: $e\n$s'); + + // Check for common database errors + final errorMessage = e.toString().toLowerCase(); + String details = 'An unexpected error occurred while saving the card.'; + + if (errorMessage.contains('constraint') || errorMessage.contains('unique')) { + details = 'A card with similar data may already exist. Please check for duplicates.'; + } else if (errorMessage.contains('foreign key') || errorMessage.contains('pack')) { + details = 'The specified pack ID may be invalid. Please verify that the pack exists.'; + } else if (errorMessage.contains('null') || errorMessage.contains('required')) { + details = 'Required fields are missing or invalid. Please check all required fields are provided.'; + } + return Response.internalServerError( - body: json.encode({'error': e.toString(), 'success': false}), + body: json.encode({ + 'error': 'Internal server error', + 'message': 'Failed to save card', + 'details': details, + 'success': false, + }), headers: {'Content-Type': 'application/json'}, ); } @@ -266,7 +356,12 @@ class AdminCardsApiV2 { try { if (cardId.isEmpty) { return Response.badRequest( - body: json.encode({'error': 'Invalid card ID'}), + body: json.encode({ + 'error': 'Invalid card ID', + 'message': 'Card ID cannot be empty', + 'field': 'cardId', + 'details': 'Please provide a valid card ID to update the card.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -277,7 +372,11 @@ class AdminCardsApiV2 { final existing = await _db.packDao.getCardById(cardId); if (existing == null) { return Response.notFound( - json.encode({'error': 'Card not found'}), + body: json.encode({ + 'error': 'Card not found', + 'message': 'The card you are trying to update does not exist', + 'details': 'Card with ID "$cardId" was not found. The card may have been deleted or the ID may be incorrect.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -300,9 +399,15 @@ class AdminCardsApiV2 { json.encode({'success': true}), headers: {'Content-Type': 'application/json'}, ); - } catch (e) { + } catch (e, s) { + print('Error in updateCard: $e\n$s'); return Response.internalServerError( - body: json.encode({'error': e.toString(), 'success': false}), + body: json.encode({ + 'error': 'Internal server error', + 'message': 'Failed to update card', + 'details': 'An unexpected error occurred while updating the card. Please try again later.', + 'success': false, + }), headers: {'Content-Type': 'application/json'}, ); } @@ -315,7 +420,25 @@ class AdminCardsApiV2 { try { if (cardId.isEmpty) { return Response.badRequest( - body: json.encode({'error': 'Invalid card ID'}), + body: json.encode({ + 'error': 'Invalid card ID', + 'message': 'Card ID cannot be empty', + 'field': 'cardId', + 'details': 'Please provide a valid card ID to delete the card.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + + // Check if card exists before attempting deletion + final existing = await _db.packDao.getCardById(cardId); + if (existing == null) { + return Response.notFound( + body: json.encode({ + 'error': 'Card not found', + 'message': 'The card you are trying to delete does not exist', + 'details': 'Card with ID "$cardId" was not found. It may have already been deleted.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -323,12 +446,29 @@ class AdminCardsApiV2 { await _db.packDao.deleteCard(cardId); return Response.ok( - json.encode({'success': true}), + body: json.encode({ + 'success': true, + 'message': 'Card deleted successfully', + }), headers: {'Content-Type': 'application/json'}, ); - } catch (e) { + } catch (e, s) { + print('Error in deleteCard: $e\n$s'); + + final errorMessage = e.toString().toLowerCase(); + String details = 'An unexpected error occurred while deleting the card.'; + + if (errorMessage.contains('foreign key') || errorMessage.contains('constraint')) { + details = 'Cannot delete card: it is still referenced by one or more packs. Please remove the card from all packs first.'; + } + return Response.internalServerError( - body: json.encode({'error': e.toString(), 'success': false}), + body: json.encode({ + 'error': 'Internal server error', + 'message': 'Failed to delete card', + 'details': details, + 'success': false, + }), headers: {'Content-Type': 'application/json'}, ); } @@ -346,7 +486,25 @@ class AdminCardsApiV2 { if (cardId.isEmpty) { return Response.badRequest( - body: json.encode({'error': 'Invalid card ID'}), + body: json.encode({ + 'error': 'Invalid card ID', + 'message': 'Card ID cannot be empty', + 'field': 'cardId', + 'details': 'Please provide a valid card ID to retrieve voices.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + + // Verify card exists + final card = await _db.packDao.getCardById(cardId); + if (card == null) { + return Response.notFound( + body: json.encode({ + 'error': 'Card not found', + 'message': 'The requested card does not exist', + 'details': 'Card with ID "$cardId" was not found. Cannot retrieve voices for a non-existent card.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -368,7 +526,11 @@ class AdminCardsApiV2 { } catch (e, s) { print('Error in getCardVoices: $e\n$s'); return Response.internalServerError( - body: json.encode({'error': e.toString()}), + body: json.encode({ + 'error': 'Internal server error', + 'message': 'Failed to retrieve card voices', + 'details': 'An unexpected error occurred while fetching voices for card "$cardId". Please try again later.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -386,7 +548,12 @@ class AdminCardsApiV2 { if (cardId.isEmpty) { return Response.badRequest( - body: json.encode({'error': 'Invalid card ID'}), + body: json.encode({ + 'error': 'Invalid card ID', + 'message': 'Card ID cannot be empty', + 'field': 'cardId', + 'details': 'Please provide a valid card ID to add a voice.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -399,7 +566,12 @@ class AdminCardsApiV2 { if (voiceUrl == null || voiceUrl.isEmpty) { return Response.badRequest( - body: json.encode({'error': 'voiceUrl is required'}), + body: json.encode({ + 'error': 'Validation error', + 'message': 'Voice URL is required', + 'field': 'voiceUrl', + 'details': 'Please provide a valid voice URL (base64 encoded audio data) to add a voice to the card.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -408,7 +580,11 @@ class AdminCardsApiV2 { final card = await _db.packDao.getCardById(cardId); if (card == null) { return Response.notFound( - json.encode({'error': 'Card not found'}), + body: json.encode({ + 'error': 'Card not found', + 'message': 'The card you are trying to add a voice to does not exist', + 'details': 'Card with ID "$cardId" was not found. Cannot add voice to a non-existent card.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -442,8 +618,21 @@ class AdminCardsApiV2 { ); } catch (e, s) { print('Error in addCardVoice: $e\n$s'); + + final errorMessage = e.toString().toLowerCase(); + String details = 'An unexpected error occurred while adding the voice to the card.'; + + if (errorMessage.contains('constraint') || errorMessage.contains('unique')) { + details = 'A voice with this URL may already exist for this card.'; + } + return Response.internalServerError( - body: json.encode({'error': e.toString(), 'success': false}), + body: json.encode({ + 'error': 'Internal server error', + 'message': 'Failed to add voice to card', + 'details': details, + 'success': false, + }), headers: {'Content-Type': 'application/json'}, ); } @@ -461,7 +650,25 @@ class AdminCardsApiV2 { if (cardId.isEmpty || voiceId.isEmpty) { return Response.badRequest( - body: json.encode({'error': 'Invalid card ID or voice ID'}), + body: json.encode({ + 'error': 'Invalid parameters', + 'message': 'Card ID and Voice ID cannot be empty', + 'field': cardId.isEmpty ? 'cardId' : 'voiceId', + 'details': 'Please provide valid card ID and voice ID to remove the voice.', + }), + headers: {'Content-Type': 'application/json'}, + ); + } + + // Verify voice exists + final voice = await _db.packDao.getVoiceById(voiceId); + if (voice == null) { + return Response.notFound( + body: json.encode({ + 'error': 'Voice not found', + 'message': 'The voice you are trying to remove does not exist', + 'details': 'Voice with ID "$voiceId" was not found. It may have already been deleted.', + }), headers: {'Content-Type': 'application/json'}, ); } @@ -473,13 +680,21 @@ class AdminCardsApiV2 { await _db.packDao.deleteVoice(voiceId); return Response.ok( - json.encode({'success': true}), + body: json.encode({ + 'success': true, + 'message': 'Voice removed successfully', + }), headers: {'Content-Type': 'application/json'}, ); } catch (e, s) { print('Error in removeCardVoice: $e\n$s'); return Response.internalServerError( - body: json.encode({'error': e.toString(), 'success': false}), + body: json.encode({ + 'error': 'Internal server error', + 'message': 'Failed to remove voice from card', + 'details': 'An unexpected error occurred while removing the voice. Please try again later.', + 'success': false, + }), headers: {'Content-Type': 'application/json'}, ); } diff --git a/mnemo_cards_backend/lib/api/v2/admin_packs_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_packs_api_v2.dart index 537dc41..8af4a78 100644 --- a/mnemo_cards_backend/lib/api/v2/admin_packs_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/admin_packs_api_v2.dart @@ -67,13 +67,23 @@ class AdminPacksApiV2 { // Validate pagination if (page < 1) { return _json( - {'error': 'Page must be greater than 0'}, + { + 'error': 'Invalid page parameter', + 'message': 'Page must be greater than 0. Received: $page', + 'field': 'page', + 'details': 'Page numbers start from 1. Please provide a valid page number.', + }, statusCode: 400, ); } if (limit < 1 || limit > 100) { return _json( - {'error': 'Limit must be between 1 and 100'}, + { + 'error': 'Invalid limit parameter', + 'message': 'Limit must be between 1 and 100. Received: $limit', + 'field': 'limit', + 'details': 'The maximum number of items per page is 100. Please use a value between 1 and 100.', + }, statusCode: 400, ); } @@ -131,6 +141,7 @@ class AdminPacksApiV2 { { 'error': 'Internal server error', 'message': 'Failed to fetch packs', + 'details': 'An unexpected error occurred while retrieving packs. Please try again later or contact support if the problem persists.', }, statusCode: 500, ); @@ -147,10 +158,26 @@ class AdminPacksApiV2 { return auth; } + if (packId.isEmpty) { + return _json( + { + 'error': 'Invalid pack ID', + 'message': 'Pack ID cannot be empty', + 'field': 'packId', + 'details': 'Please provide a valid pack ID to retrieve pack details.', + }, + statusCode: 400, + ); + } + final pack = await _db.packDao.getPackById(packId); if (pack == null) { return _json( - {'error': 'Pack not found'}, + { + 'error': 'Pack not found', + 'message': 'The requested pack does not exist or has been deleted', + 'details': 'Pack with ID "$packId" was not found in the database. Please verify the pack ID and try again.', + }, statusCode: 404, ); } @@ -195,6 +222,7 @@ class AdminPacksApiV2 { { 'error': 'Internal server error', 'message': 'Failed to fetch pack', + 'details': 'An unexpected error occurred while retrieving pack "$packId". Please try again later or contact support if the problem persists.', }, statusCode: 500, ); @@ -214,16 +242,33 @@ class AdminPacksApiV2 { final body = await request.readAsString(); final data = json.decode(body) as Map; + // Validate required fields + if (data['title'] == null || (data['title'] as String? ?? '').trim().isEmpty) { + return _json( + { + 'error': 'Validation error', + 'message': 'Title is required', + 'field': 'title', + 'details': 'Please provide a title for the pack. The title is a required field.', + }, + statusCode: 400, + ); + } + final editDto = EditCardPackDto.fromJson(data); String packId; - if (editDto.id != null) { + if (editDto.id != null && editDto.id!.isNotEmpty) { // Update existing pack packId = editDto.id!; final existing = await _db.packDao.getPackById(packId); if (existing == null) { return _json( - {'error': 'Pack not found'}, + { + 'error': 'Pack not found', + 'message': 'The pack you are trying to update does not exist', + 'details': 'Pack with ID "$packId" was not found. The pack may have been deleted or the ID may be incorrect.', + }, statusCode: 404, ); } @@ -270,11 +315,41 @@ class AdminPacksApiV2 { // Handle card associations if provided if (editDto.addCardIds != null && editDto.addCardIds!.isNotEmpty) { - for (final cardId in editDto.addCardIds!) { - await _db.packDao.addCardToPack( - packId: packId, - cardId: cardId, - ); + try { + for (final cardId in editDto.addCardIds!) { + // Verify card exists before adding + final card = await _db.packDao.getCardById(cardId); + if (card == null) { + return _json( + { + 'error': 'Card not found', + 'message': 'One of the cards you are trying to add does not exist', + 'field': 'addCardIds', + 'details': 'Card with ID "$cardId" was not found. Please verify all card IDs before adding them to the pack.', + }, + statusCode: 404, + ); + } + await _db.packDao.addCardToPack( + packId: packId, + cardId: cardId, + ); + } + } catch (e) { + // Handle duplicate or constraint errors + if (e.toString().toLowerCase().contains('unique') || + e.toString().toLowerCase().contains('constraint')) { + return _json( + { + 'error': 'Duplicate card', + 'message': 'One or more cards are already in this pack', + 'field': 'addCardIds', + 'details': 'Some cards you are trying to add are already associated with this pack. Please remove duplicates and try again.', + }, + statusCode: 409, + ); + } + rethrow; } } @@ -300,7 +375,11 @@ class AdminPacksApiV2 { final updatedPack = await _db.packDao.getPackById(packId); if (updatedPack == null) { return _json( - {'error': 'Failed to retrieve updated pack'}, + { + 'error': 'Database error', + 'message': 'Failed to retrieve updated pack', + 'details': 'Pack was saved successfully but could not be retrieved from the database. The pack ID is: $packId. Please try refreshing or fetching the pack again.', + }, statusCode: 500, ); } @@ -339,10 +418,26 @@ class AdminPacksApiV2 { }); } catch (e, s) { print('Error in upsertPack: $e\n$s'); + + // Check for common database errors + final errorMessage = e.toString().toLowerCase(); + String details = 'An unexpected error occurred while saving the pack.'; + + if (errorMessage.contains('constraint') || errorMessage.contains('unique')) { + details = 'A pack with similar data may already exist, or there is a constraint violation. Please check for duplicates or conflicting data.'; + } else if (errorMessage.contains('foreign key')) { + details = 'One of the referenced resources (cards, tests, etc.) does not exist. Please verify all IDs are correct.'; + } else if (errorMessage.contains('null') || errorMessage.contains('required')) { + details = 'Required fields are missing or invalid. Please check all required fields are provided.'; + } else if (errorMessage.contains('json')) { + details = 'Invalid JSON format in the request. Please check the request body structure.'; + } + return _json( { 'error': 'Internal server error', 'message': 'Failed to save pack', + 'details': details, }, statusCode: 500, ); @@ -359,10 +454,26 @@ class AdminPacksApiV2 { return auth; } + if (packId.isEmpty) { + return _json( + { + 'error': 'Invalid pack ID', + 'message': 'Pack ID cannot be empty', + 'field': 'packId', + 'details': 'Please provide a valid pack ID to delete the pack.', + }, + statusCode: 400, + ); + } + final pack = await _db.packDao.getPackById(packId); if (pack == null) { return _json( - {'error': 'Pack not found'}, + { + 'error': 'Pack not found', + 'message': 'The pack you are trying to delete does not exist', + 'details': 'Pack with ID "$packId" was not found. It may have already been deleted or the ID may be incorrect.', + }, statusCode: 404, ); } @@ -375,10 +486,19 @@ class AdminPacksApiV2 { }); } catch (e, s) { print('Error in deletePack: $e\n$s'); + + final errorMessage = e.toString().toLowerCase(); + String details = 'An unexpected error occurred while deleting the pack.'; + + if (errorMessage.contains('foreign key') || errorMessage.contains('constraint')) { + details = 'Cannot delete pack: it is still referenced by users or other resources. Please remove all references first.'; + } + return _json( { 'error': 'Internal server error', 'message': 'Failed to delete pack', + 'details': details, }, statusCode: 500, );