freshyo/verifier/admin-apis/apis/product.ts
2026-03-22 20:20:18 +05:30

758 lines
22 KiB
TypeScript

import { router, protectedProcedure } from '@/src/trpc/trpc-index'
import { z } from 'zod';
import { db } from '@/src/db/db_index'
import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema'
import { eq, and, inArray, desc, sql } from 'drizzle-orm';
import { ApiError } from '@/src/lib/api-error'
import { imageUploadS3, scaffoldAssetUrl, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client'
import { deleteS3Image } from '@/src/lib/delete-image'
import type { SpecialDeal } from '@/src/db/types'
import { scheduleStoreInitialization } from '@/src/stores/store-initializer'
type CreateDeal = {
quantity: number;
price: number;
validTill: string;
};
export const productRouter = router({
getProducts: protectedProcedure
.query(async ({ ctx }) => {
const products = await db.query.productInfo.findMany({
orderBy: productInfo.name,
with: {
unit: true,
store: true,
},
});
// Generate signed URLs for all product images
const productsWithSignedUrls = await Promise.all(
products.map(async (product) => ({
...product,
images: scaffoldAssetUrl((product.images as string[]) || []),
}))
);
return {
products: productsWithSignedUrls,
count: productsWithSignedUrls.length,
};
}),
getProductById: protectedProcedure
.input(z.object({
id: z.number(),
}))
.query(async ({ input, ctx }) => {
const { id } = input;
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
with: {
unit: true,
},
});
if (!product) {
throw new ApiError("Product not found", 404);
}
// Fetch special deals for this product
const deals = await db.query.specialDeals.findMany({
where: eq(specialDeals.productId, id),
orderBy: specialDeals.quantity,
});
// Fetch associated tags for this product
const productTagsData = await db.query.productTags.findMany({
where: eq(productTags.productId, id),
with: {
tag: true,
},
});
// Generate signed URLs for product images
const productWithSignedUrls = {
...product,
images: scaffoldAssetUrl((product.images as string[]) || []),
deals,
tags: productTagsData.map(pt => pt.tag),
};
return {
product: productWithSignedUrls,
};
}),
deleteProduct: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { id } = input;
const [deletedProduct] = await db
.delete(productInfo)
.where(eq(productInfo.id, id))
.returning();
if (!deletedProduct) {
throw new ApiError("Product not found", 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Product deleted successfully",
};
}),
createProduct: protectedProcedure
.input(z.object({
name: z.string().min(1),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number(),
storeId: z.number(),
price: z.number(),
marketPrice: z.number().optional(),
incrementStep: z.number().default(1),
productQuantity: z.number().default(1),
isSuspended: z.boolean().default(false),
isFlashAvailable: z.boolean().default(false),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
imageKeys: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const {
name, shortDescription, longDescription, unitId, storeId,
price, marketPrice, incrementStep, productQuantity,
isSuspended, isFlashAvailable, flashPrice,
deals, tagIds, imageKeys
} = input;
// Validation
if (!name || !unitId || !storeId || !price) {
throw new ApiError("Name, unitId, storeId, and price are required", 400);
}
// Check for duplicate name
const existingProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.name, name.trim()),
});
if (existingProduct) {
throw new ApiError("A product with this name already exists", 400);
}
// Check if unit exists
const unit = await db.query.units.findFirst({
where: eq(units.id, unitId),
});
if (!unit) {
throw new ApiError("Invalid unit ID", 400);
}
console.log(imageKeys)
const [newProduct] = await db
.insert(productInfo)
.values({
name: name.trim(),
shortDescription,
longDescription,
unitId,
storeId,
price: price.toString(),
marketPrice: marketPrice?.toString(),
incrementStep,
productQuantity,
isSuspended,
isFlashAvailable,
flashPrice: flashPrice?.toString(),
images: imageKeys || [],
})
.returning();
// Handle deals
if (deals && deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: newProduct.id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await db.insert(specialDeals).values(dealInserts);
}
// Handle tags
if (tagIds && tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: newProduct.id,
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
// Claim upload URLs
if (imageKeys && imageKeys.length > 0) {
for (const key of imageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
scheduleStoreInitialization();
return {
product: newProduct,
message: "Product created successfully",
};
}),
updateProduct: protectedProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1).optional(),
shortDescription: z.string().optional(),
longDescription: z.string().optional(),
unitId: z.number().optional(),
storeId: z.number().optional(),
price: z.number().optional(),
marketPrice: z.number().optional(),
incrementStep: z.number().optional(),
productQuantity: z.number().optional(),
isSuspended: z.boolean().optional(),
isFlashAvailable: z.boolean().optional(),
flashPrice: z.number().optional(),
deals: z.array(z.object({
quantity: z.number(),
price: z.number(),
validTill: z.string(),
})).optional(),
tagIds: z.array(z.number()).optional(),
newImageKeys: z.array(z.string()).optional(),
imagesToDelete: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input;
// Get current product
const currentProduct = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
});
if (!currentProduct) {
throw new ApiError("Product not found", 404);
}
// Handle image deletions
let currentImages = (currentProduct.images as string[]) || [];
if (imagesToDelete && imagesToDelete.length > 0) {
for (const imageUrl of imagesToDelete) {
try {
await deleteS3Image(imageUrl);
} catch (e) {
console.error(`Failed to delete image: ${imageUrl}`, e);
}
}
currentImages = currentImages.filter(img => {
//!imagesToDelete.includes(img)
const isRemoved = imagesToDelete.some(item => item.includes(img));
return !isRemoved;
});
}
// Add new images
if (newImageKeys && newImageKeys.length > 0) {
currentImages = [...currentImages, ...newImageKeys];
for (const key of newImageKeys) {
try {
await claimUploadUrl(key);
} catch (e) {
console.warn(`Failed to claim upload URL for key: ${key}`, e);
}
}
}
// Update product - convert numeric fields to strings for PostgreSQL numeric type
const { price, marketPrice, flashPrice, ...otherData } = updateData;
const [updatedProduct] = await db
.update(productInfo)
.set({
...otherData,
...(price !== undefined && { price: price.toString() }),
...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }),
...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }),
images: currentImages,
})
.where(eq(productInfo.id, id))
.returning();
// Handle deals update
if (deals !== undefined) {
await db.delete(specialDeals).where(eq(specialDeals.productId, id));
if (deals.length > 0) {
const dealInserts = deals.map(deal => ({
productId: id,
quantity: deal.quantity.toString(),
price: deal.price.toString(),
validTill: new Date(deal.validTill),
}));
await db.insert(specialDeals).values(dealInserts);
}
}
// Handle tags update
if (tagIds !== undefined) {
await db.delete(productTags).where(eq(productTags.productId, id));
if (tagIds.length > 0) {
const tagAssociations = tagIds.map(tagId => ({
productId: id,
tagId,
}));
await db.insert(productTags).values(tagAssociations);
}
}
scheduleStoreInitialization();
return {
product: updatedProduct,
message: "Product updated successfully",
};
}),
toggleOutOfStock: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { id } = input;
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, id),
});
if (!product) {
throw new ApiError("Product not found", 404);
}
const [updatedProduct] = await db
.update(productInfo)
.set({
isOutOfStock: !product.isOutOfStock,
})
.where(eq(productInfo.id, id))
.returning();
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
product: updatedProduct,
message: `Product marked as ${updatedProduct.isOutOfStock ? 'out of stock' : 'in stock'}`,
};
}),
updateSlotProducts: protectedProcedure
.input(z.object({
slotId: z.string(),
productIds: z.array(z.string()),
}))
.mutation(async ({ input, ctx }) => {
const { slotId, productIds } = input;
if (!Array.isArray(productIds)) {
throw new ApiError("productIds must be an array", 400);
}
// Get current associations
const currentAssociations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const currentProductIds = currentAssociations.map(assoc => assoc.productId);
const newProductIds = productIds.map((id: string) => parseInt(id));
// Find products to add and remove
const productsToAdd = newProductIds.filter(id => !currentProductIds.includes(id));
const productsToRemove = currentProductIds.filter(id => !newProductIds.includes(id));
// Remove associations for products that are no longer selected
if (productsToRemove.length > 0) {
await db.delete(productSlots).where(
and(
eq(productSlots.slotId, parseInt(slotId)),
inArray(productSlots.productId, productsToRemove)
)
);
}
// Add associations for newly selected products
if (productsToAdd.length > 0) {
const newAssociations = productsToAdd.map(productId => ({
productId,
slotId: parseInt(slotId),
}));
await db.insert(productSlots).values(newAssociations);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: "Slot products updated successfully",
added: productsToAdd.length,
removed: productsToRemove.length,
};
}),
getSlotProductIds: protectedProcedure
.input(z.object({
slotId: z.string(),
}))
.query(async ({ input, ctx }) => {
const { slotId } = input;
const associations = await db.query.productSlots.findMany({
where: eq(productSlots.slotId, parseInt(slotId)),
columns: {
productId: true,
},
});
const productIds = associations.map(assoc => assoc.productId);
return {
productIds,
};
}),
getSlotsProductIds: protectedProcedure
.input(z.object({
slotIds: z.array(z.number()),
}))
.query(async ({ input, ctx }) => {
const { slotIds } = input;
if (!Array.isArray(slotIds)) {
throw new ApiError("slotIds must be an array", 400);
}
if (slotIds.length === 0) {
return {};
}
// Fetch all associations for the requested slots
const associations = await db.query.productSlots.findMany({
where: inArray(productSlots.slotId, slotIds),
columns: {
slotId: true,
productId: true,
},
});
// Group by slotId
const result = associations.reduce((acc, assoc) => {
if (!acc[assoc.slotId]) {
acc[assoc.slotId] = [];
}
acc[assoc.slotId].push(assoc.productId);
return acc;
}, {} as Record<number, number[]>);
// Ensure all requested slots have entries (even if empty)
slotIds.forEach(slotId => {
if (!result[slotId]) {
result[slotId] = [];
}
});
return result;
}),
getProductReviews: protectedProcedure
.input(z.object({
productId: z.number().int().positive(),
limit: z.number().int().min(1).max(50).optional().default(10),
offset: z.number().int().min(0).optional().default(0),
}))
.query(async ({ input }) => {
const { productId, limit, offset } = input;
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);
// Generate signed URLs for images
const reviewsWithSignedUrls = await Promise.all(
reviews.map(async (review) => ({
...review,
signedImageUrls: scaffoldAssetUrl((review.imageUrls as string[]) || []),
signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages as string[]) || []),
}))
);
// Check if more reviews exist
const totalCountResult = await db
.select({ count: sql`count(*)` })
.from(productReviews)
.where(eq(productReviews.productId, productId));
const totalCount = Number(totalCountResult[0].count);
const hasMore = offset + limit < totalCount;
return { reviews: reviewsWithSignedUrls, hasMore };
}),
respondToReview: protectedProcedure
.input(z.object({
reviewId: z.number().int().positive(),
adminResponse: z.string().optional(),
adminResponseImages: z.array(z.string()).optional().default([]),
uploadUrls: z.array(z.string()).optional().default([]),
}))
.mutation(async ({ input }) => {
const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input;
const [updatedReview] = await db
.update(productReviews)
.set({
adminResponse,
adminResponseImages,
})
.where(eq(productReviews.id, reviewId))
.returning();
if (!updatedReview) {
throw new ApiError('Review not found', 404);
}
// Claim upload URLs
if (uploadUrls && uploadUrls.length > 0) {
// const { claimUploadUrl } = await import('@/src/lib/s3-client');
await Promise.all(uploadUrls.map(url => claimUploadUrl(url)));
}
return { success: true, review: updatedReview };
}),
getGroups: protectedProcedure
.query(async ({ ctx }) => {
const groups = await db.query.productGroupInfo.findMany({
with: {
memberships: {
with: {
product: true,
},
},
},
orderBy: desc(productGroupInfo.createdAt),
});
return {
groups: groups.map(group => ({
...group,
products: group.memberships.map(m => m.product),
productCount: group.memberships.length,
})),
};
}),
createGroup: protectedProcedure
.input(z.object({
group_name: z.string().min(1),
description: z.string().optional(),
product_ids: z.array(z.number()).default([]),
}))
.mutation(async ({ input, ctx }) => {
const { group_name, description, product_ids } = input;
const [newGroup] = await db
.insert(productGroupInfo)
.values({
groupName: group_name,
description,
})
.returning();
if (product_ids.length > 0) {
const memberships = product_ids.map(productId => ({
productId,
groupId: newGroup.id,
}));
await db.insert(productGroupMembership).values(memberships);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
group: newGroup,
message: 'Group created successfully',
};
}),
updateGroup: protectedProcedure
.input(z.object({
id: z.number(),
group_name: z.string().optional(),
description: z.string().optional(),
product_ids: z.array(z.number()).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { id, group_name, description, product_ids } = input;
const updateData: any = {};
if (group_name !== undefined) updateData.groupName = group_name;
if (description !== undefined) updateData.description = description;
const [updatedGroup] = await db
.update(productGroupInfo)
.set(updateData)
.where(eq(productGroupInfo.id, id))
.returning();
if (!updatedGroup) {
throw new ApiError('Group not found', 404);
}
if (product_ids !== undefined) {
// Delete existing memberships
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
// Insert new memberships
if (product_ids.length > 0) {
const memberships = product_ids.map(productId => ({
productId,
groupId: id,
}));
await db.insert(productGroupMembership).values(memberships);
}
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
group: updatedGroup,
message: 'Group updated successfully',
};
}),
deleteGroup: protectedProcedure
.input(z.object({
id: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { id } = input;
// Delete memberships first
await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id));
// Delete group
const [deletedGroup] = await db
.delete(productGroupInfo)
.where(eq(productGroupInfo.id, id))
.returning();
if (!deletedGroup) {
throw new ApiError('Group not found', 404);
}
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: 'Group deleted successfully',
};
}),
updateProductPrices: protectedProcedure
.input(z.object({
updates: z.array(z.object({
productId: z.number(),
price: z.number().optional(),
marketPrice: z.number().nullable().optional(),
flashPrice: z.number().nullable().optional(),
isFlashAvailable: z.boolean().optional(),
})),
}))
.mutation(async ({ input, ctx }) => {
const { updates } = input;
if (updates.length === 0) {
throw new ApiError('No updates provided', 400);
}
// Validate that all productIds exist
const productIds = updates.map(u => u.productId);
const existingProducts = await db.query.productInfo.findMany({
where: inArray(productInfo.id, productIds),
columns: { id: true },
});
const existingIds = new Set(existingProducts.map(p => p.id));
const invalidIds = productIds.filter(id => !existingIds.has(id));
if (invalidIds.length > 0) {
throw new ApiError(`Invalid product IDs: ${invalidIds.join(', ')}`, 400);
}
// Perform batch update
const updatePromises = updates.map(async (update) => {
const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update;
const updateData: any = {};
if (price !== undefined) updateData.price = price;
if (marketPrice !== undefined) updateData.marketPrice = marketPrice;
if (flashPrice !== undefined) updateData.flashPrice = flashPrice;
if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable;
return db
.update(productInfo)
.set(updateData)
.where(eq(productInfo.id, productId));
});
await Promise.all(updatePromises);
// Reinitialize stores to reflect changes
scheduleStoreInitialization()
return {
message: `Updated prices for ${updates.length} product(s)`,
updatedCount: updates.length,
};
}),
});