393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
import { db } from '../db/db_index'
|
|
import { deliverySlotInfo, productInfo, productCombos, productSkus, productMarketStats, productReviews, productTags, specialDeals, storeInfo, users } from '../db/schema'
|
|
import { and, desc, eq, gt, sql } from 'drizzle-orm'
|
|
import type { UserProductDetailData, UserProductReview } from '@packages/shared'
|
|
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
|
|
|
const getStringArray = (value: unknown): string[] | null => {
|
|
if (!Array.isArray(value)) return null
|
|
return value.map((item) => String(item))
|
|
}
|
|
|
|
export async function getProductDetailById(skuId: number): Promise<UserProductDetailData | null> {
|
|
const sku = await db.query.productSkus.findFirst({
|
|
where: eq(productSkus.id, skuId),
|
|
with: {
|
|
product: true,
|
|
features: true,
|
|
marketStats: true,
|
|
},
|
|
})
|
|
|
|
if (!sku) {
|
|
return null
|
|
}
|
|
if (sku.marketStats?.isSuspended || sku.isDeleted) {
|
|
return null
|
|
}
|
|
|
|
const features = sku.features || []
|
|
const product = sku.product
|
|
const marketStats = sku.marketStats
|
|
|
|
const storeData = product?.storeId ? await db.query.storeInfo.findFirst({
|
|
where: eq(storeInfo.id, product.storeId),
|
|
columns: { id: true, name: true, description: true },
|
|
}) : null
|
|
|
|
const specialDealsData = await db
|
|
.select({
|
|
quantity: specialDeals.quantity,
|
|
price: specialDeals.price,
|
|
validTill: specialDeals.validTill,
|
|
})
|
|
.from(specialDeals)
|
|
.where(
|
|
and(
|
|
eq(specialDeals.skuId, skuId),
|
|
gt(specialDeals.validTill, sql`CURRENT_TIMESTAMP`)
|
|
)
|
|
)
|
|
.orderBy(specialDeals.quantity)
|
|
|
|
const comboItemsData = await db.query.productCombos.findMany({
|
|
where: eq(productCombos.comboSkuId, skuId),
|
|
with: {
|
|
sku: { with: { product: true, features: true, marketStats: true } },
|
|
},
|
|
})
|
|
|
|
const comboItems = comboItemsData.map((ci) => {
|
|
const ciFeatures = ci.sku?.features || []
|
|
return {
|
|
skuId: ci.skuId,
|
|
skuName: ci.sku?.name ?? null,
|
|
unitNotation: composeUnitNotation(ciFeatures),
|
|
productName: composeSkuName(ci.sku?.product?.name ?? 'Unknown', ciFeatures, ci.sku?.name),
|
|
images: getStringArray(ci.sku?.images),
|
|
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
|
|
isOffer: ci.sku?.isOffer ?? false,
|
|
}
|
|
})
|
|
|
|
return {
|
|
id: sku.id,
|
|
productId: sku.productId,
|
|
name: composeSkuName(product?.name ?? 'Unknown', features, sku.name),
|
|
shortDescription: product?.shortDescription ?? null,
|
|
longDescription: product?.longDescription ?? null,
|
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
|
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
|
unitNotation: composeUnitNotation(features),
|
|
images: getStringArray(sku.images),
|
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
|
store: storeData ? {
|
|
id: storeData.id,
|
|
name: storeData.name,
|
|
description: storeData.description ?? null,
|
|
} : null,
|
|
incrementStep: product?.incrementStep ?? 1,
|
|
productQuantity: 1,
|
|
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
|
|
flashPrice: marketStats?.flashPrice?.toString() || null,
|
|
deliverySlots: [],
|
|
specialDeals: specialDealsData.map((deal) => ({
|
|
quantity: String(deal.quantity ?? '0'),
|
|
price: String(deal.price ?? '0'),
|
|
validTill: deal.validTill,
|
|
})),
|
|
productType: product?.productType ?? 'item',
|
|
comboItems,
|
|
}
|
|
}
|
|
|
|
export async function getProductReviews(productId: number, limit: number, offset: number) {
|
|
const reviews = await db
|
|
.select({
|
|
id: productReviews.id,
|
|
reviewBody: productReviews.reviewBody,
|
|
ratings: productReviews.ratings,
|
|
imageUrls: productReviews.imageUrls,
|
|
reviewTime: productReviews.reviewTime,
|
|
userName: users.name,
|
|
})
|
|
.from(productReviews)
|
|
.innerJoin(users, eq(productReviews.userId, users.id))
|
|
.where(eq(productReviews.productId, productId))
|
|
.orderBy(desc(productReviews.reviewTime))
|
|
.limit(limit)
|
|
.offset(offset)
|
|
|
|
const totalCountResult = await db
|
|
.select({ count: sql`count(*)` })
|
|
.from(productReviews)
|
|
.where(eq(productReviews.productId, productId))
|
|
|
|
const totalCount = Number(totalCountResult[0].count)
|
|
|
|
const mappedReviews: UserProductReview[] = reviews.map((review) => ({
|
|
id: review.id,
|
|
reviewBody: review.reviewBody,
|
|
ratings: review.ratings,
|
|
imageUrls: getStringArray(review.imageUrls),
|
|
reviewTime: review.reviewTime,
|
|
userName: review.userName ?? null,
|
|
}))
|
|
|
|
return {
|
|
reviews: mappedReviews,
|
|
totalCount,
|
|
}
|
|
}
|
|
|
|
export async function getProductById(skuId: number) {
|
|
const sku = await db.query.productSkus.findFirst({
|
|
where: eq(productSkus.id, skuId),
|
|
with: {
|
|
marketStats: true,
|
|
product: true,
|
|
},
|
|
})
|
|
|
|
if (!sku) {
|
|
return null
|
|
}
|
|
if (sku.isDeleted) {
|
|
return null
|
|
}
|
|
|
|
const marketStats = sku.marketStats
|
|
|
|
return {
|
|
...sku,
|
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
|
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
|
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
|
|
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
|
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
|
}
|
|
}
|
|
|
|
export async function createProductReview(
|
|
userId: number,
|
|
productId: number,
|
|
reviewBody: string,
|
|
ratings: number,
|
|
imageUrls: string[]
|
|
): Promise<UserProductReview> {
|
|
const [newReview] = await db.insert(productReviews).values({
|
|
userId,
|
|
productId,
|
|
reviewBody,
|
|
ratings,
|
|
imageUrls,
|
|
}).returning({
|
|
id: productReviews.id,
|
|
reviewBody: productReviews.reviewBody,
|
|
ratings: productReviews.ratings,
|
|
imageUrls: productReviews.imageUrls,
|
|
reviewTime: productReviews.reviewTime,
|
|
})
|
|
|
|
return {
|
|
id: newReview.id,
|
|
reviewBody: newReview.reviewBody,
|
|
ratings: newReview.ratings,
|
|
imageUrls: getStringArray(newReview.imageUrls),
|
|
reviewTime: newReview.reviewTime,
|
|
userName: null,
|
|
}
|
|
}
|
|
|
|
export interface ProductSummaryData {
|
|
id: number
|
|
name: string
|
|
skuId: number
|
|
skuName: string | null
|
|
shortDescription: string | null
|
|
price: string
|
|
marketPrice: string | null
|
|
images: unknown
|
|
isOutOfStock: boolean
|
|
unitShortNotation: string
|
|
productQuantity: number
|
|
features: { featureName: string | null; featureValue: string }[]
|
|
}
|
|
|
|
export async function getAllProductsWithUnits(tagId?: number): Promise<ProductSummaryData[]> {
|
|
const taggedProductIdSet = new Set<number>()
|
|
|
|
// If tagId is provided, get product IDs that have this tag
|
|
if (tagId) {
|
|
const taggedProducts = await db
|
|
.select({ productId: productTags.productId })
|
|
.from(productTags)
|
|
.where(eq(productTags.tagId, tagId))
|
|
|
|
for (const tp of taggedProducts) {
|
|
taggedProductIdSet.add(tp.productId)
|
|
}
|
|
}
|
|
|
|
const skus = await db.query.productSkus.findMany({
|
|
with: {
|
|
product: true,
|
|
features: true,
|
|
marketStats: true,
|
|
},
|
|
})
|
|
|
|
return skus
|
|
.filter((sku) => {
|
|
if (sku.marketStats?.isSuspended) return false
|
|
if (sku.isDeleted) return false
|
|
if (!tagId) return true
|
|
// product_tags.product_id now holds SKU ids — compare against sku.id
|
|
return taggedProductIdSet.has(sku.id)
|
|
})
|
|
.map((sku) => {
|
|
const features = sku.features || []
|
|
const marketStats = sku.marketStats
|
|
return {
|
|
id: sku.product?.id ?? 0,
|
|
name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name),
|
|
skuId: sku.id,
|
|
skuName: sku.name ?? null,
|
|
shortDescription: sku.product?.shortDescription ?? null,
|
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
|
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
|
images: sku.images,
|
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
|
unitShortNotation: composeUnitNotation(features),
|
|
productQuantity: 1,
|
|
features: features.map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })),
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Get all suspended product IDs
|
|
*/
|
|
export async function getSuspendedSkuIds(): Promise<number[]> {
|
|
const suspendedSkus = await db
|
|
.select({ id: productMarketStats.skuId })
|
|
.from(productMarketStats)
|
|
.where(eq(productMarketStats.isSuspended, true))
|
|
|
|
return suspendedSkus.map(sp => sp.id)
|
|
}
|
|
|
|
/**
|
|
* Get next delivery date for a product (with capacity check)
|
|
* This version filters by both isActive AND isCapacityFull
|
|
*/
|
|
export async function getNextDeliveryDateWithCapacity(skuId: number): Promise<Date | null> {
|
|
const slots = await db.query.deliverySlotInfo.findMany({
|
|
where: and(
|
|
eq(deliverySlotInfo.isActive, true),
|
|
eq(deliverySlotInfo.isCapacityFull, false),
|
|
gt(deliverySlotInfo.deliveryTime, sql`CURRENT_TIMESTAMP`)
|
|
),
|
|
orderBy: desc(deliverySlotInfo.deliveryTime),
|
|
})
|
|
|
|
for (const slot of slots) {
|
|
const skuIds = slot.skuIds || []
|
|
if (skuIds.includes(skuId)) {
|
|
return slot.deliveryTime
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// Single source in @packages/shared (admin.ts); re-exported for module compat.
|
|
import type { ProductSummaryCore, SkuSummary } from '@packages/shared'
|
|
export type { SkuSummary } from '@packages/shared'
|
|
|
|
export async function getAllSkusSummary(): Promise<SkuSummary[]> {
|
|
const skus = await db.query.productSkus.findMany({
|
|
with: {
|
|
features: true,
|
|
marketStats: true,
|
|
product: {
|
|
columns: { name: true, storeId: true },
|
|
},
|
|
},
|
|
})
|
|
|
|
return skus
|
|
.filter((sku) => !sku.marketStats?.isSuspended && !sku.isDeleted)
|
|
.map((sku) => {
|
|
const featureValues = (sku.features || []).map((f) => f.featureValue)
|
|
const label = [sku.product?.name ?? 'Unknown', ...featureValues].join(' ')
|
|
return {
|
|
id: sku.id,
|
|
productId: sku.productId,
|
|
productName: sku.product?.name ?? 'Unknown',
|
|
label,
|
|
images: sku.images,
|
|
storeId: sku.product?.storeId ?? null,
|
|
price: sku.marketStats?.ourPrice ? String(sku.marketStats.ourPrice) : '0',
|
|
}
|
|
})
|
|
}
|
|
|
|
export interface OffersPageProductData extends ProductSummaryCore {
|
|
}
|
|
|
|
export interface OffersPageData {
|
|
combos: OffersPageProductData[]
|
|
offers: OffersPageProductData[]
|
|
}
|
|
|
|
const mapOffersPageProduct = (sku: {
|
|
id: number
|
|
marketStats: {
|
|
ourPrice: string | null
|
|
marketPrice: string | null
|
|
isOutOfStock: boolean
|
|
} | null
|
|
images: unknown
|
|
name: string | null
|
|
product: { name: string; incrementStep: number | null } | null
|
|
features: Array<{ featureValue: string }>
|
|
}): OffersPageProductData => {
|
|
const features = sku.features || []
|
|
return {
|
|
id: sku.id,
|
|
name: composeSkuName(sku.product?.name ?? 'Unknown', features, sku.name),
|
|
price: sku.marketStats?.ourPrice ? String(sku.marketStats.ourPrice) : '0',
|
|
marketPrice: sku.marketStats?.marketPrice ? String(sku.marketStats.marketPrice) : null,
|
|
unitNotation: composeUnitNotation(features),
|
|
images: (sku.images ?? null) as string[] | null,
|
|
isOutOfStock: sku.marketStats?.isOutOfStock ?? false,
|
|
incrementStep: sku.product?.incrementStep ?? 1,
|
|
}
|
|
}
|
|
|
|
export async function getOffersAndCombos(): Promise<OffersPageData> {
|
|
const skus = await db.query.productSkus.findMany({
|
|
with: {
|
|
product: true,
|
|
features: true,
|
|
marketStats: true,
|
|
},
|
|
})
|
|
|
|
const combos: OffersPageProductData[] = []
|
|
const offers: OffersPageProductData[] = []
|
|
|
|
for (const sku of skus) {
|
|
if (sku.marketStats?.isSuspended) continue
|
|
if (sku.isDeleted) continue
|
|
if (sku.product?.productType === 'combo') {
|
|
combos.push(mapOffersPageProduct(sku))
|
|
}
|
|
if (sku.isOffer) {
|
|
offers.push(mapOffersPageProduct(sku))
|
|
}
|
|
}
|
|
|
|
return { combos, offers }
|
|
}
|