import { router, protectedProcedure } from "../trpc-index"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { db } from "../../db/db_index"; import { deliverySlotInfo, productSlots, productInfo, vendorSnippets, productGroupInfo } from "../../db/schema"; import { eq, inArray, and, desc } from "drizzle-orm"; import { ApiError } from "../../lib/api-error"; import { appUrl } from "../../lib/env-exporter"; import redisClient from "../../lib/redis-client"; import { getSlotSequenceKey } from "../../lib/redisKeyGetters"; import { initializeAllStores } from '../../stores/store-initializer'; interface CachedDeliverySequence { [userId: string]: number[]; } const cachedSequenceSchema = z.record(z.string(), z.array(z.number())); const createSlotSchema = z.object({ deliveryTime: z.string(), freezeTime: z.string(), isActive: z.boolean().optional(), productIds: z.array(z.number()).optional(), vendorSnippets: z.array(z.object({ name: z.string().min(1), productIds: z.array(z.number().int().positive()).min(1), validTill: z.string().optional(), })).optional(), groupIds: z.array(z.number()).optional(), }); const getSlotByIdSchema = z.object({ id: z.number(), }); const updateSlotSchema = z.object({ id: z.number(), deliveryTime: z.string(), freezeTime: z.string(), isActive: z.boolean().optional(), productIds: z.array(z.number()).optional(), vendorSnippets: z.array(z.object({ name: z.string().min(1), productIds: z.array(z.number().int().positive()).min(1), validTill: z.string().optional(), })).optional(), groupIds: z.array(z.number()).optional(), }); const deleteSlotSchema = z.object({ id: z.number(), }); const getDeliverySequenceSchema = z.object({ id: z.string(), }); const updateDeliverySequenceSchema = z.object({ id: z.number(), // deliverySequence: z.array(z.number()), deliverySequence: z.any(), }); export const slotsRouter = router({ // Exact replica of GET /av/slots getAll: protectedProcedure.query(async ({ ctx }) => { if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } const slots = await db.query.deliverySlotInfo .findMany({ where: eq(deliverySlotInfo.isActive, true), orderBy: desc(deliverySlotInfo.deliveryTime), with: { productSlots: { with: { product: { columns: { id: true, name: true, images: true, }, }, }, }, }, }) .then((slots) => slots.map((slot) => ({ ...slot, deliverySequence: slot.deliverySequence as number[], products: slot.productSlots.map((ps) => ps.product), })) ); return { slots, count: slots.length, }; }), // Exact replica of POST /av/products/slots/product-ids getSlotsProductIds: protectedProcedure .input(z.object({ slotIds: z.array(z.number()) })) .query(async ({ input, ctx }) => { if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } const { slotIds } = input; if (!Array.isArray(slotIds)) { throw new TRPCError({ code: "BAD_REQUEST", message: "slotIds must be an array", }); } 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; }), // Exact replica of PUT /av/products/slots/:slotId/products updateSlotProducts: protectedProcedure .input( z.object({ slotId: z.number(), productIds: z.array(z.number()), }) ) .mutation(async ({ input, ctx }) => { if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } const { slotId, productIds } = input; if (!Array.isArray(productIds)) { throw new TRPCError({ code: "BAD_REQUEST", message: "productIds must be an array", }); } // Get current associations const currentAssociations = await db.query.productSlots.findMany({ where: eq(productSlots.slotId, slotId), columns: { productId: true, }, }); const currentProductIds = currentAssociations.map( (assoc) => assoc.productId ); const newProductIds = productIds; // 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, slotId), inArray(productSlots.productId, productsToRemove) ) ); } // Add associations for newly selected products if (productsToAdd.length > 0) { const newAssociations = productsToAdd.map((productId) => ({ productId, slotId, })); await db.insert(productSlots).values(newAssociations); } // Reinitialize stores to reflect changes await initializeAllStores(); return { message: "Slot products updated successfully", added: productsToAdd.length, removed: productsToRemove.length, }; }), createSlot: protectedProcedure .input(createSlotSchema) .mutation(async ({ input, ctx }) => { if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } const { deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input; // Validate required fields if (!deliveryTime || !freezeTime) { throw new ApiError("Delivery time and orders close time are required", 400); } return await db.transaction(async (tx) => { // Create slot const [newSlot] = await tx .insert(deliverySlotInfo) .values({ deliveryTime: new Date(deliveryTime), freezeTime: new Date(freezeTime), isActive: isActive !== undefined ? isActive : true, groupIds: groupIds !== undefined ? groupIds : [], }) .returning(); // Insert product associations if provided if (productIds && productIds.length > 0) { const associations = productIds.map((productId) => ({ productId, slotId: newSlot.id, })); await tx.insert(productSlots).values(associations); } // Create vendor snippets if provided let createdSnippets: any[] = []; if (snippets && snippets.length > 0) { for (const snippet of snippets) { // Validate products exist const products = await tx.query.productInfo.findMany({ where: inArray(productInfo.id, snippet.productIds), }); if (products.length !== snippet.productIds.length) { throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400); } // Check if snippet name already exists const existingSnippet = await tx.query.vendorSnippets.findFirst({ where: eq(vendorSnippets.snippetCode, snippet.name), }); if (existingSnippet) { throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400); } const [createdSnippet] = await tx.insert(vendorSnippets).values({ snippetCode: snippet.name, slotId: newSlot.id, productIds: snippet.productIds, validTill: snippet.validTill ? new Date(snippet.validTill) : undefined, }).returning(); createdSnippets.push(createdSnippet); } } // Reinitialize stores to reflect changes await initializeAllStores(); return { slot: newSlot, createdSnippets, message: "Slot created successfully", }; }); }), getSlots: protectedProcedure.query(async ({ ctx }) => { if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } const slots = await db.query.deliverySlotInfo.findMany({ where: eq(deliverySlotInfo.isActive, true), }); return { slots, count: slots.length, }; }), getSlotById: protectedProcedure .input(getSlotByIdSchema) .query(async ({ input, ctx }) => { if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } const { id } = input; const slot = await db.query.deliverySlotInfo.findFirst({ where: eq(deliverySlotInfo.id, id), with: { productSlots: { with: { product: { columns: { id: true, name: true, images: true, }, }, }, }, vendorSnippets: true, }, }); if (!slot) { throw new ApiError("Slot not found", 404); } return { slot: { ...slot, deliverySequence: slot.deliverySequence as number[], groupIds: slot.groupIds as number[], products: slot.productSlots.map((ps) => ps.product), vendorSnippets: slot.vendorSnippets?.map(snippet => ({ ...snippet, accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}` })), }, }; }), updateSlot: protectedProcedure .input(updateSlotSchema) .mutation(async ({ input, ctx }) => { if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } try{ const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input; if (!deliveryTime || !freezeTime) { throw new ApiError("Delivery time and orders close time are required", 400); } // Filter groupIds to only include valid (existing) groups let validGroupIds = groupIds; if (groupIds && groupIds.length > 0) { const existingGroups = await db.query.productGroupInfo.findMany({ where: inArray(productGroupInfo.id, groupIds), columns: { id: true }, }); validGroupIds = existingGroups.map(g => g.id); } return await db.transaction(async (tx) => { const [updatedSlot] = await tx .update(deliverySlotInfo) .set({ deliveryTime: new Date(deliveryTime), freezeTime: new Date(freezeTime), isActive: isActive !== undefined ? isActive : true, groupIds: validGroupIds !== undefined ? validGroupIds : [], }) .where(eq(deliverySlotInfo.id, id)) .returning(); if (!updatedSlot) { throw new ApiError("Slot not found", 404); } // Update product associations if (productIds !== undefined) { // Delete existing associations await tx.delete(productSlots).where(eq(productSlots.slotId, id)); // Insert new associations if (productIds.length > 0) { const associations = productIds.map((productId) => ({ productId, slotId: id, })); await tx.insert(productSlots).values(associations); } } // Create vendor snippets if provided let createdSnippets: any[] = []; if (snippets && snippets.length > 0) { for (const snippet of snippets) { // Validate products exist const products = await tx.query.productInfo.findMany({ where: inArray(productInfo.id, snippet.productIds), }); if (products.length !== snippet.productIds.length) { throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400); } // Check if snippet name already exists const existingSnippet = await tx.query.vendorSnippets.findFirst({ where: eq(vendorSnippets.snippetCode, snippet.name), }); if (existingSnippet) { throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400); } const [createdSnippet] = await tx.insert(vendorSnippets).values({ snippetCode: snippet.name, slotId: id, productIds: snippet.productIds, validTill: snippet.validTill ? new Date(snippet.validTill) : undefined, }).returning(); createdSnippets.push(createdSnippet); } } // Reinitialize stores to reflect changes await initializeAllStores(); return { slot: updatedSlot, createdSnippets, message: "Slot updated successfully", }; }); } catch(e) { console.log(e) throw new ApiError("Unable to Update Slot"); } }), deleteSlot: protectedProcedure .input(deleteSlotSchema) .mutation(async ({ input, ctx }) => { if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } const { id } = input; const [deletedSlot] = await db .update(deliverySlotInfo) .set({ isActive: false }) .where(eq(deliverySlotInfo.id, id)) .returning(); if (!deletedSlot) { throw new ApiError("Slot not found", 404); } // Reinitialize stores to reflect changes await initializeAllStores(); return { message: "Slot deleted successfully", }; }), getDeliverySequence: protectedProcedure .input(getDeliverySequenceSchema) .query(async ({ input, ctx }) => { const { id } = input; const slotId = parseInt(id); const cacheKey = getSlotSequenceKey(slotId); try { const cached = await redisClient.get(cacheKey); if (cached) { const parsed = JSON.parse(cached); const validated = cachedSequenceSchema.parse(parsed) as CachedDeliverySequence; console.log('sending cached response') return { deliverySequence: validated }; } } catch (error) { console.warn('Redis cache read/validation failed, falling back to DB:', error); // Continue to DB fallback } // Fallback to DB const slot = await db.query.deliverySlotInfo.findFirst({ where: eq(deliverySlotInfo.id, slotId), }); if (!slot) { throw new ApiError("Slot not found", 404); } const sequence = (slot.deliverySequence || {}) as CachedDeliverySequence; // Cache the validated result try { const validated = cachedSequenceSchema.parse(sequence); await redisClient.set(cacheKey, JSON.stringify(validated), 3600); } catch (cacheError) { console.warn('Redis cache write failed:', cacheError); } return { deliverySequence: sequence }; }), updateDeliverySequence: protectedProcedure .input(updateDeliverySequenceSchema) .mutation(async ({ input, ctx }) => { if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } const { id, deliverySequence } = input; const [updatedSlot] = await db .update(deliverySlotInfo) .set({ deliverySequence }) .where(eq(deliverySlotInfo.id, id)) .returning({ id: deliverySlotInfo.id, deliverySequence: deliverySlotInfo.deliverySequence, }); if (!updatedSlot) { throw new ApiError("Slot not found", 404); } // Cache the updated sequence const cacheKey = getSlotSequenceKey(id); try { const validated = cachedSequenceSchema.parse(deliverySequence); await redisClient.set(cacheKey, JSON.stringify(validated), 3600); } catch (cacheError) { console.warn('Redis cache write failed:', cacheError); } return { slot: updatedSlot, message: "Delivery sequence updated successfully", }; }), });