freshyo/apps/backend/src/trpc/user-apis/order.ts
2026-01-29 01:05:20 +05:30

975 lines
29 KiB
TypeScript

import { router, protectedProcedure } from "../trpc-index";
import { z } from "zod";
import { db } from "../../db/db_index";
import {
orders,
orderItems,
orderStatus,
addresses,
productInfo,
paymentInfoTable,
keyValStore,
coupons,
couponUsage,
payments,
cartItems,
refunds,
units,
} from "../../db/schema";
import { eq, and, inArray, desc, gte, lte } from "drizzle-orm";
import { generateSignedUrlsFromS3Urls } from "../../lib/s3-client";
import { ApiError } from "../../lib/api-error";
import {
sendOrderPlacedNotification,
sendOrderCancelledNotification,
} from "../../lib/notif-job";
import { RazorpayPaymentService } from "../../lib/payments-utils";
import { deliveryCharge, minOrderValue } from "../../lib/env-exporter";
import { getNextDeliveryDate } from "../common-apis/common";
import { CONST_KEYS, getConstant } from "../../lib/const-store";
const validateAndGetCoupon = async (
couponId: number | undefined,
userId: number,
totalAmount: number
) => {
if (!couponId) return null;
const coupon = await db.query.coupons.findFirst({
where: eq(coupons.id, couponId),
with: {
usages: { where: eq(couponUsage.userId, userId) },
},
});
if (!coupon) throw new ApiError("Invalid coupon", 400);
if (coupon.isInvalidated)
throw new ApiError("Coupon is no longer valid", 400);
if (coupon.validTill && new Date(coupon.validTill) < new Date())
throw new ApiError("Coupon has expired", 400);
if (
coupon.maxLimitForUser &&
coupon.usages.length >= coupon.maxLimitForUser
)
throw new ApiError("Coupon usage limit exceeded", 400);
if (
coupon.minOrder &&
parseFloat(coupon.minOrder.toString()) > totalAmount
)
throw new ApiError(
"Order amount does not meet coupon minimum requirement",
400
);
return coupon;
};
const applyDiscountToOrder = (
orderTotal: number,
totalAmount: number,
appliedCoupon: typeof coupons.$inferSelect | null,
proportion: number
) => {
let finalOrderTotal = orderTotal;
// const proportion = totalAmount / orderTotal;
if (appliedCoupon) {
if (appliedCoupon.discountPercent) {
const discount = Math.min(
(orderTotal *
parseFloat(appliedCoupon.discountPercent.toString())) /
100,
appliedCoupon.maxValue
? parseFloat(appliedCoupon.maxValue.toString()) * proportion
: Infinity
);
finalOrderTotal -= discount;
} else if (appliedCoupon.flatDiscount) {
const discount = Math.min(
parseFloat(appliedCoupon.flatDiscount.toString()) * proportion,
appliedCoupon.maxValue
? parseFloat(appliedCoupon.maxValue.toString()) * proportion
: finalOrderTotal
);
finalOrderTotal -= discount;
}
}
// let orderDeliveryCharge = 0;
// if (isFirstOrder && finalOrderTotal < minOrderValue) {
// orderDeliveryCharge = deliveryCharge;
// finalOrderTotal += deliveryCharge;
// }
return { finalOrderTotal, orderGroupProportion: proportion };
};
const placeOrderUtil = async (params: {
userId: number;
selectedItems: Array<{
productId: number;
quantity: number;
slotId: number | null;
}>;
addressId: number;
paymentMethod: "online" | "cod";
couponId?: number;
userNotes?: string;
deliveryPrice?: number;
isFlash?: boolean;
}) => {
const {
userId,
selectedItems,
addressId,
paymentMethod,
couponId,
userNotes,
deliveryPrice,
} = params;
const orderGroupId = `${Date.now()}-${userId}`;
// Validate address belongs to user
const address = await db.query.addresses.findFirst({
where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)),
});
if (!address) {
throw new ApiError("Invalid address", 400);
}
// Group items by slotId and validate products
const ordersBySlot = new Map<
number | null,
Array<{
productId: number;
quantity: number;
slotId: number | null;
product: any;
}>
>();
for (const item of selectedItems) {
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, item.productId),
});
if (!product) {
throw new ApiError(`Product ${item.productId} not found`, 400);
}
if (!ordersBySlot.has(item.slotId)) {
ordersBySlot.set(item.slotId, []);
}
ordersBySlot.get(item.slotId)!.push({ ...item, product });
}
// Validate flash delivery product availability if isFlash is true
if (params.isFlash) {
// Check if all products are flash-available (flashPrice is optional - fallback to regular price)
for (const item of selectedItems) {
const product = await db.query.productInfo.findFirst({
where: eq(productInfo.id, item.productId),
});
if (!product?.isFlashAvailable) {
throw new ApiError(`Product ${item.productId} is not available for flash delivery`, 400);
}
// Note: flashPrice validation removed - frontend falls back to regular price if not set
}
}
// Calculate total amount across all orders
let totalAmount = 0;
for (const [slotId, items] of ordersBySlot) {
const orderTotal = items.reduce(
(sum, item) => {
const itemPrice = params.isFlash
? parseFloat((item.product.flashPrice || item.product.price).toString())
: parseFloat(item.product.price.toString());
return sum + itemPrice * item.quantity;
},
0
);
totalAmount += orderTotal;
}
const appliedCoupon = await validateAndGetCoupon(couponId, userId, totalAmount);
// Calculate and verify delivery charge
const expectedDeliveryCharge =
totalAmount < minOrderValue ? deliveryCharge : 0;
if (
deliveryPrice !== undefined &&
deliveryPrice !== expectedDeliveryCharge
) {
throw new ApiError("Invalid delivery charge amount", 400);
}
// Create orders in transaction
const createdOrders = await db.transaction(async (tx) => {
// Get and increment readable order ID counter
let currentReadableId = 1;
const existing = await tx.query.keyValStore.findFirst({
where: eq(keyValStore.key, CONST_KEYS.readableOrderId),
});
if (existing?.value != null) {
const storedValue = existing.value;
const parsedValue = typeof storedValue === 'number'
? storedValue
: parseInt(String(storedValue), 10) || 0;
currentReadableId = parsedValue + 1;
}
// Create shared payment info for all orders
let sharedPaymentInfoId: number | null = null;
if (paymentMethod === "online") {
const [paymentInfo] = await tx
.insert(paymentInfoTable)
.values({
status: "pending",
gateway: "razorpay",
merchantOrderId: `multi_order_${Date.now()}`,
})
.returning();
sharedPaymentInfoId = paymentInfo.id;
}
const createdOrders: any[] = [];
let isFirstOrder = true;
// Create separate order for each slot group
for (const [slotId, items] of ordersBySlot) {
// Calculate order-specific total
const orderTotal = items.reduce(
(sum, item) => {
const itemPrice = params.isFlash
? parseFloat((item.product.flashPrice || item.product.price).toString())
: parseFloat(item.product.price.toString());
return sum + itemPrice * item.quantity;
},
0
);
const orderGroupProportion = orderTotal / totalAmount;
const { finalOrderTotal } = applyDiscountToOrder(
orderTotal,
totalAmount,
appliedCoupon,
orderGroupProportion
);
// const orderGroupProportion = orderTotal / totalAmount;
// Create order record
const [order] = await tx
.insert(orders)
.values({
userId,
addressId,
slotId: params.isFlash ? null : slotId, // No slot assignment for flash delivery
isCod: paymentMethod === "cod",
isOnlinePayment: paymentMethod === "online",
paymentInfoId: sharedPaymentInfoId,
totalAmount: finalOrderTotal.toString(),
deliveryCharge: isFirstOrder ? expectedDeliveryCharge.toString() : '0',
readableId: currentReadableId++,
userNotes: userNotes || null,
orderGroupId,
orderGroupProportion: orderGroupProportion.toString(),
isFlashDelivery: params.isFlash,
})
.returning();
// Create order items
const orderItemsData = items.map((item) => ({
orderId: order.id as number,
productId: item.productId,
quantity: item.quantity.toString(),
price: params.isFlash ? (item.product.flashPrice || item.product.price) : item.product.price,
discountedPrice: (params.isFlash ? (item.product.flashPrice || item.product.price) : item.product.price).toString(),
}));
await tx.insert(orderItems).values(orderItemsData);
// Create order status
await tx.insert(orderStatus).values({
userId,
orderId: order.id as number,
paymentStatus: paymentMethod === "cod" ? "cod" : "pending",
});
createdOrders.push(order);
isFirstOrder = false;
}
// Update readable ID counter
await tx
.insert(keyValStore)
.values({
key: CONST_KEYS.readableOrderId,
value: currentReadableId,
})
.onConflictDoUpdate({
target: keyValStore.key,
set: { value: currentReadableId },
});
// Create Razorpay order for online payments
if (paymentMethod === "online" && sharedPaymentInfoId) {
const razorpayOrder = await RazorpayPaymentService.createOrder(
sharedPaymentInfoId,
(totalAmount+expectedDeliveryCharge).toString()
);
await RazorpayPaymentService.insertPaymentRecord(
sharedPaymentInfoId,
razorpayOrder,
tx
);
}
// Remove ordered items from cart
await tx.delete(cartItems).where(
and(
eq(cartItems.userId, userId),
inArray(
cartItems.productId,
selectedItems.map((item) => item.productId)
)
)
);
return createdOrders;
});
// Record single coupon usage if applied (regardless of number of orders)
if (appliedCoupon && createdOrders.length > 0) {
await db.insert(couponUsage).values({
userId,
couponId: appliedCoupon.id,
orderId: createdOrders[0].id as number, // Use first order ID
orderItemId: null,
usedAt: new Date(),
});
}
// Send notifications for each order
for (const order of createdOrders) {
sendOrderPlacedNotification(userId, order.id.toString());
}
return { success: true, data: createdOrders };
};
export const orderRouter = router({
placeOrder: protectedProcedure
.input(
z.object({
selectedItems: z.array(
z.object({
productId: z.number().int().positive(),
quantity: z.number().int().positive(),
slotId: z.union([z.number().int(), z.null()]),
})
),
addressId: z.number().int().positive(),
paymentMethod: z.enum(["online", "cod"]),
couponId: z.number().int().positive().optional(),
userNotes: z.string().optional(),
deliveryPrice: z.number().min(0).optional(),
isFlashDelivery: z.boolean().optional().default(false),
})
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const {
selectedItems,
addressId,
paymentMethod,
couponId,
userNotes,
deliveryPrice,
isFlashDelivery,
} = input;
// Check if flash delivery is enabled when placing a flash delivery order
if (isFlashDelivery) {
const isFlashDeliveryEnabled = await getConstant<boolean>(CONST_KEYS.isFlashDeliveryEnabled);
if (!isFlashDeliveryEnabled) {
throw new ApiError("Flash delivery is currently unavailable. Please opt for scheduled delivery.", 403);
}
}
let processedItems = selectedItems;
// Handle flash delivery slot resolution
if (isFlashDelivery) {
// For flash delivery, set slotId to null (no specific slot assigned)
processedItems = selectedItems.map(item => ({
...item,
slotId: null as any, // Type override for flash delivery
}));
}
console.log({isFlashDelivery, processedItems})
return await placeOrderUtil({
userId,
selectedItems: processedItems,
addressId,
paymentMethod,
couponId,
userNotes,
deliveryPrice,
isFlash: isFlashDelivery,
});
}),
getOrders: protectedProcedure
.input(
z
.object({
page: z.number().min(1).default(1),
pageSize: z.number().min(1).max(50).default(10),
})
.optional()
)
.query(async ({ input, ctx }) => {
const { page = 1, pageSize = 10 } = input || {};
const userId = ctx.user.userId;
const offset = (page - 1) * pageSize;
// Get total count for pagination
const totalCountResult = await db.$count(
orders,
eq(orders.userId, userId)
);
const totalCount = totalCountResult;
const userOrders = await db.query.orders.findMany({
where: eq(orders.userId, userId),
with: {
orderItems: {
with: {
product: true,
},
},
slot: true,
paymentInfo: true,
orderStatus: true,
refunds: true,
},
orderBy: (orders, { desc }) => [desc(orders.createdAt)],
limit: pageSize,
offset: offset,
});
const mappedOrders = await Promise.all(
userOrders.map(async (order) => {
const status = order.orderStatus[0];
const refund = order.refunds[0];
type DeliveryStatus = "cancelled" | "success" | "pending" | "packaged";
type OrderStatus = "cancelled" | "success";
let deliveryStatus: DeliveryStatus;
let orderStatus: OrderStatus;
const allItemsPackaged = order.orderItems.every(
(item) => item.is_packaged
);
if (status?.isCancelled) {
deliveryStatus = "cancelled";
orderStatus = "cancelled";
} else if (status?.isDelivered) {
deliveryStatus = "success";
orderStatus = "success";
} else if (allItemsPackaged) {
deliveryStatus = "packaged";
orderStatus = "success";
} else {
deliveryStatus = "pending";
orderStatus = "success";
}
const paymentMode = order.isCod ? "CoD" : "Online";
const paymentStatus = status?.paymentStatus || "pending";
const refundStatus = refund?.refundStatus || "none";
const refundAmount = refund?.refundAmount
? parseFloat(refund.refundAmount.toString())
: null;
const items = await Promise.all(
order.orderItems.map(async (item) => {
const signedImages = item.product.images
? await generateSignedUrlsFromS3Urls(
item.product.images as string[]
)
: [];
return {
productName: item.product.name,
quantity: parseFloat(item.quantity),
price: parseFloat(item.price.toString()),
discountedPrice: parseFloat(
item.discountedPrice?.toString() || item.price.toString()
),
amount:
parseFloat(item.price.toString()) * parseFloat(item.quantity),
image: signedImages[0] || null,
};
})
);
return {
id: order.id,
orderId: `ORD${order.readableId.toString().padStart(3, "0")}`,
orderDate: order.createdAt.toISOString(),
deliveryStatus,
deliveryDate: order.slot?.deliveryTime.toISOString(),
orderStatus,
cancelReason: status?.cancelReason || null,
paymentMode,
totalAmount: Number(order.totalAmount),
deliveryCharge: Number(order.deliveryCharge),
paymentStatus,
refundStatus,
refundAmount,
userNotes: order.userNotes || null,
items,
isFlashDelivery: order.isFlashDelivery,
createdAt: order.createdAt.toISOString(),
};
})
);
return {
success: true,
data: mappedOrders,
pagination: {
page,
pageSize,
totalCount,
totalPages: Math.ceil(totalCount / pageSize),
},
};
}),
getOrderById: protectedProcedure
.input(z.object({ orderId: z.string() }))
.query(async ({ input, ctx }) => {
const { orderId } = input;
const userId = ctx.user.userId;
const order = await db.query.orders.findFirst({
where: and(eq(orders.id, parseInt(orderId)), eq(orders.userId, userId)),
with: {
orderItems: {
with: {
product: true,
},
},
slot: true,
paymentInfo: true,
orderStatus: {
with: {
refundCoupon: true,
},
},
refunds: true,
},
});
if (!order) {
throw new Error("Order not found");
}
// Get coupon usage for this specific order using new orderId field
const couponUsageData = await db.query.couponUsage.findMany({
where: eq(couponUsage.orderId, order.id), // Use new orderId field
with: {
coupon: true,
},
});
let couponData = null;
if (couponUsageData.length > 0) {
// Calculate total discount from multiple coupons
let totalDiscountAmount = 0;
const orderTotal = parseFloat(order.totalAmount.toString());
for (const usage of couponUsageData) {
let discountAmount = 0;
if (usage.coupon.discountPercent) {
discountAmount =
(orderTotal *
parseFloat(usage.coupon.discountPercent.toString())) /
100;
} else if (usage.coupon.flatDiscount) {
discountAmount = parseFloat(usage.coupon.flatDiscount.toString());
}
// Apply max value limit if set
if (
usage.coupon.maxValue &&
discountAmount > parseFloat(usage.coupon.maxValue.toString())
) {
discountAmount = parseFloat(usage.coupon.maxValue.toString());
}
totalDiscountAmount += discountAmount;
}
couponData = {
couponCode: couponUsageData
.map((u) => u.coupon.couponCode)
.join(", "),
couponDescription: `${couponUsageData.length} coupons applied`,
discountAmount: totalDiscountAmount,
};
}
const status = order.orderStatus[0];
const refund = order.refunds[0];
type DeliveryStatus = "cancelled" | "success" | "pending" | "packaged";
type OrderStatus = "cancelled" | "success";
let deliveryStatus: DeliveryStatus;
let orderStatus: OrderStatus;
const allItemsPackaged = order.orderItems.every(
(item) => item.is_packaged
);
if (status?.isCancelled) {
deliveryStatus = "cancelled";
orderStatus = "cancelled";
} else if (status?.isDelivered) {
deliveryStatus = "success";
orderStatus = "success";
} else if (allItemsPackaged) {
deliveryStatus = "packaged";
orderStatus = "success";
} else {
deliveryStatus = "pending";
orderStatus = "success";
}
const paymentMode = order.isCod ? "CoD" : "Online";
const paymentStatus = status?.paymentStatus || "pending";
const refundStatus = refund?.refundStatus || "none";
const refundAmount = refund?.refundAmount
? parseFloat(refund.refundAmount.toString())
: null;
const items = await Promise.all(
order.orderItems.map(async (item) => {
const signedImages = item.product.images
? await generateSignedUrlsFromS3Urls(
item.product.images as string[]
)
: [];
return {
productName: item.product.name,
quantity: parseFloat(item.quantity),
price: parseFloat(item.price.toString()),
discountedPrice: parseFloat(
item.discountedPrice?.toString() || item.price.toString()
),
amount:
parseFloat(item.price.toString()) * parseFloat(item.quantity),
image: signedImages[0] || null,
};
})
);
return {
id: order.id,
orderId: `ORD${order.readableId.toString().padStart(3, "0")}`,
orderDate: order.createdAt.toISOString(),
deliveryStatus,
deliveryDate: order.slot?.deliveryTime.toISOString(),
orderStatus: order.orderStatus,
cancellationStatus: orderStatus,
cancelReason: status?.cancelReason || null,
paymentMode,
paymentStatus,
refundStatus,
refundAmount,
userNotes: order.userNotes || null,
items,
couponCode: couponData?.couponCode || null,
couponDescription: couponData?.couponDescription || null,
discountAmount: couponData?.discountAmount || null,
orderAmount: parseFloat(order.totalAmount.toString()),
isFlashDelivery: order.isFlashDelivery,
createdAt: order.createdAt.toISOString(),
};
}),
cancelOrder: protectedProcedure
.input(
z.object({
// id: z.string().regex(/^ORD\d+$/, "Invalid order ID format"),
id: z.number(),
reason: z.string().min(1, "Cancellation reason is required"),
})
)
.mutation(async ({ input, ctx }) => {
try {
const userId = ctx.user.userId;
const { id, reason } = input;
const readableId = Number(id);
console.log({id, reason})
// Check if order exists and belongs to user
const order = await db.query.orders.findFirst({
where: eq(orders.id, Number(id)),
with: {
orderStatus: true,
},
});
if (!order) {
console.error("Order not found:", id);
throw new ApiError("Order not found", 404);
}
if (order.userId !== userId) {
console.error("Order does not belong to user:", {
orderId: id,
orderUserId: order.userId,
requestUserId: userId,
});
throw new ApiError("Order not found", 404);
}
const status = order.orderStatus[0];
if (!status) {
console.error("Order status not found for order:", id);
throw new ApiError("Order status not found", 400);
}
if (status.isCancelled) {
console.error("Order is already cancelled:", id);
throw new ApiError("Order is already cancelled", 400);
}
if (status.isDelivered) {
console.error("Cannot cancel delivered order:", id);
throw new ApiError("Cannot cancel delivered order", 400);
}
// Perform database operations in transaction
const result = await db.transaction(async (tx) => {
// Update order status
await tx
.update(orderStatus)
.set({
isCancelled: true,
cancelReason: reason,
cancellationUserNotes: reason,
cancellationReviewed: false,
})
.where(eq(orderStatus.id, status.id));
// Determine refund status based on payment method
const refundStatus = order.isCod ? "na" : "pending";
// Insert refund record
await tx.insert(refunds).values({
orderId: order.id,
refundStatus,
});
return { orderId: order.id, userId };
});
// Send notification outside transaction (idempotent operation)
await sendOrderCancelledNotification(
result.userId,
result.orderId.toString()
);
return { success: true, message: "Order cancelled successfully" };
} catch (e) {
console.log(e);
throw new ApiError("failed to cancel order");
}
}),
updateUserNotes: protectedProcedure
.input(
z.object({
id: z.string(),
userNotes: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
const userId = ctx.user.userId;
const { id, userNotes } = input;
// Extract readable ID from orderId (e.g., ORD001 -> 1)
// const readableIdMatch = id.match(/^ORD(\d+)$/);
// if (!readableIdMatch) {
// console.error("Invalid order ID format:", id);
// throw new ApiError("Invalid order ID format", 400);
// }
// const readableId = parseInt(readableIdMatch[1]);
// Check if order exists and belongs to user
const order = await db.query.orders.findFirst({
where: eq(orders.id, Number(id)),
with: {
orderStatus: true,
},
});
if (!order) {
console.error("Order not found:", id);
throw new ApiError("Order not found", 404);
}
if (order.userId !== userId) {
console.error("Order does not belong to user:", {
orderId: id,
orderUserId: order.userId,
requestUserId: userId,
});
throw new ApiError("Order not found", 404);
}
const status = order.orderStatus[0];
if (!status) {
console.error("Order status not found for order:", id);
throw new ApiError("Order status not found", 400);
}
// Only allow updating notes for orders that are not delivered or cancelled
if (status.isDelivered) {
console.error("Cannot update notes for delivered order:", id);
throw new ApiError("Cannot update notes for delivered order", 400);
}
if (status.isCancelled) {
console.error("Cannot update notes for cancelled order:", id);
throw new ApiError("Cannot update notes for cancelled order", 400);
}
// Update user notes
await db
.update(orders)
.set({
userNotes: userNotes || null,
})
.where(eq(orders.id, order.id));
return { success: true, message: "Notes updated successfully" };
}),
getRecentlyOrderedProducts: protectedProcedure
.input(
z
.object({
limit: z.number().min(1).max(50).default(20),
})
.optional()
)
.query(async ({ input, ctx }) => {
const { limit = 20 } = input || {};
const userId = ctx.user.userId;
// Get user's recent delivered orders (last 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentOrders = await db
.select({ id: orders.id })
.from(orders)
.innerJoin(orderStatus, eq(orders.id, orderStatus.orderId))
.where(
and(
eq(orders.userId, userId),
eq(orderStatus.isDelivered, true),
gte(orders.createdAt, thirtyDaysAgo)
)
)
.orderBy(desc(orders.createdAt))
.limit(10); // Get last 10 orders
if (recentOrders.length === 0) {
return { success: true, products: [] };
}
const orderIds = recentOrders.map((order) => order.id);
// Get unique product IDs from recent orders
const orderItemsResult = await db
.select({ productId: orderItems.productId })
.from(orderItems)
.where(inArray(orderItems.orderId, orderIds));
const productIds = [
...new Set(orderItemsResult.map((item) => item.productId)),
];
if (productIds.length === 0) {
return { success: true, products: [] };
}
// Get product details
const productsWithUnits = await db
.select({
id: productInfo.id,
name: productInfo.name,
shortDescription: productInfo.shortDescription,
price: productInfo.price,
images: productInfo.images,
isOutOfStock: productInfo.isOutOfStock,
unitShortNotation: units.shortNotation,
incrementStep: productInfo.incrementStep,
})
.from(productInfo)
.innerJoin(units, eq(productInfo.unitId, units.id))
.where(
and(
inArray(productInfo.id, productIds),
eq(productInfo.isSuspended, false)
)
)
.orderBy(desc(productInfo.createdAt))
.limit(limit);
// Generate signed URLs for product images
const formattedProducts = await Promise.all(
productsWithUnits.map(async (product) => {
const nextDeliveryDate = await getNextDeliveryDate(product.id);
return {
id: product.id,
name: product.name,
shortDescription: product.shortDescription,
price: product.price,
unit: product.unitShortNotation,
incrementStep: product.incrementStep,
isOutOfStock: product.isOutOfStock,
nextDeliveryDate: nextDeliveryDate
? nextDeliveryDate.toISOString()
: null,
images: await generateSignedUrlsFromS3Urls(
(product.images as string[]) || []
),
};
})
);
return {
success: true,
products: formattedProducts,
};
}),
});