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

266 lines
9.2 KiB
TypeScript

import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index';
import { z } from 'zod';
import { db } from '@/src/db/db_index';
import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTagInfo, productTags, productReviews, users } from '@/src/db/schema';
import { claimUploadUrl, extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client';
import { ApiError } from '@/src/lib/api-error';
import { eq, and, gt, sql, inArray, desc } from 'drizzle-orm';
import { getProductById as getProductByIdFromCache, getAllProducts as getAllProductsFromCache } from '@/src/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<Product> => {
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<Product[]> => {
// 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;
}),
});