1103 lines
34 KiB
TypeScript
1103 lines
34 KiB
TypeScript
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)
|
||
}
|