diff --git a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx index 16991a6..e9ed777 100644 --- a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx +++ b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx @@ -253,10 +253,6 @@ export default function PricesOverview() { return; } - Alert.alert("Error", "Please enter a valid size"); - return; - } - setPendingChanges(prev => ({ ...prev, [editDialog.sku.id]: { @@ -368,6 +364,7 @@ export default function PricesOverview() { /> )} + setEditDialog({ ...editDialog, open: false, tempFlashPrice: "", tempMarketPrice: "", tempPrice: "" })}> {editDialog.productName} {editDialog.sku?.displayName || editDialog.sku?.name || ''} diff --git a/apps/admin-ui/app/(drawer)/products/add.tsx b/apps/admin-ui/app/(drawer)/products/add.tsx index 2e6e461..d5e07fa 100644 --- a/apps/admin-ui/app/(drawer)/products/add.tsx +++ b/apps/admin-ui/app/(drawer)/products/add.tsx @@ -48,6 +48,9 @@ export default function AddProduct() { featureName: attr.featureName, featureValue: attr.featureValue, })), + comboItems: (variant.comboItems || []).map((ci: any) => ({ + skuId: typeof ci.skuId === 'string' ? parseInt(ci.skuId) : ci.skuId, + })), } }) @@ -56,6 +59,7 @@ export default function AddProduct() { shortDescription: values.shortDescription || undefined, longDescription: values.longDescription || undefined, storeId: values.storeId, + productType: values.productType, incrementStep: 1, skus, }) @@ -72,14 +76,17 @@ export default function AddProduct() { shortDescription: '', longDescription: '', storeId: 1, + productType: 'item' as const, variants: [ { + id: undefined as number | undefined, name: '', price: '', marketPrice: '', isFlashAvailable: false, flashPrice: '', attributes: [{ featureName: 'quantity', featureValue: '' }], + comboItems: [] as { skuId: number | string }[], }, ], } diff --git a/apps/admin-ui/app/(drawer)/products/edit.tsx b/apps/admin-ui/app/(drawer)/products/edit.tsx index 9cf0a7d..98d1474 100644 --- a/apps/admin-ui/app/(drawer)/products/edit.tsx +++ b/apps/admin-ui/app/(drawer)/products/edit.tsx @@ -41,6 +41,7 @@ export default function EditProduct() { shortDescription: productData.shortDescription || '', longDescription: productData.longDescription || '', storeId: productData.storeId || 1, + productType: (productData.productType as 'item' | 'combo') || 'item', variants: (productData.skus || []).map((sku) => ({ id: sku.id, name: sku.name || '', @@ -52,6 +53,9 @@ export default function EditProduct() { featureName: f.featureName, featureValue: f.featureValue, })), + comboItems: (sku.comboItems || []).map((ci: any) => ({ + skuId: ci.skuId.toString(), + })), })), } }, [productData]) @@ -118,6 +122,9 @@ export default function EditProduct() { featureName: attr.featureName, featureValue: attr.featureValue, })), + comboItems: (variant.comboItems || []).map((ci: any) => ({ + skuId: typeof ci.skuId === 'string' ? parseInt(ci.skuId) : ci.skuId, + })), } }) @@ -127,6 +134,7 @@ export default function EditProduct() { shortDescription: values.shortDescription || undefined, longDescription: values.longDescription || undefined, storeId: values.storeId, + productType: values.productType, incrementStep: 1, skus, deletedImageKeys, diff --git a/apps/admin-ui/src/components/ProductForm.tsx b/apps/admin-ui/src/components/ProductForm.tsx index b87a346..ba85a0b 100644 --- a/apps/admin-ui/src/components/ProductForm.tsx +++ b/apps/admin-ui/src/components/ProductForm.tsx @@ -18,6 +18,7 @@ interface Variant { isFlashAvailable: boolean flashPrice: string attributes: Attribute[] + comboItems: { skuId: number | string }[] } interface ProductFormData { @@ -25,6 +26,7 @@ interface ProductFormData { shortDescription: string longDescription: string storeId: number + productType: 'item' | 'combo' variants: Variant[] } @@ -44,12 +46,14 @@ interface ProductFormProps { const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' }) const defaultVariant = (): Variant => ({ + id: undefined, name: '', price: '', marketPrice: '', isFlashAvailable: false, flashPrice: '', attributes: [defaultAttribute()], + comboItems: [], }) const ProductForm = forwardRef(({ @@ -76,6 +80,12 @@ const ProductForm = forwardRef(({ value: store.id, })) || [] + const { data: skusData } = trpc.common.product.getAllSkusSummary.useQuery({}) + const skuOptions = (skusData?.skus || []).map((sku) => ({ + label: sku.label, + value: sku.id.toString(), + })) + return ( (({ style={{ marginBottom: 16 }} /> + setFieldValue('productType', value)} + placeholder="Select product type" + style={{ marginBottom: 16 }} + /> + {({ push, remove }) => ( @@ -271,6 +294,46 @@ const ProductForm = forwardRef(({ } allowMultiple={true} /> + + {values.productType === 'combo' && ( + + + Included Items + { + const items = variant.comboItems || [] + items.push({ skuId: '' }) + setFieldValue(`variants.${vIndex}.comboItems`, items) + }} + style={tw`bg-gray-200 px-2 py-0.5 rounded flex-row items-center`} + > + + Add + + + {variant.comboItems?.map((ci, cIndex) => ( + + + setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, val)} + placeholder="Select SKU" + /> + + { + const items = variant.comboItems.filter((_, i) => i !== cIndex) + setFieldValue(`variants.${vIndex}.comboItems`, items) + }} + > + + + + ))} + + )} ))} diff --git a/apps/backend/index.ts b/apps/backend/index.ts index 3a828a5..01e61d5 100755 --- a/apps/backend/index.ts +++ b/apps/backend/index.ts @@ -5,11 +5,9 @@ import { createApp } from '@/src/app' // import signedUrlCache from '@/src/lib/signed-url-cache'; import { seed } from '@/src/lib/seed'; import '@/src/jobs/jobs-index'; -import { startAutomatedJobs } from '@/src/lib/automatedJobs'; seed() initFunc() -startAutomatedJobs() // signedUrlCache.loadFromDisk(); // Disabled for Workers compatibility diff --git a/apps/backend/src/lib/automatedJobs.ts b/apps/backend/src/lib/automatedJobs.ts deleted file mode 100644 index abdece9..0000000 --- a/apps/backend/src/lib/automatedJobs.ts +++ /dev/null @@ -1,137 +0,0 @@ -// import * as cron from 'node-cron'; -const cron:any = {} -import { toggleFlashDeliveryForItems, toggleKeyVal } from '@/src/dbService'; -import { CONST_KEYS } from '@/src/lib/const-keys' -import { computeConstants } from '@/src/lib/const-store' - - -const MUTTON_ITEMS = [ - 12, //Lamb mutton - 14, // Mutton Boti - 35, //Mutton Kheema - 84, //Mutton Brain - 4, //Mutton - 86, //Mutton Chops - 87, //Mutton Soup bones - 85 //Mutton paya -]; - - - -export const startAutomatedJobs = () => { - // Job to disable flash delivery for mutton at 12 PM daily - cron.schedule('0 12 * * *', async () => { - try { - console.log('Disabling flash delivery for products at 12 PM'); - await toggleFlashDeliveryForItems(false, MUTTON_ITEMS); - console.log('Flash delivery disabled successfully'); - } catch (error) { - console.error('Error disabling flash delivery:', error); - } - }); - - // Job to enable flash delivery for mutton at 6 AM daily - cron.schedule('0 6 * * *', async () => { - try { - console.log('Enabling flash delivery for products at 5 AM'); - await toggleFlashDeliveryForItems(true, MUTTON_ITEMS); - console.log('Flash delivery enabled successfully'); - } catch (error) { - console.error('Error enabling flash delivery:', error); - } - }); - - // Job to disable flash delivery feature at 9 PM daily - cron.schedule('0 21 * * *', async () => { - try { - console.log('Disabling flash delivery feature at 9 PM'); - await toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, false); - await computeConstants(); // Refresh Redis cache - console.log('Flash delivery feature disabled successfully'); - } catch (error) { - console.error('Error disabling flash delivery feature:', error); - } - }); - - // Job to enable flash delivery feature at 6 AM daily - cron.schedule('0 6 * * *', async () => { - try { - console.log('Enabling flash delivery feature at 6 AM'); - await toggleKeyVal(CONST_KEYS.isFlashDeliveryEnabled, true); - await computeConstants(); // Refresh Redis cache - console.log('Flash delivery feature enabled successfully'); - } catch (error) { - console.error('Error enabling flash delivery feature:', error); - } - }); - - console.log('Automated jobs scheduled'); -}; - -/* -// Old implementation - direct DB queries: -import { db } from '@/src/db/db_index' -import { productInfo, keyValStore } from '@/src/db/schema' -import { inArray, eq } from 'drizzle-orm'; - -// Job to disable flash delivery for mutton at 12 PM daily -cron.schedule('0 12 * * *', async () => { - try { - console.log('Disabling flash delivery for products at 12 PM'); - await db - .update(productInfo) - .set({ isFlashAvailable: false }) - .where(inArray(productInfo.id, MUTTON_ITEMS)); - console.log('Flash delivery disabled successfully'); - } catch (error) { - console.error('Error disabling flash delivery:', error); - } -}); - -// Job to enable flash delivery for mutton at 6 AM daily -cron.schedule('0 6 * * *', async () => { - try { - console.log('Enabling flash delivery for products at 5 AM'); - await db - .update(productInfo) - .set({ isFlashAvailable: true }) - .where(inArray(productInfo.id, MUTTON_ITEMS)); - console.log('Flash delivery enabled successfully'); - } catch (error) { - console.error('Error enabling flash delivery:', error); - } -}); - -// Job to disable flash delivery feature at 9 PM daily -cron.schedule('0 21 * * *', async () => { - try { - console.log('Disabling flash delivery feature at 9 PM'); - await db - .update(keyValStore) - .set({ value: false }) - .where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled)); - await computeConstants(); // Refresh Redis cache - console.log('Flash delivery feature disabled successfully'); - } catch (error) { - console.error('Error disabling flash delivery feature:', error); - } -}); - -// Job to enable flash delivery feature at 6 AM daily -cron.schedule('0 6 * * *', async () => { - try { - console.log('Enabling flash delivery feature at 6 AM'); - await db - .update(keyValStore) - .set({ value: true }) - .where(eq(keyValStore.key, CONST_KEYS.isFlashDeliveryEnabled)); - await computeConstants(); // Refresh Redis cache - console.log('Flash delivery feature enabled successfully'); - } catch (error) { - console.error('Error enabling flash delivery feature:', error); - } -}); -*/ - -// Optional: Call on import if desired, or export and call in main app -// startAutomatedJobs(); diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index 9b756cd..c4bec49 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -273,7 +273,6 @@ export { type SlotWithProductsData, type UserNegativityData, // Automated Jobs - toggleFlashDeliveryForItems, toggleKeyVal, getAllKeyValStore, // Post-order handler helpers diff --git a/apps/backend/src/stores/product-store.ts b/apps/backend/src/stores/product-store.ts index 2b500bf..47f16b1 100644 --- a/apps/backend/src/stores/product-store.ts +++ b/apps/backend/src/stores/product-store.ts @@ -6,11 +6,13 @@ import { getAllSpecialDealsForCache, getAllProductTagsForCache, getProductById as getProductByIdFromDb, + getAllProductCombosForCache, type ProductBasicData, type StoreBasicData, type DeliverySlotData, type SpecialDealData, type ProductTagData, + type ProductComboCacheData, } from '@/src/dbService' import { scaffoldAssetUrl } from '@/src/lib/s3-client' @@ -33,6 +35,15 @@ interface Product { deliverySlots: Array<{ id: number; deliveryTime: Date; freezeTime: Date; isCapacityFull: boolean }> specialDeals: Array<{ quantity: string; price: string; validTill: Date }> productTags: string[] + productType: string + comboItems: Array<{ + skuId: number + skuName: string | null + unitNotation: string + productName: string + images: string[] | null + price: string + }> } export async function initializeProducts(): Promise { @@ -213,6 +224,14 @@ export async function getAllProducts(): Promise { } const products: Product[] = [] + const allProductCombos = await getAllProductCombosForCache() + const productCombosMap = new Map() + for (const comboItem of allProductCombos) { + if (!productCombosMap.has(comboItem.comboSkuId)) + productCombosMap.set(comboItem.comboSkuId, []) + productCombosMap.get(comboItem.comboSkuId)!.push(comboItem) + } + for (const product of productsData) { const signedImages = scaffoldAssetUrl( (product.images as string[]) || [] @@ -223,6 +242,14 @@ export async function getAllProducts(): Promise { const deliverySlots = deliverySlotsMap.get(product.id) || [] const specialDeals = specialDealsMap.get(product.id) || [] const productTags = productTagsMap.get(product.productId) || [] + const comboItems = (productCombosMap.get(product.id) || []).map((ci) => ({ + skuId: ci.skuId, + skuName: ci.skuName, + unitNotation: ci.unitNotation, + productName: ci.productName, + images: ci.images ? scaffoldAssetUrl((ci.images as string[]) || []) : null, + price: ci.price, + })) products.push({ id: product.id, @@ -253,6 +280,8 @@ export async function getAllProducts(): Promise { validTill: d.validTill, })), productTags: productTags, + productType: product.productType || 'item', + comboItems: comboItems, }) } diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 0027ecc..de15fd7 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -190,6 +190,7 @@ export const productRouter = router({ longDescription: z.string().optional(), storeId: z.number().min(1, 'Store is required'), incrementStep: z.number().optional().default(1), + productType: z.enum(['item', 'combo']).optional().default('item'), skus: z.array(z.object({ name: z.string().optional().nullable(), price: z.number().positive('Price must be positive'), @@ -201,10 +202,13 @@ export const productRouter = router({ featureName: z.string().min(1, 'Attribute name is required'), featureValue: z.string().min(1, 'Value is required'), })).min(1, 'At least one feature is required'), + comboItems: z.array(z.object({ + skuId: z.number().int().positive(), + })).optional(), })).min(1, 'At least one SKU is required'), })) .mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => { - const { name, shortDescription, longDescription, storeId, incrementStep, skus } = input + const { name, shortDescription, longDescription, storeId, incrementStep, productType, skus } = input const existingProduct = await checkProductExistsByName(name.trim()) if (existingProduct) { @@ -224,6 +228,7 @@ export const productRouter = router({ featureName: f.featureName, featureValue: f.featureValue, })), + comboItems: (sku.comboItems || []).map((ci) => ({ skuId: ci.skuId })), })) const newProduct = await createProductInDb({ @@ -232,6 +237,7 @@ export const productRouter = router({ longDescription, storeId, incrementStep, + productType, skus: skuInputs, } as any) @@ -265,6 +271,7 @@ export const productRouter = router({ longDescription: z.string().optional(), storeId: z.number().min(1, 'Store is required'), incrementStep: z.number().optional().default(1), + productType: z.enum(['item', 'combo']).optional(), skus: z.array(z.object({ id: z.number().optional(), name: z.string().optional().nullable(), @@ -277,12 +284,15 @@ export const productRouter = router({ featureName: z.string().min(1, 'Attribute name is required'), featureValue: z.string().min(1, 'Value is required'), })).min(1, 'At least one feature is required'), + comboItems: z.array(z.object({ + skuId: z.number().int().positive(), + })).optional(), })).min(1, 'At least one SKU is required'), deletedImageKeys: z.array(z.string()).optional().default([]), newImageUrls: z.array(z.string()).optional().default([]), })) .mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => { - const { id, name, shortDescription, longDescription, storeId, incrementStep, skus, deletedImageKeys, newImageUrls } = input + const { id, name, shortDescription, longDescription, storeId, incrementStep, productType, skus, deletedImageKeys, newImageUrls } = input if (deletedImageKeys.length > 0) { await deleteImageUtil({ keys: deletedImageKeys }) @@ -302,6 +312,7 @@ export const productRouter = router({ featureName: f.featureName, featureValue: f.featureValue, })), + comboItems: (sku.comboItems || []).map((ci) => ({ skuId: ci.skuId })), })) const updatedProduct = await updateProductInDb(id, { @@ -310,6 +321,7 @@ export const productRouter = router({ longDescription, storeId, incrementStep, + productType, skus: skuInputs, } as any) diff --git a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql index aa127e2..ef64ebb 100644 --- a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql +++ b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql @@ -232,5 +232,18 @@ WHERE `key` = 'popularItems' -- 9. Clean up helper table. DROP TABLE `__product_to_sku`; +-- 10. Add product_type column and product_combos table for combo products. +ALTER TABLE `product_info` ADD COLUMN `product_type` text DEFAULT 'item'; + +CREATE TABLE `product_combos` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `combo_sku_id` integer NOT NULL, + `sku_id` integer NOT NULL, + FOREIGN KEY (`combo_sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action +); + +CREATE UNIQUE INDEX `unique_combo_sku_item` ON `product_combos` (`combo_sku_id`,`sku_id`); + -- PRAGMA foreign_keys=ON; PRAGMA defer_foreign_keys = off; diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index 8d0013c..fce2a2b 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -331,11 +331,13 @@ export { getAllDeliverySlotsForCache, getAllSpecialDealsForCache, getAllProductTagsForCache, + getAllProductCombosForCache, type ProductBasicData, type StoreBasicData, type DeliverySlotData, type SpecialDealData, type ProductTagData, + type ProductComboCacheData, // Product Tag Store getAllTagsForCache, getAllTagProductMappings, @@ -352,7 +354,6 @@ export { // Automated Jobs Helpers export { - toggleFlashDeliveryForItems, toggleKeyVal, getAllKeyValStore, } from './src/lib/automated-jobs' diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index ee35557..89b2800 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -4,6 +4,7 @@ import { productInfo, productSkus, skuFeatures, + productCombos, units, specialDeals, deliverySlotInfo, @@ -24,6 +25,7 @@ import { couponApplicableProducts, } from '../db/schema' import { and, desc, eq, inArray, sql } from 'drizzle-orm' +import { runBatched } from '../lib/run-batched' import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' import type { AdminProduct, @@ -82,6 +84,7 @@ const mapProduct = (product: ProductRow): AdminProduct => ({ storeId: product.storeId, incrementStep: product.incrementStep, createdAt: product.createdAt, + productType: product.productType as 'item' | 'combo', }) const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({ @@ -91,7 +94,7 @@ const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({ featureValue: feature.featureValue, }) -const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = []): AdminSku => ({ +const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = [], comboItems: any[] = []): AdminSku => ({ id: sku.id, productId: sku.productId, name: sku.name ?? null, @@ -105,6 +108,7 @@ const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = []): AdminSku => ({ flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, createdAt: sku.createdAt, features: features.map(mapSkuFeature), + comboItems: comboItems, }) const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({ @@ -128,7 +132,7 @@ const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({ export async function getAllProducts(): Promise { type ProductWithRelationsRow = ProductRow & { store: StoreRow | null - skus: Array + skus: Array } const products = await db.query.productInfo.findMany({ orderBy: productInfo.name, @@ -137,6 +141,11 @@ export async function getAllProducts(): Promise { skus: { with: { features: true, + comboItems: { + with: { + sku: { with: { product: true, features: true } }, + }, + }, }, }, }, @@ -145,7 +154,17 @@ export async function getAllProducts(): Promise { return products.map((product) => ({ ...mapProduct(product), store: product.store ? mapStore(product.store) : null, - skus: product.skus.map((sku) => mapSku(sku, sku.features)), + skus: product.skus.map((sku) => { + const comboItems = (sku.comboItems || []).map((ci: any) => ({ + skuId: ci.skuId, + skuName: ci.sku?.name ?? null, + features: (ci.sku?.features || []).map((f: any) => ({ featureName: f.featureName, featureValue: f.featureValue })), + productName: ci.sku?.product?.name ?? 'Unknown', + images: getStringArray(ci.sku?.images), + price: String(ci.sku?.price ?? '0'), + })) + return mapSku(sku, sku.features, comboItems) + }), })) } @@ -157,6 +176,11 @@ export async function getProductById(id: number): Promise + }) as Array + + const skusWithCombos = product.skus.map((sku) => { + const comboItems = (sku.comboItems || []).map((ci: any) => ({ + skuId: ci.skuId, + skuName: ci.sku?.name ?? null, + features: (ci.sku?.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), + productName: ci.sku?.product?.name ?? 'Unknown', + images: getStringArray(ci.sku?.images), + price: String(ci.sku?.price ?? '0'), + })) + return mapSku(sku, sku.features, comboItems) + }) return { ...mapProduct(product), store: product.store ? mapStore(product.store) : null, - skus: product.skus.map((sku) => mapSku(sku, sku.features)), + skus: skusWithCombos, deals: deals.map(mapSpecialDeal), tags: productTagsData.map((tag) => mapTagInfo(tag.tag)), } @@ -227,6 +263,7 @@ export async function createProduct(input: CreateProductInput): Promise 0) { + await db.insert(productCombos).values( + sku.comboItems.map((ci: any) => ({ + comboSkuId: skuRow.id, + skuId: ci.skuId, + })) + ) + } } const createdSkus = await db.query.productSkus.findMany({ @@ -283,6 +329,7 @@ export async function updateProduct(id: number, input: any): Promise 0) { + await db.insert(productCombos).values( + sku.comboItems.map((ci: any) => ({ + comboSkuId: sku.id, + skuId: ci.skuId, + })) + ) + } } else { // Insert new SKU const [newSku] = await db.insert(productSkus).values({ @@ -370,29 +427,6 @@ export async function updateProduct(id: number, input: any): Promise { const slot = await db.query.deliverySlotInfo.findFirst({ where: eq(deliverySlotInfo.id, parseInt(slotId)), @@ -815,35 +849,39 @@ export async function updateProductPrices(updates: Array<{ } const productIds = updates.map((update) => update.productId) - const existingSkus = await db.query.productSkus.findMany({ - where: inArray(productSkus.id, productIds), - columns: { id: true }, - }) as Array<{ id: number }> - const existingIds = new Set(existingSkus.map((sku: { id: number }) => sku.id)) + // Validate all SKU IDs exist (in chunks to avoid large IN clauses) + const existingSkuChunks = await runBatched(db, productIds, 10, async (tx, chunk) => { + return tx.query.productSkus.findMany({ + where: inArray(productSkus.id, chunk), + columns: { id: true }, + }) + }) + const existingIds = new Set(existingSkuChunks.flat().map((sku: { id: number }) => sku.id)) const invalidIds = productIds.filter((id) => !existingIds.has(id)) if (invalidIds.length > 0) { return { updatedCount: 0, invalidIds } } - const updatePromises = updates.map((update) => { - const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update - const updateData: any = {} + // Apply updates in chunks inside a single transaction + await runBatched(db, updates, 10, async (tx, chunk) => { + for (const update of chunk) { + const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update + const updateData: any = {} - if (price !== undefined) updateData.price = price.toString() - if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString() - if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString() - if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable + if (price !== undefined) updateData.price = price.toString() + if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString() + if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString() + if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable - return db - .update(productSkus) - .set(updateData) - .where(eq(productSkus.id, productId)) + await tx + .update(productSkus) + .set(updateData) + .where(eq(productSkus.id, productId)) + } }) - await Promise.all(updatePromises) - return { updatedCount: updates.length, invalidIds: [] } } diff --git a/packages/db_helper_sqlite/src/admin-apis/store.ts b/packages/db_helper_sqlite/src/admin-apis/store.ts index d4442ec..650c288 100644 --- a/packages/db_helper_sqlite/src/admin-apis/store.ts +++ b/packages/db_helper_sqlite/src/admin-apis/store.ts @@ -1,6 +1,7 @@ import { db } from '../db/db_index' import { storeInfo, productInfo } from '../db/schema' import { eq, inArray } from 'drizzle-orm' +import { runBatched } from '../lib/run-batched' export interface Store { id: number @@ -44,32 +45,36 @@ export async function createStore( input: CreateStoreInput, products?: number[] ): Promise { - const [newStore] = await db - .insert(storeInfo) - .values({ - name: input.name, - description: input.description, - imageUrl: input.imageUrl, - owner: input.owner, - }) - .returning() + return db.transaction(async (tx) => { + const [newStore] = await tx + .insert(storeInfo) + .values({ + name: input.name, + description: input.description, + imageUrl: input.imageUrl, + owner: input.owner, + }) + .returning() - if (products && products.length > 0) { - await db - .update(productInfo) - .set({ storeId: newStore.id }) - .where(inArray(productInfo.id, products)) - } + if (products && products.length > 0) { + await runBatched(tx, products, 10, async (t, chunk) => { + await t + .update(productInfo) + .set({ storeId: newStore.id }) + .where(inArray(productInfo.id, chunk)) + }) + } - return { - id: newStore.id, - name: newStore.name, - description: newStore.description, - imageUrl: newStore.imageUrl, - owner: newStore.owner, - createdAt: newStore.createdAt, - // updatedAt: newStore.updatedAt, - } + return { + id: newStore.id, + name: newStore.name, + description: newStore.description, + imageUrl: newStore.imageUrl, + owner: newStore.owner, + createdAt: newStore.createdAt, + // updatedAt: newStore.updatedAt, + } + }) } export interface UpdateStoreInput { @@ -84,42 +89,46 @@ export async function updateStore( input: UpdateStoreInput, products?: number[] ): Promise { - const [updatedStore] = await db - .update(storeInfo) - .set({ - ...input, - // updatedAt: new Date(), - }) - .where(eq(storeInfo.id, id)) - .returning() + return db.transaction(async (tx) => { + const [updatedStore] = await tx + .update(storeInfo) + .set({ + ...input, + // updatedAt: new Date(), + }) + .where(eq(storeInfo.id, id)) + .returning() - if (!updatedStore) { - throw new Error('Store not found') - } - - if (products !== undefined) { - await db - .update(productInfo) - .set({ storeId: null }) - .where(eq(productInfo.storeId, id)) - - if (products.length > 0) { - await db - .update(productInfo) - .set({ storeId: id }) - .where(inArray(productInfo.id, products)) + if (!updatedStore) { + throw new Error('Store not found') } - } - return { - id: updatedStore.id, - name: updatedStore.name, - description: updatedStore.description, - imageUrl: updatedStore.imageUrl, - owner: updatedStore.owner, - createdAt: updatedStore.createdAt, - // updatedAt: updatedStore.updatedAt, - } + if (products !== undefined) { + await tx + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, id)) + + if (products.length > 0) { + await runBatched(tx, products, 10, async (t, chunk) => { + await t + .update(productInfo) + .set({ storeId: id }) + .where(inArray(productInfo.id, chunk)) + }) + } + } + + return { + id: updatedStore.id, + name: updatedStore.name, + description: updatedStore.description, + imageUrl: updatedStore.imageUrl, + owner: updatedStore.owner, + createdAt: updatedStore.createdAt, + // updatedAt: updatedStore.updatedAt, + } + }) } export async function deleteStore(id: number): Promise<{ message: string }> { diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index 14dfc7f..d76a524 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -65,11 +65,13 @@ const staffRoleValues = ['super_admin', 'admin', 'marketer', 'delivery_staff'] a const staffPermissionValues = ['crud_product', 'make_coupon', 'crud_staff_users'] as const const uploadStatusValues = ['pending', 'claimed'] as const const paymentStatusValues = ['pending', 'success', 'cod', 'failed'] as const +const productTypeValues = ['item', 'combo'] as const export const staffRoleEnum = (name: string) => text(name, { enum: staffRoleValues }) export const staffPermissionEnum = (name: string) => text(name, { enum: staffPermissionValues }) export const uploadStatusEnum = (name: string) => text(name, { enum: uploadStatusValues }) export const paymentStatusEnum = (name: string) => text(name, { enum: paymentStatusValues }) +export const productTypeEnum = (name: string) => text(name, { enum: productTypeValues }) export const users = sqliteTable('users', { id: integer().primaryKey({ autoIncrement: true }), @@ -192,6 +194,7 @@ export const productInfo = sqliteTable('product_info', { storeId: integer('store_id').references(() => storeInfo.id), incrementStep: real('increment_step').notNull().default(1), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + productType: productTypeEnum('product_type').notNull().default('item'), }) export const productSkus = sqliteTable('product_skus', { @@ -217,6 +220,14 @@ export const skuFeatures = sqliteTable('sku_features', { unq_sku_feature_name: uniqueIndex('unique_sku_feature_name').on(t.skuId, t.featureName), })) +export const productCombos = sqliteTable('product_combos', { + id: integer().primaryKey({ autoIncrement: true }), + comboSkuId: integer('combo_sku_id').notNull().references(() => productSkus.id), + skuId: integer('sku_id').notNull().references(() => productSkus.id), +}, (t) => ({ + unq_combo_sku: uniqueIndex('unique_combo_sku_item').on(t.comboSkuId, t.skuId), +})) + export const productGroupInfo = sqliteTable('product_group_info', { id: integer().primaryKey({ autoIncrement: true }), groupName: text('group_name').notNull(), @@ -581,12 +592,25 @@ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({ orderItems: many(orderItems), cartItems: many(cartItems), applicableCoupons: many(couponApplicableProducts), + comboItems: many(productCombos, { relationName: 'comboSku' }), })) export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({ sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }), })) +export const productCombosRelations = relations(productCombos, ({ one }) => ({ + comboSku: one(productSkus, { + fields: [productCombos.comboSkuId], + references: [productSkus.id], + relationName: 'comboSku', + }), + sku: one(productSkus, { + fields: [productCombos.skuId], + references: [productSkus.id], + }), +})) + export const productTagInfoRelations = relations(productTagInfo, ({ many }) => ({ products: many(productTags), })) diff --git a/packages/db_helper_sqlite/src/lib/automated-jobs.ts b/packages/db_helper_sqlite/src/lib/automated-jobs.ts index 0a89a48..9eb33e2 100644 --- a/packages/db_helper_sqlite/src/lib/automated-jobs.ts +++ b/packages/db_helper_sqlite/src/lib/automated-jobs.ts @@ -1,23 +1,8 @@ import { db } from '../db/db_index' -import { productInfo, keyValStore } from '../db/schema' -import { inArray, eq } from 'drizzle-orm' +import { keyValStore } from '../db/schema' +import { eq } from 'drizzle-orm' import { castConstValue } from '../lib/const-keys' -/** - * Toggle flash delivery availability for specific products - * @param isAvailable - Whether flash delivery should be available - * @param productIds - Array of product IDs to update - */ -export async function toggleFlashDeliveryForItems( - isAvailable: boolean, - productIds: number[] -): Promise { - await db - .update(productInfo) - .set({ isFlashAvailable: isAvailable }) - .where(inArray(productInfo.id, productIds)) -} - /** * Update key-value store * @param key - The key to update diff --git a/packages/db_helper_sqlite/src/lib/delete-orders.ts b/packages/db_helper_sqlite/src/lib/delete-orders.ts index b3e91d5..f2595f4 100644 --- a/packages/db_helper_sqlite/src/lib/delete-orders.ts +++ b/packages/db_helper_sqlite/src/lib/delete-orders.ts @@ -1,6 +1,7 @@ import { db } from '../db/db_index' import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema' import { inArray } from 'drizzle-orm' +import { runBatched } from './run-batched' /** * Delete orders and all their related records @@ -13,26 +14,28 @@ export async function deleteOrdersWithRelations(orderIds: number[]): Promise { + // Delete child records first (in correct order to avoid FK constraint errors) - // 1. Delete coupon usage records - await db.delete(couponUsage).where(inArray(couponUsage.orderId, orderIds)) + // 1. Delete coupon usage records + await tx.delete(couponUsage).where(inArray(couponUsage.orderId, chunk)) - // 2. Delete complaints related to these orders - await db.delete(complaints).where(inArray(complaints.orderId, orderIds)) + // 2. Delete complaints related to these orders + await tx.delete(complaints).where(inArray(complaints.orderId, chunk)) - // 3. Delete refunds - await db.delete(refunds).where(inArray(refunds.orderId, orderIds)) + // 3. Delete refunds + await tx.delete(refunds).where(inArray(refunds.orderId, chunk)) - // 4. Delete payments - await db.delete(payments).where(inArray(payments.orderId, orderIds)) + // 4. Delete payments + await tx.delete(payments).where(inArray(payments.orderId, chunk)) - // 5. Delete order status records - await db.delete(orderStatus).where(inArray(orderStatus.orderId, orderIds)) + // 5. Delete order status records + await tx.delete(orderStatus).where(inArray(orderStatus.orderId, chunk)) - // 6. Delete order items - await db.delete(orderItems).where(inArray(orderItems.orderId, orderIds)) + // 6. Delete order items + await tx.delete(orderItems).where(inArray(orderItems.orderId, chunk)) - // 7. Finally delete the orders themselves - await db.delete(orders).where(inArray(orders.id, orderIds)) + // 7. Finally delete the orders themselves + await tx.delete(orders).where(inArray(orders.id, chunk)) + }) } diff --git a/packages/db_helper_sqlite/src/lib/run-batched.ts b/packages/db_helper_sqlite/src/lib/run-batched.ts new file mode 100644 index 0000000..c036feb --- /dev/null +++ b/packages/db_helper_sqlite/src/lib/run-batched.ts @@ -0,0 +1,39 @@ +/** Extracts the transaction handle type from any Drizzle db. */ +type TxOf = DB extends { + transaction: (cb: (tx: infer Tx) => any, ...args: any[]) => any; +} + ? Tx + : never; + +/** + * Runs `runChunk` sequentially over `items` inside a single transaction, + * passing at most `n` items per call. All chunks commit together, or the + * whole thing rolls back. + * + * @param db Drizzle database instance. + * @param items Full array of values (e.g. product ids). + * @param n Max items per batch (keep under SQLite's 999 var limit). + * @param runChunk Async fn that runs your query for one chunk, using `tx`. + */ +export async function runBatched< + DB extends { transaction: (cb: (tx: any) => any, ...args: any[]) => any }, + T, + R, +>( + db: DB, + items: readonly T[], + n: number, + runChunk: (tx: TxOf, chunk: T[]) => Promise, +): Promise { + if (n < 1) throw new RangeError("`n` must be >= 1"); + + const result = await db.transaction(async (tx: TxOf) => { + const out: R[] = []; + for (let i = 0; i < items.length; i += n) { + out.push(await runChunk(tx, items.slice(i, i + n))); + } + return out; + }); + + return result as R[]; +} diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index d0905db..6f3aeb2 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -57,6 +57,7 @@ export interface ProductBasicData { productQuantity: number isFlashAvailable: boolean flashPrice: string | null + productType: string } export interface StoreBasicData { @@ -130,6 +131,7 @@ export async function getAllProductsForCache(): Promise { productQuantity: 1, isFlashAvailable: sku.isFlashAvailable, flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + productType: sku.product?.productType ?? 'item', } }) } @@ -176,6 +178,41 @@ export async function getAllProductTagsForCache(): Promise { .innerJoin(productTagInfo, eq(productTags.tagId, productTagInfo.id)) } +// ============================================================================ +// PRODUCT COMBO STORE HELPERS +// ============================================================================ + +export interface ProductComboCacheData { + comboSkuId: number + skuId: number + productName: string + skuName: string | null + images: unknown + unitNotation: string + price: string +} + +export async function getAllProductCombosForCache(): Promise { + const results = await db.query.productCombos.findMany({ + with: { + sku: { with: { product: true, features: true } }, + }, + }) + + return results.map((ci) => { + const features = ci.sku?.features || [] + return { + comboSkuId: ci.comboSkuId, + skuId: ci.skuId, + productName: ci.sku?.product?.name ?? 'Unknown', + skuName: ci.sku?.name ?? null, + images: ci.sku?.images, + unitNotation: features.map((f) => f.featureValue).join(' '), + price: String(ci.sku?.price ?? '0'), + } + }) +} + // ============================================================================ // PRODUCT TAG STORE HELPERS // ============================================================================ diff --git a/packages/db_helper_sqlite/src/user-apis/order.ts b/packages/db_helper_sqlite/src/user-apis/order.ts index 52ded76..2e1ff77 100644 --- a/packages/db_helper_sqlite/src/user-apis/order.ts +++ b/packages/db_helper_sqlite/src/user-apis/order.ts @@ -22,6 +22,7 @@ import type { UserRecentProduct, } from '@packages/shared' import { coerceDate } from '../lib/date' +import { runBatched } from '../lib/run-batched' export interface OrderItemInput { productId: number @@ -626,12 +627,16 @@ export async function getRecentlyDeliveredOrderIds( export async function getSkuIdsFromOrders( orderIds: number[] ): Promise { - const orderItemsResult = await db - .select({ skuId: orderItems.skuId }) - .from(orderItems) - .where(inArray(orderItems.orderId, orderIds)) + if (orderIds.length === 0) return [] - return [...new Set(orderItemsResult.map((item) => item.skuId))] + const skuChunks = await runBatched(db, orderIds, 10, async (tx, chunk) => { + return tx + .select({ skuId: orderItems.skuId }) + .from(orderItems) + .where(inArray(orderItems.orderId, chunk)) + }) + + return [...new Set(skuChunks.flat().map((item) => item.skuId))] } export interface RecentProductData { @@ -649,19 +654,26 @@ export async function getProductsForRecentOrders( productIds: number[], limit: number ): Promise { - const skus = await db.query.productSkus.findMany({ - where: and( - inArray(productSkus.id, productIds), - eq(productSkus.isSuspended, false) - ), - with: { - product: true, - features: true, - }, - orderBy: desc(productSkus.createdAt), - limit, + if (productIds.length === 0) return [] + + const skuChunks = await runBatched(db, productIds, 10, async (tx, chunk) => { + return tx.query.productSkus.findMany({ + where: and( + inArray(productSkus.id, chunk), + eq(productSkus.isSuspended, false) + ), + with: { + product: true, + features: true, + }, + }) }) + const skus = skuChunks + .flat() + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + .slice(0, limit) + return skus.map((sku) => { const features = sku.features || [] return { @@ -711,48 +723,49 @@ export interface OrderWithFullData { export async function getOrdersByIdsWithFullData( orderIds: number[] ): Promise { - console.log('getting orders byid') + if (orderIds.length === 0) return [] - const ordersResp = await db.query.orders.findMany({ - where: inArray(orders.id, orderIds), - with: { - address: { - columns: { - name: true, - addressLine1: true, - addressLine2: true, - city: true, - state: true, - pincode: true, - phone: true, + const orderChunks = await runBatched(db, orderIds, 10, async (tx, chunk) => { + return tx.query.orders.findMany({ + where: inArray(orders.id, chunk), + with: { + address: { + columns: { + name: true, + addressLine1: true, + addressLine2: true, + city: true, + state: true, + pincode: true, + phone: true, + }, }, - }, - orderItems: { - with: { - sku: { - columns: { - name: true, - price: true, - }, - with: { - product: { - columns: { name: true }, + orderItems: { + with: { + sku: { + columns: { + name: true, + price: true, + }, + with: { + product: { + columns: { name: true }, + }, + features: true, }, - features: true, }, }, }, - }, - slot: { - columns: { - deliveryTime: true, + slot: { + columns: { + deliveryTime: true, + }, }, }, - }, - }) - // as Promise + }) + }) - return ordersResp as OrderWithFullData[]; + return orderChunks.flat() as OrderWithFullData[] } export interface OrderWithCancellationData extends OrderWithFullData { diff --git a/packages/db_helper_sqlite/src/user-apis/product.ts b/packages/db_helper_sqlite/src/user-apis/product.ts index 6c30982..3ac89b9 100644 --- a/packages/db_helper_sqlite/src/user-apis/product.ts +++ b/packages/db_helper_sqlite/src/user-apis/product.ts @@ -1,5 +1,5 @@ import { db } from '../db/db_index' -import { deliverySlotInfo, productInfo, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema' +import { deliverySlotInfo, productInfo, productCombos, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema' import { and, desc, eq, gt, sql } from 'drizzle-orm' import type { UserProductDetailData, UserProductReview } from '@packages/shared' @@ -44,6 +44,25 @@ export async function getProductDetailById(skuId: number): Promise { + const ciFeatures = ci.sku?.features || [] + return { + skuId: ci.skuId, + skuName: ci.sku?.name ?? null, + unitNotation: ciFeatures.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + productName: ci.sku?.product?.name ?? 'Unknown', + images: getStringArray(ci.sku?.images), + price: String(ci.sku?.price ?? '0'), + } + }) + return { id: sku.id, name: product?.name ?? 'Unknown', @@ -69,6 +88,8 @@ export async function getProductDetailById(skuId: number): Promise { - let productIds: number[] | null = null + const taggedProductIdSet = new Set() - // If tagId is provided, get products that have this tag + // If tagId is provided, get product IDs that have this tag if (tagId) { const taggedProducts = await db .select({ productId: productTags.productId }) .from(productTags) .where(eq(productTags.tagId, tagId)) - productIds = taggedProducts.map(tp => tp.productId) - } - - let whereCondition = undefined - - // Filter by product IDs if tag filtering is applied - if (productIds && productIds.length > 0) { - whereCondition = inArray(productSkus.productId, productIds) + for (const tp of taggedProducts) { + taggedProductIdSet.add(tp.productId) + } } const skus = await db.query.productSkus.findMany({ - where: whereCondition, with: { product: true, features: true, }, }) - return skus.map((sku) => { - const features = sku.features || [] - return { - id: sku.product?.id ?? 0, - name: sku.product?.name ?? 'Unknown', - skuId: sku.id, - skuName: sku.name ?? null, - shortDescription: sku.product?.shortDescription ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + return skus + .filter((sku) => { + if (!tagId) return true + return taggedProductIdSet.has(sku.productId) + }) + .map((sku) => { + const features = sku.features || [] + return { + id: sku.product?.id ?? 0, + name: sku.product?.name ?? 'Unknown', + skuId: sku.id, + skuName: sku.name ?? null, + shortDescription: sku.product?.shortDescription ?? null, + price: String(sku.price ?? '0'), + marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, images: sku.images, isOutOfStock: sku.isOutOfStock, + unitShortNotation: features.map((f) => f.featureValue.replace(/\.0(?=\D|$)/g, '')).join(' '), + productQuantity: 1, features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), } }) diff --git a/packages/db_helper_sqlite/src/user-apis/stores.ts b/packages/db_helper_sqlite/src/user-apis/stores.ts index d5d2a25..894e664 100644 --- a/packages/db_helper_sqlite/src/user-apis/stores.ts +++ b/packages/db_helper_sqlite/src/user-apis/stores.ts @@ -1,6 +1,6 @@ import { db } from '../db/db_index' import { productInfo, productSkus, storeInfo } from '../db/schema' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, asc, eq, inArray } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm' import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared' @@ -12,72 +12,44 @@ const getStringArray = (value: unknown): string[] | null => { } export async function getStoreSummaries(): Promise { - // Count SKUs per store, filtering by suspended SKUs - const storesData = await db - .select({ - id: storeInfo.id, - name: storeInfo.name, - description: storeInfo.description, - imageUrl: storeInfo.imageUrl, - productCount: sql`count(${productSkus.id})`.as('productCount'), - }) - .from(storeInfo) - .leftJoin( - productInfo, - eq(productInfo.storeId, storeInfo.id) - ) - .leftJoin( - productSkus, - and( - eq(productSkus.productId, productInfo.id), - eq(productSkus.isSuspended, false) - ) - ) - .groupBy(storeInfo.id) + const storesData = await db.select({ + id: storeInfo.id, + name: storeInfo.name, + description: storeInfo.description, + imageUrl: storeInfo.imageUrl, + }).from(storeInfo) - const storesWithDetails = await Promise.all( - storesData.map(async (store) => { - let sampleProducts: any[] = [] - // Get sample SKUs from this store - if (store.productCount > 0) { - const storeProductIds = await db - .select({ id: productInfo.id }) - .from(productInfo) - .where(eq(productInfo.storeId, store.id)) + const skus = await db.query.productSkus.findMany({ + where: eq(productSkus.isSuspended, false), + with: { product: true }, + orderBy: asc(productSkus.id), + }) - const productIdArr = storeProductIds.map((p) => p.id) - if (productIdArr.length > 0) { - const skus = await db.query.productSkus.findMany({ - where: and( - inArray(productSkus.productId, productIdArr), - eq(productSkus.isSuspended, false) - ), - with: { - product: { columns: { name: true } }, - }, - columns: { id: true, images: true, name: true }, - limit: 3, - }) - sampleProducts = skus.map((sku) => ({ - id: sku.id, - name: sku.product?.name ?? sku.name ?? 'Unknown', - images: getStringArray(sku.images), - })) - } - } + const skusByStore = new Map() + for (const sku of skus) { + const storeId = sku.product?.storeId + if (storeId == null) continue + if (!skusByStore.has(storeId)) skusByStore.set(storeId, []) + skusByStore.get(storeId)!.push(sku) + } - return { - id: store.id, - name: store.name, - description: store.description ?? null, - imageUrl: store.imageUrl ?? null, - productCount: store.productCount || 0, - sampleProducts, - } - }) - ) + return storesData.map((store) => { + const storeSkus = skusByStore.get(store.id) || [] + const sampleProducts = storeSkus.slice(0, 3).map((sku) => ({ + id: sku.id, + name: sku.product?.name ?? sku.name ?? 'Unknown', + images: getStringArray(sku.images), + })) - return storesWithDetails + return { + id: store.id, + name: store.name, + description: store.description ?? null, + imageUrl: store.imageUrl ?? null, + productCount: storeSkus.length, + sampleProducts, + } + }) } export async function getStoreDetail(storeId: number): Promise { diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts index 1c784e2..dea702f 100644 --- a/packages/shared/types/admin.ts +++ b/packages/shared/types/admin.ts @@ -360,13 +360,13 @@ export interface AdminUnit { fullName: string; } -export interface AdminSkuVariant { - id: number - skuId: number - name: string - value: string - unitId: number | null - sortOrder: number +export interface AdminProductComboItem { + skuId: number; + skuName: string | null; + features: AdminSkuFeature[]; + productName: string; + images: string[] | null; + price: string; } export interface AdminSku { @@ -383,7 +383,7 @@ export interface AdminSku { flashPrice: string | null createdAt: Date features: AdminSkuFeature[] -} + comboItems: AdminProductComboItem[] } export interface AdminProduct { @@ -394,6 +394,7 @@ export interface AdminProduct { storeId: number | null; incrementStep: number; createdAt: Date; + productType: 'item' | 'combo'; } export interface AdminProductWithRelations extends AdminProduct { diff --git a/packages/shared/types/user.ts b/packages/shared/types/user.ts index 2c19277..43506bb 100644 --- a/packages/shared/types/user.ts +++ b/packages/shared/types/user.ts @@ -286,6 +286,15 @@ export interface UserProductSpecialDeal { validTill: Date; } +export interface UserProductComboItem { + skuId: number; + skuName: string | null; + unitNotation: string; + productName: string; + images: string[] | null; + price: string; +} + export interface UserProductDetailData { id: number; name: string; @@ -303,6 +312,8 @@ export interface UserProductDetailData { flashPrice: string | null; deliverySlots: UserProductDeliverySlot[]; specialDeals: UserProductSpecialDeal[]; + productType: string; + comboItems: UserProductComboItem[]; } export interface UserProductDetail extends UserProductDetailData {