freshyo/apps/backend/src/trpc/admin-apis/slots.ts
2026-02-08 00:10:47 +05:30

573 lines
17 KiB
TypeScript

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<number, number[]>);
// 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",
};
}),
});