610 lines
18 KiB
TypeScript
610 lines
18 KiB
TypeScript
import { router, protectedProcedure } from "@/src/trpc/trpc-index"
|
|
import { TRPCError } from "@trpc/server";
|
|
import { z } from "zod";
|
|
import { db } from "@/src/db/db_index"
|
|
import { deliverySlotInfo, productSlots, productInfo, vendorSnippets, productGroupInfo } from "@/src/db/schema"
|
|
import { eq, inArray, and, desc } from "drizzle-orm";
|
|
import { ApiError } from "@/src/lib/api-error"
|
|
import { appUrl } from "@/src/lib/env-exporter"
|
|
import redisClient from "@/src/lib/redis-client"
|
|
import { getSlotSequenceKey } from "@/src/lib/redisKeyGetters"
|
|
import { scheduleStoreInitialization } from '@/src/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
|
|
scheduleStoreInitialization()
|
|
|
|
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);
|
|
}
|
|
|
|
const result = 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);
|
|
}
|
|
}
|
|
|
|
return {
|
|
slot: newSlot,
|
|
createdSnippets,
|
|
message: "Slot created successfully",
|
|
};
|
|
});
|
|
|
|
// Reinitialize stores to reflect changes (outside transaction)
|
|
scheduleStoreInitialization()
|
|
|
|
return result;
|
|
}),
|
|
|
|
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);
|
|
}
|
|
|
|
const result = 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);
|
|
}
|
|
}
|
|
|
|
return {
|
|
slot: updatedSlot,
|
|
createdSnippets,
|
|
message: "Slot updated successfully",
|
|
};
|
|
});
|
|
|
|
// Reinitialize stores to reflect changes (outside transaction)
|
|
scheduleStoreInitialization()
|
|
|
|
return result;
|
|
}
|
|
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
|
|
scheduleStoreInitialization()
|
|
|
|
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",
|
|
};
|
|
}),
|
|
|
|
updateSlotCapacity: protectedProcedure
|
|
.input(z.object({
|
|
slotId: z.number(),
|
|
isCapacityFull: z.boolean(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
if (!ctx.staffUser?.id) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" });
|
|
}
|
|
|
|
const { slotId, isCapacityFull } = input;
|
|
|
|
const [updatedSlot] = await db
|
|
.update(deliverySlotInfo)
|
|
.set({ isCapacityFull })
|
|
.where(eq(deliverySlotInfo.id, slotId))
|
|
.returning();
|
|
|
|
if (!updatedSlot) {
|
|
throw new ApiError("Slot not found", 404);
|
|
}
|
|
|
|
// Reinitialize stores to reflect changes
|
|
scheduleStoreInitialization()
|
|
|
|
return {
|
|
success: true,
|
|
slot: updatedSlot,
|
|
message: `Slot ${isCapacityFull ? 'marked as full capacity' : 'capacity reset'}`,
|
|
};
|
|
}),
|
|
});
|