import { router, publicProcedure, protectedProcedure } from '../trpc-index'; import { z } from 'zod'; import { db } from '../../db/db_index'; import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTagInfo, productTags, productReviews, users } from '../../db/schema'; import { claimUploadUrl, extractKeyFromPresignedUrl, scaffoldAssetUrl } from '../../lib/s3-client'; import { ApiError } from '../../lib/api-error'; import { eq, and, gt, sql, inArray, desc } from 'drizzle-orm'; import { getProductById as getProductByIdFromCache, getAllProducts as getAllProductsFromCache } from '../../stores/product-store'; import dayjs from 'dayjs'; // Uniform Product Type interface Product { id: number; name: string; shortDescription: string | null; longDescription: string | null; price: string; marketPrice: string | null; unitNotation: string; images: string[]; isOutOfStock: boolean; store: { id: number; name: string; description: string | null } | null; incrementStep: number; productQuantity: number; isFlashAvailable: boolean; flashPrice: string | null; deliverySlots: Array<{ id: number; deliveryTime: Date; freezeTime: Date }>; specialDeals: Array<{ quantity: string; price: string; validTill: Date }>; } export const productRouter = router({ getProductDetails: publicProcedure .input(z.object({ id: z.string().regex(/^\d+$/, 'Invalid product ID'), })) .query(async ({ input }): Promise => { const { id } = input; const productId = parseInt(id); if (isNaN(productId)) { throw new Error('Invalid product ID'); } console.log('from the api to get product details') // First, try to get the product from Redis cache const cachedProduct = await getProductByIdFromCache(productId); if (cachedProduct) { // Filter delivery slots to only include those with future freeze times and not at full capacity const currentTime = new Date(); const filteredSlots = cachedProduct.deliverySlots.filter(slot => dayjs(slot.freezeTime).isAfter(currentTime) && !slot.isCapacityFull ); return { ...cachedProduct, deliverySlots: filteredSlots }; } // If not in cache, fetch from database (fallback) const productData = await db .select({ id: productInfo.id, name: productInfo.name, shortDescription: productInfo.shortDescription, longDescription: productInfo.longDescription, price: productInfo.price, marketPrice: productInfo.marketPrice, images: productInfo.images, isOutOfStock: productInfo.isOutOfStock, storeId: productInfo.storeId, unitShortNotation: units.shortNotation, incrementStep: productInfo.incrementStep, productQuantity: productInfo.productQuantity, isFlashAvailable: productInfo.isFlashAvailable, flashPrice: productInfo.flashPrice, }) .from(productInfo) .innerJoin(units, eq(productInfo.unitId, units.id)) .where(eq(productInfo.id, productId)) .limit(1); if (productData.length === 0) { throw new Error('Product not found'); } const product = productData[0]; // Fetch store info for this product const storeData = product.storeId ? await db.query.storeInfo.findFirst({ where: eq(storeInfo.id, product.storeId), columns: { id: true, name: true, description: true }, }) : null; // Fetch delivery slots for this product const deliverySlotsData = await db .select({ id: deliverySlotInfo.id, deliveryTime: deliverySlotInfo.deliveryTime, freezeTime: deliverySlotInfo.freezeTime, }) .from(productSlots) .innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id)) .where( and( eq(productSlots.productId, productId), eq(deliverySlotInfo.isActive, true), eq(deliverySlotInfo.isCapacityFull, false), gt(deliverySlotInfo.deliveryTime, sql`NOW()`), gt(deliverySlotInfo.freezeTime, sql`NOW()`) ) ) .orderBy(deliverySlotInfo.deliveryTime); // Fetch special deals for this product const specialDealsData = await db .select({ quantity: specialDeals.quantity, price: specialDeals.price, validTill: specialDeals.validTill, }) .from(specialDeals) .where( and( eq(specialDeals.productId, productId), gt(specialDeals.validTill, sql`NOW()`) ) ) .orderBy(specialDeals.quantity); // Generate signed URLs for images const signedImages = scaffoldAssetUrl((product.images as string[]) || []); const response: Product = { id: product.id, name: product.name, shortDescription: product.shortDescription, longDescription: product.longDescription, price: product.price.toString(), marketPrice: product.marketPrice?.toString() || null, unitNotation: product.unitShortNotation, images: signedImages, isOutOfStock: product.isOutOfStock, store: storeData ? { id: storeData.id, name: storeData.name, description: storeData.description, } : null, incrementStep: product.incrementStep, productQuantity: product.productQuantity, isFlashAvailable: product.isFlashAvailable, flashPrice: product.flashPrice?.toString() || null, deliverySlots: deliverySlotsData, specialDeals: specialDealsData.map(d => ({ quantity: d.quantity.toString(), price: d.price.toString(), validTill: d.validTill })), }; return response; }), getProductReviews: publicProcedure .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, 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[]) || []), })) ); // 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 }; }), createReview: protectedProcedure .input(z.object({ productId: z.number().int().positive(), reviewBody: z.string().min(1, 'Review body is required'), ratings: z.number().int().min(1).max(5), imageUrls: z.array(z.string()).optional().default([]), uploadUrls: z.array(z.string()).optional().default([]), })) .mutation(async ({ input, ctx }) => { const { productId, reviewBody, ratings, imageUrls, uploadUrls } = input; const userId = ctx.user.userId; // Optional: Check if product exists const product = await db.query.productInfo.findFirst({ where: eq(productInfo.id, productId), }); if (!product) { throw new ApiError('Product not found', 404); } // Insert review const [newReview] = await db.insert(productReviews).values({ userId, productId, reviewBody, ratings, imageUrls: uploadUrls.map(item => extractKeyFromPresignedUrl(item)), }).returning(); // Claim upload URLs if (uploadUrls && uploadUrls.length > 0) { try { await Promise.all(uploadUrls.map(url => claimUploadUrl(url))); } catch (error) { console.error('Error claiming upload URLs:', error); // Don't fail the review creation } } return { success: true, review: newReview }; }), getAllProductsSummary: publicProcedure .query(async (): Promise => { // Get all products from cache const allCachedProducts = await getAllProductsFromCache(); // Transform the cached products to match the expected summary format // (with empty deliverySlots and specialDeals arrays for summary view) const transformedProducts = allCachedProducts.map(product => ({ ...product, deliverySlots: [], // Empty for summary view specialDeals: [], // Empty for summary view })); return transformedProducts; }), });