265 lines
9.2 KiB
TypeScript
265 lines
9.2 KiB
TypeScript
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<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;
|
|
}),
|
|
|
|
});
|