feat(ItemsList, database): enhance item update logic with validation and improved error handling

This commit is contained in:
ryan 2025-07-03 17:04:08 +03:00
parent 887815c7da
commit dfad78a0ca
3 changed files with 196 additions and 106 deletions

View file

@ -1,6 +1,62 @@
//Handles DELETE requests for specific items //Handles DELETE requests for specific items
import { NextRequest, NextResponse } from 'next/server'; 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 // DELETE /api/urls/[encodedUrl]/items/[itemId] - Delete a specific item
export async function DELETE( export async function DELETE(

View file

@ -344,16 +344,36 @@ export function ItemsList({ url }: ItemsListProps) {
newItems[index] = item; newItems[index] = item;
// Only save if the item has some content
const hasContent = item.name.trim() ||
item.description.trim() ||
Object.values(item.customProperties).some(prop => prop.trim());
if (hasContent) {
// Auto-save with error handling // Auto-save with error handling
saveItemMutation.mutate(item, { saveItemMutation.mutate(item, {
onError: (error) => { onError: (error) => {
console.error('Failed to save item:', error); console.error('Failed to save item:', error);
// You could add a toast notification here // 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 // 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()) { 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 = { const newItem: Item = {
id: uuidv4(), id: uuidv4(),
name: '', name: '',
@ -362,16 +382,15 @@ export function ItemsList({ url }: ItemsListProps) {
customProperties: {} customProperties: {}
}; };
newItems.push(newItem); newItems.push(newItem);
saveItemMutation.mutate(newItem, {
onError: (error) => { // Don't auto-save empty items immediately
console.error('Failed to save new item:', error); // They will be saved when user starts typing
} }
});
} }
return newItems; return newItems;
}); });
}, [saveItemMutation]); }, [saveItemMutation, queryClient, url]);
const removeItem = useCallback((index: number) => { const removeItem = useCallback((index: number) => {
const itemId = items[index].id; const itemId = items[index].id;

View file

@ -39,85 +39,76 @@ async function getOrCreateProperty(tx: TransactionClient, propertyName: string):
// Get items by URL // Get items by URL
export async function getItemsByUrl(url: string): Promise<Item[]> { export async function getItemsByUrl(url: string): Promise<Item[]> {
console.log(`[DB] Fetching items for URL: ${url}`); console.log(`[DB] Getting items for URL: ${url}`);
// First check if URL exists return await withTransaction(async (tx) => {
const urlRecord = await prisma.url.findUnique({ // Get URL record
where: { url } 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
}
}
}
}); });
if (!urlRecord) { if (!urlRecord) {
console.log(`[DB] URL not found: ${url}`); console.log(`[DB] No URL record found for: ${url}`);
return []; return [];
} }
console.log(`[DB] Found URL ID: ${urlRecord.id}`); console.log(`[DB] Found ${urlRecord.items.length} items for URL: ${url}`);
// Get items with their properties, excluding deleted ones // For each item, get properties from globalItemId
const items = await prisma.item.findMany({ const items: Item[] = [];
where: { urlId: urlRecord.id },
include: { for (const dbItem of urlRecord.items) {
itemProperties: { // Get all properties for this globalItemId
include: { property: true }, const itemProperties = await tx.itemProperty.findMany({
where: { where: { globalItemId: dbItem.globalItemId },
NOT: { include: { property: true }
AND: [
{ globalItemId: { in: [] } }, // This will be populated below
{ propertyId: { in: [] } } // This will be populated below
]
}
}
}
},
orderBy: { itemOrder: 'asc' }
}); });
// Get deleted properties for this URL to filter them out // Separate core properties from custom properties
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<string, string> = {}; const customProperties: Record<string, string> = {};
let name = ''; let name = '';
let description = ''; let description = '';
item.itemProperties.forEach(ip => { for (const prop of itemProperties) {
const key = `${ip.globalItemId}-${ip.propertyId}`; const propName = prop.property.name;
if (deletedPropsSet.has(key)) { const propValue = prop.value;
return; // Skip deleted properties
}
if (ip.property.name === 'name') { if (propName === 'name') {
name = ip.value; name = propValue;
} else if (ip.property.name === 'description') { } else if (propName === 'description') {
description = ip.value; description = propValue;
} else { } else {
customProperties[ip.property.name] = ip.value; customProperties[propName] = propValue;
}
} }
});
return { const item: Item = {
id: item.id, id: dbItem.id,
name, name,
description, description,
wikidataId: item.wikidataId || undefined, wikidataId: dbItem.wikidataId || undefined,
customProperties customProperties
}; };
});
console.log(`[DB] Fetched ${result.length} items`); items.push(item);
return result; }
console.log(`[DB] Returning ${items.length} items with properties`);
return items;
});
} }
// Insert item by URL // Insert item by URL
export async function insertItemByUrl(url: string, item: Item): Promise<void> { export async function insertItemByUrl(url: string, item: Item): Promise<void> {
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) => { await withTransaction(async (tx) => {
// 1. Get or create URL // 1. Get or create URL
@ -129,27 +120,50 @@ export async function insertItemByUrl(url: string, item: Item): Promise<void> {
console.log(`[DB] URL ID: ${urlRecord.id}`); 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({ const maxOrder = await tx.item.findFirst({
where: { urlId: urlRecord.id }, where: { urlId: urlRecord.id },
orderBy: { itemOrder: 'desc' }, orderBy: { itemOrder: 'desc' },
select: { itemOrder: true } select: { itemOrder: true }
}); });
// 3. Check if global item exists (by name) // 5. Upsert the item record (unique by item.id)
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
await tx.item.upsert({ await tx.item.upsert({
where: { id: item.id }, where: { id: item.id },
update: { update: {
@ -166,7 +180,7 @@ export async function insertItemByUrl(url: string, item: Item): Promise<void> {
} }
}); });
// 5. Handle all properties (core + custom) // 6. Handle all properties (core + custom)
const allProperties = [ const allProperties = [
['name', item.name], ['name', item.name],
['description', item.description], ['description', item.description],
@ -176,6 +190,7 @@ export async function insertItemByUrl(url: string, item: Item): Promise<void> {
for (const [propName, propValue] of allProperties) { for (const [propName, propValue] of allProperties) {
const propertyId = await getOrCreateProperty(tx, propName); const propertyId = await getOrCreateProperty(tx, propName);
// Update properties for the globalItemId (this affects all items with same name)
await tx.itemProperty.upsert({ await tx.itemProperty.upsert({
where: { where: {
globalItemId_propertyId: { globalItemId_propertyId: {
@ -192,7 +207,7 @@ export async function insertItemByUrl(url: string, item: Item): Promise<void> {
}); });
} }
console.log(`[DB] Item ${item.id} inserted/updated successfully`); console.log(`[DB] Item ${item.id} with global ID ${globalItemId} inserted/updated successfully`);
}); });
} }