This commit is contained in:
shafi54 2026-08-08 12:40:19 +05:30
parent b41980736a
commit 0ca746d3c6
33 changed files with 770 additions and 231 deletions

View file

@ -463,8 +463,8 @@ export default function OrderDetails() {
{item.name} {item.name}
</MyText> </MyText>
<MyText style={tw`text-xs text-gray-500`}> <MyText style={tw`text-xs text-gray-500`}>
{Number(item.quantity) * item.productSize} {item.unit} × {item.price} {Number(item.quantity)} x {item.productSize}{item.unit} × {item.price}
</MyText> </MyText>
<View style={tw`flex-row items-center mt-2 gap-3`}> <View style={tw`flex-row items-center mt-2 gap-3`}>
<TouchableOpacity <TouchableOpacity
style={tw`flex-row items-center`} style={tw`flex-row items-center`}

View file

@ -413,7 +413,7 @@ const OrderItem = ({ order, refetch }: { order: OrderType; refetch: () => void }
<View key={idx} style={tw`py-2 border-b border-gray-50 last:border-0`}> <View key={idx} style={tw`py-2 border-b border-gray-50 last:border-0`}>
<View style={tw`flex-row items-center`}> <View style={tw`flex-row items-center`}>
<View style={tw`bg-gray-100 px-2 py-1 rounded items-center justify-center mr-2`}> <View style={tw`bg-gray-100 px-2 py-1 rounded items-center justify-center mr-2`}>
<MyText style={tw`text-xs font-bold text-gray-600`}>{item.quantity * item.productSize } {item.unit}</MyText> <MyText style={tw`text-xs font-bold text-gray-600`}>{item.quantity} x {item.productSize}{item.unit}</MyText>
</View> </View>
<MyText style={tw`text-sm text-gray-800 flex-1`} numberOfLines={1} ellipsizeMode="tail"> <MyText style={tw`text-sm text-gray-800 flex-1`} numberOfLines={1} ellipsizeMode="tail">
{item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name} {item.name.length > 30 ? `${item.name.substring(0, 30)}...` : item.name}

View file

@ -402,15 +402,6 @@ export default function PricesOverview() {
/> />
</View> </View>
<View style={tw`mb-4`}>
<MyText style={tw`text-sm font-medium mb-1`}>Size</MyText>
<TextInput
style={tw`border border-gray-300 rounded-md px-3 py-2`}
keyboardType="decimal-pad"
placeholder="Enter size"
/>
</View>
<TouchableOpacity <TouchableOpacity
style={tw`bg-blue-600 py-3 rounded-md items-center`} style={tw`bg-blue-600 py-3 rounded-md items-center`}
onPress={saveEditDialog} onPress={saveEditDialog}

View file

@ -1,11 +1,11 @@
import { Buffer } from 'buffer' import { Buffer } from 'buffer'
import { scaffoldProducts } from '@/src/trpc/apis/common-apis/common' import { scaffoldProducts, scaffoldAvailability } from '@/src/trpc/apis/common-apis/common'
import { scaffoldEssentialConsts } from '@/src/trpc/apis/common-apis/common-trpc-index' import { scaffoldEssentialConsts } from '@/src/trpc/apis/common-apis/common-trpc-index'
import { scaffoldStores } from '@/src/trpc/apis/user-apis/apis/stores' import { scaffoldStores } from '@/src/trpc/apis/user-apis/apis/stores'
import { scaffoldSlotsWithProducts } from '@/src/trpc/apis/user-apis/apis/slots' import { scaffoldSlotsWithProducts } from '@/src/trpc/apis/user-apis/apis/slots'
import { scaffoldBanners } from '@/src/trpc/apis/user-apis/apis/banners' import { scaffoldBanners } from '@/src/trpc/apis/user-apis/apis/banners'
import { scaffoldStoreWithProducts } from '@/src/trpc/apis/user-apis/apis/stores' import { scaffoldStoreWithProducts } from '@/src/trpc/apis/user-apis/apis/stores'
import { getStoresSummary, incrementCacheVersion } from '@/src/dbService' import { getStoresSummary, incrementCacheVersion, incrementAvailabilityVersionNum, incrementSlotsVersionNum } from '@/src/dbService'
import { imageUploadS3 } from '@/src/lib/s3-client' import { imageUploadS3 } from '@/src/lib/s3-client'
import { getApiCacheKey, getCloudflareApiToken, getCloudflareZoneId, getAssetsDomain } from '@/src/lib/env-exporter' import { getApiCacheKey, getCloudflareApiToken, getCloudflareZoneId, getAssetsDomain } from '@/src/lib/env-exporter'
import { CACHE_FILENAMES } from '@packages/shared' import { CACHE_FILENAMES } from '@packages/shared'
@ -13,16 +13,29 @@ import { retryWithExponentialBackoff } from '@/src/lib/retry'
const buildCachePath = (path: string, version: number) => `v-${version}/${path}` const buildCachePath = (path: string, version: number) => `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 { function constructCacheUrl(path: string, version: number): string {
return `${getAssetsDomain()}${getApiCacheKey()}/${buildCachePath(path, version)}` 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 { export interface CreateAllCacheFilesResult {
cacheVersion: number cacheVersion: number
products: string products: string
essentialConsts: string essentialConsts: string
stores: string stores: string
slots: string slotsVersion: number
availabilityVersion: number
banners: string banners: string
individualStores: string[] individualStores: string[]
} }
@ -37,14 +50,16 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
productsKey, productsKey,
essentialConstsKey, essentialConstsKey,
storesKey, storesKey,
slotsKey, slotsVersion,
availabilityVersion,
bannersKey, bannersKey,
individualStoreKeys, individualStoreKeys,
] = await Promise.all([ ] = await Promise.all([
createProductsFileInternal(cacheVersion), createProductsFileInternal(cacheVersion),
createEssentialConstsFileInternal(cacheVersion), createEssentialConstsFileInternal(cacheVersion),
createStoresFileInternal(cacheVersion), createStoresFileInternal(cacheVersion),
createSlotsFileInternal(cacheVersion), createSlotsCacheFile(),
createAvailabilityCacheFile(),
createBannersFileInternal(cacheVersion), createBannersFileInternal(cacheVersion),
createAllStoresFilesInternal(cacheVersion), createAllStoresFilesInternal(cacheVersion),
]) ])
@ -56,7 +71,8 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
constructCacheUrl(CACHE_FILENAMES.products, cacheVersion), constructCacheUrl(CACHE_FILENAMES.products, cacheVersion),
constructCacheUrl(CACHE_FILENAMES.essentialConsts, cacheVersion), constructCacheUrl(CACHE_FILENAMES.essentialConsts, cacheVersion),
constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion), constructCacheUrl(CACHE_FILENAMES.stores, cacheVersion),
constructCacheUrl(CACHE_FILENAMES.slots, cacheVersion), constructSlotsUrl(slotsVersion),
constructAvailabilityUrl(availabilityVersion),
constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion), constructCacheUrl(CACHE_FILENAMES.banners, cacheVersion),
...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)), ...stores.map((store) => constructCacheUrl(`stores/${store.id}.json`, cacheVersion)),
] ]
@ -76,7 +92,8 @@ export async function createAllCacheFiles(): Promise<CreateAllCacheFilesResult>
products: productsKey, products: productsKey,
essentialConsts: essentialConstsKey, essentialConsts: essentialConstsKey,
stores: storesKey, stores: storesKey,
slots: slotsKey, slotsVersion,
availabilityVersion,
banners: bannersKey, banners: bannersKey,
individualStores: individualStoreKeys, individualStores: individualStoreKeys,
} }
@ -98,6 +115,23 @@ async function createProductsFileInternal(version: number): Promise<string> {
} }
export async function createAvailabilityCacheFile(): Promise<number> {
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<string> { async function createEssentialConstsFileInternal(version: number): Promise<string> {
const essentialConstsData = await scaffoldEssentialConsts() const essentialConstsData = await scaffoldEssentialConsts()
const jsonContent = JSON.stringify(essentialConstsData, null, 2) const jsonContent = JSON.stringify(essentialConstsData, null, 2)
@ -120,15 +154,21 @@ async function createStoresFileInternal(version: number): Promise<string> {
) )
} }
async function createSlotsFileInternal(version: number): Promise<string> { export async function createSlotsCacheFile(): Promise<number> {
const version = await incrementSlotsVersionNum()
const slotsData = await scaffoldSlotsWithProducts() const slotsData = await scaffoldSlotsWithProducts()
const jsonContent = JSON.stringify(slotsData, null, 2) const jsonContent = JSON.stringify(slotsData, null, 2)
const buffer = Buffer.from(jsonContent, 'utf-8') const buffer = Buffer.from(jsonContent, 'utf-8')
return await imageUploadS3( const filePath = buildSlotsPath(version)
console.log(filePath)
await imageUploadS3(
buffer, buffer,
'application/json', 'application/json',
`${getApiCacheKey()}/${buildCachePath(CACHE_FILENAMES.slots, version)}` filePath
) )
return version
} }
async function createBannersFileInternal(version: number): Promise<string> { async function createBannersFileInternal(version: number): Promise<string> {

View file

@ -240,6 +240,7 @@ export {
// Store Helpers // Store Helpers
getAllBannersForCache, getAllBannersForCache,
getAllProductsForCache, getAllProductsForCache,
getAvailabilityForCache,
getAllStoresForCache, getAllStoresForCache,
getAllDeliverySlotsForCache, getAllDeliverySlotsForCache,
getAllSpecialDealsForCache, getAllSpecialDealsForCache,

View file

@ -44,6 +44,7 @@ interface Product {
productName: string productName: string
images: string[] | null images: string[] | null
price: string price: string
isOffer: boolean
}> }>
} }
@ -250,6 +251,7 @@ export async function getAllProducts(): Promise<Product[]> {
productName: ci.productName, productName: ci.productName,
images: ci.images ? scaffoldAssetUrl((ci.images as string[]) || []) : null, images: ci.images ? scaffoldAssetUrl((ci.images as string[]) || []) : null,
price: ci.price, price: ci.price,
isOffer: ci.isOffer,
})) }))
products.push({ products.push({

View file

@ -3,6 +3,7 @@ import { z } from 'zod'
import { ApiError } from '@/src/lib/api-error' import { ApiError } from '@/src/lib/api-error'
import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client' import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl, claimUploadUrl, extractKeyFromPresignedUrl, deleteImageUtil } from '@/src/lib/s3-client'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer' import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { createAvailabilityCacheFile } from '@/src/lib/cloud_cache'
import { import {
getAllProducts as getAllProductsInDb, getAllProducts as getAllProductsInDb,
getProductById as getProductByIdInDb, getProductById as getProductByIdInDb,
@ -196,6 +197,7 @@ export const productRouter = router({
images: z.array(z.string()).optional().default([]), images: z.array(z.string()).optional().default([]),
isFlashAvailable: z.boolean().optional().default(false), isFlashAvailable: z.boolean().optional().default(false),
flashPrice: z.number().optional().nullable(), flashPrice: z.number().optional().nullable(),
isOutOfStock: z.boolean().optional().default(false),
isOffer: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false),
isComboOnly: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false),
isSuspended: 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)), images: sku.images.map((url) => extractKeyFromPresignedUrl(url)),
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: sku.isFlashAvailable,
flashPrice: sku.flashPrice ?? null, flashPrice: sku.flashPrice ?? null,
isOutOfStock: sku.isOutOfStock,
isOffer: sku.isOffer, isOffer: sku.isOffer,
isComboOnly: sku.isComboOnly, isComboOnly: sku.isComboOnly,
isSuspended: sku.isSuspended, isSuspended: sku.isSuspended,
@ -284,6 +287,7 @@ export const productRouter = router({
images: z.array(z.string()).optional().default([]), images: z.array(z.string()).optional().default([]),
isFlashAvailable: z.boolean().optional().default(false), isFlashAvailable: z.boolean().optional().default(false),
flashPrice: z.number().optional().nullable(), flashPrice: z.number().optional().nullable(),
isOutOfStock: z.boolean().optional().default(false),
isOffer: z.boolean().optional().default(false), isOffer: z.boolean().optional().default(false),
isComboOnly: z.boolean().optional().default(false), isComboOnly: z.boolean().optional().default(false),
isSuspended: 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)), images: sku.images.map((url) => extractKeyFromPresignedUrl(url)),
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: sku.isFlashAvailable,
flashPrice: sku.flashPrice ?? null, flashPrice: sku.flashPrice ?? null,
isOutOfStock: sku.isOutOfStock,
isOffer: sku.isOffer, isOffer: sku.isOffer,
isComboOnly: sku.isComboOnly, isComboOnly: sku.isComboOnly,
isSuspended: sku.isSuspended, isSuspended: sku.isSuspended,
@ -842,11 +847,13 @@ export const productRouter = router({
}; };
*/ */
if (result.invalidIds.length > 0) { if (result.invalidIds.length > 0) {
throw new ApiError(`Invalid product IDs: ${result.invalidIds.join(', ')}`, 400) 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 { return {
message: `Updated prices for ${result.updatedCount} product(s)`, message: `Updated prices for ${result.updatedCount} product(s)`,

View file

@ -6,6 +6,7 @@ import { getAppUrl } from "@/src/lib/env-exporter"
// import redisClient from "@/src/lib/redis-client" // import redisClient from "@/src/lib/redis-client"
// import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters" // import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters"
import { scheduleStoreInitialization } from '@/src/stores/store-initializer' import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
import { createSlotsCacheFile } from '@/src/lib/cloud_cache'
import { import {
getActiveSlotsWithProducts as getActiveSlotsWithProductsInDb, getActiveSlotsWithProducts as getActiveSlotsWithProductsInDb,
getActiveSlots as getActiveSlotsInDb, 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 { return {
message: result.message, message: result.message,
@ -360,8 +363,10 @@ export const slotsRouter = router({
}); });
*/ */
// Reinitialize stores to reflect changes (outside transaction) // Regenerate slots cache file (availability/products stay as-is)
await scheduleStoreInitialization() await createSlotsCacheFile().catch((error) => {
console.error('Failed to regenerate slots cache after slot create:', error)
})
// Fire and forget: cleanup stale product slot associations // Fire and forget: cleanup stale product slot associations
staleSlotsCleanup().catch((error) => { staleSlotsCleanup().catch((error) => {
@ -548,8 +553,10 @@ export const slotsRouter = router({
throw new ApiError('Slot not found', 404) throw new ApiError('Slot not found', 404)
} }
// Reinitialize stores to reflect changes (outside transaction) // Regenerate slots cache file (availability/products stay as-is)
await scheduleStoreInitialization() await createSlotsCacheFile().catch((error) => {
console.error('Failed to regenerate slots cache after slot update:', error)
})
return result return result
} }
@ -587,8 +594,10 @@ export const slotsRouter = router({
throw new ApiError('Slot not found', 404) throw new ApiError('Slot not found', 404)
} }
// Reinitialize stores to reflect changes // Regenerate slots cache file (availability/products stay as-is)
await scheduleStoreInitialization() await createSlotsCacheFile().catch((error) => {
console.error('Failed to regenerate slots cache after slot delete:', error)
})
return { return {
message: 'Slot deleted successfully', message: 'Slot deleted successfully',
@ -736,7 +745,9 @@ export const slotsRouter = router({
throw new ApiError('Slot not found', 404) 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 return result
}), }),

View file

@ -4,6 +4,8 @@ import {
getStoresSummary, getStoresSummary,
healthCheck, healthCheck,
getCacheVersion, getCacheVersion,
getAvailabilityVersionNum,
getSlotsVersionNum,
} from '@/src/dbService' } from '@/src/dbService'
import type { StoresSummaryResponse } from '@packages/shared' import type { StoresSummaryResponse } from '@packages/shared'
import { polygon as turfPolygon, point as turfPoint } from '@turf/helpers'; 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() { export async function scaffoldEssentialConsts() {
const consts = await getAllConstValues(); const consts = await getAllConstValues();
const cacheVersion = await getCacheVersion() const cacheVersion = await getCacheVersion()
const availabilityVersionNum = await getAvailabilityVersionNum()
const slotsVersionNum = await getSlotsVersionNum()
return { return {
freeDeliveryThreshold: consts[CONST_KEYS.freeDeliveryThreshold] ?? 200, freeDeliveryThreshold: consts[CONST_KEYS.freeDeliveryThreshold] ?? 200,
@ -40,6 +44,8 @@ export async function scaffoldEssentialConsts() {
assetsDomain: getAssetsDomain(), assetsDomain: getAssetsDomain(),
apiCacheKey: getApiCacheKey(), apiCacheKey: getApiCacheKey(),
cacheVersion, cacheVersion,
availabilityVersionNum,
slotsVersionNum,
}; };
} }

View file

@ -6,6 +6,7 @@ import {
getAllSkusSummary as getAllSkusSummaryInDb, getAllSkusSummary as getAllSkusSummaryInDb,
getAllTagsForCache, getAllTagsForCache,
getAllTagProductMappings, getAllTagProductMappings,
getAvailabilityForCache,
} from '@/src/dbService' } from '@/src/dbService'
import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } from '@/src/lib/s3-client' import { generateSignedUrlsFromS3Urls, generateSignedUrlFromS3Url, scaffoldAssetUrl } from '@/src/lib/s3-client'
import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store' import { getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'
@ -45,18 +46,14 @@ export async function scaffoldProducts() {
id: product.id, id: product.id,
name: product.name, name: product.name,
shortDescription: product.shortDescription, shortDescription: product.shortDescription,
price: parseFloat(product.price),
marketPrice: product.marketPrice ? parseFloat(product.marketPrice) : null,
unit: product.unitNotation, unit: product.unitNotation,
unitNotation: product.unitNotation, unitNotation: product.unitNotation,
incrementStep: product.incrementStep, incrementStep: product.incrementStep,
productQuantity: product.productQuantity, productQuantity: product.productQuantity,
storeId: product.store?.id || null, storeId: product.store?.id || null,
isOutOfStock: product.isOutOfStock, isOutOfStock: product.isOutOfStock,
isFlashAvailable: product.isFlashAvailable,
nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null, nextDeliveryDate: nextDeliveryDate ? nextDeliveryDate.toISOString() : null,
images: product.images, images: product.images,
flashPrice: product.flashPrice,
productType: product.productType || 'item' 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({ export const commonRouter = router({
getDashboardTags: publicProcedure getDashboardTags: publicProcedure
.query(async () => { .query(async () => {

View file

@ -17,28 +17,16 @@ export async function scaffoldSlotsWithProducts(): Promise<UserSlotsWithProducts
const productAvailability = await getUserProductAvailabilityInDb() const productAvailability = await getUserProductAvailabilityInDb()
/*
// Old implementation - direct DB query:
const allProducts = await db
.select({
id: productInfo.id,
name: productInfo.name,
isOutOfStock: productInfo.isOutOfStock,
isFlashAvailable: productInfo.isFlashAvailable,
})
.from(productInfo)
.where(eq(productInfo.isSuspended, false));
const productAvailability = allProducts.map(product => ({
id: product.id,
name: product.name,
isOutOfStock: product.isOutOfStock,
isFlashAvailable: product.isFlashAvailable,
}));
*/
return { 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, productAvailability,
count: validSlots.length, count: validSlots.length,
}; };

View file

@ -3,7 +3,7 @@ import { z } from 'zod';
import { adminRouter } from '@/src/trpc/apis/admin-apis/apis/admin-trpc-index' 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 { userRouter } from '@/src/trpc/apis/user-apis/apis/user-trpc-index'
import { commonApiRouter } from '@/src/trpc/apis/common-apis/common-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 { scaffoldStores, scaffoldStoreWithProducts } from './apis/user-apis/apis/stores';
import { scaffoldSlotsWithProducts } from './apis/user-apis/apis/slots'; import { scaffoldSlotsWithProducts } from './apis/user-apis/apis/slots';
import { scaffoldEssentialConsts } from './apis/common-apis/common-trpc-index'; import { scaffoldEssentialConsts } from './apis/common-apis/common-trpc-index';
@ -26,6 +26,7 @@ export const appRouter = router({
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;
export type AllProductsApiType = Awaited<ReturnType<typeof scaffoldProducts>>; export type AllProductsApiType = Awaited<ReturnType<typeof scaffoldProducts>>;
export type AvailabilityApiType = Awaited<ReturnType<typeof scaffoldAvailability>>;
export type StoresApiType = Awaited<ReturnType<typeof scaffoldStores>>; export type StoresApiType = Awaited<ReturnType<typeof scaffoldStores>>;
export type SlotsApiType = Awaited<ReturnType<typeof scaffoldSlotsWithProducts>>; export type SlotsApiType = Awaited<ReturnType<typeof scaffoldSlotsWithProducts>>;
export type EssentialConstsApiType = Awaited<ReturnType<typeof scaffoldEssentialConsts>>; export type EssentialConstsApiType = Awaited<ReturnType<typeof scaffoldEssentialConsts>>;

View file

@ -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/<dump>.sql; COMMIT;" sqlite3 :memory: "PRAGMA foreign_keys=ON; BEGIN; .read dumps/<dump>.sql; COMMIT;"
This should exit 0 with no error. (Example already applied to `latest_1.sql`: This should exit 0 with no error. (Example already applied to `latest_1.sql` and
`product_info` was moved above `product_skus`.) `local_8_aug.sql`: `product_info` was moved above `product_skus`.)
## When to re-check ## When to re-check
After ANY new `wrangler d1 export`, especially once a migration that re-creates 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. 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.

View file

@ -9,7 +9,7 @@ routes = [
[[d1_databases]] [[d1_databases]]
binding = "DB" binding = "DB"
database_name = "freshyo-backend-dev" database_name = "freshyo-backend-dev"
database_id = "0814d709-5278-4311-8978-c36c0f05875d" database_id = "a976d1fb-c6a7-4948-b303-81c6433ed265"
#database_name = "freshyo-dev" #database_name = "freshyo-dev"
#database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de" #database_id = "3cad440f-dc14-4baa-ab05-04eb08e206de"
migrations_dir="../../packages/db_helper_sqlite/drizzle" migrations_dir="../../packages/db_helper_sqlite/drizzle"

View file

@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react';
import { View, ScrollView, Alert, Dimensions, FlatList, RefreshControl } from 'react-native'; import { View, ScrollView, Alert, Dimensions, FlatList, RefreshControl } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { ImageCarousel, tw, BottomDialog, useManualRefresh, useMarkDataFetchers, LoadingDialog, ImageUploader, MyText, MyTextInput, MyTouchableOpacity, Quantifier } from 'common-ui'; 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 { LinearGradient } from 'expo-linear-gradient';
import usePickImage from 'common-ui/src/components/use-pick-image'; import usePickImage from 'common-ui/src/components/use-pick-image';
import { theme } from 'common-ui/src/theme'; import { theme } from 'common-ui/src/theme';
@ -377,6 +378,54 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ productId, isFlashDeliver
</View> </View>
</View> </View>
{/* Combo Items */}
{productDetail.productType === 'combo' && productDetail.comboItems && productDetail.comboItems.length > 0 && (
<View style={tw`px-4 mb-4`}>
<View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}>
<View style={tw`flex-row items-center mb-4`}>
<View style={tw`w-8 h-8 bg-brand50 rounded-full items-center justify-center mr-3`}>
<MaterialIcons name="view-list" size={18} color="#3B82F6" />
</View>
<MyText style={tw`text-lg font-bold text-gray-900`}>Included Items</MyText>
</View>
{productDetail.comboItems.map((comboItem, index) => (
<View
key={comboItem.skuId}
style={tw`flex-row items-center py-2 ${index !== productDetail.comboItems.length - 1 ? 'border-b border-gray-50' : ''}`}
>
<View style={tw`w-12 h-12 bg-gray-100 rounded-lg overflow-hidden items-center justify-center mr-3`}>
{comboItem.images?.[0] ? (
<Image
source={{ uri: comboItem.images[0] }}
style={tw`w-full h-full`}
resizeMode="cover"
/>
) : (
<MaterialIcons name="image" size={20} color="#9CA3AF" />
)}
</View>
<View style={tw`flex-1`}>
<View style={tw`flex-row items-center`}>
<MyText style={tw`text-sm font-bold text-gray-900 flex-1`} numberOfLines={1}>
{comboItem.productName}
</MyText>
{comboItem.isOffer && (
<View style={tw`ml-2 bg-pink-100 px-2 py-0.5 rounded-full`}>
<MyText style={tw`text-[10px] font-bold text-pink-600`}>OFFER</MyText>
</View>
)}
</View>
<MyText style={tw`text-xs text-gray-500 mt-0.5`}>
{comboItem.unitNotation || comboItem.skuName || ''}
</MyText>
</View>
</View>
))}
</View>
</View>
)}
{/* Delivery Slots */} {/* Delivery Slots */}
<View style={tw`px-4 mb-4`}> <View style={tw`px-4 mb-4`}>
<View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}> <View style={tw`bg-white p-5 rounded-2xl shadow-sm border border-gray-100`}>

View file

@ -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 { useCentralSlotStore } from '@/src/store/centralSlotStore';
import { Alert } from 'react-native'; import { Alert } from 'react-native';
import { useQuery, useMutation, useQueryClient, UseQueryResult, UseMutationResult } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient, UseQueryResult, UseMutationResult } from '@tanstack/react-query';
@ -184,7 +184,7 @@ const clearLocalCart = async (cartType: CartType = "regular"): Promise<void> =>
}; };
export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType = "regular"): UseGetCartReturn { 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 productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap);
const query: UseQueryResult<CartData, Error> = useQuery({ const query: UseQueryResult<CartData, Error> = useQuery({
@ -193,7 +193,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType =
const cartItems = await getLocalCart(cartType); const cartItems = await getLocalCart(cartType);
const productMap: Record<number, Omit<ProductSummary, 'isOutOfStock' | 'isFlashAvailable'>> = Object.fromEntries( const productMap: Record<number, Omit<ProductSummary, 'isOutOfStock' | 'isFlashAvailable'>> = Object.fromEntries(
products?.products?.map((p) => [ Object.values(productsById).map((p) => [
p.id, p.id,
{ {
id: p.id, id: p.id,
@ -206,7 +206,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType =
productQuantity: p.productQuantity, productQuantity: p.productQuantity,
unitNotation: p.unitNotation, unitNotation: p.unitNotation,
}, },
]) ?? [] ])
); );
const items: CartItem[] = cartItems const items: CartItem[] = cartItems
@ -236,7 +236,7 @@ export function useGetCart(options: UseGetCartOptions = {}, cartType: CartType =
}; };
}, },
refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true, refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true,
enabled: (options?.enabled ?? true) && !!products, enabled: (options?.enabled ?? true) && Object.keys(productsById).length > 0,
}); });
return { return {

View file

@ -1,7 +1,8 @@
import React from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import axios from 'axios' import axios from 'axios'
import { trpc } from '@/src/trpc-client' 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"; import { CACHE_FILENAMES } from "@packages/shared";
// Local useGetEssentialConsts hook // Local useGetEssentialConsts hook
@ -18,6 +19,19 @@ type SlotsResponse = SlotsApiType;
type EssentialConstsResponse = EssentialConstsApiType; type EssentialConstsResponse = EssentialConstsApiType;
type BannersResponse = BannersApiType; type BannersResponse = BannersApiType;
type StoreWithProductsResponse = StoreWithProductsApiType; 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 { function useCacheUrl(filename: string): string | null {
const { data: essentialConsts } = useGetEssentialConsts() const { data: essentialConsts } = useGetEssentialConsts()
@ -33,11 +47,37 @@ function useCacheUrl(filename: string): string | null {
return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}` 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() { export function useAllProducts() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) const cacheUrl = useCacheUrl(CACHE_FILENAMES.products)
const { data: availabilityData } = useAvailability()
const productsQuery = useQuery<ProductsResponse>({
return useQuery<ProductsResponse>({
queryKey: ['all-products', cacheUrl], queryKey: ['all-products', cacheUrl],
queryFn: async () => { queryFn: async () => {
if (!cacheUrl) { if (!cacheUrl) {
@ -49,6 +89,57 @@ export function useAllProducts() {
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
enabled: !!cacheUrl, enabled: !!cacheUrl,
}) })
const mergedProducts = React.useMemo(() => {
const rawProducts = productsQuery.data?.products || []
const availabilityById: Record<number, AvailabilityEntry> = {}
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<AvailabilityResponse>({
queryKey: ['availability', cacheUrl],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<AvailabilityResponse>(cacheUrl)
return response.data
},
staleTime: 60000, // 1 minute
enabled: !!cacheUrl,
})
} }
export function useStores() { export function useStores() {
@ -69,7 +160,7 @@ export function useStores() {
} }
export function useSlots() { export function useSlots() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots) const cacheUrl = useSlotsCacheUrl()
return useQuery<SlotsResponse>({ return useQuery<SlotsResponse>({
queryKey: ['slots', cacheUrl], queryKey: ['slots', cacheUrl],

View file

@ -1,9 +1,8 @@
import { create } from 'zustand' import { create } from 'zustand'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useAllProducts } from '@/src/hooks/prominent-api-hooks' import { useAllProducts, type MergedProduct } from '@/src/hooks/prominent-api-hooks'
import { AllProductsApiType } from '@backend/trpc/router'
type Product = AllProductsApiType['products'][number] export type Product = MergedProduct
interface CentralProductState { interface CentralProductState {
products: Product[] products: Product[]

View file

@ -1,22 +1,24 @@
import { create } from 'zustand'; 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 { useEffect } from 'react';
import { SlotsApiType } from "@backend/trpc/router"; import { SlotsApiType, AvailabilityApiType } from "@backend/trpc/router";
type Slot = SlotsApiType['slots'][number]; type Slot = SlotsApiType['slots'][number];
type ProductAvailability = SlotsApiType['productAvailability'][number]; type ProductAvailability = SlotsApiType['productAvailability'][number];
type AvailabilityEntry = AvailabilityApiType['availability'][number];
interface ProductSlotInfo { interface ProductSlotInfo {
slots: Slot[]; slots: Slot[];
isOutOfStock: boolean; isOutOfStock: boolean;
isFlashAvailable: boolean; isFlashAvailable: boolean;
isSuspended: boolean;
} }
interface CentralSlotState { interface CentralSlotState {
slots: Slot[]; slots: Slot[];
productSlotsMap: Record<number, ProductSlotInfo>; productSlotsMap: Record<number, ProductSlotInfo>;
refetchSlots: (() => Promise<void>) | null; refetchSlots: (() => Promise<void>) | null;
setSlotsData: (slots: Slot[], productAvailability: ProductAvailability[]) => void; setSlotsData: (slots: Slot[], productAvailability: ProductAvailability[], availability: AvailabilityEntry[]) => void;
clearSlotsData: () => void; clearSlotsData: () => void;
setRefetchSlots: (refetch: () => Promise<void>) => void; setRefetchSlots: (refetch: () => Promise<void>) => void;
} }
@ -25,15 +27,20 @@ export const useCentralSlotStore = create<CentralSlotState>((set) => ({
slots: [], slots: [],
productSlotsMap: {}, productSlotsMap: {},
refetchSlots: null, refetchSlots: null,
setSlotsData: (slots, productAvailability) => { setSlotsData: (slots, productAvailability, availability) => {
const productSlotsMap: Record<number, ProductSlotInfo> = {}; const productSlotsMap: Record<number, ProductSlotInfo> = {};
const availabilityById: Record<number, AvailabilityEntry> = {};
availability.forEach((entry) => {
availabilityById[entry.id] = entry;
});
// First, create entries for ALL products from productAvailability // First, create entries for ALL products from productAvailability
productAvailability.forEach((product) => { productAvailability.forEach((product) => {
productSlotsMap[product.id] = { productSlotsMap[product.id] = {
slots: [], slots: [],
isOutOfStock: product.isOutOfStock, isOutOfStock: availabilityById[product.id]?.isOutOfStock ?? false,
isFlashAvailable: product.isFlashAvailable, isFlashAvailable: availabilityById[product.id]?.isFlashAvailable ?? false,
isSuspended: availabilityById[product.id]?.isSuspended ?? false,
}; };
}); });
@ -54,14 +61,15 @@ export const useCentralSlotStore = create<CentralSlotState>((set) => ({
export function useInitializeCentralSlotStore() { export function useInitializeCentralSlotStore() {
const { data: slotsData, refetch } = useSlots(); const { data: slotsData, refetch } = useSlots();
const { data: availabilityData } = useAvailability();
const setSlotsData = useCentralSlotStore((state) => state.setSlotsData); const setSlotsData = useCentralSlotStore((state) => state.setSlotsData);
const setRefetchSlots = useCentralSlotStore((state) => state.setRefetchSlots); const setRefetchSlots = useCentralSlotStore((state) => state.setRefetchSlots);
useEffect(() => { useEffect(() => {
if (slotsData?.slots) { if (slotsData?.slots) {
setSlotsData(slotsData.slots, slotsData.productAvailability || []); setSlotsData(slotsData.slots, slotsData.productAvailability || [], availabilityData?.availability || []);
} }
}, [slotsData, setSlotsData]); }, [slotsData, availabilityData, setSlotsData]);
useEffect(() => { useEffect(() => {
setRefetchSlots(async () => { setRefetchSlots(async () => {

View file

@ -4,6 +4,7 @@ import { BottomDialog, p, div, Quantifier } from 'web-components'
import { useSlots } from '../hooks/prominent-api-hooks' import { useSlots } from '../hooks/prominent-api-hooks'
import { useAddToCart, useUpdateCartItem, useRemoveFromCart, useGetCart } from '../hooks/cart-query-hooks' import { useAddToCart, useUpdateCartItem, useRemoveFromCart, useGetCart } from '../hooks/cart-query-hooks'
import { useCartStore } from '../lib/stores/cart-store' import { useCartStore } from '../lib/stores/cart-store'
import { useCentralSlotStore } from '../lib/stores/central-slot-store'
import { ShoppingCart, Truck, Zap, X } from 'lucide-react' import { ShoppingCart, Truck, Zap, X } from 'lucide-react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -29,6 +30,7 @@ export default function AddToCartDialog() {
const { data: slotsData } = useSlots() const { data: slotsData } = useSlots()
const { data: cartData } = useGetCart() const { data: cartData } = useGetCart()
const productSlotsMap = useCentralSlotStore((state) => state.productSlotsMap)
const isFlashDeliveryEnabled = true const isFlashDeliveryEnabled = true
const addToCart = useAddToCart('regular') const addToCart = useAddToCart('regular')
@ -76,7 +78,7 @@ export default function AddToCartDialog() {
const cartItem = cartData?.items?.find((item: any) => item.productId === product?.id) const cartItem = cartData?.items?.find((item: any) => item.productId === product?.id)
const isUpdate = (cartItem?.quantity || 0) >= 1 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 showFlashOption = productAvailability?.isFlashAvailable === true && isFlashDeliveryEnabled
const handleAddToCart = () => { const handleAddToCart = () => {

View file

@ -1,8 +1,10 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import axios from 'axios' import axios from 'axios'
import { trpc } from '../lib/trpc-client' import { trpc } from '../lib/trpc-client'
import type { import type {
AllProductsApiType, AllProductsApiType,
AvailabilityApiType,
StoresApiType, StoresApiType,
SlotsApiType, SlotsApiType,
EssentialConstsApiType, EssentialConstsApiType,
@ -23,6 +25,17 @@ type StoresResponse = StoresApiType
type SlotsResponse = SlotsApiType type SlotsResponse = SlotsApiType
type BannersResponse = BannersApiType type BannersResponse = BannersApiType
type StoreWithProductsResponse = StoreWithProductsApiType 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 { function useCacheUrl(filename: string): string | null {
const { data: essentialConsts } = useGetEssentialConsts() const { data: essentialConsts } = useGetEssentialConsts()
@ -43,10 +56,37 @@ function useCacheUrl(filename: string): string | null {
return `${assetsDomain}${apiCacheKey}/v-${cacheVersion}/${filename}` 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() { export function useAllProducts() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.products) const cacheUrl = useCacheUrl(CACHE_FILENAMES.products)
const { data: availabilityData } = useAvailability()
return useQuery<ProductsResponse>({ const productsQuery = useQuery<ProductsResponse>({
queryKey: ['all-products', cacheUrl], queryKey: ['all-products', cacheUrl],
queryFn: async () => { queryFn: async () => {
if (!cacheUrl) { if (!cacheUrl) {
@ -58,6 +98,55 @@ export function useAllProducts() {
staleTime: 60000, staleTime: 60000,
enabled: !!cacheUrl, enabled: !!cacheUrl,
}) })
const mergedProducts = useMemo(() => {
const rawProducts = productsQuery.data?.products || []
const availabilityById: Record<number, AvailabilityEntry> = {}
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<AvailabilityResponse>({
queryKey: ['availability', cacheUrl],
queryFn: async () => {
if (!cacheUrl) {
throw new Error('Cache URL not available')
}
const response = await axios.get<AvailabilityResponse>(cacheUrl)
return response.data
},
staleTime: 60000,
enabled: !!cacheUrl,
})
} }
export function useStores() { export function useStores() {
@ -78,7 +167,7 @@ export function useStores() {
} }
export function useSlots() { export function useSlots() {
const cacheUrl = useCacheUrl(CACHE_FILENAMES.slots) const cacheUrl = useSlotsCacheUrl()
return useQuery<SlotsResponse>({ return useQuery<SlotsResponse>({
queryKey: ['slots', cacheUrl], queryKey: ['slots', cacheUrl],

View file

@ -9,13 +9,7 @@ CREATE TABLE `product_skus` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`product_id` integer NOT NULL, `product_id` integer NOT NULL,
`name` text, `name` text,
`price` text NOT NULL,
`market_price` text,
`images` 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_offer` integer DEFAULT false NOT NULL,
`is_combo_only` integer DEFAULT false NOT NULL, `is_combo_only` integer DEFAULT false NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP 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. -- 2. Migrate each existing product into one default SKU plus a mandatory quantity feature.
INSERT INTO `product_skus` ( INSERT INTO `product_skus` (
`product_id`, `name`, `price`, `market_price`, `images`, `is_out_of_stock`, `product_id`, `name`, `images`, `created_at`
`is_suspended`, `is_flash_available`, `flash_price`, `created_at`
) )
SELECT SELECT
`id`, `id`,
NULL, NULL,
`price`,
`market_price`,
`images`, `images`,
`is_out_of_stock`,
`is_suspended`,
`is_flash_available`,
`flash_price`,
`created_at` `created_at`
FROM `product_info`; FROM `product_info`;
@ -59,6 +46,33 @@ FROM `product_info` `pi`
JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id` JOIN `product_skus` `ps` ON `ps`.`product_id` = `pi`.`id`
LEFT JOIN `units` `u` ON `u`.`id` = `pi`.`unit_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. -- 3. Build a product_id -> sku_id mapping for downstream tables.
CREATE TABLE `__product_to_sku` ( CREATE TABLE `__product_to_sku` (
`product_id` integer PRIMARY KEY, `product_id` integer PRIMARY KEY,

View file

@ -32,6 +32,10 @@ export {
upsertConstants, upsertConstants,
getCacheVersion, getCacheVersion,
incrementCacheVersion, incrementCacheVersion,
getAvailabilityVersionNum,
incrementAvailabilityVersionNum,
getSlotsVersionNum,
incrementSlotsVersionNum,
} from './src/admin-apis/const' } from './src/admin-apis/const'
export { export {
@ -314,12 +318,14 @@ export {
type BannerData, type BannerData,
// Product Store // Product Store
getAllProductsForCache, getAllProductsForCache,
getAvailabilityForCache,
getAllStoresForCache, getAllStoresForCache,
getAllDeliverySlotsForCache, getAllDeliverySlotsForCache,
getAllSpecialDealsForCache, getAllSpecialDealsForCache,
getAllProductTagsForCache, getAllProductTagsForCache,
getAllProductCombosForCache, getAllProductCombosForCache,
type ProductBasicData, type ProductBasicData,
type AvailabilityCacheData,
type StoreBasicData, type StoreBasicData,
type DeliverySlotData, type DeliverySlotData,
type SpecialDealData, type SpecialDealData,

View file

@ -76,3 +76,69 @@ export async function incrementCacheVersion(): Promise<number> {
return nextValue return nextValue
}) })
} }
const AVAILABILITY_VERSION_KEY = CONST_KEYS.availabilityVersionNum
export async function getAvailabilityVersionNum(): Promise<number> {
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<number> {
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<number> {
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<number> {
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
})
}

View file

@ -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 { db } from '../db/db_index'
import { import {
productInfo, productInfo,
productMarketStats,
productSkus, productSkus,
skuFeatures, skuFeatures,
productCombos, productCombos,
@ -45,7 +45,42 @@ import type {
type ProductRow = InferSelectModel<typeof productInfo> type ProductRow = InferSelectModel<typeof productInfo>
type SkuRow = InferSelectModel<typeof productSkus> type SkuRow = InferSelectModel<typeof productSkus>
type MarketStatsRow = InferSelectModel<typeof productMarketStats>
type SkuFeatureRow = InferSelectModel<typeof skuFeatures> type SkuFeatureRow = InferSelectModel<typeof skuFeatures>
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<typeof units> type UnitRow = InferSelectModel<typeof units>
type StoreRow = InferSelectModel<typeof storeInfo> type StoreRow = InferSelectModel<typeof storeInfo>
type SpecialDealRow = InferSelectModel<typeof specialDeals> type SpecialDealRow = InferSelectModel<typeof specialDeals>
@ -94,18 +129,23 @@ const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({
featureValue: feature.featureValue, 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, id: sku.id,
productId: sku.productId, productId: sku.productId,
name: sku.name ?? null, name: sku.name ?? null,
price: String(sku.price ?? '0'), price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
images: getStringArray(sku.images), images: getStringArray(sku.images),
imageKeys: getStringArray(sku.images), imageKeys: getStringArray(sku.images),
isOutOfStock: sku.isOutOfStock, isOutOfStock: marketStats?.isOutOfStock ?? false,
isSuspended: sku.isSuspended, isSuspended: marketStats?.isSuspended ?? false,
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: marketStats?.isFlashAvailable ?? false,
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
isOffer: sku.isOffer, isOffer: sku.isOffer,
isComboOnly: sku.isComboOnly, isComboOnly: sku.isComboOnly,
createdAt: sku.createdAt, createdAt: sku.createdAt,
@ -134,7 +174,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[]; comboItems: any[] }> skus: Array<SkuRow & { features: SkuFeatureRow[]; comboItems: any[]; marketStats: MarketStatsRow | null }>
} }
const products = await db.query.productInfo.findMany({ const products = await db.query.productInfo.findMany({
orderBy: productInfo.name, orderBy: productInfo.name,
@ -143,9 +183,10 @@ export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
skus: { skus: {
with: { with: {
features: true, features: true,
marketStats: true,
comboItems: { comboItems: {
with: { with: {
sku: { with: { product: true, features: true } }, sku: { with: { product: true, features: true, marketStats: true } },
}, },
}, },
}, },
@ -163,9 +204,9 @@ export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
features: (ci.sku?.features || []).map((f: any) => ({ 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', productName: ci.sku?.product?.name ?? 'Unknown',
images: getStringArray(ci.sku?.images), 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<AdminProductWithDetail
skus: { skus: {
with: { with: {
features: true, features: true,
marketStats: true,
comboItems: { comboItems: {
with: { with: {
sku: { with: { product: true, features: true } }, sku: { with: { product: true, features: true, marketStats: true } },
}, },
}, },
}, },
@ -212,12 +254,12 @@ export async function getProductById(id: number): Promise<AdminProductWithDetail
const comboItems = (sku.comboItems || []).map((ci: any) => ({ const comboItems = (sku.comboItems || []).map((ci: any) => ({
skuId: ci.skuId, skuId: ci.skuId,
skuName: ci.sku?.name ?? null, 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', productName: ci.sku?.product?.name ?? 'Unknown',
images: getStringArray(ci.sku?.images), 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 { return {
@ -272,20 +314,26 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
skus.map((sku) => ({ skus.map((sku) => ({
productId: product.id, productId: product.id,
name: sku.name ?? null, name: sku.name ?? null,
price: String(sku.price),
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
images: sku.images ?? null, images: sku.images ?? null,
isFlashAvailable: sku.isFlashAvailable ?? false,
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
isOffer: sku.isOffer ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false, isComboOnly: sku.isComboOnly ?? false,
isSuspended: sku.isSuspended ?? false,
})) }))
).returning() ).returning()
for (let i = 0; i < skuRows.length; i++) { for (let i = 0; i < skuRows.length; i++) {
const skuRow = skuRows[i] const skuRow = skuRows[i]
const sku = skus[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( await db.insert(skuFeatures).values(
sku.features.map((f) => ({ sku.features.map((f) => ({
skuId: skuRow.id, skuId: skuRow.id,
@ -306,13 +354,13 @@ export async function createProduct(input: CreateProductInput): Promise<AdminPro
const createdSkus = await db.query.productSkus.findMany({ const createdSkus = await db.query.productSkus.findMany({
where: eq(productSkus.productId, product.id), where: eq(productSkus.productId, product.id),
with: { features: true }, with: { features: true, marketStats: true },
}) })
return { return {
...mapProduct(product), ...mapProduct(product),
store: null, store: null,
skus: createdSkus.map((s) => 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<AdminProduc
const comboIds = Array.from(new Set(comboMemberships.map((c) => c.comboSkuId))) const comboIds = Array.from(new Set(comboMemberships.map((c) => c.comboSkuId)))
if (comboIds.length > 0) { if (comboIds.length > 0) {
const combos = await db.query.productSkus.findMany({ const combos = await db.query.productMarketStats.findMany({
where: inArray(productSkus.id, comboIds), where: inArray(productMarketStats.skuId, comboIds),
columns: { id: true, isSuspended: true }, 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) { if (activeComboIds.length > 0) {
throw new Error( throw new Error(
`Cannot suspend SKU(s): they are members of combo(s) ${activeComboIds.join(', ')} that are not suspended` `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<AdminProduc
await db.update(productSkus) await db.update(productSkus)
.set({ .set({
name: sku.name ?? null, name: sku.name ?? null,
price: String(sku.price),
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
images: sku.images ?? null, images: sku.images ?? null,
isFlashAvailable: sku.isFlashAvailable ?? false,
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
isOffer: sku.isOffer ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false, isComboOnly: sku.isComboOnly ?? false,
isSuspended: sku.isSuspended ?? false,
}) })
.where(eq(productSkus.id, sku.id)) .where(eq(productSkus.id, sku.id))
const existingMarketStats = await db.query.productMarketStats.findFirst({
where: eq(productMarketStats.skuId, sku.id),
columns: { id: true },
})
const marketStatsValues = {
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,
}
if (existingMarketStats) {
await db.update(productMarketStats)
.set(marketStatsValues)
.where(eq(productMarketStats.skuId, sku.id))
} else {
await db.insert(productMarketStats).values({
skuId: sku.id,
...marketStatsValues,
})
}
await db.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id)) await db.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id))
await db.insert(skuFeatures).values( await db.insert(skuFeatures).values(
sku.features.map((f: any) => ({ sku.features.map((f: any) => ({
@ -421,16 +487,21 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
const [newSku] = await db.insert(productSkus).values({ const [newSku] = await db.insert(productSkus).values({
productId: id, productId: id,
name: sku.name ?? null, name: sku.name ?? null,
price: String(sku.price),
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
images: sku.images ?? null, images: sku.images ?? null,
isFlashAvailable: sku.isFlashAvailable ?? false,
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
isOffer: sku.isOffer ?? false, isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false, isComboOnly: sku.isComboOnly ?? false,
isSuspended: sku.isSuspended ?? false,
}).returning() }).returning()
await db.insert(productMarketStats).values({
skuId: newSku.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( await db.insert(skuFeatures).values(
sku.features.map((f: any) => ({ sku.features.map((f: any) => ({
skuId: newSku.id, skuId: newSku.id,
@ -447,7 +518,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
with: { with: {
store: true, store: true,
skus: { skus: {
with: { features: true }, with: { features: true, marketStats: true },
}, },
}, },
}) })
@ -459,7 +530,7 @@ export async function updateProduct(id: number, input: any): Promise<AdminProduc
return { return {
...mapProduct(updatedProduct), ...mapProduct(updatedProduct),
store: updatedProduct.store ? mapStore(updatedProduct.store) : null, store: updatedProduct.store ? mapStore(updatedProduct.store) : null,
skus: updatedProduct.skus.map((s) => 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 { productId, price, marketPrice, flashPrice, isFlashAvailable } = update
const updateData: any = {} 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 (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
await tx if (Object.keys(updateData).length === 0) continue
.update(productSkus)
.set(updateData) const existingMarketStats = await tx.query.productMarketStats.findFirst({
.where(eq(productSkus.id, productId)) 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( export async function createSpecialDealsForSku(
productId: number, skuId: number,
deals: CreateSpecialDealInput[] deals: CreateSpecialDealInput[]
): Promise<AdminSpecialDeal[]> { ): Promise<AdminSpecialDeal[]> {
if (deals.length === 0) { if (deals.length === 0) {
@ -964,7 +1052,7 @@ export async function createSpecialDealsForSku(
} }
const dealInserts = deals.map((deal) => ({ const dealInserts = deals.map((deal) => ({
productId, skuId,
quantity: deal.quantity.toString(), quantity: deal.quantity.toString(),
price: deal.price.toString(), price: deal.price.toString(),
validTill: new Date(deal.validTill), validTill: new Date(deal.validTill),
@ -1025,7 +1113,7 @@ export async function updateSkuDeals(
if (dealsToAdd.length > 0) { if (dealsToAdd.length > 0) {
const dealInserts = dealsToAdd.map((deal) => ({ const dealInserts = dealsToAdd.map((deal) => ({
productId, skuId: productId,
quantity: deal.quantity.toString(), quantity: deal.quantity.toString(),
price: deal.price.toString(), price: deal.price.toString(),
validTill: new Date(deal.validTill), validTill: new Date(deal.validTill),

View file

@ -201,18 +201,23 @@ export const productSkus = sqliteTable('product_skus', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
productId: integer('product_id').notNull().references(() => productInfo.id), productId: integer('product_id').notNull().references(() => productInfo.id),
name: text(), name: text(),
price: numericText('price').notNull(),
marketPrice: numericText('market_price'),
images: jsonText<string[] | null>('images'), images: jsonText<string[] | null>('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), isOffer: integer('is_offer', { mode: 'boolean' }).notNull().default(false),
isComboOnly: integer('is_combo_only', { mode: 'boolean' }).notNull().default(false), isComboOnly: integer('is_combo_only', { mode: 'boolean' }).notNull().default(false),
createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), 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', { export const skuFeatures = sqliteTable('sku_features', {
id: integer().primaryKey({ autoIncrement: true }), id: integer().primaryKey({ autoIncrement: true }),
skuId: integer('sku_id').notNull().references(() => productSkus.id), 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 }) => ({ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({
product: one(productInfo, { fields: [productSkus.productId], references: [productInfo.id] }), product: one(productInfo, { fields: [productSkus.productId], references: [productInfo.id] }),
features: many(skuFeatures), features: many(skuFeatures),
marketStats: one(productMarketStats),
specialDeals: many(specialDeals), specialDeals: many(specialDeals),
orderItems: many(orderItems), orderItems: many(orderItems),
cartItems: many(cartItems), cartItems: many(cartItems),
@ -597,6 +603,10 @@ export const productSkusRelations = relations(productSkus, ({ one, many }) => ({
comboItems: many(productCombos, { relationName: 'comboSku' }), 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 }) => ({ export const skuFeaturesRelations = relations(skuFeatures, ({ one }) => ({
sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }), sku: one(productSkus, { fields: [skuFeatures.skuId], references: [productSkus.id] }),
})) }))

View file

@ -13,6 +13,8 @@ export const CONST_KEYS = {
readableOrderId: 'readableOrderId', readableOrderId: 'readableOrderId',
versionNum: 'versionNum', versionNum: 'versionNum',
cacheVersion: 'cache_version', cacheVersion: 'cache_version',
availabilityVersionNum: 'availability_version_num',
slotsVersionNum: 'slots_version_num',
playStoreUrl: 'playStoreUrl', playStoreUrl: 'playStoreUrl',
appStoreUrl: 'appStoreUrl', appStoreUrl: 'appStoreUrl',
popularItems: 'popularItems', popularItems: 'popularItems',
@ -37,6 +39,8 @@ export const CONST_LABELS: Record<ConstKey, string> = {
readableOrderId: 'Readable Order ID', readableOrderId: 'Readable Order ID',
versionNum: 'Version Number', versionNum: 'Version Number',
'cache_version': 'Cache Version', 'cache_version': 'Cache Version',
availability_version_num: 'Availability Cache Version',
slots_version_num: 'Slots Cache Version',
playStoreUrl: 'Play Store URL', playStoreUrl: 'Play Store URL',
appStoreUrl: 'App Store URL', appStoreUrl: 'App Store URL',
popularItems: 'Popular Items', popularItems: 'Popular Items',
@ -67,6 +71,8 @@ export const CONST_TYPES: Record<ConstKey, ConstValueType> = {
readableOrderId: 'number', readableOrderId: 'number',
versionNum: 'string', versionNum: 'string',
'cache_version': 'number', 'cache_version': 'number',
availability_version_num: 'number',
slots_version_num: 'number',
playStoreUrl: 'string', playStoreUrl: 'string',
appStoreUrl: 'string', appStoreUrl: 'string',
popularItems: 'string', popularItems: 'string',
@ -91,6 +97,8 @@ export const CONST_VISIBILITY: Record<ConstKey, boolean> = {
readableOrderId: false, readableOrderId: false,
versionNum: true, versionNum: true,
'cache_version': false, 'cache_version': false,
availability_version_num: false,
slots_version_num: false,
playStoreUrl: true, playStoreUrl: true,
appStoreUrl: true, appStoreUrl: true,
popularItems: true, popularItems: true,

View file

@ -5,6 +5,7 @@ import { db } from '../db/db_index'
import { import {
homeBanners, homeBanners,
productInfo, productInfo,
productMarketStats,
productSkus, productSkus,
skuFeatures, skuFeatures,
deliverySlotInfo, deliverySlotInfo,
@ -61,6 +62,16 @@ export interface ProductBasicData {
productType: string productType: string
} }
export interface AvailabilityCacheData {
id: number
price: string
marketPrice: string | null
flashPrice: string | null
isFlashAvailable: boolean
isOutOfStock: boolean
isSuspended: boolean
}
export interface StoreBasicData { export interface StoreBasicData {
id: number id: number
name: string name: string
@ -107,15 +118,18 @@ export interface ProductTagData {
export async function getAllProductsForCache(): Promise<ProductBasicData[]> { export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: { with: {
product: true, product: true,
features: true, features: true,
marketStats: true,
}, },
}) })
return skus.map((sku) => { return skus
.filter((sku) => !sku.marketStats?.isSuspended)
.map((sku) => {
const features = sku.features || [] const features = sku.features || []
const marketStats = sku.marketStats
return { return {
id: sku.id, id: sku.id,
productId: sku.productId, productId: sku.productId,
@ -123,21 +137,37 @@ export async function getAllProductsForCache(): Promise<ProductBasicData[]> {
skuName: sku.name ?? null, skuName: sku.name ?? null,
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
longDescription: sku.product?.longDescription ?? null, longDescription: sku.product?.longDescription ?? null,
price: String(sku.price ?? '0'), price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
images: sku.images, images: sku.images,
isOutOfStock: sku.isOutOfStock, isOutOfStock: marketStats?.isOutOfStock ?? false,
storeId: sku.product?.storeId ?? null, storeId: sku.product?.storeId ?? null,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
incrementStep: sku.product?.incrementStep ?? 1, incrementStep: sku.product?.incrementStep ?? 1,
productQuantity: 1, productQuantity: 1,
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: marketStats?.isFlashAvailable ?? false,
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
productType: sku.product?.productType ?? 'item', productType: sku.product?.productType ?? 'item',
} }
}) })
} }
export async function getAvailabilityForCache(): Promise<AvailabilityCacheData[]> {
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<StoreBasicData[]> { export async function getAllStoresForCache(): Promise<StoreBasicData[]> {
return db.query.storeInfo.findMany({ return db.query.storeInfo.findMany({
columns: { id: true, name: true, description: true }, columns: { id: true, name: true, description: true },
@ -192,20 +222,21 @@ export interface ProductComboCacheData {
images: unknown images: unknown
unitNotation: string unitNotation: string
price: string price: string
isOffer: boolean
} }
export async function getAllProductCombosForCache(): Promise<ProductComboCacheData[]> { export async function getAllProductCombosForCache(): Promise<ProductComboCacheData[]> {
const results = await db.query.productCombos.findMany({ const results = await db.query.productCombos.findMany({
with: { with: {
sku: { with: { product: true, features: true } }, sku: { with: { product: true, features: true, marketStats: true } },
}, },
}) })
const suspendedSkuIds = new Set( const suspendedSkuIds = new Set(
(await db (await db
.select({ id: productSkus.id }) .select({ id: productMarketStats.skuId })
.from(productSkus) .from(productMarketStats)
.where(eq(productSkus.isSuspended, true))).map((r) => r.id) .where(eq(productMarketStats.isSuspended, true))).map((r) => r.id)
) )
return results return results
@ -219,7 +250,8 @@ export async function getAllProductCombosForCache(): Promise<ProductComboCacheDa
skuName: ci.sku?.name ?? null, skuName: ci.sku?.name ?? null,
images: ci.sku?.images, images: ci.sku?.images,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
price: String(ci.sku?.price ?? '0'), price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
isOffer: ci.sku?.isOffer ?? false,
} }
}) })
} }
@ -317,15 +349,15 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
let skusData: any[] = [] let skusData: any[] = []
if (skuIdsArray.length > 0) { if (skuIdsArray.length > 0) {
skusData = await db.query.productSkus.findMany({ skusData = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: { with: {
product: { product: {
with: { store: true }, with: { store: true },
}, },
features: 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])) const skuMap = new Map(skusData.map((s: any) => [s.id, s]))
@ -341,6 +373,7 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
.filter((p): p is NonNullable<typeof p> => p != null) .filter((p): p is NonNullable<typeof p> => p != null)
.map((sku: any) => { .map((sku: any) => {
const features = sku.features || [] const features = sku.features || []
const marketStats = sku.marketStats
return { return {
id: sku.id, id: sku.id,
productId: sku.productId, productId: sku.productId,
@ -348,8 +381,8 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
skuName: sku.name ?? null, skuName: sku.name ?? null,
productQuantity: 1, productQuantity: 1,
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
price: String(sku.price ?? '0'), price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
store: sku.product?.store ? { store: sku.product?.store ? {
id: sku.product.store.id, id: sku.product.store.id,
@ -357,10 +390,10 @@ export async function getAllSlotsWithProductsForCache(): Promise<SlotWithProduct
description: sku.product.store.description description: sku.product.store.description
} : null, } : null,
images: sku.images, images: sku.images,
isOutOfStock: sku.isOutOfStock, isOutOfStock: marketStats?.isOutOfStock ?? false,
storeId: sku.product?.storeId ?? null, storeId: sku.product?.storeId ?? null,
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: marketStats?.isFlashAvailable ?? false,
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null, flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
} }
}), }),
})) as SlotWithProductsData[] })) as SlotWithProductsData[]

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index' import { db } from '../db/db_index'
import { deliverySlotInfo, productInfo, productCombos, productSkus, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema' import { deliverySlotInfo, productInfo, productCombos, productSkus, productMarketStats, 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'
import { composeSkuName, composeUnitNotation } from '../lib/sku-features' import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
@ -11,19 +11,24 @@ const getStringArray = (value: unknown): string[] | null => {
export async function getProductDetailById(skuId: number): Promise<UserProductDetailData | null> { export async function getProductDetailById(skuId: number): Promise<UserProductDetailData | null> {
const sku = await db.query.productSkus.findFirst({ const sku = await db.query.productSkus.findFirst({
where: and(eq(productSkus.id, skuId), eq(productSkus.isSuspended, false)), where: eq(productSkus.id, skuId),
with: { with: {
product: true, product: true,
features: true, features: true,
marketStats: true,
}, },
}) })
if (!sku) { if (!sku) {
return null return null
} }
if (sku.marketStats?.isSuspended) {
return null
}
const features = sku.features || [] const features = sku.features || []
const product = sku.product const product = sku.product
const marketStats = sku.marketStats
const storeData = product?.storeId ? await db.query.storeInfo.findFirst({ const storeData = product?.storeId ? await db.query.storeInfo.findFirst({
where: eq(storeInfo.id, product.storeId), where: eq(storeInfo.id, product.storeId),
@ -48,7 +53,7 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
const comboItemsData = await db.query.productCombos.findMany({ const comboItemsData = await db.query.productCombos.findMany({
where: eq(productCombos.comboSkuId, skuId), where: eq(productCombos.comboSkuId, skuId),
with: { with: {
sku: { with: { product: true, features: true } }, sku: { with: { product: true, features: true, marketStats: true } },
}, },
}) })
@ -60,7 +65,8 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
unitNotation: composeUnitNotation(ciFeatures), unitNotation: composeUnitNotation(ciFeatures),
productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures), productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures),
images: getStringArray(ci.sku?.images), images: getStringArray(ci.sku?.images),
price: String(ci.sku?.price ?? '0'), price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
isOffer: ci.sku?.isOffer ?? false,
} }
}) })
@ -70,11 +76,11 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
name: composeSkuName(product?.name ?? 'Unknown', features), name: composeSkuName(product?.name ?? 'Unknown', features),
shortDescription: product?.shortDescription ?? null, shortDescription: product?.shortDescription ?? null,
longDescription: product?.longDescription ?? null, longDescription: product?.longDescription ?? null,
price: String(sku.price ?? '0'), price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
images: getStringArray(sku.images), images: getStringArray(sku.images),
isOutOfStock: sku.isOutOfStock, isOutOfStock: marketStats?.isOutOfStock ?? false,
store: storeData ? { store: storeData ? {
id: storeData.id, id: storeData.id,
name: storeData.name, name: storeData.name,
@ -82,8 +88,8 @@ export async function getProductDetailById(skuId: number): Promise<UserProductDe
} : null, } : null,
incrementStep: product?.incrementStep ?? 1, incrementStep: product?.incrementStep ?? 1,
productQuantity: 1, productQuantity: 1,
isFlashAvailable: sku.isFlashAvailable, isFlashAvailable: marketStats?.isFlashAvailable ?? false,
flashPrice: sku.flashPrice?.toString() || null, flashPrice: marketStats?.flashPrice?.toString() || null,
deliverySlots: [], deliverySlots: [],
specialDeals: specialDealsData.map((deal) => ({ specialDeals: specialDealsData.map((deal) => ({
quantity: String(deal.quantity ?? '0'), quantity: String(deal.quantity ?? '0'),
@ -202,30 +208,32 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
} }
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: { with: {
product: true, product: true,
features: true, features: true,
marketStats: true,
}, },
}) })
return skus return skus
.filter((sku) => { .filter((sku) => {
if (sku.marketStats?.isSuspended) return false
if (!tagId) return true if (!tagId) return true
return taggedProductIdSet.has(sku.productId) return taggedProductIdSet.has(sku.productId)
}) })
.map((sku) => { .map((sku) => {
const features = sku.features || [] const features = sku.features || []
const marketStats = sku.marketStats
return { return {
id: sku.product?.id ?? 0, id: sku.product?.id ?? 0,
name: composeSkuName(sku.product?.name ?? 'Unknown', features), name: composeSkuName(sku.product?.name ?? 'Unknown', features),
skuId: sku.id, skuId: sku.id,
skuName: sku.name ?? null, skuName: sku.name ?? null,
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
price: String(sku.price ?? '0'), price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
images: sku.images, images: sku.images,
isOutOfStock: sku.isOutOfStock, isOutOfStock: marketStats?.isOutOfStock ?? false,
unitShortNotation: composeUnitNotation(features), unitShortNotation: composeUnitNotation(features),
productQuantity: 1, productQuantity: 1,
features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })), features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })),
@ -238,9 +246,9 @@ export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSu
*/ */
export async function getSuspendedSkuIds(): Promise<number[]> { export async function getSuspendedSkuIds(): Promise<number[]> {
const suspendedSkus = await db const suspendedSkus = await db
.select({ id: productSkus.id }) .select({ id: productMarketStats.skuId })
.from(productSkus) .from(productMarketStats)
.where(eq(productSkus.isSuspended, true)) .where(eq(productMarketStats.isSuspended, true))
return suspendedSkus.map(sp => sp.id) return suspendedSkus.map(sp => sp.id)
} }
@ -279,16 +287,18 @@ export interface SkuSummary {
export async function getAllSkusSummary(): Promise<SkuSummary[]> { export async function getAllSkusSummary(): Promise<SkuSummary[]> {
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: { with: {
features: true, features: true,
marketStats: true,
product: { product: {
columns: { name: true }, 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 featureValues = (sku.features || []).map((f) => f.featureValue)
const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ') const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ')
return { return {
@ -319,10 +329,12 @@ export interface OffersPageData {
const mapOffersPageProduct = (sku: { const mapOffersPageProduct = (sku: {
id: number id: number
price: string | null marketStats: {
marketPrice: string | null ourPrice: string | null
marketPrice: string | null
isOutOfStock: boolean
} | null
images: unknown images: unknown
isOutOfStock: boolean
product: { name: string; incrementStep: number | null } | null product: { name: string; incrementStep: number | null } | null
features: Array<{ featureValue: string }> features: Array<{ featureValue: string }>
}): OffersPageProductData => { }): OffersPageProductData => {
@ -330,21 +342,21 @@ const mapOffersPageProduct = (sku: {
return { return {
id: sku.id, id: sku.id,
name: composeSkuName(sku.product?.name ?? 'Unknown', features), name: composeSkuName(sku.product?.name ?? 'Unknown', features),
price: String(sku.price ?? '0'), price: sku.marketStats?.ourPrice ? String(sku.marketStats.ourPrice) : '0',
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: sku.marketStats?.marketPrice ? String(sku.marketStats.marketPrice) : null,
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
images: sku.images, images: sku.images,
isOutOfStock: sku.isOutOfStock, isOutOfStock: sku.marketStats?.isOutOfStock ?? false,
incrementStep: sku.product?.incrementStep ?? 1, incrementStep: sku.product?.incrementStep ?? 1,
} }
} }
export async function getOffersAndCombos(): Promise<OffersPageData> { export async function getOffersAndCombos(): Promise<OffersPageData> {
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false),
with: { with: {
product: true, product: true,
features: true, features: true,
marketStats: true,
}, },
}) })
@ -352,6 +364,7 @@ export async function getOffersAndCombos(): Promise<OffersPageData> {
const offers: OffersPageProductData[] = [] const offers: OffersPageProductData[] = []
for (const sku of skus) { for (const sku of skus) {
if (sku.marketStats?.isSuspended) continue
if (sku.product?.productType === 'combo') { if (sku.product?.productType === 'combo') {
combos.push(mapOffersPageProduct(sku)) combos.push(mapOffersPageProduct(sku))
} }

View file

@ -1,5 +1,5 @@
import { db } from '../db/db_index' 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 { asc, eq } from 'drizzle-orm'
import type { InferSelectModel } from 'drizzle-orm' import type { InferSelectModel } from 'drizzle-orm'
import type { UserDeliverySlot, UserSlotAvailability } from '@packages/shared' import type { UserDeliverySlot, UserSlotAvailability } from '@packages/shared'
@ -27,17 +27,16 @@ export async function getActiveSlotsList(): Promise<UserDeliverySlot[]> {
} }
export async function getProductAvailability(): Promise<UserSlotAvailability[]> { export async function getProductAvailability(): Promise<UserSlotAvailability[]> {
const skus = await db.query.productSkus.findMany({ const stats = await db.query.productMarketStats.findMany({
where: eq(productSkus.isSuspended, false), where: eq(productMarketStats.isSuspended, false),
with: { columns: {
product: { columns: { name: true } }, skuId: true,
isOutOfStock: true,
}, },
}) })
return skus.map((sku) => ({ return stats.map((stat) => ({
id: sku.id, id: stat.skuId,
name: sku.product?.name ?? 'Unknown', isOutOfStock: stat.isOutOfStock,
isOutOfStock: sku.isOutOfStock,
isFlashAvailable: sku.isFlashAvailable,
})) }))
} }

View file

@ -21,13 +21,13 @@ export async function getStoreSummaries(): Promise<UserStoreSummaryData[]> {
}).from(storeInfo) }).from(storeInfo)
const skus = await db.query.productSkus.findMany({ const skus = await db.query.productSkus.findMany({
where: eq(productSkus.isSuspended, false), with: { product: true, marketStats: true },
with: { product: true },
orderBy: asc(productSkus.id), orderBy: asc(productSkus.id),
}) })
const activeSkus = skus.filter((sku) => !sku.marketStats?.isSuspended)
const skusByStore = new Map<number, typeof skus>() const skusByStore = new Map<number, typeof skus>()
for (const sku of skus) { for (const sku of activeSkus) {
const storeId = sku.product?.storeId const storeId = sku.product?.storeId
if (storeId == null) continue if (storeId == null) continue
if (!skusByStore.has(storeId)) skusByStore.set(storeId, []) if (!skusByStore.has(storeId)) skusByStore.set(storeId, [])
@ -77,30 +77,31 @@ export async function getStoreDetail(storeId: number): Promise<UserStoreDetailDa
const skus = productIdArr.length > 0 const skus = productIdArr.length > 0
? await db.query.productSkus.findMany({ ? await db.query.productSkus.findMany({
where: and( where: inArray(productSkus.productId, productIdArr),
inArray(productSkus.productId, productIdArr),
eq(productSkus.isSuspended, false)
),
with: { with: {
product: true, product: true,
features: 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 features = sku.features || []
const marketStats = sku.marketStats
return { return {
id: sku.id, id: sku.id,
name: composeSkuName(sku.product?.name ?? 'Unknown', features), name: composeSkuName(sku.product?.name ?? 'Unknown', features),
shortDescription: sku.product?.shortDescription ?? null, shortDescription: sku.product?.shortDescription ?? null,
price: String(sku.price ?? '0'), price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null, marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
incrementStep: sku.product?.incrementStep ?? 1, incrementStep: sku.product?.incrementStep ?? 1,
unit: composeUnitNotation(features), unit: composeUnitNotation(features),
unitNotation: composeUnitNotation(features), unitNotation: composeUnitNotation(features),
images: getStringArray(sku.images), images: getStringArray(sku.images),
isOutOfStock: sku.isOutOfStock, isOutOfStock: marketStats?.isOutOfStock ?? false,
productQuantity: 1, productQuantity: 1,
} }
}) })

View file

@ -2,6 +2,7 @@ export const CACHE_FILENAMES = {
products: 'products.json', products: 'products.json',
stores: 'stores.json', stores: 'stores.json',
slots: 'slots.json', slots: 'slots.json',
availability: 'availability.json',
essentialConsts: 'essential-consts.json', essentialConsts: 'essential-consts.json',
banners: 'banners.json', banners: 'banners.json',
} as const } as const

View file

@ -267,6 +267,7 @@ export interface UserProductComboItem {
productName: string; productName: string;
images: string[] | null; images: string[] | null;
price: string; price: string;
isOffer: boolean;
} }
export interface UserProductDetailData { export interface UserProductDetailData {
@ -320,32 +321,34 @@ export interface UserCreateReviewResponse {
export interface UserSlotProduct { export interface UserSlotProduct {
id: number; id: number;
name: string; images: string[] | null;
shortDescription: string | null;
productQuantity: number;
price: string;
marketPrice: string | null;
unit: string | null;
images: string[];
isOutOfStock: boolean;
storeId: number | null;
nextDeliveryDate: Date;
} }
export interface UserSlotWithProducts { export interface UserSlotWithProducts {
id: number; id: number;
deliveryTime: Date; deliveryTime: Date;
freezeTime: Date; freezeTime: Date;
isActive: boolean;
isCapacityFull: boolean;
products: UserSlotProduct[]; products: UserSlotProduct[];
} }
export interface UserSlotAvailability { export interface UserSlotAvailability {
id: number; id: number;
name: string;
isOutOfStock: boolean; isOutOfStock: boolean;
}
export interface UserAvailabilityEntry {
id: number;
price: string;
marketPrice: string | null;
flashPrice: string | null;
isFlashAvailable: boolean; isFlashAvailable: boolean;
isOutOfStock: boolean;
isSuspended: boolean;
}
export interface UserAvailabilityResponse {
availability: UserAvailabilityEntry[];
count: number;
} }
export interface UserDeliverySlot { export interface UserDeliverySlot {