diff --git a/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx b/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx index 3851941..43c45c4 100644 --- a/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx +++ b/apps/admin-ui/app/(drawer)/manage-orders/order-details/[id].tsx @@ -463,8 +463,8 @@ export default function OrderDetails() { {item.name} - {Number(item.quantity) * item.productSize} {item.unit} × ₹{item.price} - + {Number(item.quantity)} x {item.productSize}{item.unit} × ₹{item.price} + void } - {item.quantity * item.productSize } {item.unit} + {item.quantity} x {item.productSize}{item.unit} {item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name} diff --git a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx index e9ed777..eb39369 100644 --- a/apps/admin-ui/app/(drawer)/prices-overview/index.tsx +++ b/apps/admin-ui/app/(drawer)/prices-overview/index.tsx @@ -402,15 +402,6 @@ export default function PricesOverview() { /> - - Size - - - `v-${version}/${path}` +const buildAvailabilityPath = (version: number) => `av-${version}/${CACHE_FILENAMES.availability}` + +const buildSlotsPath = (version: number) => `slots/v-${version}/${CACHE_FILENAMES.slots}` + function constructCacheUrl(path: string, version: number): string { return `${getAssetsDomain()}${getApiCacheKey()}/${buildCachePath(path, version)}` } +function constructAvailabilityUrl(version: number): string { + return `${getAssetsDomain()}${buildAvailabilityPath(version)}` +} + +function constructSlotsUrl(version: number): string { + return `${getAssetsDomain()}${buildSlotsPath(version)}` +} + export interface CreateAllCacheFilesResult { cacheVersion: number products: string essentialConsts: string stores: string - slots: string + slotsVersion: number + availabilityVersion: number banners: string individualStores: string[] } @@ -37,14 +50,16 @@ export async function createAllCacheFiles(): Promise productsKey, essentialConstsKey, storesKey, - slotsKey, + slotsVersion, + availabilityVersion, bannersKey, individualStoreKeys, ] = await Promise.all([ createProductsFileInternal(cacheVersion), createEssentialConstsFileInternal(cacheVersion), createStoresFileInternal(cacheVersion), - createSlotsFileInternal(cacheVersion), + createSlotsCacheFile(), + createAvailabilityCacheFile(), createBannersFileInternal(cacheVersion), createAllStoresFilesInternal(cacheVersion), ]) @@ -56,7 +71,8 @@ export async function createAllCacheFiles(): Promise constructCacheUrl(CACHE_FILENAMES.products, cacheVersion), constructCacheUrl(CACHE_FILENAMES.essentialConsts, cacheVersion), constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion), - constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion), + constructSlotsUrl(slotsVersion), + constructAvailabilityUrl(availabilityVersion), constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion), ...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)), ] @@ -76,7 +92,8 @@ export async function createAllCacheFiles(): Promise products: productsKey, essentialConsts: essentialConstsKey, stores: storesKey, - slots: slotsKey, + slotsVersion, + availabilityVersion, banners: bannersKey, individualStores: individualStoreKeys, } @@ -98,6 +115,23 @@ async function createProductsFileInternal(version: number): Promise { } +export async function createAvailabilityCacheFile(): Promise { + const version = await incrementAvailabilityVersionNum() + const availabilityData = await scaffoldAvailability() + const jsonContent = JSON.stringify(availabilityData, null, 2) + const buffer = Buffer.from(jsonContent, 'utf-8') + const filePath = buildAvailabilityPath(version) + + console.log(filePath) + await imageUploadS3( + buffer, + 'application/json', + filePath + ) + + return version +} + async function createEssentialConstsFileInternal(version: number): Promise { const essentialConstsData = await scaffoldEssentialConsts() const jsonContent = JSON.stringify(essentialConstsData, null, 2) @@ -120,15 +154,21 @@ async function createStoresFileInternal(version: number): Promise { ) } -async function createSlotsFileInternal(version: number): Promise { +export async function createSlotsCacheFile(): Promise { + const version = await incrementSlotsVersionNum() const slotsData = await scaffoldSlotsWithProducts() const jsonContent = JSON.stringify(slotsData, null, 2) const buffer = Buffer.from(jsonContent, 'utf-8') - return await imageUploadS3( + const filePath = buildSlotsPath(version) + + console.log(filePath) + await imageUploadS3( buffer, 'application/json', - `${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.slots, version)}` + filePath ) + + return version } async function createBannersFileInternal(version: number): Promise { diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts index 111fac3..322fc09 100644 --- a/apps/backend/src/sqliteImporter.ts +++ b/apps/backend/src/sqliteImporter.ts @@ -240,6 +240,7 @@ export { // Store Helpers getAllBannersForCache, getAllProductsForCache, + getAvailabilityForCache, getAllStoresForCache, getAllDeliverySlotsForCache, getAllSpecialDealsForCache, diff --git a/apps/backend/src/stores/product-store.ts b/apps/backend/src/stores/product-store.ts index 73f7cd4..db04482 100644 --- a/apps/backend/src/stores/product-store.ts +++ b/apps/backend/src/stores/product-store.ts @@ -44,6 +44,7 @@ interface Product { productName: string images: string[] | null price: string + isOffer: boolean }> } @@ -250,6 +251,7 @@ export async function getAllProducts(): Promise { productName: ci.productName, images: ci.images ? scaffoldAssetUrl((ci.images as string[]) || []) : null, price: ci.price, + isOffer: ci.isOffer, })) products.push({ 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 8d99e3e..eac06d3 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { ApiError } from '@/src/lib/api-error' import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client' import { scheduleStoreInitialization } from '@/src/stores/store-initializer' +import { createAvailabilityCacheFile } from '@/src/lib/cloud_cache' import { getAllProducts as getAllProductsInDb, getProductById as getProductByIdInDb, @@ -196,6 +197,7 @@ export const productRouter = router({ images: z.array(z.string()).optional().default([]), isFlashAvailable: z.boolean().optional().default(false), flashPrice: z.number().optional().nullable(), + isOutOfStock: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), isSuspended: z.boolean().optional().default(false), @@ -225,6 +227,7 @@ export const productRouter = router({ images: sku.images.map((url) => extractKeyFromPresignedUrl(url)), isFlashAvailable: sku.isFlashAvailable, flashPrice: sku.flashPrice ?? null, + isOutOfStock: sku.isOutOfStock, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, isSuspended: sku.isSuspended, @@ -284,6 +287,7 @@ export const productRouter = router({ images: z.array(z.string()).optional().default([]), isFlashAvailable: z.boolean().optional().default(false), flashPrice: z.number().optional().nullable(), + isOutOfStock: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false), isSuspended: z.boolean().optional().default(false), @@ -315,6 +319,7 @@ export const productRouter = router({ images: sku.images.map((url) => extractKeyFromPresignedUrl(url)), isFlashAvailable: sku.isFlashAvailable, flashPrice: sku.flashPrice ?? null, + isOutOfStock: sku.isOutOfStock, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, isSuspended: sku.isSuspended, @@ -842,11 +847,13 @@ export const productRouter = router({ }; */ - if (result.invalidIds.length > 0) { - throw new ApiError(`Invalid product IDs: ${result.invalidIds.join(', ')}`, 400) - } + if (result.invalidIds.length > 0) { + throw new ApiError(`Invalid product IDs: ${result.invalidIds.join(', ')}`, 400) + } - await scheduleStoreInitialization() + await createAvailabilityCacheFile().catch((err) => { + console.error('Failed to regenerate availability cache after price update:', err) + }) return { message: `Updated prices for ${result.updatedCount} product(s)`, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts index c2e622c..7c7932d 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts @@ -6,6 +6,7 @@ import { getAppUrl } from "@/src/lib/env-exporter" // import redisClient from "@/src/lib/redis-client" // import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters" import { scheduleStoreInitialization } from '@/src/stores/store-initializer' +import { createSlotsCacheFile } from '@/src/lib/cloud_cache' import { getActiveSlotsWithProducts as getActiveSlotsWithProductsInDb, getActiveSlots as getActiveSlotsInDb, @@ -266,7 +267,9 @@ export const slotsRouter = router({ }; */ - await scheduleStoreInitialization() + await createSlotsCacheFile().catch((err) => { + console.error('Failed to regenerate slots cache after product update:', err) + }) return { message: result.message, @@ -360,8 +363,10 @@ export const slotsRouter = router({ }); */ - // Reinitialize stores to reflect changes (outside transaction) - await scheduleStoreInitialization() + // Regenerate slots cache file (availability/products stay as-is) + await createSlotsCacheFile().catch((error) => { + console.error('Failed to regenerate slots cache after slot create:', error) + }) // Fire and forget: cleanup stale product slot associations staleSlotsCleanup().catch((error) => { @@ -548,8 +553,10 @@ export const slotsRouter = router({ throw new ApiError('Slot not found', 404) } - // Reinitialize stores to reflect changes (outside transaction) - await scheduleStoreInitialization() + // Regenerate slots cache file (availability/products stay as-is) + await createSlotsCacheFile().catch((error) => { + console.error('Failed to regenerate slots cache after slot update:', error) + }) return result } @@ -587,8 +594,10 @@ export const slotsRouter = router({ throw new ApiError('Slot not found', 404) } - // Reinitialize stores to reflect changes - await scheduleStoreInitialization() + // Regenerate slots cache file (availability/products stay as-is) + await createSlotsCacheFile().catch((error) => { + console.error('Failed to regenerate slots cache after slot delete:', error) + }) return { message: 'Slot deleted successfully', @@ -736,7 +745,9 @@ export const slotsRouter = router({ throw new ApiError('Slot not found', 404) } - await scheduleStoreInitialization() + await createSlotsCacheFile().catch((error) => { + console.error('Failed to regenerate slots cache after capacity update:', error) + }) return result }), diff --git a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts index 69d9449..912d23c 100644 --- a/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts +++ b/apps/backend/src/trpc/apis/common-apis/common-trpc-index.ts @@ -4,6 +4,8 @@ import { getStoresSummary, healthCheck, getCacheVersion, + getAvailabilityVersionNum, + getSlotsVersionNum, } from '@/src/dbService' import type { StoresSummaryResponse } from '@packages/shared' import { polygon as turfPolygon, point as turfPoint } from '@turf/helpers'; @@ -21,6 +23,8 @@ const polygon = turfPolygon(mbnrGeoJson.features[0].geometry.coordinates); export async function scaffoldEssentialConsts() { const consts = await getAllConstValues(); const cacheVersion = await getCacheVersion() + const availabilityVersionNum = await getAvailabilityVersionNum() + const slotsVersionNum = await getSlotsVersionNum() return { freeDeliveryThreshold: consts[CONST_KEYS.freeDeliveryThreshold] ?? 200, @@ -40,6 +44,8 @@ export async function scaffoldEssentialConsts() { assetsDomain: getAssetsDomain(), apiCacheKey: getApiCacheKey(), cacheVersion, + availabilityVersionNum, + slotsVersionNum, }; } diff --git a/apps/backend/src/trpc/apis/common-apis/common.ts b/apps/backend/src/trpc/apis/common-apis/common.ts index 5f945b9..2f2cece 100644 --- a/apps/backend/src/trpc/apis/common-apis/common.ts +++ b/apps/backend/src/trpc/apis/common-apis/common.ts @@ -6,6 +6,7 @@ import { getAllSkusSummary as getAllSkusSummaryInDb, getAllTagsForCache, getAllTagProductMappings, + getAvailabilityForCache, } from '@/src/dbService' import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } from '@/src/lib/s3-client' import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store' @@ -45,18 +46,14 @@ export async function scaffoldProducts() { id: product.id, name: product.name, shortDescription: product.shortDescription, - price: parseFloat(product.price), - marketPrice: product.marketPrice ? parseFloat(product.marketPrice) : null, unit: product.unitNotation, unitNotation: product.unitNotation, incrementStep: product.incrementStep, productQuantity: product.productQuantity, storeId: product.store?.id || null, isOutOfStock: product.isOutOfStock, - isFlashAvailable: product.isFlashAvailable, nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, images: product.images, - flashPrice: product.flashPrice, productType: product.productType || 'item' }; }) @@ -93,6 +90,15 @@ export async function scaffoldProducts() { }; } +export async function scaffoldAvailability() { + const availability = await getAvailabilityForCache() + + return { + availability, + count: availability.length, + }; +} + export const commonRouter = router({ getDashboardTags: publicProcedure .query(async () => { diff --git a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts index a938534..169f3e5 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts @@ -17,28 +17,16 @@ export async function scaffoldSlotsWithProducts(): Promise ({ - id: product.id, - name: product.name, - isOutOfStock: product.isOutOfStock, - isFlashAvailable: product.isFlashAvailable, - })); - */ - return { - slots: validSlots, + slots: validSlots.map((slot) => ({ + id: slot.id, + deliveryTime: slot.deliveryTime, + freezeTime: slot.freezeTime, + products: (slot.products || []).map((product) => ({ + id: product.id, + images: product.images, + })), + })), productAvailability, count: validSlots.length, }; diff --git a/apps/backend/src/trpc/router.ts b/apps/backend/src/trpc/router.ts index f7ccecc..24cd8cd 100644 --- a/apps/backend/src/trpc/router.ts +++ b/apps/backend/src/trpc/router.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { adminRouter } from '@/src/trpc/apis/admin-apis/apis/admin-trpc-index' import { userRouter } from '@/src/trpc/apis/user-apis/apis/user-trpc-index' import { commonApiRouter } from '@/src/trpc/apis/common-apis/common-trpc-index' -import { scaffoldProducts } from './apis/common-apis/common'; +import { scaffoldProducts, scaffoldAvailability } from './apis/common-apis/common'; import { scaffoldStores, scaffoldStoreWithProducts } from './apis/user-apis/apis/stores'; import { scaffoldSlotsWithProducts } from './apis/user-apis/apis/slots'; import { scaffoldEssentialConsts } from './apis/common-apis/common-trpc-index'; @@ -26,6 +26,7 @@ export const appRouter = router({ export type AppRouter = typeof appRouter; export type AllProductsApiType = Awaited>; +export type AvailabilityApiType = Awaited>; export type StoresApiType = Awaited>; export type SlotsApiType = Awaited>; export type EssentialConstsApiType = Awaited>; diff --git a/apps/backend/wrangler-commands.md b/apps/backend/wrangler-commands.md index 5844c56..14927e8 100644 --- a/apps/backend/wrangler-commands.md +++ b/apps/backend/wrangler-commands.md @@ -49,10 +49,16 @@ and paste it ABOVE the child table's block. Then verify it loads cleanly: sqlite3 :memory: "PRAGMA foreign_keys=ON; BEGIN; .read dumps/.sql; COMMIT;" -This should exit 0 with no error. (Example already applied to `latest_1.sql`: -`product_info` was moved above `product_skus`.) +This should exit 0 with no error. (Example already applied to `latest_1.sql` and +`local_8_aug.sql`: `product_info` was moved above `product_skus`.) ## When to re-check After ANY new `wrangler d1 export`, especially once a migration that re-creates tables has been applied. This is a general trap, not specific to one dump. + +> The SKU-split migration re-creates these tables in this historical order: +> `product_skus`, `sku_features`, `product_market_stats`, `product_info`, +> `cart_items`, `order_items`, `product_combos`. Exports put `product_info` +> AFTER its child `product_skus` — every fresh export needs `product_info` +> moved above `product_skus` (or the whole chain checked) before local import. diff --git a/apps/backend/wrangler.dev.toml b/apps/backend/wrangler.dev.toml index bd1f08b..1048178 100644 --- a/apps/backend/wrangler.dev.toml +++ b/apps/backend/wrangler.dev.toml @@ -9,7 +9,7 @@ routes = [ [[d1_databases]] binding = "DB" database_name = "freshyo-backend-dev" -database_id = "0814d709-5278-4311-8978-c36c0f05875d" +database_id = "a976d1fb-c6a7-4948-b303-81c6433ed265" #database_name = "freshyo-dev" #database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" migrations_dir="../../packages/db_helper_sqlite/drizzle" diff --git a/apps/user-ui/components/ProductDetail.tsx b/apps/user-ui/components/ProductDetail.tsx index 1fde5da..9f4d6a1 100644 --- a/apps/user-ui/components/ProductDetail.tsx +++ b/apps/user-ui/components/ProductDetail.tsx @@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react'; import { View, ScrollView, Alert, Dimensions, FlatList, RefreshControl } from 'react-native'; import { useRouter } from 'expo-router'; import { ImageCarousel, tw, BottomDialog, useManualRefresh, useMarkDataFetchers, LoadingDialog, ImageUploader, MyText, MyTextInput, MyTouchableOpacity, Quantifier } from 'common-ui'; +import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import usePickImage from 'common-ui/src/components/use-pick-image'; import { theme } from 'common-ui/src/theme'; @@ -377,6 +378,54 @@ const ProductDetail: React.FC = ({ productId, isFlashDeliver + {/* Combo Items */} + {productDetail.productType === 'combo' && productDetail.comboItems && productDetail.comboItems.length > 0 && ( + + + + + + + Included Items + + + {productDetail.comboItems.map((comboItem, index) => ( + + + {comboItem.images?.[0] ? ( + + ) : ( + + )} + + + + + {comboItem.productName} + + {comboItem.isOffer && ( + + OFFER + + )} + + + {comboItem.unitNotation || comboItem.skuName || ''} + + + + ))} + + + )} + {/* Delivery Slots */} diff --git a/apps/user-ui/hooks/cart-query-hooks.tsx b/apps/user-ui/hooks/cart-query-hooks.tsx index 09b7003..ca8ac5e 100644 --- a/apps/user-ui/hooks/cart-query-hooks.tsx +++ b/apps/user-ui/hooks/cart-query-hooks.tsx @@ -1,4 +1,4 @@ -import { useAllProducts } from '@/src/hooks/prominent-api-hooks'; +import { useCentralProductStore } from '@/src/store/centralProductStore'; import { useCentralSlotStore } from '@/src/store/centralSlotStore'; import { Alert } from 'react-native'; import { useQuery, useMutation, useQueryClient, UseQueryResult, UseMutationResult } from '@tanstack/react-query'; @@ -184,7 +184,7 @@ const clearLocalCart = async (cartType: CartType = "regular"): Promise => }; export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = "regular"): UseGetCartReturn { - const { data: products } = useAllProducts(); + const productsById = useCentralProductStore((state) => state.productsById); const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap); const query: UseQueryResult = useQuery({ @@ -193,7 +193,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = const cartItems = await getLocalCart(cartType); const productMap: Record> = Object.fromEntries( - products?.products?.map((p) => [ + Object.values(productsById).map((p) => [ p.id, { id: p.id, @@ -206,7 +206,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = productQuantity: p.productQuantity, unitNotation: p.unitNotation, }, - ]) ?? [] + ]) ); const items: CartItem[] = cartItems @@ -236,7 +236,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = }; }, refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true, - enabled: (options?.enabled ?? true) && !!products, + enabled: (options?.enabled ?? true) && Object.keys(productsById).length > 0, }); return { diff --git a/apps/user-ui/src/hooks/prominent-api-hooks.ts b/apps/user-ui/src/hooks/prominent-api-hooks.ts index b6c23f4..aada9f8 100644 --- a/apps/user-ui/src/hooks/prominent-api-hooks.ts +++ b/apps/user-ui/src/hooks/prominent-api-hooks.ts @@ -1,7 +1,8 @@ +import React from 'react' import { useQuery } from '@tanstack/react-query' import axios from 'axios' import { trpc } from '@/src/trpc-client' -import { AllProductsApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, BannersApiType, StoreWithProductsApiType } from "@backend/trpc/router"; +import { AllProductsApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, BannersApiType, StoreWithProductsApiType, AvailabilityApiType } from "@backend/trpc/router"; import { CACHE_FILENAMES } from "@packages/shared"; // Local useGetEssentialConsts hook @@ -18,6 +19,19 @@ type SlotsResponse = SlotsApiType; type EssentialConstsResponse = EssentialConstsApiType; type BannersResponse = BannersApiType; type StoreWithProductsResponse = StoreWithProductsApiType; +type AvailabilityResponse = AvailabilityApiType; + +type BaseProduct = AllProductsApiType['products'][number] +type AvailabilityEntry = AvailabilityApiType['availability'][number] + +export type MergedProduct = BaseProduct & { + price: number + marketPrice: number | null + flashPrice: string | null + isFlashAvailable: boolean + isOutOfStock: boolean + isSuspended: boolean +} function useCacheUrl(filename: string): string | null { const { data: essentialConsts } = useGetEssentialConsts() @@ -33,11 +47,37 @@ function useCacheUrl(filename: string): string | null { return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}` } +function useAvailabilityCacheUrl(): string | null { + const { data: essentialConsts } = useGetEssentialConsts() + + const assetsDomain = essentialConsts?.assetsDomain + const availabilityVersionNum = essentialConsts?.availabilityVersionNum + + if (!assetsDomain || availabilityVersionNum === undefined || availabilityVersionNum === null) { + return null + } + + return `${assetsDomain}av-${availabilityVersionNum}/${CACHE_FILENAMES.availability}` +} + +function useSlotsCacheUrl(): string | null { + const { data: essentialConsts } = useGetEssentialConsts() + + const assetsDomain = essentialConsts?.assetsDomain + const slotsVersionNum = essentialConsts?.slotsVersionNum + + if (!assetsDomain || slotsVersionNum === undefined || slotsVersionNum === null) { + return null + } + + return `${assetsDomain}slots/v-${slotsVersionNum}/${CACHE_FILENAMES.slots}` +} + export function useAllProducts() { const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) + const { data: availabilityData } = useAvailability() - - return useQuery({ + const productsQuery = useQuery({ queryKey: ['all-products', cacheUrl], queryFn: async () => { if (!cacheUrl) { @@ -49,6 +89,57 @@ export function useAllProducts() { staleTime: 60000, // 1 minute enabled: !!cacheUrl, }) + + const mergedProducts = React.useMemo(() => { + const rawProducts = productsQuery.data?.products || [] + const availabilityById: Record = {} + availabilityData?.availability?.forEach((entry: AvailabilityEntry) => { + availabilityById[entry.id] = entry + }) + + return rawProducts.map((product) => { + const availability = availabilityById[product.id] + return { + ...product, + price: availability ? Number(availability.price) : 0, + marketPrice: availability?.marketPrice != null ? Number(availability.marketPrice) : null, + flashPrice: availability?.flashPrice ?? null, + isFlashAvailable: availability?.isFlashAvailable ?? false, + isOutOfStock: availability?.isOutOfStock ?? false, + isSuspended: availability?.isSuspended ?? false, + } + }) + }, [productsQuery.data, availabilityData]) + + const mergedData = React.useMemo(() => { + if (!productsQuery.data) return undefined + return { + ...productsQuery.data, + products: mergedProducts, + } as ProductsResponse & { products: MergedProduct[] } + }, [productsQuery.data, mergedProducts]) + + return { + ...productsQuery, + data: mergedData, + } +} + +export function useAvailability() { + const cacheUrl = useAvailabilityCacheUrl() + + return useQuery({ + queryKey: ['availability', cacheUrl], + queryFn: async () => { + if (!cacheUrl) { + throw new Error('Cache URL not available') + } + const response = await axios.get(cacheUrl) + return response.data + }, + staleTime: 60000, // 1 minute + enabled: !!cacheUrl, + }) } export function useStores() { @@ -69,7 +160,7 @@ export function useStores() { } export function useSlots() { - const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots) + const cacheUrl = useSlotsCacheUrl() return useQuery({ queryKey: ['slots', cacheUrl], diff --git a/apps/user-ui/src/store/centralProductStore.ts b/apps/user-ui/src/store/centralProductStore.ts index a1b1b59..d228eda 100644 --- a/apps/user-ui/src/store/centralProductStore.ts +++ b/apps/user-ui/src/store/centralProductStore.ts @@ -1,9 +1,8 @@ import { create } from 'zustand' import { useEffect } from 'react' -import { useAllProducts } from '@/src/hooks/prominent-api-hooks' -import { AllProductsApiType } from '@backend/trpc/router' +import { useAllProducts, type MergedProduct } from '@/src/hooks/prominent-api-hooks' -type Product = AllProductsApiType['products'][number] +export type Product = MergedProduct interface CentralProductState { products: Product[] diff --git a/apps/user-ui/src/store/centralSlotStore.ts b/apps/user-ui/src/store/centralSlotStore.ts index 483c680..fa72172 100644 --- a/apps/user-ui/src/store/centralSlotStore.ts +++ b/apps/user-ui/src/store/centralSlotStore.ts @@ -1,22 +1,24 @@ import { create } from 'zustand'; -import { useSlots } from '@/src/hooks/prominent-api-hooks'; +import { useSlots, useAvailability } from '@/src/hooks/prominent-api-hooks'; import { useEffect } from 'react'; -import { SlotsApiType } from "@backend/trpc/router"; +import { SlotsApiType, AvailabilityApiType } from "@backend/trpc/router"; type Slot = SlotsApiType['slots'][number]; type ProductAvailability = SlotsApiType['productAvailability'][number]; +type AvailabilityEntry = AvailabilityApiType['availability'][number]; interface ProductSlotInfo { slots: Slot[]; isOutOfStock: boolean; isFlashAvailable: boolean; + isSuspended: boolean; } interface CentralSlotState { slots: Slot[]; productSlotsMap: Record; refetchSlots: (() => Promise) | null; - setSlotsData: (slots: Slot[], productAvailability: ProductAvailability[]) => void; + setSlotsData: (slots: Slot[], productAvailability: ProductAvailability[], availability: AvailabilityEntry[]) => void; clearSlotsData: () => void; setRefetchSlots: (refetch: () => Promise) => void; } @@ -25,15 +27,20 @@ export const useCentralSlotStore = create((set) => ({ slots: [], productSlotsMap: {}, refetchSlots: null, - setSlotsData: (slots, productAvailability) => { + setSlotsData: (slots, productAvailability, availability) => { const productSlotsMap: Record = {}; + const availabilityById: Record = {}; + availability.forEach((entry) => { + availabilityById[entry.id] = entry; + }); // First, create entries for ALL products from productAvailability productAvailability.forEach((product) => { productSlotsMap[product.id] = { slots: [], - isOutOfStock: product.isOutOfStock, - isFlashAvailable: product.isFlashAvailable, + isOutOfStock: availabilityById[product.id]?.isOutOfStock ?? false, + isFlashAvailable: availabilityById[product.id]?.isFlashAvailable ?? false, + isSuspended: availabilityById[product.id]?.isSuspended ?? false, }; }); @@ -54,14 +61,15 @@ export const useCentralSlotStore = create((set) => ({ export function useInitializeCentralSlotStore() { const { data: slotsData, refetch } = useSlots(); + const { data: availabilityData } = useAvailability(); const setSlotsData = useCentralSlotStore((state) => state.setSlotsData); const setRefetchSlots = useCentralSlotStore((state) => state.setRefetchSlots); useEffect(() => { if (slotsData?.slots) { - setSlotsData(slotsData.slots, slotsData.productAvailability || []); + setSlotsData(slotsData.slots, slotsData.productAvailability || [], availabilityData?.availability || []); } - }, [slotsData, setSlotsData]); + }, [slotsData, availabilityData, setSlotsData]); useEffect(() => { setRefetchSlots(async () => { diff --git a/apps/web-ui/src/components/AddToCartDialog.tsx b/apps/web-ui/src/components/AddToCartDialog.tsx index 6477f83..c1de297 100644 --- a/apps/web-ui/src/components/AddToCartDialog.tsx +++ b/apps/web-ui/src/components/AddToCartDialog.tsx @@ -4,6 +4,7 @@ import { BottomDialog, p, div, Quantifier } from 'web-components' import { useSlots } from '../hooks/prominent-api-hooks' import { useAddToCart, useUpdateCartItem, useRemoveFromCart, useGetCart } from '../hooks/cart-query-hooks' import { useCartStore } from '../lib/stores/cart-store' +import { useCentralSlotStore } from '../lib/stores/central-slot-store' import { ShoppingCart, Truck, Zap, X } from 'lucide-react' import dayjs from 'dayjs' @@ -29,6 +30,7 @@ export default function AddToCartDialog() { const { data: slotsData } = useSlots() const { data: cartData } = useGetCart() + const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap) const isFlashDeliveryEnabled = true const addToCart = useAddToCart('regular') @@ -76,7 +78,7 @@ export default function AddToCartDialog() { const cartItem = cartData?.items?.find((item: any) => item.productId === product?.id) const isUpdate = (cartItem?.quantity || 0) >= 1 - const productAvailability = slotsData?.productAvailability?.find((pa: any) => pa.id === product?.id) + const productAvailability = productSlotsMap[product?.id] const showFlashOption = productAvailability?.isFlashAvailable === true && isFlashDeliveryEnabled const handleAddToCart = () => { diff --git a/apps/web-ui/src/hooks/prominent-api-hooks.ts b/apps/web-ui/src/hooks/prominent-api-hooks.ts index 820ec54..fba16bb 100644 --- a/apps/web-ui/src/hooks/prominent-api-hooks.ts +++ b/apps/web-ui/src/hooks/prominent-api-hooks.ts @@ -1,8 +1,10 @@ +import { useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import axios from 'axios' import { trpc } from '../lib/trpc-client' import type { AllProductsApiType, + AvailabilityApiType, StoresApiType, SlotsApiType, EssentialConstsApiType, @@ -23,6 +25,17 @@ type StoresResponse = StoresApiType type SlotsResponse = SlotsApiType type BannersResponse = BannersApiType type StoreWithProductsResponse = StoreWithProductsApiType +type AvailabilityResponse = AvailabilityApiType + +type BaseProduct = AllProductsApiType['products'][number] +type AvailabilityEntry = AvailabilityApiType['availability'][number] + +export type MergedProduct = BaseProduct & { + price: number + marketPrice: number | null + flashPrice: string | null + isFlashAvailable: boolean +} function useCacheUrl(filename: string): string | null { const { data: essentialConsts } = useGetEssentialConsts() @@ -43,10 +56,37 @@ function useCacheUrl(filename: string): string | null { return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}` } +function useAvailabilityCacheUrl(): string | null { + const { data: essentialConsts } = useGetEssentialConsts() + + const assetsDomain = essentialConsts?.assetsDomain + const availabilityVersionNum = essentialConsts?.availabilityVersionNum + + if (!assetsDomain || availabilityVersionNum === undefined || availabilityVersionNum === null) { + return null + } + + return `${assetsDomain}av-${availabilityVersionNum}/${CACHE_FILENAMES.availability}` +} + +function useSlotsCacheUrl(): string | null { + const { data: essentialConsts } = useGetEssentialConsts() + + const assetsDomain = essentialConsts?.assetsDomain + const slotsVersionNum = essentialConsts?.slotsVersionNum + + if (!assetsDomain || slotsVersionNum === undefined || slotsVersionNum === null) { + return null + } + + return `${assetsDomain}slots/v-${slotsVersionNum}/${CACHE_FILENAMES.slots}` +} + export function useAllProducts() { const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) + const { data: availabilityData } = useAvailability() - return useQuery({ + const productsQuery = useQuery({ queryKey: ['all-products', cacheUrl], queryFn: async () => { if (!cacheUrl) { @@ -58,6 +98,55 @@ export function useAllProducts() { staleTime: 60000, enabled: !!cacheUrl, }) + + const mergedProducts = useMemo(() => { + const rawProducts = productsQuery.data?.products || [] + const availabilityById: Record = {} + availabilityData?.availability?.forEach((entry: AvailabilityEntry) => { + availabilityById[entry.id] = entry + }) + + return rawProducts.map((product) => { + const availability = availabilityById[product.id] + return { + ...product, + price: availability ? Number(availability.price) : 0, + marketPrice: availability?.marketPrice != null ? Number(availability.marketPrice) : null, + flashPrice: availability?.flashPrice ?? null, + isFlashAvailable: availability?.isFlashAvailable ?? false, + } + }) + }, [productsQuery.data, availabilityData]) + + const mergedData = useMemo(() => { + if (!productsQuery.data) return undefined + return { + ...productsQuery.data, + products: mergedProducts, + } as ProductsResponse & { products: MergedProduct[] } + }, [productsQuery.data, mergedProducts]) + + return { + ...productsQuery, + data: mergedData, + } +} + +export function useAvailability() { + const cacheUrl = useAvailabilityCacheUrl() + + return useQuery({ + queryKey: ['availability', cacheUrl], + queryFn: async () => { + if (!cacheUrl) { + throw new Error('Cache URL not available') + } + const response = await axios.get(cacheUrl) + return response.data + }, + staleTime: 60000, + enabled: !!cacheUrl, + }) } export function useStores() { @@ -78,7 +167,7 @@ export function useStores() { } export function useSlots() { - const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots) + const cacheUrl = useSlotsCacheUrl() return useQuery({ queryKey: ['slots', cacheUrl], diff --git a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql index 2d30e73..84ef397 100644 --- a/packages/db_helper_sqlite/drizzle/0002_sku_split.sql +++ b/packages/db_helper_sqlite/drizzle/0002_sku_split.sql @@ -9,13 +9,7 @@ CREATE TABLE `product_skus` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `product_id` integer NOT NULL, `name` text, - `price` text NOT NULL, - `market_price` text, `images` text, - `is_out_of_stock` integer DEFAULT false NOT NULL, - `is_suspended` integer DEFAULT false NOT NULL, - `is_flash_available` integer DEFAULT false NOT NULL, - `flash_price` text, `is_offer` integer DEFAULT false NOT NULL, `is_combo_only` integer DEFAULT false NOT NULL, `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, @@ -34,19 +28,12 @@ CREATE UNIQUE INDEX `unique_sku_feature_name` ON `sku_features` (`sku_id`,`featu -- 2. Migrate each existing product into one default SKU plus a mandatory quantity feature. INSERT INTO `product_skus` ( - `product_id`, `name`, `price`, `market_price`, `images`, `is_out_of_stock`, - `is_suspended`, `is_flash_available`, `flash_price`, `created_at` + `product_id`, `name`, `images`, `created_at` ) SELECT `id`, NULL, - `price`, - `market_price`, `images`, - `is_out_of_stock`, - `is_suspended`, - `is_flash_available`, - `flash_price`, `created_at` FROM `product_info`; @@ -59,6 +46,33 @@ FROM `product_info` `pi` JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id` LEFT JOIN `units` `u` ON `u`.`id` = `pi`.`unit_id`; +-- 2b. Create product_market_stats to hold pricing/flash/stock per SKU, and backfill it. +CREATE TABLE `product_market_stats` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `sku_id` integer NOT NULL, + `market_price` text, + `our_price` text NOT NULL, + `is_flash_available` integer DEFAULT false NOT NULL, + `flash_price` text, + `is_out_of_stock` integer DEFAULT false NOT NULL, + `is_suspended` integer DEFAULT false NOT NULL, + FOREIGN KEY (`sku_id`) REFERENCES `product_skus`(`id`) ON UPDATE no action ON DELETE no action +); + +CREATE UNIQUE INDEX `product_market_stats_sku_id_unique` ON `product_market_stats` (`sku_id`); + +INSERT INTO `product_market_stats` (`sku_id`, `market_price`, `our_price`, `is_flash_available`, `flash_price`, `is_out_of_stock`, `is_suspended`) +SELECT + `ps`.`id`, + `pi`.`market_price`, + `pi`.`price`, + `pi`.`is_flash_available`, + `pi`.`flash_price`, + `pi`.`is_out_of_stock`, + `pi`.`is_suspended` +FROM `product_info` `pi` +JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id`; + -- 3. Build a product_id -> sku_id mapping for downstream tables. CREATE TABLE `__product_to_sku` ( `product_id` integer PRIMARY KEY, diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts index bc6aad0..813cb73 100644 --- a/packages/db_helper_sqlite/index.ts +++ b/packages/db_helper_sqlite/index.ts @@ -32,6 +32,10 @@ export { upsertConstants, getCacheVersion, incrementCacheVersion, + getAvailabilityVersionNum, + incrementAvailabilityVersionNum, + getSlotsVersionNum, + incrementSlotsVersionNum, } from './src/admin-apis/const' export { @@ -314,12 +318,14 @@ export { type BannerData, // Product Store getAllProductsForCache, + getAvailabilityForCache, getAllStoresForCache, getAllDeliverySlotsForCache, getAllSpecialDealsForCache, getAllProductTagsForCache, getAllProductCombosForCache, type ProductBasicData, + type AvailabilityCacheData, type StoreBasicData, type DeliverySlotData, type SpecialDealData, diff --git a/packages/db_helper_sqlite/src/admin-apis/const.ts b/packages/db_helper_sqlite/src/admin-apis/const.ts index 6ffd507..194a34a 100644 --- a/packages/db_helper_sqlite/src/admin-apis/const.ts +++ b/packages/db_helper_sqlite/src/admin-apis/const.ts @@ -76,3 +76,69 @@ export async function incrementCacheVersion(): Promise { return nextValue }) } + +const AVAILABILITY_VERSION_KEY = CONST_KEYS.availabilityVersionNum + +export async function getAvailabilityVersionNum(): Promise { + const record = await db.query.keyValStore.findFirst({ + where: eq(keyValStore.key, AVAILABILITY_VERSION_KEY), + columns: { value: true }, + }) + + return record ? parseCacheVersion(record.value) : 0 +} + +export async function incrementAvailabilityVersionNum(): Promise { + return db.transaction(async (tx) => { + const existing = await tx.query.keyValStore.findFirst({ + where: eq(keyValStore.key, AVAILABILITY_VERSION_KEY), + columns: { value: true }, + }) + + const nextValue = parseCacheVersion(existing?.value) + 1 + + if (existing) { + await tx.update(keyValStore) + .set({ value: nextValue +'' }) + .where(eq(keyValStore.key, AVAILABILITY_VERSION_KEY)) + } else { + await tx.insert(keyValStore) + .values({ key: AVAILABILITY_VERSION_KEY, value: nextValue+'' }) + } + + return nextValue + }) +} + +const SLOTS_VERSION_KEY = CONST_KEYS.slotsVersionNum + +export async function getSlotsVersionNum(): Promise { + const record = await db.query.keyValStore.findFirst({ + where: eq(keyValStore.key, SLOTS_VERSION_KEY), + columns: { value: true }, + }) + + return record ? parseCacheVersion(record.value) : 0 +} + +export async function incrementSlotsVersionNum(): Promise { + return db.transaction(async (tx) => { + const existing = await tx.query.keyValStore.findFirst({ + where: eq(keyValStore.key, SLOTS_VERSION_KEY), + columns: { value: true }, + }) + + const nextValue = parseCacheVersion(existing?.value) + 1 + + if (existing) { + await tx.update(keyValStore) + .set({ value: nextValue +'' }) + .where(eq(keyValStore.key, SLOTS_VERSION_KEY)) + } else { + await tx.insert(keyValStore) + .values({ key: SLOTS_VERSION_KEY, value: nextValue+'' }) + } + + return nextValue + }) +} diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts index 6d75c13..fa833f9 100644 --- a/packages/db_helper_sqlite/src/admin-apis/product.ts +++ b/packages/db_helper_sqlite/src/admin-apis/product.ts @@ -1,7 +1,7 @@ -// @ts-nocheck -- temporary: file partially updated for SKU migration; remaining functions to be fixed later import { db } from '../db/db_index' import { productInfo, + productMarketStats, productSkus, skuFeatures, productCombos, @@ -45,7 +45,42 @@ import type { type ProductRow = InferSelectModel type SkuRow = InferSelectModel +type MarketStatsRow = InferSelectModel type SkuFeatureRow = InferSelectModel + +interface CreateSkuFeatureInput { + featureName?: string | null + featureValue: string +} + +interface CreateComboItemInput { + skuId: number +} + +interface CreateSkuInput { + name?: string | null + price: number + marketPrice?: number | null + images?: string[] | null + isFlashAvailable?: boolean + flashPrice?: number | null + isOutOfStock?: boolean + isSuspended?: boolean + isOffer?: boolean + isComboOnly?: boolean + features: CreateSkuFeatureInput[] + comboItems?: CreateComboItemInput[] +} + +interface CreateProductInput { + name: string + shortDescription?: string | null + longDescription?: string | null + storeId?: number | null + incrementStep?: number + productType?: 'item' | 'combo' + skus: CreateSkuInput[] +} type UnitRow = InferSelectModel type StoreRow = InferSelectModel type SpecialDealRow = InferSelectModel @@ -94,18 +129,23 @@ const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({ featureValue: feature.featureValue, }) -const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = [], comboItems: any[] = []): AdminSku => ({ +const mapSku = ( + sku: SkuRow, + features: SkuFeatureRow[] = [], + comboItems: any[] = [], + marketStats: MarketStatsRow | null = null +): AdminSku => ({ id: sku.id, productId: sku.productId, name: sku.name ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, images: getStringArray(sku.images), imageKeys: getStringArray(sku.images), - isOutOfStock: sku.isOutOfStock, - isSuspended: sku.isSuspended, - isFlashAvailable: sku.isFlashAvailable, - flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + isOutOfStock: marketStats?.isOutOfStock ?? false, + isSuspended: marketStats?.isSuspended ?? false, + isFlashAvailable: marketStats?.isFlashAvailable ?? false, + flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, isOffer: sku.isOffer, isComboOnly: sku.isComboOnly, createdAt: sku.createdAt, @@ -134,7 +174,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, @@ -143,9 +183,10 @@ export async function getAllProducts(): Promise { skus: { with: { features: true, + marketStats: true, comboItems: { with: { - sku: { with: { product: true, features: true } }, + sku: { with: { product: true, features: true, marketStats: true } }, }, }, }, @@ -163,9 +204,9 @@ export async function getAllProducts(): Promise { 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'), + price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0', })) - return mapSku(sku, sku.features, comboItems) + return mapSku(sku, sku.features, comboItems, sku.marketStats) }), })) } @@ -178,9 +219,10 @@ export async function getProductById(id: number): Promise ({ skuId: ci.skuId, skuName: ci.sku?.name ?? null, - features: (ci.sku?.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), + 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'), + price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0', })) - return mapSku(sku, sku.features, comboItems) + return mapSku(sku, sku.features, comboItems, sku.marketStats) }) return { @@ -272,20 +314,26 @@ export async function createProduct(input: CreateProductInput): Promise ({ productId: product.id, name: sku.name ?? null, - price: String(sku.price), - marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, images: sku.images ?? null, - isFlashAvailable: sku.isFlashAvailable ?? false, - flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, isOffer: sku.isOffer ?? false, isComboOnly: sku.isComboOnly ?? false, - isSuspended: sku.isSuspended ?? false, })) ).returning() for (let i = 0; i < skuRows.length; i++) { const skuRow = skuRows[i] const sku = skus[i] + + await db.insert(productMarketStats).values({ + skuId: skuRow.id, + marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null, + ourPrice: sku.price != null ? String(sku.price) : '0', + isFlashAvailable: sku.isFlashAvailable ?? false, + flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null, + isOutOfStock: sku.isOutOfStock ?? false, + isSuspended: sku.isSuspended ?? false, + }) + await db.insert(skuFeatures).values( sku.features.map((f) => ({ skuId: skuRow.id, @@ -306,13 +354,13 @@ export async function createProduct(input: CreateProductInput): Promise mapSku(s, s.features)), + skus: createdSkus.map((s) => mapSku(s, s.features, [], s.marketStats)), } } @@ -368,11 +416,11 @@ export async function updateProduct(id: number, input: any): Promise c.comboSkuId))) if (comboIds.length > 0) { - const combos = await db.query.productSkus.findMany({ - where: inArray(productSkus.id, comboIds), - columns: { id: true, isSuspended: true }, + const combos = await db.query.productMarketStats.findMany({ + where: inArray(productMarketStats.skuId, comboIds), + columns: { skuId: true, isSuspended: true }, }) - const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.id) + const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.skuId) if (activeComboIds.length > 0) { throw new Error( `Cannot suspend SKU(s): they are members of combo(s) ${activeComboIds.join(', ')} that are not suspended` @@ -387,17 +435,35 @@ export async function updateProduct(id: number, input: any): Promise ({ @@ -421,16 +487,21 @@ export async function updateProduct(id: number, input: any): Promise ({ skuId: newSku.id, @@ -447,7 +518,7 @@ export async function updateProduct(id: number, input: any): Promise mapSku(s, s.features)), + skus: updatedProduct.skus.map((s) => mapSku(s, s.features, [], s.marketStats)), } } @@ -898,15 +969,32 @@ export async function updateProductPrices(updates: Array<{ const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update const updateData: any = {} - if (price !== undefined) updateData.price = price.toString() + if (price !== undefined) updateData.ourPrice = 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 - await tx - .update(productSkus) - .set(updateData) - .where(eq(productSkus.id, productId)) + if (Object.keys(updateData).length === 0) continue + + const existingMarketStats = await tx.query.productMarketStats.findFirst({ + where: eq(productMarketStats.skuId, productId), + columns: { id: true }, + }) + + if (existingMarketStats) { + await tx + .update(productMarketStats) + .set(updateData) + .where(eq(productMarketStats.skuId, productId)) + } else { + await tx.insert(productMarketStats).values({ + skuId: productId, + ourPrice: updateData.ourPrice ?? '0', + marketPrice: updateData.marketPrice ?? null, + flashPrice: updateData.flashPrice ?? null, + isFlashAvailable: updateData.isFlashAvailable ?? false, + }) + } } }) @@ -956,7 +1044,7 @@ export interface CreateSpecialDealInput { } export async function createSpecialDealsForSku( - productId: number, + skuId: number, deals: CreateSpecialDealInput[] ): Promise { if (deals.length === 0) { @@ -964,7 +1052,7 @@ export async function createSpecialDealsForSku( } const dealInserts = deals.map((deal) => ({ - productId, + skuId, quantity: deal.quantity.toString(), price: deal.price.toString(), validTill: new Date(deal.validTill), @@ -1025,7 +1113,7 @@ export async function updateSkuDeals( if (dealsToAdd.length > 0) { const dealInserts = dealsToAdd.map((deal) => ({ - productId, + skuId: productId, quantity: deal.quantity.toString(), price: deal.price.toString(), validTill: new Date(deal.validTill), diff --git a/packages/db_helper_sqlite/src/db/schema.ts b/packages/db_helper_sqlite/src/db/schema.ts index eeb6c1e..a767840 100644 --- a/packages/db_helper_sqlite/src/db/schema.ts +++ b/packages/db_helper_sqlite/src/db/schema.ts @@ -201,18 +201,23 @@ export const productSkus = sqliteTable('product_skus', { id: integer().primaryKey({ autoIncrement: true }), productId: integer('product_id').notNull().references(() => productInfo.id), name: text(), - price: numericText('price').notNull(), - marketPrice: numericText('market_price'), images: jsonText('images'), - isOutOfStock: integer('is_out_of_stock', { mode: 'boolean' }).notNull().default(false), - isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false), - isFlashAvailable: integer('is_flash_available', { mode: 'boolean' }).notNull().default(false), - flashPrice: numericText('flash_price'), isOffer: integer('is_offer', { mode: 'boolean' }).notNull().default(false), isComboOnly: integer('is_combo_only', { mode: 'boolean' }).notNull().default(false), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), }) +export const productMarketStats = sqliteTable('product_market_stats', { + id: integer().primaryKey({ autoIncrement: true }), + skuId: integer('sku_id').notNull().references(() => productSkus.id).unique(), + marketPrice: numericText('market_price'), + ourPrice: numericText('our_price').notNull(), + isFlashAvailable: integer('is_flash_available', { mode: 'boolean' }).notNull().default(false), + flashPrice: numericText('flash_price'), + isOutOfStock: integer('is_out_of_stock', { mode: 'boolean' }).notNull().default(false), + isSuspended: integer('is_suspended', { mode: 'boolean' }).notNull().default(false), +}) + export const skuFeatures = sqliteTable('sku_features', { id: integer().primaryKey({ autoIncrement: true }), skuId: integer('sku_id').notNull().references(() => productSkus.id), @@ -590,6 +595,7 @@ export const productInfoRelations = relations(productInfo, ({ one, many }) => ({ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({ product: one(productInfo, { fields: [productSkus.productId], references: [productInfo.id] }), features: many(skuFeatures), + marketStats: one(productMarketStats), specialDeals: many(specialDeals), orderItems: many(orderItems), cartItems: many(cartItems), @@ -597,6 +603,10 @@ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({ comboItems: many(productCombos, { relationName: 'comboSku' }), })) +export const productMarketStatsRelations = relations(productMarketStats, ({ one }) => ({ + sku: one(productSkus, { fields: [productMarketStats.skuId], references: [productSkus.id] }), +})) + export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({ sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }), })) diff --git a/packages/db_helper_sqlite/src/lib/const-keys.ts b/packages/db_helper_sqlite/src/lib/const-keys.ts index 60e81e2..2c61b25 100644 --- a/packages/db_helper_sqlite/src/lib/const-keys.ts +++ b/packages/db_helper_sqlite/src/lib/const-keys.ts @@ -13,6 +13,8 @@ export const CONST_KEYS = { readableOrderId: 'readableOrderId', versionNum: 'versionNum', cacheVersion: 'cache_version', + availabilityVersionNum: 'availability_version_num', + slotsVersionNum: 'slots_version_num', playStoreUrl: 'playStoreUrl', appStoreUrl: 'appStoreUrl', popularItems: 'popularItems', @@ -37,6 +39,8 @@ export const CONST_LABELS: Record = { readableOrderId: 'Readable Order ID', versionNum: 'Version Number', 'cache_version': 'Cache Version', + availability_version_num: 'Availability Cache Version', + slots_version_num: 'Slots Cache Version', playStoreUrl: 'Play Store URL', appStoreUrl: 'App Store URL', popularItems: 'Popular Items', @@ -67,6 +71,8 @@ export const CONST_TYPES: Record = { readableOrderId: 'number', versionNum: 'string', 'cache_version': 'number', + availability_version_num: 'number', + slots_version_num: 'number', playStoreUrl: 'string', appStoreUrl: 'string', popularItems: 'string', @@ -91,6 +97,8 @@ export const CONST_VISIBILITY: Record = { readableOrderId: false, versionNum: true, 'cache_version': false, + availability_version_num: false, + slots_version_num: false, playStoreUrl: true, appStoreUrl: true, popularItems: true, diff --git a/packages/db_helper_sqlite/src/stores/store-helpers.ts b/packages/db_helper_sqlite/src/stores/store-helpers.ts index 1404b17..03758b6 100644 --- a/packages/db_helper_sqlite/src/stores/store-helpers.ts +++ b/packages/db_helper_sqlite/src/stores/store-helpers.ts @@ -5,6 +5,7 @@ import { db } from '../db/db_index' import { homeBanners, productInfo, + productMarketStats, productSkus, skuFeatures, deliverySlotInfo, @@ -61,6 +62,16 @@ export interface ProductBasicData { productType: string } +export interface AvailabilityCacheData { + id: number + price: string + marketPrice: string | null + flashPrice: string | null + isFlashAvailable: boolean + isOutOfStock: boolean + isSuspended: boolean +} + export interface StoreBasicData { id: number name: string @@ -107,15 +118,18 @@ export interface ProductTagData { export async function getAllProductsForCache(): Promise { const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), with: { product: true, features: true, + marketStats: true, }, }) - return skus.map((sku) => { + return skus + .filter((sku) => !sku.marketStats?.isSuspended) + .map((sku) => { const features = sku.features || [] + const marketStats = sku.marketStats return { id: sku.id, productId: sku.productId, @@ -123,21 +137,37 @@ export async function getAllProductsForCache(): Promise { skuName: sku.name ?? null, shortDescription: sku.product?.shortDescription ?? null, longDescription: sku.product?.longDescription ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, images: sku.images, - isOutOfStock: sku.isOutOfStock, + isOutOfStock: marketStats?.isOutOfStock ?? false, storeId: sku.product?.storeId ?? null, unitNotation: composeUnitNotation(features), incrementStep: sku.product?.incrementStep ?? 1, productQuantity: 1, - isFlashAvailable: sku.isFlashAvailable, - flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, + isFlashAvailable: marketStats?.isFlashAvailable ?? false, + flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null, productType: sku.product?.productType ?? 'item', } }) } +export async function getAvailabilityForCache(): Promise { + const stats = await db.query.productMarketStats.findMany({}) + + return stats + .filter((stat) => !stat.isSuspended) + .map((stat) => ({ + id: stat.skuId, + price: stat.ourPrice ? String(stat.ourPrice) : '0', + marketPrice: stat.marketPrice ? String(stat.marketPrice) : null, + flashPrice: stat.flashPrice ? String(stat.flashPrice) : null, + isFlashAvailable: stat.isFlashAvailable, + isOutOfStock: stat.isOutOfStock, + isSuspended: stat.isSuspended, + })) +} + export async function getAllStoresForCache(): Promise { return db.query.storeInfo.findMany({ columns: { id: true, name: true, description: true }, @@ -192,20 +222,21 @@ export interface ProductComboCacheData { images: unknown unitNotation: string price: string + isOffer: boolean } export async function getAllProductCombosForCache(): Promise { const results = await db.query.productCombos.findMany({ with: { - sku: { with: { product: true, features: true } }, + sku: { with: { product: true, features: true, marketStats: true } }, }, }) const suspendedSkuIds = new Set( (await db - .select({ id: productSkus.id }) - .from(productSkus) - .where(eq(productSkus.isSuspended, true))).map((r) => r.id) + .select({ id: productMarketStats.skuId }) + .from(productMarketStats) + .where(eq(productMarketStats.isSuspended, true))).map((r) => r.id) ) return results @@ -219,7 +250,8 @@ export async function getAllProductCombosForCache(): Promise 0) { skusData = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), with: { product: { with: { store: true }, }, features: true, + marketStats: true, }, }) - skusData = skusData.filter((item: any) => skuIdSet.has(item.id)) + skusData = skusData.filter((item: any) => skuIdSet.has(item.id) && !item.marketStats?.isSuspended) } const skuMap = new Map(skusData.map((s: any) => [s.id, s])) @@ -341,6 +373,7 @@ export async function getAllSlotsWithProductsForCache(): Promise => p != null) .map((sku: any) => { const features = sku.features || [] + const marketStats = sku.marketStats return { id: sku.id, productId: sku.productId, @@ -348,8 +381,8 @@ export async function getAllSlotsWithProductsForCache(): Promise { export async function getProductDetailById(skuId: number): Promise { const sku = await db.query.productSkus.findFirst({ - where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)), + where: eq(productSkus.id, skuId), with: { product: true, features: true, + marketStats: true, }, }) if (!sku) { return null } + if (sku.marketStats?.isSuspended) { + return null + } const features = sku.features || [] const product = sku.product + const marketStats = sku.marketStats const storeData = product?.storeId ? await db.query.storeInfo.findFirst({ where: eq(storeInfo.id, product.storeId), @@ -48,7 +53,7 @@ export async function getProductDetailById(skuId: number): Promise ({ quantity: String(deal.quantity ?? '0'), @@ -202,30 +208,32 @@ export async function getAllProductsWithUnits(tagId?: number): Promise { + if (sku.marketStats?.isSuspended) return false if (!tagId) return true return taggedProductIdSet.has(sku.productId) }) .map((sku) => { const features = sku.features || [] + const marketStats = sku.marketStats return { id: sku.product?.id ?? 0, name: composeSkuName(sku.product?.name ?? 'Unknown', features), skuId: sku.id, skuName: sku.name ?? null, shortDescription: sku.product?.shortDescription ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, images: sku.images, - isOutOfStock: sku.isOutOfStock, + isOutOfStock: marketStats?.isOutOfStock ?? false, unitShortNotation: composeUnitNotation(features), productQuantity: 1, features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), @@ -238,9 +246,9 @@ export async function getAllProductsWithUnits(tagId?: number): Promise { const suspendedSkus = await db - .select({ id: productSkus.id }) - .from(productSkus) - .where(eq(productSkus.isSuspended, true)) + .select({ id: productMarketStats.skuId }) + .from(productMarketStats) + .where(eq(productMarketStats.isSuspended, true)) return suspendedSkus.map(sp => sp.id) } @@ -279,16 +287,18 @@ export interface SkuSummary { export async function getAllSkusSummary(): Promise { const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), with: { features: true, + marketStats: true, product: { columns: { name: true }, }, }, }) - return skus.map((sku) => { + return skus + .filter((sku) => !sku.marketStats?.isSuspended) + .map((sku) => { const featureValues = (sku.features || []).map((f) => f.featureValue) const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ') return { @@ -319,10 +329,12 @@ export interface OffersPageData { const mapOffersPageProduct = (sku: { id: number - price: string | null - marketPrice: string | null + marketStats: { + ourPrice: string | null + marketPrice: string | null + isOutOfStock: boolean + } | null images: unknown - isOutOfStock: boolean product: { name: string; incrementStep: number | null } | null features: Array<{ featureValue: string }> }): OffersPageProductData => { @@ -330,21 +342,21 @@ const mapOffersPageProduct = (sku: { return { id: sku.id, name: composeSkuName(sku.product?.name ?? 'Unknown', features), - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: sku.marketStats?.ourPrice ? String(sku.marketStats.ourPrice) : '0', + marketPrice: sku.marketStats?.marketPrice ? String(sku.marketStats.marketPrice) : null, unitNotation: composeUnitNotation(features), images: sku.images, - isOutOfStock: sku.isOutOfStock, + isOutOfStock: sku.marketStats?.isOutOfStock ?? false, incrementStep: sku.product?.incrementStep ?? 1, } } export async function getOffersAndCombos(): Promise { const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), with: { product: true, features: true, + marketStats: true, }, }) @@ -352,6 +364,7 @@ export async function getOffersAndCombos(): Promise { const offers: OffersPageProductData[] = [] for (const sku of skus) { + if (sku.marketStats?.isSuspended) continue if (sku.product?.productType === 'combo') { combos.push(mapOffersPageProduct(sku)) } diff --git a/packages/db_helper_sqlite/src/user-apis/slots.ts b/packages/db_helper_sqlite/src/user-apis/slots.ts index 8ad89ce..f631ffd 100644 --- a/packages/db_helper_sqlite/src/user-apis/slots.ts +++ b/packages/db_helper_sqlite/src/user-apis/slots.ts @@ -1,5 +1,5 @@ import { db } from '../db/db_index' -import { deliverySlotInfo, productSkus } from '../db/schema' +import { deliverySlotInfo, productMarketStats } from '../db/schema' import { asc, eq } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm' import type { UserDeliverySlot, UserSlotAvailability } from '@packages/shared' @@ -27,17 +27,16 @@ export async function getActiveSlotsList(): Promise { } export async function getProductAvailability(): Promise { - const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), - with: { - product: { columns: { name: true } }, + const stats = await db.query.productMarketStats.findMany({ + where: eq(productMarketStats.isSuspended, false), + columns: { + skuId: true, + isOutOfStock: true, }, }) - return skus.map((sku) => ({ - id: sku.id, - name: sku.product?.name ?? 'Unknown', - isOutOfStock: sku.isOutOfStock, - isFlashAvailable: sku.isFlashAvailable, + return stats.map((stat) => ({ + id: stat.skuId, + isOutOfStock: stat.isOutOfStock, })) } diff --git a/packages/db_helper_sqlite/src/user-apis/stores.ts b/packages/db_helper_sqlite/src/user-apis/stores.ts index dd9019a..b31680d 100644 --- a/packages/db_helper_sqlite/src/user-apis/stores.ts +++ b/packages/db_helper_sqlite/src/user-apis/stores.ts @@ -21,13 +21,13 @@ export async function getStoreSummaries(): Promise { }).from(storeInfo) const skus = await db.query.productSkus.findMany({ - where: eq(productSkus.isSuspended, false), - with: { product: true }, + with: { product: true, marketStats: true }, orderBy: asc(productSkus.id), }) + const activeSkus = skus.filter((sku) => !sku.marketStats?.isSuspended) const skusByStore = new Map() - for (const sku of skus) { + for (const sku of activeSkus) { const storeId = sku.product?.storeId if (storeId == null) continue if (!skusByStore.has(storeId)) skusByStore.set(storeId, []) @@ -77,30 +77,31 @@ export async function getStoreDetail(storeId: number): Promise 0 ? await db.query.productSkus.findMany({ - where: and( - inArray(productSkus.productId, productIdArr), - eq(productSkus.isSuspended, false) - ), + where: inArray(productSkus.productId, productIdArr), with: { product: true, features: true, + marketStats: true, }, }) : [] - const products: UserStoreProductData[] = skus.map((sku) => { + const products: UserStoreProductData[] = skus + .filter((sku) => !sku.marketStats?.isSuspended) + .map((sku) => { const features = sku.features || [] + const marketStats = sku.marketStats return { id: sku.id, name: composeSkuName(sku.product?.name ?? 'Unknown', features), shortDescription: sku.product?.shortDescription ?? null, - price: String(sku.price ?? '0'), - marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, + price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0', + marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null, incrementStep: sku.product?.incrementStep ?? 1, unit: composeUnitNotation(features), unitNotation: composeUnitNotation(features), images: getStringArray(sku.images), - isOutOfStock: sku.isOutOfStock, + isOutOfStock: marketStats?.isOutOfStock ?? false, productQuantity: 1, } }) diff --git a/packages/shared/index.ts b/packages/shared/index.ts index a502ff8..79d2335 100644 --- a/packages/shared/index.ts +++ b/packages/shared/index.ts @@ -2,6 +2,7 @@ export const CACHE_FILENAMES = { products: 'products.json', stores: 'stores.json', slots: 'slots.json', + availability: 'availability.json', essentialConsts: 'essential-consts.json', banners: 'banners.json', } as const diff --git a/packages/shared/types/user.ts b/packages/shared/types/user.ts index 1efc02b..e98bd3f 100644 --- a/packages/shared/types/user.ts +++ b/packages/shared/types/user.ts @@ -267,6 +267,7 @@ export interface UserProductComboItem { productName: string; images: string[] | null; price: string; + isOffer: boolean; } export interface UserProductDetailData { @@ -320,32 +321,34 @@ export interface UserCreateReviewResponse { export interface UserSlotProduct { id: number; - name: string; - shortDescription: string | null; - productQuantity: number; - price: string; - marketPrice: string | null; - unit: string | null; - images: string[]; - isOutOfStock: boolean; - storeId: number | null; - nextDeliveryDate: Date; + images: string[] | null; } export interface UserSlotWithProducts { id: number; deliveryTime: Date; freezeTime: Date; - isActive: boolean; - isCapacityFull: boolean; products: UserSlotProduct[]; } export interface UserSlotAvailability { id: number; - name: string; isOutOfStock: boolean; +} + +export interface UserAvailabilityEntry { + id: number; + price: string; + marketPrice: string | null; + flashPrice: string | null; isFlashAvailable: boolean; + isOutOfStock: boolean; + isSuspended: boolean; +} + +export interface UserAvailabilityResponse { + availability: UserAvailabilityEntry[]; + count: number; } export interface UserDeliverySlot {