diff --git a/packages/validations/src/validations/itemRoutesValidator.ts b/packages/validations/src/validations/itemRoutesValidator.ts index 890257491..95eb2bb6f 100644 --- a/packages/validations/src/validations/itemRoutesValidator.ts +++ b/packages/validations/src/validations/itemRoutesValidator.ts @@ -66,10 +66,20 @@ export const addItemGlobal = z.object({ name: z.string(), weight: z.number(), unit: z.string(), - type: z.string(), + type: z.enum(['Food', 'Water', 'Essentials']), ownerId: z.string(), + sku: z.string().optional(), + productUrl: z.string().optional(), + description: z.string().optional(), + productDetails: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional(), + seller: z.string().optional(), + image_urls: z.string().optional(), }); +export type AddItemGlobalType = z.infer; + export const importItemsGlobal = z.object({ content: z.string(), ownerId: z.string(), diff --git a/packages/validations/src/validations/packTemplateRoutesValidator.ts b/packages/validations/src/validations/packTemplateRoutesValidator.ts index a98162019..de1c47541 100644 --- a/packages/validations/src/validations/packTemplateRoutesValidator.ts +++ b/packages/validations/src/validations/packTemplateRoutesValidator.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { addItemGlobal } from './itemRoutesValidator'; export const getPackTemplates = z.object({ filter: z @@ -13,11 +14,35 @@ export const getPackTemplates = z.object({ }), }); -export const getPackTemplate = z.object({ - id: z.string().min(1), -}); +export const getPackTemplate = z.union([ + z.object({ + id: z.string().min(1), + name: z.string().optional(), + }), + z.object({ + name: z.string(), + id: z.string().optional(), + }), +]); export const createPackFromTemplate = z.object({ packTemplateId: z.string().min(1), newPackName: z.string().min(1), }); + +export const addPackTemplate = z.object({ + name: z.string(), + description: z.string(), + type: z.string(), + itemsOwnerId: z.string(), + itemPackTemplates: z.array( + z.object({ + item: addItemGlobal.omit({ ownerId: true }), + quantity: z.number(), + }), + ), +}); + +export type AddPackTemplateType = z.infer; + +export const addPackTemplates = z.array(addPackTemplate); diff --git a/server/src/controllers/item/importItemsGlobal.ts b/server/src/controllers/item/importItemsGlobal.ts index e29dc511d..50014a0e0 100644 --- a/server/src/controllers/item/importItemsGlobal.ts +++ b/server/src/controllers/item/importItemsGlobal.ts @@ -1,7 +1,7 @@ import { type Context } from 'hono'; import { addItemGlobalService, - addItemGlobalServiceBatch, + bulkAddItemsGlobalService, } from '../../services/item/item.service'; import { protectedProcedure } from '../../trpc'; import * as validator from '@packrat/validations'; @@ -72,6 +72,61 @@ export const importItemsGlobal = async (c: Context) => { } }; +/** + * Converts a list of raw CSV items into an iterable of validated items. + * @param {Array>} csvRawItems - The raw CSV items. + * @param {string} ownerId - The ID of the owner. + * @returns {Iterable} An iterable that yields the validated items. + */ +function* sanitizeItemsIterator( + csvRawItems: Array>, + ownerId: string, +): Generator { + for (let idx = 0; idx < csvRawItems.length; idx++) { + const item = csvRawItems[idx]; + + const productDetailsStr = `${item.techs}` + .replace(/'([^']*)'\s*:/g, '"$1":') // Replace single quotes keys with double quotes. + .replace(/:\s*'([^']*)'/g, ': "$1"') // Replace single quotes values with double quotes. + .replace(/\\x([0-9A-Fa-f]{2})/g, (match, hex) => { + // Replace hex escape sequences with UTF-8 characters + const codePoint = parseInt(hex, 16); + return String.fromCharCode(codePoint); + }); + + console.log(`${idx} / ${csvRawItems.length}`); + let parsedProductDetails: + | validator.AddItemGlobalType['productDetails'] + | null = null; + try { + parsedProductDetails = JSON.parse(productDetailsStr); + } catch (e) { + console.log( + `${productDetailsStr}\nFailed to parse product details for item ${item.Name}: ${e.message}`, + ); + throw e; + } + + const validatedItem: validator.AddItemGlobalType = { + name: String(item.Name), + weight: Number(item.Weight), + unit: String(item.Unit), + type: String(item.Category) as ItemCategoryEnum, + ownerId, + image_urls: item.image_urls && String(item.image_urls), + sku: item.sku && String(item.sku), + productUrl: item.product_url && String(item.product_url), + description: item.description && String(item.description), + seller: item.seller && String(item.seller), + }; + + if (parsedProductDetails) { + validatedItem.productDetails = parsedProductDetails; + } + + yield validatedItem; + } +} export function importItemsGlobalRoute() { const expectedHeaders = [ 'Name', @@ -114,49 +169,23 @@ export function importItemsGlobalRoute() { results.data.pop(); } - let idx = 0; - await addItemGlobalServiceBatch( - results.data, - (item) => { - const productDetailsStr = `${item.techs}` - .replace(/'([^']*)'\s*:/g, '"$1":') // Replace single quotes keys with double quotes. - .replace(/:\s*'([^']*)'/g, ': "$1"') // Replace single quotes values with double quotes. - .replace(/\\x([0-9A-Fa-f]{2})/g, (match, hex) => { - // Replace hex escape sequences with UTF-8 characters - const codePoint = parseInt(hex, 16); - return String.fromCharCode(codePoint); - }); - - idx++; - console.log(`${idx} / ${results.data.length}`); - try { - const parsedProductDetails = JSON.parse(productDetailsStr); - } catch (e) { - console.log( - `${productDetailsStr}\nFailed to parse product details for item ${item.Name}: ${e.message}`, - ); - throw e; - } - - return { - name: String(item.Name), - weight: Number(item.Weight), - unit: String(item.Unit), - type: String(item.Category) as ItemCategoryEnum, - ownerId, - executionCtx: opts.ctx.executionCtx, - image_urls: item.image_urls && String(item.image_urls), - sku: item.sku && String(item.sku), - productUrl: item.product_url && String(item.product_url), - description: item.description && String(item.description), - seller: item.seller && String(item.seller), - productDetails: JSON.parse(productDetailsStr), - }; - }, - false, + const errors: Error[] = []; + const createdItems = await bulkAddItemsGlobalService( + sanitizeItemsIterator(results.data, ownerId), opts.ctx.executionCtx, + { + onItemCreationError: (error) => { + errors.push(error); + }, + }, ); - return resolve('items'); + + return resolve({ + status: 'success', + items: createdItems, + errorsCount: errors.length, + errors, + }); } catch (error) { console.error(error); return reject(new Error(`Failed to add items: ${error.message}`)); diff --git a/server/src/controllers/packTemplates/addPackTemplate.ts b/server/src/controllers/packTemplates/addPackTemplate.ts new file mode 100644 index 000000000..310551123 --- /dev/null +++ b/server/src/controllers/packTemplates/addPackTemplate.ts @@ -0,0 +1,38 @@ +import { addPackTemplateService } from '../../services/packTemplate/packTemplate.service'; +import * as validator from '@packrat/validations'; +import { publicProcedure } from '../../trpc'; + +export function importPackTemplatesRoute() { + return publicProcedure + .input(validator.addPackTemplates) + .mutation(async (opts) => { + const array = opts.input; + const packTemplates = []; + for (let idx = 0; idx < array.length; idx++) { + const input = array[idx]; + try { + const packTemplate = await addPackTemplateService( + input, + opts.ctx.executionCtx, + ); + packTemplates.push(packTemplate); + } catch (error) { + console.log(error); + throw error; + } + } + return packTemplates; + }); +} + +export function addPackTemplateRoute() { + return publicProcedure + .input(validator.addPackTemplate) + .mutation(async (opts) => { + const packTemplate = await addPackTemplateService( + opts.input, + opts.ctx.executionCtx, + ); + return packTemplate; + }); +} diff --git a/server/src/controllers/packTemplates/getPackTemplate.ts b/server/src/controllers/packTemplates/getPackTemplate.ts index 41713c24a..03942e002 100644 --- a/server/src/controllers/packTemplates/getPackTemplate.ts +++ b/server/src/controllers/packTemplates/getPackTemplate.ts @@ -6,6 +6,7 @@ export function getPackTemplateRoute() { return protectedProcedure .input(validator.getPackTemplate) .query(async ({ input }) => { - return await getPackTemplateService(input.id); + const param = input.id ? { id: input.id } : { name: input.name }; + return await getPackTemplateService(param); }); } diff --git a/server/src/controllers/packTemplates/index.ts b/server/src/controllers/packTemplates/index.ts index e83211275..31d9ba1a1 100644 --- a/server/src/controllers/packTemplates/index.ts +++ b/server/src/controllers/packTemplates/index.ts @@ -1,3 +1,4 @@ +export * from './addPackTemplate'; export * from './getPackTemplates'; -export * from './getPackTemplate' +export * from './getPackTemplate'; export * from './createPackFromTemplate'; diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 7ce1a31b8..41b40f2fb 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -182,10 +182,10 @@ export const packTemplate = sqliteTable('pack_template', { }); export const packTemplateRelations = relations(packTemplate, ({ many }) => ({ - itemPackTemplates: many(itemPackTemplates), + itemPackTemplates: many(itemPackTemplate), })); -export const itemPackTemplates = sqliteTable( +export const itemPackTemplate = sqliteTable( 'item_pack_templates', { itemId: text('item_id').references(() => item.id, { onDelete: 'cascade' }), @@ -205,14 +205,14 @@ export const itemPackTemplates = sqliteTable( ); export const itemPackTemplatesRelations = relations( - itemPackTemplates, + itemPackTemplate, ({ one }) => ({ packTemplate: one(packTemplate, { - fields: [itemPackTemplates.packTemplateId], + fields: [itemPackTemplate.packTemplateId], references: [packTemplate.id], }), item: one(item, { - fields: [itemPackTemplates.itemId], + fields: [itemPackTemplate.itemId], references: [item.id], }), }), @@ -369,6 +369,7 @@ export const itemRelations = relations(item, ({ one, many }) => ({ images: many(itemImage), itemOwners: many(itemOwners), itemPacks: many(itemPacks), + itemPackTemplates: many(itemPackTemplate), })); export const template = sqliteTable('template', { @@ -611,8 +612,12 @@ export const insertTemplateSchema = createInsertSchema(template); export const selectTemplateSchema = createSelectSchema(template); export type PackTemplate = InferSelectModel; +export type InsertPackTemplate = InferInsertModel; export const selectPackTemplateSchema = createSelectSchema(packTemplate); +export type ItemPackTemplate = InferSelectModel; +export type InsertItemPackTemplate = InferInsertModel; + export type Pack = InferSelectModel; export type InsertPack = InferInsertModel; export const insertPackSchema = createInsertSchema(pack); diff --git a/server/src/drizzle/methods/Item.ts b/server/src/drizzle/methods/Item.ts index f2e5bbc18..7dee75417 100644 --- a/server/src/drizzle/methods/Item.ts +++ b/server/src/drizzle/methods/Item.ts @@ -5,6 +5,7 @@ import { ITEM_TABLE_NAME, itemPacks, item as ItemTable, + itemImage as itemImageTable, } from '../../db/schema'; import { scorePackService } from '../../services/pack/scorePackService'; import { ItemPacks } from './ItemPacks'; @@ -25,6 +26,22 @@ export class Item { } } + async insertImage(itemId: string, url: string) { + try { + const itemImage = await DbClient.instance + .insert(itemImageTable) + .values({ + itemId, + url, + }) + .run(); + + return itemImage; + } catch (error) { + throw new Error(`Failed to update item: ${error.message}`); + } + } + async createPackItem(data: InsertItem, packId: string, quantity: number) { // TODO wrap in transaction const item = await this.create(data); diff --git a/server/src/drizzle/methods/ItemPackTemplate.ts b/server/src/drizzle/methods/ItemPackTemplate.ts new file mode 100644 index 000000000..5dfef9e9f --- /dev/null +++ b/server/src/drizzle/methods/ItemPackTemplate.ts @@ -0,0 +1,39 @@ +import { DbClient } from '../../db/client'; +import { eq, and } from 'drizzle-orm'; +import { + type InsertItemPackTemplate, + itemPackTemplate as ItemPackTemplateTable, +} from '../../db/schema'; + +export class ItemPackTemplate { + async create(data: InsertItemPackTemplate) { + try { + const item = await DbClient.instance + .insert(ItemPackTemplateTable) + .values(data) + .returning() + .get(); + + return item; + } catch (error) { + throw new Error(`Failed to create item: ${error.message}`); + } + } + + async findByItemIdAndPackTemplateId({ + itemId, + packTemplateId, + }: { + itemId: string; + packTemplateId: string; + }) { + const itemFilter = eq(ItemPackTemplateTable.itemId, itemId); + const packFilter = eq(ItemPackTemplateTable.packTemplateId, packTemplateId); + + const filter = and(itemFilter, packFilter); + + return await DbClient.instance.query.itemPackTemplate.findFirst({ + where: filter, + }); + } +} diff --git a/server/src/drizzle/methods/PackTemplate.ts b/server/src/drizzle/methods/PackTemplate.ts index 1413bd8b8..8a7679469 100644 --- a/server/src/drizzle/methods/PackTemplate.ts +++ b/server/src/drizzle/methods/PackTemplate.ts @@ -1,12 +1,17 @@ import { asc, count, desc, eq, like, sql } from 'drizzle-orm'; import { DbClient } from '../../db/client'; import { convertWeight, type WeightUnit } from 'src/utils/convertWeight'; -import { packTemplate, item, itemPackTemplates } from 'src/db/schema'; +import { + packTemplate, + item, + itemPackTemplate, + InsertPackTemplate, +} from 'src/db/schema'; import { PaginationParams } from 'src/helpers/pagination'; -export type Filter = { +export interface Filter { searchQuery?: string; -}; +} export type ORDER_BY = 'Lightest' | 'Heaviest'; @@ -27,17 +32,17 @@ export class PackTemplate { type: packTemplate.type, description: packTemplate.description, total_weight: - sql`SUM(${item.weight} * ${itemPackTemplates.quantity})`.as( + sql`SUM(${item.weight} * ${itemPackTemplate.quantity})`.as( 'total_weight', ), - quantity: sql`SUM(${itemPackTemplates.quantity})`, + quantity: sql`SUM(${itemPackTemplate.quantity})`, }) .from(packTemplate) .leftJoin( - itemPackTemplates, - eq(packTemplate.id, itemPackTemplates.packTemplateId), + itemPackTemplate, + eq(packTemplate.id, itemPackTemplate.packTemplateId), ) - .leftJoin(item, eq(itemPackTemplates.itemId, item.id)) + .leftJoin(item, eq(itemPackTemplate.itemId, item.id)) .groupBy(packTemplate.id); if (filter?.searchQuery) { @@ -68,15 +73,55 @@ export class PackTemplate { }; } - async findPackTemplate(id: string) { + /** + * Creates a pack template. + * @param data The date used to create a pack template. + */ + async create(data: InsertPackTemplate) { + try { + const createdPackTemplate = await DbClient.instance + .insert(packTemplate) + .values(data) + .returning() + .get(); + return createdPackTemplate; + } catch (error) { + throw new Error(`Failed to create a pack: ${error.message}`); + } + } + + /** + * Finds a pack template by its ID or name. + * @param params The parameters to search for a pack template. + * @param params.id The ID of the pack template to search for. + * @param params.name The name of the pack template to search for. + * @returns + */ + async findPackTemplate( + params: { id: string; name?: undefined } | { name: string; id?: undefined }, + ) { + const { id, name } = params; + let filter; + if (id) { + filter = eq(packTemplate.id, id); + } else if (name) { + filter = eq(packTemplate.name, name); + } else { + throw new Error('Either id or name must be provided'); + } const packTemplateResult = await DbClient.instance.query.packTemplate.findFirst({ - where: eq(packTemplate.id, id), + where: filter, with: { itemPackTemplates: { with: { item: { with: { category: {} } } } }, }, }); + + if (!packTemplateResult) { + return packTemplateResult; + } + const items = packTemplateResult.itemPackTemplates.map( (itemPackTemplate) => ({ ...itemPackTemplate.item, @@ -91,13 +136,14 @@ export class PackTemplate { ); return sum + weightInGrams * item.quantity; }, 0); + const quantity = items.reduce((sum, item) => sum + item.quantity, 0); delete packTemplateResult.itemPackTemplates; return { ...packTemplateResult, - items, + itemsPackTemplate: items, total_weight, quantity, }; diff --git a/server/src/routes/trpcRouter.ts b/server/src/routes/trpcRouter.ts index 97d36e693..972100773 100644 --- a/server/src/routes/trpcRouter.ts +++ b/server/src/routes/trpcRouter.ts @@ -106,6 +106,8 @@ import { getPackTemplatesRoute, getPackTemplateRoute, createPackFromTemplateRoute, + addPackTemplateRoute, + importPackTemplatesRoute, } from '../controllers/packTemplates'; import { router as trpcRouter } from '../trpc'; @@ -151,6 +153,8 @@ export const appRouter = trpcRouter({ getPackTemplates: getPackTemplatesRoute(), getPackTemplate: getPackTemplateRoute(), createPackFromTemplate: createPackFromTemplateRoute(), + addPackTemplate: addPackTemplateRoute(), + addPackTemplates: importPackTemplatesRoute(), getTemplates: getTemplatesRoute(), getTemplateById: getTemplateByIdRoute(), addTemplate: addTemplateRoute(), diff --git a/server/src/services/item/addItemGlobalService.ts b/server/src/services/item/addItemGlobalService.ts index 01b8af03c..b9371af67 100644 --- a/server/src/services/item/addItemGlobalService.ts +++ b/server/src/services/item/addItemGlobalService.ts @@ -1,4 +1,5 @@ import { type ExecutionContext } from 'hono'; +import * as validator from '@packrat/validations'; import { Item, ITEM_TABLE_NAME, @@ -9,195 +10,76 @@ import { ItemCategory } from '../../drizzle/methods/itemcategory'; import { ItemCategory as categories } from '../../utils/itemCategory'; import { VectorClient } from '../../vector/client'; import { convertWeight, SMALLEST_WEIGHT_UNIT } from 'src/utils/convertWeight'; -import { DbClient } from 'src/db/client'; -import { itemImage as itemImageTable } from '../../db/schema'; import { summarizeItem } from 'src/utils/item'; -// import { prisma } from '../../prisma'; -interface AddItemGlobalServiceParams { - /** The name of the item. */ - name: string; - /** The description of the item. */ - description?: string; - /** The weight of the item. */ - weight: number; - /** The unit of measurement for the item. */ - unit: string; - /** The category of the item. */ - type: (typeof categories)[number]; - /** The ID of the owner of the item. */ - ownerId: string; - /** The URLs of the images of the item. */ - image_urls?: string; - /** The SKU of the item. */ - sku?: string; - /** The URL of the product of the item. */ - productUrl?: string; - /** The product details of the item. */ - productDetails?: Record; - /** The seller of the item. */ - seller?: string; -} +type ItemWithCategory = Item & { category?: InsertItemCategory }; /** * Adds an item to the global service. + * @param {Object} item - The data for the new item. + * @param {ExecutionContext} executionCtx - Then vector database execution context * @return {Promise} The newly created item. */ export const addItemGlobalService = async ( - { - name, - weight, - unit, - type, - ownerId, - image_urls, - sku, - productUrl, - description, - productDetails, - seller, - }: AddItemGlobalServiceParams, - executionCtx: ExecutionContext, -) => { + item: validator.AddItemGlobalType, + executionCtx?: ExecutionContext, +): Promise => { let category: InsertItemCategory | null; - if (!categories.includes(type)) { - throw new Error(`Category must be one of: ${categories.join(', ')}`); + if (!categories.includes(item.type)) { + const error = new Error( + `[${item.sku}#${item.name}]: Category must be one of: ${categories.join(', ')}`, + ); + throw error; } + const itemClass = new ItemClass(); const itemCategoryClass = new ItemCategory(); - category = (await itemCategoryClass.findItemCategory({ name: type })) || null; + category = + (await itemCategoryClass.findItemCategory({ name: item.type })) || null; if (!category) { - category = await itemCategoryClass.create({ name: type }); + category = await itemCategoryClass.create({ name: item.type }); } - const newItem = await itemClass.create({ - name, - weight: convertWeight(Number(weight), unit as any, SMALLEST_WEIGHT_UNIT), - unit, + + const newItem = (await itemClass.create({ + name: item.name, + weight: convertWeight( + Number(item.weight), + item.unit as any, + SMALLEST_WEIGHT_UNIT, + ), + unit: item.unit, categoryId: category.id, global: true, - ownerId, - sku, - productUrl, - description, - productDetails, - seller, - }); + ownerId: item.ownerId, + sku: item.sku, + productUrl: item.productUrl, + description: item.description, + productDetails: item.productDetails, + seller: item.seller, + })) as ItemWithCategory; - if (image_urls) { - const urls = image_urls.split(','); + if (item.image_urls) { + const urls = item.image_urls.split(','); for (const url of urls) { - const newItemImage = { - itemId: newItem.id, - url, - }; - await DbClient.instance.insert(itemImageTable).values(newItemImage).run(); + await itemClass.insertImage(newItem.id, url); } } - executionCtx.waitUntil( - VectorClient.instance.syncRecord({ - id: newItem.id, - content: `product_name: ${name}, category: ${type}, description: ${description}, productDetails: ${JSON.stringify( - productDetails, - )}`, - namespace: ITEM_TABLE_NAME, - metadata: { - isPublic: newItem.global, - ownerId, - }, - }), - ); - - return newItem; -}; - -/** - * Adds list of items to the global service. - * @return {Promise} The newly created item. - */ -export const addItemGlobalServiceBatch = async ( - rawItems: T[], - transform: (rawItem: T) => AddItemGlobalServiceParams, - continueOnError = false, - executionCtx: ExecutionContext, -) => { - const errors = []; - - const createdItemsInOrder: Array<[itemIndex: number, item: Item]> = []; - for (let idx = 0; idx < rawItems.length; idx++) { - const item = transform(rawItems[idx]); - let category: InsertItemCategory | null; - if (!categories.includes(item.type)) { - const error = new Error( - `[${item.sku}#${item.name}]: Category must be one of: ${categories.join(', ')}`, - ); - if (continueOnError) { - errors.push(error); - continue; - } else { - throw error; - } - } - - const itemClass = new ItemClass(); - const itemCategoryClass = new ItemCategory(); - category = - (await itemCategoryClass.findItemCategory({ name: item.type })) || null; - if (!category) { - category = await itemCategoryClass.create({ name: item.type }); - } - - const newItem = await itemClass.create({ - name: item.name, - weight: convertWeight( - Number(item.weight), - item.unit as any, - SMALLEST_WEIGHT_UNIT, - ), - unit: item.unit, - categoryId: category.id, - global: true, - ownerId: item.ownerId, - sku: item.sku, - productUrl: item.productUrl, - description: item.description, - productDetails: item.productDetails, - seller: item.seller, - }); - - createdItemsInOrder.push([idx, newItem]); + newItem.category = category; - if (item.image_urls) { - const urls = item.image_urls.split(','); - - const newItemImages = []; - for (const url of urls) { - newItemImages.push({ - itemId: newItem.id, - url, - }); - } - - await DbClient.instance - .insert(itemImageTable) - .values(newItemImages) - .run(); - } + if (executionCtx) { + executionCtx.waitUntil( + VectorClient.instance.syncRecord({ + id: newItem.id, + content: summarizeItem(newItem), + namespace: ITEM_TABLE_NAME, + metadata: { + isPublic: newItem.global, + ownerId: newItem.ownerId, + }, + }), + ); } - // Format Item for vector indexes - const vectorData = []; - for (const [idx, item] of createdItemsInOrder) { - item.category = { name: transform(rawItems[idx]).type }; - vectorData.push({ - id: item.id, - content: summarizeItem(item), - namespace: ITEM_TABLE_NAME, - metadata: { - isPublic: item.global, - ownerId: item.ownerId, - }, - }); - } - executionCtx.waitUntil(VectorClient.instance.syncRecords(vectorData)); + return newItem; }; diff --git a/server/src/services/item/addItemService.ts b/server/src/services/item/addItemService.ts index 6488095e9..59f0652d2 100644 --- a/server/src/services/item/addItemService.ts +++ b/server/src/services/item/addItemService.ts @@ -1,12 +1,11 @@ -// import { prisma } from '../../prisma'; -import { Item } from '../../drizzle/methods/Item'; +import { type ExecutionContext } from 'hono'; +import { Item as ItemClass } from '../../drizzle/methods/Item'; import { ItemCategory } from '../../drizzle/methods/itemcategory'; import { ItemOwners } from '../../drizzle/methods/ItemOwners'; import { ItemCategory as categories } from '../../utils/itemCategory'; import { type InsertItemCategory } from '../../db/schema'; import { VectorClient } from '../../vector/client'; import { convertWeight, SMALLEST_WEIGHT_UNIT } from 'src/utils/convertWeight'; -import { type ExecutionContext } from 'hono'; /** * Generates a new item and adds it to a pack based on the given parameters. @@ -34,7 +33,7 @@ export const addItemService = async ( throw new Error(`Category must be one of: ${categories.join(', ')}`); } const itemCategoryClass = new ItemCategory(); - const itemClass = new Item(); + const itemClass = new ItemClass(); const itemOwnersClass = new ItemOwners(); category = (await itemCategoryClass.findItemCategory({ name: type })) || null; if (!category) { diff --git a/server/src/services/item/bulkAddGlobalItemService.ts b/server/src/services/item/bulkAddGlobalItemService.ts index d60ed3f40..c0aa1bb49 100644 --- a/server/src/services/item/bulkAddGlobalItemService.ts +++ b/server/src/services/item/bulkAddGlobalItemService.ts @@ -1,111 +1,60 @@ import { type ExecutionContext } from 'hono'; -import { type InsertItemCategory } from '../../db/schema'; -import { ItemCategory } from '../../drizzle/methods/itemcategory'; -import { DbClient } from 'src/db/client'; -import { - item as ItemTable, - itemImage as itemImageTable, -} from '../../db/schema'; -import { convertWeight, SMALLEST_WEIGHT_UNIT } from 'src/utils/convertWeight'; -import { eq } from 'drizzle-orm'; +import * as validator from '@packrat/validations'; +import { ITEM_TABLE_NAME } from '../../db/schema'; import { VectorClient } from 'src/vector/client'; - +import { addItemGlobalService } from './addItemGlobalService'; +import { summarizeItem } from 'src/utils/item'; + +/** + * Adds a list of items to the global inventory and indexes them in the vector database. + * @param {Object} items - The list of items to add. + * @param {string} executionCtx - The execution context. + * @param {ExecutionContext} callbacks.onItemCreated - A callback function to be called when an item is created. + * @param {Function} callbacks.onItemCreationError - A callback function to be called when an item creation fails. + * @return {Promise} A promise that resolves to an array of the created items. + */ export const bulkAddItemsGlobalService = async ( - items: Array<{ - name: string; - weight: number; - unit: string; - type: 'Food' | 'Water' | 'Essentials'; - ownerId: string; - image_urls?: string; - sku?: string; - productUrl?: string; - description?: string; - productDetails?: { - [key: string]: string | number | boolean | null; - }; - seller?: string; - }>, + items: Iterable, executionCtx: ExecutionContext, -) => { - const categories = ['Food', 'Water', 'Essentials']; - - const itemCategoryClass = new ItemCategory(); - const insertedItems = []; - - for (const itemData of items) { - const { name, weight, unit, type, ownerId, image_urls } = itemData; - if (!categories.includes(type)) { - throw new Error(`Category must be one of: ${categories.join(', ')}`); - } - - let category: InsertItemCategory | null; - category = - (await itemCategoryClass.findItemCategory({ name: type })) || null; - if (!category) { - category = await itemCategoryClass.create({ name: type }); - } - - // Check if item with the same name already exists - const existingItem = await DbClient.instance - .select() - .from(ItemTable) - .where(eq(ItemTable.name, name)) - .get(); - - if (existingItem) { - continue; - } - - const newItem = { - name, - weight: convertWeight(Number(weight), unit as any, SMALLEST_WEIGHT_UNIT), - unit, - categoryId: category.id, - global: true, - ownerId, - sku, - productUrl, - description, - productDetails, - seller, - }; - - const item = await DbClient.instance - .insert(ItemTable) - .values(newItem) - .returning() - .get(); + callbacks?: { + onItemCreated?: ( + item: Awaited>, + bulkIndex: number, + ) => void; + onItemCreationError?: (error: Error, bulkIndex: number) => void; + }, +): Promise>>> => { + const { onItemCreated, onItemCreationError } = callbacks; + const createdItems: Array>> = + []; + const vectorData = []; + + let idx = -1; + for (const item of items) { + idx += 1; + try { + const createdItem = await addItemGlobalService(item); + if (onItemCreated) { + onItemCreated(createdItem, idx); + } + createdItems.push(createdItem); - executionCtx.waitUntil( - VectorClient.instance.syncRecord({ - id: item.id, - content: name, - namespace: 'items', + vectorData.push({ + id: createdItem.id, + content: summarizeItem(createdItem), + namespace: ITEM_TABLE_NAME, metadata: { - isPublic: item.global, - ownerId, + isPublic: createdItem.global, + ownerId: createdItem.ownerId, }, - }), - ); - - if (image_urls) { - const urls = image_urls.split(','); - for (const url of urls) { - const newItemImage = { - itemId: item.id, - url, - }; - await DbClient.instance - .insert(itemImageTable) - .values(newItemImage) - .run(); + }); + } catch (error) { + if (onItemCreationError) { + onItemCreationError(error as Error, idx); } - console.log('Added image urls for item:', item.id); } - - insertedItems.push(item); } - return insertedItems; + executionCtx.waitUntil(VectorClient.instance.syncRecords(vectorData)); + return createdItems; }; diff --git a/server/src/services/itemPackTemplate/addItemPackTemplate.ts b/server/src/services/itemPackTemplate/addItemPackTemplate.ts new file mode 100644 index 000000000..fefd556c4 --- /dev/null +++ b/server/src/services/itemPackTemplate/addItemPackTemplate.ts @@ -0,0 +1,28 @@ +import { ItemPackTemplate } from '../../drizzle/methods/ItemPackTemplate'; + +/** + * Adds a item to the pack template service. + * @param {string} packTemplateId - The ID of the pack template. + * @param {string} itemId - The ID reference of the item. + * @param {number} quantity - The ID of the owner. + * @return {Promise} - A promise that resolves to the added item. + */ +export const addItemPackTemplate = async (params: { + packTemplateId: string; + itemId: string; + quantity: number; +}) => { + const { packTemplateId, itemId, quantity } = params; + const itemPackTemplate = new ItemPackTemplate(); + const item = await itemPackTemplate.findByItemIdAndPackTemplateId({ + packTemplateId, + itemId, + }); + + if (item) { + throw new Error( + 'A template item with the same item and pack references already exists', + ); + } + await itemPackTemplate.create({ itemId, packTemplateId, quantity }); +}; diff --git a/server/src/services/itemPackTemplate/itemPackTemplate.service.ts b/server/src/services/itemPackTemplate/itemPackTemplate.service.ts new file mode 100644 index 000000000..0c6fa2785 --- /dev/null +++ b/server/src/services/itemPackTemplate/itemPackTemplate.service.ts @@ -0,0 +1 @@ +export * from './addItemPackTemplate'; diff --git a/server/src/services/packTemplate/addPackTemplateService.ts b/server/src/services/packTemplate/addPackTemplateService.ts new file mode 100644 index 000000000..637a8083c --- /dev/null +++ b/server/src/services/packTemplate/addPackTemplateService.ts @@ -0,0 +1,65 @@ +import type { ExecutionContext } from 'hono'; +import * as validator from '@packrat/validations'; + +import { PackTemplate as PackTemplateClass } from '../../drizzle/methods/PackTemplate'; +import * as ItemPackTemplateService from '../itemPackTemplate/itemPackTemplate.service'; +import * as ItemService from '../item/item.service'; +import { ITEM_TABLE_NAME, PackTemplate } from 'src/db/schema'; +import { summarizeItem } from 'src/utils/item'; +import { VectorClient } from 'src/vector/client'; +import { i } from 'vitest/dist/reporters-QGe8gs4b.js'; + +/** + * Adds a new pack template service. + * @param {Object} packTemplateData - The data for the new pack template. + * @param {string} packTemplateData.name - The name of the pack. + * @param {string} packTemplateData.description - The description of the pack. + * @param {string} packTemplateData.type - Whether the pack is public or not. + * @return {Object} An object containing the created pack. + */ +export const addPackTemplateService = async ( + packTemplateData: validator.AddPackTemplateType, + executionCtx: ExecutionContext, +): Promise => { + const { name, description, type } = packTemplateData; + console.log({ packTemplateData }); + const packTemplateClass = new PackTemplateClass(); + let existingPack: PackTemplate | null = + await packTemplateClass.findPackTemplate({ name }); + + if (!existingPack) { + existingPack = await packTemplateClass.create({ + name, + description, + type, + }); + } else { + console.log( + 'Pack template already exists. Skipping creation and proceeding with update of items', + ); + } + + function* itemIterator() { + for (const itemPackTemplate of packTemplateData.itemPackTemplates) { + yield { + ...itemPackTemplate.item, + ownerId: packTemplateData.itemsOwnerId, + }; + } + } + + await ItemService.bulkAddItemsGlobalService(itemIterator(), executionCtx, { + onItemCreationError: (error, idx) => { + console.error(`Error creating item at ${idx}:`, error); + }, + onItemCreated: async (createdItem, idx) => { + await ItemPackTemplateService.addItemPackTemplate({ + itemId: createdItem.id, + quantity: packTemplateData.itemPackTemplates[idx].quantity, + packTemplateId: existingPack.id, + }); + }, + }); + + return existingPack; +}; diff --git a/server/src/services/packTemplate/createPackFromTemplateService.ts b/server/src/services/packTemplate/createPackFromTemplateService.ts index a7828c5dc..df93a0312 100644 --- a/server/src/services/packTemplate/createPackFromTemplateService.ts +++ b/server/src/services/packTemplate/createPackFromTemplateService.ts @@ -11,8 +11,9 @@ export const createPackFromTemplateService = async ( ) => { const packTemplateRepository = new PackTemplateRepository(); - const packTemplate = - await packTemplateRepository.findPackTemplate(packTemplateId); + const packTemplate = await packTemplateRepository.findPackTemplate({ + id: packTemplateId, + }); // TODO - creating pack and adding items to it should ideally be transactional const createdPack = await addPackService( diff --git a/server/src/services/packTemplate/getPackTemplateService.ts b/server/src/services/packTemplate/getPackTemplateService.ts index e03ea3a9e..cb86e2846 100644 --- a/server/src/services/packTemplate/getPackTemplateService.ts +++ b/server/src/services/packTemplate/getPackTemplateService.ts @@ -1,5 +1,7 @@ import { PackTemplate } from 'src/drizzle/methods/PackTemplate'; -export async function getPackTemplateService(packTemplateId: string) { - return await new PackTemplate().findPackTemplate(packTemplateId); +export async function getPackTemplateService( + params: { id: string; name?: undefined } | { name: string; id?: undefined }, +) { + return await new PackTemplate().findPackTemplate(params); } diff --git a/server/src/services/packTemplate/packTemplate.service.ts b/server/src/services/packTemplate/packTemplate.service.ts index 40fd201b4..191893919 100644 --- a/server/src/services/packTemplate/packTemplate.service.ts +++ b/server/src/services/packTemplate/packTemplate.service.ts @@ -1,3 +1,4 @@ +export * from './addPackTemplateService'; export * from './getPackTemplatesService'; export * from './getPackTemplateService'; export * from './createPackFromTemplateService'; diff --git a/server/src/tests/routes/packTemplate.spec.ts b/server/src/tests/routes/packTemplate.spec.ts index 59d02867a..d914ee981 100644 --- a/server/src/tests/routes/packTemplate.spec.ts +++ b/server/src/tests/routes/packTemplate.spec.ts @@ -7,7 +7,7 @@ import type { trpcCaller } from '../testHelpers'; import { type PackTemplate, type Item, - itemPackTemplates as itemPackTemplatesTable, + itemPackTemplate as itemPackTemplatesTable, packTemplate as packTemplateTable, } from 'src/db/schema'; import { DbClient } from '../../db/client'; @@ -117,6 +117,23 @@ describe('Pack template routes', () => { }); }); + describe('addPackTemplate', () => { + it('should add pack template', async () => { + const packTemplate = await caller.addPackTemplate({ + name: 'test', + description: 'pack template description', + type: 'pack', + itemPackTemplates: packTemplateItems.map((item) => ({ + itemId: item.id, + quantity: 1, + })), + }); + expect(packTemplate).toMatchObject([ + { ...packTemplate, items: packTemplateItems }, + ]); + }); + }); + describe('getPackTemplate', () => { it('should get a pack template', async () => { const packTemplateResult = await caller.getPackTemplate({