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); // 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, }; }), });