From 50d735ae5dea2a144568167272156d990dddb857 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 18 Jun 2025 14:24:38 +0300 Subject: [PATCH] feat(database): implement CRUD operations for URLs and items with transaction support --- src/lib/database.ts | 338 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 src/lib/database.ts diff --git a/src/lib/database.ts b/src/lib/database.ts new file mode 100644 index 0000000..cdc5bb5 --- /dev/null +++ b/src/lib/database.ts @@ -0,0 +1,338 @@ +import { prisma, withTransaction } from './prisma'; +import { Item} from '../types/database'; +import { v4 as uuidv4 } from 'uuid'; +import { Prisma } from '../generated/prisma'; + +// Type for transaction client +type TransactionClient = Prisma.TransactionClient; + +// Insert URL +export async function insertUrl(url: string): Promise { + console.log(`[DB] Inserting URL: ${url}`); + + const result = await prisma.url.upsert({ + where: { url }, + update: {}, + create: { url }, + }); + + console.log(`[DB] URL inserted/found with ID: ${result.id}`); + return result.id; +} + +// Get or create property +async function getOrCreateProperty(tx: TransactionClient, propertyName: string): Promise { + const existing = await tx.property.findUnique({ + where: { name: propertyName } + }); + + if (existing) { + return existing.id; + } + + const created = await tx.property.create({ + data: { name: propertyName, globalUsageCount: 0 } + }); + + return created.id; +} + +// Get items by URL +export async function getItemsByUrl(url: string): Promise { + console.log(`[DB] Fetching 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 + ] + } + } + } + }, + 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 + }; + }); + + 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}`); + + await withTransaction(async (tx) => { + // 1. Get or create URL + const urlRecord = await tx.url.upsert({ + where: { url }, + update: {}, + create: { url }, + }); + + console.log(`[DB] URL ID: ${urlRecord.id}`); + + // 2. 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 + await tx.item.upsert({ + where: { id: item.id }, + update: { + urlId: urlRecord.id, + wikidataId: item.wikidataId, + globalItemId + }, + create: { + id: item.id, + urlId: urlRecord.id, + wikidataId: item.wikidataId, + itemOrder: (maxOrder?.itemOrder || 0) + 1, + globalItemId + } + }); + + // 5. Handle all properties (core + custom) + const allProperties = [ + ['name', item.name], + ['description', item.description], + ...Object.entries(item.customProperties) + ]; + + for (const [propName, propValue] of allProperties) { + const propertyId = await getOrCreateProperty(tx, propName); + + await tx.itemProperty.upsert({ + where: { + globalItemId_propertyId: { + globalItemId, + propertyId + } + }, + update: { value: propValue }, + create: { + globalItemId, + propertyId, + value: propValue + } + }); + } + + console.log(`[DB] Item ${item.id} inserted/updated successfully`); + }); +} + +// Delete item by URL +export async function deleteItemByUrl(url: string, itemId: string): Promise { + console.log(`[DB] Deleting item ${itemId} from URL: ${url}`); + + await withTransaction(async (tx) => { + // Get URL ID + const urlRecord = await tx.url.findUnique({ + where: { url } + }); + + if (!urlRecord) { + throw new Error(`URL not found: ${url}`); + } + + // Get the item to find its global_item_id + const item = await tx.item.findFirst({ + where: { id: itemId, urlId: urlRecord.id } + }); + + if (!item) { + throw new Error(`Item not found: ${itemId}`); + } + + // Delete the item (this will cascade to item_properties via foreign key) + await tx.item.delete({ + where: { id: itemId } + }); + + // Clean up item properties for this global item + await tx.itemProperty.deleteMany({ + where: { globalItemId: item.globalItemId } + }); + + console.log(`[DB] Item ${itemId} deleted successfully`); + }); +} + +// Delete property by URL +export async function deletePropertyByUrl(url: string, propertyName: string): Promise { + console.log(`[DB] Soft deleting property ${propertyName} for URL: ${url}`); + + await withTransaction(async (tx) => { + // Get URL ID + const urlRecord = await tx.url.findUnique({ + where: { url } + }); + + if (!urlRecord) { + throw new Error(`URL not found: ${url}`); + } + + // Get property ID + const property = await tx.property.findUnique({ + where: { name: propertyName } + }); + + if (!property) { + throw new Error(`Property not found: ${propertyName}`); + } + + // Get all global item IDs for this URL + const items = await tx.item.findMany({ + where: { urlId: urlRecord.id }, + select: { globalItemId: true } + }); + + // Insert into deleted_properties for each global item + for (const item of items) { + await tx.deletedProperty.upsert({ + where: { + urlId_globalItemId_propertyId: { + urlId: urlRecord.id, + globalItemId: item.globalItemId, + propertyId: property.id + } + }, + update: {}, + create: { + urlId: urlRecord.id, + globalItemId: item.globalItemId, + propertyId: property.id + } + }); + } + + console.log(`[DB] Property ${propertyName} soft deleted for URL: ${url}`); + }); +} + +// Add selected property +export async function addSelectedProperty(url: string, propertyName: string): Promise { + console.log(`[DB] Adding selected property ${propertyName} for URL: ${url}`); + + await withTransaction(async (tx) => { + // Get or create URL + const urlRecord = await tx.url.upsert({ + where: { url }, + update: {}, + create: { url } + }); + + // Get or create property + const propertyId = await getOrCreateProperty(tx, propertyName); + + // Add to selected properties + await tx.selectedProperty.upsert({ + where: { + urlId_propertyId: { + urlId: urlRecord.id, + propertyId + } + }, + update: {}, + create: { + urlId: urlRecord.id, + propertyId + } + }); + + console.log(`[DB] Selected property ${propertyName} added for URL: ${url}`); + }); +} + +// Get selected properties +export async function getSelectedProperties(url: string): Promise { + console.log(`[DB] Getting selected properties for URL: ${url}`); + + const result = await prisma.selectedProperty.findMany({ + where: { + url: { url } + }, + include: { + property: true + } + }); + + const properties = result.map(sp => sp.property.name); + console.log(`[DB] Found ${properties.length} selected properties`); + return properties; +} \ No newline at end of file