freshyo/packages/db_helper_sqlite/src/admin-apis/product.ts
2026-09-06 18:05:20 +05:30

1103 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { db } from '../db/db_index'
import {
productInfo,
productMarketStats,
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'
// Chunk sizes for multi-row inserts so no single statement exceeds SQLite's
// 999-bind-parameter limit (chunk size × params-per-row must stay under 999).
const SKU_INSERT_CHUNK_SIZE = 100 // 6 params per SKU row
const FEATURE_INSERT_CHUNK_SIZE = 200 // 3 params per feature row
const COMBO_ITEM_INSERT_CHUNK_SIZE = 400 // 2 params per combo-item row
// product_combos has a UNIQUE index on (comboSkuId, skuId) — drop duplicates
// (by skuId) before inserting so a repeated pick doesn't fail the insert.
const dedupeComboItems = (items: any[]): any[] => {
const seen = new Set<number>()
return (items || []).filter((ci: any) => {
const id = Number(ci.skuId)
if (!id || seen.has(id)) return false
seen.add(id)
return true
})
}
import type {
AdminProduct,
AdminProductGroupInfo,
AdminProductTagInfo,
AdminProductTagWithProducts,
AdminProductReview,
AdminProductWithDetails,
AdminProductWithRelations,
AdminSku,
AdminSkuFeature,
AdminSpecialDeal,
AdminUnit,
AdminUpdateSlotProductsResult,
CreateComboItemInput,
CreateProductInput,
CreateSkuInput,
ProductTagCore,
SkuFeatureLike,
Store,
} from '@packages/shared'
type ProductRow = InferSelectModel<typeof productInfo>
type SkuRow = InferSelectModel<typeof productSkus>
type MarketStatsRow = InferSelectModel<typeof productMarketStats>
type SkuFeatureRow = InferSelectModel<typeof skuFeatures>
type StoreRow = InferSelectModel<typeof storeInfo>
type SpecialDealRow = InferSelectModel<typeof specialDeals>
type ProductTagInfoRow = InferSelectModel<typeof productTagInfo>
type ProductTagRow = InferSelectModel<typeof productTags>
const getStringArray = (value: unknown): string[] | null => {
if (!Array.isArray(value)) return null
return value.map((item) => String(item))
}
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[] = [],
marketStats: MarketStatsRow | null = null
): AdminSku => ({
id: sku.id,
productId: sku.productId,
name: sku.name ?? null,
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
images: getStringArray(sku.images),
imageKeys: getStringArray(sku.images),
isOutOfStock: marketStats?.isOutOfStock ?? false,
isSuspended: marketStats?.isSuspended ?? false,
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
isOffer: sku.isOffer,
isComboOnly: sku.isComboOnly,
isDeleted: sku.isDeleted ?? false,
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,
sortOrder: tag.sortOrder ?? [],
createdAt: tag.createdAt,
})
export async function getAllProducts(): Promise<AdminProductWithRelations[]> {
type ProductWithRelationsRow = ProductRow & {
store: StoreRow | null
skus: Array<SkuRow & { features: SkuFeatureRow[]; comboItems: any[]; marketStats: MarketStatsRow | null }>
}
const products = await db.query.productInfo.findMany({
orderBy: productInfo.name,
with: {
store: true,
skus: {
with: {
features: true,
marketStats: true,
comboItems: {
with: {
sku: { with: { product: true, features: true, marketStats: 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: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
}))
return mapSku(sku, sku.features, comboItems, sku.marketStats)
}),
}))
}
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,
marketStats: true,
comboItems: {
with: {
sku: { with: { product: true, features: true, marketStats: 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: any) => ({ featureName: f.featureName, featureValue: f.featureValue })),
productName: ci.sku?.product?.name ?? 'Unknown',
images: getStringArray(ci.sku?.images),
price: ci.sku?.marketStats?.ourPrice ? String(ci.sku.marketStats.ourPrice) : '0',
}))
return mapSku(sku, sku.features, comboItems, sku.marketStats)
})
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>
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')
}
try {
return await db.transaction(async (tx) => {
const { skus, ...productData } = input
const [product] = await tx.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 skuChunks = await runBatched(tx, skus, SKU_INSERT_CHUNK_SIZE, async (t, chunk) => {
return t.insert(productSkus).values(
chunk.map((sku) => ({
productId: product.id,
name: sku.name ?? null,
images: sku.images ?? null,
isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false,
isDeleted: sku.isDeleted ?? false,
}))
).returning()
})
const skuRows = skuChunks.flat()
for (let i = 0; i < skuRows.length; i++) {
const skuRow = skuRows[i]
const sku = skus[i]
await tx.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,
})
if (sku.features && sku.features.length > 0) {
await runBatched(tx, sku.features, FEATURE_INSERT_CHUNK_SIZE, async (t, chunk) => {
await t.insert(skuFeatures).values(
chunk.map((f) => ({
skuId: skuRow.id,
featureName: f.featureName,
featureValue: f.featureValue,
}))
)
})
}
if (sku.comboItems && sku.comboItems.length > 0) {
const comboItems = dedupeComboItems(sku.comboItems)
if (comboItems.length > 0) {
await runBatched(tx, comboItems, COMBO_ITEM_INSERT_CHUNK_SIZE, async (t, chunk) => {
await t.insert(productCombos).values(
chunk.map((ci: any) => ({
comboSkuId: skuRow.id,
skuId: ci.skuId,
}))
)
})
}
}
}
const createdSkus = await tx.query.productSkus.findMany({
where: eq(productSkus.productId, product.id),
with: { features: true, marketStats: true },
})
return {
...mapProduct(product),
store: null,
skus: createdSkus.map((s) => mapSku(s, s.features, [], s.marketStats)),
}
})
} catch (error) {
console.error('createProduct failed:', error)
throw new Error('Failed to create product. Please check the SKU details and try again.')
}
}
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
// Input validation outside the transaction so specific messages reach the UI.
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))
const skusBeingSuspended = skus.filter(
(sku: any) => sku.isSuspended && sku.id != null && existingSkuIdSet.has(sku.id)
)
if (skusBeingSuspended.length > 0) {
const suspendingIds = skusBeingSuspended.map((sku: any) => sku.id)
const comboMemberships = await db.query.productCombos.findMany({
where: inArray(productCombos.skuId, suspendingIds),
columns: { comboSkuId: true },
})
const comboIds = Array.from(new Set(comboMemberships.map((c) => c.comboSkuId)))
if (comboIds.length > 0) {
const combos = await db.query.productMarketStats.findMany({
where: inArray(productMarketStats.skuId, comboIds),
columns: { skuId: true, isSuspended: true },
})
const activeComboIds = combos.filter((c) => !c.isSuspended).map((c) => c.skuId)
if (activeComboIds.length > 0) {
throw new Error(
`Cannot suspend SKU(s): they are members of combo(s) ${activeComboIds.join(', ')} that are not suspended`
)
}
}
}
}
try {
return await db.transaction(async (tx) => {
await tx.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) {
const existingSkus = await tx.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 tx.update(productSkus)
.set({
name: sku.name ?? null,
images: sku.images ?? null,
isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false,
...(sku.isDeleted !== undefined && { isDeleted: sku.isDeleted }),
})
.where(eq(productSkus.id, sku.id))
const existingMarketStats = await tx.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 tx.update(productMarketStats)
.set(marketStatsValues)
.where(eq(productMarketStats.skuId, sku.id))
} else {
await tx.insert(productMarketStats).values({
skuId: sku.id,
...marketStatsValues,
})
}
await tx.delete(skuFeatures).where(eq(skuFeatures.skuId, sku.id))
if (sku.features && sku.features.length > 0) {
await runBatched(tx, sku.features, FEATURE_INSERT_CHUNK_SIZE, async (t, chunk) => {
await t.insert(skuFeatures).values(
chunk.map((f: any) => ({
skuId: sku.id,
featureName: f.featureName,
featureValue: f.featureValue,
}))
)
})
}
await tx.delete(productCombos).where(eq(productCombos.comboSkuId, sku.id))
if (sku.comboItems && sku.comboItems.length > 0) {
const comboItems = dedupeComboItems(sku.comboItems)
if (comboItems.length > 0) {
await runBatched(tx, comboItems, COMBO_ITEM_INSERT_CHUNK_SIZE, async (t, chunk) => {
await t.insert(productCombos).values(
chunk.map((ci: any) => ({
comboSkuId: sku.id,
skuId: ci.skuId,
}))
)
})
}
}
} else {
// Insert new SKU
const [newSku] = await tx.insert(productSkus).values({
productId: id,
name: sku.name ?? null,
images: sku.images ?? null,
isOffer: sku.isOffer ?? false,
isComboOnly: sku.isComboOnly ?? false,
isDeleted: sku.isDeleted ?? false,
}).returning()
await tx.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,
})
if (sku.features && sku.features.length > 0) {
await runBatched(tx, sku.features, FEATURE_INSERT_CHUNK_SIZE, async (t, chunk) => {
await t.insert(skuFeatures).values(
chunk.map((f: any) => ({
skuId: newSku.id,
featureName: f.featureName,
featureValue: f.featureValue,
}))
)
})
}
// New SKUs also save their combo items (with dedupe).
if (sku.comboItems && sku.comboItems.length > 0) {
const comboItems = dedupeComboItems(sku.comboItems)
if (comboItems.length > 0) {
await runBatched(tx, comboItems, COMBO_ITEM_INSERT_CHUNK_SIZE, async (t, chunk) => {
await t.insert(productCombos).values(
chunk.map((ci: any) => ({
comboSkuId: newSku.id,
skuId: ci.skuId,
}))
)
})
}
}
}
}
}
const updatedProduct = await tx.query.productInfo.findFirst({
where: eq(productInfo.id, id),
with: {
store: true,
skus: {
with: { features: true, marketStats: true },
},
},
})
if (!updatedProduct) {
return null
}
return {
...mapProduct(updatedProduct),
store: updatedProduct.store ? mapStore(updatedProduct.store) : null,
skus: updatedProduct.skus.map((s) => mapSku(s, s.features, [], s.marketStats)),
}
})
} catch (error) {
console.error('updateProduct failed:', error)
throw new Error('Failed to update product. Please check the SKU details and try again.')
}
}
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 getAllProductTags(): Promise<AdminProductTagWithProducts[]> {
const tags = await db.query.productTagInfo.findMany({
with: {
products: {
with: {
sku: {
with: { features: true, marketStats: true },
},
},
},
},
}) as Array<ProductTagInfoRow & { products: Array<ProductTagRow & { sku: SkuRow & { features: SkuFeatureRow[]; marketStats: MarketStatsRow | null } }> }>
return tags.map((tag: any) => ({
...mapTagInfo(tag),
products: tag.products.map((assignment: any) => ({
productId: assignment.productId,
tagId: assignment.tagId,
assignedAt: assignment.assignedAt,
product: mapSku(assignment.sku, assignment.sku.features, [], assignment.sku.marketStats),
})),
productIds: tag.products.map((assignment: ProductTagRow) => assignment.productId),
}))
}
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 extends ProductTagCore {
sortOrder?: 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 || [],
sortOrder: input.sortOrder || [],
}).returning()
return {
...mapTagInfo(tag),
products: [],
productIds: [],
}
}
export async function getProductTagById(tagId: number): Promise<AdminProductTagWithProducts | null> {
const tag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, tagId),
with: {
products: {
with: {
sku: {
with: { features: true, marketStats: true },
},
},
},
},
})
if (!tag) {
return null
}
return {
...mapTagInfo(tag),
products: (tag.products || []).map((assignment: any) => ({
productId: assignment.productId,
tagId: assignment.tagId,
assignedAt: assignment.assignedAt,
product: mapSku(assignment.sku, assignment.sku.features, [], assignment.sku.marketStats),
})),
productIds: (tag.products || []).map((assignment: ProductTagRow) => assignment.productId),
}
}
export type UpdateProductTagInput = Omit<ProductTagCore, 'tagName'> & {
tagName?: string
sortOrder?: 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 }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
}).where(eq(productTagInfo.id, tagId)).returning()
const fullTag = await db.query.productTagInfo.findFirst({
where: eq(productTagInfo.id, tagId),
with: {
products: {
with: {
sku: {
with: { features: true, marketStats: true },
},
},
},
},
})
return {
...mapTagInfo(tag),
products: (fullTag?.products || []).map((assignment: any) => ({
productId: assignment.productId,
tagId: assignment.tagId,
assignedAt: assignment.assignedAt,
product: mapSku(assignment.sku, assignment.sku.features, [], assignment.sku.marketStats),
})),
productIds: (fullTag?.products || []).map((assignment: ProductTagRow) => assignment.productId) || [],
}
}
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: {
sku: {
with: { features: true, marketStats: 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) =>
mapSku(membership.sku, membership.sku.features, [], membership.sku.marketStats)
),
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 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.ourPrice = price.toString()
if (marketPrice !== undefined) updateData.marketPrice = marketPrice === null ? null : marketPrice.toString()
if (flashPrice !== undefined) updateData.flashPrice = flashPrice === null ? null : flashPrice.toString()
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable
if (Object.keys(updateData).length === 0) continue
const existingMarketStats = await tx.query.productMarketStats.findFirst({
where: eq(productMarketStats.skuId, productId),
columns: { id: true },
})
if (existingMarketStats) {
await tx
.update(productMarketStats)
.set(updateData)
.where(eq(productMarketStats.skuId, productId))
} else {
await tx.insert(productMarketStats).values({
skuId: productId,
ourPrice: updateData.ourPrice ?? '0',
marketPrice: updateData.marketPrice ?? null,
flashPrice: updateData.flashPrice ?? null,
isFlashAvailable: updateData.isFlashAvailable ?? false,
})
}
}
})
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 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 replaceTagProducts(tagId: number, productIds: number[]): Promise<void> {
await db.delete(productTags).where(eq(productTags.tagId, tagId))
if (productIds.length === 0) {
return
}
const productAssociations = productIds.map((productId) => ({
productId,
tagId,
}))
await db.insert(productTags).values(productAssociations)
}