From dfad78a0ca55f9f4cf6196edb744a13251bff63c Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 3 Jul 2025 17:04:08 +0300 Subject: [PATCH] feat(ItemsList, database): enhance item update logic with validation and improved error handling --- .../urls/[encodedUrl]/items/[itemId]/route.ts | 58 +++++- src/components/ItemsList.tsx | 59 ++++-- src/lib/database.ts | 185 ++++++++++-------- 3 files changed, 196 insertions(+), 106 deletions(-) diff --git a/src/app/api/urls/[encodedUrl]/items/[itemId]/route.ts b/src/app/api/urls/[encodedUrl]/items/[itemId]/route.ts index 910db02..2e6d46a 100644 --- a/src/app/api/urls/[encodedUrl]/items/[itemId]/route.ts +++ b/src/app/api/urls/[encodedUrl]/items/[itemId]/route.ts @@ -1,6 +1,62 @@ //Handles DELETE requests for specific items import { NextRequest, NextResponse } from 'next/server'; -import { deleteItemByUrl } from '@/lib/database'; +import { deleteItemByUrl, insertItemByUrl } from '@/lib/database'; +import { Item } from '@/types/database'; + +// PUT /api/urls/[encodedUrl]/items/[itemId] - Update a specific item +export async function PUT( + request: NextRequest, + { params }: { params: { encodedUrl: string; itemId: string } } +) { + try { + const { encodedUrl, itemId } = params; + const url = decodeURIComponent(encodedUrl); + const item: Item = await request.json(); + + console.log('[API] Updating item', itemId, 'for URL', url); + + // Validate that the item ID matches the URL parameter + if (item.id !== itemId) { + return NextResponse.json( + { error: 'Item ID mismatch' }, + { status: 400 } + ); + } + + // Validate required fields + if (!item.id) { + return NextResponse.json( + { error: 'Item ID is required' }, + { status: 400 } + ); + } + + // Ensure name and description are strings + const sanitizedItem: Item = { + ...item, + name: item.name || '', + description: item.description || '', + customProperties: item.customProperties || {} + }; + + await insertItemByUrl(url, sanitizedItem); + + console.log('[API] Successfully updated item:', itemId); + + return NextResponse.json({ success: true, itemId }); + } catch (error) { + console.error('[API] Update error:', error); + return NextResponse.json( + { + error: 'Database error', + details: error instanceof Error ? error.message : 'Unknown error', + itemId: params.itemId, + url: decodeURIComponent(params.encodedUrl) + }, + { status: 500 } + ); + } +} // DELETE /api/urls/[encodedUrl]/items/[itemId] - Delete a specific item export async function DELETE( diff --git a/src/components/ItemsList.tsx b/src/components/ItemsList.tsx index 0afe4e1..550d320 100644 --- a/src/components/ItemsList.tsx +++ b/src/components/ItemsList.tsx @@ -344,34 +344,53 @@ export function ItemsList({ url }: ItemsListProps) { newItems[index] = item; - // Auto-save with error handling - saveItemMutation.mutate(item, { - onError: (error) => { - console.error('Failed to save item:', error); - // You could add a toast notification here - } - }); + // Only save if the item has some content + const hasContent = item.name.trim() || + item.description.trim() || + Object.values(item.customProperties).some(prop => prop.trim()); - // Add new row if editing last row and value is not empty - if (index === newItems.length - 1 && value.trim()) { - const newItem: Item = { - id: uuidv4(), - name: '', - description: '', - wikidataId: undefined, - customProperties: {} - }; - newItems.push(newItem); - saveItemMutation.mutate(newItem, { + if (hasContent) { + // Auto-save with error handling + saveItemMutation.mutate(item, { onError: (error) => { - console.error('Failed to save new item:', error); + console.error('Failed to save item:', error); + // You could add a toast notification here + }, + onSuccess: () => { + // After successful save, reload items to get any synchronized properties + // This ensures that if another item with the same name exists, + // its properties are updated in the UI + queryClient.invalidateQueries({ queryKey: ['items', url] }); } }); } + // Add new row if editing last row and value is not empty + // Only add if the last item doesn't already have content and we're not already at the end + if (index === newItems.length - 1 && value.trim()) { + const lastItem = newItems[newItems.length - 1]; + const lastItemHasContent = lastItem.name.trim() || + lastItem.description.trim() || + Object.values(lastItem.customProperties).some(prop => prop.trim()); + + if (lastItemHasContent) { + const newItem: Item = { + id: uuidv4(), + name: '', + description: '', + wikidataId: undefined, + customProperties: {} + }; + newItems.push(newItem); + + // Don't auto-save empty items immediately + // They will be saved when user starts typing + } + } + return newItems; }); - }, [saveItemMutation]); + }, [saveItemMutation, queryClient, url]); const removeItem = useCallback((index: number) => { const itemId = items[index].id; diff --git a/src/lib/database.ts b/src/lib/database.ts index cdc5bb5..357cea3 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -39,85 +39,76 @@ async function getOrCreateProperty(tx: TransactionClient, propertyName: string): // Get items by URL export async function getItemsByUrl(url: string): Promise { - console.log(`[DB] Fetching items for URL: ${url}`); + console.log(`[DB] Getting items for URL: ${url}`); - // First check if URL exists - const urlRecord = await prisma.url.findUnique({ - where: { url } - }); - - if (!urlRecord) { - console.log(`[DB] URL not found: ${url}`); - return []; - } - - console.log(`[DB] Found URL ID: ${urlRecord.id}`); - - // Get items with their properties, excluding deleted ones - const items = await prisma.item.findMany({ - where: { urlId: urlRecord.id }, - include: { - itemProperties: { - include: { property: true }, - where: { - NOT: { - AND: [ - { globalItemId: { in: [] } }, // This will be populated below - { propertyId: { in: [] } } // This will be populated below - ] + return await withTransaction(async (tx) => { + // Get URL record + const urlRecord = await tx.url.findUnique({ + where: { url }, + include: { + items: { + orderBy: { itemOrder: 'asc' }, + include: { + // We don't include itemProperties here since we'll fetch them by globalItemId } } } - }, - orderBy: { itemOrder: 'asc' } - }); - - // Get deleted properties for this URL to filter them out - const deletedProps = await prisma.deletedProperty.findMany({ - where: { urlId: urlRecord.id } - }); - - const deletedPropsSet = new Set( - deletedProps.map(dp => `${dp.globalItemId}-${dp.propertyId}`) - ); - - // Transform to Item format - const result: Item[] = items.map(item => { - const customProperties: Record = {}; - let name = ''; - let description = ''; - - item.itemProperties.forEach(ip => { - const key = `${ip.globalItemId}-${ip.propertyId}`; - if (deletedPropsSet.has(key)) { - return; // Skip deleted properties - } - - if (ip.property.name === 'name') { - name = ip.value; - } else if (ip.property.name === 'description') { - description = ip.value; - } else { - customProperties[ip.property.name] = ip.value; - } }); - return { - id: item.id, - name, - description, - wikidataId: item.wikidataId || undefined, - customProperties - }; + if (!urlRecord) { + console.log(`[DB] No URL record found for: ${url}`); + return []; + } + + console.log(`[DB] Found ${urlRecord.items.length} items for URL: ${url}`); + + // For each item, get properties from globalItemId + const items: Item[] = []; + + for (const dbItem of urlRecord.items) { + // Get all properties for this globalItemId + const itemProperties = await tx.itemProperty.findMany({ + where: { globalItemId: dbItem.globalItemId }, + include: { property: true } + }); + + // Separate core properties from custom properties + const customProperties: Record = {}; + let name = ''; + let description = ''; + + for (const prop of itemProperties) { + const propName = prop.property.name; + const propValue = prop.value; + + if (propName === 'name') { + name = propValue; + } else if (propName === 'description') { + description = propValue; + } else { + customProperties[propName] = propValue; + } + } + + const item: Item = { + id: dbItem.id, + name, + description, + wikidataId: dbItem.wikidataId || undefined, + customProperties + }; + + items.push(item); + } + + console.log(`[DB] Returning ${items.length} items with properties`); + return items; }); - - console.log(`[DB] Fetched ${result.length} items`); - return result; } // Insert item by URL export async function insertItemByUrl(url: string, item: Item): Promise { - console.log(`[DB] Starting insert for URL: ${url}, Item: ${item.id}`); + console.log(`[DB] Starting insert for URL: ${url}, Item: ${item.id}, Name: ${item.name}`); await withTransaction(async (tx) => { // 1. Get or create URL @@ -129,27 +120,50 @@ export async function insertItemByUrl(url: string, item: Item): Promise { console.log(`[DB] URL ID: ${urlRecord.id}`); - // 2. Get max order for new items + // 2. Check if this specific item already exists for this URL + const existingItem = await tx.item.findUnique({ + where: { id: item.id } + }); + + // 3. Find or create globalItemId based on item name + let globalItemId: string; + + if (existingItem) { + // Use existing globalItemId if item already exists + globalItemId = existingItem.globalItemId; + console.log(`[DB] Using existing global item ID: ${globalItemId}`); + } else { + // For new items, find existing globalItemId by name or create new one + const namePropertyId = await getOrCreateProperty(tx, 'name'); + + // Look for existing global item with the same name + const existingGlobalItem = await tx.itemProperty.findFirst({ + where: { + propertyId: namePropertyId, + value: item.name.trim() + }, + select: { globalItemId: true } + }); + + if (existingGlobalItem && item.name.trim()) { + // Use existing globalItemId for items with the same name + globalItemId = existingGlobalItem.globalItemId; + console.log(`[DB] Found existing global item ID for name "${item.name}": ${globalItemId}`); + } else { + // Create new globalItemId for new names or empty names + globalItemId = uuidv4(); + console.log(`[DB] Created new global item ID: ${globalItemId}`); + } + } + + // 4. Get max order for new items const maxOrder = await tx.item.findFirst({ where: { urlId: urlRecord.id }, orderBy: { itemOrder: 'desc' }, select: { itemOrder: true } }); - // 3. Check if global item exists (by name) - const namePropertyId = await getOrCreateProperty(tx, 'name'); - const existingGlobalItem = await tx.itemProperty.findFirst({ - where: { - propertyId: namePropertyId, - value: item.name - }, - select: { globalItemId: true } - }); - - const globalItemId = existingGlobalItem?.globalItemId || uuidv4(); - console.log(`[DB] Using global item ID: ${globalItemId}`); - - // 4. Upsert item + // 5. Upsert the item record (unique by item.id) await tx.item.upsert({ where: { id: item.id }, update: { @@ -166,7 +180,7 @@ export async function insertItemByUrl(url: string, item: Item): Promise { } }); - // 5. Handle all properties (core + custom) + // 6. Handle all properties (core + custom) const allProperties = [ ['name', item.name], ['description', item.description], @@ -176,6 +190,7 @@ export async function insertItemByUrl(url: string, item: Item): Promise { for (const [propName, propValue] of allProperties) { const propertyId = await getOrCreateProperty(tx, propName); + // Update properties for the globalItemId (this affects all items with same name) await tx.itemProperty.upsert({ where: { globalItemId_propertyId: { @@ -192,7 +207,7 @@ export async function insertItemByUrl(url: string, item: Item): Promise { }); } - console.log(`[DB] Item ${item.id} inserted/updated successfully`); + console.log(`[DB] Item ${item.id} with global ID ${globalItemId} inserted/updated successfully`); }); }