diff --git a/.kiro/specs/ultimate-lists-app/tasks.md b/.kiro/specs/ultimate-lists-app/tasks.md index 3f94799..24bd1d5 100644 --- a/.kiro/specs/ultimate-lists-app/tasks.md +++ b/.kiro/specs/ultimate-lists-app/tasks.md @@ -199,7 +199,7 @@ - Implement skeleton loaders - _Requirements: 7.3_ -- [ ] 16. Implement error handling +- [-] 16. Implement error handling - Add try-catch blocks to all server actions - Implement error boundaries in React components - Add user-friendly error messages diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..a4906a4 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,46 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle } from 'lucide-react' + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('Page error:', error) + }, [error]) + + return ( +
+
+
+ +
+

+ Something went wrong +

+

+ {error.message || 'We encountered an unexpected error. Please try again.'} +

+
+ + +
+
+
+ ) +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..0cfc06f --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle } from 'lucide-react' + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('Global error:', error) + }, [error]) + + return ( + + +
+
+
+ +
+

+ Application Error +

+

+ A critical error occurred. Please refresh the page or contact support if the problem persists. +

+
+ + +
+
+
+ + + ) +} diff --git a/app/layout.tsx b/app/layout.tsx index 6f98ef1..ab65a07 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import './globals.css' import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration' import OfflineIndicator from '@/components/OfflineIndicator' import { ToastProvider } from '@/components/ToastContainer' +import ErrorBoundary from '@/components/ErrorBoundary' export const metadata: Metadata = { title: 'Ultimate Lists', @@ -68,11 +69,13 @@ export default function RootLayout({ - - - - {children} - + + + + + {children} + + ) diff --git a/app/list/[id]/error.tsx b/app/list/[id]/error.tsx new file mode 100644 index 0000000..e5f0c2d --- /dev/null +++ b/app/list/[id]/error.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle } from 'lucide-react' +import Link from 'next/link' + +export default function ListError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('List page error:', error) + }, [error]) + + const isNotFound = error.message.includes('not found') + + return ( +
+
+
+ +
+

+ {isNotFound ? 'List Not Found' : 'Error Loading List'} +

+

+ {isNotFound + ? 'The list you are looking for does not exist or has been deleted.' + : error.message || 'We encountered an error loading this list. Please try again.'} +

+
+ {!isNotFound && ( + + )} + + Back to Lists + +
+
+
+ ) +} diff --git a/app/list/[id]/item/[itemId]/error.tsx b/app/list/[id]/item/[itemId]/error.tsx new file mode 100644 index 0000000..fb874aa --- /dev/null +++ b/app/list/[id]/item/[itemId]/error.tsx @@ -0,0 +1,55 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle } from 'lucide-react' +import { useRouter } from 'next/navigation' + +export default function ItemError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const router = useRouter() + + useEffect(() => { + console.error('Item page error:', error) + }, [error]) + + const isNotFound = error.message.includes('not found') + + return ( +
+
+
+ +
+

+ {isNotFound ? 'Item Not Found' : 'Error Loading Item'} +

+

+ {isNotFound + ? 'The item you are looking for does not exist or has been deleted.' + : error.message || 'We encountered an error loading this item. Please try again.'} +

+
+ {!isNotFound && ( + + )} + +
+
+
+ ) +} diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 0000000..d55a3f1 --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -0,0 +1,81 @@ +'use client' + +import React, { Component, ReactNode } from 'react' +import { AlertTriangle } from 'lucide-react' + +interface Props { + children: ReactNode + fallback?: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Error caught by boundary:', error, errorInfo) + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback + } + + return ( +
+
+
+ +
+

+ Something went wrong +

+

+ We encountered an unexpected error. Please try refreshing the page. +

+ {this.state.error && ( +
+ + Error details + +
+                  {this.state.error.message}
+                
+
+ )} +
+ + +
+
+
+ ) + } + + return this.props.children + } +} + +export default ErrorBoundary diff --git a/lib/actions.ts b/lib/actions.ts index 411018a..d5ed1b4 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -3,48 +3,149 @@ import { prisma } from './db' import { revalidatePath } from 'next/cache' +// Retry utility for database operations +async function retryOperation( + operation: () => Promise, + maxRetries: number = 3, + delayMs: number = 1000 +): Promise { + let lastError: Error | undefined + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await operation() + } catch (error) { + lastError = error as Error + + // Don't retry on validation errors or not found errors + if ( + error instanceof Error && + (error.message.includes('Unique constraint') || + error.message.includes('Record to update not found') || + error.message.includes('Record to delete does not exist')) + ) { + throw error + } + + // Wait before retrying (exponential backoff) + if (attempt < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, delayMs * Math.pow(2, attempt))) + } + } + } + + throw lastError +} + export async function getLists() { - return await prisma.list.findMany({ - include: { - items: true, - }, - orderBy: { - updatedAt: 'desc', - }, - }) + try { + return await retryOperation(() => + prisma.list.findMany({ + include: { + items: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }) + ) + } catch (error) { + console.error('Error fetching lists:', error) + throw new Error('Failed to load lists. Please check your connection and try again.') + } } export async function createList(title: string, color: string = '#3B82F6') { - const list = await prisma.list.create({ - data: { - title, - color, - }, - }) - revalidatePath('/') - return list + try { + // Validate input + if (!title || !title.trim()) { + throw new Error('List title is required') + } + + if (title.length > 200) { + throw new Error('List title must be less than 200 characters') + } + + const list = await retryOperation(() => + prisma.list.create({ + data: { + title: title.trim(), + color, + }, + }) + ) + + revalidatePath('/') + return list + } catch (error) { + console.error('Error creating list:', error) + if (error instanceof Error && error.message.includes('required')) { + throw error + } + throw new Error('Failed to create list. Please try again.') + } } export async function getListWithItems(listId: string) { - return await prisma.list.findUnique({ - where: { id: listId }, - include: { - items: { - orderBy: { - createdAt: 'desc', + try { + if (!listId) { + throw new Error('List ID is required') + } + + const list = await retryOperation(() => + prisma.list.findUnique({ + where: { id: listId }, + include: { + items: { + orderBy: { + createdAt: 'desc', + }, + }, }, - }, - }, - }) + }) + ) + + if (!list) { + throw new Error('List not found') + } + + return list + } catch (error) { + console.error('Error fetching list:', error) + if (error instanceof Error && error.message === 'List not found') { + throw error + } + throw new Error('Failed to load list. Please try again.') + } } export async function getItem(itemId: string) { - return await prisma.item.findUnique({ - where: { id: itemId }, - include: { - list: true, - }, - }) + try { + if (!itemId) { + throw new Error('Item ID is required') + } + + const item = await retryOperation(() => + prisma.item.findUnique({ + where: { id: itemId }, + include: { + list: true, + }, + }) + ) + + if (!item) { + throw new Error('Item not found') + } + + return item + } catch (error) { + console.error('Error fetching item:', error) + if (error instanceof Error && error.message === 'Item not found') { + throw error + } + throw new Error('Failed to load item. Please try again.') + } } export async function createItem( @@ -58,31 +159,75 @@ export async function createItem( notes?: string | null } ) { - const item = await prisma.item.create({ - data: { - title, - description, - listId, - ...(metadata?.priority && { priority: metadata.priority }), - ...(metadata?.dueDate !== undefined && { dueDate: metadata.dueDate }), - ...(metadata?.tags && { tags: metadata.tags }), - ...(metadata?.notes !== undefined && { notes: metadata.notes }), - }, - }) - revalidatePath(`/list/${listId}`) - return item + try { + // Validate input + if (!listId) { + throw new Error('List ID is required') + } + + if (!title || !title.trim()) { + throw new Error('Item title is required') + } + + if (title.length > 200) { + throw new Error('Item title must be less than 200 characters') + } + + const item = await retryOperation(() => + prisma.item.create({ + data: { + title: title.trim(), + description: description?.trim() || null, + listId, + ...(metadata?.priority && { priority: metadata.priority }), + ...(metadata?.dueDate !== undefined && { dueDate: metadata.dueDate }), + ...(metadata?.tags && { tags: metadata.tags }), + ...(metadata?.notes !== undefined && { notes: metadata.notes }), + }, + }) + ) + + revalidatePath(`/list/${listId}`) + return item + } catch (error) { + console.error('Error creating item:', error) + if (error instanceof Error && (error.message.includes('required') || error.message.includes('must be'))) { + throw error + } + throw new Error('Failed to create item. Please try again.') + } } export async function toggleItem(itemId: string) { - const item = await prisma.item.findUnique({ where: { id: itemId } }) - if (!item) return null - - const updated = await prisma.item.update({ - where: { id: itemId }, - data: { completed: !item.completed }, - }) - revalidatePath(`/list/${item.listId}`) - return updated + try { + if (!itemId) { + throw new Error('Item ID is required') + } + + const item = await retryOperation(() => + prisma.item.findUnique({ where: { id: itemId } }) + ) + + if (!item) { + throw new Error('Item not found') + } + + const updated = await retryOperation(() => + prisma.item.update({ + where: { id: itemId }, + data: { completed: !item.completed }, + }) + ) + + revalidatePath(`/list/${item.listId}`) + return updated + } catch (error) { + console.error('Error toggling item:', error) + if (error instanceof Error && error.message === 'Item not found') { + throw error + } + throw new Error('Failed to update item. Please try again.') + } } export async function updateItem( @@ -97,34 +242,114 @@ export async function updateItem( notes?: string | null } ) { - const item = await prisma.item.findUnique({ where: { id: itemId } }) - if (!item) return null + try { + if (!itemId) { + throw new Error('Item ID is required') + } + + // Validate title if provided + if (data.title !== undefined) { + if (!data.title || !data.title.trim()) { + throw new Error('Item title cannot be empty') + } + if (data.title.length > 200) { + throw new Error('Item title must be less than 200 characters') + } + data.title = data.title.trim() + } + + // Trim description if provided + if (data.description !== undefined && data.description !== null) { + data.description = data.description.trim() || null + } + + const item = await retryOperation(() => + prisma.item.findUnique({ where: { id: itemId } }) + ) + + if (!item) { + throw new Error('Item not found') + } - const updated = await prisma.item.update({ - where: { id: itemId }, - data, - }) - revalidatePath(`/list/${item.listId}`) - return updated + const updated = await retryOperation(() => + prisma.item.update({ + where: { id: itemId }, + data, + }) + ) + + revalidatePath(`/list/${item.listId}`) + return updated + } catch (error) { + console.error('Error updating item:', error) + if (error instanceof Error && (error.message.includes('not found') || error.message.includes('required') || error.message.includes('cannot be'))) { + throw error + } + throw new Error('Failed to update item. Please try again.') + } } export async function deleteItem(itemId: string) { - const item = await prisma.item.findUnique({ where: { id: itemId } }) - if (!item) return null + try { + if (!itemId) { + throw new Error('Item ID is required') + } + + const item = await retryOperation(() => + prisma.item.findUnique({ where: { id: itemId } }) + ) + + if (!item) { + throw new Error('Item not found') + } - await prisma.item.delete({ - where: { id: itemId }, - }) - revalidatePath(`/list/${item.listId}`) - return { success: true } + await retryOperation(() => + prisma.item.delete({ + where: { id: itemId }, + }) + ) + + revalidatePath(`/list/${item.listId}`) + return { success: true } + } catch (error) { + console.error('Error deleting item:', error) + if (error instanceof Error && error.message === 'Item not found') { + throw error + } + throw new Error('Failed to delete item. Please try again.') + } } export async function deleteList(listId: string) { - await prisma.list.delete({ - where: { id: listId }, - }) - revalidatePath('/') - return { success: true } + try { + if (!listId) { + throw new Error('List ID is required') + } + + // Check if list exists + const list = await retryOperation(() => + prisma.list.findUnique({ where: { id: listId } }) + ) + + if (!list) { + throw new Error('List not found') + } + + await retryOperation(() => + prisma.list.delete({ + where: { id: listId }, + }) + ) + + revalidatePath('/') + return { success: true } + } catch (error) { + console.error('Error deleting list:', error) + if (error instanceof Error && error.message === 'List not found') { + throw error + } + throw new Error('Failed to delete list. Please try again.') + } } export async function updateList( @@ -134,85 +359,120 @@ export async function updateList( color?: string } ) { - const updated = await prisma.list.update({ - where: { id: listId }, - data, - }) - revalidatePath('/') - revalidatePath(`/list/${listId}`) - return updated + try { + if (!listId) { + throw new Error('List ID is required') + } + + // Validate title if provided + if (data.title !== undefined) { + if (!data.title || !data.title.trim()) { + throw new Error('List title cannot be empty') + } + if (data.title.length > 200) { + throw new Error('List title must be less than 200 characters') + } + data.title = data.title.trim() + } + + const updated = await retryOperation(() => + prisma.list.update({ + where: { id: listId }, + data, + }) + ) + + revalidatePath('/') + revalidatePath(`/list/${listId}`) + return updated + } catch (error) { + console.error('Error updating list:', error) + if (error instanceof Error && (error.message.includes('not found') || error.message.includes('required') || error.message.includes('cannot be'))) { + throw error + } + throw new Error('Failed to update list. Please try again.') + } } export async function searchItems(query: string, listId?: string) { - if (!query.trim()) { - return [] - } + try { + if (!query || !query.trim()) { + return [] + } - const searchTerm = query.toLowerCase() + const searchTerm = query.toLowerCase() - // Build the where clause - const whereClause: any = { - OR: [ - { - title: { - contains: query, - mode: 'insensitive' as const, + // Build the where clause + const whereClause: any = { + OR: [ + { + title: { + contains: query, + mode: 'insensitive' as const, + }, }, - }, - { - description: { - contains: query, - mode: 'insensitive' as const, + { + description: { + contains: query, + mode: 'insensitive' as const, + }, }, - }, - { - notes: { - contains: query, - mode: 'insensitive' as const, + { + notes: { + contains: query, + mode: 'insensitive' as const, + }, }, - }, - ], - } + ], + } - // Add list scope if specified - if (listId) { - whereClause.listId = listId - } + // Add list scope if specified + if (listId) { + whereClause.listId = listId + } - const items = await prisma.item.findMany({ - where: whereClause, - include: { - list: true, - }, - orderBy: { - updatedAt: 'desc', - }, - }) - - // Also search in tags (stored as JSON) - const allItems = listId - ? await prisma.item.findMany({ - where: { listId }, - include: { list: true }, + const items = await retryOperation(() => + prisma.item.findMany({ + where: whereClause, + include: { + list: true, + }, + orderBy: { + updatedAt: 'desc', + }, }) - : await prisma.item.findMany({ - include: { list: true }, - }) - - const tagMatches = allItems.filter((item) => { - const tags = Array.isArray(item.tags) ? item.tags : [] - return tags.some((tag) => - typeof tag === 'string' && tag.toLowerCase().includes(searchTerm) ) - }) - // Combine results and remove duplicates - const combinedResults = [...items, ...tagMatches] - const uniqueResults = Array.from( - new Map(combinedResults.map((item) => [item.id, item])).values() - ) + // Also search in tags (stored as JSON) + const allItems = await retryOperation(() => + listId + ? prisma.item.findMany({ + where: { listId }, + include: { list: true }, + }) + : prisma.item.findMany({ + include: { list: true }, + }) + ) - return uniqueResults + const tagMatches = allItems.filter((item) => { + const tags = Array.isArray(item.tags) ? item.tags : [] + return tags.some((tag) => + typeof tag === 'string' && tag.toLowerCase().includes(searchTerm) + ) + }) + + // Combine results and remove duplicates + const combinedResults = [...items, ...tagMatches] + const uniqueResults = Array.from( + new Map(combinedResults.map((item) => [item.id, item])).values() + ) + + return uniqueResults + } catch (error) { + console.error('Error searching items:', error) + throw new Error('Failed to search items. Please try again.') + } } export async function getItemsByFilter( @@ -226,93 +486,100 @@ export async function getItemsByFilter( sortOrder?: 'asc' | 'desc' } = {} ) { - // Build the where clause with compound filters (AND logic) - const whereClause: any = {} + try { + // Build the where clause with compound filters (AND logic) + const whereClause: any = {} - // Add list scope if specified - if (listId) { - whereClause.listId = listId - } - - // Filter by priority - if (filters.priority) { - whereClause.priority = filters.priority - } - - // Filter by completed status - if (filters.completed !== undefined && filters.completed !== null) { - whereClause.completed = filters.completed - } - - // Filter by due date range - if (filters.dueDateRange) { - const { start, end } = filters.dueDateRange - if (start || end) { - whereClause.dueDate = {} - if (start) { - whereClause.dueDate.gte = start - } - if (end) { - whereClause.dueDate.lte = end - } + // Add list scope if specified + if (listId) { + whereClause.listId = listId } - } - // Fetch items from database - const items = await prisma.item.findMany({ - where: whereClause, - include: { - list: true, - }, - }) + // Filter by priority + if (filters.priority) { + whereClause.priority = filters.priority + } - // Filter by tags (client-side since tags are stored as JSON) - let filteredItems = items - if (filters.tags && filters.tags.length > 0) { - filteredItems = items.filter((item) => { - const itemTags = Array.isArray(item.tags) ? item.tags : [] - return filters.tags!.every((filterTag) => - itemTags.some((itemTag) => typeof itemTag === 'string' && itemTag === filterTag) - ) - }) - } + // Filter by completed status + if (filters.completed !== undefined && filters.completed !== null) { + whereClause.completed = filters.completed + } - // Sort items - const sortBy = filters.sortBy || 'createdAt' - const sortOrder = filters.sortOrder || 'desc' - - const sortedItems = [...filteredItems].sort((a, b) => { - let comparison = 0 - - switch (sortBy) { - case 'createdAt': - comparison = a.createdAt.getTime() - b.createdAt.getTime() - break - case 'dueDate': - // Handle null due dates - place at end - if (a.dueDate === null && b.dueDate === null) { - comparison = 0 - } else if (a.dueDate === null) { - comparison = 1 // a goes after b - } else if (b.dueDate === null) { - comparison = -1 // b goes after a - } else { - comparison = a.dueDate.getTime() - b.dueDate.getTime() + // Filter by due date range + if (filters.dueDateRange) { + const { start, end } = filters.dueDateRange + if (start || end) { + whereClause.dueDate = {} + if (start) { + whereClause.dueDate.gte = start } - break - case 'priority': - const priorityOrder = { low: 1, medium: 2, high: 3 } - const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2 - const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2 - comparison = aPriority - bPriority - break - case 'title': - comparison = a.title.localeCompare(b.title) - break + if (end) { + whereClause.dueDate.lte = end + } + } } - return sortOrder === 'asc' ? comparison : -comparison - }) + // Fetch items from database + const items = await retryOperation(() => + prisma.item.findMany({ + where: whereClause, + include: { + list: true, + }, + }) + ) - return sortedItems + // Filter by tags (client-side since tags are stored as JSON) + let filteredItems = items + if (filters.tags && filters.tags.length > 0) { + filteredItems = items.filter((item) => { + const itemTags = Array.isArray(item.tags) ? item.tags : [] + return filters.tags!.every((filterTag) => + itemTags.some((itemTag) => typeof itemTag === 'string' && itemTag === filterTag) + ) + }) + } + + // Sort items + const sortBy = filters.sortBy || 'createdAt' + const sortOrder = filters.sortOrder || 'desc' + + const sortedItems = [...filteredItems].sort((a, b) => { + let comparison = 0 + + switch (sortBy) { + case 'createdAt': + comparison = a.createdAt.getTime() - b.createdAt.getTime() + break + case 'dueDate': + // Handle null due dates - place at end + if (a.dueDate === null && b.dueDate === null) { + comparison = 0 + } else if (a.dueDate === null) { + comparison = 1 // a goes after b + } else if (b.dueDate === null) { + comparison = -1 // b goes after a + } else { + comparison = a.dueDate.getTime() - b.dueDate.getTime() + } + break + case 'priority': + const priorityOrder = { low: 1, medium: 2, high: 3 } + const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 2 + const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 2 + comparison = aPriority - bPriority + break + case 'title': + comparison = a.title.localeCompare(b.title) + break + } + + return sortOrder === 'asc' ? comparison : -comparison + }) + + return sortedItems + } catch (error) { + console.error('Error filtering items:', error) + throw new Error('Failed to filter items. Please try again.') + } } \ No newline at end of file diff --git a/lib/db.ts b/lib/db.ts index 16dfe88..4af4f8b 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -4,6 +4,22 @@ const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined } -export const prisma = globalForPrisma.prisma ?? new PrismaClient() +// Create Prisma client with connection error handling +export const prisma = globalForPrisma.prisma ?? new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'], +}) + +// Handle connection errors +prisma.$connect().catch((error) => { + console.error('Failed to connect to database:', error) + // Don't throw here - let individual queries handle connection errors +}) + +// Graceful shutdown +if (typeof window === 'undefined') { + process.on('beforeExit', async () => { + await prisma.$disconnect() + }) +} if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma \ No newline at end of file