1157 lines
34 KiB
TypeScript
1157 lines
34 KiB
TypeScript
// @ts-nocheck -- temporary: file partially updated for SKU migration; remaining functions to be fixed later
|
|
import { db } from '../db/db_index'
|
|
import {
|
|
productInfo,
|
|
productSkus,
|
|
skuFeatures,
|
|
productCombos,
|
|
units,
|
|
specialDeals,
|
|
deliverySlotInfo,
|
|
productTags,
|
|
productReviews,
|
|
productGroupInfo,
|
|
productGroupMembership,
|
|
productTagInfo,
|
|
users,
|
|
storeInfo,
|
|
cartItems,
|
|
orderItems,
|
|
coupons,
|
|
reservedCoupons,
|
|
vendorSnippets,
|
|
homeBanners,
|
|
keyValStore,
|
|
couponApplicableProducts,
|
|
} from '../db/schema'
|
|
import { and, desc, eq, inArray, sql } from 'drizzle-orm'
|
|
import { runBatched } from '../lib/run-batched'
|
|
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
|
|
import type {
|
|
AdminProduct,
|
|
AdminProductGroupInfo,
|
|
AdminProductTagInfo,
|
|
AdminProductTagWithProducts,
|
|
AdminProductReview,
|
|
AdminProductWithDetails,
|
|
AdminProductWithRelations,
|
|
AdminSku,
|
|
AdminSkuFeature,
|
|
AdminSpecialDeal,
|
|
AdminUnit,
|
|
AdminUpdateSlotProductsResult,
|
|
Store,
|
|
} from '@packages/shared'
|
|
|
|
type ProductRow = InferSelectModel<typeof productInfo>
|
|
type SkuRow = InferSelectModel<typeof productSkus>
|
|
type SkuFeatureRow = InferSelectModel<typeof skuFeatures>
|
|
type UnitRow = InferSelectModel<typeof units>
|
|
type StoreRow = InferSelectModel<typeof storeInfo>
|
|
type SpecialDealRow = InferSelectModel<typeof specialDeals>
|
|
type ProductTagInfoRow = InferSelectModel<typeof productTagInfo>
|
|
type ProductTagRow = InferSelectModel<typeof productTags>
|
|
type ProductGroupRow = InferSelectModel<typeof productGroupInfo>
|
|
type ProductGroupMembershipRow = InferSelectModel<typeof productGroupMembership>
|
|
type ProductReviewRow = InferSelectModel<typeof productReviews>
|
|
|
|
const getStringArray = (value: unknown): string[] | null => {
|
|
if (!Array.isArray(value)) return null
|
|
return value.map((item) => String(item))
|
|
}
|
|
|
|
const mapUnit = (unit: UnitRow): AdminUnit => ({
|
|
id: unit.id,
|
|
shortNotation: unit.shortNotation,
|
|
fullName: unit.fullName,
|
|
})
|
|
|
|
const mapStore = (store: StoreRow): Store => ({
|
|
id: store.id,
|
|
name: store.name,
|
|
description: store.description,
|
|
imageUrl: store.imageUrl,
|
|
owner: store.owner,
|
|
createdAt: store.createdAt,
|
|
// updatedAt: store.createdAt,
|
|
})
|
|
|
|
const mapProduct = (product: ProductRow): AdminProduct => ({
|
|
id: product.id,
|
|
name: product.name,
|
|
shortDescription: product.shortDescription ?? null,
|
|
longDescription: product.longDescription ?? null,
|
|
storeId: product.storeId,
|
|
incrementStep: product.incrementStep,
|
|
createdAt: product.createdAt,
|
|
productType: product.productType as 'item' | 'combo',
|
|
})
|
|
|
|
const mapSkuFeature = (feature: SkuFeatureRow): AdminSkuFeature => ({
|
|
id: feature.id,
|
|
skuId: feature.skuId,
|
|
featureName: feature.featureName,
|
|
featureValue: feature.featureValue,
|
|
})
|
|
|
|
const mapSku = (sku: SkuRow, features: SkuFeatureRow[] = [], comboItems: any[] = []): AdminSku => ({
|
|
id: sku.id,
|
|
productId: sku.productId,
|
|
name: sku.name ?? null,
|
|
price: String(sku.price ?? '0'),
|
|
marketPrice: sku.marketPrice ? String(sku.marketPrice) : null,
|
|
images: getStringArray(sku.images),
|
|
imageKeys: getStringArray(sku.images),
|
|
isOutOfStock: sku.isOutOfStock,
|
|
isSuspended: sku.isSuspended,
|
|
isFlashAvailable: sku.isFlashAvailable,
|
|
flashPrice: sku.flashPrice ? String(sku.flashPrice) : null,
|
|
createdAt: sku.createdAt,
|
|
features: features.map(mapSkuFeature),
|
|
comboItems: comboItems,
|
|
})
|
|
|
|
const mapSpecialDeal = (deal: SpecialDealRow): AdminSpecialDeal => ({
|
|
id: deal.id,
|
|
skuId: deal.skuId,
|
|
quantity: String(deal.quantity ?? '0'),
|
|
price: String(deal.price ?? '0'),
|
|
validTill: deal.validTill,
|
|
})
|
|
|
|
const mapTagInfo = (tag: ProductTagInfoRow): AdminProductTagInfo => ({
|
|
id: tag.id,
|
|
tagName: tag.tagName,
|
|
tagDescription: tag.tagDescription ?? null,
|
|
imageUrl: tag.imageUrl ?? null,
|
|
isDashboardTag: tag.isDashboardTag,
|
|
relatedStores: tag.relatedStores,
|
|
createdAt: tag.createdAt,
|
|
})
|
|
|
|
export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
|
|
type ProductWithRelationsRow = ProductRow & {
|
|
store: StoreRow | null
|
|
skus: Array<SkuRow & { features: SkuFeatureRow[]; comboItems: any[] }>
|
|
}
|
|
const products = await db.query.productInfo.findMany({
|
|
orderBy: productInfo.name,
|
|
with: {
|
|
store: true,
|
|
skus: {
|
|
with: {
|
|
features: true,
|
|
comboItems: {
|
|
with: {
|
|
sku: { with: { product: true, features: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}) as ProductWithRelationsRow[]
|
|
|
|
return products.map((product) => ({
|
|
...mapProduct(product),
|
|
store: product.store ? mapStore(product.store) : null,
|
|
skus: product.skus.map((sku) => {
|
|
const comboItems = (sku.comboItems || []).map((ci: any) => ({
|
|
skuId: ci.skuId,
|
|
skuName: ci.sku?.name ?? null,
|
|
features: (ci.sku?.features || []).map((f: any) => ({ featureName: f.featureName, featureValue: f.featureValue })),
|
|
productName: ci.sku?.product?.name ?? 'Unknown',
|
|
images: getStringArray(ci.sku?.images),
|
|
price: String(ci.sku?.price ?? '0'),
|
|
}))
|
|
return mapSku(sku, sku.features, comboItems)
|
|
}),
|
|
}))
|
|
}
|
|
|
|
export async function getProductById(id: number): Promise<AdminProductWithDetails | null> {
|
|
const product = await db.query.productInfo.findFirst({
|
|
where: eq(productInfo.id, id),
|
|
with: {
|
|
store: true,
|
|
skus: {
|
|
with: {
|
|
features: true,
|
|
comboItems: {
|
|
with: {
|
|
sku: { with: { product: true, features: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!product) {
|
|
return null
|
|
}
|
|
|
|
const skuIds = product.skus.map((sku) => sku.id)
|
|
|
|
const deals = skuIds.length > 0
|
|
? await db.query.specialDeals.findMany({
|
|
where: inArray(specialDeals.skuId, skuIds),
|
|
orderBy: specialDeals.quantity,
|
|
})
|
|
: []
|
|
|
|
const productTagsData = await db.query.productTags.findMany({
|
|
where: eq(productTags.productId, id),
|
|
with: {
|
|
tag: true,
|
|
},
|
|
}) as Array<ProductTagRow & { tag: ProductTagInfoRow }>
|
|
|
|
const skusWithCombos = product.skus.map((sku) => {
|
|
const comboItems = (sku.comboItems || []).map((ci: any) => ({
|
|
skuId: ci.skuId,
|
|
skuName: ci.sku?.name ?? null,
|
|
features: (ci.sku?.features || []).map((f) => ({ featureName: f.featureName, featureValue: f.featureValue })),
|
|
productName: ci.sku?.product?.name ?? 'Unknown',
|
|
images: getStringArray(ci.sku?.images),
|
|
price: String(ci.sku?.price ?? '0'),
|
|
}))
|
|
return mapSku(sku, sku.features, comboItems)
|
|
})
|
|
|
|
return {
|
|
...mapProduct(product),
|
|
store: product.store ? mapStore(product.store) : null,
|
|
skus: skusWithCombos,
|
|
deals: deals.map(mapSpecialDeal),
|
|
tags: productTagsData.map((tag) => mapTagInfo(tag.tag)),
|
|
}
|
|
}
|
|
|
|
export async function deleteProduct(id: number): Promise<AdminProduct | null> {
|
|
const [deletedProduct] = await db
|
|
.delete(productInfo)
|
|
.where(eq(productInfo.id, id))
|
|
.returning()
|
|
|
|
if (!deletedProduct) {
|
|
return null
|
|
}
|
|
|
|
return mapProduct(deletedProduct)
|
|
}
|
|
|
|
type ProductInfoInsert = InferInsertModel<typeof productInfo>
|
|
type ProductInfoUpdate = Partial<ProductInfoInsert>
|
|
|
|
export async function createProduct(input: CreateProductInput): Promise<AdminProductWithRelations> {
|
|
if (!input.skus || input.skus.length === 0) {
|
|
throw new Error('At least one SKU is required')
|
|
}
|
|
|
|
const featuresHaveQuantity = input.skus.every((sku) =>
|
|
sku.features.some((f) => f.featureName === 'quantity')
|
|
)
|
|
if (!featuresHaveQuantity) {
|
|
throw new Error('Every SKU must have a quantity feature')
|
|
}
|
|
|
|
const { skus, ...productData } = input
|
|
|
|
const [product] = await db.insert(productInfo).values({
|
|
name: productData.name,
|
|
shortDescription: productData.shortDescription ?? null,
|
|
longDescription: productData.longDescription ?? null,
|
|
storeId: productData.storeId ?? null,
|
|
incrementStep: productData.incrementStep ?? 1,
|
|
productType: productData.productType ?? 'item',
|
|
}).returning()
|
|
|
|
const skuRows = await db.insert(productSkus).values(
|
|
skus.map((sku) => ({
|
|
productId: product.id,
|
|
name: sku.name ?? null,
|
|
price: String(sku.price),
|
|
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
|
|
images: sku.images ?? null,
|
|
isFlashAvailable: sku.isFlashAvailable ?? false,
|
|
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
|
}))
|
|
).returning()
|
|
|
|
for (let i = 0; i < skuRows.length; i++) {
|
|
const skuRow = skuRows[i]
|
|
const sku = skus[i]
|
|
await db.insert(skuFeatures).values(
|
|
sku.features.map((f) => ({
|
|
skuId: skuRow.id,
|
|
featureName: f.featureName,
|
|
featureValue: f.featureValue,
|
|
}))
|
|
)
|
|
|
|
if (sku.comboItems && sku.comboItems.length > 0) {
|
|
await db.insert(productCombos).values(
|
|
sku.comboItems.map((ci: any) => ({
|
|
comboSkuId: skuRow.id,
|
|
skuId: ci.skuId,
|
|
}))
|
|
)
|
|
}
|
|
}
|
|
|
|
const createdSkus = await db.query.productSkus.findMany({
|
|
where: eq(productSkus.productId, product.id),
|
|
with: { features: true },
|
|
})
|
|
|
|
return {
|
|
...mapProduct(product),
|
|
store: null,
|
|
skus: createdSkus.map((s) => mapSku(s, s.features)),
|
|
}
|
|
}
|
|
|
|
export async function updateProduct(id: number, input: any): Promise<AdminProductWithRelations | null> {
|
|
const product = await db.query.productInfo.findFirst({
|
|
where: eq(productInfo.id, id),
|
|
})
|
|
|
|
if (!product) {
|
|
return null
|
|
}
|
|
|
|
const { skus, ...productData } = input
|
|
|
|
await db.update(productInfo)
|
|
.set({
|
|
name: productData.name,
|
|
shortDescription: productData.shortDescription ?? null,
|
|
longDescription: productData.longDescription ?? null,
|
|
storeId: productData.storeId ?? null,
|
|
incrementStep: productData.incrementStep ?? 1,
|
|
productType: productData.productType,
|
|
})
|
|
.where(eq(productInfo.id, id))
|
|
|
|
if (skus !== undefined) {
|
|
if (skus.length === 0) {
|
|
throw new Error('At least one SKU is required')
|
|
}
|
|
|
|
const featuresHaveQuantity = skus.every((sku: any) =>
|
|
sku.features.some((f: any) => f.featureName === 'quantity')
|
|
)
|
|
if (!featuresHaveQuantity) {
|
|
throw new Error('Every SKU must have a quantity feature')
|
|
}
|
|
|
|
const existingSkus = await db.query.productSkus.findMany({
|
|
where: eq(productSkus.productId, id),
|
|
columns: { id: true },
|
|
})
|
|
const existingSkuIdSet = new Set(existingSkus.map((s) => s.id))
|
|
|
|
for (const sku of skus) {
|
|
if (sku.id != null && existingSkuIdSet.has(sku.id)) {
|
|
// Update existing SKU
|
|
await db.update(productSkus)
|
|
.set({
|
|
name: sku.name ?? null,
|
|
price: String(sku.price),
|
|
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
|
|
images: sku.images ?? null,
|
|
isFlashAvailable: sku.isFlashAvailable ?? false,
|
|
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
|
})
|
|
.where(eq(productSkus.id, sku.id))
|
|
|
|
await db.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id))
|
|
await db.insert(skuFeatures).values(
|
|
sku.features.map((f: any) => ({
|
|
skuId: sku.id,
|
|
featureName: f.featureName,
|
|
featureValue: f.featureValue,
|
|
}))
|
|
)
|
|
|
|
await db.delete(productCombos).where(eq(productCombos.comboSkuId, sku.id))
|
|
if (sku.comboItems && sku.comboItems.length > 0) {
|
|
await db.insert(productCombos).values(
|
|
sku.comboItems.map((ci: any) => ({
|
|
comboSkuId: sku.id,
|
|
skuId: ci.skuId,
|
|
}))
|
|
)
|
|
}
|
|
} else {
|
|
// Insert new SKU
|
|
const [newSku] = await db.insert(productSkus).values({
|
|
productId: id,
|
|
name: sku.name ?? null,
|
|
price: String(sku.price),
|
|
marketPrice: sku.marketPrice != null ? String(sku.marketPrice) : null,
|
|
images: sku.images ?? null,
|
|
isFlashAvailable: sku.isFlashAvailable ?? false,
|
|
flashPrice: sku.flashPrice != null ? String(sku.flashPrice) : null,
|
|
}).returning()
|
|
|
|
await db.insert(skuFeatures).values(
|
|
sku.features.map((f: any) => ({
|
|
skuId: newSku.id,
|
|
featureName: f.featureName,
|
|
featureValue: f.featureValue,
|
|
}))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
const updatedProduct = await db.query.productInfo.findFirst({
|
|
where: eq(productInfo.id, id),
|
|
with: {
|
|
store: true,
|
|
skus: {
|
|
with: { features: true },
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!updatedProduct) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
...mapProduct(updatedProduct),
|
|
store: updatedProduct.store ? mapStore(updatedProduct.store) : null,
|
|
skus: updatedProduct.skus.map((s) => mapSku(s, s.features)),
|
|
}
|
|
}
|
|
|
|
export async function updateSlotProducts(slotId: string, productIds: string[]): Promise<AdminUpdateSlotProductsResult> {
|
|
const slot = await db.query.deliverySlotInfo.findFirst({
|
|
where: eq(deliverySlotInfo.id, parseInt(slotId)),
|
|
})
|
|
|
|
if (!slot) {
|
|
throw new Error(`Slot ${slotId} not found`)
|
|
}
|
|
|
|
const currentSkuIds = slot.skuIds || []
|
|
const newSkuIds = productIds.map((id: string) => parseInt(id))
|
|
|
|
await db.update(deliverySlotInfo)
|
|
.set({ skuIds: newSkuIds })
|
|
.where(eq(deliverySlotInfo.id, parseInt(slotId)))
|
|
|
|
const productsToAdd = newSkuIds.filter((id: number) => !currentSkuIds.includes(id))
|
|
const productsToRemove = currentSkuIds.filter((id: number) => !newSkuIds.includes(id))
|
|
|
|
return {
|
|
message: 'Slot products updated successfully',
|
|
added: productsToAdd.length,
|
|
removed: productsToRemove.length,
|
|
}
|
|
}
|
|
|
|
export async function getSlotProductIds(slotId: string): Promise<number[]> {
|
|
const slot = await db.query.deliverySlotInfo.findFirst({
|
|
where: eq(deliverySlotInfo.id, parseInt(slotId)),
|
|
})
|
|
|
|
return slot?.skuIds || []
|
|
}
|
|
|
|
export async function getAllUnits(): Promise<AdminUnit[]> {
|
|
const allUnits = await db.query.units.findMany({
|
|
orderBy: units.shortNotation,
|
|
})
|
|
|
|
return allUnits.map(mapUnit)
|
|
}
|
|
|
|
export async function getAllProductTags(): Promise<AdminProductTagWithProducts[]> {
|
|
const tags = await db.query.productTagInfo.findMany({
|
|
with: {
|
|
products: {
|
|
with: {
|
|
product: true,
|
|
},
|
|
},
|
|
},
|
|
}) as Array<ProductTagInfoRow & { products: Array<ProductTagRow & { product: ProductRow }> }>
|
|
|
|
return tags.map((tag: ProductTagInfoRow & { products: Array<ProductTagRow & { product: ProductRow }> }) => ({
|
|
...mapTagInfo(tag),
|
|
products: tag.products.map((assignment: ProductTagRow & { product: ProductRow }) => ({
|
|
productId: assignment.productId,
|
|
tagId: assignment.tagId,
|
|
assignedAt: assignment.assignedAt,
|
|
product: mapProduct(assignment.product),
|
|
})),
|
|
}))
|
|
}
|
|
|
|
export async function getAllProductTagInfos(): Promise<AdminProductTagInfo[]> {
|
|
const tags = await db.query.productTagInfo.findMany({
|
|
orderBy: productTagInfo.tagName,
|
|
})
|
|
|
|
return tags.map(mapTagInfo)
|
|
}
|
|
|
|
export async function getProductTagInfoById(tagId: number): Promise<AdminProductTagInfo | null> {
|
|
const tag = await db.query.productTagInfo.findFirst({
|
|
where: eq(productTagInfo.id, tagId),
|
|
})
|
|
|
|
if (!tag) {
|
|
return null
|
|
}
|
|
|
|
return mapTagInfo(tag)
|
|
}
|
|
|
|
export interface CreateProductTagInput {
|
|
tagName: string
|
|
tagDescription?: string | null
|
|
imageUrl?: string | null
|
|
isDashboardTag?: boolean
|
|
relatedStores?: number[]
|
|
}
|
|
|
|
export async function createProductTag(input: CreateProductTagInput): Promise<AdminProductTagWithProducts> {
|
|
const [tag] = await db.insert(productTagInfo).values({
|
|
tagName: input.tagName,
|
|
tagDescription: input.tagDescription || null,
|
|
imageUrl: input.imageUrl || null,
|
|
isDashboardTag: input.isDashboardTag || false,
|
|
relatedStores: input.relatedStores || [],
|
|
}).returning()
|
|
|
|
return {
|
|
...mapTagInfo(tag),
|
|
products: [],
|
|
}
|
|
}
|
|
|
|
export async function getProductTagById(tagId: number): Promise<AdminProductTagWithProducts | null> {
|
|
const tag = await db.query.productTagInfo.findFirst({
|
|
where: eq(productTagInfo.id, tagId),
|
|
with: {
|
|
products: {
|
|
with: {
|
|
product: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!tag) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
...mapTagInfo(tag),
|
|
products: tag.products.map((assignment: ProductTagRow & { product: ProductRow }) => ({
|
|
productId: assignment.productId,
|
|
tagId: assignment.tagId,
|
|
assignedAt: assignment.assignedAt,
|
|
product: mapProduct(assignment.product),
|
|
})),
|
|
}
|
|
}
|
|
|
|
export interface UpdateProductTagInput {
|
|
tagName?: string
|
|
tagDescription?: string | null
|
|
imageUrl?: string | null
|
|
isDashboardTag?: boolean
|
|
relatedStores?: number[]
|
|
}
|
|
|
|
export async function updateProductTag(tagId: number, input: UpdateProductTagInput): Promise<AdminProductTagWithProducts> {
|
|
const [tag] = await db.update(productTagInfo).set({
|
|
...(input.tagName !== undefined && { tagName: input.tagName }),
|
|
...(input.tagDescription !== undefined && { tagDescription: input.tagDescription }),
|
|
...(input.imageUrl !== undefined && { imageUrl: input.imageUrl }),
|
|
...(input.isDashboardTag !== undefined && { isDashboardTag: input.isDashboardTag }),
|
|
...(input.relatedStores !== undefined && { relatedStores: input.relatedStores }),
|
|
}).where(eq(productTagInfo.id, tagId)).returning()
|
|
|
|
const fullTag = await db.query.productTagInfo.findFirst({
|
|
where: eq(productTagInfo.id, tagId),
|
|
with: {
|
|
products: {
|
|
with: {
|
|
product: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
return {
|
|
...mapTagInfo(tag),
|
|
products: fullTag?.products.map((assignment: ProductTagRow & { product: ProductRow }) => ({
|
|
productId: assignment.productId,
|
|
tagId: assignment.tagId,
|
|
assignedAt: assignment.assignedAt,
|
|
product: mapProduct(assignment.product),
|
|
})) || [],
|
|
}
|
|
}
|
|
|
|
export async function deleteProductTag(tagId: number): Promise<void> {
|
|
await db.delete(productTagInfo).where(eq(productTagInfo.id, tagId))
|
|
}
|
|
|
|
export async function checkProductTagExistsByName(tagName: string): Promise<boolean> {
|
|
const tag = await db.query.productTagInfo.findFirst({
|
|
where: eq(productTagInfo.tagName, tagName),
|
|
})
|
|
return !!tag
|
|
}
|
|
|
|
export async function getSlotsProductIds(slotIds: number[]): Promise<Record<number, number[]>> {
|
|
if (slotIds.length === 0) {
|
|
return {}
|
|
}
|
|
|
|
const slots = await db.query.deliverySlotInfo.findMany({
|
|
where: inArray(deliverySlotInfo.id, slotIds),
|
|
})
|
|
|
|
const result: Record<number, number[]> = {}
|
|
for (const slot of slots) {
|
|
result[slot.id] = slot.skuIds || []
|
|
}
|
|
|
|
slotIds.forEach((slotId) => {
|
|
if (!result[slotId]) {
|
|
result[slotId] = []
|
|
}
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
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,
|
|
adminResponse: productReviews.adminResponse,
|
|
adminResponseImages: productReviews.adminResponseImages,
|
|
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: AdminProductReview[] = reviews.map((review: any) => ({
|
|
id: review.id,
|
|
reviewBody: review.reviewBody,
|
|
ratings: review.ratings,
|
|
imageUrls: review.imageUrls,
|
|
reviewTime: review.reviewTime,
|
|
adminResponse: review.adminResponse ?? null,
|
|
adminResponseImages: review.adminResponseImages,
|
|
userName: review.userName ?? null,
|
|
}))
|
|
|
|
return {
|
|
reviews: mappedReviews,
|
|
totalCount,
|
|
}
|
|
}
|
|
|
|
export async function respondToReview(
|
|
reviewId: number,
|
|
adminResponse: string | undefined,
|
|
adminResponseImages: string[]
|
|
): Promise<AdminProductReview | null> {
|
|
const [updatedReview] = await db
|
|
.update(productReviews)
|
|
.set({
|
|
adminResponse,
|
|
adminResponseImages,
|
|
})
|
|
.where(eq(productReviews.id, reviewId))
|
|
.returning()
|
|
|
|
if (!updatedReview) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
id: updatedReview.id,
|
|
reviewBody: updatedReview.reviewBody,
|
|
ratings: updatedReview.ratings,
|
|
imageUrls: updatedReview.imageUrls,
|
|
reviewTime: updatedReview.reviewTime,
|
|
adminResponse: updatedReview.adminResponse ?? null,
|
|
adminResponseImages: updatedReview.adminResponseImages,
|
|
userName: null,
|
|
}
|
|
}
|
|
|
|
export async function getAllProductGroups() {
|
|
const groups = await db.query.productGroupInfo.findMany({
|
|
with: {
|
|
memberships: {
|
|
with: {
|
|
product: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: desc(productGroupInfo.createdAt),
|
|
})
|
|
|
|
return groups.map((group: any) => ({
|
|
id: group.id,
|
|
groupName: group.groupName,
|
|
description: group.description ?? null,
|
|
createdAt: group.createdAt,
|
|
products: group.memberships.map((membership: any) => mapProduct(membership.product)),
|
|
productCount: group.memberships.length,
|
|
memberships: group.memberships
|
|
}))
|
|
}
|
|
|
|
export async function createProductGroup(
|
|
groupName: string,
|
|
description: string | undefined,
|
|
productIds: number[]
|
|
): Promise<AdminProductGroupInfo> {
|
|
const [newGroup] = await db
|
|
.insert(productGroupInfo)
|
|
.values({
|
|
groupName,
|
|
description,
|
|
})
|
|
.returning()
|
|
|
|
if (productIds.length > 0) {
|
|
const memberships = productIds.map((productId) => ({
|
|
productId,
|
|
groupId: newGroup.id,
|
|
}))
|
|
|
|
await db.insert(productGroupMembership).values(memberships)
|
|
}
|
|
|
|
return {
|
|
id: newGroup.id,
|
|
groupName: newGroup.groupName,
|
|
description: newGroup.description ?? null,
|
|
createdAt: newGroup.createdAt,
|
|
}
|
|
}
|
|
|
|
export async function updateProductGroup(
|
|
id: number,
|
|
groupName: string | undefined,
|
|
description: string | undefined,
|
|
productIds: number[] | undefined
|
|
): Promise<AdminProductGroupInfo | null> {
|
|
const updateData: Partial<{
|
|
groupName: string
|
|
description: string | null
|
|
}> = {}
|
|
|
|
if (groupName !== undefined) updateData.groupName = groupName
|
|
if (description !== undefined) updateData.description = description
|
|
|
|
const [updatedGroup] = await db
|
|
.update(productGroupInfo)
|
|
.set(updateData)
|
|
.where(eq(productGroupInfo.id, id))
|
|
.returning()
|
|
|
|
if (!updatedGroup) {
|
|
return null
|
|
}
|
|
|
|
if (productIds !== undefined) {
|
|
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id))
|
|
|
|
if (productIds.length > 0) {
|
|
const memberships = productIds.map((productId) => ({
|
|
productId,
|
|
groupId: id,
|
|
}))
|
|
|
|
await db.insert(productGroupMembership).values(memberships)
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: updatedGroup.id,
|
|
groupName: updatedGroup.groupName,
|
|
description: updatedGroup.description ?? null,
|
|
createdAt: updatedGroup.createdAt,
|
|
}
|
|
}
|
|
|
|
export async function deleteProductGroup(id: number): Promise<AdminProductGroupInfo | null> {
|
|
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id))
|
|
|
|
const [deletedGroup] = await db
|
|
.delete(productGroupInfo)
|
|
.where(eq(productGroupInfo.id, id))
|
|
.returning()
|
|
|
|
if (!deletedGroup) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
id: deletedGroup.id,
|
|
groupName: deletedGroup.groupName,
|
|
description: deletedGroup.description ?? null,
|
|
createdAt: deletedGroup.createdAt,
|
|
}
|
|
}
|
|
|
|
export async function addProductToGroup(groupId: number, productId: number): Promise<void> {
|
|
await db.insert(productGroupMembership).values({ groupId, productId })
|
|
}
|
|
|
|
export async function removeProductFromGroup(groupId: number, productId: number): Promise<void> {
|
|
await db.delete(productGroupMembership)
|
|
.where(and(
|
|
eq(productGroupMembership.groupId, groupId),
|
|
eq(productGroupMembership.productId, productId)
|
|
))
|
|
}
|
|
|
|
export async function updateProductPrices(updates: Array<{
|
|
productId: number
|
|
price?: number
|
|
marketPrice?: number | null
|
|
flashPrice?: number | null
|
|
isFlashAvailable?: boolean
|
|
}>) {
|
|
if (updates.length === 0) {
|
|
return { updatedCount: 0, invalidIds: [] }
|
|
}
|
|
|
|
const productIds = updates.map((update) => update.productId)
|
|
|
|
// Validate all SKU IDs exist (in chunks to avoid large IN clauses)
|
|
const existingSkuChunks = await runBatched(db, productIds, 10, async (tx, chunk) => {
|
|
return tx.query.productSkus.findMany({
|
|
where: inArray(productSkus.id, chunk),
|
|
columns: { id: true },
|
|
})
|
|
})
|
|
const existingIds = new Set(existingSkuChunks.flat().map((sku: { id: number }) => sku.id))
|
|
const invalidIds = productIds.filter((id) => !existingIds.has(id))
|
|
|
|
if (invalidIds.length > 0) {
|
|
return { updatedCount: 0, invalidIds }
|
|
}
|
|
|
|
// Apply updates in chunks inside a single transaction
|
|
await runBatched(db, updates, 10, async (tx, chunk) => {
|
|
for (const update of chunk) {
|
|
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update
|
|
const updateData: any = {}
|
|
|
|
if (price !== undefined) updateData.price = price.toString()
|
|
if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString()
|
|
if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString()
|
|
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable
|
|
|
|
await tx
|
|
.update(productSkus)
|
|
.set(updateData)
|
|
.where(eq(productSkus.id, productId))
|
|
}
|
|
})
|
|
|
|
return { updatedCount: updates.length, invalidIds: [] }
|
|
}
|
|
|
|
|
|
// ==========================================================================
|
|
// Product Helpers for Admin Controller
|
|
// ==========================================================================
|
|
|
|
export async function checkProductExistsByName(name: string): Promise<boolean> {
|
|
const product = await db.query.productInfo.findFirst({
|
|
where: eq(productInfo.name, name),
|
|
columns: { id: true },
|
|
})
|
|
|
|
return !!product
|
|
}
|
|
|
|
export async function checkUnitExists(unitId: number): Promise<boolean> {
|
|
const unit = await db.query.units.findFirst({
|
|
where: eq(units.id, unitId),
|
|
columns: { id: true },
|
|
})
|
|
|
|
return !!unit
|
|
}
|
|
|
|
export async function getProductImagesById(productId: number): Promise<string[] | null> {
|
|
const product = await db.query.productSkus.findFirst({
|
|
where: eq(productSkus.id, productId),
|
|
columns: { images: true },
|
|
})
|
|
|
|
if (!product) {
|
|
return null
|
|
}
|
|
|
|
return getStringArray(product.images) || []
|
|
}
|
|
|
|
export interface CreateSpecialDealInput {
|
|
quantity: number
|
|
price: number
|
|
validTill: string | Date
|
|
}
|
|
|
|
export async function createSpecialDealsForSku(
|
|
productId: number,
|
|
deals: CreateSpecialDealInput[]
|
|
): Promise<AdminSpecialDeal[]> {
|
|
if (deals.length === 0) {
|
|
return []
|
|
}
|
|
|
|
const dealInserts = deals.map((deal) => ({
|
|
productId,
|
|
quantity: deal.quantity.toString(),
|
|
price: deal.price.toString(),
|
|
validTill: new Date(deal.validTill),
|
|
}))
|
|
|
|
const createdDeals = await db
|
|
.insert(specialDeals)
|
|
.values(dealInserts)
|
|
.returning()
|
|
|
|
return createdDeals.map(mapSpecialDeal)
|
|
}
|
|
|
|
export async function updateSkuDeals(
|
|
productId: number,
|
|
deals: CreateSpecialDealInput[]
|
|
): Promise<void> {
|
|
if (deals.length === 0) {
|
|
await db.delete(specialDeals).where(eq(specialDeals.skuId, productId))
|
|
return
|
|
}
|
|
|
|
const existingDeals = await db.query.specialDeals.findMany({
|
|
where: eq(specialDeals.skuId, productId),
|
|
})
|
|
|
|
const existingDealsMap = new Map<string, SpecialDealRow>(
|
|
existingDeals.map((deal: SpecialDealRow) => [`${deal.quantity}-${deal.price}`, deal])
|
|
)
|
|
const newDealsMap = new Map<string, CreateSpecialDealInput>(
|
|
deals.map((deal) => [`${deal.quantity}-${deal.price}`, deal])
|
|
)
|
|
|
|
const dealsToAdd = deals.filter((deal) => {
|
|
const key = `${deal.quantity}-${deal.price}`
|
|
return !existingDealsMap.has(key)
|
|
})
|
|
|
|
const dealsToRemove = existingDeals.filter((deal: SpecialDealRow) => {
|
|
const key = `${deal.quantity}-${deal.price}`
|
|
return !newDealsMap.has(key)
|
|
})
|
|
|
|
const dealsToUpdate = deals.filter((deal: CreateSpecialDealInput) => {
|
|
const key = `${deal.quantity}-${deal.price}`
|
|
const existing = existingDealsMap.get(key)
|
|
const nextValidTill = deal.validTill instanceof Date
|
|
? deal.validTill.toISOString().split('T')[0]
|
|
: String(deal.validTill)
|
|
return existing && existing.validTill.toISOString().split('T')[0] !== nextValidTill
|
|
})
|
|
|
|
if (dealsToRemove.length > 0) {
|
|
await db.delete(specialDeals).where(
|
|
inArray(specialDeals.id, dealsToRemove.map((deal: SpecialDealRow) => deal.id))
|
|
)
|
|
}
|
|
|
|
if (dealsToAdd.length > 0) {
|
|
const dealInserts = dealsToAdd.map((deal) => ({
|
|
productId,
|
|
quantity: deal.quantity.toString(),
|
|
price: deal.price.toString(),
|
|
validTill: new Date(deal.validTill),
|
|
}))
|
|
await db.insert(specialDeals).values(dealInserts)
|
|
}
|
|
|
|
for (const deal of dealsToUpdate) {
|
|
const key = `${deal.quantity}-${deal.price}`
|
|
const existingDeal = existingDealsMap.get(key)
|
|
if (existingDeal) {
|
|
await db.update(specialDeals)
|
|
.set({ validTill: new Date(deal.validTill) })
|
|
.where(eq(specialDeals.id, existingDeal.id))
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function replaceProductTags(productId: number, tagIds: number[]): Promise<void> {
|
|
await db.delete(productTags).where(eq(productTags.productId, productId))
|
|
|
|
if (tagIds.length === 0) {
|
|
return
|
|
}
|
|
|
|
const tagAssociations = tagIds.map((tagId) => ({
|
|
productId,
|
|
tagId,
|
|
}))
|
|
|
|
await db.insert(productTags).values(tagAssociations)
|
|
}
|
|
|
|
export async function mergeSkus(fromSkuId: number, toSkuId: number) {
|
|
if (fromSkuId === toSkuId) {
|
|
return { fromSkuId, toSkuId, message: 'SKUs are the same, no merge needed', counts: {} }
|
|
}
|
|
|
|
const fromSku = await db.query.productSkus.findFirst({ where: eq(productSkus.id, fromSkuId) })
|
|
if (!fromSku) throw new Error(`SKU ${fromSkuId} not found`)
|
|
|
|
const toSku = await db.query.productSkus.findFirst({ where: eq(productSkus.id, toSkuId) })
|
|
if (!toSku) throw new Error(`SKU ${toSkuId} not found`)
|
|
|
|
const counts: Record<string, number> = {}
|
|
|
|
// 1. order_items — direct update
|
|
const orderItemsResult = await db.update(orderItems)
|
|
.set({ skuId: toSkuId })
|
|
.where(eq(orderItems.skuId, fromSkuId))
|
|
counts.orderItems = orderItemsResult.changes ?? 0
|
|
|
|
// 2. special_deals — direct update
|
|
const specialDealsResult = await db.update(specialDeals)
|
|
.set({ skuId: toSkuId })
|
|
.where(eq(specialDeals.skuId, fromSkuId))
|
|
counts.specialDeals = specialDealsResult.changes ?? 0
|
|
|
|
// 3. cart_items — delete all with fromSkuId
|
|
const cartResult = await db.delete(cartItems)
|
|
.where(eq(cartItems.skuId, fromSkuId))
|
|
counts.cartItems = cartResult.changes ?? 0
|
|
|
|
// 4. coupon_applicable_products — update, but handle unique constraint
|
|
// First delete rows where (coupon_id, toSkuId) already exists
|
|
const existingCapRows = await db.query.couponApplicableProducts.findMany({
|
|
where: eq(couponApplicableProducts.skuId, toSkuId),
|
|
columns: { couponId: true },
|
|
})
|
|
const existingCouponIds = new Set(existingCapRows.map((r) => r.couponId))
|
|
|
|
if (existingCouponIds.size > 0) {
|
|
const dupResult = await db.delete(couponApplicableProducts)
|
|
.where(
|
|
and(
|
|
eq(couponApplicableProducts.skuId, fromSkuId),
|
|
inArray(couponApplicableProducts.couponId, Array.from(existingCouponIds))
|
|
)
|
|
)
|
|
counts.couponDedupDeleted = dupResult.changes ?? 0
|
|
}
|
|
|
|
// Now update remaining rows
|
|
const capResult = await db.update(couponApplicableProducts)
|
|
.set({ skuId: toSkuId })
|
|
.where(eq(couponApplicableProducts.skuId, fromSkuId))
|
|
counts.couponApplicable = capResult.changes ?? 0
|
|
|
|
// 5. JSON arrays — remap fromSkuId to toSkuId
|
|
const jsonTables: Array<{ table: any; column: string; name: string }> = [
|
|
{ table: deliverySlotInfo, column: 'skuIds', name: 'deliverySlotInfo' },
|
|
{ table: homeBanners, column: 'skuIds', name: 'homeBanners' },
|
|
{ table: coupons, column: 'skuIds', name: 'coupons' },
|
|
{ table: reservedCoupons, column: 'skuIds', name: 'reservedCoupons' },
|
|
{ table: vendorSnippets, column: 'skuIds', name: 'vendorSnippets' },
|
|
]
|
|
|
|
for (const { table, column, name } of jsonTables) {
|
|
const rows = await db.select({ id: table.id, ids: table[column] }).from(table)
|
|
let updated = 0
|
|
for (const row of rows) {
|
|
const ids: number[] = (row.ids as number[]) || []
|
|
if (!ids.includes(fromSkuId)) continue
|
|
const newIds = ids.map((id) => (id === fromSkuId ? toSkuId : id))
|
|
const deduped = [...new Set(newIds)]
|
|
if (deduped.length !== ids.length || deduped.some((id, i) => id !== ids[i])) {
|
|
await db.update(table).set({ [column]: deduped } as any).where(eq(table.id, row.id))
|
|
updated++
|
|
}
|
|
}
|
|
counts[name] = updated
|
|
}
|
|
|
|
// popularItems in key_val_store
|
|
const kvRow = await db.query.keyValStore.findFirst({
|
|
where: eq(keyValStore.key, 'popularItems'),
|
|
})
|
|
if (kvRow && kvRow.value) {
|
|
try {
|
|
const arr: number[] = JSON.parse(kvRow.value)
|
|
if (arr.includes(fromSkuId)) {
|
|
const newArr = [...new Set(arr.map((id) => (id === fromSkuId ? toSkuId : id)))]
|
|
await db.update(keyValStore)
|
|
.set({ value: JSON.stringify(newArr) })
|
|
.where(eq(keyValStore.key, 'popularItems'))
|
|
counts.popularItems = 1
|
|
}
|
|
} catch { /* value not valid JSON, skip */ }
|
|
}
|
|
|
|
// 6. Delete SKU features and the SKU itself
|
|
const featuresResult = await db.delete(skuFeatures).where(eq(skuFeatures.skuId, fromSkuId))
|
|
counts.skuFeatures = featuresResult.changes ?? 0
|
|
|
|
const skuResult = await db.delete(productSkus).where(eq(productSkus.id, fromSkuId))
|
|
counts.productSkus = skuResult.changes ?? 0
|
|
|
|
// 7. Delete orphaned product
|
|
const remainingSkus = await db.query.productSkus.findMany({
|
|
where: eq(productSkus.productId, fromSku.productId),
|
|
columns: { id: true },
|
|
})
|
|
|
|
let orphanedProductId: number | undefined
|
|
if (remainingSkus.length === 0) {
|
|
await db.delete(productInfo).where(eq(productInfo.id, fromSku.productId))
|
|
orphanedProductId = fromSku.productId
|
|
counts.orphanedProduct = 1
|
|
}
|
|
|
|
return {
|
|
fromSkuId,
|
|
toSkuId,
|
|
orphanedProductId,
|
|
counts,
|
|
}
|
|
}
|