This commit is contained in:
shafi54 2026-08-01 20:18:42 +05:30
parent 6193104e6f
commit 0cbd24341f
23 changed files with 571 additions and 424 deletions

View file

@ -253,10 +253,6 @@ export default function PricesOverview() {
return; return;
} }
Alert.alert("Error", "Please enter a valid size");
return;
}
setPendingChanges(prev => ({ setPendingChanges(prev => ({
...prev, ...prev,
[editDialog.sku.id]: { [editDialog.sku.id]: {
@ -368,6 +364,7 @@ export default function PricesOverview() {
/> />
)} )}
<BottomDialog open={editDialog.open} onClose={() => setEditDialog({ ...editDialog, open: false, tempFlashPrice: "", tempMarketPrice: "", tempPrice: "" })}>
<View style={tw`p-4`}> <View style={tw`p-4`}>
<MyText style={tw`text-lg font-bold mb-1`}>{editDialog.productName}</MyText> <MyText style={tw`text-lg font-bold mb-1`}>{editDialog.productName}</MyText>
<MyText style={tw`text-sm text-gray-500 mb-4`}>{editDialog.sku?.displayName || editDialog.sku?.name || ''}</MyText> <MyText style={tw`text-sm text-gray-500 mb-4`}>{editDialog.sku?.displayName || editDialog.sku?.name || ''}</MyText>

View file

@ -48,6 +48,9 @@ export default function AddProduct() {
featureName: attr.featureName, featureName: attr.featureName,
featureValue: attr.featureValue, 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, shortDescription: values.shortDescription || undefined,
longDescription: values.longDescription || undefined, longDescription: values.longDescription || undefined,
storeId: values.storeId, storeId: values.storeId,
productType: values.productType,
incrementStep: 1, incrementStep: 1,
skus, skus,
}) })
@ -72,14 +76,17 @@ export default function AddProduct() {
shortDescription: '', shortDescription: '',
longDescription: '', longDescription: '',
storeId: 1, storeId: 1,
productType: 'item' as const,
variants: [ variants: [
{ {
id: undefined as number | undefined,
name: '', name: '',
price: '', price: '',
marketPrice: '', marketPrice: '',
isFlashAvailable: false, isFlashAvailable: false,
flashPrice: '', flashPrice: '',
attributes: [{ featureName: 'quantity', featureValue: '' }], attributes: [{ featureName: 'quantity', featureValue: '' }],
comboItems: [] as { skuId: number | string }[],
}, },
], ],
} }

View file

@ -41,6 +41,7 @@ export default function EditProduct() {
shortDescription: productData.shortDescription || '', shortDescription: productData.shortDescription || '',
longDescription: productData.longDescription || '', longDescription: productData.longDescription || '',
storeId: productData.storeId || 1, storeId: productData.storeId || 1,
productType: (productData.productType as 'item' | 'combo') || 'item',
variants: (productData.skus || []).map((sku) => ({ variants: (productData.skus || []).map((sku) => ({
id: sku.id, id: sku.id,
name: sku.name || '', name: sku.name || '',
@ -52,6 +53,9 @@ export default function EditProduct() {
featureName: f.featureName, featureName: f.featureName,
featureValue: f.featureValue, featureValue: f.featureValue,
})), })),
comboItems: (sku.comboItems || []).map((ci: any) => ({
skuId: ci.skuId.toString(),
})),
})), })),
} }
}, [productData]) }, [productData])
@ -118,6 +122,9 @@ export default function EditProduct() {
featureName: attr.featureName, featureName: attr.featureName,
featureValue: attr.featureValue, 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, shortDescription: values.shortDescription || undefined,
longDescription: values.longDescription || undefined, longDescription: values.longDescription || undefined,
storeId: values.storeId, storeId: values.storeId,
productType: values.productType,
incrementStep: 1, incrementStep: 1,
skus, skus,
deletedImageKeys, deletedImageKeys,

View file

@ -18,6 +18,7 @@ interface Variant {
isFlashAvailable: boolean isFlashAvailable: boolean
flashPrice: string flashPrice: string
attributes: Attribute[] attributes: Attribute[]
comboItems: { skuId: number | string }[]
} }
interface ProductFormData { interface ProductFormData {
@ -25,6 +26,7 @@ interface ProductFormData {
shortDescription: string shortDescription: string
longDescription: string longDescription: string
storeId: number storeId: number
productType: 'item' | 'combo'
variants: Variant[] variants: Variant[]
} }
@ -44,12 +46,14 @@ interface ProductFormProps {
const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' }) const defaultAttribute = (): Attribute => ({ featureName: 'quantity', featureValue: '' })
const defaultVariant = (): Variant => ({ const defaultVariant = (): Variant => ({
id: undefined,
name: '', name: '',
price: '', price: '',
marketPrice: '', marketPrice: '',
isFlashAvailable: false, isFlashAvailable: false,
flashPrice: '', flashPrice: '',
attributes: [defaultAttribute()], attributes: [defaultAttribute()],
comboItems: [],
}) })
const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
@ -76,6 +80,12 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
value: store.id, 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 ( return (
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
@ -136,6 +146,19 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
/> />
<BottomDropdown
topLabel="Product Type"
label="Product Type"
value={values.productType}
options={[
{ label: 'Item', value: 'item' },
{ label: 'Combo', value: 'combo' },
]}
onValueChange={(value) => setFieldValue('productType', value)}
placeholder="Select product type"
style={{ marginBottom: 16 }}
/>
<FieldArray name="variants"> <FieldArray name="variants">
{({ push, remove }) => ( {({ push, remove }) => (
<View> <View>
@ -271,6 +294,46 @@ const ProductForm = forwardRef<ProductFormRef, ProductFormProps>(({
} }
allowMultiple={true} allowMultiple={true}
/> />
{values.productType === 'combo' && (
<View style={tw`mt-3 pt-3 border-t border-gray-100`}>
<View style={tw`flex-row justify-between items-center mb-2`}>
<MyText style={tw`font-medium text-gray-600`}>Included Items</MyText>
<TouchableOpacity
onPress={() => {
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`}
>
<MaterialIcons name="add" size={14} color="#4B5563" />
<MyText style={tw`text-gray-600 text-xs ml-0.5`}>Add</MyText>
</TouchableOpacity>
</View>
{variant.comboItems?.map((ci, cIndex) => (
<View key={cIndex} style={tw`flex-row items-center gap-2 mb-2`}>
<View style={tw`flex-1`}>
<BottomDropdown
label="SKU"
value={ci.skuId}
options={skuOptions}
onValueChange={(val) => setFieldValue(`variants.${vIndex}.comboItems.${cIndex}.skuId`, val)}
placeholder="Select SKU"
/>
</View>
<TouchableOpacity
onPress={() => {
const items = variant.comboItems.filter((_, i) => i !== cIndex)
setFieldValue(`variants.${vIndex}.comboItems`, items)
}}
>
<MaterialIcons name="close" size={18} color="#EF4444" />
</TouchableOpacity>
</View>
))}
</View>
)}
</View> </View>
))} ))}
</View> </View>

View file

@ -5,11 +5,9 @@ import { createApp } from '@/src/app'
// import signedUrlCache from '@/src/lib/signed-url-cache'; // import signedUrlCache from '@/src/lib/signed-url-cache';
import { seed } from '@/src/lib/seed'; import { seed } from '@/src/lib/seed';
import '@/src/jobs/jobs-index'; import '@/src/jobs/jobs-index';
import { startAutomatedJobs } from '@/src/lib/automatedJobs';
seed() seed()
initFunc() initFunc()
startAutomatedJobs()
// signedUrlCache.loadFromDisk(); // Disabled for Workers compatibility // signedUrlCache.loadFromDisk(); // Disabled for Workers compatibility

View file

@ -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();

View file

@ -273,7 +273,6 @@ export {
type SlotWithProductsData, type SlotWithProductsData,
type UserNegativityData, type UserNegativityData,
// Automated Jobs // Automated Jobs
toggleFlashDeliveryForItems,
toggleKeyVal, toggleKeyVal,
getAllKeyValStore, getAllKeyValStore,
// Post-order handler helpers // Post-order handler helpers

View file

@ -6,11 +6,13 @@ import {
getAllSpecialDealsForCache, getAllSpecialDealsForCache,
getAllProductTagsForCache, getAllProductTagsForCache,
getProductById as getProductByIdFromDb, getProductById as getProductByIdFromDb,
getAllProductCombosForCache,
type ProductBasicData, type ProductBasicData,
type StoreBasicData, type StoreBasicData,
type DeliverySlotData, type DeliverySlotData,
type SpecialDealData, type SpecialDealData,
type ProductTagData, type ProductTagData,
type ProductComboCacheData,
} from '@/src/dbService' } from '@/src/dbService'
import { scaffoldAssetUrl } from '@/src/lib/s3-client' import { scaffoldAssetUrl } from '@/src/lib/s3-client'
@ -33,6 +35,15 @@ interface Product {
deliverySlots: Array<{ id: number; deliveryTime: Date; freezeTime: Date; isCapacityFull: boolean }> deliverySlots: Array<{ id: number; deliveryTime: Date; freezeTime: Date; isCapacityFull: boolean }>
specialDeals: Array<{ quantity: string; price: string; validTill: Date }> specialDeals: Array<{ quantity: string; price: string; validTill: Date }>
productTags: string[] 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<void> { export async function initializeProducts(): Promise<void> {
@ -213,6 +224,14 @@ export async function getAllProducts(): Promise<Product[]> {
} }
const products: Product[] = [] const products: Product[] = []
const allProductCombos = await getAllProductCombosForCache()
const productCombosMap = new Map<number, ProductComboCacheData[]>()
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) { for (const product of productsData) {
const signedImages = scaffoldAssetUrl( const signedImages = scaffoldAssetUrl(
(product.images as string[]) || [] (product.images as string[]) || []
@ -223,6 +242,14 @@ export async function getAllProducts(): Promise<Product[]> {
const deliverySlots = deliverySlotsMap.get(product.id) || [] const deliverySlots = deliverySlotsMap.get(product.id) || []
const specialDeals = specialDealsMap.get(product.id) || [] const specialDeals = specialDealsMap.get(product.id) || []
const productTags = productTagsMap.get(product.productId) || [] 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({ products.push({
id: product.id, id: product.id,
@ -253,6 +280,8 @@ export async function getAllProducts(): Promise<Product[]> {
validTill: d.validTill, validTill: d.validTill,
})), })),
productTags: productTags, productTags: productTags,
productType: product.productType || 'item',
comboItems: comboItems,
}) })
} }

View file

@ -190,6 +190,7 @@ export const productRouter = router({
longDescription: z.string().optional(), longDescription: z.string().optional(),
storeId: z.number().min(1, 'Store is required'), storeId: z.number().min(1, 'Store is required'),
incrementStep: z.number().optional().default(1), incrementStep: z.number().optional().default(1),
productType: z.enum(['item', 'combo']).optional().default('item'),
skus: z.array(z.object({ skus: z.array(z.object({
name: z.string().optional().nullable(), name: z.string().optional().nullable(),
price: z.number().positive('Price must be positive'), 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'), featureName: z.string().min(1, 'Attribute name is required'),
featureValue: z.string().min(1, 'Value is required'), featureValue: z.string().min(1, 'Value is required'),
})).min(1, 'At least one feature 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'), })).min(1, 'At least one SKU is required'),
})) }))
.mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => { .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()) const existingProduct = await checkProductExistsByName(name.trim())
if (existingProduct) { if (existingProduct) {
@ -224,6 +228,7 @@ export const productRouter = router({
featureName: f.featureName, featureName: f.featureName,
featureValue: f.featureValue, featureValue: f.featureValue,
})), })),
comboItems: (sku.comboItems || []).map((ci) => ({ skuId: ci.skuId })),
})) }))
const newProduct = await createProductInDb({ const newProduct = await createProductInDb({
@ -232,6 +237,7 @@ export const productRouter = router({
longDescription, longDescription,
storeId, storeId,
incrementStep, incrementStep,
productType,
skus: skuInputs, skus: skuInputs,
} as any) } as any)
@ -265,6 +271,7 @@ export const productRouter = router({
longDescription: z.string().optional(), longDescription: z.string().optional(),
storeId: z.number().min(1, 'Store is required'), storeId: z.number().min(1, 'Store is required'),
incrementStep: z.number().optional().default(1), incrementStep: z.number().optional().default(1),
productType: z.enum(['item', 'combo']).optional(),
skus: z.array(z.object({ skus: z.array(z.object({
id: z.number().optional(), id: z.number().optional(),
name: z.string().optional().nullable(), name: z.string().optional().nullable(),
@ -277,12 +284,15 @@ export const productRouter = router({
featureName: z.string().min(1, 'Attribute name is required'), featureName: z.string().min(1, 'Attribute name is required'),
featureValue: z.string().min(1, 'Value is required'), featureValue: z.string().min(1, 'Value is required'),
})).min(1, 'At least one feature 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'), })).min(1, 'At least one SKU is required'),
deletedImageKeys: z.array(z.string()).optional().default([]), deletedImageKeys: z.array(z.string()).optional().default([]),
newImageUrls: z.array(z.string()).optional().default([]), newImageUrls: z.array(z.string()).optional().default([]),
})) }))
.mutation(async ({ input }): Promise<{ product: AdminProductWithRelations; message: string }> => { .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) { if (deletedImageKeys.length > 0) {
await deleteImageUtil({ keys: deletedImageKeys }) await deleteImageUtil({ keys: deletedImageKeys })
@ -302,6 +312,7 @@ export const productRouter = router({
featureName: f.featureName, featureName: f.featureName,
featureValue: f.featureValue, featureValue: f.featureValue,
})), })),
comboItems: (sku.comboItems || []).map((ci) => ({ skuId: ci.skuId })),
})) }))
const updatedProduct = await updateProductInDb(id, { const updatedProduct = await updateProductInDb(id, {
@ -310,6 +321,7 @@ export const productRouter = router({
longDescription, longDescription,
storeId, storeId,
incrementStep, incrementStep,
productType,
skus: skuInputs, skus: skuInputs,
} as any) } as any)

View file

@ -232,5 +232,18 @@ WHERE `key` = 'popularItems'
-- 9. Clean up helper table. -- 9. Clean up helper table.
DROP TABLE `__product_to_sku`; 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 foreign_keys=ON;
PRAGMA defer_foreign_keys = off; PRAGMA defer_foreign_keys = off;

View file

@ -331,11 +331,13 @@ export {
getAllDeliverySlotsForCache, getAllDeliverySlotsForCache,
getAllSpecialDealsForCache, getAllSpecialDealsForCache,
getAllProductTagsForCache, getAllProductTagsForCache,
getAllProductCombosForCache,
type ProductBasicData, type ProductBasicData,
type StoreBasicData, type StoreBasicData,
type DeliverySlotData, type DeliverySlotData,
type SpecialDealData, type SpecialDealData,
type ProductTagData, type ProductTagData,
type ProductComboCacheData,
// Product Tag Store // Product Tag Store
getAllTagsForCache, getAllTagsForCache,
getAllTagProductMappings, getAllTagProductMappings,
@ -352,7 +354,6 @@ export {
// Automated Jobs Helpers // Automated Jobs Helpers
export { export {
toggleFlashDeliveryForItems,
toggleKeyVal, toggleKeyVal,
getAllKeyValStore, getAllKeyValStore,
} from './src/lib/automated-jobs' } from './src/lib/automated-jobs'

View file

@ -4,6 +4,7 @@ import {
productInfo, productInfo,
productSkus, productSkus,
skuFeatures, skuFeatures,
productCombos,
units, units,
specialDeals, specialDeals,
deliverySlotInfo, deliverySlotInfo,
@ -24,6 +25,7 @@ import {
couponApplicableProducts, couponApplicableProducts,
} from '../db/schema' } from '../db/schema'
import { and, desc, eq, inArray, sql } from 'drizzle-orm' import { and, desc, eq, inArray, sql } from 'drizzle-orm'
import { runBatched } from '../lib/run-batched'
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import type { import type {
AdminProduct, AdminProduct,
@ -82,6 +84,7 @@ const mapProduct = (product: ProductRow): AdminProduct => ({
storeId: product.storeId, storeId: product.storeId,
incrementStep: product.incrementStep, incrementStep: product.incrementStep,
createdAt: product.createdAt, createdAt: product.createdAt,
productType: product.productType as 'item' | 'combo',
}) })
const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({ const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({
@ -91,7 +94,7 @@ const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({
featureValue: feature.featureValue, featureValue: feature.featureValue,
}) })
const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = []): AdminSku => ({ const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = [], comboItems: any[] = []): AdminSku => ({
id: sku.id, id: sku.id,
productId: sku.productId, productId: sku.productId,
name: sku.name ?? null, name: sku.name ?? null,
@ -105,6 +108,7 @@ const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = []): AdminSku => ({
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
createdAt: sku.createdAt, createdAt: sku.createdAt,
features: features.map(mapSkuFeature), features: features.map(mapSkuFeature),
comboItems: comboItems,
}) })
const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({ const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({
@ -128,7 +132,7 @@ const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({
export async function getAllProducts(): Promise<AdminProductWithRelations[]> { export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
type ProductWithRelationsRow = ProductRow & { type ProductWithRelationsRow = ProductRow & {
store: StoreRow | null store: StoreRow | null
skus: Array<SkuRow & { features: SkuFeatureRow[] }> skus: Array<SkuRow & { features: SkuFeatureRow[]; comboItems: any[] }>
} }
const products = await db.query.productInfo.findMany({ const products = await db.query.productInfo.findMany({
orderBy: productInfo.name, orderBy: productInfo.name,
@ -137,6 +141,11 @@ export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
skus: { skus: {
with: { with: {
features: true, features: true,
comboItems: {
with: {
sku: { with: { product: true, features: true } },
},
},
}, },
}, },
}, },
@ -145,7 +154,17 @@ export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
return products.map((product) => ({ return products.map((product) => ({
...mapProduct(product), ...mapProduct(product),
store: product.store ? mapStore(product.store) : null, 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<AdminProductWithDetail
skus: { skus: {
with: { with: {
features: true, features: true,
comboItems: {
with: {
sku: { with: { product: true, features: true } },
},
},
}, },
}, },
}, },
@ -180,12 +204,24 @@ export async function getProductById(id: number): Promise<AdminProductWithDetail
with: { with: {
tag: true, tag: true,
}, },
}) as Array<ProductTagRow & { tag: ProductTagInfoRow }> }) as Array<ProductTagRow & { tag: ProductTagInfoRow }>
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 { return {
...mapProduct(product), ...mapProduct(product),
store: product.store ? mapStore(product.store) : null, store: product.store ? mapStore(product.store) : null,
skus: product.skus.map((sku) => mapSku(sku, sku.features)), skus: skusWithCombos,
deals: deals.map(mapSpecialDeal), deals: deals.map(mapSpecialDeal),
tags: productTagsData.map((tag) => mapTagInfo(tag.tag)), tags: productTagsData.map((tag) => mapTagInfo(tag.tag)),
} }
@ -227,6 +263,7 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
longDescription: productData.longDescription ?? null, longDescription: productData.longDescription ?? null,
storeId: productData.storeId ?? null, storeId: productData.storeId ?? null,
incrementStep: productData.incrementStep ?? 1, incrementStep: productData.incrementStep ?? 1,
productType: productData.productType ?? 'item',
}).returning() }).returning()
const skuRows = await db.insert(productSkus).values( const skuRows = await db.insert(productSkus).values(
@ -251,6 +288,15 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
featureValue: f.featureValue, featureValue: f.featureValue,
})) }))
) )
if (sku.comboItems && sku.comboItems.length > 0) {
await db.insert(productCombos).values(
sku.comboItems.map((ci: any) => ({
comboSkuId: skuRow.id,
skuId: ci.skuId,
}))
)
}
} }
const createdSkus = await db.query.productSkus.findMany({ const createdSkus = await db.query.productSkus.findMany({
@ -283,6 +329,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
longDescription: productData.longDescription ?? null, longDescription: productData.longDescription ?? null,
storeId: productData.storeId ?? null, storeId: productData.storeId ?? null,
incrementStep: productData.incrementStep ?? 1, incrementStep: productData.incrementStep ?? 1,
productType: productData.productType,
}) })
.where(eq(productInfo.id, id)) .where(eq(productInfo.id, id))
@ -326,6 +373,16 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
featureValue: f.featureValue, featureValue: f.featureValue,
})) }))
) )
await db.delete(productCombos).where(eq(productCombos.comboSkuId, sku.id))
if (sku.comboItems && sku.comboItems.length > 0) {
await db.insert(productCombos).values(
sku.comboItems.map((ci: any) => ({
comboSkuId: sku.id,
skuId: ci.skuId,
}))
)
}
} else { } else {
// Insert new SKU // Insert new SKU
const [newSku] = await db.insert(productSkus).values({ const [newSku] = await db.insert(productSkus).values({
@ -370,29 +427,6 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
} }
} }
const product = await db.query.productSkus.findFirst({
where: eq(productSkus.id, id),
})
if (!product) {
return null
}
const [updatedProduct] = await db
.update(productSkus)
.set({
isOutOfStock: !product.isOutOfStock,
})
.where(eq(productSkus.id, id))
.returning()
if (!updatedProduct) {
return null
}
return mapSku(updatedProduct)
}
export async function updateSlotProducts(slotId: string, productIds: string[]): Promise<AdminUpdateSlotProductsResult> { export async function updateSlotProducts(slotId: string, productIds: string[]): Promise<AdminUpdateSlotProductsResult> {
const slot = await db.query.deliverySlotInfo.findFirst({ const slot = await db.query.deliverySlotInfo.findFirst({
where: eq(deliverySlotInfo.id, parseInt(slotId)), where: eq(deliverySlotInfo.id, parseInt(slotId)),
@ -815,35 +849,39 @@ export async function updateProductPrices(updates: Array<{
} }
const productIds = updates.map((update) => update.productId) 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)) const invalidIds = productIds.filter((id) => !existingIds.has(id))
if (invalidIds.length > 0) { if (invalidIds.length > 0) {
return { updatedCount: 0, invalidIds } return { updatedCount: 0, invalidIds }
} }
const updatePromises = updates.map((update) => { // Apply updates in chunks inside a single transaction
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update await runBatched(db, updates, 10, async (tx, chunk) => {
const updateData: any = {} for (const update of chunk) {
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update
const updateData: any = {}
if (price !== undefined) updateData.price = price.toString() if (price !== undefined) updateData.price = price.toString()
if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString() if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString()
if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString() if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString()
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable
return db await tx
.update(productSkus) .update(productSkus)
.set(updateData) .set(updateData)
.where(eq(productSkus.id, productId)) .where(eq(productSkus.id, productId))
}
}) })
await Promise.all(updatePromises)
return { updatedCount: updates.length, invalidIds: [] } return { updatedCount: updates.length, invalidIds: [] }
} }

View file

@ -1,6 +1,7 @@
import { db } from '../db/db_index' import { db } from '../db/db_index'
import { storeInfo, productInfo } from '../db/schema' import { storeInfo, productInfo } from '../db/schema'
import { eq, inArray } from 'drizzle-orm' import { eq, inArray } from 'drizzle-orm'
import { runBatched } from '../lib/run-batched'
export interface Store { export interface Store {
id: number id: number
@ -44,32 +45,36 @@ export async function createStore(
input: CreateStoreInput, input: CreateStoreInput,
products?: number[] products?: number[]
): Promise<Store> { ): Promise<Store> {
const [newStore] = await db return db.transaction(async (tx) => {
.insert(storeInfo) const [newStore] = await tx
.values({ .insert(storeInfo)
name: input.name, .values({
description: input.description, name: input.name,
imageUrl: input.imageUrl, description: input.description,
owner: input.owner, imageUrl: input.imageUrl,
}) owner: input.owner,
.returning() })
.returning()
if (products && products.length > 0) { if (products && products.length > 0) {
await db await runBatched(tx, products, 10, async (t, chunk) => {
.update(productInfo) await t
.set({ storeId: newStore.id }) .update(productInfo)
.where(inArray(productInfo.id, products)) .set({ storeId: newStore.id })
} .where(inArray(productInfo.id, chunk))
})
}
return { return {
id: newStore.id, id: newStore.id,
name: newStore.name, name: newStore.name,
description: newStore.description, description: newStore.description,
imageUrl: newStore.imageUrl, imageUrl: newStore.imageUrl,
owner: newStore.owner, owner: newStore.owner,
createdAt: newStore.createdAt, createdAt: newStore.createdAt,
// updatedAt: newStore.updatedAt, // updatedAt: newStore.updatedAt,
} }
})
} }
export interface UpdateStoreInput { export interface UpdateStoreInput {
@ -84,42 +89,46 @@ export async function updateStore(
input: UpdateStoreInput, input: UpdateStoreInput,
products?: number[] products?: number[]
): Promise<Store> { ): Promise<Store> {
const [updatedStore] = await db return db.transaction(async (tx) => {
.update(storeInfo) const [updatedStore] = await tx
.set({ .update(storeInfo)
...input, .set({
// updatedAt: new Date(), ...input,
}) // updatedAt: new Date(),
.where(eq(storeInfo.id, id)) })
.returning() .where(eq(storeInfo.id, id))
.returning()
if (!updatedStore) { if (!updatedStore) {
throw new Error('Store not found') 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))
} }
}
return { if (products !== undefined) {
id: updatedStore.id, await tx
name: updatedStore.name, .update(productInfo)
description: updatedStore.description, .set({ storeId: null })
imageUrl: updatedStore.imageUrl, .where(eq(productInfo.storeId, id))
owner: updatedStore.owner,
createdAt: updatedStore.createdAt, if (products.length > 0) {
// updatedAt: updatedStore.updatedAt, 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 }> { export async function deleteStore(id: number): Promise<{ message: string }> {

View file

@ -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 staffPermissionValues = ['crud_product', 'make_coupon', 'crud_staff_users'] as const
const uploadStatusValues = ['pending', 'claimed'] as const const uploadStatusValues = ['pending', 'claimed'] as const
const paymentStatusValues = ['pending', 'success', 'cod', 'failed'] 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 staffRoleEnum = (name: string) => text(name, { enum: staffRoleValues })
export const staffPermissionEnum = (name: string) => text(name, { enum: staffPermissionValues }) export const staffPermissionEnum = (name: string) => text(name, { enum: staffPermissionValues })
export const uploadStatusEnum = (name: string) => text(name, { enum: uploadStatusValues }) export const uploadStatusEnum = (name: string) => text(name, { enum: uploadStatusValues })
export const paymentStatusEnum = (name: string) => text(name, { enum: paymentStatusValues }) export const paymentStatusEnum = (name: string) => text(name, { enum: paymentStatusValues })
export const productTypeEnum = (name: string) => text(name, { enum: productTypeValues })
export const users = sqliteTable('users', { export const users = sqliteTable('users', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
@ -192,6 +194,7 @@ export const productInfo = sqliteTable('product_info', {
storeId: integer('store_id').references(() => storeInfo.id), storeId: integer('store_id').references(() => storeInfo.id),
incrementStep: real('increment_step').notNull().default(1), incrementStep: real('increment_step').notNull().default(1),
createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
productType: productTypeEnum('product_type').notNull().default('item'),
}) })
export const productSkus = sqliteTable('product_skus', { 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), 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', { export const productGroupInfo = sqliteTable('product_group_info', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
groupName: text('group_name').notNull(), groupName: text('group_name').notNull(),
@ -581,12 +592,25 @@ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({
orderItems: many(orderItems), orderItems: many(orderItems),
cartItems: many(cartItems), cartItems: many(cartItems),
applicableCoupons: many(couponApplicableProducts), applicableCoupons: many(couponApplicableProducts),
comboItems: many(productCombos, { relationName: 'comboSku' }),
})) }))
export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({ export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({
sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }), 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 }) => ({ export const productTagInfoRelations = relations(productTagInfo, ({ many }) => ({
products: many(productTags), products: many(productTags),
})) }))

View file

@ -1,23 +1,8 @@
import { db } from '../db/db_index' import { db } from '../db/db_index'
import { productInfo, keyValStore } from '../db/schema' import { keyValStore } from '../db/schema'
import { inArray, eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { castConstValue } from '../lib/const-keys' 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<void> {
await db
.update(productInfo)
.set({ isFlashAvailable: isAvailable })
.where(inArray(productInfo.id, productIds))
}
/** /**
* Update key-value store * Update key-value store
* @param key - The key to update * @param key - The key to update

View file

@ -1,6 +1,7 @@
import { db } from '../db/db_index' import { db } from '../db/db_index'
import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema' import { orders, orderItems, orderStatus, payments, refunds, couponUsage, complaints } from '../db/schema'
import { inArray } from 'drizzle-orm' import { inArray } from 'drizzle-orm'
import { runBatched } from './run-batched'
/** /**
* Delete orders and all their related records * Delete orders and all their related records
@ -13,26 +14,28 @@ export async function deleteOrdersWithRelations(orderIds: number[]): Promise<voi
return return
} }
// Delete child records first (in correct order to avoid FK constraint errors) await runBatched(db, orderIds, 10, async (tx, chunk) => {
// Delete child records first (in correct order to avoid FK constraint errors)
// 1. Delete coupon usage records // 1. Delete coupon usage records
await db.delete(couponUsage).where(inArray(couponUsage.orderId, orderIds)) await tx.delete(couponUsage).where(inArray(couponUsage.orderId, chunk))
// 2. Delete complaints related to these orders // 2. Delete complaints related to these orders
await db.delete(complaints).where(inArray(complaints.orderId, orderIds)) await tx.delete(complaints).where(inArray(complaints.orderId, chunk))
// 3. Delete refunds // 3. Delete refunds
await db.delete(refunds).where(inArray(refunds.orderId, orderIds)) await tx.delete(refunds).where(inArray(refunds.orderId, chunk))
// 4. Delete payments // 4. Delete payments
await db.delete(payments).where(inArray(payments.orderId, orderIds)) await tx.delete(payments).where(inArray(payments.orderId, chunk))
// 5. Delete order status records // 5. Delete order status records
await db.delete(orderStatus).where(inArray(orderStatus.orderId, orderIds)) await tx.delete(orderStatus).where(inArray(orderStatus.orderId, chunk))
// 6. Delete order items // 6. Delete order items
await db.delete(orderItems).where(inArray(orderItems.orderId, orderIds)) await tx.delete(orderItems).where(inArray(orderItems.orderId, chunk))
// 7. Finally delete the orders themselves // 7. Finally delete the orders themselves
await db.delete(orders).where(inArray(orders.id, orderIds)) await tx.delete(orders).where(inArray(orders.id, chunk))
})
} }

View file

@ -0,0 +1,39 @@
/** Extracts the transaction handle type from any Drizzle db. */
type TxOf<DB> = 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<DB>, chunk: T[]) => Promise<R>,
): Promise<R[]> {
if (n < 1) throw new RangeError("`n` must be >= 1");
const result = await db.transaction(async (tx: TxOf<DB>) => {
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[];
}

View file

@ -57,6 +57,7 @@ export interface ProductBasicData {
productQuantity: number productQuantity: number
isFlashAvailable: boolean isFlashAvailable: boolean
flashPrice: string | null flashPrice: string | null
productType: string
} }
export interface StoreBasicData { export interface StoreBasicData {
@ -130,6 +131,7 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
productQuantity: 1, productQuantity: 1,
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: sku.isFlashAvailable,
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
productType: sku.product?.productType ?? 'item',
} }
}) })
} }
@ -176,6 +178,41 @@ export async function getAllProductTagsForCache(): Promise<ProductTagData[]> {
.innerJoin(productTagInfo, eq(productTags.tagId, productTagInfo.id)) .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<ProductComboCacheData[]> {
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 // PRODUCT TAG STORE HELPERS
// ============================================================================ // ============================================================================

View file

@ -22,6 +22,7 @@ import type {
UserRecentProduct, UserRecentProduct,
} from '@packages/shared' } from '@packages/shared'
import { coerceDate } from '../lib/date' import { coerceDate } from '../lib/date'
import { runBatched } from '../lib/run-batched'
export interface OrderItemInput { export interface OrderItemInput {
productId: number productId: number
@ -626,12 +627,16 @@ export async function getRecentlyDeliveredOrderIds(
export async function getSkuIdsFromOrders( export async function getSkuIdsFromOrders(
orderIds: number[] orderIds: number[]
): Promise<number[]> { ): Promise<number[]> {
const orderItemsResult = await db if (orderIds.length === 0) return []
.select({ skuId: orderItems.skuId })
.from(orderItems)
.where(inArray(orderItems.orderId, orderIds))
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 { export interface RecentProductData {
@ -649,19 +654,26 @@ export async function getProductsForRecentOrders(
productIds: number[], productIds: number[],
limit: number limit: number
): Promise<RecentProductData[]> { ): Promise<RecentProductData[]> {
const skus = await db.query.productSkus.findMany({ if (productIds.length === 0) return []
where: and(
inArray(productSkus.id, productIds), const skuChunks = await runBatched(db, productIds, 10, async (tx, chunk) => {
eq(productSkus.isSuspended, false) return tx.query.productSkus.findMany({
), where: and(
with: { inArray(productSkus.id, chunk),
product: true, eq(productSkus.isSuspended, false)
features: true, ),
}, with: {
orderBy: desc(productSkus.createdAt), product: true,
limit, features: true,
},
})
}) })
const skus = skuChunks
.flat()
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit)
return skus.map((sku) => { return skus.map((sku) => {
const features = sku.features || [] const features = sku.features || []
return { return {
@ -711,48 +723,49 @@ export interface OrderWithFullData {
export async function getOrdersByIdsWithFullData( export async function getOrdersByIdsWithFullData(
orderIds: number[] orderIds: number[]
): Promise<OrderWithFullData[]> { ): Promise<OrderWithFullData[]> {
console.log('getting orders byid') if (orderIds.length === 0) return []
const ordersResp = await db.query.orders.findMany({ const orderChunks = await runBatched(db, orderIds, 10, async (tx, chunk) => {
where: inArray(orders.id, orderIds), return tx.query.orders.findMany({
with: { where: inArray(orders.id, chunk),
address: { with: {
columns: { address: {
name: true, columns: {
addressLine1: true, name: true,
addressLine2: true, addressLine1: true,
city: true, addressLine2: true,
state: true, city: true,
pincode: true, state: true,
phone: true, pincode: true,
phone: true,
},
}, },
}, orderItems: {
orderItems: { with: {
with: { sku: {
sku: { columns: {
columns: { name: true,
name: true, price: true,
price: true, },
}, with: {
with: { product: {
product: { columns: { name: true },
columns: { name: true }, },
features: true,
}, },
features: true,
}, },
}, },
}, },
}, slot: {
slot: { columns: {
columns: { deliveryTime: true,
deliveryTime: true, },
}, },
}, },
}, })
}) })
// as Promise<OrderWithFullData[]>
return ordersResp as OrderWithFullData[]; return orderChunks.flat() as OrderWithFullData[]
} }
export interface OrderWithCancellationData extends OrderWithFullData { export interface OrderWithCancellationData extends OrderWithFullData {

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index' 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 { and, desc, eq, gt, sql } from 'drizzle-orm'
import type { UserProductDetailData, UserProductReview } from '@packages/shared' import type { UserProductDetailData, UserProductReview } from '@packages/shared'
@ -44,6 +44,25 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
) )
.orderBy(specialDeals.quantity) .orderBy(specialDeals.quantity)
const comboItemsData = await db.query.productCombos.findMany({
where: eq(productCombos.comboSkuId, skuId),
with: {
sku: { with: { product: true, features: true } },
},
})
const comboItems = comboItemsData.map((ci) => {
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 { return {
id: sku.id, id: sku.id,
name: product?.name ?? 'Unknown', name: product?.name ?? 'Unknown',
@ -69,6 +88,8 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
price: String(deal.price ?? '0'), price: String(deal.price ?? '0'),
validTill: deal.validTill, validTill: deal.validTill,
})), })),
productType: product?.productType ?? 'item',
comboItems,
} }
} }
@ -151,6 +172,8 @@ export async function createProductReview(
export interface ProductSummaryData { export interface ProductSummaryData {
id: number id: number
name: string name: string
skuId: number
skuName: string | null
shortDescription: string | null shortDescription: string | null
price: string price: string
marketPrice: string | null marketPrice: string | null
@ -158,48 +181,50 @@ export interface ProductSummaryData {
isOutOfStock: boolean isOutOfStock: boolean
unitShortNotation: string unitShortNotation: string
productQuantity: number productQuantity: number
features: { featureName: string; featureValue: string }[]
} }
export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSummaryData[]> { export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSummaryData[]> {
let productIds: number[] | null = null const taggedProductIdSet = new Set<number>()
// If tagId is provided, get products that have this tag // If tagId is provided, get product IDs that have this tag
if (tagId) { if (tagId) {
const taggedProducts = await db const taggedProducts = await db
.select({ productId: productTags.productId }) .select({ productId: productTags.productId })
.from(productTags) .from(productTags)
.where(eq(productTags.tagId, tagId)) .where(eq(productTags.tagId, tagId))
productIds = taggedProducts.map(tp => tp.productId) for (const tp of taggedProducts) {
} taggedProductIdSet.add(tp.productId)
}
let whereCondition = undefined
// Filter by product IDs if tag filtering is applied
if (productIds && productIds.length > 0) {
whereCondition = inArray(productSkus.productId, productIds)
} }
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
where: whereCondition,
with: { with: {
product: true, product: true,
features: true, features: true,
}, },
}) })
return skus.map((sku) => { return skus
const features = sku.features || [] .filter((sku) => {
return { if (!tagId) return true
id: sku.product?.id ?? 0, return taggedProductIdSet.has(sku.productId)
name: sku.product?.name ?? 'Unknown', })
skuId: sku.id, .map((sku) => {
skuName: sku.name ?? null, const features = sku.features || []
shortDescription: sku.product?.shortDescription ?? null, return {
price: String(sku.price ?? '0'), id: sku.product?.id ?? 0,
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, 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, images: sku.images,
isOutOfStock: sku.isOutOfStock, 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 })), features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })),
} }
}) })

View file

@ -1,6 +1,6 @@
import { db } from '../db/db_index' import { db } from '../db/db_index'
import { productInfo, productSkus, storeInfo } from '../db/schema' 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 { InferSelectModel } from 'drizzle-orm'
import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared' import type { UserStoreDetailData, UserStoreProductData, UserStoreSummaryData, StoreSummary } from '@packages/shared'
@ -12,72 +12,44 @@ const getStringArray = (value: unknown): string[] | null => {
} }
export async function getStoreSummaries(): Promise<UserStoreSummaryData[]> { export async function getStoreSummaries(): Promise<UserStoreSummaryData[]> {
// Count SKUs per store, filtering by suspended SKUs const storesData = await db.select({
const storesData = await db id: storeInfo.id,
.select({ name: storeInfo.name,
id: storeInfo.id, description: storeInfo.description,
name: storeInfo.name, imageUrl: storeInfo.imageUrl,
description: storeInfo.description, }).from(storeInfo)
imageUrl: storeInfo.imageUrl,
productCount: sql<number>`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 storesWithDetails = await Promise.all( const skus = await db.query.productSkus.findMany({
storesData.map(async (store) => { where: eq(productSkus.isSuspended, false),
let sampleProducts: any[] = [] with: { product: true },
// Get sample SKUs from this store orderBy: asc(productSkus.id),
if (store.productCount > 0) { })
const storeProductIds = await db
.select({ id: productInfo.id })
.from(productInfo)
.where(eq(productInfo.storeId, store.id))
const productIdArr = storeProductIds.map((p) => p.id) const skusByStore = new Map<number, typeof skus>()
if (productIdArr.length > 0) { for (const sku of skus) {
const skus = await db.query.productSkus.findMany({ const storeId = sku.product?.storeId
where: and( if (storeId == null) continue
inArray(productSkus.productId, productIdArr), if (!skusByStore.has(storeId)) skusByStore.set(storeId, [])
eq(productSkus.isSuspended, false) skusByStore.get(storeId)!.push(sku)
), }
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),
}))
}
}
return { return storesData.map((store) => {
id: store.id, const storeSkus = skusByStore.get(store.id) || []
name: store.name, const sampleProducts = storeSkus.slice(0, 3).map((sku) => ({
description: store.description ?? null, id: sku.id,
imageUrl: store.imageUrl ?? null, name: sku.product?.name ?? sku.name ?? 'Unknown',
productCount: store.productCount || 0, images: getStringArray(sku.images),
sampleProducts, }))
}
})
)
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<UserStoreDetailData | null> { export async function getStoreDetail(storeId: number): Promise<UserStoreDetailData | null> {

View file

@ -360,13 +360,13 @@ export interface AdminUnit {
fullName: string; fullName: string;
} }
export interface AdminSkuVariant { export interface AdminProductComboItem {
id: number skuId: number;
skuId: number skuName: string | null;
name: string features: AdminSkuFeature[];
value: string productName: string;
unitId: number | null images: string[] | null;
sortOrder: number price: string;
} }
export interface AdminSku { export interface AdminSku {
@ -383,7 +383,7 @@ export interface AdminSku {
flashPrice: string | null flashPrice: string | null
createdAt: Date createdAt: Date
features: AdminSkuFeature[] features: AdminSkuFeature[]
} comboItems: AdminProductComboItem[]
} }
export interface AdminProduct { export interface AdminProduct {
@ -394,6 +394,7 @@ export interface AdminProduct {
storeId: number | null; storeId: number | null;
incrementStep: number; incrementStep: number;
createdAt: Date; createdAt: Date;
productType: 'item' | 'combo';
} }
export interface AdminProductWithRelations extends AdminProduct { export interface AdminProductWithRelations extends AdminProduct {

View file

@ -286,6 +286,15 @@ export interface UserProductSpecialDeal {
validTill: Date; validTill: Date;
} }
export interface UserProductComboItem {
skuId: number;
skuName: string | null;
unitNotation: string;
productName: string;
images: string[] | null;
price: string;
}
export interface UserProductDetailData { export interface UserProductDetailData {
id: number; id: number;
name: string; name: string;
@ -303,6 +312,8 @@ export interface UserProductDetailData {
flashPrice: string | null; flashPrice: string | null;
deliverySlots: UserProductDeliverySlot[]; deliverySlots: UserProductDeliverySlot[];
specialDeals: UserProductSpecialDeal[]; specialDeals: UserProductSpecialDeal[];
productType: string;
comboItems: UserProductComboItem[];
} }
export interface UserProductDetail extends UserProductDetailData { export interface UserProductDetail extends UserProductDetailData {