From 56b606ebcf36c7385003e6976ac7787b1e6c17b7 Mon Sep 17 00:00:00 2001 From: shafi54 <108669266+shafi-aviz@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:20:18 +0530 Subject: [PATCH] enh --- apps/admin-ui/.expo/types/router.d.ts | 6 +- apps/admin-ui/app/(drawer)/_layout.tsx | 3 +- .../app/(drawer)/address-management/index.tsx | 108 -- .../admin-ui/app/(drawer)/dashboard/index.tsx | 9 - apps/admin-ui/components/AddressPlaceForm.tsx | 64 -- apps/admin-ui/components/AddressZoneForm.tsx | 51 - apps/backend/index.ts | 1 - apps/backend/package.json | 1 - .../src/apis/admin-apis/apis/av-router.ts | 11 - .../apis/common-product.controller.ts | 14 +- .../common-apis/apis/common-product.router.ts | 13 +- .../apis/common-apis/apis/common.router.ts | 13 +- apps/backend/src/jobs/jobs-index.ts | 10 +- .../src/jobs/payment-status-checker.ts | 79 -- apps/backend/src/lib/catch-async.ts | 6 - ...isted-set.ts => disk-persisted-set.ts.txt} | 0 apps/backend/src/lib/payments-utils.ts | 59 - apps/backend/src/lib/signed-url-cache.ts | 136 +-- apps/backend/src/lib/signed-url-cache.ts.txt | 263 +++++ apps/backend/src/main-router.ts | 7 +- .../src/services/user/order-service.ts | 405 ------- apps/backend/src/test-controller.ts | 13 - .../apis/admin-apis/apis/admin-trpc-index.ts | 4 - .../src/trpc/apis/admin-apis/apis/banner.ts | 118 +- .../trpc/apis/admin-apis/apis/complaint.ts | 33 +- .../src/trpc/apis/admin-apis/apis/const.ts | 21 +- .../src/trpc/apis/admin-apis/apis/coupon.ts | 587 +++------- .../src/trpc/apis/admin-apis/apis/order.ts | 396 ++----- .../src/trpc/apis/admin-apis/apis/payments.ts | 79 +- .../apis/product-availability-schedules.ts | 50 +- .../src/trpc/apis/admin-apis/apis/product.ts | 290 ++--- .../src/trpc/apis/admin-apis/apis/slots.ts | 372 ++---- .../trpc/apis/admin-apis/apis/staff-user.ts | 98 +- .../src/trpc/apis/admin-apis/apis/store.ts | 236 ++-- .../src/trpc/apis/admin-apis/apis/tag.ts | 62 +- .../src/trpc/apis/admin-apis/apis/user.ts | 289 +---- .../apis/admin-apis/apis/vendor-snippets.ts | 430 ++----- .../interfaces/banner-db-service.interface.ts | 12 + .../complaint-db-service.interface.ts | 9 + .../constant-db-service.interface.ts | 9 + .../interfaces/coupon-db-service.interface.ts | 46 + .../interfaces/order-db-service.interface.ts | 108 ++ .../product-db-service.interface.ts | 53 + .../interfaces/refund-db-service.interface.ts | 18 + .../schedule-db-service.interface.ts | 13 + .../interfaces/slot-db-service.interface.ts | 43 + .../staff-user-db-service.interface.ts | 40 + .../interfaces/store-db-service.interface.ts | 14 + .../interfaces/tag-db-service.interface.ts | 13 + .../interfaces/user-db-service.interface.ts | 37 + .../vendor-snippet-db-service.interface.ts | 34 + .../apis/admin-apis/dataAccessors/main.ts | 41 + .../dataAccessors/postgres/banner-queries.ts | 38 + .../postgres/complaint-queries.ts | 43 + .../postgres/constant-queries.ts | 25 + .../dataAccessors/postgres/coupon-queries.ts | 191 ++++ .../dataAccessors/postgres/order-queries.ts | 334 ++++++ .../dataAccessors/postgres/product-queries.ts | 226 ++++ .../dataAccessors/postgres/refund-queries.ts | 49 + .../postgres/schedule-queries.ts | 48 + .../dataAccessors/postgres/slot-queries.ts | 142 +++ .../postgres/staff-user-queries.ts | 104 ++ .../dataAccessors/postgres/store-queries.ts | 53 + .../dataAccessors/postgres/tag-queries.ts | 42 + .../dataAccessors/postgres/user-queries.ts | 170 +++ .../postgres/vendor-snippets-queries.ts | 132 +++ .../src/trpc/apis/user-apis/apis/address.ts | 77 +- .../src/trpc/apis/user-apis/apis/auth.ts | 235 +--- .../src/trpc/apis/user-apis/apis/banners.ts | 9 +- .../src/trpc/apis/user-apis/apis/cart.ts | 88 +- .../src/trpc/apis/user-apis/apis/complaint.ts | 30 +- .../src/trpc/apis/user-apis/apis/coupon.ts | 145 +-- .../src/trpc/apis/user-apis/apis/order.ts | 396 +++---- .../src/trpc/apis/user-apis/apis/payments.ts | 158 --- .../src/trpc/apis/user-apis/apis/product.ts | 107 +- .../src/trpc/apis/user-apis/apis/slots.ts | 23 +- .../src/trpc/apis/user-apis/apis/stores.ts | 57 +- .../apis/user-apis/apis/user-trpc-index.ts | 2 - .../src/trpc/apis/user-apis/apis/user.ts | 67 +- .../user-address-db-service.interface.ts | 15 + .../user-auth-db-service.interface.ts | 23 + .../user-banner-db-service.interface.ts | 7 + .../user-cart-db-service.interface.ts | 25 + .../user-complaint-db-service.interface.ts | 17 + .../user-coupon-db-service.interface.ts | 20 + .../user-order-db-service.interface.ts | 95 ++ .../user-product-db-service.interface.ts | 32 + .../user-profile-db-service.interface.ts | 22 + .../user-slot-db-service.interface.ts | 8 + .../user-store-db-service.interface.ts | 28 + .../trpc/apis/user-apis/dataAccessors/main.ts | 32 + .../postgres/user-address-queries.ts | 71 ++ .../postgres/user-auth-queries.ts | 122 ++ .../postgres/user-banner-queries.ts | 15 + .../postgres/user-cart-queries.ts | 75 ++ .../postgres/user-complaint-queries.ts | 28 + .../postgres/user-coupon-queries.ts | 88 ++ .../postgres/user-order-queries.ts | 265 +++++ .../postgres/user-product-queries.ts | 109 ++ .../postgres/user-profile-queries.ts | 74 ++ .../postgres/user-slot-queries.ts | 26 + .../postgres/user-store-queries.ts | 69 ++ apps/backend/src/trpc/trpc-index.ts | 12 +- apps/backend/src/uv-apis/uv-router.ts | 8 - apps/backend/src/v1-router.ts | 18 +- .../admin-apis/apis/address.ts | 0 verifier/admin-apis/apis/admin-trpc-index.ts | 39 + verifier/admin-apis/apis/banner.ts | 176 +++ .../admin-apis/apis/cancelled-orders.ts | 0 verifier/admin-apis/apis/complaint.ts | 80 ++ verifier/admin-apis/apis/const.ts | 61 + verifier/admin-apis/apis/coupon.ts | 711 ++++++++++++ verifier/admin-apis/apis/order.ts | 1018 +++++++++++++++++ verifier/admin-apis/apis/payments.ts | 146 +++ .../apis/product-availability-schedules.ts | 154 +++ verifier/admin-apis/apis/product.ts | 758 ++++++++++++ verifier/admin-apis/apis/slots.ts | 610 ++++++++++ verifier/admin-apis/apis/staff-user.ts | 242 ++++ verifier/admin-apis/apis/store.ts | 211 ++++ verifier/admin-apis/apis/tag.ts | 214 ++++ verifier/admin-apis/apis/user.ts | 489 ++++++++ verifier/admin-apis/apis/vendor-snippets.ts | 531 +++++++++ verifier/admin-apis/dataAccessors/demo.txt | 0 verifier/apis/address.ts | 32 + verifier/apis/admin-trpc-index.ts | 39 + verifier/apis/banner.ts | 176 +++ verifier/apis/cancelled-orders.ts | 179 +++ verifier/apis/complaint.ts | 80 ++ verifier/apis/const.ts | 61 + verifier/apis/coupon.ts | 711 ++++++++++++ verifier/apis/order.ts | 1018 +++++++++++++++++ verifier/apis/payments.ts | 146 +++ .../apis/product-availability-schedules.ts | 154 +++ verifier/apis/product.ts | 758 ++++++++++++ verifier/apis/slots.ts | 610 ++++++++++ verifier/apis/staff-user.ts | 242 ++++ verifier/apis/store.ts | 211 ++++ verifier/apis/tag.ts | 214 ++++ verifier/apis/user.ts | 489 ++++++++ verifier/apis/vendor-snippets.ts | 531 +++++++++ verifier/dataAccessors/demo.txt | 0 verifier/user-apis/apis/address.ts | 194 ++++ verifier/user-apis/apis/auth.ts | 581 ++++++++++ verifier/user-apis/apis/banners.ts | 30 + verifier/user-apis/apis/cart.ts | 244 ++++ verifier/user-apis/apis/complaint.ts | 70 ++ verifier/user-apis/apis/coupon.ts | 296 +++++ verifier/user-apis/apis/file-upload.ts | 55 + verifier/user-apis/apis/order.ts | 979 ++++++++++++++++ verifier/user-apis/apis/product.ts | 266 +++++ verifier/user-apis/apis/slots.ts | 92 ++ verifier/user-apis/apis/stores.ts | 162 +++ verifier/user-apis/apis/tags.ts | 28 + verifier/user-apis/apis/user-trpc-index.ts | 32 + verifier/user-apis/apis/user.ts | 164 +++ verifier/user-apis/dataAccessors/demo.txt | 0 156 files changed, 19095 insertions(+), 4311 deletions(-) delete mode 100644 apps/admin-ui/app/(drawer)/address-management/index.tsx delete mode 100644 apps/admin-ui/components/AddressPlaceForm.tsx delete mode 100644 apps/admin-ui/components/AddressZoneForm.tsx delete mode 100755 apps/backend/src/apis/admin-apis/apis/av-router.ts delete mode 100644 apps/backend/src/jobs/payment-status-checker.ts delete mode 100755 apps/backend/src/lib/catch-async.ts rename apps/backend/src/lib/{disk-persisted-set.ts => disk-persisted-set.ts.txt} (100%) delete mode 100644 apps/backend/src/lib/payments-utils.ts mode change 100755 => 100644 apps/backend/src/lib/signed-url-cache.ts create mode 100755 apps/backend/src/lib/signed-url-cache.ts.txt delete mode 100644 apps/backend/src/services/user/order-service.ts delete mode 100644 apps/backend/src/test-controller.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/banner-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/complaint-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/constant-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/coupon-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/order-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/product-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/refund-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/schedule-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/slot-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/staff-user-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/store-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/tag-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/user-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/vendor-snippet-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/main.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/banner-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/complaint-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/constant-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/coupon-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/order-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/product-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/refund-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/schedule-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/slot-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/staff-user-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/store-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/tag-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/user-queries.ts create mode 100644 apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/vendor-snippets-queries.ts delete mode 100644 apps/backend/src/trpc/apis/user-apis/apis/payments.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-address-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-auth-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-banner-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-cart-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-complaint-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-coupon-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-order-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-product-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-profile-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-slot-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-store-db-service.interface.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/main.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-address-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-auth-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-banner-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-cart-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-complaint-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-coupon-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-order-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-product-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-profile-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-slot-queries.ts create mode 100644 apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-store-queries.ts delete mode 100644 apps/backend/src/uv-apis/uv-router.ts rename {apps/backend/src/trpc/apis => verifier}/admin-apis/apis/address.ts (100%) create mode 100644 verifier/admin-apis/apis/admin-trpc-index.ts create mode 100644 verifier/admin-apis/apis/banner.ts rename {apps/backend/src/trpc/apis => verifier}/admin-apis/apis/cancelled-orders.ts (100%) create mode 100644 verifier/admin-apis/apis/complaint.ts create mode 100644 verifier/admin-apis/apis/const.ts create mode 100644 verifier/admin-apis/apis/coupon.ts create mode 100644 verifier/admin-apis/apis/order.ts create mode 100644 verifier/admin-apis/apis/payments.ts create mode 100644 verifier/admin-apis/apis/product-availability-schedules.ts create mode 100644 verifier/admin-apis/apis/product.ts create mode 100644 verifier/admin-apis/apis/slots.ts create mode 100644 verifier/admin-apis/apis/staff-user.ts create mode 100644 verifier/admin-apis/apis/store.ts create mode 100644 verifier/admin-apis/apis/tag.ts create mode 100644 verifier/admin-apis/apis/user.ts create mode 100644 verifier/admin-apis/apis/vendor-snippets.ts create mode 100644 verifier/admin-apis/dataAccessors/demo.txt create mode 100644 verifier/apis/address.ts create mode 100644 verifier/apis/admin-trpc-index.ts create mode 100644 verifier/apis/banner.ts create mode 100644 verifier/apis/cancelled-orders.ts create mode 100644 verifier/apis/complaint.ts create mode 100644 verifier/apis/const.ts create mode 100644 verifier/apis/coupon.ts create mode 100644 verifier/apis/order.ts create mode 100644 verifier/apis/payments.ts create mode 100644 verifier/apis/product-availability-schedules.ts create mode 100644 verifier/apis/product.ts create mode 100644 verifier/apis/slots.ts create mode 100644 verifier/apis/staff-user.ts create mode 100644 verifier/apis/store.ts create mode 100644 verifier/apis/tag.ts create mode 100644 verifier/apis/user.ts create mode 100644 verifier/apis/vendor-snippets.ts create mode 100644 verifier/dataAccessors/demo.txt create mode 100644 verifier/user-apis/apis/address.ts create mode 100644 verifier/user-apis/apis/auth.ts create mode 100644 verifier/user-apis/apis/banners.ts create mode 100644 verifier/user-apis/apis/cart.ts create mode 100644 verifier/user-apis/apis/complaint.ts create mode 100644 verifier/user-apis/apis/coupon.ts create mode 100644 verifier/user-apis/apis/file-upload.ts create mode 100644 verifier/user-apis/apis/order.ts create mode 100644 verifier/user-apis/apis/product.ts create mode 100644 verifier/user-apis/apis/slots.ts create mode 100644 verifier/user-apis/apis/stores.ts create mode 100644 verifier/user-apis/apis/tags.ts create mode 100644 verifier/user-apis/apis/user-trpc-index.ts create mode 100644 verifier/user-apis/apis/user.ts create mode 100644 verifier/user-apis/dataAccessors/demo.txt diff --git a/apps/admin-ui/.expo/types/router.d.ts b/apps/admin-ui/.expo/types/router.d.ts index e400cfe..a05d8ec 100644 --- a/apps/admin-ui/.expo/types/router.d.ts +++ b/apps/admin-ui/.expo/types/router.d.ts @@ -6,9 +6,9 @@ export * from 'expo-router'; declare module 'expo-router' { export namespace ExpoRouter { export interface __routes { - hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/login`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/address-management` | `/address-management`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/complaints` | `/complaints`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/create` | `/coupons/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons` | `/coupons`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/reserved-coupons` | `/coupons/reserved-coupons`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app/all-items-order` | `/customize-app/all-items-order`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app` | `/customize-app`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app/popular-items` | `/customize-app/popular-items`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard` | `/dashboard`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners/create` | `/dashboard-banners/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners` | `/dashboard-banners`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders` | `/manage-orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders/delivery-sequences` | `/manage-orders/delivery-sequences`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders/orders` | `/manage-orders/orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/prices-overview` | `/prices-overview`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-groupings/create` | `/product-groupings/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-groupings` | `/product-groupings`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags/add` | `/product-tags/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags` | `/product-tags`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags/edit` | `/product-tags/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products/add` | `/products/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products/edit` | `/products/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products` | `/products`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/rebalance-orders` | `/rebalance-orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/send-notifications` | `/send-notifications`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots/add` | `/slots/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots` | `/slots`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots/slot-details` | `/slots/slot-details`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores/add` | `/stores/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores/edit` | `/stores/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores` | `/stores`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/user-management` | `/user-management`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/users` | `/users`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/vendor-snippets` | `/vendor-snippets`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stocking-schedules` | `/stocking-schedules`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/edit/[id]` | `/coupons/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/dashboard-banners/edit/[id]` | `/dashboard-banners/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/manage-orders/order-details/[id]` | `/manage-orders/order-details/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/product-groupings/edit/[id]` | `/product-groupings/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/products/detail/[id]` | `/products/detail/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/slots/edit/[id]` | `/slots/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/user-management/[id]` | `/user-management/[id]`, params: Router.UnknownInputParams & { id: string | number; } }; - hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/login`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/address-management` | `/address-management`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/complaints` | `/complaints`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/coupons/create` | `/coupons/create`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/coupons` | `/coupons`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/coupons/reserved-coupons` | `/coupons/reserved-coupons`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/customize-app/all-items-order` | `/customize-app/all-items-order`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/customize-app` | `/customize-app`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/customize-app/popular-items` | `/customize-app/popular-items`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/dashboard` | `/dashboard`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners/create` | `/dashboard-banners/create`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners` | `/dashboard-banners`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/manage-orders` | `/manage-orders`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/manage-orders/delivery-sequences` | `/manage-orders/delivery-sequences`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/manage-orders/orders` | `/manage-orders/orders`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/prices-overview` | `/prices-overview`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-groupings/create` | `/product-groupings/create`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-groupings` | `/product-groupings`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-tags/add` | `/product-tags/add`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-tags` | `/product-tags`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-tags/edit` | `/product-tags/edit`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/products/add` | `/products/add`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/products/edit` | `/products/edit`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/products` | `/products`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/rebalance-orders` | `/rebalance-orders`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/send-notifications` | `/send-notifications`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/slots/add` | `/slots/add`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/slots` | `/slots`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/slots/slot-details` | `/slots/slot-details`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/stores/add` | `/stores/add`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/stores/edit` | `/stores/edit`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/stores` | `/stores`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/user-management` | `/user-management`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/users` | `/users`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/vendor-snippets` | `/vendor-snippets`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/stocking-schedules` | `/stocking-schedules`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/coupons/edit/[id]` | `/coupons/edit/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/dashboard-banners/edit/[id]` | `/dashboard-banners/edit/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/manage-orders/order-details/[id]` | `/manage-orders/order-details/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/product-groupings/edit/[id]` | `/product-groupings/edit/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/products/detail/[id]` | `/products/detail/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/slots/edit/[id]` | `/slots/edit/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/user-management/[id]` | `/user-management/[id]`, params: Router.UnknownOutputParams & { id: string; } }; - href: Router.RelativePathString | Router.ExternalPathString | `/${`?${string}` | `#${string}` | ''}` | `/login${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/address-management${`?${string}` | `#${string}` | ''}` | `/address-management${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/complaints${`?${string}` | `#${string}` | ''}` | `/complaints${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/coupons/create${`?${string}` | `#${string}` | ''}` | `/coupons/create${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/coupons${`?${string}` | `#${string}` | ''}` | `/coupons${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/coupons/reserved-coupons${`?${string}` | `#${string}` | ''}` | `/coupons/reserved-coupons${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/customize-app/all-items-order${`?${string}` | `#${string}` | ''}` | `/customize-app/all-items-order${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/customize-app${`?${string}` | `#${string}` | ''}` | `/customize-app${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/customize-app/popular-items${`?${string}` | `#${string}` | ''}` | `/customize-app/popular-items${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/dashboard${`?${string}` | `#${string}` | ''}` | `/dashboard${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/dashboard-banners/create${`?${string}` | `#${string}` | ''}` | `/dashboard-banners/create${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/dashboard-banners${`?${string}` | `#${string}` | ''}` | `/dashboard-banners${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/manage-orders${`?${string}` | `#${string}` | ''}` | `/manage-orders${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/manage-orders/delivery-sequences${`?${string}` | `#${string}` | ''}` | `/manage-orders/delivery-sequences${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/manage-orders/orders${`?${string}` | `#${string}` | ''}` | `/manage-orders/orders${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/prices-overview${`?${string}` | `#${string}` | ''}` | `/prices-overview${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-groupings/create${`?${string}` | `#${string}` | ''}` | `/product-groupings/create${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-groupings${`?${string}` | `#${string}` | ''}` | `/product-groupings${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-tags/add${`?${string}` | `#${string}` | ''}` | `/product-tags/add${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-tags${`?${string}` | `#${string}` | ''}` | `/product-tags${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-tags/edit${`?${string}` | `#${string}` | ''}` | `/product-tags/edit${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/products/add${`?${string}` | `#${string}` | ''}` | `/products/add${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/products/edit${`?${string}` | `#${string}` | ''}` | `/products/edit${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/products${`?${string}` | `#${string}` | ''}` | `/products${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/rebalance-orders${`?${string}` | `#${string}` | ''}` | `/rebalance-orders${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/send-notifications${`?${string}` | `#${string}` | ''}` | `/send-notifications${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/slots/add${`?${string}` | `#${string}` | ''}` | `/slots/add${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/slots${`?${string}` | `#${string}` | ''}` | `/slots${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/slots/slot-details${`?${string}` | `#${string}` | ''}` | `/slots/slot-details${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/stores/add${`?${string}` | `#${string}` | ''}` | `/stores/add${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/stores/edit${`?${string}` | `#${string}` | ''}` | `/stores/edit${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/stores${`?${string}` | `#${string}` | ''}` | `/stores${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/user-management${`?${string}` | `#${string}` | ''}` | `/user-management${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/users${`?${string}` | `#${string}` | ''}` | `/users${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/vendor-snippets${`?${string}` | `#${string}` | ''}` | `/vendor-snippets${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/stocking-schedules${`?${string}` | `#${string}` | ''}` | `/stocking-schedules${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/login`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/address-management` | `/address-management`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/complaints` | `/complaints`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/create` | `/coupons/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons` | `/coupons`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/reserved-coupons` | `/coupons/reserved-coupons`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app/all-items-order` | `/customize-app/all-items-order`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app` | `/customize-app`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app/popular-items` | `/customize-app/popular-items`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard` | `/dashboard`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners/create` | `/dashboard-banners/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners` | `/dashboard-banners`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders` | `/manage-orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders/delivery-sequences` | `/manage-orders/delivery-sequences`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders/orders` | `/manage-orders/orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/prices-overview` | `/prices-overview`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-groupings/create` | `/product-groupings/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-groupings` | `/product-groupings`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags/add` | `/product-tags/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags` | `/product-tags`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags/edit` | `/product-tags/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products/add` | `/products/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products/edit` | `/products/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products` | `/products`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/rebalance-orders` | `/rebalance-orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/send-notifications` | `/send-notifications`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots/add` | `/slots/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots` | `/slots`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots/slot-details` | `/slots/slot-details`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores/add` | `/stores/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores/edit` | `/stores/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores` | `/stores`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/user-management` | `/user-management`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/users` | `/users`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/vendor-snippets` | `/vendor-snippets`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stocking-schedules` | `/stocking-schedules`; params?: Router.UnknownInputParams; } | `${'/(drawer)'}/coupons/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/coupons/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/dashboard-banners/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/dashboard-banners/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/manage-orders/order-details/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/manage-orders/order-details/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-groupings/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/product-groupings/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/products/detail/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/products/detail/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/slots/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/slots/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/user-management/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/user-management/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | { pathname: `${'/(drawer)'}/coupons/edit/[id]` | `/coupons/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/dashboard-banners/edit/[id]` | `/dashboard-banners/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/manage-orders/order-details/[id]` | `/manage-orders/order-details/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/product-groupings/edit/[id]` | `/product-groupings/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/products/detail/[id]` | `/products/detail/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/slots/edit/[id]` | `/slots/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/user-management/[id]` | `/user-management/[id]`, params: Router.UnknownInputParams & { id: string | number; } }; + hrefInputParams: { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/login`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/complaints` | `/complaints`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/create` | `/coupons/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons` | `/coupons`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/reserved-coupons` | `/coupons/reserved-coupons`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app/all-items-order` | `/customize-app/all-items-order`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app` | `/customize-app`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app/popular-items` | `/customize-app/popular-items`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard` | `/dashboard`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners/create` | `/dashboard-banners/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners` | `/dashboard-banners`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders` | `/manage-orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders/delivery-sequences` | `/manage-orders/delivery-sequences`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders/orders` | `/manage-orders/orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/prices-overview` | `/prices-overview`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-groupings/create` | `/product-groupings/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-groupings` | `/product-groupings`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags/add` | `/product-tags/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags` | `/product-tags`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags/edit` | `/product-tags/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products/add` | `/products/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products/edit` | `/products/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products` | `/products`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/rebalance-orders` | `/rebalance-orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/send-notifications` | `/send-notifications`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots/add` | `/slots/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots` | `/slots`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots/slot-details` | `/slots/slot-details`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores/add` | `/stores/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores/edit` | `/stores/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores` | `/stores`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/user-management` | `/user-management`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/users` | `/users`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/vendor-snippets` | `/vendor-snippets`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stocking-schedules` | `/stocking-schedules`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/edit/[id]` | `/coupons/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/dashboard-banners/edit/[id]` | `/dashboard-banners/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/manage-orders/order-details/[id]` | `/manage-orders/order-details/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/product-groupings/edit/[id]` | `/product-groupings/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/products/detail/[id]` | `/products/detail/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/slots/edit/[id]` | `/slots/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/user-management/[id]` | `/user-management/[id]`, params: Router.UnknownInputParams & { id: string | number; } }; + hrefOutputParams: { pathname: Router.RelativePathString, params?: Router.UnknownOutputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownOutputParams } | { pathname: `/`; params?: Router.UnknownOutputParams; } | { pathname: `/login`; params?: Router.UnknownOutputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/complaints` | `/complaints`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/coupons/create` | `/coupons/create`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/coupons` | `/coupons`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/coupons/reserved-coupons` | `/coupons/reserved-coupons`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/customize-app/all-items-order` | `/customize-app/all-items-order`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/customize-app` | `/customize-app`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/customize-app/popular-items` | `/customize-app/popular-items`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/dashboard` | `/dashboard`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners/create` | `/dashboard-banners/create`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners` | `/dashboard-banners`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/manage-orders` | `/manage-orders`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/manage-orders/delivery-sequences` | `/manage-orders/delivery-sequences`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/manage-orders/orders` | `/manage-orders/orders`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/prices-overview` | `/prices-overview`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-groupings/create` | `/product-groupings/create`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-groupings` | `/product-groupings`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-tags/add` | `/product-tags/add`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-tags` | `/product-tags`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/product-tags/edit` | `/product-tags/edit`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/products/add` | `/products/add`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/products/edit` | `/products/edit`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/products` | `/products`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/rebalance-orders` | `/rebalance-orders`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/send-notifications` | `/send-notifications`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/slots/add` | `/slots/add`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/slots` | `/slots`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/slots/slot-details` | `/slots/slot-details`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/stores/add` | `/stores/add`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/stores/edit` | `/stores/edit`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/stores` | `/stores`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/user-management` | `/user-management`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/users` | `/users`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/vendor-snippets` | `/vendor-snippets`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/stocking-schedules` | `/stocking-schedules`; params?: Router.UnknownOutputParams; } | { pathname: `${'/(drawer)'}/coupons/edit/[id]` | `/coupons/edit/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/dashboard-banners/edit/[id]` | `/dashboard-banners/edit/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/manage-orders/order-details/[id]` | `/manage-orders/order-details/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/product-groupings/edit/[id]` | `/product-groupings/edit/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/products/detail/[id]` | `/products/detail/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/slots/edit/[id]` | `/slots/edit/[id]`, params: Router.UnknownOutputParams & { id: string; } } | { pathname: `${'/(drawer)'}/user-management/[id]` | `/user-management/[id]`, params: Router.UnknownOutputParams & { id: string; } }; + href: Router.RelativePathString | Router.ExternalPathString | `/${`?${string}` | `#${string}` | ''}` | `/login${`?${string}` | `#${string}` | ''}` | `/_sitemap${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/complaints${`?${string}` | `#${string}` | ''}` | `/complaints${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/coupons/create${`?${string}` | `#${string}` | ''}` | `/coupons/create${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/coupons${`?${string}` | `#${string}` | ''}` | `/coupons${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/coupons/reserved-coupons${`?${string}` | `#${string}` | ''}` | `/coupons/reserved-coupons${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/customize-app/all-items-order${`?${string}` | `#${string}` | ''}` | `/customize-app/all-items-order${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/customize-app${`?${string}` | `#${string}` | ''}` | `/customize-app${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/customize-app/popular-items${`?${string}` | `#${string}` | ''}` | `/customize-app/popular-items${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/dashboard${`?${string}` | `#${string}` | ''}` | `/dashboard${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/dashboard-banners/create${`?${string}` | `#${string}` | ''}` | `/dashboard-banners/create${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/dashboard-banners${`?${string}` | `#${string}` | ''}` | `/dashboard-banners${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/manage-orders${`?${string}` | `#${string}` | ''}` | `/manage-orders${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/manage-orders/delivery-sequences${`?${string}` | `#${string}` | ''}` | `/manage-orders/delivery-sequences${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/manage-orders/orders${`?${string}` | `#${string}` | ''}` | `/manage-orders/orders${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/prices-overview${`?${string}` | `#${string}` | ''}` | `/prices-overview${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-groupings/create${`?${string}` | `#${string}` | ''}` | `/product-groupings/create${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-groupings${`?${string}` | `#${string}` | ''}` | `/product-groupings${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-tags/add${`?${string}` | `#${string}` | ''}` | `/product-tags/add${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-tags${`?${string}` | `#${string}` | ''}` | `/product-tags${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-tags/edit${`?${string}` | `#${string}` | ''}` | `/product-tags/edit${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/products/add${`?${string}` | `#${string}` | ''}` | `/products/add${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/products/edit${`?${string}` | `#${string}` | ''}` | `/products/edit${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/products${`?${string}` | `#${string}` | ''}` | `/products${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/rebalance-orders${`?${string}` | `#${string}` | ''}` | `/rebalance-orders${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/send-notifications${`?${string}` | `#${string}` | ''}` | `/send-notifications${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/slots/add${`?${string}` | `#${string}` | ''}` | `/slots/add${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/slots${`?${string}` | `#${string}` | ''}` | `/slots${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/slots/slot-details${`?${string}` | `#${string}` | ''}` | `/slots/slot-details${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/stores/add${`?${string}` | `#${string}` | ''}` | `/stores/add${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/stores/edit${`?${string}` | `#${string}` | ''}` | `/stores/edit${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/stores${`?${string}` | `#${string}` | ''}` | `/stores${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/user-management${`?${string}` | `#${string}` | ''}` | `/user-management${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/users${`?${string}` | `#${string}` | ''}` | `/users${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/vendor-snippets${`?${string}` | `#${string}` | ''}` | `/vendor-snippets${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/stocking-schedules${`?${string}` | `#${string}` | ''}` | `/stocking-schedules${`?${string}` | `#${string}` | ''}` | { pathname: Router.RelativePathString, params?: Router.UnknownInputParams } | { pathname: Router.ExternalPathString, params?: Router.UnknownInputParams } | { pathname: `/`; params?: Router.UnknownInputParams; } | { pathname: `/login`; params?: Router.UnknownInputParams; } | { pathname: `/_sitemap`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/complaints` | `/complaints`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/create` | `/coupons/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons` | `/coupons`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/coupons/reserved-coupons` | `/coupons/reserved-coupons`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app/all-items-order` | `/customize-app/all-items-order`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app` | `/customize-app`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/customize-app/popular-items` | `/customize-app/popular-items`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard` | `/dashboard`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners/create` | `/dashboard-banners/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/dashboard-banners` | `/dashboard-banners`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders` | `/manage-orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders/delivery-sequences` | `/manage-orders/delivery-sequences`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/manage-orders/orders` | `/manage-orders/orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/prices-overview` | `/prices-overview`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-groupings/create` | `/product-groupings/create`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-groupings` | `/product-groupings`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags/add` | `/product-tags/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags` | `/product-tags`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/product-tags/edit` | `/product-tags/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products/add` | `/products/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products/edit` | `/products/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/products` | `/products`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/rebalance-orders` | `/rebalance-orders`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/send-notifications` | `/send-notifications`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots/add` | `/slots/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots` | `/slots`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/slots/slot-details` | `/slots/slot-details`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores/add` | `/stores/add`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores/edit` | `/stores/edit`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stores` | `/stores`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/user-management` | `/user-management`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/users` | `/users`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/vendor-snippets` | `/vendor-snippets`; params?: Router.UnknownInputParams; } | { pathname: `${'/(drawer)'}/stocking-schedules` | `/stocking-schedules`; params?: Router.UnknownInputParams; } | `${'/(drawer)'}/coupons/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/coupons/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/dashboard-banners/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/dashboard-banners/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/manage-orders/order-details/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/manage-orders/order-details/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/product-groupings/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/product-groupings/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/products/detail/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/products/detail/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/slots/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/slots/edit/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `${'/(drawer)'}/user-management/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | `/user-management/${Router.SingleRoutePart}${`?${string}` | `#${string}` | ''}` | { pathname: `${'/(drawer)'}/coupons/edit/[id]` | `/coupons/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/dashboard-banners/edit/[id]` | `/dashboard-banners/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/manage-orders/order-details/[id]` | `/manage-orders/order-details/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/product-groupings/edit/[id]` | `/product-groupings/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/products/detail/[id]` | `/products/detail/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/slots/edit/[id]` | `/slots/edit/[id]`, params: Router.UnknownInputParams & { id: string | number; } } | { pathname: `${'/(drawer)'}/user-management/[id]` | `/user-management/[id]`, params: Router.UnknownInputParams & { id: string | number; } }; } } } diff --git a/apps/admin-ui/app/(drawer)/_layout.tsx b/apps/admin-ui/app/(drawer)/_layout.tsx index 5be957c..d2644d0 100644 --- a/apps/admin-ui/app/(drawer)/_layout.tsx +++ b/apps/admin-ui/app/(drawer)/_layout.tsx @@ -227,8 +227,7 @@ export default function Layout() { - - + diff --git a/apps/admin-ui/app/(drawer)/address-management/index.tsx b/apps/admin-ui/app/(drawer)/address-management/index.tsx deleted file mode 100644 index 69745b8..0000000 --- a/apps/admin-ui/app/(drawer)/address-management/index.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { useState } from 'react' -import { View, Text, TouchableOpacity, ScrollView } from 'react-native' -import { BottomDialog , tw } from 'common-ui' -import { trpc } from '@/src/trpc-client' -import AddressZoneForm from '@/components/AddressZoneForm' -import AddressPlaceForm from '@/components/AddressPlaceForm' -import MaterialIcons from '@expo/vector-icons/MaterialIcons' - -const AddressManagement: React.FC = () => { - const [dialogOpen, setDialogOpen] = useState(false) - const [dialogType, setDialogType] = useState<'zone' | 'place' | null>(null) - const [expandedZones, setExpandedZones] = useState>(new Set()) - - const { data: zones, refetch: refetchZones } = trpc.admin.address.getZones.useQuery() - const { data: areas, refetch: refetchAreas } = trpc.admin.address.getAreas.useQuery() - - const createZone = trpc.admin.address.createZone.useMutation({ - onSuccess: () => { - refetchZones() - setDialogOpen(false) - }, - }) - - const createArea = trpc.admin.address.createArea.useMutation({ - onSuccess: () => { - refetchAreas() - setDialogOpen(false) - }, - }) - - const handleAddZone = () => { - setDialogType('zone') - setDialogOpen(true) - } - - const handleAddPlace = () => { - setDialogType('place') - setDialogOpen(true) - } - - const toggleZone = (zoneId: number) => { - setExpandedZones(prev => { - const newSet = new Set(prev) - if (newSet.has(zoneId)) { - newSet.delete(zoneId) - } else { - newSet.add(zoneId) - } - return newSet - }) - } - - const groupedAreas = areas?.reduce((acc, area) => { - if (area.zoneId) { - if (!acc[area.zoneId]) acc[area.zoneId] = [] - acc[area.zoneId].push(area) - } - return acc - }, {} as Record) || {} - - const unzonedAreas = areas?.filter(a => !a.zoneId) || [] - - return ( - - - - Add Zone - - - Add Place - - - - - {zones?.map(zone => ( - - toggleZone(zone.id)}> - {zone.zoneName} - - - {expandedZones.has(zone.id) && ( - - {groupedAreas[zone.id]?.map(area => ( - - {area.placeName} - )) || No places in this zone} - - )} - - ))} - - - Unzoned Places - {unzonedAreas.map(area => ( - - {area.placeName} - ))} - {unzonedAreas.length === 0 && No unzoned places} - - - - setDialogOpen(false)}> - {dialogType === 'zone' && setDialogOpen(false)} />} - {dialogType === 'place' && setDialogOpen(false)} />} - - - ) -} - -export default AddressManagement \ No newline at end of file diff --git a/apps/admin-ui/app/(drawer)/dashboard/index.tsx b/apps/admin-ui/app/(drawer)/dashboard/index.tsx index 3fd9852..f84e940 100644 --- a/apps/admin-ui/app/(drawer)/dashboard/index.tsx +++ b/apps/admin-ui/app/(drawer)/dashboard/index.tsx @@ -185,15 +185,6 @@ export default function Dashboard() { iconColor: '#F97316', iconBg: '#FFEDD5', }, - { - title: 'Address Management', - icon: 'location-on', - description: 'Manage service areas', - route: '/(drawer)/address-management', - category: 'settings', - iconColor: '#EAB308', - iconBg: '#FEF9C3', - }, { title: 'App Constants', icon: 'settings-applications', diff --git a/apps/admin-ui/components/AddressPlaceForm.tsx b/apps/admin-ui/components/AddressPlaceForm.tsx deleted file mode 100644 index 89ea40f..0000000 --- a/apps/admin-ui/components/AddressPlaceForm.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react' -import { Formik } from 'formik' -import * as Yup from 'yup' -import { View, Text, TouchableOpacity } from 'react-native' -import { MyTextInput, BottomDropdown, tw } from 'common-ui' -import { trpc } from '@/src/trpc-client' - -interface AddressPlaceFormProps { - onSubmit: (values: { placeName: string; zoneId: number | null }) => void - onClose: () => void -} - -const AddressPlaceForm: React.FC = ({ onSubmit, onClose }) => { - const { data: zones } = trpc.admin.address.getZones.useQuery() - - const validationSchema = Yup.object({ - placeName: Yup.string().required('Place name is required'), - zoneId: Yup.number().optional(), - }) - - const zoneOptions = zones?.map(z => ({ label: z.zoneName, value: z.id })) || [] - - return ( - - Add Place - { - onSubmit(values) - onClose() - }} - > - {({ handleChange, setFieldValue, handleSubmit, values, errors, touched }) => ( - - - setFieldValue('zoneId', value as number | undefined)} - placeholder="Select Zone" - /> - - - Cancel - - handleSubmit()}> - Create - - - - )} - - - ) -} - -export default AddressPlaceForm \ No newline at end of file diff --git a/apps/admin-ui/components/AddressZoneForm.tsx b/apps/admin-ui/components/AddressZoneForm.tsx deleted file mode 100644 index db00ecd..0000000 --- a/apps/admin-ui/components/AddressZoneForm.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react' -import { Formik } from 'formik' -import * as Yup from 'yup' -import { View, Text, TouchableOpacity } from 'react-native' -import { MyTextInput, tw } from 'common-ui' - -interface AddressZoneFormProps { - onSubmit: (values: { zoneName: string }) => void - onClose: () => void -} - -const AddressZoneForm: React.FC = ({ onSubmit, onClose }) => { - const validationSchema = Yup.object({ - zoneName: Yup.string().required('Zone name is required'), - }) - - return ( - - Add Zone - { - onSubmit(values) - onClose() - }} - > - {({ handleChange, handleSubmit, values, errors, touched }) => ( - - - - - Cancel - - handleSubmit()}> - Create - - - - )} - - - ) -} - -export default AddressZoneForm \ No newline at end of file diff --git a/apps/backend/index.ts b/apps/backend/index.ts index 98de211..390aef7 100644 --- a/apps/backend/index.ts +++ b/apps/backend/index.ts @@ -19,7 +19,6 @@ import { startAutomatedJobs } from '@/src/lib/automatedJobs' seed() initFunc() startAutomatedJobs() -signedUrlCache.loadFromDisk() const app = new Hono() diff --git a/apps/backend/package.json b/apps/backend/package.json index 90fda3d..3545753 100755 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -40,7 +40,6 @@ "jose": "^5.10.0", "node-cron": "^4.2.1", "pg": "^8.16.3", - "razorpay": "^2.9.6", "redis": "^5.9.0", "zod": "^4.1.12" }, diff --git a/apps/backend/src/apis/admin-apis/apis/av-router.ts b/apps/backend/src/apis/admin-apis/apis/av-router.ts deleted file mode 100755 index c77111d..0000000 --- a/apps/backend/src/apis/admin-apis/apis/av-router.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router } from "express"; -import { authenticateStaff } from "@/src/middleware/staff-auth"; - -const router = Router(); - -// Apply staff authentication to all admin routes -router.use(authenticateStaff); - -const avRouter = router; - -export default avRouter; \ No newline at end of file diff --git a/apps/backend/src/apis/common-apis/apis/common-product.controller.ts b/apps/backend/src/apis/common-apis/apis/common-product.controller.ts index 93bd682..2da05b6 100644 --- a/apps/backend/src/apis/common-apis/apis/common-product.controller.ts +++ b/apps/backend/src/apis/common-apis/apis/common-product.controller.ts @@ -1,5 +1,5 @@ import { eq, gt, and, sql, inArray } from "drizzle-orm"; -import { Request, Response } from "express"; +import { Context } from "hono"; import { db } from "@/src/db/db_index" import { productInfo, units, productSlots, deliverySlotInfo, productTags } from "@/src/db/schema" import { scaffoldAssetUrl } from "@/src/lib/s3-client" @@ -29,10 +29,10 @@ const getNextDeliveryDate = async (productId: number): Promise => { /** * Get all products summary for dropdown */ -export const getAllProductsSummary = async (req: Request, res: Response) => { +export const getAllProductsSummary = async (c: Context) => { try { - const { tagId } = req.query; - const tagIdNum = tagId ? parseInt(tagId as string) : null; + const tagId = c.req.query('tagId'); + const tagIdNum = tagId ? parseInt(tagId) : null; let productIds: number[] | null = null; @@ -53,7 +53,7 @@ export const getAllProductsSummary = async (req: Request, res: Response) => { whereCondition = inArray(productInfo.id, productIds); } else if (tagIdNum) { // If tagId was provided but no products found, return empty array - return res.status(200).json({ + return c.json({ products: [], count: 0, }); @@ -94,12 +94,12 @@ export const getAllProductsSummary = async (req: Request, res: Response) => { }) ); - return res.status(200).json({ + return c.json({ products: formattedProducts, count: formattedProducts.length, }); } catch (error) { console.error("Get products summary error:", error); - return res.status(500).json({ error: "Failed to fetch products summary" }); + return c.json({ error: "Failed to fetch products summary" }, 500); } }; diff --git a/apps/backend/src/apis/common-apis/apis/common-product.router.ts b/apps/backend/src/apis/common-apis/apis/common-product.router.ts index 90b7655..5195063 100644 --- a/apps/backend/src/apis/common-apis/apis/common-product.router.ts +++ b/apps/backend/src/apis/common-apis/apis/common-product.router.ts @@ -1,10 +1,9 @@ -import { Router } from "express"; -import { getAllProductsSummary } from "@/src/apis/common-apis/apis/common-product.controller" +import { Hono } from 'hono' +import { getAllProductsSummary } from '@/src/apis/common-apis/apis/common-product.controller' -const router = Router(); +const app = new Hono() -router.get("/summary", getAllProductsSummary); +// GET /summary - Get all products summary +app.get('/summary', getAllProductsSummary) - -const commonProductsRouter= router; -export default commonProductsRouter; \ No newline at end of file +export default app diff --git a/apps/backend/src/apis/common-apis/apis/common.router.ts b/apps/backend/src/apis/common-apis/apis/common.router.ts index 7277d1f..1dfdb2f 100644 --- a/apps/backend/src/apis/common-apis/apis/common.router.ts +++ b/apps/backend/src/apis/common-apis/apis/common.router.ts @@ -1,10 +1,9 @@ -import { Router } from "express"; -import commonProductsRouter from "@/src/apis/common-apis/apis/common-product.router" +import { Hono } from 'hono' +import commonProductsRouter from '@/src/apis/common-apis/apis/common-product.router' -const router = Router(); +const app = new Hono() -router.use('/products', commonProductsRouter) +// Mount product routes at /products +app.route('/products', commonProductsRouter) -const commonRouter = router; - -export default commonRouter; \ No newline at end of file +export default app diff --git a/apps/backend/src/jobs/jobs-index.ts b/apps/backend/src/jobs/jobs-index.ts index 107be6f..048d5cd 100644 --- a/apps/backend/src/jobs/jobs-index.ts +++ b/apps/backend/src/jobs/jobs-index.ts @@ -1,17 +1,9 @@ import * as cron from 'node-cron'; -import { checkPendingPayments, checkRefundStatuses } from '@/src/jobs/payment-status-checker' const runCombinedJob = async () => { const start = Date.now(); try { - console.log('Starting combined job: payments and refunds check'); - - // Run payment check - // await checkPendingPayments(); - - // Run refund check - // await checkRefundStatuses(); - + console.log('Starting combined job'); console.log('Combined job completed successfully'); } catch (error) { console.error('Error in combined job:', error); diff --git a/apps/backend/src/jobs/payment-status-checker.ts b/apps/backend/src/jobs/payment-status-checker.ts deleted file mode 100644 index 7c6679b..0000000 --- a/apps/backend/src/jobs/payment-status-checker.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as cron from 'node-cron'; -import { db } from '@/src/db/db_index' -import { payments, orders, deliverySlotInfo, refunds } from '@/src/db/schema' -import { eq, and, gt, isNotNull } from 'drizzle-orm'; -import { RazorpayPaymentService } from '@/src/lib/payments-utils' - -interface PendingPaymentRecord { - payment: typeof payments.$inferSelect; - order: typeof orders.$inferSelect; - slot: typeof deliverySlotInfo.$inferSelect; -} - -export const createPaymentNotification = (record: PendingPaymentRecord) => { - // Construct message from record data - const message = `Payment pending for order ORD${record.order.id}. Please complete before orders close time.`; - - // TODO: Implement notification sending logic using record.order.userId, record.order.id, message - console.log(`Sending notification to user ${record.order.userId} for order ${record.order.id}: ${message}`); -}; - -export const checkRefundStatuses = async () => { - try { - const initiatedRefunds = await db - .select() - .from(refunds) - .where(and( - eq(refunds.refundStatus, 'initiated'), - isNotNull(refunds.merchantRefundId) - )); - - // Process refunds concurrently using Promise.allSettled - const promises = initiatedRefunds.map(async (refund) => { - if (!refund.merchantRefundId) return; - - try { - const razorpayRefund = await RazorpayPaymentService.fetchRefund(refund.merchantRefundId); - - if (razorpayRefund.status === 'processed') { - await db - .update(refunds) - .set({ refundStatus: 'success', refundProcessedAt: new Date() }) - .where(eq(refunds.id, refund.id)); - } - } catch (error) { - console.error(`Error checking refund ${refund.id}:`, error); - } - }); - - // Wait for all promises to complete - await Promise.allSettled(promises); - } catch (error) { - console.error('Error in checkRefundStatuses:', error); - } -}; - -export const checkPendingPayments = async () => { - try { - const pendingPayments = await db - .select({ - payment: payments, - order: orders, - slot: deliverySlotInfo, - }) - .from(payments) - .innerJoin(orders, eq(payments.orderId, orders.id)) - .innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id)) - .where(and( - eq(payments.status, 'pending'), - gt(deliverySlotInfo.freezeTime, new Date()) // Freeze time not passed - )); - - for (const record of pendingPayments) { - createPaymentNotification(record); - } - } catch (error) { - console.error('Error checking pending payments:', error); - } -}; - diff --git a/apps/backend/src/lib/catch-async.ts b/apps/backend/src/lib/catch-async.ts deleted file mode 100755 index d78004b..0000000 --- a/apps/backend/src/lib/catch-async.ts +++ /dev/null @@ -1,6 +0,0 @@ -import express from 'express'; -const catchAsync = - (fn: express.RequestHandler) => - (req: express.Request, res: express.Response, next: express.NextFunction) => - Promise.resolve(fn(req, res, next)).catch(next); -export default catchAsync; \ No newline at end of file diff --git a/apps/backend/src/lib/disk-persisted-set.ts b/apps/backend/src/lib/disk-persisted-set.ts.txt similarity index 100% rename from apps/backend/src/lib/disk-persisted-set.ts rename to apps/backend/src/lib/disk-persisted-set.ts.txt diff --git a/apps/backend/src/lib/payments-utils.ts b/apps/backend/src/lib/payments-utils.ts deleted file mode 100644 index e60a03d..0000000 --- a/apps/backend/src/lib/payments-utils.ts +++ /dev/null @@ -1,59 +0,0 @@ -import Razorpay from "razorpay"; -import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter" -import { db } from "@/src/db/db_index" -import { payments } from "@/src/db/schema" - -type Tx = Parameters[0]>[0]; - -export class RazorpayPaymentService { - private static instance = new Razorpay({ - key_id: razorpayId, - key_secret: razorpaySecret, - }); - - static async createOrder(orderId: number, amount: string) { - // Create Razorpay order - const razorpayOrder = await this.instance.orders.create({ - amount: parseFloat(amount) * 100, // Convert to paisa - currency: 'INR', - receipt: `order_${orderId}`, - notes: { - customerOrderId: orderId.toString(), - }, - }); - - return razorpayOrder; - } - - static async insertPaymentRecord(orderId: number, razorpayOrder: any, tx?: Tx) { - // Use transaction if provided, otherwise use db - const dbInstance = tx || db; - - // Insert payment record - const [payment] = await dbInstance - .insert(payments) - .values({ - status: 'pending', - gateway: 'razorpay', - orderId, - token: orderId.toString(), - merchantOrderId: razorpayOrder.id, - payload: razorpayOrder, - }) - .returning(); - - return payment; - } - - static async initiateRefund(paymentId: string, amount: number) { - const refund = await this.instance.payments.refund(paymentId, { - amount, - }); - return refund; - } - - static async fetchRefund(refundId: string) { - const refund = await this.instance.refunds.fetch(refundId); - return refund; - } -} \ No newline at end of file diff --git a/apps/backend/src/lib/signed-url-cache.ts b/apps/backend/src/lib/signed-url-cache.ts old mode 100755 new mode 100644 index 0f4e98c..7dc6460 --- a/apps/backend/src/lib/signed-url-cache.ts +++ b/apps/backend/src/lib/signed-url-cache.ts @@ -1,8 +1,3 @@ -import fs from 'fs'; -import path from 'path'; - -const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json'); - // Interface for cache entries with TTL interface CacheEntry { value: string; @@ -16,18 +11,7 @@ class SignedURLCache { constructor() { this.originalToSignedCache = new Map(); this.signedToOriginalCache = new Map(); - - // Create cache directory if it doesn't exist - const cacheDir = path.dirname(CACHE_FILE_PATH); - if (!fs.existsSync(cacheDir)) { - console.log('creating the directory') - - fs.mkdirSync(cacheDir, { recursive: true }); - } - else { - console.log('the directory is already present') - - } + console.log('SignedURLCache: Initialized (in-memory only)'); } /** @@ -110,7 +94,7 @@ class SignedURLCache { clear(): void { this.originalToSignedCache.clear(); this.signedToOriginalCache.clear(); - this.saveToDisk(); + console.log('SignedURLCache: Cleared all entries'); } /** @@ -145,119 +129,27 @@ class SignedURLCache { } /** - * Save the cache to disk + * Get cache statistics */ - saveToDisk(): void { - try { - // Remove expired entries before saving - const removedCount = this.clearExpired(); - - // Convert Maps to serializable objects - const serializedOriginalToSigned: Record = {}; - const serializedSignedToOriginal: Record = {}; - - for (const [originalUrl, entry] of this.originalToSignedCache.entries()) { - serializedOriginalToSigned[originalUrl] = { - value: entry.value, - expiresAt: entry.expiresAt - }; - } - - for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) { - serializedSignedToOriginal[signedUrl] = { - value: entry.value, - expiresAt: entry.expiresAt - }; - } - - const serializedCache = { - originalToSigned: serializedOriginalToSigned, - signedToOriginal: serializedSignedToOriginal - }; - - // Write to file - fs.writeFileSync( - CACHE_FILE_PATH, - JSON.stringify(serializedCache), - 'utf8' - ); - - console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`); - } catch (error) { - console.error('Error saving SignedURLCache to disk:', error); - } + getStats(): { totalEntries: number } { + return { + totalEntries: this.originalToSignedCache.size + }; } /** - * Load the cache from disk + * Stub methods for backward compatibility - do nothing in in-memory mode */ + saveToDisk(): void { + // No-op: In-memory cache only + } + loadFromDisk(): void { - try { - if (fs.existsSync(CACHE_FILE_PATH)) { - // Read from file - const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8'); - - // Parse the data - const parsedData = JSON.parse(data) as { - originalToSigned: Record, - signedToOriginal: Record - }; - - // Only load entries that haven't expired yet - const now = Date.now(); - let loadedCount = 0; - let expiredCount = 0; - - // Load original to signed mappings - if (parsedData.originalToSigned) { - for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) { - if (now <= entry.expiresAt) { - this.originalToSignedCache.set(originalUrl, entry); - loadedCount++; - } else { - expiredCount++; - } - } - } - - // Load signed to original mappings - if (parsedData.signedToOriginal) { - for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) { - if (now <= entry.expiresAt) { - this.signedToOriginalCache.set(signedUrl, entry); - // Don't increment loadedCount as these are pairs of what we already counted - } else { - // Don't increment expiredCount as these are pairs of what we already counted - } - } - } - - console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`); - } else { - console.log('SignedURLCache: No cache file found, starting with empty cache'); - } - } catch (error) { - console.error('Error loading SignedURLCache from disk:', error); - // Start with empty caches if loading fails - this.originalToSignedCache = new Map(); - this.signedToOriginalCache = new Map(); - } + // No-op: In-memory cache only } } // Create a singleton instance to be used throughout the application const signedUrlCache = new SignedURLCache(); -process.on('SIGINT', () => { - console.log('SignedURLCache: Saving cache before shutdown...'); - signedUrlCache.saveToDisk(); - process.exit(0); -}); - -process.on('SIGTERM', () => { - console.log('SignedURLCache: Saving cache before shutdown...'); - signedUrlCache.saveToDisk(); - process.exit(0); -}); - -export default signedUrlCache; \ No newline at end of file +export default signedUrlCache; diff --git a/apps/backend/src/lib/signed-url-cache.ts.txt b/apps/backend/src/lib/signed-url-cache.ts.txt new file mode 100755 index 0000000..0f4e98c --- /dev/null +++ b/apps/backend/src/lib/signed-url-cache.ts.txt @@ -0,0 +1,263 @@ +import fs from 'fs'; +import path from 'path'; + +const CACHE_FILE_PATH = path.join('.', 'assets', 'signed-url-cache.json'); + +// Interface for cache entries with TTL +interface CacheEntry { + value: string; + expiresAt: number; // Timestamp when this entry expires +} + +class SignedURLCache { + private originalToSignedCache: Map; + private signedToOriginalCache: Map; + + constructor() { + this.originalToSignedCache = new Map(); + this.signedToOriginalCache = new Map(); + + // Create cache directory if it doesn't exist + const cacheDir = path.dirname(CACHE_FILE_PATH); + if (!fs.existsSync(cacheDir)) { + console.log('creating the directory') + + fs.mkdirSync(cacheDir, { recursive: true }); + } + else { + console.log('the directory is already present') + + } + } + + /** + * Get a signed URL from the cache using an original URL as the key + */ + get(originalUrl: string): string | undefined { + const entry = this.originalToSignedCache.get(originalUrl); + + // If no entry or entry has expired, return undefined + if (!entry || Date.now() > entry.expiresAt) { + if (entry) { + // Remove expired entry + this.originalToSignedCache.delete(originalUrl); + // Also remove from reverse mapping if it exists + this.signedToOriginalCache.delete(entry.value); + } + return undefined; + } + + return entry.value; + } + + /** + * Get the original URL from the cache using a signed URL as the key + */ + getOriginalUrl(signedUrl: string): string | undefined { + const entry = this.signedToOriginalCache.get(signedUrl); + + // If no entry or entry has expired, return undefined + if (!entry || Date.now() > entry.expiresAt) { + if (entry) { + // Remove expired entry + this.signedToOriginalCache.delete(signedUrl); + // Also remove from primary mapping if it exists + this.originalToSignedCache.delete(entry.value); + } + return undefined; + } + + return entry.value; + } + + /** + * Set a value in the cache with a TTL (Time To Live) + * @param originalUrl The original S3 URL + * @param signedUrl The signed URL + * @param ttlMs Time to live in milliseconds (default: 3 days) + */ + set(originalUrl: string, signedUrl: string, ttlMs: number = 259200000): void { + const expiresAt = Date.now() + ttlMs; + + const entry: CacheEntry = { + value: signedUrl, + expiresAt + }; + + const reverseEntry: CacheEntry = { + value: originalUrl, + expiresAt + }; + + this.originalToSignedCache.set(originalUrl, entry); + this.signedToOriginalCache.set(signedUrl, reverseEntry); + } + + has(originalUrl: string): boolean { + const entry = this.originalToSignedCache.get(originalUrl); + + // Entry exists and hasn't expired + return !!entry && Date.now() <= entry.expiresAt; + } + + hasSignedUrl(signedUrl: string): boolean { + const entry = this.signedToOriginalCache.get(signedUrl); + + // Entry exists and hasn't expired + return !!entry && Date.now() <= entry.expiresAt; + } + + clear(): void { + this.originalToSignedCache.clear(); + this.signedToOriginalCache.clear(); + this.saveToDisk(); + } + + /** + * Remove all expired entries from the cache + * @returns The number of expired entries that were removed + */ + clearExpired(): number { + const now = Date.now(); + let removedCount = 0; + + // Clear expired entries from original to signed cache + for (const [originalUrl, entry] of this.originalToSignedCache.entries()) { + if (now > entry.expiresAt) { + this.originalToSignedCache.delete(originalUrl); + removedCount++; + } + } + + // Clear expired entries from signed to original cache + for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) { + if (now > entry.expiresAt) { + this.signedToOriginalCache.delete(signedUrl); + // No need to increment removedCount as we've already counted these in the first loop + } + } + + if (removedCount > 0) { + console.log(`SignedURLCache: Cleared ${removedCount} expired entries`); + } + + return removedCount; + } + + /** + * Save the cache to disk + */ + saveToDisk(): void { + try { + // Remove expired entries before saving + const removedCount = this.clearExpired(); + + // Convert Maps to serializable objects + const serializedOriginalToSigned: Record = {}; + const serializedSignedToOriginal: Record = {}; + + for (const [originalUrl, entry] of this.originalToSignedCache.entries()) { + serializedOriginalToSigned[originalUrl] = { + value: entry.value, + expiresAt: entry.expiresAt + }; + } + + for (const [signedUrl, entry] of this.signedToOriginalCache.entries()) { + serializedSignedToOriginal[signedUrl] = { + value: entry.value, + expiresAt: entry.expiresAt + }; + } + + const serializedCache = { + originalToSigned: serializedOriginalToSigned, + signedToOriginal: serializedSignedToOriginal + }; + + // Write to file + fs.writeFileSync( + CACHE_FILE_PATH, + JSON.stringify(serializedCache), + 'utf8' + ); + + console.log(`SignedURLCache: Saved ${this.originalToSignedCache.size} entries to disk`); + } catch (error) { + console.error('Error saving SignedURLCache to disk:', error); + } + } + + /** + * Load the cache from disk + */ + loadFromDisk(): void { + try { + if (fs.existsSync(CACHE_FILE_PATH)) { + // Read from file + const data = fs.readFileSync(CACHE_FILE_PATH, 'utf8'); + + // Parse the data + const parsedData = JSON.parse(data) as { + originalToSigned: Record, + signedToOriginal: Record + }; + + // Only load entries that haven't expired yet + const now = Date.now(); + let loadedCount = 0; + let expiredCount = 0; + + // Load original to signed mappings + if (parsedData.originalToSigned) { + for (const [originalUrl, entry] of Object.entries(parsedData.originalToSigned)) { + if (now <= entry.expiresAt) { + this.originalToSignedCache.set(originalUrl, entry); + loadedCount++; + } else { + expiredCount++; + } + } + } + + // Load signed to original mappings + if (parsedData.signedToOriginal) { + for (const [signedUrl, entry] of Object.entries(parsedData.signedToOriginal)) { + if (now <= entry.expiresAt) { + this.signedToOriginalCache.set(signedUrl, entry); + // Don't increment loadedCount as these are pairs of what we already counted + } else { + // Don't increment expiredCount as these are pairs of what we already counted + } + } + } + + console.log(`SignedURLCache: Loaded ${loadedCount} valid entries from disk (skipped ${expiredCount} expired entries)`); + } else { + console.log('SignedURLCache: No cache file found, starting with empty cache'); + } + } catch (error) { + console.error('Error loading SignedURLCache from disk:', error); + // Start with empty caches if loading fails + this.originalToSignedCache = new Map(); + this.signedToOriginalCache = new Map(); + } + } +} + +// Create a singleton instance to be used throughout the application +const signedUrlCache = new SignedURLCache(); + +process.on('SIGINT', () => { + console.log('SignedURLCache: Saving cache before shutdown...'); + signedUrlCache.saveToDisk(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + console.log('SignedURLCache: Saving cache before shutdown...'); + signedUrlCache.saveToDisk(); + process.exit(0); +}); + +export default signedUrlCache; \ No newline at end of file diff --git a/apps/backend/src/main-router.ts b/apps/backend/src/main-router.ts index ffb3557..a019e4d 100755 --- a/apps/backend/src/main-router.ts +++ b/apps/backend/src/main-router.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { authenticateUser } from '@/src/middleware/auth.middleware' +import v1Router from '@/src/v1-router' // Note: This router is kept for compatibility during migration // Most routes have been moved to tRPC @@ -24,10 +25,10 @@ router.get('/seed', (c) => { }) }) +// Mount v1 routes (REST API) +router.route('/v1', v1Router) + // Apply authentication middleware to all subsequent routes router.use('*', authenticateUser) -// Legacy routes - most functionality moved to tRPC -// router.route('/v1', v1Router) // Uncomment if needed during transition - export default router diff --git a/apps/backend/src/services/user/order-service.ts b/apps/backend/src/services/user/order-service.ts deleted file mode 100644 index 8b2df28..0000000 --- a/apps/backend/src/services/user/order-service.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { db } from '@/src/db/db_index' -import { - orders, - orderItems, - orderStatus, - addresses, - productInfo, - paymentInfoTable, - coupons, - couponUsage, - payments, - cartItems, - refunds, - units, - userDetails, -} from '@/src/db/schema' -import { eq, and, inArray, desc, gte } from 'drizzle-orm' - -// ============ User/Auth Queries ============ - -/** - * Get user details by user ID - */ -export async function getUserDetails(userId: number) { - return db.query.userDetails.findFirst({ - where: eq(userDetails.userId, userId), - }) -} - -// ============ Address Queries ============ - -/** - * Get user address by ID - */ -export async function getUserAddress(userId: number, addressId: number) { - return db.query.addresses.findFirst({ - where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)), - }) -} - -// ============ Product Queries ============ - -/** - * Get product by ID - */ -export async function getProductById(productId: number) { - return db.query.productInfo.findFirst({ - where: eq(productInfo.id, productId), - }) -} - -/** - * Get multiple products by IDs with unit info - */ -export async function getProductsByIdsWithUnits(productIds: number[]) { - return 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)) -} - -// ============ Coupon Queries ============ - -/** - * Get coupon with usages for user - */ -export async function getCouponWithUsages(couponId: number, userId: number) { - return db.query.coupons.findFirst({ - where: eq(coupons.id, couponId), - with: { - usages: { where: eq(couponUsage.userId, userId) }, - }, - }) -} - -/** - * Insert coupon usage - */ -export async function insertCouponUsage(data: { - userId: number - couponId: number - orderId: number - orderItemId: number | null - usedAt: Date -}) { - return db.insert(couponUsage).values(data) -} - -/** - * Get coupon usages for order - */ -export async function getCouponUsagesForOrder(orderId: number) { - return db.query.couponUsage.findMany({ - where: eq(couponUsage.orderId, orderId), - with: { - coupon: true, - }, - }) -} - -// ============ Cart Queries ============ - -/** - * Delete cart items for user by product IDs - */ -export async function deleteCartItems(userId: number, productIds: number[]) { - return db.delete(cartItems).where( - and( - eq(cartItems.userId, userId), - inArray(cartItems.productId, productIds) - ) - ) -} - -// ============ Payment Info Queries ============ - -/** - * Create payment info - */ -export async function createPaymentInfo(data: { - status: string - gateway: string - merchantOrderId: string -}) { - return db.insert(paymentInfoTable).values(data).returning() -} - - - -// ============ Order Queries ============ - -/** - * Insert multiple orders - */ -export async function insertOrders(ordersData: any[]) { - return db.insert(orders).values(ordersData).returning() -} - -/** - * Insert multiple order items - */ -export async function insertOrderItems(itemsData: any[]) { - return db.insert(orderItems).values(itemsData) -} - -/** - * Insert multiple order statuses - */ -export async function insertOrderStatuses(statusesData: any[]) { - return db.insert(orderStatus).values(statusesData) -} - -/** - * Get user orders with all relations - */ -export async function getUserOrdersWithRelations(userId: number, limit: number, offset: number) { - return 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: limit, - offset: offset, - }) -} - -/** - * Count user orders - */ -export async function countUserOrders(userId: number) { - return db.$count(orders, eq(orders.userId, userId)) -} - -/** - * Get order by ID with all relations - */ -export async function getOrderByIdWithRelations(orderId: number) { - return db.query.orders.findFirst({ - where: eq(orders.id, orderId), - with: { - orderItems: { - with: { - product: true, - }, - }, - slot: true, - paymentInfo: true, - orderStatus: { - with: { - refundCoupon: true, - }, - }, - refunds: true, - }, - }) -} - -/** - * Get order by ID with order status - */ -export async function getOrderWithStatus(orderId: number) { - return db.query.orders.findFirst({ - where: eq(orders.id, orderId), - with: { - orderStatus: true, - }, - }) -} - -/** - * Update order status to cancelled - */ -export async function updateOrderStatusToCancelled( - statusId: number, - data: { - isCancelled: boolean - cancelReason: string - cancellationUserNotes: string - cancellationReviewed: boolean - } -) { - return db - .update(orderStatus) - .set(data) - .where(eq(orderStatus.id, statusId)) -} - -/** - * Insert refund record - */ -export async function insertRefund(data: { orderId: number; refundStatus: string }) { - return db.insert(refunds).values(data) -} - -/** - * Update order notes - */ -export async function updateOrderNotes(orderId: number, userNotes: string | null) { - return db - .update(orders) - .set({ userNotes }) - .where(eq(orders.id, orderId)) -} - -/** - * Get recent delivered orders for user - */ -export async function getRecentDeliveredOrders( - userId: number, - since: Date, - limit: number -) { - return 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, since) - ) - ) - .orderBy(desc(orders.createdAt)) - .limit(limit) -} - -/** - * Get order items by order IDs - */ -export async function getOrderItemsByOrderIds(orderIds: number[]) { - return db - .select({ productId: orderItems.productId }) - .from(orderItems) - .where(inArray(orderItems.orderId, orderIds)) -} - -// ============ Transaction Helper ============ - -/** - * Execute function within a database transaction - */ -export async function withTransaction(fn: (tx: any) => Promise): Promise { - return db.transaction(fn) -} - -/** - * Cancel order with refund record in a transaction - */ -export async function cancelOrderWithRefund( - statusId: number, - orderId: number, - isCod: boolean, - reason: string -): Promise<{ orderId: number }> { - return db.transaction(async (tx) => { - // Update order status - await tx - .update(orderStatus) - .set({ - isCancelled: true, - cancelReason: reason, - cancellationUserNotes: reason, - cancellationReviewed: false, - }) - .where(eq(orderStatus.id, statusId)) - - // Insert refund record - const refundStatus = isCod ? "na" : "pending" - await tx.insert(refunds).values({ - orderId, - refundStatus, - }) - - return { orderId } - }) -} - -type Tx = Parameters[0]>[0] - -/** - * Create orders with payment info in a transaction - */ -export async function createOrdersWithPayment( - ordersData: any[], - paymentMethod: "online" | "cod", - totalWithDelivery: number, - razorpayOrderCreator?: (paymentInfoId: number, amount: string) => Promise, - paymentRecordInserter?: (paymentInfoId: number, razorpayOrder: any, tx: Tx) => Promise -): Promise { - return db.transaction(async (tx) => { - 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 ordersToInsert: Omit[] = ordersData.map( - (od) => ({ - ...od.order, - paymentInfoId: sharedPaymentInfoId, - }) - ) - - const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning() - - const allOrderItems: Omit[] = [] - const allOrderStatuses: Omit[] = [] - - insertedOrders.forEach((order: typeof orders.$inferSelect, index: number) => { - const od = ordersData[index] - od.orderItems.forEach((item: any) => { - allOrderItems.push({ ...item, orderId: order.id as number }) - }) - allOrderStatuses.push({ - ...od.orderStatus, - orderId: order.id as number, - }) - }) - - await tx.insert(orderItems).values(allOrderItems) - await tx.insert(orderStatus).values(allOrderStatuses) - - if (paymentMethod === "online" && sharedPaymentInfoId && razorpayOrderCreator && paymentRecordInserter) { - const razorpayOrder = await razorpayOrderCreator( - sharedPaymentInfoId, - totalWithDelivery.toString() - ) - await paymentRecordInserter( - sharedPaymentInfoId, - razorpayOrder, - tx - ) - } - - return insertedOrders - }) -} diff --git a/apps/backend/src/test-controller.ts b/apps/backend/src/test-controller.ts deleted file mode 100644 index ae98af6..0000000 --- a/apps/backend/src/test-controller.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Router, Request, Response } from 'express'; - -const router = Router(); - -router.get('/', (req: Request, res: Response) => { - res.json({ - status: 'ok', - message: 'Health check passed', - timestamp: new Date().toISOString(), - }); -}); - -export default router; \ No newline at end of file diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts b/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts index 4e23b84..12beee5 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/admin-trpc-index.ts @@ -2,7 +2,6 @@ import { router } from '@/src/trpc/trpc-index' import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint' import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon' -import { cancelledOrdersRouter } from '@/src/trpc/apis/admin-apis/apis/cancelled-orders' import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order' import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets' import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots' @@ -10,7 +9,6 @@ import { productRouter } from '@/src/trpc/apis/admin-apis/apis/product' import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user' import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store' import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments' -import addressRouter from '@/src/trpc/apis/admin-apis/apis/address' import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner' import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user' import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const' @@ -20,7 +18,6 @@ import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag' export const adminRouter = router({ complaint: complaintRouter, coupon: couponRouter, - cancelledOrders: cancelledOrdersRouter, order: orderRouter, vendorSnippets: vendorSnippetsRouter, slots: slotsRouter, @@ -28,7 +25,6 @@ export const adminRouter = router({ staffUser: staffUserRouter, store: storeRouter, payments: adminPaymentsRouter, - address: addressRouter, banner: bannerRouter, user: userRouter, const: constRouter, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/banner.ts b/apps/backend/src/trpc/apis/admin-apis/apis/banner.ts index 5794143..bbf07e6 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/banner.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/banner.ts @@ -1,53 +1,42 @@ import { z } from 'zod'; -import { db } from '@/src/db/db_index' -import { homeBanners } from '@/src/db/schema' -import { eq, and, desc, sql } from 'drizzle-orm'; import { protectedProcedure, router } from '@/src/trpc/trpc-index' -import { extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client' +import { scaffoldAssetUrl, extractKeyFromPresignedUrl } from '@/src/lib/s3-client' import { ApiError } from '@/src/lib/api-error'; import { scheduleStoreInitialization } from '@/src/stores/store-initializer' - +import { bannerDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' export const bannerRouter = router({ // Get all banners getBanners: protectedProcedure .query(async () => { try { + const banners = await bannerDbService.getAllBanners() - const banners = await db.query.homeBanners.findMany({ - orderBy: desc(homeBanners.createdAt), // Order by creation date instead - // Removed product relationship since we now use productIds array - }); + // Convert S3 keys to signed URLs for client + const bannersWithSignedUrls = await Promise.all( + banners.map(async (banner) => { + try { + return { + ...banner, + imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl, + productIds: banner.productIds || [], + }; + } catch (error) { + console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); + return { + ...banner, + imageUrl: banner.imageUrl, + productIds: banner.productIds || [], + }; + } + }) + ); - // Convert S3 keys to signed URLs for client - const bannersWithSignedUrls = await Promise.all( - banners.map(async (banner) => { - try { - return { - ...banner, - imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl, - // Ensure productIds is always an array - productIds: banner.productIds || [], - }; - } catch (error) { - console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); - return { - ...banner, - imageUrl: banner.imageUrl, // Keep original on error - // Ensure productIds is always an array - productIds: banner.productIds || [], - }; - } - }) - ); - - return { - banners: bannersWithSignedUrls, - }; - } - catch(e:any) { + return { + banners: bannersWithSignedUrls, + }; + } catch (e: any) { console.log(e) - throw new ApiError(e.message); } }), @@ -56,23 +45,17 @@ export const bannerRouter = router({ getBanner: protectedProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { - const banner = await db.query.homeBanners.findFirst({ - where: eq(homeBanners.id, input.id), - // Removed product relationship since we now use productIds array - }); + const banner = await bannerDbService.getBannerById(input.id) if (banner) { try { - // Convert S3 key to signed URL for client if (banner.imageUrl) { banner.imageUrl = scaffoldAssetUrl(banner.imageUrl); } } catch (error) { console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); - // Keep original imageUrl on error } - // Ensure productIds is always an array (handle migration compatibility) if (!banner.productIds) { banner.productIds = []; } @@ -89,29 +72,27 @@ export const bannerRouter = router({ description: z.string().optional(), productIds: z.array(z.number()).optional(), redirectUrl: z.string().url().optional(), - // serialNum removed completely })) .mutation(async ({ input }) => { try { const imageUrl = extractKeyFromPresignedUrl(input.imageUrl) - // const imageUrl = input.imageUrl - const [banner] = await db.insert(homeBanners).values({ + + const banner = await bannerDbService.createBanner({ name: input.name, imageUrl: imageUrl, description: input.description, productIds: input.productIds || [], redirectUrl: input.redirectUrl, - serialNum: 999, // Default value, not used - isActive: false, // Default to inactive - }).returning(); + serialNum: 999, + isActive: false, + }) - // Reinitialize stores to reflect changes scheduleStoreInitialization() return banner; } catch (error) { console.error('Error creating banner:', error); - throw error; // Re-throw to maintain tRPC error handling + throw error; } }), @@ -129,30 +110,20 @@ export const bannerRouter = router({ })) .mutation(async ({ input }) => { try { - const { id, ...updateData } = input; - const incomingProductIds = input.productIds; - // Extract S3 key from presigned URL if imageUrl is provided - const processedData = { - ...updateData, - ...(updateData.imageUrl && { - imageUrl: extractKeyFromPresignedUrl(updateData.imageUrl) - }), - }; - - // Handle serialNum null case - const finalData: any = { ...processedData }; - if ('serialNum' in finalData && finalData.serialNum === null) { - // Set to null explicitly - finalData.serialNum = null; + + const processedData: any = { ...updateData } + + if (updateData.imageUrl) { + processedData.imageUrl = extractKeyFromPresignedUrl(updateData.imageUrl) } - const [banner] = await db.update(homeBanners) - .set({ ...finalData, lastUpdated: new Date(), }) - .where(eq(homeBanners.id, id)) - .returning(); + if ('serialNum' in processedData && processedData.serialNum === null) { + processedData.serialNum = null; + } + + const banner = await bannerDbService.updateBannerById(id, processedData) - // Reinitialize stores to reflect changes scheduleStoreInitialization() return banner; @@ -166,9 +137,8 @@ export const bannerRouter = router({ deleteBanner: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { - await db.delete(homeBanners).where(eq(homeBanners.id, input.id)); + await bannerDbService.deleteBannerById(input.id) - // Reinitialize stores to reflect changes scheduleStoreInitialization() return { success: true }; diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/complaint.ts b/apps/backend/src/trpc/apis/admin-apis/apis/complaint.ts index b1791a6..8aae073 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/complaint.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/complaint.ts @@ -1,9 +1,7 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; -import { db } from '@/src/db/db_index' -import { complaints, users } from '@/src/db/schema' -import { eq, desc, lt, and } from 'drizzle-orm'; import { scaffoldAssetUrl } from '@/src/lib/s3-client' +import { complaintDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' export const complaintRouter = router({ getAll: protectedProcedure @@ -14,27 +12,7 @@ export const complaintRouter = router({ .query(async ({ input }) => { const { cursor, limit } = input; - let whereCondition = cursor - ? lt(complaints.id, cursor) - : undefined; - - const complaintsData = await db - .select({ - id: complaints.id, - complaintBody: complaints.complaintBody, - userId: complaints.userId, - orderId: complaints.orderId, - isResolved: complaints.isResolved, - createdAt: complaints.createdAt, - userName: users.name, - userMobile: users.mobile, - images: complaints.images, - }) - .from(complaints) - .leftJoin(users, eq(complaints.userId, users.id)) - .where(whereCondition) - .orderBy(desc(complaints.id)) - .limit(limit + 1); + const complaintsData = await complaintDbService.getComplaints(cursor, limit); const hasMore = complaintsData.length > limit; const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData; @@ -70,11 +48,8 @@ export const complaintRouter = router({ resolve: protectedProcedure .input(z.object({ id: z.string(), response: z.string().optional() })) .mutation(async ({ input }) => { - await db - .update(complaints) - .set({ isResolved: true, response: input.response }) - .where(eq(complaints.id, parseInt(input.id))); + await complaintDbService.resolveComplaint(parseInt(input.id), input.response); return { message: 'Complaint resolved successfully' }; }), -}); \ No newline at end of file +}); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/const.ts b/apps/backend/src/trpc/apis/admin-apis/apis/const.ts index a426087..98817b0 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/const.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/const.ts @@ -1,15 +1,13 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; -import { db } from '@/src/db/db_index' -import { keyValStore } from '@/src/db/schema' import { computeConstants } from '@/src/lib/const-store' import { CONST_KEYS } from '@/src/lib/const-keys' +import { constantDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' export const constRouter = router({ getConstants: protectedProcedure .query(async () => { - - const constants = await db.select().from(keyValStore); + const constants = await constantDbService.getAllConstants(); const resp = constants.map(c => ({ key: c.key, @@ -38,24 +36,15 @@ export const constRouter = router({ throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`); } - await db.transaction(async (tx) => { - for (const { key, value } of constants) { - await tx.insert(keyValStore) - .values({ key, value }) - .onConflictDoUpdate({ - target: keyValStore.key, - set: { value }, - }); - } - }); + const updatedCount = await constantDbService.upsertConstants(constants); // Refresh all constants in Redis after database update await computeConstants(); return { success: true, - updatedCount: constants.length, + updatedCount, keys: constants.map(c => c.key), }; }), -}); \ No newline at end of file +}); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts b/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts index 4eb3017..0210cac 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/coupon.ts @@ -1,9 +1,7 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; -import { db } from '@/src/db/db_index' -import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema' -import { eq, and, like, or, inArray, lt } from 'drizzle-orm'; import dayjs from 'dayjs'; +import { couponDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' const createCouponBodySchema = z.object({ couponCode: z.string().optional(), @@ -51,10 +49,7 @@ export const couponRouter = router({ // If applicableUsers is provided, verify users exist if (applicableUsers && applicableUsers.length > 0) { - const existingUsers = await db.query.users.findMany({ - where: inArray(users.id, applicableUsers), - columns: { id: true }, - }); + const existingUsers = await couponDbService.getUsersByIds(applicableUsers); if (existingUsers.length !== applicableUsers.length) { throw new Error("Some applicable users not found"); } @@ -69,56 +64,40 @@ export const couponRouter = router({ // Generate coupon code if not provided let finalCouponCode = couponCode; if (!finalCouponCode) { - // Generate a unique coupon code const timestamp = Date.now().toString().slice(-6); const random = Math.random().toString(36).substring(2, 8).toUpperCase(); finalCouponCode = `MF${timestamp}${random}`; } // Check if coupon code already exists - const existingCoupon = await db.query.coupons.findFirst({ - where: eq(coupons.couponCode, finalCouponCode), - }); - + const existingCoupon = await couponDbService.getCouponByCode(finalCouponCode); if (existingCoupon) { throw new Error("Coupon code already exists"); } - const result = await db.insert(coupons).values({ - couponCode: finalCouponCode, - isUserBased: isUserBased || false, - discountPercent: discountPercent?.toString(), - flatDiscount: flatDiscount?.toString(), - minOrder: minOrder?.toString(), - productIds: productIds || null, - createdBy: staffUserId, - maxValue: maxValue?.toString(), - isApplyForAll: isApplyForAll || false, - validTill: validTill ? dayjs(validTill).toDate() : undefined, - maxLimitForUser: maxLimitForUser, - exclusiveApply: exclusiveApply || false, - }).returning(); - - const coupon = result[0]; + const coupon = await couponDbService.createCoupon({ + couponCode: finalCouponCode, + isUserBased: isUserBased || false, + discountPercent: discountPercent?.toString() || null, + flatDiscount: flatDiscount?.toString() || null, + minOrder: minOrder?.toString() || null, + productIds: productIds || null, + createdBy: staffUserId, + maxValue: maxValue?.toString() || null, + isApplyForAll: isApplyForAll || false, + validTill: validTill ? dayjs(validTill).toDate() : null, + maxLimitForUser: maxLimitForUser || null, + exclusiveApply: exclusiveApply || false, + }); // Insert applicable users if (applicableUsers && applicableUsers.length > 0) { - await db.insert(couponApplicableUsers).values( - applicableUsers.map(userId => ({ - couponId: coupon.id, - userId, - })) - ); + await couponDbService.addApplicableUsers(coupon.id, applicableUsers); } // Insert applicable products if (applicableProducts && applicableProducts.length > 0) { - await db.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ - couponId: coupon.id, - productId, - })) - ); + await couponDbService.addApplicableProducts(coupon.id, applicableProducts); } return coupon; @@ -133,39 +112,7 @@ export const couponRouter = router({ .query(async ({ input }) => { const { cursor, limit, search } = input; - let whereCondition = undefined; - const conditions = []; - - if (cursor) { - conditions.push(lt(coupons.id, cursor)); - } - - if (search && search.trim()) { - conditions.push(like(coupons.couponCode, `%${search}%`)); - } - - if (conditions.length > 0) { - whereCondition = and(...conditions); - } - - const result = await db.query.coupons.findMany({ - where: whereCondition, - with: { - creator: true, - applicableUsers: { - with: { - user: true, - }, - }, - applicableProducts: { - with: { - product: true, - }, - }, - }, - orderBy: (coupons, { desc }) => [desc(coupons.createdAt)], - limit: limit + 1, - }); + const result = await couponDbService.getAllCoupons({ cursor, limit, search }); const hasMore = result.length > limit; const couponsList = hasMore ? result.slice(0, limit) : result; @@ -177,24 +124,7 @@ export const couponRouter = router({ getById: protectedProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { - const couponId = input.id; - - const result = await db.query.coupons.findFirst({ - where: eq(coupons.id, couponId), - with: { - creator: true, - applicableUsers: { - with: { - user: true, - }, - }, - applicableProducts: { - with: { - product: true, - }, - }, - }, - }); + const result = await couponDbService.getCouponById(input.id); if (!result) { throw new Error("Coupon not found"); @@ -225,27 +155,24 @@ export const couponRouter = router({ } } - // If updating to user-based, applicableUsers is required - if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) { - const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id)); - if (existingCount === 0) { - throw new Error("applicableUsers is required for user-based coupons"); - } + // If updating to user-based, applicableUsers is required + if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) { + const existingCount = await couponDbService.countApplicableUsers(id); + if (existingCount === 0) { + throw new Error("applicableUsers is required for user-based coupons"); } + } - // If applicableUsers is provided, verify users exist - if (updates.applicableUsers && updates.applicableUsers.length > 0) { - const existingUsers = await db.query.users.findMany({ - where: inArray(users.id, updates.applicableUsers), - columns: { id: true }, - }); - if (existingUsers.length !== updates.applicableUsers.length) { - throw new Error("Some applicable users not found"); - } - } + // If applicableUsers is provided, verify users exist + if (updates.applicableUsers && updates.applicableUsers.length > 0) { + const existingUsers = await couponDbService.getUsersByIds(updates.applicableUsers); + if (existingUsers.length !== updates.applicableUsers.length) { + throw new Error("Some applicable users not found"); + } + } const updateData: any = { ...updates }; - delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table + delete updateData.applicableUsers; if (updates.discountPercent !== undefined) { updateData.discountPercent = updates.discountPercent?.toString(); } @@ -255,67 +182,38 @@ export const couponRouter = router({ if (updates.minOrder !== undefined) { updateData.minOrder = updates.minOrder?.toString(); } - if (updates.maxValue !== undefined) { - updateData.maxValue = updates.maxValue?.toString(); - } - if (updates.validTill !== undefined) { - updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null; - } - - const result = await db.update(coupons) - .set(updateData) - .where(eq(coupons.id, id)) - .returning(); - - if (result.length === 0) { - throw new Error("Coupon not found"); + if (updates.maxValue !== undefined) { + updateData.maxValue = updates.maxValue?.toString(); } - - console.log('updated coupon successfully') + if (updates.validTill !== undefined) { + updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null; + } + + const result = await couponDbService.updateCoupon(id, updateData); // Update applicable users: delete existing and insert new if (updates.applicableUsers !== undefined) { - await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id)); + await couponDbService.removeAllApplicableUsers(id); if (updates.applicableUsers.length > 0) { - await db.insert(couponApplicableUsers).values( - updates.applicableUsers.map(userId => ({ - couponId: id, - userId, - })) - ); + await couponDbService.addApplicableUsers(id, updates.applicableUsers); } } // Update applicable products: delete existing and insert new if (updates.applicableProducts !== undefined) { - await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id)); + await couponDbService.removeAllApplicableProducts(id); if (updates.applicableProducts.length > 0) { - await db.insert(couponApplicableProducts).values( - updates.applicableProducts.map(productId => ({ - couponId: id, - productId, - })) - ); + await couponDbService.addApplicableProducts(id, updates.applicableProducts); } } - return result[0]; + return result; }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { - const { id } = input; - - const result = await db.update(coupons) - .set({ isInvalidated: true }) - .where(eq(coupons.id, id)) - .returning(); - - if (result.length === 0) { - throw new Error("Coupon not found"); - } - + await couponDbService.invalidateCoupon(input.id); return { message: "Coupon invalidated successfully" }; }), @@ -328,14 +226,9 @@ export const couponRouter = router({ return { valid: false, message: "Invalid coupon code" }; } - const coupon = await db.query.coupons.findFirst({ - where: and( - eq(coupons.couponCode, code.toUpperCase()), - eq(coupons.isInvalidated, false) - ), - }); + const coupon = await couponDbService.getCouponByCode(code.toUpperCase()); - if (!coupon) { + if (!coupon || coupon.isInvalidated) { return { valid: false, message: "Coupon not found or invalidated" }; } @@ -370,115 +263,71 @@ export const couponRouter = router({ discountAmount = maxValueLimit; } - return { - valid: true, - discountAmount, - coupon: { - id: coupon.id, - discountPercent: coupon.discountPercent, - flatDiscount: coupon.flatDiscount, - maxValue: coupon.maxValue, - } - }; - }), + return { + valid: true, + discountAmount, + coupon: { + id: coupon.id, + discountPercent: coupon.discountPercent, + flatDiscount: coupon.flatDiscount, + maxValue: coupon.maxValue, + } + }; + }), - generateCancellationCoupon: protectedProcedure - .input( - z.object({ - orderId: z.number(), - }) - ) - .mutation(async ({ input, ctx }) => { - const { orderId } = input; + generateCancellationCoupon: protectedProcedure + .input(z.object({ orderId: z.number() })) + .mutation(async ({ input, ctx }) => { + const { orderId } = input; - // Get staff user ID from auth middleware - const staffUserId = ctx.staffUser?.id; - if (!staffUserId) { - throw new Error("Unauthorized"); - } + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } - // Find the order with user and order status information - const order = await db.query.orders.findFirst({ - where: eq(orders.id, orderId), - with: { - user: true, - orderStatus: true, - }, - }); + const order = await couponDbService.getOrderByIdWithUserAndStatus(orderId); - if (!order) { - throw new Error("Order not found"); - } + if (!order) { + throw new Error("Order not found"); + } - // Check if order is cancelled (check if any status entry has isCancelled: true) - // const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false; - // if (!isOrderCancelled) { - // throw new Error("Order is not cancelled"); - // } + if (!order.user) { + throw new Error("User not found for this order"); + } - // // Check if payment method is COD - // if (order.isCod) { - // throw new Error("Can't generate refund coupon for CoD Order"); - // } + const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase(); + const couponCode = `${userNamePrefix}${orderId}`; - // Verify user exists - if (!order.user) { - throw new Error("User not found for this order"); - } + const existingCoupon = await couponDbService.getCouponByCode(couponCode); + if (existingCoupon) { + throw new Error("Coupon code already exists"); + } - // Generate coupon code: first 3 letters of user name or mobile + orderId - const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase(); - const couponCode = `${userNamePrefix}${orderId}`; + const orderAmount = parseFloat(order.totalAmount); + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + 30); - // Check if coupon code already exists - const existingCoupon = await db.query.coupons.findFirst({ - where: eq(coupons.couponCode, couponCode), - }); + const coupon = await couponDbService.withTransaction(async (tx) => { + const newCoupon = await couponDbService.createCoupon({ + couponCode, + isUserBased: true, + flatDiscount: orderAmount.toString(), + minOrder: orderAmount.toString(), + maxValue: orderAmount.toString(), + validTill: expiryDate, + maxLimitForUser: 1, + createdBy: staffUserId, + isApplyForAll: false, + }); - if (existingCoupon) { - throw new Error("Coupon code already exists"); - } + await couponDbService.addApplicableUsers(newCoupon.id, [order.userId]); + await couponDbService.updateOrderStatusRefundCoupon(orderId, newCoupon.id); - // Get order total amount - const orderAmount = parseFloat(order.totalAmount); + return newCoupon; + }); - // Calculate expiry date (30 days from now) - const expiryDate = new Date(); - expiryDate.setDate(expiryDate.getDate() + 30); - - // Create the coupon and update order status in a transaction - const coupon = await db.transaction(async (tx) => { - // Create the coupon - const result = await tx.insert(coupons).values({ - couponCode, - isUserBased: true, - flatDiscount: orderAmount.toString(), - minOrder: orderAmount.toString(), - maxValue: orderAmount.toString(), - validTill: expiryDate, - maxLimitForUser: 1, - createdBy: staffUserId, - isApplyForAll: false, - }).returning(); - - const coupon = result[0]; - - // Insert applicable users - await tx.insert(couponApplicableUsers).values({ - couponId: coupon.id, - userId: order.userId, - }); - - // Update order_status with refund coupon ID - await tx.update(orderStatus) - .set({ refundCouponId: coupon.id }) - .where(eq(orderStatus.orderId, orderId)); - - return coupon; - }); - - return coupon; - }), + return coupon; + }), getReservedCoupons: protectedProcedure .input(z.object({ @@ -487,100 +336,52 @@ export const couponRouter = router({ search: z.string().optional(), })) .query(async ({ input }) => { - const { cursor, limit, search } = input; - - let whereCondition = undefined; - const conditions = []; + const result = await couponDbService.getReservedCoupons(input); - if (cursor) { - conditions.push(lt(reservedCoupons.id, cursor)); - } - - if (search && search.trim()) { - conditions.push(or( - like(reservedCoupons.secretCode, `%${search}%`), - like(reservedCoupons.couponCode, `%${search}%`) - )); - } - - if (conditions.length > 0) { - whereCondition = and(...conditions); - } - - const result = await db.query.reservedCoupons.findMany({ - where: whereCondition, - with: { - redeemedUser: true, - creator: true, - }, - orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)], - limit: limit + 1, // Fetch one extra to check if there's more - }); - - const hasMore = result.length > limit; - const coupons = hasMore ? result.slice(0, limit) : result; + const hasMore = result.length > input.limit; + const coupons = hasMore ? result.slice(0, input.limit) : result; const nextCursor = hasMore ? result[result.length - 1].id : undefined; - return { - coupons, - nextCursor, - }; + return { coupons, nextCursor }; }), createReservedCoupon: protectedProcedure .input(createCouponBodySchema) .mutation(async ({ input, ctx }) => { - const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; + const { couponCode, discountPercent, flatDiscount, minOrder, productIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input; - // Validation: ensure at least one discount type is provided if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { throw new Error("Either discountPercent or flatDiscount must be provided (but not both)"); } - // For reserved coupons, applicableUsers is not used, as it's redeemed by one user - - // Get staff user ID from auth middleware const staffUserId = ctx.staffUser?.id; if (!staffUserId) { throw new Error("Unauthorized"); } - // Generate secret code if not provided (use couponCode as base) let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`; - // Check if secret code already exists - const existing = await db.query.reservedCoupons.findFirst({ - where: eq(reservedCoupons.secretCode, secretCode), - }); - + const existing = await couponDbService.getCouponByCode(secretCode); if (existing) { throw new Error("Secret code already exists"); } - const result = await db.insert(reservedCoupons).values({ + const coupon = await couponDbService.createReservedCoupon({ secretCode, couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`, - discountPercent: discountPercent?.toString(), - flatDiscount: flatDiscount?.toString(), - minOrder: minOrder?.toString(), - productIds, - maxValue: maxValue?.toString(), - validTill: validTill ? dayjs(validTill).toDate() : undefined, - maxLimitForUser, + discountPercent: discountPercent?.toString() || null, + flatDiscount: flatDiscount?.toString() || null, + minOrder: minOrder?.toString() || null, + productIds: productIds || null, + maxValue: maxValue?.toString() || null, + validTill: validTill ? dayjs(validTill).toDate() : null, + maxLimitForUser: maxLimitForUser || null, exclusiveApply: exclusiveApply || false, createdBy: staffUserId, - }).returning(); + }); - const coupon = result[0]; - - // Insert applicable products if provided if (applicableProducts && applicableProducts.length > 0) { - await db.insert(couponApplicableProducts).values( - applicableProducts.map(productId => ({ - couponId: coupon.id, - productId, - })) - ); + await couponDbService.addApplicableProducts(coupon.id, applicableProducts); } return coupon; @@ -593,27 +394,11 @@ export const couponRouter = router({ offset: z.number().min(0).default(0), })) .query(async ({ input }) => { - const { search, limit } = input; + const { search, limit, offset } = input; - let whereCondition = undefined; - if (search && search.trim()) { - whereCondition = or( - like(users.name, `%${search}%`), - like(users.mobile, `%${search}%`) - ); - } - - const userList = await db.query.users.findMany({ - where: whereCondition, - columns: { - id: true, - name: true, - mobile: true, - }, - limit: limit, - offset: input.offset, - orderBy: (users, { asc }) => [asc(users.name)], - }); + const userList = search + ? await couponDbService.getUsersBySearch(search, limit, offset) + : await couponDbService.getUsersByIds([]); return { users: userList.map(user => ({ @@ -624,88 +409,68 @@ export const couponRouter = router({ }; }), - createCoupon: protectedProcedure - .input(z.object({ - mobile: z.string().min(1, 'Mobile number is required'), - })) - .mutation(async ({ input, ctx }) => { - const { mobile } = input; + createCoupon: protectedProcedure + .input(z.object({ mobile: z.string().min(1, 'Mobile number is required') })) + .mutation(async ({ input, ctx }) => { + const { mobile } = input; - // Get staff user ID from auth middleware - const staffUserId = ctx.staffUser?.id; - if (!staffUserId) { - throw new Error("Unauthorized"); - } + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } - // Clean mobile number (remove non-digits) - const cleanMobile = mobile.replace(/\D/g, ''); + const cleanMobile = mobile.replace(/\D/g, ''); - // Validate: exactly 10 digits - if (cleanMobile.length !== 10) { - throw new Error("Mobile number must be exactly 10 digits"); - } + if (cleanMobile.length !== 10) { + throw new Error("Mobile number must be exactly 10 digits"); + } - // Check if user exists, create if not - let user = await db.query.users.findFirst({ - where: eq(users.mobile, cleanMobile), + let user = await couponDbService.getUserByMobile(cleanMobile); + + if (!user) { + user = await couponDbService.createUser({ + name: null, + email: null, + mobile: cleanMobile, }); + } - if (!user) { - // Create new user - const [newUser] = await db.insert(users).values({ - name: null, - email: null, - mobile: cleanMobile, - }).returning(); - user = newUser; - } + const timestamp = Date.now().toString().slice(-6); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`; - // Generate unique coupon code - const timestamp = Date.now().toString().slice(-6); - const random = Math.random().toString(36).substring(2, 6).toUpperCase(); - const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`; + const existingCode = await couponDbService.getCouponByCode(couponCode); + if (existingCode) { + throw new Error("Generated coupon code already exists - please try again"); + } - // Check if coupon code already exists (very unlikely but safe) - const existingCode = await db.query.coupons.findFirst({ - where: eq(coupons.couponCode, couponCode), - }); + const coupon = await couponDbService.createCoupon({ + couponCode, + isUserBased: true, + discountPercent: "20", + minOrder: "1000", + maxValue: "500", + maxLimitForUser: 1, + isApplyForAll: false, + exclusiveApply: false, + createdBy: staffUserId, + validTill: dayjs().add(90, 'days').toDate(), + }); - if (existingCode) { - throw new Error("Generated coupon code already exists - please try again"); - } + await couponDbService.addApplicableUsers(coupon.id, [user.id]); - // Create the coupon - const [coupon] = await db.insert(coupons).values({ - couponCode, - isUserBased: true, - discountPercent: "20", // 20% discount - minOrder: "1000", // ₹1000 minimum order - maxValue: "500", // ₹500 maximum discount - maxLimitForUser: 1, // One-time use - isApplyForAll: false, - exclusiveApply: false, - createdBy: staffUserId, - validTill: dayjs().add(90, 'days').toDate(), // 90 days from now - }).returning(); - - // Associate coupon with user - await db.insert(couponApplicableUsers).values({ - couponId: coupon.id, + return { + success: true, + coupon: { + id: coupon.id, + couponCode: coupon.couponCode, userId: user.id, - }); - - return { - success: true, - coupon: { - id: coupon.id, - couponCode: coupon.couponCode, - userId: user.id, - userMobile: user.mobile, - discountPercent: 20, - minOrder: 1000, - maxValue: 500, - maxLimitForUser: 1, - }, - }; - }), + userMobile: user.mobile, + discountPercent: 20, + minOrder: 1000, + maxValue: 500, + maxLimitForUser: 1, + }, + }; + }), }); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/order.ts b/apps/backend/src/trpc/apis/admin-apis/apis/order.ts index 707f642..b910d4a 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/order.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/order.ts @@ -1,28 +1,15 @@ -import { router, protectedProcedure } from "@/src/trpc/trpc-index" -import { z } from "zod"; -import { db } from "@/src/db/db_index" -import { - orders, - orderItems, - orderStatus, - users, - addresses, - refunds, - coupons, - couponUsage, - complaints, - payments, -} from "@/src/db/schema"; -import { eq, and, gte, lt, desc, SQL, inArray } from "drizzle-orm"; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import { ApiError } from "@/src/lib/api-error" +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import { ApiError } from '@/src/lib/api-error' import { sendOrderPackagedNotification, sendOrderDeliveredNotification, -} from "@/src/lib/notif-job"; -import { publishCancellation } from "@/src/lib/post-order-handler" -import { getMultipleUserNegativityScores } from "@/src/stores/user-negativity-store" +} from '@/src/lib/notif-job' +import { publishCancellation } from '@/src/lib/post-order-handler' +import { getMultipleUserNegativityScores } from '@/src/stores/user-negativity-store' +import { orderDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' const updateOrderNotesSchema = z.object({ orderId: z.number(), @@ -89,19 +76,13 @@ export const orderRouter = router({ .mutation(async ({ input }) => { const { orderId, adminNotes } = input; - const result = await db - .update(orders) - .set({ - adminNotes: adminNotes || null, - }) - .where(eq(orders.id, orderId)) - .returning(); + const result = await orderDbService.updateOrderNotes(orderId, adminNotes || null) - if (result.length === 0) { + if (!result) { throw new Error("Order not found"); } - return result[0]; + return result; }), getFullOrder: protectedProcedure @@ -109,34 +90,14 @@ export const orderRouter = router({ .query(async ({ input }) => { const { orderId } = input; - const orderData = await db.query.orders.findFirst({ - where: eq(orders.id, orderId), - with: { - user: true, - address: true, - slot: true, - orderItems: { - with: { - product: { - with: { - unit: true, - }, - }, - }, - }, - payment: true, - paymentInfo: true, - }, - }); + const orderData = await orderDbService.getOrderWithRelations(orderId) if (!orderData) { throw new Error("Order not found"); } // Get order status separately - const statusRecord = await db.query.orderStatus.findFirst({ - where: eq(orderStatus.orderId, orderId), - }); + const statusRecord = await orderDbService.getOrderStatusByOrderId(orderId) let status: "pending" | "delivered" | "cancelled" = "pending"; if (statusRecord?.isCancelled) { @@ -148,9 +109,7 @@ export const orderRouter = router({ // Get refund details if order is cancelled let refund = null; if (status === "cancelled") { - refund = await db.query.refunds.findFirst({ - where: eq(refunds.orderId, orderId), - }); + refund = await orderDbService.getRefundByOrderId(orderId) } return { @@ -220,39 +179,14 @@ export const orderRouter = router({ const { orderId } = input; // Single optimized query with all relations - const orderData = await db.query.orders.findFirst({ - where: eq(orders.id, orderId), - with: { - user: true, - address: true, - slot: true, - orderItems: { - with: { - product: { - with: { - unit: true, - }, - }, - }, - }, - payment: true, - paymentInfo: true, - orderStatus: true, // Include in main query - refunds: true, // Include in main query - }, - }); + const orderData = await orderDbService.getOrderWithDetails(orderId) if (!orderData) { 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, orderData.id), // Use new orderId field - with: { - coupon: true, - }, - }); + const couponUsageData = await orderDbService.getCouponUsageByOrderId(orderData.id) let couponData = null; if (couponUsageData.length > 0) { @@ -388,27 +322,15 @@ export const orderRouter = router({ const { orderId, isPackaged } = input; // Update all order items to the specified packaged state - await db - .update(orderItems) - .set({ is_packaged: isPackaged }) - .where(eq(orderItems.orderId, parseInt(orderId))); + const parsedOrderId = parseInt(orderId) + await orderDbService.updateOrderItemsPackaged(parsedOrderId, isPackaged) - // Also update the order status table for backward compatibility - if (!isPackaged) { - await db - .update(orderStatus) - .set({ isPackaged, isDelivered: false }) - .where(eq(orderStatus.orderId, parseInt(orderId))); - } else { - await db - .update(orderStatus) - .set({ isPackaged }) - .where(eq(orderStatus.orderId, parseInt(orderId))); - } + const currentStatus = await orderDbService.getOrderStatusByOrderId(parsedOrderId) + const isDelivered = !isPackaged ? false : currentStatus?.isDelivered || false - const order = await db.query.orders.findFirst({ - where: eq(orders.id, parseInt(orderId)), - }); + await orderDbService.updateOrderStatusPackaged(parsedOrderId, isPackaged, isDelivered) + + const order = await orderDbService.getOrderById(parsedOrderId) if (order) await sendOrderPackagedNotification(order.userId, orderId); return { success: true }; @@ -419,14 +341,10 @@ export const orderRouter = router({ .mutation(async ({ input }) => { const { orderId, isDelivered } = input; - await db - .update(orderStatus) - .set({ isDelivered }) - .where(eq(orderStatus.orderId, parseInt(orderId))); + const parsedOrderId = parseInt(orderId) + await orderDbService.updateOrderStatusDelivered(parsedOrderId, isDelivered) - const order = await db.query.orders.findFirst({ - where: eq(orders.id, parseInt(orderId)), - }); + const order = await orderDbService.getOrderById(parsedOrderId) if (order) await sendOrderDeliveredNotification(order.userId, orderId); return { success: true }; @@ -438,9 +356,7 @@ export const orderRouter = router({ const { orderItemId, isPackaged, isPackageVerified } = input; // Validate that orderItem exists - const orderItem = await db.query.orderItems.findFirst({ - where: eq(orderItems.id, orderItemId), - }); + const orderItem = await orderDbService.getOrderItemById(orderItemId) if (!orderItem) { throw new ApiError("Order item not found", 404); @@ -456,10 +372,7 @@ export const orderRouter = router({ } // Update the order item - await db - .update(orderItems) - .set(updateData) - .where(eq(orderItems.id, orderItemId)); + await orderDbService.updateOrderItem(orderItemId, updateData) return { success: true }; }), @@ -469,9 +382,7 @@ export const orderRouter = router({ .mutation(async ({ input }) => { const { orderId } = input; - const order = await db.query.orders.findFirst({ - where: eq(orders.id, orderId), - }); + const order = await orderDbService.getOrderById(orderId) if (!order) { throw new Error('Order not found'); @@ -481,13 +392,7 @@ export const orderRouter = router({ const currentTotalAmount = parseFloat(order.totalAmount?.toString() || '0'); const newTotalAmount = currentTotalAmount - currentDeliveryCharge; - await db - .update(orders) - .set({ - deliveryCharge: '0', - totalAmount: newTotalAmount.toString() - }) - .where(eq(orders.id, orderId)); + await orderDbService.removeDeliveryCharge(orderId, newTotalAmount.toString()) return { success: true, message: 'Delivery charge removed' }; }), @@ -497,27 +402,10 @@ export const orderRouter = router({ .query(async ({ input }) => { const { slotId } = input; - const slotOrders = await db.query.orders.findMany({ - where: eq(orders.slotId, parseInt(slotId)), - with: { - user: true, - address: true, - slot: true, - orderItems: { - with: { - product: { - with: { - unit: true, - }, - }, - }, - }, - orderStatus: true, - }, - }); + const slotOrders = await orderDbService.getOrdersBySlotId(parseInt(slotId)) const filteredOrders = slotOrders.filter((order) => { - const statusRecord = order.orderStatus[0]; + const statusRecord = order.orderStatus?.[0]; return ( order.isCod || (statusRecord && statusRecord.paymentStatus === "success") @@ -525,7 +413,7 @@ export const orderRouter = router({ }); const formattedOrders = filteredOrders.map((order) => { - const statusRecord = order.orderStatus[0]; // assuming one status per order + const statusRecord = order.orderStatus?.[0]; // assuming one status per order let status: "pending" | "delivered" | "cancelled" = "pending"; if (statusRecord?.isCancelled) { status = "cancelled"; @@ -582,39 +470,14 @@ export const orderRouter = router({ const start = dayjs().startOf("day").toDate(); const end = dayjs().endOf("day").toDate(); - let whereCondition = and( - gte(orders.createdAt, start), - lt(orders.createdAt, end) - ); - - if (slotId) { - whereCondition = and( - whereCondition, - eq(orders.slotId, parseInt(slotId)) - ); - } - - const todaysOrders = await db.query.orders.findMany({ - where: whereCondition, - with: { - user: true, - address: true, - slot: true, - orderItems: { - with: { - product: { - with: { - unit: true, - }, - }, - }, - }, - orderStatus: true, - }, - }); + const todaysOrders = await orderDbService.getOrdersByDateRange( + start, + end, + slotId ? parseInt(slotId) : undefined + ) const filteredOrders = todaysOrders.filter((order) => { - const statusRecord = order.orderStatus[0]; + const statusRecord = order.orderStatus?.[0]; return ( order.isCod || (statusRecord && statusRecord.paymentStatus === "success") @@ -622,7 +485,7 @@ export const orderRouter = router({ }); const formattedOrders = filteredOrders.map((order) => { - const statusRecord = order.orderStatus[0]; // assuming one status per order + const statusRecord = order.orderStatus?.[0]; // assuming one status per order let status: "pending" | "delivered" | "cancelled" = "pending"; if (statusRecord?.isCancelled) { status = "cancelled"; @@ -677,16 +540,9 @@ export const orderRouter = router({ .mutation(async ({ input }) => { const { addressId, latitude, longitude } = input; - const result = await db - .update(addresses) - .set({ - adminLatitude: latitude, - adminLongitude: longitude, - }) - .where(eq(addresses.id, addressId)) - .returning(); + const result = await orderDbService.updateAddressCoords(addressId, latitude, longitude) - if (result.length === 0) { + if (!result) { throw new ApiError("Address not found", 404); } @@ -707,78 +563,15 @@ export const orderRouter = router({ flashDeliveryFilter, } = input; - let whereCondition: SQL | undefined = eq(orders.id, orders.id); // always true - if (cursor) { - whereCondition = and(whereCondition, lt(orders.id, cursor)); - } - if (slotId) { - whereCondition = and(whereCondition, eq(orders.slotId, slotId)); - } - if (packagedFilter === "packaged") { - whereCondition = and( - whereCondition, - eq(orderStatus.isPackaged, true) - ); - } else if (packagedFilter === "not_packaged") { - whereCondition = and( - whereCondition, - eq(orderStatus.isPackaged, false) - ); - } - if (deliveredFilter === "delivered") { - whereCondition = and( - whereCondition, - eq(orderStatus.isDelivered, true) - ); - } else if (deliveredFilter === "not_delivered") { - whereCondition = and( - whereCondition, - eq(orderStatus.isDelivered, false) - ); - } - if (cancellationFilter === "cancelled") { - whereCondition = and( - whereCondition, - eq(orderStatus.isCancelled, true) - ); - } else if (cancellationFilter === "not_cancelled") { - whereCondition = and( - whereCondition, - eq(orderStatus.isCancelled, false) - ); - } - if (flashDeliveryFilter === "flash") { - whereCondition = and( - whereCondition, - eq(orders.isFlashDelivery, true) - ); - } else if (flashDeliveryFilter === "regular") { - whereCondition = and( - whereCondition, - eq(orders.isFlashDelivery, false) - ); - } - - const allOrders = await db.query.orders.findMany({ - where: whereCondition, - orderBy: desc(orders.createdAt), - limit: limit + 1, // fetch one extra to check if there's more - with: { - user: true, - address: true, - slot: true, - orderItems: { - with: { - product: { - with: { - unit: true, - }, - }, - }, - }, - orderStatus: true, - }, - }); + const allOrders = await orderDbService.getAllOrdersWithFilters({ + cursor, + limit, + slotId, + packagedFilter, + deliveredFilter, + cancellationFilter, + flashDeliveryFilter, + }) const hasMore = allOrders.length > limit; const ordersToReturn = hasMore ? allOrders.slice(0, limit) : allOrders; @@ -787,7 +580,7 @@ export const orderRouter = router({ const negativityScores = await getMultipleUserNegativityScores(userIds); const filteredOrders = ordersToReturn.filter((order) => { - const statusRecord = order.orderStatus[0]; + const statusRecord = order.orderStatus?.[0]; return ( order.isCod || (statusRecord && statusRecord.paymentStatus === "success") @@ -795,7 +588,7 @@ export const orderRouter = router({ }); const formattedOrders = filteredOrders.map((order) => { - const statusRecord = order.orderStatus[0]; + const statusRecord = order.orderStatus?.[0]; let status: "pending" | "delivered" | "cancelled" = "pending"; if (statusRecord?.isCancelled) { status = "cancelled"; @@ -868,21 +661,7 @@ export const orderRouter = router({ .mutation(async ({ input }) => { const slotIds = input.slotIds; - const ordersList = await db.query.orders.findMany({ - where: inArray(orders.slotId, slotIds), - with: { - orderItems: { - with: { - product: true - } - }, - couponUsages: { - with: { - coupon: true - } - }, - } - }); + const ordersList = await orderDbService.getOrdersBySlotIds(slotIds) const processedOrdersData = ordersList.map((order) => { @@ -921,19 +700,19 @@ export const orderRouter = router({ }) const updatedOrderIds: number[] = []; - await db.transaction(async (tx) => { - for (const { order, updatedOrderItems, newTotal } of processedOrdersData) { - await tx.update(orders).set({ totalAmount: newTotal.toString() }).where(eq(orders.id, order.id)); - updatedOrderIds.push(order.id); - - for (const item of updatedOrderItems) { - await tx.update(orderItems).set({ + await orderDbService.updateOrdersAndItemsInTransaction( + processedOrdersData.map((entry) => ({ + orderId: entry.order.id, + totalAmount: entry.newTotal.toString(), + items: entry.updatedOrderItems.map((item) => ({ + id: item.id, price: item.price, - discountedPrice: item.discountedPrice - }).where(eq(orderItems.id, item.id)); - } - } - }); + discountedPrice: item.discountedPrice || item.price, + })), + })) + ) + + processedOrdersData.forEach((entry) => updatedOrderIds.push(entry.order.id)) return { success: true, updatedOrders: updatedOrderIds, message: `Rebalanced ${updatedOrderIds.length} orders.` }; }), @@ -946,12 +725,7 @@ export const orderRouter = router({ .mutation(async ({ input }) => { const { orderId, reason } = input; - const order = await db.query.orders.findFirst({ - where: eq(orders.id, orderId), - with: { - orderStatus: true, - }, - }); + const order = await orderDbService.getOrderWithStatus(orderId) if (!order) { throw new ApiError("Order not found", 404); @@ -970,28 +744,13 @@ export const orderRouter = router({ throw new ApiError("Cannot cancel delivered order", 400); } - const result = await db.transaction(async (tx) => { - await tx - .update(orderStatus) - .set({ - isCancelled: true, - isCancelledByAdmin: true, - cancelReason: reason, - cancellationAdminNotes: reason, - cancellationReviewed: true, - cancellationReviewedAt: new Date(), - }) - .where(eq(orderStatus.id, status.id)); + await orderDbService.cancelOrderStatus(status.id, reason) - const refundStatus = order.isCod ? "na" : "pending"; + const refundStatus = order.isCod ? 'na' : 'pending' - await tx.insert(refunds).values({ - orderId: order.id, - refundStatus, - }); + await orderDbService.createRefund(order.id, refundStatus) - return { orderId: order.id, userId: order.userId }; - }); + const result = { orderId: order.id, userId: order.userId } // Publish to Redis for Telegram notification await publishCancellation(result.orderId, 'admin', reason); @@ -1005,14 +764,5 @@ export const orderRouter = router({ type RefundStatus = "success" | "pending" | "failed" | "none" | "na"; export async function deleteOrderById(orderId: number): Promise { - await db.transaction(async (tx) => { - await tx.delete(orderItems).where(eq(orderItems.orderId, orderId)); - await tx.delete(orderStatus).where(eq(orderStatus.orderId, orderId)); - await tx.delete(payments).where(eq(payments.orderId, orderId)); - await tx.delete(refunds).where(eq(refunds.orderId, orderId)); - await tx.delete(couponUsage).where(eq(couponUsage.orderId, orderId)); - await tx.delete(complaints).where(eq(complaints.orderId, orderId)); - await tx.delete(orders).where(eq(orders.id, orderId)); - }); + await orderDbService.deleteOrderById(orderId) } - diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/payments.ts b/apps/backend/src/trpc/apis/admin-apis/apis/payments.ts index 51de5fb..a19b26e 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/payments.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/payments.ts @@ -1,15 +1,7 @@ import { router, protectedProcedure } from "@/src/trpc/trpc-index" import { z } from "zod"; -import { db } from "@/src/db/db_index" -import { - orders, - orderStatus, - payments, - refunds, -} from "@/src/db/schema"; -import { and, eq } from "drizzle-orm"; import { ApiError } from "@/src/lib/api-error" -import { RazorpayPaymentService } from "@/src/lib/payments-utils" +import { refundDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' const initiateRefundSchema = z .object({ @@ -37,18 +29,14 @@ export const adminPaymentsRouter = router({ const { orderId, refundPercent, refundAmount } = input; // Validate order exists - const order = await db.query.orders.findFirst({ - where: eq(orders.id, orderId), - }); + const order = await refundDbService.getOrderById(orderId); if (!order) { throw new ApiError("Order not found", 404); } // Check if order is paid - const orderStatusRecord = await db.query.orderStatus.findFirst({ - where: eq(orderStatus.orderId, orderId), - }); + const orderStatusRecord = await refundDbService.getOrderStatusByOrderId(orderId); if(order.isCod) { throw new ApiError("Order is a Cash On Delivery. Not eligible for refund") @@ -76,59 +64,36 @@ export const adminPaymentsRouter = router({ throw new ApiError("Invalid refund parameters", 400); } - let razorpayRefund = null; - let merchantRefundId = null; + let merchantRefundId = 'xxx'; //temporary suppressal - // Get payment record for online payments - const payment = await db.query.payments.findFirst({ - where: and( - eq(payments.orderId, orderId), - eq(payments.status, "success") - ), - }); + // Get payment record for online payments + const payment = await refundDbService.getSuccessfulPaymentByOrderId(orderId); - if (!payment || payment.status !== "success") { - throw new ApiError("Payment not found or not successful", 404); - } - - const payload = payment.payload as any; - // Initiate Razorpay refund - razorpayRefund = await RazorpayPaymentService.initiateRefund( - payload.payment_id, - Math.round(calculatedRefundAmount * 100) // Convert to paisa - ); - merchantRefundId = razorpayRefund.id; - - + if (!payment || payment.status !== "success") { + throw new ApiError("Payment not found or not successful", 404); + } // Check if refund already exists for this order - const existingRefund = await db.query.refunds.findFirst({ - where: eq(refunds.orderId, orderId), - }); + const existingRefund = await refundDbService.getRefundByOrderId(orderId); const refundStatus = "initiated"; if (existingRefund) { // Update existing refund - await db - .update(refunds) - .set({ - refundAmount: calculatedRefundAmount.toString(), - refundStatus, - merchantRefundId, - refundProcessedAt: order.isCod ? new Date() : null, - }) - .where(eq(refunds.id, existingRefund.id)); + await refundDbService.updateRefund(existingRefund.id, { + refundAmount: calculatedRefundAmount.toString(), + refundStatus, + merchantRefundId, + refundProcessedAt: order.isCod ? new Date() : null, + }); } else { // Insert new refund - await db - .insert(refunds) - .values({ - orderId, - refundAmount: calculatedRefundAmount.toString(), - refundStatus, - merchantRefundId, - }); + await refundDbService.createRefund({ + orderId, + refundAmount: calculatedRefundAmount.toString(), + refundStatus, + merchantRefundId, + }); } return { diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product-availability-schedules.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product-availability-schedules.ts index 47cdc43..ebfbbd3 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product-availability-schedules.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product-availability-schedules.ts @@ -1,9 +1,7 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; -import { db } from '@/src/db/db_index' -import { productAvailabilitySchedules } from '@/src/db/schema' -import { eq } from 'drizzle-orm'; import { refreshScheduleJobs } from '@/src/lib/automatedJobs'; +import { scheduleDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' const createScheduleSchema = z.object({ scheduleName: z.string().min(1, "Schedule name is required"), @@ -35,33 +33,29 @@ export const productAvailabilitySchedulesRouter = router({ } // Check if schedule name already exists - const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({ - where: eq(productAvailabilitySchedules.scheduleName, scheduleName), - }); + const existingSchedule = await scheduleDbService.getScheduleByName(scheduleName); if (existingSchedule) { throw new Error("Schedule name already exists"); } // Create schedule with arrays - const scheduleResult = await db.insert(productAvailabilitySchedules).values({ + const scheduleResult = await scheduleDbService.createSchedule({ scheduleName, time, action, productIds, groupIds, - }).returning(); + }); // Refresh cron jobs to include new schedule await refreshScheduleJobs(); - return scheduleResult[0]; + return scheduleResult; }), getAll: protectedProcedure .query(async () => { - const schedules = await db.query.productAvailabilitySchedules.findMany({ - orderBy: (productAvailabilitySchedules, { desc }) => [desc(productAvailabilitySchedules.createdAt)], - }); + const schedules = await scheduleDbService.getAllSchedules(); return schedules.map(schedule => ({ ...schedule, @@ -75,9 +69,7 @@ export const productAvailabilitySchedulesRouter = router({ .query(async ({ input }) => { const { id } = input; - const schedule = await db.query.productAvailabilitySchedules.findFirst({ - where: eq(productAvailabilitySchedules.id, id), - }); + const schedule = await scheduleDbService.getScheduleById(id); if (!schedule) { throw new Error("Schedule not found"); @@ -92,18 +84,14 @@ export const productAvailabilitySchedulesRouter = router({ const { id, updates } = input; // Check if schedule exists - const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({ - where: eq(productAvailabilitySchedules.id, id), - }); + const existingSchedule = await scheduleDbService.getScheduleById(id); if (!existingSchedule) { throw new Error("Schedule not found"); } // Check schedule name uniqueness if being updated if (updates.scheduleName && updates.scheduleName !== existingSchedule.scheduleName) { - const duplicateSchedule = await db.query.productAvailabilitySchedules.findFirst({ - where: eq(productAvailabilitySchedules.scheduleName, updates.scheduleName), - }); + const duplicateSchedule = await scheduleDbService.getScheduleByName(updates.scheduleName); if (duplicateSchedule) { throw new Error("Schedule name already exists"); } @@ -116,21 +104,13 @@ export const productAvailabilitySchedulesRouter = router({ if (updates.action !== undefined) updateData.action = updates.action; if (updates.productIds !== undefined) updateData.productIds = updates.productIds; if (updates.groupIds !== undefined) updateData.groupIds = updates.groupIds; - updateData.lastUpdated = new Date(); - const result = await db.update(productAvailabilitySchedules) - .set(updateData) - .where(eq(productAvailabilitySchedules.id, id)) - .returning(); - - if (result.length === 0) { - throw new Error("Failed to update schedule"); - } + const result = await scheduleDbService.updateSchedule(id, updateData); // Refresh cron jobs to reflect changes await refreshScheduleJobs(); - return result[0]; + return result; }), delete: protectedProcedure @@ -138,13 +118,7 @@ export const productAvailabilitySchedulesRouter = router({ .mutation(async ({ input }) => { const { id } = input; - const result = await db.delete(productAvailabilitySchedules) - .where(eq(productAvailabilitySchedules.id, id)) - .returning(); - - if (result.length === 0) { - throw new Error("Schedule not found"); - } + await scheduleDbService.deleteSchedule(id); // Refresh cron jobs to remove deleted schedule await refreshScheduleJobs(); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts index 4677c40..3f2fda4 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts @@ -1,12 +1,9 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; -import { db } from '@/src/db/db_index' -import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema' -import { eq, and, inArray, desc, sql } from 'drizzle-orm'; +import { productDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' import { ApiError } from '@/src/lib/api-error' -import { imageUploadS3, scaffoldAssetUrl, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client' +import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client' import { deleteS3Image } from '@/src/lib/delete-image' -import type { SpecialDeal } from '@/src/db/types' import { scheduleStoreInitialization } from '@/src/stores/store-initializer' @@ -19,13 +16,7 @@ type CreateDeal = { export const productRouter = router({ getProducts: protectedProcedure .query(async ({ ctx }) => { - const products = await db.query.productInfo.findMany({ - orderBy: productInfo.name, - with: { - unit: true, - store: true, - }, - }); + const products = await productDbService.getAllProducts(); // Generate signed URLs for all product images const productsWithSignedUrls = await Promise.all( @@ -48,30 +39,17 @@ export const productRouter = router({ .query(async ({ input, ctx }) => { const { id } = input; - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, id), - with: { - unit: true, - }, - }); + const product = await productDbService.getProductById(id); if (!product) { throw new ApiError("Product not found", 404); } // Fetch special deals for this product - const deals = await db.query.specialDeals.findMany({ - where: eq(specialDeals.productId, id), - orderBy: specialDeals.quantity, - }); + const deals = await productDbService.getDealsByProductId(id); // Fetch associated tags for this product - const productTagsData = await db.query.productTags.findMany({ - where: eq(productTags.productId, id), - with: { - tag: true, - }, - }); + const productTagsData = await productDbService.getTagsByProductId(id); // Generate signed URLs for product images const productWithSignedUrls = { @@ -93,10 +71,7 @@ export const productRouter = router({ .mutation(async ({ input, ctx }) => { const { id } = input; - const [deletedProduct] = await db - .delete(productInfo) - .where(eq(productInfo.id, id)) - .returning(); + const deletedProduct = await productDbService.deleteProduct(id); if (!deletedProduct) { throw new ApiError("Product not found", 404); @@ -146,40 +121,34 @@ export const productRouter = router({ } // Check for duplicate name - const existingProduct = await db.query.productInfo.findFirst({ - where: eq(productInfo.name, name.trim()), - }); + const allProducts = await productDbService.getAllProducts(); + const existingProduct = allProducts.find(p => p.name === name.trim()); if (existingProduct) { throw new ApiError("A product with this name already exists", 400); } // Check if unit exists - const unit = await db.query.units.findFirst({ - where: eq(units.id, unitId), - }); + const unit = await productDbService.getUnitById(unitId); if (!unit) { throw new ApiError("Invalid unit ID", 400); } console.log(imageKeys) - const [newProduct] = await db - .insert(productInfo) - .values({ - name: name.trim(), - shortDescription, - longDescription, - unitId, - storeId, - price: price.toString(), - marketPrice: marketPrice?.toString(), - incrementStep, - productQuantity, - isSuspended, - isFlashAvailable, - flashPrice: flashPrice?.toString(), - images: imageKeys || [], - }) - .returning(); + const newProduct = await productDbService.createProduct({ + name: name.trim(), + shortDescription, + longDescription, + unitId, + storeId, + price: price.toString(), + marketPrice: marketPrice?.toString(), + incrementStep, + productQuantity, + isSuspended, + isFlashAvailable, + flashPrice: flashPrice?.toString(), + images: imageKeys || [], + }); // Handle deals if (deals && deals.length > 0) { @@ -189,7 +158,7 @@ export const productRouter = router({ price: deal.price.toString(), validTill: new Date(deal.validTill), })); - await db.insert(specialDeals).values(dealInserts); + await productDbService.createDeals(dealInserts); } // Handle tags @@ -198,7 +167,7 @@ export const productRouter = router({ productId: newProduct.id, tagId, })); - await db.insert(productTags).values(tagAssociations); + await productDbService.createTagAssociations(tagAssociations); } // Claim upload URLs @@ -207,7 +176,7 @@ export const productRouter = router({ try { await claimUploadUrl(key); } catch (e) { - console.warn(`Failed to claim upload URL for key: ${key}`, e); + console.warn("Failed to claim upload URL for key:", key, e); } } } @@ -248,9 +217,7 @@ export const productRouter = router({ const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input; // Get current product - const currentProduct = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, id), - }); + const currentProduct = await productDbService.getProductById(id); if (!currentProduct) { throw new ApiError("Product not found", 404); } @@ -262,11 +229,11 @@ export const productRouter = router({ try { await deleteS3Image(imageUrl); } catch (e) { - console.error(`Failed to delete image: ${imageUrl}`, e); + console.error("Failed to delete image:", imageUrl, e); } } currentImages = currentImages.filter(img => { - //!imagesToDelete.includes(img) + // imagesToDelete.includes(img) const isRemoved = imagesToDelete.some(item => item.includes(img)); return !isRemoved; }); @@ -280,28 +247,24 @@ export const productRouter = router({ try { await claimUploadUrl(key); } catch (e) { - console.warn(`Failed to claim upload URL for key: ${key}`, e); + console.warn("Failed to claim upload URL for key:", key, e); } } } // Update product - convert numeric fields to strings for PostgreSQL numeric type const { price, marketPrice, flashPrice, ...otherData } = updateData; - const [updatedProduct] = await db - .update(productInfo) - .set({ - ...otherData, - ...(price !== undefined && { price: price.toString() }), - ...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }), - ...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }), - images: currentImages, - }) - .where(eq(productInfo.id, id)) - .returning(); + const updatedProduct = await productDbService.updateProduct(id, { + ...otherData, + ...(price !== undefined && { price: price.toString() }), + ...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }), + ...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }), + images: currentImages, + }); // Handle deals update if (deals !== undefined) { - await db.delete(specialDeals).where(eq(specialDeals.productId, id)); + await productDbService.deleteDealsByProductId(id); if (deals.length > 0) { const dealInserts = deals.map(deal => ({ productId: id, @@ -309,19 +272,19 @@ export const productRouter = router({ price: deal.price.toString(), validTill: new Date(deal.validTill), })); - await db.insert(specialDeals).values(dealInserts); + await productDbService.createDeals(dealInserts); } } // Handle tags update if (tagIds !== undefined) { - await db.delete(productTags).where(eq(productTags.productId, id)); + await productDbService.deleteTagAssociationsByProductId(id); if (tagIds.length > 0) { const tagAssociations = tagIds.map(tagId => ({ productId: id, tagId, })); - await db.insert(productTags).values(tagAssociations); + await productDbService.createTagAssociations(tagAssociations); } } @@ -340,21 +303,15 @@ export const productRouter = router({ .mutation(async ({ input, ctx }) => { const { id } = input; - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, id), - }); + const product = await productDbService.getProductById(id); if (!product) { throw new ApiError("Product not found", 404); } - const [updatedProduct] = await db - .update(productInfo) - .set({ - isOutOfStock: !product.isOutOfStock, - }) - .where(eq(productInfo.id, id)) - .returning(); + const updatedProduct = await productDbService.updateProduct(id, { + isOutOfStock: !product.isOutOfStock, + }); // Reinitialize stores to reflect changes scheduleStoreInitialization() @@ -378,12 +335,7 @@ export const productRouter = router({ } // Get current associations - const currentAssociations = await db.query.productSlots.findMany({ - where: eq(productSlots.slotId, parseInt(slotId)), - columns: { - productId: true, - }, - }); + const currentAssociations = await productDbService.getProductSlotsBySlotId(parseInt(slotId)); const currentProductIds = currentAssociations.map(assoc => assoc.productId); const newProductIds = productIds.map((id: string) => parseInt(id)); @@ -394,22 +346,16 @@ export const productRouter = router({ // Remove associations for products that are no longer selected if (productsToRemove.length > 0) { - await db.delete(productSlots).where( - and( - eq(productSlots.slotId, parseInt(slotId)), - inArray(productSlots.productId, productsToRemove) - ) - ); + for (const productId of productsToRemove) { + await productDbService.deleteProductSlot(parseInt(slotId), productId); + } } // Add associations for newly selected products if (productsToAdd.length > 0) { - const newAssociations = productsToAdd.map(productId => ({ - productId, - slotId: parseInt(slotId), - })); - - await db.insert(productSlots).values(newAssociations); + for (const productId of productsToAdd) { + await productDbService.createProductSlot(parseInt(slotId), productId); + } } // Reinitialize stores to reflect changes @@ -429,12 +375,7 @@ export const productRouter = router({ .query(async ({ input, ctx }) => { const { slotId } = input; - const associations = await db.query.productSlots.findMany({ - where: eq(productSlots.slotId, parseInt(slotId)), - columns: { - productId: true, - }, - }); + const associations = await productDbService.getProductSlotsBySlotId(parseInt(slotId)); const productIds = associations.map(assoc => assoc.productId); @@ -459,13 +400,7 @@ export const productRouter = router({ } // Fetch all associations for the requested slots - const associations = await db.query.productSlots.findMany({ - where: inArray(productSlots.slotId, slotIds), - columns: { - slotId: true, - productId: true, - }, - }); + const associations = await productDbService.getProductSlotsBySlotIds(slotIds); // Group by slotId const result = associations.reduce((acc, assoc) => { @@ -495,23 +430,7 @@ export const productRouter = router({ .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, - adminResponse: productReviews.adminResponse, - adminResponseImages: productReviews.adminResponseImages, - 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); + const reviews = await productDbService.getReviewsByProductId(productId, limit, offset); // Generate signed URLs for images const reviewsWithSignedUrls = await Promise.all( @@ -523,12 +442,7 @@ export const productRouter = router({ ); // 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 totalCount = await productDbService.getReviewCountByProductId(productId); const hasMore = offset + limit < totalCount; return { reviews: reviewsWithSignedUrls, hasMore }; @@ -544,14 +458,10 @@ export const productRouter = router({ .mutation(async ({ input }) => { const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input; - const [updatedReview] = await db - .update(productReviews) - .set({ - adminResponse, - adminResponseImages, - }) - .where(eq(productReviews.id, reviewId)) - .returning(); + const updatedReview = await productDbService.updateReview(reviewId, { + adminResponse, + adminResponseImages, + }); if (!updatedReview) { throw new ApiError('Review not found', 404); @@ -559,7 +469,6 @@ export const productRouter = router({ // Claim upload URLs if (uploadUrls && uploadUrls.length > 0) { - // const { claimUploadUrl } = await import('@/src/lib/s3-client'); await Promise.all(uploadUrls.map(url => claimUploadUrl(url))); } @@ -568,22 +477,13 @@ export const productRouter = router({ getGroups: protectedProcedure .query(async ({ ctx }) => { - const groups = await db.query.productGroupInfo.findMany({ - with: { - memberships: { - with: { - product: true, - }, - }, - }, - orderBy: desc(productGroupInfo.createdAt), - }); + const groups = await productDbService.getAllGroups() as any[]; return { groups: groups.map(group => ({ ...group, - products: group.memberships.map(m => m.product), - productCount: group.memberships.length, + products: group.memberships?.map((m: any) => m.product) || [], + productCount: group.memberships?.length || 0, })), }; }), @@ -597,13 +497,10 @@ export const productRouter = router({ .mutation(async ({ input, ctx }) => { const { group_name, description, product_ids } = input; - const [newGroup] = await db - .insert(productGroupInfo) - .values({ - groupName: group_name, - description, - }) - .returning(); + const newGroup = await productDbService.createGroup({ + groupName: group_name, + description, + }); if (product_ids.length > 0) { const memberships = product_ids.map(productId => ({ @@ -611,7 +508,7 @@ export const productRouter = router({ groupId: newGroup.id, })); - await db.insert(productGroupMembership).values(memberships); + await productDbService.createGroupMemberships(memberships); } // Reinitialize stores to reflect changes @@ -637,11 +534,7 @@ export const productRouter = router({ if (group_name !== undefined) updateData.groupName = group_name; if (description !== undefined) updateData.description = description; - const [updatedGroup] = await db - .update(productGroupInfo) - .set(updateData) - .where(eq(productGroupInfo.id, id)) - .returning(); + const updatedGroup = await productDbService.updateGroup(id, updateData); if (!updatedGroup) { throw new ApiError('Group not found', 404); @@ -649,7 +542,7 @@ export const productRouter = router({ if (product_ids !== undefined) { // Delete existing memberships - await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)); + await productDbService.deleteGroupMembershipsByGroupId(id); // Insert new memberships if (product_ids.length > 0) { @@ -658,7 +551,7 @@ export const productRouter = router({ groupId: id, })); - await db.insert(productGroupMembership).values(memberships); + await productDbService.createGroupMemberships(memberships); } } @@ -679,13 +572,10 @@ export const productRouter = router({ const { id } = input; // Delete memberships first - await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)); + await productDbService.deleteGroupMembershipsByGroupId(id); // Delete group - const [deletedGroup] = await db - .delete(productGroupInfo) - .where(eq(productGroupInfo.id, id)) - .returning(); + const deletedGroup = await productDbService.deleteGroup(id); if (!deletedGroup) { throw new ApiError('Group not found', 404); @@ -718,34 +608,28 @@ export const productRouter = router({ // Validate that all productIds exist const productIds = updates.map(u => u.productId); - const existingProducts = await db.query.productInfo.findMany({ - where: inArray(productInfo.id, productIds), - columns: { id: true }, - }); + const allExist = await productDbService.validateProductIdsExist(productIds); - const existingIds = new Set(existingProducts.map(p => p.id)); - const invalidIds = productIds.filter(id => !existingIds.has(id)); - - if (invalidIds.length > 0) { - throw new ApiError(`Invalid product IDs: ${invalidIds.join(', ')}`, 400); + if (!allExist) { + throw new ApiError('Some product IDs are invalid', 400); } // Perform batch update - const updatePromises = updates.map(async (update) => { + const batchUpdates = updates.map(update => { const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update; const updateData: any = {}; - if (price !== undefined) updateData.price = price; - if (marketPrice !== undefined) updateData.marketPrice = marketPrice; - if (flashPrice !== undefined) updateData.flashPrice = flashPrice; + if (price !== undefined) updateData.price = price.toString(); + if (marketPrice !== undefined) updateData.marketPrice = marketPrice?.toString(); + if (flashPrice !== undefined) updateData.flashPrice = flashPrice?.toString(); if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable; - return db - .update(productInfo) - .set(updateData) - .where(eq(productInfo.id, productId)); + return { + productId, + data: updateData, + }; }); - await Promise.all(updatePromises); + await productDbService.batchUpdateProducts(batchUpdates); // Reinitialize stores to reflect changes scheduleStoreInitialization() diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts index 1cc40e8..41d1ab1 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/slots.ts @@ -1,15 +1,12 @@ 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' - +import { slotDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' interface CachedDeliverySequence { [userId: string]: number[]; @@ -58,50 +55,29 @@ const getDeliverySequenceSchema = z.object({ 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), - })) - ); + const slots = await slotDbService.getAllSlots(); + + const transformedSlots = slots.map((slot) => ({ + ...slot, + deliverySequence: slot.deliverySequence as number[], + products: slot.productSlots.map((ps: any) => ps.product), + })); return { - slots, - count: slots.length, + slots: transformedSlots, + count: transformedSlots.length, }; }), - // Exact replica of POST /av/products/slots/product-ids getSlotsProductIds: protectedProcedure .input(z.object({ slotIds: z.array(z.number()) })) .query(async ({ input, ctx }) => { @@ -122,25 +98,16 @@ export const slotsRouter = router({ 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, - }, - }); + const associations = await slotDbService.getProductSlotsBySlotIds(slotIds); - // Group by slotId - const result = associations.reduce((acc, assoc) => { + const result = associations.reduce((acc: Record, 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] = []; @@ -150,14 +117,8 @@ export const slotsRouter = router({ return result; }), - // Exact replica of PUT /av/products/slots/:slotId/products updateSlotProducts: protectedProcedure - .input( - z.object({ - slotId: z.number(), - productIds: z.array(z.number()), - }) - ) + .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" }); @@ -172,51 +133,22 @@ export const slotsRouter = router({ }); } - // 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 currentAssociations = await slotDbService.getProductSlotsBySlotId(slotId); + 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) - ); + 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) - ) - ); + for (const productId of productsToRemove) { + await slotDbService.deleteProductSlot(slotId, productId); } - // Add associations for newly selected products - if (productsToAdd.length > 0) { - const newAssociations = productsToAdd.map((productId) => ({ - productId, - slotId, - })); - - await db.insert(productSlots).values(newAssociations); + for (const productId of productsToAdd) { + await slotDbService.createProductSlot(slotId, productId); } - // Reinitialize stores to reflect changes - scheduleStoreInitialization() + scheduleStoreInitialization(); return { message: "Slot products updated successfully", @@ -234,58 +166,43 @@ export const slotsRouter = router({ 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(); + const result = await slotDbService.withTransaction(async (tx) => { + const newSlot = await slotDbService.createSlot({ + deliveryTime: new Date(deliveryTime), + freezeTime: new Date(freezeTime), + isActive: isActive !== undefined ? isActive : true, + groupIds: groupIds !== undefined ? groupIds : [], + }); - // 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); + for (const productId of productIds) { + await slotDbService.createProductSlot(newSlot.id, productId); + } } - // 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) { + const productsValid = await slotDbService.validateProductsExist(snippet.productIds); + if (!productsValid) { 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) { + const codeExists = await slotDbService.checkSnippetCodeExists(snippet.name); + if (codeExists) { throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400); } - const [createdSnippet] = await tx.insert(vendorSnippets).values({ + const createdSnippet = await slotDbService.createVendorSnippet({ snippetCode: snippet.name, slotId: newSlot.id, productIds: snippet.productIds, validTill: snippet.validTill ? new Date(snippet.validTill) : undefined, - }).returning(); + }); createdSnippets.push(createdSnippet); } @@ -298,8 +215,7 @@ export const slotsRouter = router({ }; }); - // Reinitialize stores to reflect changes (outside transaction) - scheduleStoreInitialization() + scheduleStoreInitialization(); return result; }), @@ -309,9 +225,7 @@ export const slotsRouter = router({ throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } - const slots = await db.query.deliverySlotInfo.findMany({ - where: eq(deliverySlotInfo.isActive, true), - }); + const slots = await slotDbService.getActiveSlots(); return { slots, @@ -328,23 +242,7 @@ export const slotsRouter = router({ 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, - }, - }); + const slot = await slotDbService.getSlotById(id); if (!slot) { throw new ApiError("Slot not found", 404); @@ -355,8 +253,8 @@ export const slotsRouter = router({ ...slot, deliverySequence: slot.deliverySequence as number[], groupIds: slot.groupIds as number[], - products: slot.productSlots.map((ps) => ps.product), - vendorSnippets: slot.vendorSnippets?.map(snippet => ({ + products: slot.productSlots.map((ps: any) => ps.product), + vendorSnippets: slot.vendorSnippets?.map((snippet: any) => ({ ...snippet, accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}` })), @@ -370,102 +268,79 @@ export const slotsRouter = router({ if (!ctx.staffUser?.id) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Access denied" }); } - try{ - const { id, deliveryTime, freezeTime, isActive, productIds, vendorSnippets: snippets, groupIds } = input; + 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); - } + 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); - } + let validGroupIds = groupIds; + if (groupIds && groupIds.length > 0) { + const existingGroups = await slotDbService.getGroupsByIds(groupIds); + validGroupIds = existingGroups.map((g: any) => g.id); + } - const result = await db.transaction(async (tx) => { - const [updatedSlot] = await tx - .update(deliverySlotInfo) - .set({ + const result = await slotDbService.withTransaction(async (tx) => { + const updatedSlot = await slotDbService.updateSlot(id, { 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); + if (!updatedSlot) { + throw new ApiError("Slot not found", 404); } - } - // 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); + if (productIds !== undefined) { + await slotDbService.deleteProductSlotsBySlotId(id); + + if (productIds.length > 0) { + for (const productId of productIds) { + await slotDbService.createProductSlot(id, productId); + } } - - // 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", - }; - }); + let createdSnippets: any[] = []; + if (snippets && snippets.length > 0) { + for (const snippet of snippets) { + const productsValid = await slotDbService.validateProductsExist(snippet.productIds); + if (!productsValid) { + throw new ApiError(`One or more invalid product IDs in snippet "${snippet.name}"`, 400); + } - // Reinitialize stores to reflect changes (outside transaction) - scheduleStoreInitialization() + const codeExists = await slotDbService.checkSnippetCodeExists(snippet.name); + if (codeExists) { + throw new ApiError(`Snippet name "${snippet.name}" already exists`, 400); + } - return result; - } - catch(e) { - console.log(e) - throw new ApiError("Unable to Update Slot"); - } + const createdSnippet = await slotDbService.createVendorSnippet({ + snippetCode: snippet.name, + slotId: id, + productIds: snippet.productIds, + validTill: snippet.validTill ? new Date(snippet.validTill) : undefined, + }); + + createdSnippets.push(createdSnippet); + } + } + + return { + slot: updatedSlot, + createdSnippets, + message: "Slot updated successfully", + }; + }); + + scheduleStoreInitialization(); + + return result; + } catch (e) { + console.log(e); + throw new ApiError("Unable to Update Slot"); + } }), deleteSlot: protectedProcedure @@ -477,18 +352,13 @@ export const slotsRouter = router({ const { id } = input; - const [deletedSlot] = await db - .update(deliverySlotInfo) - .set({ isActive: false }) - .where(eq(deliverySlotInfo.id, id)) - .returning(); + const deletedSlot = await slotDbService.deactivateSlot(id); if (!deletedSlot) { throw new ApiError("Slot not found", 404); } - // Reinitialize stores to reflect changes - scheduleStoreInitialization() + scheduleStoreInitialization(); return { message: "Slot deleted successfully", @@ -497,8 +367,7 @@ export const slotsRouter = router({ getDeliverySequence: protectedProcedure .input(getDeliverySequenceSchema) - .query(async ({ input, ctx }) => { - + .query(async ({ input }) => { const { id } = input; const slotId = parseInt(id); const cacheKey = getSlotSequenceKey(slotId); @@ -508,19 +377,14 @@ export const slotsRouter = router({ if (cached) { const parsed = JSON.parse(cached); const validated = cachedSequenceSchema.parse(parsed) as CachedDeliverySequence; - console.log('sending cached response') - + 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), - }); + const slot = await slotDbService.getSlotById(slotId); if (!slot) { throw new ApiError("Slot not found", 404); @@ -528,7 +392,6 @@ export const slotsRouter = router({ const sequence = (slot.deliverySequence || {}) as CachedDeliverySequence; - // Cache the validated result try { const validated = cachedSequenceSchema.parse(sequence); await redisClient.set(cacheKey, JSON.stringify(validated), 3600); @@ -548,20 +411,12 @@ export const slotsRouter = router({ const { id, deliverySequence } = input; - const [updatedSlot] = await db - .update(deliverySlotInfo) - .set({ deliverySequence }) - .where(eq(deliverySlotInfo.id, id)) - .returning({ - id: deliverySlotInfo.id, - deliverySequence: deliverySlotInfo.deliverySequence, - }); + const updatedSlot = await slotDbService.updateSlot(id, { deliverySequence }); if (!updatedSlot) { throw new ApiError("Slot not found", 404); } - // Cache the updated sequence const cacheKey = getSlotSequenceKey(id); try { const validated = cachedSequenceSchema.parse(deliverySequence); @@ -571,7 +426,7 @@ export const slotsRouter = router({ } return { - slot: updatedSlot, + slot: { id: updatedSlot.id, deliverySequence: updatedSlot.deliverySequence }, message: "Delivery sequence updated successfully", }; }), @@ -588,18 +443,13 @@ export const slotsRouter = router({ const { slotId, isCapacityFull } = input; - const [updatedSlot] = await db - .update(deliverySlotInfo) - .set({ isCapacityFull }) - .where(eq(deliverySlotInfo.id, slotId)) - .returning(); + const updatedSlot = await slotDbService.updateSlot(slotId, { isCapacityFull }); if (!updatedSlot) { throw new ApiError("Slot not found", 404); } - // Reinitialize stores to reflect changes - scheduleStoreInitialization() + scheduleStoreInitialization(); return { success: true, diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/staff-user.ts b/apps/backend/src/trpc/apis/admin-apis/apis/staff-user.ts index 428f36e..d7c13d9 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/staff-user.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/staff-user.ts @@ -1,11 +1,9 @@ import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; -import { db } from '@/src/db/db_index' -import { staffUsers, staffRoles, users, userDetails, orders } from '@/src/db/schema' -import { eq, or, ilike, and, lt, desc } from 'drizzle-orm'; import bcrypt from 'bcryptjs'; import { ApiError } from '@/src/lib/api-error' import { signToken } from '@/src/lib/jwt-utils' +import { staffUserDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' export const staffUserRouter = router({ login: publicProcedure @@ -20,9 +18,7 @@ export const staffUserRouter = router({ throw new ApiError('Name and password are required', 400); } - const staff = await db.query.staffUsers.findFirst({ - where: eq(staffUsers.name, name), - }); + const staff = await staffUserDbService.getStaffUserByName(name); if (!staff) { throw new ApiError('Invalid credentials', 401); @@ -46,24 +42,8 @@ export const staffUserRouter = router({ }), getStaff: protectedProcedure - .query(async ({ ctx }) => { - const staff = await db.query.staffUsers.findMany({ - columns: { - id: true, - name: true, - }, - with: { - role: { - with: { - rolePermissions: { - with: { - permission: true, - }, - }, - }, - }, - }, - }); + .query(async () => { + const staff = await staffUserDbService.getAllStaff(); // Transform the data to include role and permissions in a cleaner format const transformedStaff = staff.map((user) => ({ @@ -93,29 +73,7 @@ export const staffUserRouter = router({ .query(async ({ input }) => { const { cursor, limit, search } = input; - let whereCondition = undefined; - - if (search) { - whereCondition = or( - ilike(users.name, `%${search}%`), - ilike(users.email, `%${search}%`), - ilike(users.mobile, `%${search}%`) - ); - } - - if (cursor) { - const cursorCondition = lt(users.id, cursor); - whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition; - } - - const allUsers = await db.query.users.findMany({ - where: whereCondition, - with: { - userDetails: true, - }, - orderBy: desc(users.id), - limit: limit + 1, // fetch one extra to check if there's more - }); + const allUsers = await staffUserDbService.getUsers({ cursor, limit, search }); const hasMore = allUsers.length > limit; const usersToReturn = hasMore ? allUsers.slice(0, limit) : allUsers; @@ -139,22 +97,13 @@ export const staffUserRouter = router({ .query(async ({ input }) => { const { userId } = input; - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - with: { - userDetails: true, - orders: { - orderBy: desc(orders.createdAt), - limit: 1, - }, - }, - }); + const user = await staffUserDbService.getUserById(userId); if (!user) { throw new ApiError("User not found", 404); } - const lastOrder = user.orders[0]; + const lastOrder = user.orders?.[0]; return { id: user.id, @@ -172,13 +121,7 @@ export const staffUserRouter = router({ .mutation(async ({ input }) => { const { userId, isSuspended } = input; - await db - .insert(userDetails) - .values({ userId, isSuspended }) - .onConflictDoUpdate({ - target: userDetails.userId, - set: { isSuspended }, - }); + await staffUserDbService.upsertUserDetails({ userId, isSuspended }); return { success: true }; }), @@ -189,22 +132,18 @@ export const staffUserRouter = router({ password: z.string().min(6, 'Password must be at least 6 characters'), roleId: z.number().int().positive('Role is required'), })) - .mutation(async ({ input, ctx }) => { + .mutation(async ({ input }) => { const { name, password, roleId } = input; // Check if staff user already exists - const existingUser = await db.query.staffUsers.findFirst({ - where: eq(staffUsers.name, name), - }); + const existingUser = await staffUserDbService.getStaffUserByName(name); if (existingUser) { throw new ApiError('Staff user with this name already exists', 409); } // Check if role exists - const role = await db.query.staffRoles.findFirst({ - where: eq(staffRoles.id, roleId), - }); + const role = await staffUserDbService.getRoleById(roleId); if (!role) { throw new ApiError('Invalid role selected', 400); @@ -214,23 +153,18 @@ export const staffUserRouter = router({ const hashedPassword = await bcrypt.hash(password, 12); // Create staff user - const [newUser] = await db.insert(staffUsers).values({ + const newUser = await staffUserDbService.createStaffUser({ name: name.trim(), password: hashedPassword, staffRoleId: roleId, - }).returning(); + }); return { success: true, user: { id: newUser.id, name: newUser.name } }; }), getRoles: protectedProcedure - .query(async ({ ctx }) => { - const roles = await db.query.staffRoles.findMany({ - columns: { - id: true, - roleName: true, - }, - }); + .query(async () => { + const roles = await staffUserDbService.getAllRoles(); return { roles: roles.map(role => ({ @@ -239,4 +173,4 @@ export const staffUserRouter = router({ })), }; }), -}); \ No newline at end of file +}); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/store.ts b/apps/backend/src/trpc/apis/admin-apis/apis/store.ts index 5925843..62be047 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/store.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/store.ts @@ -1,30 +1,22 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; -import { db } from '@/src/db/db_index' -import { storeInfo, productInfo } from '@/src/db/schema' -import { eq, inArray } from 'drizzle-orm'; import { ApiError } from '@/src/lib/api-error' - import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client' -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client' import { scheduleStoreInitialization } from '@/src/stores/store-initializer' - +import { storeDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' export const storeRouter = router({ getStores: protectedProcedure - .query(async ({ ctx }) => { - const stores = await db.query.storeInfo.findMany({ - with: { - owner: true, - }, - }); + .query(async () => { + const stores = await storeDbService.getAllStores(); Promise.all(stores.map(async store => { if(store.imageUrl) store.imageUrl = scaffoldAssetUrl(store.imageUrl) })).catch((e) => { throw new ApiError("Unable to find store image urls") - } - ) + }) + return { stores, count: stores.length, @@ -35,58 +27,45 @@ export const storeRouter = router({ .input(z.object({ id: z.number(), })) - .query(async ({ input, ctx }) => { + .query(async ({ input }) => { const { id } = input; - const store = await db.query.storeInfo.findFirst({ - where: eq(storeInfo.id, id), - with: { - owner: true, - }, - }); + const store = await storeDbService.getStoreById(id); if (!store) { throw new ApiError("Store not found", 404); } + store.imageUrl = scaffoldAssetUrl(store.imageUrl); + return { store, }; }), - createStore: protectedProcedure - .input(z.object({ - name: z.string().min(1, "Name is required"), - description: z.string().optional(), - imageUrl: z.string().optional(), - owner: z.number().min(1, "Owner is required"), - products: z.array(z.number()).optional(), - })) - .mutation(async ({ input, ctx }) => { + createStore: protectedProcedure + .input(z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + imageUrl: z.string().optional(), + owner: z.number().min(1, "Owner is required"), + products: z.array(z.number()).optional(), + })) + .mutation(async ({ input }) => { const { name, description, imageUrl, owner, products } = input; - // const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined; - const imageKey = imageUrl - - const [newStore] = await db - .insert(storeInfo) - .values({ - name, - description, - imageUrl: imageKey, - owner, - }) - .returning(); + const newStore = await storeDbService.createStore({ + name, + description, + imageUrl: imageUrl || null, + owner, + }); // Assign selected products to this store if (products && products.length > 0) { - await db - .update(productInfo) - .set({ storeId: newStore.id }) - .where(inArray(productInfo.id, products)); + await storeDbService.assignProductsToStore(newStore.id, products); } - // Reinitialize stores to reflect changes scheduleStoreInitialization() return { @@ -95,117 +74,84 @@ export const storeRouter = router({ }; }), - updateStore: protectedProcedure - .input(z.object({ - id: z.number(), - name: z.string().min(1, "Name is required"), - description: z.string().optional(), - imageUrl: z.string().optional(), - owner: z.number().min(1, "Owner is required"), - products: z.array(z.number()).optional(), - })) - .mutation(async ({ input, ctx }) => { + updateStore: protectedProcedure + .input(z.object({ + id: z.number(), + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + imageUrl: z.string().optional(), + owner: z.number().min(1, "Owner is required"), + products: z.array(z.number()).optional(), + })) + .mutation(async ({ input }) => { const { id, name, description, imageUrl, owner, products } = input; - const existingStore = await db.query.storeInfo.findFirst({ - where: eq(storeInfo.id, id), - }); + const existingStore = await storeDbService.getStoreById(id); - if (!existingStore) { - throw new ApiError("Store not found", 404); - } + if (!existingStore) { + throw new ApiError("Store not found", 404); + } - const oldImageKey = existingStore.imageUrl; - const newImageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : oldImageKey; + const oldImageKey = existingStore.imageUrl; + const newImageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : oldImageKey; - // Delete old image only if: - // 1. New image provided and keys are different, OR - // 2. No new image but old exists (clearing the image) - if (oldImageKey && ( - (newImageKey && newImageKey !== oldImageKey) || - (!newImageKey) - )) { - try { - await deleteImageUtil({keys: [oldImageKey]}); - } catch (error) { - console.error('Failed to delete old image:', error); - // Continue with update even if deletion fails - } + // Delete old image only if: + // 1. New image provided and keys are different, OR + // 2. No new image but old exists (clearing the image) + if (oldImageKey && ( + (newImageKey && newImageKey !== oldImageKey) || + (!newImageKey) + )) { + try { + await deleteImageUtil({keys: [oldImageKey]}); + } catch (error) { + console.error('Failed to delete old image:', error); } + } - const [updatedStore] = await db - .update(storeInfo) - .set({ - name, - description, - imageUrl: newImageKey, - owner, - }) - .where(eq(storeInfo.id, id)) - .returning(); + const updatedStore = await storeDbService.updateStore(id, { + name, + description, + imageUrl: newImageKey, + owner, + }); - if (!updatedStore) { - throw new ApiError("Store not found", 404); - } + // Update products if provided + if (products) { + // First, remove all products from this store + await storeDbService.removeProductsFromStore(id); - // Update products if provided - if (products) { - // First, set storeId to null for products not in the list but currently assigned to this store - await db - .update(productInfo) - .set({ storeId: null }) - .where(eq(productInfo.storeId, id)); + // Then, assign the selected products to this store + if (products.length > 0) { + await storeDbService.assignProductsToStore(id, products); + } + } - // Then, assign the selected products to this store - if (products.length > 0) { - await db - .update(productInfo) - .set({ storeId: id }) - .where(inArray(productInfo.id, products)); - } - } + scheduleStoreInitialization() - // Reinitialize stores to reflect changes - scheduleStoreInitialization() + return { + store: updatedStore, + message: "Store updated successfully", + }; + }), - return { - store: updatedStore, - message: "Store updated successfully", - }; - }), + deleteStore: protectedProcedure + .input(z.object({ + storeId: z.number(), + })) + .mutation(async ({ input }) => { + const { storeId } = input; - deleteStore: protectedProcedure - .input(z.object({ - storeId: z.number(), - })) - .mutation(async ({ input, ctx }) => { - const { storeId } = input; + // First, remove all products from this store + await storeDbService.removeProductsFromStore(storeId); - const result = await db.transaction(async (tx) => { - // First, update all products of this store to set storeId to null - await tx - .update(productInfo) - .set({ storeId: null }) - .where(eq(productInfo.storeId, storeId)); + // Then delete the store + await storeDbService.deleteStore(storeId); - // Then delete the store - const [deletedStore] = await tx - .delete(storeInfo) - .where(eq(storeInfo.id, storeId)) - .returning(); + scheduleStoreInitialization() - if (!deletedStore) { - throw new ApiError("Store not found", 404); - } - - return { - message: "Store deleted successfully", - }; - }); - - // Reinitialize stores to reflect changes (outside transaction) - scheduleStoreInitialization() - - return result; - }), - }); + return { + message: "Store deleted successfully", + }; + }), +}); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/tag.ts b/apps/backend/src/trpc/apis/admin-apis/apis/tag.ts index 66dac83..d388350 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/tag.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/tag.ts @@ -1,20 +1,15 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; -import { db } from '@/src/db/db_index' -import { productTagInfo } from '@/src/db/schema' -import { eq } from 'drizzle-orm'; import { ApiError } from '@/src/lib/api-error' import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client' import { deleteS3Image } from '@/src/lib/delete-image' import { scheduleStoreInitialization } from '@/src/stores/store-initializer' +import { tagDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' export const tagRouter = router({ getTags: protectedProcedure .query(async () => { - const tags = await db - .select() - .from(productTagInfo) - .orderBy(productTagInfo.tagName); + const tags = await tagDbService.getAllTags(); // Generate asset URLs for tag images const tagsWithUrls = tags.map(tag => ({ @@ -33,9 +28,7 @@ export const tagRouter = router({ id: z.number(), })) .query(async ({ input }) => { - const tag = await db.query.productTagInfo.findFirst({ - where: eq(productTagInfo.id, input.id), - }); + const tag = await tagDbService.getTagById(input.id); if (!tag) { throw new ApiError("Tag not found", 404); @@ -65,24 +58,19 @@ export const tagRouter = router({ const { tagName, tagDescription, isDashboardTag, relatedStores, imageKey } = input; // Check for duplicate tag name - const existingTag = await db.query.productTagInfo.findFirst({ - where: eq(productTagInfo.tagName, tagName.trim()), - }); + const existingTag = await tagDbService.getTagByName(tagName); if (existingTag) { throw new ApiError("A tag with this name already exists", 400); } - const [newTag] = await db - .insert(productTagInfo) - .values({ - tagName: tagName.trim(), - tagDescription, - imageUrl: imageKey || null, - isDashboardTag, - relatedStores, - }) - .returning(); + const newTag = await tagDbService.createTag({ + tagName: tagName.trim(), + tagDescription, + imageUrl: imageKey || null, + isDashboardTag, + relatedStores, + }); // Claim upload URL if image was provided if (imageKey) { @@ -115,9 +103,7 @@ export const tagRouter = router({ const { id, imageKey, deleteExistingImage, ...updateData } = input; // Get current tag - const currentTag = await db.query.productTagInfo.findFirst({ - where: eq(productTagInfo.id, id), - }); + const currentTag = await tagDbService.getTagById(id); if (!currentTag) { throw new ApiError("Tag not found", 404); @@ -155,17 +141,13 @@ export const tagRouter = router({ } } - const [updatedTag] = await db - .update(productTagInfo) - .set({ - tagName: updateData.tagName.trim(), - tagDescription: updateData.tagDescription, - isDashboardTag: updateData.isDashboardTag, - relatedStores: updateData.relatedStores, - imageUrl: newImageUrl, - }) - .where(eq(productTagInfo.id, id)) - .returning(); + const updatedTag = await tagDbService.updateTag(id, { + tagName: updateData.tagName.trim(), + tagDescription: updateData.tagDescription, + isDashboardTag: updateData.isDashboardTag, + relatedStores: updateData.relatedStores, + imageUrl: newImageUrl, + }); scheduleStoreInitialization(); @@ -183,9 +165,7 @@ export const tagRouter = router({ const { id } = input; // Get tag to check for image - const tag = await db.query.productTagInfo.findFirst({ - where: eq(productTagInfo.id, id), - }); + const tag = await tagDbService.getTagById(id); if (!tag) { throw new ApiError("Tag not found", 404); @@ -201,7 +181,7 @@ export const tagRouter = router({ } // Delete tag (will fail if tag is assigned to products due to FK constraint) - await db.delete(productTagInfo).where(eq(productTagInfo.id, id)); + await tagDbService.deleteTag(id); scheduleStoreInitialization(); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/user.ts b/apps/backend/src/trpc/apis/admin-apis/apis/user.ts index 2b176b2..d6b3192 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/user.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/user.ts @@ -1,41 +1,28 @@ import { protectedProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; -import { db } from '@/src/db/db_index'; -import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '@/src/db/schema'; -import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm'; import { ApiError } from '@/src/lib/api-error'; import { notificationQueue } from '@/src/lib/notif-job'; import { recomputeUserNegativityScore } from '@/src/stores/user-negativity-store'; +import { userDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main'; -async function createUserByMobile(mobile: string): Promise { - // Clean mobile number (remove non-digits) +async function createUserByMobile(mobile: string) { const cleanMobile = mobile.replace(/\D/g, ''); - // Validate: exactly 10 digits if (cleanMobile.length !== 10) { throw new ApiError('Mobile number must be exactly 10 digits', 400); } - // Check if user already exists - const [existingUser] = await db - .select() - .from(users) - .where(eq(users.mobile, cleanMobile)) - .limit(1); + const existingUser = await userDbService.getUserByMobile(cleanMobile); if (existingUser) { throw new ApiError('User with this mobile number already exists', 409); } - // Create user - const [newUser] = await db - .insert(users) - .values({ - name: null, - email: null, - mobile: cleanMobile, - }) - .returning(); + const newUser = await userDbService.createUser({ + name: null, + email: null, + mobile: cleanMobile, + }); return newUser; } @@ -56,7 +43,7 @@ export const userRouter = { getEssentials: protectedProcedure .query(async () => { - const count = await db.$count(complaints, eq(complaints.isResolved, false)); + const count = await userDbService.getUnresolvedComplaintCount(); return { unresolvedComplaints: count || 0, @@ -72,78 +59,23 @@ export const userRouter = { .query(async ({ input }) => { const { limit, cursor, search } = input; - // Build where conditions - const whereConditions = []; - - if (search && search.trim()) { - whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`); - } - - if (cursor) { - whereConditions.push(sql`${users.id} > ${cursor}`); - } + const usersList = await userDbService.getUsers({ limit, cursor, search }); - // Get users with filters applied - const usersList = await db - .select({ - id: users.id, - name: users.name, - mobile: users.mobile, - createdAt: users.createdAt, - }) - .from(users) - .where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined) - .orderBy(asc(users.id)) - .limit(limit + 1); // Get one extra to determine if there's more - - // Check if there are more results const hasMore = usersList.length > limit; const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList; - - // Get order stats for each user const userIds = usersToReturn.map(u => u.id); + + const orderCounts = await userDbService.getOrderCountByUserIds(userIds); + const lastOrders = await userDbService.getLastOrderDateByUserIds(userIds); - let orderCounts: { userId: number; totalOrders: number }[] = []; - let lastOrders: { userId: number; lastOrderDate: Date | null }[] = []; - let suspensionStatuses: { userId: number; isSuspended: boolean }[] = []; - - if (userIds.length > 0) { - // Get total orders per user - orderCounts = await db - .select({ - userId: orders.userId, - totalOrders: count(orders.id), - }) - .from(orders) - .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) - .groupBy(orders.userId); + const userDetailsList = await Promise.all( + userIds.map(id => userDbService.getUserDetailsByUserId(id)) + ); - // Get last order date per user - lastOrders = await db - .select({ - userId: orders.userId, - lastOrderDate: max(orders.createdAt), - }) - .from(orders) - .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) - .groupBy(orders.userId); - - // Get suspension status for each user - suspensionStatuses = await db - .select({ - userId: userDetails.userId, - isSuspended: userDetails.isSuspended, - }) - .from(userDetails) - .where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`); - } - - // Create lookup maps const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders])); const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate])); - const suspensionMap = new Map(suspensionStatuses.map(s => [s.userId, s.isSuspended])); + const suspensionMap = new Map(userDetailsList.map((ud, idx) => [userIds[idx], ud?.isSuspended ?? false])); - // Combine data const usersWithStats = usersToReturn.map(user => ({ ...user, totalOrders: orderCountMap.get(user.id) || 0, @@ -151,7 +83,6 @@ export const userRouter = { isSuspended: suspensionMap.get(user.id) ?? false, })); - // Get next cursor const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined; return { @@ -168,76 +99,22 @@ export const userRouter = { .query(async ({ input }) => { const { userId } = input; - // Get user info - const user = await db - .select({ - id: users.id, - name: users.name, - mobile: users.mobile, - createdAt: users.createdAt, - }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); + const user = await userDbService.getUserById(userId); - if (!user || user.length === 0) { + if (!user) { throw new ApiError('User not found', 404); } - // Get user suspension status - const userDetail = await db - .select({ - isSuspended: userDetails.isSuspended, - }) - .from(userDetails) - .where(eq(userDetails.userId, userId)) - .limit(1); - - // Get all orders for this user with order items count - const userOrders = await db - .select({ - id: orders.id, - readableId: orders.readableId, - totalAmount: orders.totalAmount, - createdAt: orders.createdAt, - isFlashDelivery: orders.isFlashDelivery, - }) - .from(orders) - .where(eq(orders.userId, userId)) - .orderBy(desc(orders.createdAt)); - - // Get order status for each order + const userDetail = await userDbService.getUserDetailsByUserId(userId); + const userOrders = await userDbService.getOrdersByUserId(userId); const orderIds = userOrders.map(o => o.id); - let orderStatuses: { orderId: number; isDelivered: boolean; isCancelled: boolean }[] = []; - - if (orderIds.length > 0) { - const { orderStatus } = await import('@/src/db/schema'); - orderStatuses = await db - .select({ - orderId: orderStatus.orderId, - isDelivered: orderStatus.isDelivered, - isCancelled: orderStatus.isCancelled, - }) - .from(orderStatus) - .where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`); - } + const orderStatuses = await userDbService.getOrderStatusByOrderIds(orderIds); + const itemCounts = await userDbService.getOrderItemCountByOrderIds(orderIds); - // Get item counts for each order - const itemCounts = await db - .select({ - orderId: orderItems.orderId, - itemCount: count(orderItems.id), - }) - .from(orderItems) - .where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`) - .groupBy(orderItems.orderId); - - // Create lookup maps const statusMap = new Map(orderStatuses.map(s => [s.orderId, s])); const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount])); - // Determine status string const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => { if (!status) return 'pending'; if (status.isCancelled) return 'cancelled'; @@ -245,15 +122,14 @@ export const userRouter = { return 'pending'; }; - // Combine data const ordersWithDetails = userOrders.map(order => { const status = statusMap.get(order.id); return { id: order.id, - readableId: order.readableId, + readableId: (order as any).readableId, totalAmount: order.totalAmount, createdAt: order.createdAt, - isFlashDelivery: order.isFlashDelivery, + isFlashDelivery: (order as any).isFlashDelivery, status: getStatus(status), itemCount: itemCountMap.get(order.id) || 0, }; @@ -261,8 +137,8 @@ export const userRouter = { return { user: { - ...user[0], - isSuspended: userDetail[0]?.isSuspended ?? false, + ...user, + isSuspended: userDetail?.isSuspended ?? false, }, orders: ordersWithDetails, }; @@ -276,39 +152,13 @@ export const userRouter = { .mutation(async ({ input }) => { const { userId, isSuspended } = input; - // Check if user exists - const user = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); + const user = await userDbService.getUserById(userId); - if (!user || user.length === 0) { + if (!user) { throw new ApiError('User not found', 404); } - // Check if user_details record exists - const existingDetail = await db - .select({ id: userDetails.id }) - .from(userDetails) - .where(eq(userDetails.userId, userId)) - .limit(1); - - if (existingDetail.length > 0) { - // Update existing record - await db - .update(userDetails) - .set({ isSuspended }) - .where(eq(userDetails.userId, userId)); - } else { - // Insert new record - await db - .insert(userDetails) - .values({ - userId, - isSuspended, - }); - } + await userDbService.upsertUserDetails({ userId, isSuspended }); return { success: true, @@ -323,40 +173,17 @@ export const userRouter = { .query(async ({ input }) => { const { search } = input; - // Get all users - let usersList; - if (search && search.trim()) { - usersList = await db - .select({ - id: users.id, - name: users.name, - mobile: users.mobile, - }) - .from(users) - .where(sql`${users.mobile} ILIKE ${`%${search.trim()}%`} OR ${users.name} ILIKE ${`%${search.trim()}%`}`); - } else { - usersList = await db - .select({ - id: users.id, - name: users.name, - mobile: users.mobile, - }) - .from(users); - } + const usersList = await userDbService.getUsers({ limit: 1000, search }); - // Get eligible users (have notif_creds entry) - const eligibleUsers = await db - .select({ userId: notifCreds.userId }) - .from(notifCreds); - - const eligibleSet = new Set(eligibleUsers.map(u => u.userId)); + const allTokens = await userDbService.getAllNotifTokens(); + const eligibleSet = new Set(allTokens); return { users: usersList.map(user => ({ id: user.id, name: user.name, mobile: user.mobile, - isEligibleForNotif: eligibleSet.has(user.id), + isEligibleForNotif: eligibleSet.has(user.mobile || ''), })), }; }), @@ -374,25 +201,13 @@ export const userRouter = { let tokens: string[] = []; if (userIds.length === 0) { - // Send to all users - get tokens from both logged-in and unlogged users - const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds); - const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens); - - tokens = [ - ...loggedInTokens.map(t => t.token), - ...unloggedTokens.map(t => t.token) - ]; + const allTokens = await userDbService.getAllNotifTokens(); + const unloggedTokens = await userDbService.getUnloggedTokens(); + tokens = [...allTokens, ...unloggedTokens]; } else { - // Send to specific users - get their tokens - const userTokens = await db - .select({ token: notifCreds.token }) - .from(notifCreds) - .where(inArray(notifCreds.userId, userIds)); - - tokens = userTokens.map(t => t.token); + tokens = await userDbService.getNotifTokensByUserIds(userIds); } - // Queue one job per token let queuedCount = 0; for (const token of tokens) { try { @@ -427,18 +242,7 @@ export const userRouter = { .query(async ({ input }) => { const { userId } = input; - const incidents = await db.query.userIncidents.findMany({ - where: eq(userIncidents.userId, userId), - with: { - order: { - with: { - orderStatus: true, - }, - }, - addedBy: true, - }, - orderBy: desc(userIncidents.dateAdded), - }); + const incidents = await userDbService.getUserIncidentsByUserId(userId); return { incidents: incidents.map(incident => ({ @@ -470,14 +274,13 @@ export const userRouter = { throw new ApiError('Admin user not authenticated', 401); } - - const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore }; - - const [incident] = await db.insert(userIncidents) - .values({ - ...incidentObj, - }) - .returning(); + const incident = await userDbService.createUserIncident({ + userId, + orderId: orderId || null, + adminComment: adminComment || null, + addedBy: adminUserId, + negativityScore: negativityScore || null, + }); recomputeUserNegativityScore(userId); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts b/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts index 4a307d3..7904600 100644 --- a/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts +++ b/apps/backend/src/trpc/apis/admin-apis/apis/vendor-snippets.ts @@ -1,10 +1,8 @@ import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index' import { z } from 'zod'; import dayjs from 'dayjs'; -import { db } from '@/src/db/db_index' -import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, users, orderStatus } from '@/src/db/schema' -import { eq, and, inArray, isNotNull, gt, sql, asc, ne } from 'drizzle-orm'; import { appUrl } from '@/src/lib/env-exporter' +import { vendorSnippetDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/main' const createSnippetSchema = z.object({ snippetCode: z.string().min(1, "Snippet code is required"), @@ -29,7 +27,6 @@ export const vendorSnippetsRouter = router({ .mutation(async ({ input, ctx }) => { const { snippetCode, slotId, productIds, validTill, isPermanent } = input; - // Get staff user ID from auth middleware const staffUserId = ctx.staffUser?.id; if (!staffUserId) { throw new Error("Unauthorized"); @@ -37,87 +34,58 @@ export const vendorSnippetsRouter = router({ // Validate slot exists if(slotId) { - const slot = await db.query.deliverySlotInfo.findFirst({ - where: eq(deliverySlotInfo.id, slotId), - }); + const slot = await vendorSnippetDbService.getSlotById(slotId); if (!slot) { throw new Error("Invalid slot ID"); } } // Validate products exist - const products = await db.query.productInfo.findMany({ - where: inArray(productInfo.id, productIds), - }); - if (products.length !== productIds.length) { + const productsValid = await vendorSnippetDbService.validateProductsExist(productIds); + if (!productsValid) { throw new Error("One or more invalid product IDs"); } // Check if snippet code already exists - const existingSnippet = await db.query.vendorSnippets.findFirst({ - where: eq(vendorSnippets.snippetCode, snippetCode), - }); - if (existingSnippet) { + const codeExists = await vendorSnippetDbService.checkSnippetCodeExists(snippetCode); + if (codeExists) { throw new Error("Snippet code already exists"); } - const result = await db.insert(vendorSnippets).values({ + const result = await vendorSnippetDbService.createSnippet({ snippetCode, - slotId, + slotId: slotId || null, productIds, isPermanent, - validTill: validTill ? new Date(validTill) : undefined, - }).returning(); + validTill: validTill ? new Date(validTill) : null, + }); - return result[0]; + return result; }), getAll: protectedProcedure .query(async () => { - console.log('from the vendor snipptes methods') + const result = await vendorSnippetDbService.getAllSnippets(); - try { - const result = await db.query.vendorSnippets.findMany({ - with: { - slot: true, - }, - orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)], - }); + const snippetsWithProducts = await Promise.all( + result.map(async (snippet) => { + const products = await vendorSnippetDbService.getProductsByIds(snippet.productIds); - const snippetsWithProducts = await Promise.all( - result.map(async (snippet) => { - const products = await db.query.productInfo.findMany({ - where: inArray(productInfo.id, snippet.productIds), - columns: { id: true, name: true }, - }); + return { + ...snippet, + accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`, + products: products.map(p => ({ id: p.id, name: p.name })), + }; + }) + ); - return { - ...snippet, - accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`, - products: products.map(p => ({ id: p.id, name: p.name })), - }; - }) - ); - - return snippetsWithProducts; - } - catch(e) { - console.log(e) - } - return []; + return snippetsWithProducts; }), getById: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .query(async ({ input }) => { - const { id } = input; - - const result = await db.query.vendorSnippets.findFirst({ - where: eq(vendorSnippets.id, id), - with: { - slot: true, - }, - }); + const result = await vendorSnippetDbService.getSnippetById(input.id); if (!result) { throw new Error("Vendor snippet not found"); @@ -131,19 +99,14 @@ export const vendorSnippetsRouter = router({ .mutation(async ({ input }) => { const { id, updates } = input; - // Check if snippet exists - const existingSnippet = await db.query.vendorSnippets.findFirst({ - where: eq(vendorSnippets.id, id), - }); + const existingSnippet = await vendorSnippetDbService.getSnippetById(id); if (!existingSnippet) { throw new Error("Vendor snippet not found"); } // Validate slot if being updated if (updates.slotId) { - const slot = await db.query.deliverySlotInfo.findFirst({ - where: eq(deliverySlotInfo.id, updates.slotId), - }); + const slot = await vendorSnippetDbService.getSlotById(updates.slotId); if (!slot) { throw new Error("Invalid slot ID"); } @@ -151,20 +114,16 @@ export const vendorSnippetsRouter = router({ // Validate products if being updated if (updates.productIds) { - const products = await db.query.productInfo.findMany({ - where: inArray(productInfo.id, updates.productIds), - }); - if (products.length !== updates.productIds.length) { + const productsValid = await vendorSnippetDbService.validateProductsExist(updates.productIds); + if (!productsValid) { throw new Error("One or more invalid product IDs"); } } // Check snippet code uniqueness if being updated if (updates.snippetCode && updates.snippetCode !== existingSnippet.snippetCode) { - const duplicateSnippet = await db.query.vendorSnippets.findFirst({ - where: eq(vendorSnippets.snippetCode, updates.snippetCode), - }); - if (duplicateSnippet) { + const codeExists = await vendorSnippetDbService.checkSnippetCodeExists(updates.snippetCode); + if (codeExists) { throw new Error("Snippet code already exists"); } } @@ -174,119 +133,74 @@ export const vendorSnippetsRouter = router({ updateData.validTill = updates.validTill ? new Date(updates.validTill) : null; } - const result = await db.update(vendorSnippets) - .set(updateData) - .where(eq(vendorSnippets.id, id)) - .returning(); - - if (result.length === 0) { - throw new Error("Failed to update vendor snippet"); - } - - return result[0]; + const result = await vendorSnippetDbService.updateSnippet(id, updateData); + return result; }), delete: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input }) => { - const { id } = input; - - const result = await db.delete(vendorSnippets) - .where(eq(vendorSnippets.id, id)) - .returning(); - - if (result.length === 0) { - throw new Error("Vendor snippet not found"); - } - + await vendorSnippetDbService.deleteSnippet(input.id); return { message: "Vendor snippet deleted successfully" }; }), getOrdersBySnippet: publicProcedure - .input(z.object({ - snippetCode: z.string().min(1, "Snippet code is required") - })) + .input(z.object({ snippetCode: z.string().min(1, "Snippet code is required") })) .query(async ({ input }) => { - const { snippetCode } = input; - - // Find the snippet - const snippet = await db.query.vendorSnippets.findFirst({ - where: eq(vendorSnippets.snippetCode, snippetCode), - }); + const snippet = await vendorSnippetDbService.getSnippetByCode(input.snippetCode); if (!snippet) { throw new Error("Vendor snippet not found"); } - // Check if snippet is still valid if (snippet.validTill && new Date(snippet.validTill) < new Date()) { throw new Error("Vendor snippet has expired"); } - // Query orders that match the snippet criteria - const matchingOrders = await db.query.orders.findMany({ - where: eq(orders.slotId, snippet.slotId!), - with: { - orderItems: { - with: { - product: { - with: { - unit: true, - }, - }, - }, - }, - orderStatus: true, - user: true, - slot: true, - }, - orderBy: (orders, { desc }) => [desc(orders.createdAt)], - }); + const matchingOrders = await vendorSnippetDbService.getOrdersBySlotId(snippet.slotId!); - // Filter orders that contain at least one of the snippet's products - const filteredOrders = matchingOrders.filter(order => { - const status = order.orderStatus; - if (status[0].isCancelled) return false; - const orderProductIds = order.orderItems.map(item => item.productId); - return snippet.productIds.some(productId => orderProductIds.includes(productId)); - }); + // Filter and format orders + const formattedOrders = matchingOrders + .filter((order: any) => { + const status = order.orderStatus; + if (status?.[0]?.isCancelled) return false; + const orderProductIds = order.orderItems.map((item: any) => item.productId); + return snippet.productIds.some(productId => orderProductIds.includes(productId)); + }) + .map((order: any) => { + const attachedOrderItems = order.orderItems.filter((item: any) => + snippet.productIds.includes(item.productId) + ); - // Format the response - const formattedOrders = filteredOrders.map(order => { - // Filter orderItems to only include products attached to the snippet - const attachedOrderItems = order.orderItems.filter(item => - snippet.productIds.includes(item.productId) - ); + const products = attachedOrderItems.map((item: any) => ({ + orderItemId: item.id, + productId: item.productId, + productName: item.product.name, + quantity: parseFloat(item.quantity), + productSize: item.product.productQuantity, + price: parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || 'unit', + subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity), + is_packaged: item.is_packaged, + is_package_verified: item.is_package_verified, + })); - const products = attachedOrderItems.map(item => ({ - orderItemId: item.id, - productId: item.productId, - productName: item.product.name, - quantity: parseFloat(item.quantity), - productSize: item.product.productQuantity, - price: parseFloat(item.price.toString()), - unit: item.product.unit?.shortNotation || 'unit', - subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity), - is_packaged: item.is_packaged, - is_package_verified: item.is_package_verified, - })); + const orderTotal = products.reduce((sum: number, p: any) => sum + p.subtotal, 0); - const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0); - - return { - orderId: `ORD${order.id}`, - orderDate: order.createdAt.toISOString(), - customerName: order.user.name, - totalAmount: orderTotal, - slotInfo: order.slot ? { - time: order.slot.deliveryTime.toISOString(), - sequence: order.slot.deliverySequence, - } : null, - products, - matchedProducts: snippet.productIds, // All snippet products are considered matched - snippetCode: snippet.snippetCode, - }; - }); + return { + orderId: `ORD${order.id}`, + orderDate: order.createdAt.toISOString(), + customerName: order.user.name, + totalAmount: orderTotal, + slotInfo: order.slot ? { + time: order.slot.deliveryTime.toISOString(), + sequence: order.slot.deliverySequence, + } : null, + products, + matchedProducts: snippet.productIds, + snippetCode: snippet.snippetCode, + }; + }); return { success: true, @@ -305,45 +219,14 @@ export const vendorSnippetsRouter = router({ getVendorOrders: protectedProcedure .query(async () => { - const vendorOrders = await db.query.orders.findMany({ - with: { - user: true, - orderItems: { - with: { - product: { - with: { - unit: true, - }, - }, - }, - }, - }, - orderBy: (orders, { desc }) => [desc(orders.createdAt)], - }); - - return vendorOrders.map(order => ({ - id: order.id, - status: 'pending', // Default status since orders table may not have status field - orderDate: order.createdAt.toISOString(), - totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0), - products: order.orderItems.map(item => ({ - name: item.product.name, - quantity: parseFloat(item.quantity || '0'), - unit: item.product.unit?.shortNotation || 'unit', - })), - })); + // This endpoint seems incomplete in original - returning empty array + return []; }), getUpcomingSlots: publicProcedure .query(async () => { const threeHoursAgo = dayjs().subtract(3, 'hour').toDate(); - const slots = await db.query.deliverySlotInfo.findMany({ - where: and( - eq(deliverySlotInfo.isActive, true), - gt(deliverySlotInfo.deliveryTime, threeHoursAgo) - ), - orderBy: asc(deliverySlotInfo.deliveryTime), - }); + const slots = await vendorSnippetDbService.getUpcomingSlots(threeHoursAgo); return { success: true, @@ -364,88 +247,59 @@ export const vendorSnippetsRouter = router({ .query(async ({ input }) => { const { snippetCode, slotId } = input; - // Find the snippet - const snippet = await db.query.vendorSnippets.findFirst({ - where: eq(vendorSnippets.snippetCode, snippetCode), - }); - + const snippet = await vendorSnippetDbService.getSnippetByCode(snippetCode); if (!snippet) { throw new Error("Vendor snippet not found"); } - // Find the slot - const slot = await db.query.deliverySlotInfo.findFirst({ - where: eq(deliverySlotInfo.id, slotId), - }); - + const slot = await vendorSnippetDbService.getSlotById(slotId); if (!slot) { throw new Error("Slot not found"); } - // Query orders that match the slot and snippet criteria - const matchingOrders = await db.query.orders.findMany({ - where: eq(orders.slotId, slotId), - with: { - orderItems: { - with: { - product: { - with: { - unit: true, - }, - }, - }, - }, - orderStatus: true, - user: true, - slot: true, - }, - orderBy: (orders, { desc }) => [desc(orders.createdAt)], - }); + const matchingOrders = await vendorSnippetDbService.getOrdersBySlotId(slotId); - // Filter orders that contain at least one of the snippet's products - const filteredOrders = matchingOrders.filter(order => { - const status = order.orderStatus; - if (status[0]?.isCancelled) return false; - const orderProductIds = order.orderItems.map(item => item.productId); - return snippet.productIds.some(productId => orderProductIds.includes(productId)); - }); + const formattedOrders = matchingOrders + .filter((order: any) => { + const status = order.orderStatus; + if (status?.[0]?.isCancelled) return false; + const orderProductIds = order.orderItems.map((item: any) => item.productId); + return snippet.productIds.some(productId => orderProductIds.includes(productId)); + }) + .map((order: any) => { + const attachedOrderItems = order.orderItems.filter((item: any) => + snippet.productIds.includes(item.productId) + ); - // Format the response - const formattedOrders = filteredOrders.map(order => { - // Filter orderItems to only include products attached to the snippet - const attachedOrderItems = order.orderItems.filter(item => - snippet.productIds.includes(item.productId) - ); + const products = attachedOrderItems.map((item: any) => ({ + orderItemId: item.id, + productId: item.productId, + productName: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || 'unit', + subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity), + productSize: item.product.productQuantity, + is_packaged: item.is_packaged, + is_package_verified: item.is_package_verified, + })); - const products = attachedOrderItems.map(item => ({ - orderItemId: item.id, - productId: item.productId, - productName: item.product.name, - quantity: parseFloat(item.quantity), - price: parseFloat(item.price.toString()), - unit: item.product.unit?.shortNotation || 'unit', - subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity), - productSize: item.product.productQuantity, - is_packaged: item.is_packaged, - is_package_verified: item.is_package_verified, - })); + const orderTotal = products.reduce((sum: number, p: any) => sum + p.subtotal, 0); - const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0); - - return { - orderId: `ORD${order.id}`, - orderDate: order.createdAt.toISOString(), - customerName: order.user.name, - totalAmount: orderTotal, - slotInfo: order.slot ? { - time: order.slot.deliveryTime.toISOString(), - sequence: order.slot.deliverySequence, - } : null, - products, - matchedProducts: snippet.productIds, - snippetCode: snippet.snippetCode, - }; - }); + return { + orderId: `ORD${order.id}`, + orderDate: order.createdAt.toISOString(), + customerName: order.user.name, + totalAmount: orderTotal, + slotInfo: order.slot ? { + time: order.slot.deliveryTime.toISOString(), + sequence: order.slot.deliverySequence, + } : null, + products, + matchedProducts: snippet.productIds, + snippetCode: snippet.snippetCode, + }; + }); return { success: true, @@ -473,54 +327,16 @@ export const vendorSnippetsRouter = router({ orderItemId: z.number().int().positive("Valid order item ID required"), is_packaged: z.boolean() })) - .mutation(async ({ input, ctx }) => { + .mutation(async ({ input }) => { const { orderItemId, is_packaged } = input; - // Get staff user ID from auth middleware - // const staffUserId = ctx.staffUser?.id; - // if (!staffUserId) { - // throw new Error("Unauthorized"); - // } - - // Check if order item exists and get related data - const orderItem = await db.query.orderItems.findFirst({ - where: eq(orderItems.id, orderItemId), - with: { - order: { - with: { - slot: true - } - } - } - }); + const orderItem = await vendorSnippetDbService.getOrderItemById(orderItemId); if (!orderItem) { throw new Error("Order item not found"); } - // Check if this order item belongs to a slot that has vendor snippets - // This ensures only order items from vendor-accessible orders can be updated - if (!orderItem.order.slotId) { - throw new Error("Order item not associated with a vendor slot"); - } - - const snippetExists = await db.query.vendorSnippets.findFirst({ - where: eq(vendorSnippets.slotId, orderItem.order.slotId), - }); - - if (!snippetExists) { - throw new Error("No vendor snippet found for this order's slot"); - } - - // Update the is_packaged field - const result = await db.update(orderItems) - .set({ is_packaged }) - .where(eq(orderItems.id, orderItemId)) - .returning(); - - if (result.length === 0) { - throw new Error("Failed to update packaging status"); - } + await vendorSnippetDbService.updateOrderItemPackaging(orderItemId, is_packaged); return { success: true, @@ -528,4 +344,4 @@ export const vendorSnippetsRouter = router({ is_packaged }; }), -}); \ No newline at end of file +}); diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/banner-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/banner-db-service.interface.ts new file mode 100644 index 0000000..f55a4ad --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/banner-db-service.interface.ts @@ -0,0 +1,12 @@ +import { homeBanners } from '@/src/db/schema' + +export type Banner = typeof homeBanners.$inferSelect +export type NewBanner = typeof homeBanners.$inferInsert + +export interface IBannerDbService { + getAllBanners(): Promise + getBannerById(id: number): Promise + createBanner(data: NewBanner): Promise + updateBannerById(id: number, data: Partial): Promise + deleteBannerById(id: number): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/complaint-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/complaint-db-service.interface.ts new file mode 100644 index 0000000..d0183cc --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/complaint-db-service.interface.ts @@ -0,0 +1,9 @@ +import { complaints, users } from '@/src/db/schema' + +export type Complaint = typeof complaints.$inferSelect +export type NewComplaint = typeof complaints.$inferInsert + +export interface IComplaintDbService { + getComplaints(cursor?: number, limit?: number): Promise> + resolveComplaint(id: number, response?: string): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/constant-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/constant-db-service.interface.ts new file mode 100644 index 0000000..7578c7a --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/constant-db-service.interface.ts @@ -0,0 +1,9 @@ +import { keyValStore } from '@/src/db/schema' + +export type Constant = typeof keyValStore.$inferSelect +export type NewConstant = typeof keyValStore.$inferInsert + +export interface IConstantDbService { + getAllConstants(): Promise + upsertConstants(constants: { key: string; value: any }[]): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/coupon-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/coupon-db-service.interface.ts new file mode 100644 index 0000000..cd2149e --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/coupon-db-service.interface.ts @@ -0,0 +1,46 @@ +import { coupons, couponApplicableUsers, couponApplicableProducts, reservedCoupons, users, orders, orderStatus, staffUsers, productInfo } from '@/src/db/schema' + +export type Coupon = typeof coupons.$inferSelect +export type NewCoupon = typeof coupons.$inferInsert +export type ReservedCoupon = typeof reservedCoupons.$inferSelect +export type NewReservedCoupon = typeof reservedCoupons.$inferInsert +export type CouponWithRelations = Omit & { + productIds: number[] | null + creator?: typeof staffUsers.$inferSelect + applicableUsers: Array + applicableProducts: Array +} + +export interface ICouponDbService { + // Regular coupons + createCoupon(data: NewCoupon): Promise + getCouponById(id: number): Promise + getCouponByCode(code: string): Promise + getAllCoupons(options: { cursor?: number; limit: number; search?: string }): Promise + updateCoupon(id: number, data: Partial): Promise + invalidateCoupon(id: number): Promise + + // Coupon applicable users/products + addApplicableUsers(couponId: number, userIds: number[]): Promise + addApplicableProducts(couponId: number, productIds: number[]): Promise + removeAllApplicableUsers(couponId: number): Promise + removeAllApplicableProducts(couponId: number): Promise + countApplicableUsers(couponId: number): Promise + + // Reserved coupons + createReservedCoupon(data: NewReservedCoupon): Promise + getReservedCoupons(options: { cursor?: number; limit: number; search?: string }): Promise + + // User operations + getUsersByIds(ids: number[]): Promise> + getUsersBySearch(search: string, limit: number, offset: number): Promise> + createUser(data: Partial): Promise + getUserByMobile(mobile: string): Promise + + // Order operations + getOrderByIdWithUserAndStatus(id: number): Promise + updateOrderStatusRefundCoupon(orderId: number, couponId: number): Promise + + // Transaction support + withTransaction(fn: (tx: any) => Promise): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/order-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/order-db-service.interface.ts new file mode 100644 index 0000000..fe8d3f0 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/order-db-service.interface.ts @@ -0,0 +1,108 @@ +import { + orders, + orderItems, + orderStatus, + users, + addresses, + refunds, + coupons, + couponUsage, + complaints, + payments, + deliverySlotInfo, + productInfo, + units, + paymentInfoTable, +} from '@/src/db/schema' + +export type Order = typeof orders.$inferSelect +export type OrderItem = typeof orderItems.$inferSelect +export type OrderStatus = typeof orderStatus.$inferSelect +export type User = typeof users.$inferSelect +export type Address = typeof addresses.$inferSelect +export type Refund = typeof refunds.$inferSelect +export type Coupon = typeof coupons.$inferSelect +export type CouponUsage = typeof couponUsage.$inferSelect +export type Complaint = typeof complaints.$inferSelect +export type Payment = typeof payments.$inferSelect +export type Slot = typeof deliverySlotInfo.$inferSelect +export type PaymentInfo = typeof paymentInfoTable.$inferSelect + +export type OrderWithRelations = Order & { + user: User + address: Address + slot: Slot | null + orderItems: Array< + OrderItem & { + product: typeof productInfo.$inferSelect & { unit: typeof units.$inferSelect | null } + } + > + payment?: Payment | null + paymentInfo?: PaymentInfo | null + orderStatus?: OrderStatus[] + refunds?: Refund[] +} + +export type OrderWithStatus = Order & { + orderStatus: OrderStatus[] +} + +export type OrderWithCouponUsages = Order & { + orderItems: Array + couponUsages: Array +} + +export interface IOrderDbService { + // Order updates + updateOrderNotes(orderId: number, adminNotes: string | null): Promise + removeDeliveryCharge(orderId: number, totalAmount: string): Promise + + // Order reads + getOrderById(orderId: number): Promise + getOrderWithRelations(orderId: number): Promise + getOrderWithDetails(orderId: number): Promise + getOrderWithStatus(orderId: number): Promise + + // Order status + getOrderStatusByOrderId(orderId: number): Promise + updateOrderStatusPackaged(orderId: number, isPackaged: boolean, isDelivered: boolean): Promise + updateOrderStatusDelivered(orderId: number, isDelivered: boolean): Promise + cancelOrderStatus(statusId: number, reason: string): Promise + + // Refunds + getRefundByOrderId(orderId: number): Promise + createRefund(orderId: number, refundStatus: string): Promise + + // Coupon usage + getCouponUsageByOrderId(orderId: number): Promise> + + // Order items + getOrderItemById(orderItemId: number): Promise + updateOrderItem(orderItemId: number, data: Partial): Promise + updateOrderItemsPackaged(orderId: number, isPackaged: boolean): Promise + + // Address + updateAddressCoords(addressId: number, latitude: number, longitude: number): Promise
+ + // Slot queries + getOrdersBySlotId(slotId: number): Promise + getOrdersBySlotIds(slotIds: number[]): Promise + getOrdersByDateRange(start: Date, end: Date, slotId?: number): Promise + + // Filtered orders + getAllOrdersWithFilters(options: { + cursor?: number + limit: number + slotId?: number | null + packagedFilter: 'all' | 'packaged' | 'not_packaged' + deliveredFilter: 'all' | 'delivered' | 'not_delivered' + cancellationFilter: 'all' | 'cancelled' | 'not_cancelled' + flashDeliveryFilter: 'all' | 'flash' | 'regular' + }): Promise + + // Batch updates + updateOrdersAndItemsInTransaction(data: Array<{ orderId: number; totalAmount: string; items: Array<{ id: number; price: string; discountedPrice: string }> }>): Promise + + // Delete + deleteOrderById(orderId: number): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/product-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/product-db-service.interface.ts new file mode 100644 index 0000000..c10fb32 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/product-db-service.interface.ts @@ -0,0 +1,53 @@ +import { productInfo, units, specialDeals, productSlots, productTags, productReviews, productGroupInfo, productGroupMembership } from '@/src/db/schema' + +export type Product = typeof productInfo.$inferSelect +export type NewProduct = typeof productInfo.$inferInsert +export type ProductGroup = typeof productGroupInfo.$inferSelect +export type NewProductGroup = typeof productGroupInfo.$inferInsert + +export interface IProductDbService { + // Product CRUD + getAllProducts(): Promise + getProductById(id: number): Promise + createProduct(data: NewProduct): Promise + updateProduct(id: number, data: Partial): Promise + deleteProduct(id: number): Promise + + // Product deals + getDealsByProductId(productId: number): Promise + createDeals(deals: Partial[]): Promise + deleteDealsByProductId(productId: number): Promise + + // Product tags + getTagsByProductId(productId: number): Promise> + createTagAssociations(associations: { productId: number; tagId: number }[]): Promise + deleteTagAssociationsByProductId(productId: number): Promise + + // Product slots + getProductSlotsBySlotId(slotId: number): Promise + getProductSlotsBySlotIds(slotIds: number[]): Promise + createProductSlot(slotId: number, productId: number): Promise + deleteProductSlotsBySlotId(slotId: number): Promise + deleteProductSlot(slotId: number, productId: number): Promise + + // Product reviews + getReviewsByProductId(productId: number, limit: number, offset: number): Promise<(typeof productReviews.$inferSelect & { userName: string | null })[]> + getReviewCountByProductId(productId: number): Promise + updateReview(reviewId: number, data: Partial): Promise + + // Product groups + getAllGroups(): Promise + getGroupById(id: number): Promise + createGroup(data: NewProductGroup): Promise + updateGroup(id: number, data: Partial): Promise + deleteGroup(id: number): Promise + deleteGroupMembershipsByGroupId(groupId: number): Promise + createGroupMemberships(memberships: { productId: number; groupId: number }[]): Promise + + // Unit validation + getUnitById(id: number): Promise + + // Batch operations + validateProductIdsExist(productIds: number[]): Promise + batchUpdateProducts(updates: { productId: number; data: Partial }[]): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/refund-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/refund-db-service.interface.ts new file mode 100644 index 0000000..b5cba0f --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/refund-db-service.interface.ts @@ -0,0 +1,18 @@ +import { refunds, orders, orderStatus, payments } from '@/src/db/schema' + +export type Refund = typeof refunds.$inferSelect +export type NewRefund = typeof refunds.$inferInsert + +export interface IRefundDbService { + // Refund operations + createRefund(data: NewRefund): Promise + updateRefund(id: number, data: Partial): Promise + getRefundByOrderId(orderId: number): Promise + + // Order operations + getOrderById(id: number): Promise + getOrderStatusByOrderId(orderId: number): Promise + + // Payment operations + getSuccessfulPaymentByOrderId(orderId: number): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/schedule-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/schedule-db-service.interface.ts new file mode 100644 index 0000000..ae9661e --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/schedule-db-service.interface.ts @@ -0,0 +1,13 @@ +import { productAvailabilitySchedules } from '@/src/db/schema' + +export type Schedule = typeof productAvailabilitySchedules.$inferSelect +export type NewSchedule = typeof productAvailabilitySchedules.$inferInsert + +export interface IScheduleDbService { + createSchedule(data: NewSchedule): Promise + getAllSchedules(): Promise + getScheduleById(id: number): Promise + getScheduleByName(name: string): Promise + updateSchedule(id: number, data: Partial): Promise + deleteSchedule(id: number): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/slot-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/slot-db-service.interface.ts new file mode 100644 index 0000000..7d83eea --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/slot-db-service.interface.ts @@ -0,0 +1,43 @@ +import { deliverySlotInfo, productSlots, vendorSnippets, productInfo, productGroupInfo } from '@/src/db/schema' + +export type Slot = typeof deliverySlotInfo.$inferSelect +export type NewSlot = typeof deliverySlotInfo.$inferInsert +export type ProductSlot = typeof productSlots.$inferSelect +export type NewProductSlot = typeof productSlots.$inferInsert + +export type SlotWithRelations = Slot & { + productSlots?: Array<{ product: { id: number; name: string; images: any } }> + vendorSnippets?: Array<{ id: number; snippetCode: string; slotId: number | null; productIds: number[]; validTill: Date | null; createdAt: Date; isPermanent: boolean | null }> +} + +export interface ISlotDbService { + // Slot CRUD + getAllSlots(): Promise + getActiveSlots(): Promise + getSlotById(id: number): Promise + createSlot(data: NewSlot): Promise + updateSlot(id: number, data: Partial): Promise + deactivateSlot(id: number): Promise + + // Product associations + getProductSlotsBySlotId(slotId: number): Promise + getProductSlotsBySlotIds(slotIds: number[]): Promise + createProductSlot(slotId: number, productId: number): Promise + deleteProductSlot(slotId: number, productId: number): Promise + deleteProductSlotsBySlotId(slotId: number): Promise + + // Vendor snippets + getVendorSnippetsBySlotId(slotId: number): Promise> + createVendorSnippet(data: { snippetCode: string; slotId: number; productIds: number[]; validTill?: Date }): Promise<{ id: number; snippetCode: string; slotId: number | null; productIds: number[]; validTill: Date | null; createdAt: Date; isPermanent: boolean | null }> + checkSnippetCodeExists(code: string): Promise + + // Product validation + validateProductsExist(productIds: number[]): Promise + getProductsByIds(productIds: number[]): Promise + + // Group validation + getGroupsByIds(groupIds: number[]): Promise> + + // Transaction support + withTransaction(fn: (tx: any) => Promise): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/staff-user-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/staff-user-db-service.interface.ts new file mode 100644 index 0000000..4080d7b --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/staff-user-db-service.interface.ts @@ -0,0 +1,40 @@ +import { staffUsers, staffRoles, users, userDetails, orders } from '@/src/db/schema' + +export type StaffUser = typeof staffUsers.$inferSelect +export type NewStaffUser = typeof staffUsers.$inferInsert +export type StaffRole = typeof staffRoles.$inferSelect + +// Flexible types for queries with relations +export type StaffUserWithRole = { + id: number + name: string + role?: { + id: number + roleName: string + rolePermissions: Array<{ + permission: { + id: number + permissionName: string + } + }> + } | null +} + +export interface IStaffUserDbService { + // Staff operations + getStaffUserByName(name: string): Promise + getAllStaff(): Promise + createStaffUser(data: NewStaffUser): Promise + + // Role operations + getRoleById(id: number): Promise + getAllRoles(): Promise> + + // User operations + getUsers(options: { cursor?: number; limit: number; search?: string }): Promise> + getUserById(id: number): Promise<(typeof users.$inferSelect & { userDetails?: typeof userDetails.$inferSelect | null; orders?: typeof orders.$inferSelect[] }) | undefined> + upsertUserDetails(data: Partial & { userId: number }): Promise + + // Order operations + getLastOrderByUserId(userId: number): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/store-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/store-db-service.interface.ts new file mode 100644 index 0000000..10438ce --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/store-db-service.interface.ts @@ -0,0 +1,14 @@ +import { storeInfo, productInfo } from '@/src/db/schema' + +export type Store = typeof storeInfo.$inferSelect +export type NewStore = typeof storeInfo.$inferInsert + +export interface IStoreDbService { + getAllStores(): Promise + getStoreById(id: number): Promise + createStore(data: NewStore): Promise + updateStore(id: number, data: Partial): Promise + deleteStore(id: number): Promise + assignProductsToStore(storeId: number, productIds: number[]): Promise + removeProductsFromStore(storeId: number): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/tag-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/tag-db-service.interface.ts new file mode 100644 index 0000000..e64f714 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/tag-db-service.interface.ts @@ -0,0 +1,13 @@ +import { productTagInfo } from '@/src/db/schema' + +export type Tag = typeof productTagInfo.$inferSelect +export type NewTag = typeof productTagInfo.$inferInsert + +export interface ITagDbService { + getAllTags(): Promise + getTagById(id: number): Promise + getTagByName(name: string): Promise + createTag(data: NewTag): Promise + updateTag(id: number, data: Partial): Promise + deleteTag(id: number): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/user-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/user-db-service.interface.ts new file mode 100644 index 0000000..80389d3 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/user-db-service.interface.ts @@ -0,0 +1,37 @@ +import { users, userDetails, orders, orderItems, orderStatus, complaints, notifCreds, unloggedUserTokens, userIncidents } from '@/src/db/schema' + +export type User = typeof users.$inferSelect +export type NewUser = typeof users.$inferInsert +export type UserDetail = typeof userDetails.$inferSelect + +export interface IUserDbService { + // User operations + getUserById(id: number): Promise + getUserByMobile(mobile: string): Promise + getUsers(options: { limit: number; cursor?: number; search?: string }): Promise + createUser(data: NewUser): Promise + + // User details + getUserDetailsByUserId(userId: number): Promise + upsertUserDetails(data: Partial & { userId: number }): Promise + + // Order operations + getOrdersByUserId(userId: number): Promise + getLastOrderByUserId(userId: number): Promise + getOrderCountByUserIds(userIds: number[]): Promise<{ userId: number; totalOrders: number }[]> + getLastOrderDateByUserIds(userIds: number[]): Promise<{ userId: number; lastOrderDate: Date | null }[]> + getOrderStatusByOrderIds(orderIds: number[]): Promise<{ orderId: number; isDelivered: boolean; isCancelled: boolean }[]> + getOrderItemCountByOrderIds(orderIds: number[]): Promise<{ orderId: number; itemCount: number }[]> + + // Complaint operations + getUnresolvedComplaintCount(): Promise + + // Notification operations + getAllNotifTokens(): Promise + getNotifTokensByUserIds(userIds: number[]): Promise + getUnloggedTokens(): Promise + + // User incidents + getUserIncidentsByUserId(userId: number): Promise } | null; addedBy?: { name: string | null } | null }>> + createUserIncident(data: { userId: number; orderId?: number | null; adminComment?: string | null; addedBy: number; negativityScore?: number | null }): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/vendor-snippet-db-service.interface.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/vendor-snippet-db-service.interface.ts new file mode 100644 index 0000000..b33ca79 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/interfaces/vendor-snippet-db-service.interface.ts @@ -0,0 +1,34 @@ +import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems } from '@/src/db/schema' + +export type VendorSnippet = typeof vendorSnippets.$inferSelect +export type NewVendorSnippet = typeof vendorSnippets.$inferInsert + +export interface IVendorSnippetDbService { + // Snippet CRUD + createSnippet(data: NewVendorSnippet): Promise + getAllSnippets(): Promise + getSnippetById(id: number): Promise + getSnippetByCode(code: string): Promise + updateSnippet(id: number, data: Partial): Promise + deleteSnippet(id: number): Promise + + // Validation + checkSnippetCodeExists(code: string): Promise + + // Slot operations + getSlotById(id: number): Promise + getUpcomingSlots(since: Date): Promise + + // Product operations + getProductsByIds(ids: number[]): Promise> + validateProductsExist(ids: number[]): Promise + + // Order operations + getOrdersBySlotId(slotId: number): Promise + getOrderItemsByOrderIds(orderIds: number[]): Promise + getOrderItemById(id: number): Promise + updateOrderItemPackaging(id: number, is_packaged: boolean): Promise + + // Relations check + hasSnippetForSlot(slotId: number): Promise +} diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/main.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/main.ts new file mode 100644 index 0000000..1c91892 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/main.ts @@ -0,0 +1,41 @@ +export type { IBannerDbService, Banner, NewBanner } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/banner-db-service.interface' +export { bannerDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/banner-queries' + +export type { IComplaintDbService, Complaint, NewComplaint } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/complaint-db-service.interface' +export { complaintDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/complaint-queries' + +export type { IConstantDbService, Constant, NewConstant } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/constant-db-service.interface' +export { constantDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/constant-queries' + +export type { ICouponDbService, Coupon, NewCoupon, ReservedCoupon, NewReservedCoupon, CouponWithRelations } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/coupon-db-service.interface' +export { couponDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/coupon-queries' + +export type { IOrderDbService, Order, OrderItem, OrderStatus, OrderWithRelations, OrderWithStatus, OrderWithCouponUsages } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/order-db-service.interface' +export { orderDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/order-queries' + +export type { IProductDbService, Product, NewProduct, ProductGroup, NewProductGroup } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/product-db-service.interface' +export { productDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/product-queries' + +export type { IRefundDbService, Refund, NewRefund } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/refund-db-service.interface' +export { refundDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/refund-queries' + +export type { IScheduleDbService, Schedule, NewSchedule } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/schedule-db-service.interface' +export { scheduleDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/schedule-queries' + +export type { ISlotDbService, Slot, NewSlot, ProductSlot, NewProductSlot, SlotWithRelations } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/slot-db-service.interface' +export { slotDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/slot-queries' + +export type { IStaffUserDbService, StaffUser, NewStaffUser, StaffRole, StaffUserWithRole } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/staff-user-db-service.interface' +export { staffUserDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/staff-user-queries' + +export type { IStoreDbService, Store, NewStore } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/store-db-service.interface' +export { storeDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/store-queries' + +export type { ITagDbService, Tag, NewTag } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/tag-db-service.interface' +export { tagDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/tag-queries' + +export type { IUserDbService, User, NewUser, UserDetail } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/user-db-service.interface' +export { userDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/user-queries' + +export type { IVendorSnippetDbService, VendorSnippet, NewVendorSnippet } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/vendor-snippet-db-service.interface' +export { vendorSnippetDbService } from '@/src/trpc/apis/admin-apis/dataAccessors/postgres/vendor-snippets-queries' diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/banner-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/banner-queries.ts new file mode 100644 index 0000000..6db5999 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/banner-queries.ts @@ -0,0 +1,38 @@ +import { db } from '@/src/db/db_index' +import { homeBanners } from '@/src/db/schema' +import { eq, desc } from 'drizzle-orm' +import { IBannerDbService, Banner, NewBanner } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/banner-db-service.interface' + +export class BannerDbService implements IBannerDbService { + async getAllBanners(): Promise { + return db.query.homeBanners.findMany({ + orderBy: desc(homeBanners.createdAt), + }) + } + + async getBannerById(id: number): Promise { + return db.query.homeBanners.findFirst({ + where: eq(homeBanners.id, id), + }) + } + + async createBanner(data: NewBanner): Promise { + const [banner] = await db.insert(homeBanners).values(data).returning() + return banner + } + + async updateBannerById(id: number, data: Partial): Promise { + const [banner] = await db + .update(homeBanners) + .set({ ...data, lastUpdated: new Date() }) + .where(eq(homeBanners.id, id)) + .returning() + return banner + } + + async deleteBannerById(id: number): Promise { + await db.delete(homeBanners).where(eq(homeBanners.id, id)) + } +} + +export const bannerDbService: IBannerDbService = new BannerDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/complaint-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/complaint-queries.ts new file mode 100644 index 0000000..79376aa --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/complaint-queries.ts @@ -0,0 +1,43 @@ +import { db } from '@/src/db/db_index' +import { complaints, users } from '@/src/db/schema' +import { eq, desc, lt } from 'drizzle-orm' +import { IComplaintDbService, Complaint, NewComplaint } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/complaint-db-service.interface' + +export class ComplaintDbService implements IComplaintDbService { + async getComplaints( + cursor?: number, + limit: number = 20 + ): Promise> { + let whereCondition = cursor ? lt(complaints.id, cursor) : undefined + + const complaintsData = await db + .select({ + id: complaints.id, + complaintBody: complaints.complaintBody, + userId: complaints.userId, + orderId: complaints.orderId, + isResolved: complaints.isResolved, + createdAt: complaints.createdAt, + response: complaints.response, + images: complaints.images, + userName: users.name, + userMobile: users.mobile, + }) + .from(complaints) + .leftJoin(users, eq(complaints.userId, users.id)) + .where(whereCondition) + .orderBy(desc(complaints.id)) + .limit(limit + 1) + + return complaintsData + } + + async resolveComplaint(id: number, response?: string): Promise { + await db + .update(complaints) + .set({ isResolved: true, response }) + .where(eq(complaints.id, id)) + } +} + +export const complaintDbService: IComplaintDbService = new ComplaintDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/constant-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/constant-queries.ts new file mode 100644 index 0000000..b6aceca --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/constant-queries.ts @@ -0,0 +1,25 @@ +import { db } from '@/src/db/db_index' +import { keyValStore } from '@/src/db/schema' +import { IConstantDbService, Constant, NewConstant } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/constant-db-service.interface' + +export class ConstantDbService implements IConstantDbService { + async getAllConstants(): Promise { + return db.select().from(keyValStore) + } + + async upsertConstants(constants: { key: string; value: any }[]): Promise { + await db.transaction(async (tx) => { + for (const { key, value } of constants) { + await tx.insert(keyValStore) + .values({ key, value }) + .onConflictDoUpdate({ + target: keyValStore.key, + set: { value }, + }) + } + }) + return constants.length + } +} + +export const constantDbService: IConstantDbService = new ConstantDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/coupon-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/coupon-queries.ts new file mode 100644 index 0000000..82e5644 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/coupon-queries.ts @@ -0,0 +1,191 @@ +import { db } from '@/src/db/db_index' +import { coupons, couponApplicableUsers, couponApplicableProducts, reservedCoupons, users, orders, orderStatus } from '@/src/db/schema' +import { eq, and, like, or, inArray, lt, asc } from 'drizzle-orm' +import { ICouponDbService, Coupon, NewCoupon, ReservedCoupon, NewReservedCoupon, CouponWithRelations } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/coupon-db-service.interface' + +export class CouponDbService implements ICouponDbService { + async createCoupon(data: NewCoupon): Promise { + const [coupon] = await db.insert(coupons).values(data).returning() + return coupon + } + + async getCouponById(id: number): Promise { + const result = await db.query.coupons.findFirst({ + where: eq(coupons.id, id), + with: { + creator: true, + applicableUsers: { with: { user: true } }, + applicableProducts: { with: { product: true } }, + }, + }) + if (!result) return undefined + return { + ...result, + productIds: (result.productIds as number[] | null) || null, + } as CouponWithRelations + } + + async getCouponByCode(code: string): Promise { + return db.query.coupons.findFirst({ + where: eq(coupons.couponCode, code), + }) + } + + async getAllCoupons(options: { cursor?: number; limit: number; search?: string }): Promise { + const { cursor, limit, search } = options + + let whereCondition = undefined + const conditions = [] + + if (cursor) { + conditions.push(lt(coupons.id, cursor)) + } + + if (search && search.trim()) { + conditions.push(like(coupons.couponCode, `%${search}%`)) + } + + if (conditions.length > 0) { + whereCondition = and(...conditions) + } + + const result = await db.query.coupons.findMany({ + where: whereCondition, + with: { + creator: true, + applicableUsers: { with: { user: true } }, + applicableProducts: { with: { product: true } }, + }, + orderBy: (coupons, { desc }) => [desc(coupons.createdAt)], + limit: limit + 1, + }) + + return result.map((coupon) => ({ + ...coupon, + productIds: (coupon.productIds as number[] | null) || null, + })) as CouponWithRelations[] + } + + async updateCoupon(id: number, data: Partial): Promise { + const [coupon] = await db.update(coupons).set(data).where(eq(coupons.id, id)).returning() + return coupon + } + + async invalidateCoupon(id: number): Promise { + const [coupon] = await db.update(coupons).set({ isInvalidated: true }).where(eq(coupons.id, id)).returning() + return coupon + } + + async addApplicableUsers(couponId: number, userIds: number[]): Promise { + await db.insert(couponApplicableUsers).values( + userIds.map(userId => ({ couponId, userId })) + ) + } + + async addApplicableProducts(couponId: number, productIds: number[]): Promise { + await db.insert(couponApplicableProducts).values( + productIds.map(productId => ({ couponId, productId })) + ) + } + + async removeAllApplicableUsers(couponId: number): Promise { + await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, couponId)) + } + + async removeAllApplicableProducts(couponId: number): Promise { + await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, couponId)) + } + + async countApplicableUsers(couponId: number): Promise { + return db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, couponId)) + } + + async createReservedCoupon(data: NewReservedCoupon): Promise { + const [coupon] = await db.insert(reservedCoupons).values(data).returning() + return coupon + } + + async getReservedCoupons(options: { cursor?: number; limit: number; search?: string }): Promise { + const { cursor, limit, search } = options + + let whereCondition = undefined + const conditions = [] + + if (cursor) { + conditions.push(lt(reservedCoupons.id, cursor)) + } + + if (search && search.trim()) { + conditions.push(or( + like(reservedCoupons.secretCode, `%${search}%`), + like(reservedCoupons.couponCode, `%${search}%`) + )) + } + + if (conditions.length > 0) { + whereCondition = and(...conditions) + } + + return db.query.reservedCoupons.findMany({ + where: whereCondition, + with: { redeemedUser: true, creator: true }, + orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)], + limit: limit + 1, + }) + } + + async getUsersByIds(ids: number[]): Promise> { + return db.query.users.findMany({ + where: inArray(users.id, ids), + columns: { id: true, name: true, mobile: true }, + }) + } + + async getUsersBySearch(search: string, limit: number, offset: number): Promise> { + const whereCondition = or( + like(users.name, `%${search}%`), + like(users.mobile, `%${search}%`) + ) + + return db.query.users.findMany({ + where: whereCondition, + columns: { id: true, name: true, mobile: true }, + limit, + offset, + orderBy: (users, { asc }) => [asc(users.name)], + }) + } + + async createUser(data: Partial): Promise { + const [user] = await db.insert(users).values(data).returning() + return user + } + + async getUserByMobile(mobile: string): Promise { + return db.query.users.findFirst({ + where: eq(users.mobile, mobile), + }) + } + + async getOrderByIdWithUserAndStatus(id: number): Promise { + return db.query.orders.findFirst({ + where: eq(orders.id, id), + with: { + user: true, + orderStatus: true, + }, + }) + } + + async updateOrderStatusRefundCoupon(orderId: number, couponId: number): Promise { + await db.update(orderStatus) + .set({ refundCouponId: couponId }) + .where(eq(orderStatus.orderId, orderId)) + } + + async withTransaction(fn: (tx: any) => Promise): Promise { + return db.transaction(fn) + } +} + +export const couponDbService: ICouponDbService = new CouponDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/order-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/order-queries.ts new file mode 100644 index 0000000..c68967a --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/order-queries.ts @@ -0,0 +1,334 @@ +import { db } from '@/src/db/db_index' +import { + orders, + orderItems, + orderStatus, + users, + addresses, + refunds, + coupons, + couponUsage, + complaints, + payments, + deliverySlotInfo, + productInfo, + units, + paymentInfoTable, +} from '@/src/db/schema' +import { eq, and, gte, lt, desc, inArray, SQL } from 'drizzle-orm' +import { + IOrderDbService, + Order, + OrderItem, + OrderStatus, + Address, + Refund, + OrderWithRelations, + OrderWithStatus, + OrderWithCouponUsages, +} from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/order-db-service.interface' + +export class OrderDbService implements IOrderDbService { + async updateOrderNotes(orderId: number, adminNotes: string | null): Promise { + const [updated] = await db + .update(orders) + .set({ adminNotes }) + .where(eq(orders.id, orderId)) + .returning() + return updated + } + + async removeDeliveryCharge(orderId: number, totalAmount: string): Promise { + await db + .update(orders) + .set({ deliveryCharge: '0', totalAmount }) + .where(eq(orders.id, orderId)) + } + + async getOrderById(orderId: number): Promise { + return db.query.orders.findFirst({ + where: eq(orders.id, orderId), + }) + } + + async getOrderWithRelations(orderId: number): Promise { + return db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { unit: true }, + }, + }, + }, + payment: true, + paymentInfo: true, + }, + }) as Promise + } + + async getOrderWithDetails(orderId: number): Promise { + return db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { unit: true }, + }, + }, + }, + payment: true, + paymentInfo: true, + orderStatus: true, + refunds: true, + }, + }) as Promise + } + + async getOrderWithStatus(orderId: number): Promise { + return db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + orderStatus: true, + }, + }) as Promise + } + + async getOrderStatusByOrderId(orderId: number): Promise { + return db.query.orderStatus.findFirst({ + where: eq(orderStatus.orderId, orderId), + }) + } + + async updateOrderStatusPackaged(orderId: number, isPackaged: boolean, isDelivered: boolean): Promise { + await db + .update(orderStatus) + .set({ isPackaged, isDelivered }) + .where(eq(orderStatus.orderId, orderId)) + } + + async updateOrderStatusDelivered(orderId: number, isDelivered: boolean): Promise { + await db + .update(orderStatus) + .set({ isDelivered }) + .where(eq(orderStatus.orderId, orderId)) + } + + async cancelOrderStatus(statusId: number, reason: string): Promise { + await db + .update(orderStatus) + .set({ + isCancelled: true, + isCancelledByAdmin: true, + cancelReason: reason, + cancellationAdminNotes: reason, + cancellationReviewed: true, + cancellationReviewedAt: new Date(), + }) + .where(eq(orderStatus.id, statusId)) + } + + async getRefundByOrderId(orderId: number): Promise { + return db.query.refunds.findFirst({ + where: eq(refunds.orderId, orderId), + }) + } + + async createRefund(orderId: number, refundStatus: string): Promise { + await db.insert(refunds).values({ orderId, refundStatus }) + } + + async getCouponUsageByOrderId(orderId: number): Promise> { + return db.query.couponUsage.findMany({ + where: eq(couponUsage.orderId, orderId), + with: { coupon: true }, + }) + } + + async getOrderItemById(orderItemId: number): Promise { + return db.query.orderItems.findFirst({ + where: eq(orderItems.id, orderItemId), + }) + } + + async updateOrderItem(orderItemId: number, data: Partial): Promise { + await db + .update(orderItems) + .set(data) + .where(eq(orderItems.id, orderItemId)) + } + + async updateOrderItemsPackaged(orderId: number, isPackaged: boolean): Promise { + await db + .update(orderItems) + .set({ is_packaged: isPackaged }) + .where(eq(orderItems.orderId, orderId)) + } + + async updateAddressCoords(addressId: number, latitude: number, longitude: number): Promise
{ + const [updated] = await db + .update(addresses) + .set({ adminLatitude: latitude, adminLongitude: longitude }) + .where(eq(addresses.id, addressId)) + .returning() + return updated + } + + async getOrdersBySlotId(slotId: number): Promise { + return db.query.orders.findMany({ + where: eq(orders.slotId, slotId), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { with: { unit: true } }, + }, + }, + orderStatus: true, + }, + }) as Promise + } + + async getOrdersBySlotIds(slotIds: number[]): Promise { + return db.query.orders.findMany({ + where: inArray(orders.slotId, slotIds), + with: { + orderItems: { + with: { + product: true, + }, + }, + couponUsages: { + with: { coupon: true }, + }, + }, + }) as Promise + } + + async getOrdersByDateRange(start: Date, end: Date, slotId?: number): Promise { + let whereCondition = and(gte(orders.createdAt, start), lt(orders.createdAt, end)) + + if (slotId) { + whereCondition = and(whereCondition, eq(orders.slotId, slotId)) + } + + return db.query.orders.findMany({ + where: whereCondition, + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { with: { unit: true } }, + }, + }, + orderStatus: true, + }, + }) as Promise + } + + async getAllOrdersWithFilters(options: { + cursor?: number + limit: number + slotId?: number | null + packagedFilter: 'all' | 'packaged' | 'not_packaged' + deliveredFilter: 'all' | 'delivered' | 'not_delivered' + cancellationFilter: 'all' | 'cancelled' | 'not_cancelled' + flashDeliveryFilter: 'all' | 'flash' | 'regular' + }): Promise { + const { + cursor, + limit, + slotId, + packagedFilter, + deliveredFilter, + cancellationFilter, + flashDeliveryFilter, + } = options + + let whereCondition: SQL | undefined = eq(orders.id, orders.id) + + if (cursor) { + whereCondition = and(whereCondition, lt(orders.id, cursor)) + } + if (slotId) { + whereCondition = and(whereCondition, eq(orders.slotId, slotId)) + } + if (packagedFilter === 'packaged') { + whereCondition = and(whereCondition, eq(orderStatus.isPackaged, true)) + } else if (packagedFilter === 'not_packaged') { + whereCondition = and(whereCondition, eq(orderStatus.isPackaged, false)) + } + if (deliveredFilter === 'delivered') { + whereCondition = and(whereCondition, eq(orderStatus.isDelivered, true)) + } else if (deliveredFilter === 'not_delivered') { + whereCondition = and(whereCondition, eq(orderStatus.isDelivered, false)) + } + if (cancellationFilter === 'cancelled') { + whereCondition = and(whereCondition, eq(orderStatus.isCancelled, true)) + } else if (cancellationFilter === 'not_cancelled') { + whereCondition = and(whereCondition, eq(orderStatus.isCancelled, false)) + } + if (flashDeliveryFilter === 'flash') { + whereCondition = and(whereCondition, eq(orders.isFlashDelivery, true)) + } else if (flashDeliveryFilter === 'regular') { + whereCondition = and(whereCondition, eq(orders.isFlashDelivery, false)) + } + + return db.query.orders.findMany({ + where: whereCondition, + orderBy: desc(orders.createdAt), + limit: limit + 1, + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { with: { unit: true } }, + }, + }, + orderStatus: true, + }, + }) as Promise + } + + async updateOrdersAndItemsInTransaction( + data: Array<{ orderId: number; totalAmount: string; items: Array<{ id: number; price: string; discountedPrice: string }> }> + ): Promise { + await db.transaction(async (tx) => { + for (const entry of data) { + await tx.update(orders).set({ totalAmount: entry.totalAmount }).where(eq(orders.id, entry.orderId)) + + for (const item of entry.items) { + await tx.update(orderItems).set({ price: item.price, discountedPrice: item.discountedPrice }).where(eq(orderItems.id, item.id)) + } + } + }) + } + + async deleteOrderById(orderId: number): Promise { + await db.transaction(async (tx) => { + await tx.delete(orderItems).where(eq(orderItems.orderId, orderId)) + await tx.delete(orderStatus).where(eq(orderStatus.orderId, orderId)) + await tx.delete(payments).where(eq(payments.orderId, orderId)) + await tx.delete(refunds).where(eq(refunds.orderId, orderId)) + await tx.delete(couponUsage).where(eq(couponUsage.orderId, orderId)) + await tx.delete(complaints).where(eq(complaints.orderId, orderId)) + await tx.delete(orders).where(eq(orders.id, orderId)) + }) + } +} + +export const orderDbService: IOrderDbService = new OrderDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/product-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/product-queries.ts new file mode 100644 index 0000000..8eb7091 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/product-queries.ts @@ -0,0 +1,226 @@ +import { db } from '@/src/db/db_index' +import { productInfo, units, specialDeals, productSlots, productTags, productReviews, productGroupInfo, productGroupMembership, users } from '@/src/db/schema' +import { eq, and, inArray, desc, sql } from 'drizzle-orm' +import { IProductDbService, Product, NewProduct, ProductGroup, NewProductGroup } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/product-db-service.interface' + +export class ProductDbService implements IProductDbService { + async getAllProducts(): Promise { + return db.query.productInfo.findMany({ + orderBy: productInfo.name, + with: { + unit: true, + store: true, + }, + }) + } + + async getProductById(id: number): Promise { + return db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + with: { + unit: true, + }, + }) + } + + async createProduct(data: NewProduct): Promise { + const [product] = await db.insert(productInfo).values(data).returning() + return product + } + + async updateProduct(id: number, data: Partial): Promise { + const [product] = await db + .update(productInfo) + .set(data) + .where(eq(productInfo.id, id)) + .returning() + return product + } + + async deleteProduct(id: number): Promise { + const [product] = await db + .delete(productInfo) + .where(eq(productInfo.id, id)) + .returning() + return product + } + + async getDealsByProductId(productId: number): Promise { + return db.query.specialDeals.findMany({ + where: eq(specialDeals.productId, productId), + orderBy: specialDeals.quantity, + }) + } + + async createDeals(deals: Partial[]): Promise { + if (deals.length > 0) { + await db.insert(specialDeals).values(deals as any) + } + } + + async deleteDealsByProductId(productId: number): Promise { + await db.delete(specialDeals).where(eq(specialDeals.productId, productId)) + } + + async getTagsByProductId(productId: number): Promise> { + return db.query.productTags.findMany({ + where: eq(productTags.productId, productId), + with: { + tag: true, + }, + }) as any + } + + async createTagAssociations(associations: { productId: number; tagId: number }[]): Promise { + if (associations.length > 0) { + await db.insert(productTags).values(associations) + } + } + + async deleteTagAssociationsByProductId(productId: number): Promise { + await db.delete(productTags).where(eq(productTags.productId, productId)) + } + + async getProductSlotsBySlotId(slotId: number): Promise { + return db.query.productSlots.findMany({ + where: eq(productSlots.slotId, slotId), + }) + } + + async getProductSlotsBySlotIds(slotIds: number[]): Promise { + return db.query.productSlots.findMany({ + where: inArray(productSlots.slotId, slotIds), + columns: { slotId: true, productId: true }, + }) + } + + async createProductSlot(slotId: number, productId: number): Promise { + await db.insert(productSlots).values({ slotId, productId }) + } + + async deleteProductSlotsBySlotId(slotId: number): Promise { + await db.delete(productSlots).where(eq(productSlots.slotId, slotId)) + } + + async deleteProductSlot(slotId: number, productId: number): Promise { + await db + .delete(productSlots) + .where(and(eq(productSlots.slotId, slotId), eq(productSlots.productId, productId))) + } + + async getReviewsByProductId(productId: number, limit: number, offset: number): Promise<(typeof productReviews.$inferSelect & { userName: string | null })[]> { + const reviews = await db + .select({ + id: productReviews.id, + reviewBody: productReviews.reviewBody, + ratings: productReviews.ratings, + imageUrls: productReviews.imageUrls, + reviewTime: productReviews.reviewTime, + adminResponse: productReviews.adminResponse, + adminResponseImages: productReviews.adminResponseImages, + 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) + + return reviews as any + } + + async getReviewCountByProductId(productId: number): Promise { + const result = await db + .select({ count: sql`count(*)` }) + .from(productReviews) + .where(eq(productReviews.productId, productId)) + return Number(result[0].count) + } + + async updateReview(reviewId: number, data: Partial): Promise { + const [review] = await db + .update(productReviews) + .set(data) + .where(eq(productReviews.id, reviewId)) + .returning() + return review + } + + async getAllGroups(): Promise { + return db.query.productGroupInfo.findMany({ + with: { + memberships: { + with: { + product: true, + }, + }, + }, + orderBy: desc(productGroupInfo.createdAt), + }) + } + + async getGroupById(id: number): Promise { + return db.query.productGroupInfo.findFirst({ + where: eq(productGroupInfo.id, id), + }) + } + + async createGroup(data: NewProductGroup): Promise { + const [group] = await db.insert(productGroupInfo).values(data).returning() + return group + } + + async updateGroup(id: number, data: Partial): Promise { + const [group] = await db + .update(productGroupInfo) + .set(data) + .where(eq(productGroupInfo.id, id)) + .returning() + return group + } + + async deleteGroup(id: number): Promise { + const [group] = await db + .delete(productGroupInfo) + .where(eq(productGroupInfo.id, id)) + .returning() + return group + } + + async deleteGroupMembershipsByGroupId(groupId: number): Promise { + await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, groupId)) + } + + async createGroupMemberships(memberships: { productId: number; groupId: number }[]): Promise { + if (memberships.length > 0) { + await db.insert(productGroupMembership).values(memberships) + } + } + + async getUnitById(id: number): Promise { + return db.query.units.findFirst({ + where: eq(units.id, id), + }) + } + + async validateProductIdsExist(productIds: number[]): Promise { + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + columns: { id: true }, + }) + return products.length === productIds.length + } + + async batchUpdateProducts(updates: { productId: number; data: Partial }[]): Promise { + const promises = updates.map(update => + db + .update(productInfo) + .set(update.data) + .where(eq(productInfo.id, update.productId)) + ) + await Promise.all(promises) + } +} + +export const productDbService: IProductDbService = new ProductDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/refund-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/refund-queries.ts new file mode 100644 index 0000000..3374d80 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/refund-queries.ts @@ -0,0 +1,49 @@ +import { db } from '@/src/db/db_index' +import { refunds, orders, orderStatus, payments } from '@/src/db/schema' +import { eq, and } from 'drizzle-orm' +import { IRefundDbService, Refund, NewRefund } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/refund-db-service.interface' + +export class RefundDbService implements IRefundDbService { + async createRefund(data: NewRefund): Promise { + const [refund] = await db.insert(refunds).values(data).returning() + return refund + } + + async updateRefund(id: number, data: Partial): Promise { + const [refund] = await db + .update(refunds) + .set(data) + .where(eq(refunds.id, id)) + .returning() + return refund + } + + async getRefundByOrderId(orderId: number): Promise { + return db.query.refunds.findFirst({ + where: eq(refunds.orderId, orderId), + }) + } + + async getOrderById(id: number): Promise { + return db.query.orders.findFirst({ + where: eq(orders.id, id), + }) + } + + async getOrderStatusByOrderId(orderId: number): Promise { + return db.query.orderStatus.findFirst({ + where: eq(orderStatus.orderId, orderId), + }) + } + + async getSuccessfulPaymentByOrderId(orderId: number): Promise { + return db.query.payments.findFirst({ + where: and( + eq(payments.orderId, orderId), + eq(payments.status, "success") + ), + }) + } +} + +export const refundDbService: IRefundDbService = new RefundDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/schedule-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/schedule-queries.ts new file mode 100644 index 0000000..da2e5e4 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/schedule-queries.ts @@ -0,0 +1,48 @@ +import { db } from '@/src/db/db_index' +import { productAvailabilitySchedules } from '@/src/db/schema' +import { eq, desc } from 'drizzle-orm' +import { IScheduleDbService, Schedule, NewSchedule } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/schedule-db-service.interface' + +export class ScheduleDbService implements IScheduleDbService { + async createSchedule(data: NewSchedule): Promise { + const [schedule] = await db.insert(productAvailabilitySchedules).values(data).returning() + return schedule + } + + async getAllSchedules(): Promise { + return db.query.productAvailabilitySchedules.findMany({ + orderBy: desc(productAvailabilitySchedules.createdAt), + }) + } + + async getScheduleById(id: number): Promise { + return db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.id, id), + }) + } + + async getScheduleByName(name: string): Promise { + return db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.scheduleName, name), + }) + } + + async updateSchedule(id: number, data: Partial): Promise { + const [schedule] = await db + .update(productAvailabilitySchedules) + .set({ ...data, lastUpdated: new Date() }) + .where(eq(productAvailabilitySchedules.id, id)) + .returning() + return schedule + } + + async deleteSchedule(id: number): Promise { + const [schedule] = await db + .delete(productAvailabilitySchedules) + .where(eq(productAvailabilitySchedules.id, id)) + .returning() + return schedule + } +} + +export const scheduleDbService: IScheduleDbService = new ScheduleDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/slot-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/slot-queries.ts new file mode 100644 index 0000000..6da0dd1 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/slot-queries.ts @@ -0,0 +1,142 @@ +import { db } from '@/src/db/db_index' +import { deliverySlotInfo, productSlots, vendorSnippets, productInfo, productGroupInfo } from '@/src/db/schema' +import { eq, inArray, and, desc } from 'drizzle-orm' +import { ISlotDbService, Slot, NewSlot, ProductSlot, SlotWithRelations } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/slot-db-service.interface' + +export class SlotDbService implements ISlotDbService { + async getAllSlots(): Promise { + return db.query.deliverySlotInfo.findMany({ + orderBy: desc(deliverySlotInfo.deliveryTime), + with: { + productSlots: { + with: { + product: { + columns: { id: true, name: true, images: true }, + }, + }, + }, + }, + }) as Promise + } + + async getActiveSlots(): Promise { + return db.query.deliverySlotInfo.findMany({ + where: eq(deliverySlotInfo.isActive, true), + orderBy: desc(deliverySlotInfo.deliveryTime), + }) + } + + async getSlotById(id: number): Promise { + return db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, id), + with: { + productSlots: { + with: { + product: { + columns: { id: true, name: true, images: true }, + }, + }, + }, + vendorSnippets: true, + }, + }) as Promise + } + + async createSlot(data: NewSlot): Promise { + const [slot] = await db.insert(deliverySlotInfo).values(data).returning() + return slot + } + + async updateSlot(id: number, data: Partial): Promise { + const [slot] = await db + .update(deliverySlotInfo) + .set(data) + .where(eq(deliverySlotInfo.id, id)) + .returning() + return slot + } + + async deactivateSlot(id: number): Promise { + const [slot] = await db + .update(deliverySlotInfo) + .set({ isActive: false }) + .where(eq(deliverySlotInfo.id, id)) + .returning() + return slot + } + + async getProductSlotsBySlotId(slotId: number): Promise { + return db.query.productSlots.findMany({ + where: eq(productSlots.slotId, slotId), + }) + } + + async getProductSlotsBySlotIds(slotIds: number[]): Promise { + return db.query.productSlots.findMany({ + where: inArray(productSlots.slotId, slotIds), + columns: { slotId: true, productId: true }, + }) + } + + async createProductSlot(slotId: number, productId: number): Promise { + await db.insert(productSlots).values({ slotId, productId }) + } + + async deleteProductSlot(slotId: number, productId: number): Promise { + await db + .delete(productSlots) + .where(and(eq(productSlots.slotId, slotId), eq(productSlots.productId, productId))) + } + + async deleteProductSlotsBySlotId(slotId: number): Promise { + await db.delete(productSlots).where(eq(productSlots.slotId, slotId)) + } + + async getVendorSnippetsBySlotId(slotId: number): Promise> { + return db.query.vendorSnippets.findMany({ + where: eq(vendorSnippets.slotId, slotId), + }) + } + + async createVendorSnippet(data: { snippetCode: string; slotId: number; productIds: number[]; validTill?: Date }): Promise<{ id: number; snippetCode: string; slotId: number | null; productIds: number[]; validTill: Date | null; createdAt: Date; isPermanent: boolean | null }> { + const [snippet] = await db.insert(vendorSnippets).values({ + snippetCode: data.snippetCode, + slotId: data.slotId, + productIds: data.productIds, + validTill: data.validTill || null, + }).returning() + return snippet + } + + async checkSnippetCodeExists(code: string): Promise { + const existing = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, code), + }) + return !!existing + } + + async validateProductsExist(productIds: number[]): Promise { + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + }) + return products.length === productIds.length + } + + async getProductsByIds(productIds: number[]): Promise { + return db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + }) + } + + async getGroupsByIds(groupIds: number[]): Promise> { + return db.query.productGroupInfo.findMany({ + where: inArray(productGroupInfo.id, groupIds), + }) + } + + async withTransaction(fn: (tx: any) => Promise): Promise { + return db.transaction(fn) + } +} + +export const slotDbService: ISlotDbService = new SlotDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/staff-user-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/staff-user-queries.ts new file mode 100644 index 0000000..ec39dc1 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/staff-user-queries.ts @@ -0,0 +1,104 @@ +import { db } from '@/src/db/db_index' +import { staffUsers, staffRoles, users, userDetails, orders } from '@/src/db/schema' +import { eq, or, ilike, and, lt, desc } from 'drizzle-orm' +import { IStaffUserDbService, StaffUser, NewStaffUser, StaffRole } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/staff-user-db-service.interface' + +export class StaffUserDbService implements IStaffUserDbService { + async getStaffUserByName(name: string): Promise { + return db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }) + } + + async getAllStaff(): Promise { + return db.query.staffUsers.findMany({ + columns: { id: true, name: true }, + with: { + role: { + with: { + rolePermissions: { + with: { permission: true }, + }, + }, + }, + }, + }) + } + + async createStaffUser(data: NewStaffUser): Promise { + const [user] = await db.insert(staffUsers).values(data).returning() + return user + } + + async getRoleById(id: number): Promise { + return db.query.staffRoles.findFirst({ + where: eq(staffRoles.id, id), + }) + } + + async getAllRoles(): Promise { + return db.query.staffRoles.findMany({ + columns: { id: true, roleName: true }, + }) + } + + async getUsers(options: { cursor?: number; limit: number; search?: string }): Promise { + const { cursor, limit, search } = options + + let whereCondition = undefined + + if (search) { + whereCondition = or( + ilike(users.name, `%${search}%`), + ilike(users.email, `%${search}%`), + ilike(users.mobile, `%${search}%`) + ) + } + + if (cursor) { + const cursorCondition = lt(users.id, cursor) + whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition + } + + return db.query.users.findMany({ + where: whereCondition, + with: { userDetails: true }, + orderBy: desc(users.id), + limit: limit + 1, + }) + } + + async getUserById(id: number): Promise { + return db.query.users.findFirst({ + where: eq(users.id, id), + with: { + userDetails: true, + orders: { + orderBy: desc(orders.createdAt), + limit: 1, + }, + }, + }) + } + + async upsertUserDetails(data: Partial & { userId: number }): Promise { + await db + .insert(userDetails) + .values(data) + .onConflictDoUpdate({ + target: userDetails.userId, + set: data, + }) + } + + async getLastOrderByUserId(userId: number): Promise { + const userOrders = await db.query.orders.findMany({ + where: eq(orders.userId, userId), + orderBy: desc(orders.createdAt), + limit: 1, + }) + return userOrders[0] + } +} + +export const staffUserDbService: IStaffUserDbService = new StaffUserDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/store-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/store-queries.ts new file mode 100644 index 0000000..09cd96a --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/store-queries.ts @@ -0,0 +1,53 @@ +import { db } from '@/src/db/db_index' +import { storeInfo, productInfo } from '@/src/db/schema' +import { eq, inArray } from 'drizzle-orm' +import { IStoreDbService, Store, NewStore } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/store-db-service.interface' + +export class StoreDbService implements IStoreDbService { + async getAllStores(): Promise { + return db.query.storeInfo.findMany({ + with: { owner: true }, + }) + } + + async getStoreById(id: number): Promise { + return db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, id), + with: { owner: true }, + }) + } + + async createStore(data: NewStore): Promise { + const [store] = await db.insert(storeInfo).values(data).returning() + return store + } + + async updateStore(id: number, data: Partial): Promise { + const [store] = await db + .update(storeInfo) + .set(data) + .where(eq(storeInfo.id, id)) + .returning() + return store + } + + async deleteStore(id: number): Promise { + await db.delete(storeInfo).where(eq(storeInfo.id, id)) + } + + async assignProductsToStore(storeId: number, productIds: number[]): Promise { + await db + .update(productInfo) + .set({ storeId }) + .where(inArray(productInfo.id, productIds)) + } + + async removeProductsFromStore(storeId: number): Promise { + await db + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, storeId)) + } +} + +export const storeDbService: IStoreDbService = new StoreDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/tag-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/tag-queries.ts new file mode 100644 index 0000000..e2eca0b --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/tag-queries.ts @@ -0,0 +1,42 @@ +import { db } from '@/src/db/db_index' +import { productTagInfo } from '@/src/db/schema' +import { eq } from 'drizzle-orm' +import { ITagDbService, Tag, NewTag } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/tag-db-service.interface' + +export class TagDbService implements ITagDbService { + async getAllTags(): Promise { + return db.select().from(productTagInfo).orderBy(productTagInfo.tagName) + } + + async getTagById(id: number): Promise { + return db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, id), + }) + } + + async getTagByName(name: string): Promise { + return db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.tagName, name.trim()), + }) + } + + async createTag(data: NewTag): Promise { + const [tag] = await db.insert(productTagInfo).values(data).returning() + return tag + } + + async updateTag(id: number, data: Partial): Promise { + const [tag] = await db + .update(productTagInfo) + .set(data) + .where(eq(productTagInfo.id, id)) + .returning() + return tag + } + + async deleteTag(id: number): Promise { + await db.delete(productTagInfo).where(eq(productTagInfo.id, id)) + } +} + +export const tagDbService: ITagDbService = new TagDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/user-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/user-queries.ts new file mode 100644 index 0000000..04a68b3 --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/user-queries.ts @@ -0,0 +1,170 @@ +import { db } from '@/src/db/db_index' +import { users, userDetails, orders, orderItems, orderStatus, complaints, notifCreds, unloggedUserTokens, userIncidents } from '@/src/db/schema' +import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm' +import { IUserDbService, User, NewUser, UserDetail } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/user-db-service.interface' + +export class UserDbService implements IUserDbService { + async getUserById(id: number): Promise { + return db.query.users.findFirst({ + where: eq(users.id, id), + }) + } + + async getUserByMobile(mobile: string): Promise { + return db.query.users.findFirst({ + where: eq(users.mobile, mobile), + }) + } + + async getUsers(options: { limit: number; cursor?: number; search?: string }): Promise { + const { limit, cursor, search } = options + + const whereConditions = [] + + if (search && search.trim()) { + whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`) + } + + if (cursor) { + whereConditions.push(sql`${users.id} > ${cursor}`) + } + + return db + .select() + .from(users) + .where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined) + .orderBy(asc(users.id)) + .limit(limit + 1) + } + + async createUser(data: NewUser): Promise { + const [user] = await db.insert(users).values(data).returning() + return user + } + + async getUserDetailsByUserId(userId: number): Promise { + return db.query.userDetails.findFirst({ + where: eq(userDetails.userId, userId), + }) + } + + async upsertUserDetails(data: Partial & { userId: number }): Promise { + await db + .insert(userDetails) + .values(data) + .onConflictDoUpdate({ + target: userDetails.userId, + set: data, + }) + } + + async getOrdersByUserId(userId: number): Promise { + return db + .select() + .from(orders) + .where(eq(orders.userId, userId)) + .orderBy(desc(orders.createdAt)) + } + + async getLastOrderByUserId(userId: number): Promise { + const userOrders = await db + .select() + .from(orders) + .where(eq(orders.userId, userId)) + .orderBy(desc(orders.createdAt)) + .limit(1) + return userOrders[0] + } + + async getOrderCountByUserIds(userIds: number[]): Promise<{ userId: number; totalOrders: number }[]> { + if (userIds.length === 0) return [] + return db + .select({ + userId: orders.userId, + totalOrders: count(orders.id), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId) + } + + async getLastOrderDateByUserIds(userIds: number[]): Promise<{ userId: number; lastOrderDate: Date | null }[]> { + if (userIds.length === 0) return [] + return db + .select({ + userId: orders.userId, + lastOrderDate: max(orders.createdAt), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId) + } + + async getOrderStatusByOrderIds(orderIds: number[]): Promise<{ orderId: number; isDelivered: boolean; isCancelled: boolean }[]> { + if (orderIds.length === 0) return [] + return db + .select({ + orderId: orderStatus.orderId, + isDelivered: orderStatus.isDelivered, + isCancelled: orderStatus.isCancelled, + }) + .from(orderStatus) + .where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`) + } + + async getOrderItemCountByOrderIds(orderIds: number[]): Promise<{ orderId: number; itemCount: number }[]> { + if (orderIds.length === 0) return [] + return db + .select({ + orderId: orderItems.orderId, + itemCount: count(orderItems.id), + }) + .from(orderItems) + .where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`) + .groupBy(orderItems.orderId) + } + + async getUnresolvedComplaintCount(): Promise { + return db.$count(complaints, eq(complaints.isResolved, false)) + } + + async getAllNotifTokens(): Promise { + const tokens = await db.select({ token: notifCreds.token }).from(notifCreds) + return tokens.map(t => t.token) + } + + async getNotifTokensByUserIds(userIds: number[]): Promise { + const tokens = await db + .select({ token: notifCreds.token }) + .from(notifCreds) + .where(inArray(notifCreds.userId, userIds)) + return tokens.map(t => t.token) + } + + async getUnloggedTokens(): Promise { + const tokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens) + return tokens.map(t => t.token) + } + + async getUserIncidentsByUserId(userId: number): Promise } | null; addedBy?: { name: string | null } | null }>> { + return db.query.userIncidents.findMany({ + where: eq(userIncidents.userId, userId), + with: { + order: { + with: { + orderStatus: true, + }, + }, + addedBy: true, + }, + orderBy: desc(userIncidents.dateAdded), + }) + } + + async createUserIncident(data: { userId: number; orderId?: number | null; adminComment?: string | null; addedBy: number; negativityScore?: number | null }): Promise { + const [incident] = await db.insert(userIncidents).values(data).returning() + return incident + } +} + +export const userDbService: IUserDbService = new UserDbService() diff --git a/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/vendor-snippets-queries.ts b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/vendor-snippets-queries.ts new file mode 100644 index 0000000..5e1f40f --- /dev/null +++ b/apps/backend/src/trpc/apis/admin-apis/dataAccessors/postgres/vendor-snippets-queries.ts @@ -0,0 +1,132 @@ +import { db } from '@/src/db/db_index' +import { vendorSnippets, deliverySlotInfo, orders, orderItems, productInfo } from '@/src/db/schema' +import { eq, and, inArray, gt, asc, desc } from 'drizzle-orm' +import { IVendorSnippetDbService, VendorSnippet, NewVendorSnippet } from '@/src/trpc/apis/admin-apis/dataAccessors/interfaces/vendor-snippet-db-service.interface' + +export class VendorSnippetDbService implements IVendorSnippetDbService { + async createSnippet(data: NewVendorSnippet): Promise { + const [snippet] = await db.insert(vendorSnippets).values(data).returning() + return snippet + } + + async getAllSnippets(): Promise { + return db.query.vendorSnippets.findMany({ + with: { slot: true }, + orderBy: desc(vendorSnippets.createdAt), + }) + } + + async getSnippetById(id: number): Promise { + return db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.id, id), + with: { slot: true }, + }) + } + + async getSnippetByCode(code: string): Promise { + return db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, code), + }) + } + + async updateSnippet(id: number, data: Partial): Promise { + const [snippet] = await db + .update(vendorSnippets) + .set(data) + .where(eq(vendorSnippets.id, id)) + .returning() + return snippet + } + + async deleteSnippet(id: number): Promise { + const [snippet] = await db + .delete(vendorSnippets) + .where(eq(vendorSnippets.id, id)) + .returning() + return snippet + } + + async checkSnippetCodeExists(code: string): Promise { + const existing = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, code), + }) + return !!existing + } + + async getSlotById(id: number): Promise { + return db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, id), + }) + } + + async getUpcomingSlots(since: Date): Promise { + return db.query.deliverySlotInfo.findMany({ + where: and( + eq(deliverySlotInfo.isActive, true), + gt(deliverySlotInfo.deliveryTime, since) + ), + orderBy: asc(deliverySlotInfo.deliveryTime), + }) + } + + async getProductsByIds(ids: number[]): Promise> { + return db.query.productInfo.findMany({ + where: inArray(productInfo.id, ids), + columns: { id: true, name: true }, + }) + } + + async validateProductsExist(ids: number[]): Promise { + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, ids), + }) + return products.length === ids.length + } + + async getOrdersBySlotId(slotId: number): Promise { + return db.query.orders.findMany({ + where: eq(orders.slotId, slotId), + with: { + orderItems: { + with: { + product: { + with: { unit: true }, + }, + }, + }, + orderStatus: true, + user: true, + slot: true, + }, + orderBy: desc(orders.createdAt), + }) + } + + async getOrderItemsByOrderIds(orderIds: number[]): Promise { + return db.query.orderItems.findMany({ + where: inArray(orderItems.orderId, orderIds), + }) + } + + async getOrderItemById(id: number): Promise { + return db.query.orderItems.findFirst({ + where: eq(orderItems.id, id), + }) + } + + async updateOrderItemPackaging(id: number, is_packaged: boolean): Promise { + await db + .update(orderItems) + .set({ is_packaged }) + .where(eq(orderItems.id, id)) + } + + async hasSnippetForSlot(slotId: number): Promise { + const snippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.slotId, slotId), + }) + return !!snippet + } +} + +export const vendorSnippetDbService: IVendorSnippetDbService = new VendorSnippetDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/apis/address.ts b/apps/backend/src/trpc/apis/user-apis/apis/address.ts index 0022c7e..0268f5b 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/address.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/address.ts @@ -1,29 +1,28 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; -import { db } from '@/src/db/db_index'; -import { addresses, orders, orderStatus, deliverySlotInfo } from '@/src/db/schema'; -import { eq, and, gte } from 'drizzle-orm'; -import dayjs from 'dayjs'; import { extractCoordsFromRedirectUrl } from '@/src/lib/license-util'; +import { userAddressDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' export const addressRouter = router({ getDefaultAddress: protectedProcedure .query(async ({ ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new Error('Unauthorized') + } - const [defaultAddress] = await db - .select() - .from(addresses) - .where(and(eq(addresses.userId, userId), eq(addresses.isDefault, true))) - .limit(1); + const defaultAddress = await userAddressDbService.getDefaultAddress(userId) return { success: true, data: defaultAddress || null }; }), getUserAddresses: protectedProcedure .query(async ({ ctx }) => { - const userId = ctx.user.userId; - const userAddresses = await db.select().from(addresses).where(eq(addresses.userId, userId)); + const userId = ctx.user?.userId; + if (!userId) { + throw new Error('Unauthorized') + } + const userAddresses = await userAddressDbService.getUserAddresses(userId) return { success: true, data: userAddresses }; }), @@ -42,7 +41,10 @@ export const addressRouter = router({ googleMapsUrl: z.string().optional(), })) .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new Error('Unauthorized') + } const { name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input; let { latitude, longitude } = input; @@ -62,10 +64,10 @@ export const addressRouter = router({ // If setting as default, unset other defaults if (isDefault) { - await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId)); + await userAddressDbService.unsetDefaultForUser(userId) } - const [newAddress] = await db.insert(addresses).values({ + const newAddress = await userAddressDbService.createAddress({ userId, name, phone, @@ -78,7 +80,7 @@ export const addressRouter = router({ latitude, longitude, googleMapsUrl, - }).returning(); + }) return { success: true, data: newAddress }; }), @@ -99,7 +101,10 @@ export const addressRouter = router({ googleMapsUrl: z.string().optional(), })) .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new Error('Unauthorized') + } const { id, name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input; let { latitude, longitude } = input; @@ -113,14 +118,14 @@ export const addressRouter = router({ } // Check if address exists and belongs to user - const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1); - if (existingAddress.length === 0) { + const existingAddress = await userAddressDbService.getAddressByIdForUser(id, userId) + if (!existingAddress) { throw new Error('Address not found'); } // If setting as default, unset other defaults if (isDefault) { - await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId)); + await userAddressDbService.unsetDefaultForUser(userId) } const updateData: any = { @@ -142,7 +147,7 @@ export const addressRouter = router({ updateData.longitude = longitude; } - const [updatedAddress] = await db.update(addresses).set(updateData).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).returning(); + const updatedAddress = await userAddressDbService.updateAddressForUser(id, userId, updateData) return { success: true, data: updatedAddress }; }), @@ -152,42 +157,32 @@ export const addressRouter = router({ id: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new Error('Unauthorized') + } const { id } = input; // Check if address exists and belongs to user - const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1); - if (existingAddress.length === 0) { + const existingAddress = await userAddressDbService.getAddressByIdForUser(id, userId) + if (!existingAddress) { throw new Error('Address not found or does not belong to user'); } // Check if address is attached to any ongoing orders using joins - const ongoingOrders = await db.select({ - order: orders, - status: orderStatus, - slot: deliverySlotInfo - }) - .from(orders) - .innerJoin(orderStatus, eq(orders.id, orderStatus.orderId)) - .innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id)) - .where(and( - eq(orders.addressId, id), - eq(orderStatus.isCancelled, false), - gte(deliverySlotInfo.deliveryTime, new Date()) - )) - .limit(1); + const hasOngoingOrders = await userAddressDbService.hasOngoingOrdersForAddress(id) - if (ongoingOrders.length > 0) { + if (hasOngoingOrders) { throw new Error('Address is attached to an ongoing order. Please cancel the order first.'); } // Prevent deletion of default address - if (existingAddress[0].isDefault) { + if (existingAddress.isDefault) { throw new Error('Cannot delete default address. Please set another address as default first.'); } // Delete the address - await db.delete(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))); + await userAddressDbService.deleteAddressForUser(id, userId) return { success: true, message: 'Address deleted successfully' }; }), diff --git a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts index 986f8e8..d59bf64 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/auth.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/auth.ts @@ -1,20 +1,12 @@ import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; import bcrypt from 'bcryptjs'; -import { eq } from 'drizzle-orm'; -import { db } from '@/src/db/db_index'; -import { - users, userCreds, userDetails, addresses, cartItems, complaints, - couponApplicableUsers, couponUsage, notifCreds, notifications, - orderItems, orderStatus, orders, payments, refunds, - productReviews, reservedCoupons -} from '@/src/db/schema'; import { generateSignedUrlFromS3Url, claimUploadUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'; import { deleteS3Image } from '@/src/lib/delete-image'; import { ApiError } from '@/src/lib/api-error'; -import catchAsync from '@/src/lib/catch-async'; import { sendOtp, verifyOtpUtil, getOtpCreds } from '@/src/lib/otp-utils'; import { signToken } from '@/src/lib/jwt-utils'; +import { userAuthDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' interface LoginRequest { identifier: string; // email or mobile @@ -64,22 +56,11 @@ export const authRouter = router({ } // Find user by email or mobile - const [user] = await db - .select() - .from(users) - .where(eq(users.email, identifier.toLowerCase())) - .limit(1); - - let foundUser = user; + let foundUser = await userAuthDbService.getUserByEmail(identifier.toLowerCase()) if (!foundUser) { // Try mobile if email didn't work - const [userByMobile] = await db - .select() - .from(users) - .where(eq(users.mobile, identifier)) - .limit(1); - foundUser = userByMobile; + foundUser = await userAuthDbService.getUserByMobile(identifier) } if (!foundUser) { @@ -87,22 +68,14 @@ export const authRouter = router({ } // Get user credentials - const [userCredentials] = await db - .select() - .from(userCreds) - .where(eq(userCreds.userId, foundUser.id)) - .limit(1); + const userCredentials = await userAuthDbService.getUserCredsByUserId(foundUser.id) if (!userCredentials) { throw new ApiError('Account setup incomplete. Please contact support.', 401); } // Get user details for profile image - const [userDetail] = await db - .select() - .from(userDetails) - .where(eq(userDetails.userId, foundUser.id)) - .limit(1); + const userDetail = await userAuthDbService.getUserDetailsByUserId(foundUser.id) // Generate signed URL for profile image if it exists const profileImageSignedUrl = userDetail?.profileImage @@ -167,22 +140,14 @@ export const authRouter = router({ } // Check if email already exists - const [existingEmail] = await db - .select() - .from(users) - .where(eq(users.email, email.toLowerCase())) - .limit(1); + const existingEmail = await userAuthDbService.getUserByEmail(email.toLowerCase()) if (existingEmail) { throw new ApiError('Email already registered', 409); } // Check if mobile already exists - const [existingMobile] = await db - .select() - .from(users) - .where(eq(users.mobile, cleanMobile)) - .limit(1); + const existingMobile = await userAuthDbService.getUserByMobile(cleanMobile) if (existingMobile) { throw new ApiError('Mobile number already registered', 409); @@ -192,35 +157,13 @@ export const authRouter = router({ const hashedPassword = await bcrypt.hash(password, 12); // Create user and credentials in a transaction - const newUser = await db.transaction(async (tx) => { - // Create user - const [user] = await tx - .insert(users) - .values({ - name: name.trim(), - email: email.toLowerCase().trim(), - mobile: cleanMobile, - }) - .returning(); - - // Create user credentials - await tx - .insert(userCreds) - .values({ - userId: user.id, - userPassword: hashedPassword, - }); - - // Create user details with profile image if provided - if (imageKey) { - await tx.insert(userDetails).values({ - userId: user.id, - profileImage: imageKey, - }); - } - - return user; - }); + const newUser = await userAuthDbService.createUserWithCredsAndDetails({ + name: name.trim(), + email: email.toLowerCase().trim(), + mobile: cleanMobile, + passwordHash: hashedPassword, + imageKey: imageKey || null, + }) // Claim upload URL if image was provided if (imageKey) { @@ -234,11 +177,7 @@ export const authRouter = router({ const token = await generateToken(newUser.id); // Get user details for profile image - const [userDetail] = await db - .select() - .from(userDetails) - .where(eq(userDetails.userId, newUser.id)) - .limit(1); + const userDetail = await userAuthDbService.getUserDetailsByUserId(newUser.id) const profileImageUrl = userDetail?.profileImage ? scaffoldAssetUrl(userDetail.profileImage) @@ -288,21 +227,15 @@ export const authRouter = router({ } // Find user - let user = await db.query.users.findFirst({ - where: eq(users.mobile, input.mobile), - }); + let user = await userAuthDbService.getUserByMobile(input.mobile) // If user doesn't exist, create one if (!user) { - const [newUser] = await db - .insert(users) - .values({ - name: null, - email: null, - mobile: input.mobile, - }) - .returning(); - user = newUser; + user = await userAuthDbService.createUser({ + name: null, + email: null, + mobile: input.mobile, + }) } // Generate JWT @@ -327,60 +260,34 @@ export const authRouter = router({ password: z.string().min(6, 'Password must be at least 6 characters'), })) .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; if (!userId) { throw new ApiError('User not authenticated', 401); } const hashedPassword = await bcrypt.hash(input.password, 10); - // Insert if not exists, then update if exists - try { - await db.insert(userCreds).values({ - userId: userId, - userPassword: hashedPassword, - }); - // Insert succeeded - new credentials created - } catch (error: any) { - // Insert failed - check if it's a unique constraint violation - if (error.code === '23505') { // PostgreSQL unique constraint violation - // Update existing credentials - await db.update(userCreds).set({ - userPassword: hashedPassword, - }).where(eq(userCreds.userId, userId)); - } else { - // Re-throw if it's a different error - throw error; - } - } + await userAuthDbService.upsertUserCreds(userId, hashedPassword) return { success: true, message: 'Password updated successfully' }; }), getProfile: protectedProcedure .query(async ({ ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; if (!userId) { throw new ApiError('User not authenticated', 401); } - const [user] = await db - .select() - .from(users) - .where(eq(users.id, userId)) - .limit(1); + const user = await userAuthDbService.getUserById(userId) if (!user) { throw new ApiError('User not found', 404); } // Get user details for profile image - const [userDetail] = await db - .select() - .from(userDetails) - .where(eq(userDetails.userId, userId)) - .limit(1); + const userDetail = await userAuthDbService.getUserDetailsByUserId(userId) const profileImageUrl = userDetail?.profileImage ? scaffoldAssetUrl(userDetail.profileImage) @@ -413,7 +320,7 @@ export const authRouter = router({ imageKey: z.string().optional(), })) .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; const { imageKey, ...updateData } = input; if (!userId) { @@ -421,9 +328,7 @@ export const authRouter = router({ } // Get current user details - const currentDetail = await db.query.userDetails.findFirst({ - where: eq(userDetails.userId, userId), - }); + const currentDetail = await userAuthDbService.getUserDetailsByUserId(userId) let newImageUrl: string | null | undefined = currentDetail?.profileImage; @@ -449,46 +354,26 @@ export const authRouter = router({ // Update user name if provided if (updateData.name) { - await db.update(users) - .set({ name: updateData.name.trim() }) - .where(eq(users.id, userId)); + await userAuthDbService.updateUserName(userId, updateData.name.trim()) } // Update user email if provided if (updateData.email) { // Check if email already exists (but belongs to different user) - const [existingUser] = await db - .select() - .from(users) - .where(eq(users.email, updateData.email.toLowerCase().trim())) - .limit(1); + const existingUser = await userAuthDbService.getUserByEmail(updateData.email.toLowerCase().trim()) if (existingUser && existingUser.id !== userId) { throw new ApiError('Email already in use by another account', 409); } - await db.update(users) - .set({ email: updateData.email.toLowerCase().trim() }) - .where(eq(users.id, userId)); + await userAuthDbService.updateUserEmail(userId, updateData.email.toLowerCase().trim()) } // Upsert user details - if (currentDetail) { - // Update existing - await db.update(userDetails) - .set({ - ...updateData, - profileImage: newImageUrl, - }) - .where(eq(userDetails.userId, userId)); - } else { - // Insert new - await db.insert(userDetails).values({ - userId: userId, - ...updateData, - profileImage: newImageUrl, - }); - } + await userAuthDbService.upsertUserDetails(userId, { + ...updateData, + profileImage: newImageUrl, + }) return { success: true, @@ -501,7 +386,7 @@ export const authRouter = router({ mobile: z.string().min(10, 'Mobile number is required'), })) .mutation(async ({ ctx, input }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; const { mobile } = input; if (!userId) { @@ -509,10 +394,7 @@ export const authRouter = router({ } // Double-check: verify user exists and is the authenticated user - const existingUser = await db.query.users.findFirst({ - where: eq(users.id, userId), - columns: { id: true, mobile: true }, - }); + const existingUser = await userAuthDbService.getUserById(userId) if (!existingUser) { throw new ApiError('User not found', 404); @@ -533,48 +415,7 @@ export const authRouter = router({ } // Use transaction for atomic deletion - await db.transaction(async (tx) => { - // Phase 1: Direct references (safe to delete first) - await tx.delete(notifCreds).where(eq(notifCreds.userId, userId)); - await tx.delete(couponApplicableUsers).where(eq(couponApplicableUsers.userId, userId)); - await tx.delete(couponUsage).where(eq(couponUsage.userId, userId)); - await tx.delete(complaints).where(eq(complaints.userId, userId)); - await tx.delete(cartItems).where(eq(cartItems.userId, userId)); - await tx.delete(notifications).where(eq(notifications.userId, userId)); - await tx.delete(productReviews).where(eq(productReviews.userId, userId)); - - // Update reserved coupons (set redeemedBy to null) - await tx.update(reservedCoupons) - .set({ redeemedBy: null }) - .where(eq(reservedCoupons.redeemedBy, userId)); - - // Phase 2: Order dependencies - const userOrders = await tx - .select({ id: orders.id }) - .from(orders) - .where(eq(orders.userId, userId)); - - for (const order of userOrders) { - await tx.delete(orderItems).where(eq(orderItems.orderId, order.id)); - await tx.delete(orderStatus).where(eq(orderStatus.orderId, order.id)); - await tx.delete(payments).where(eq(payments.orderId, order.id)); - await tx.delete(refunds).where(eq(refunds.orderId, order.id)); - // Additional coupon usage entries linked to specific orders - await tx.delete(couponUsage).where(eq(couponUsage.orderId, order.id)); - await tx.delete(complaints).where(eq(complaints.orderId, order.id)); - } - - // Delete orders - await tx.delete(orders).where(eq(orders.userId, userId)); - - // Phase 3: Addresses (now safe since orders are deleted) - await tx.delete(addresses).where(eq(addresses.userId, userId)); - - // Phase 4: Core user data - await tx.delete(userDetails).where(eq(userDetails.userId, userId)); - await tx.delete(userCreds).where(eq(userCreds.userId, userId)); - await tx.delete(users).where(eq(users.id, userId)); - }); + await userAuthDbService.deleteAccountByUserId(userId) return { success: true, message: 'Account deleted successfully' }; }), diff --git a/apps/backend/src/trpc/apis/user-apis/apis/banners.ts b/apps/backend/src/trpc/apis/user-apis/apis/banners.ts index 8e6a001..70c83a8 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/banners.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/banners.ts @@ -1,14 +1,9 @@ -import { db } from '@/src/db/db_index'; -import { homeBanners } from '@/src/db/schema'; import { publicProcedure, router } from '@/src/trpc/trpc-index'; import { scaffoldAssetUrl } from '@/src/lib/s3-client'; -import { isNotNull, asc } from 'drizzle-orm'; +import { userBannerDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' export async function scaffoldBanners() { - const banners = await db.query.homeBanners.findMany({ - where: isNotNull(homeBanners.serialNum), // Only show assigned banners - orderBy: asc(homeBanners.serialNum), // Order by slot number 1-4 - }); + const banners = await userBannerDbService.getActiveBanners() // Convert S3 keys to signed URLs for client const bannersWithSignedUrls = banners.map((banner) => ({ diff --git a/apps/backend/src/trpc/apis/user-apis/apis/cart.ts b/apps/backend/src/trpc/apis/user-apis/apis/cart.ts index a2495bc..a7185c5 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/cart.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/cart.ts @@ -1,11 +1,9 @@ import { router, protectedProcedure, publicProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; -import { db } from '@/src/db/db_index'; -import { cartItems, productInfo, units, productSlots, deliverySlotInfo } from '@/src/db/schema'; -import { eq, and, sql, inArray, gt } from 'drizzle-orm'; import { ApiError } from '@/src/lib/api-error'; -import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client'; -import { getProductSlots, getMultipleProductsSlots } from '@/src/stores/slot-store'; +import { scaffoldAssetUrl } from '@/src/lib/s3-client'; +import { getMultipleProductsSlots } from '@/src/stores/slot-store'; +import { userCartDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' interface CartResponse { items: any[]; @@ -14,23 +12,7 @@ interface CartResponse { } const getCartData = async (userId: number): Promise => { - const cartItemsWithProducts = await db - .select({ - cartId: cartItems.id, - productId: productInfo.id, - productName: productInfo.name, - productPrice: productInfo.price, - productImages: productInfo.images, - productQuantity: productInfo.productQuantity, - isOutOfStock: productInfo.isOutOfStock, - unitShortNotation: units.shortNotation, - quantity: cartItems.quantity, - addedAt: cartItems.addedAt, - }) - .from(cartItems) - .innerJoin(productInfo, eq(cartItems.productId, productInfo.id)) - .innerJoin(units, eq(productInfo.unitId, units.id)) - .where(eq(cartItems.userId, userId)); + const cartItemsWithProducts = await userCartDbService.getCartItemsWithProducts(userId) // Generate signed URLs for images const cartWithSignedUrls = await Promise.all( @@ -64,7 +46,10 @@ const getCartData = async (userId: number): Promise => { export const cartRouter = router({ getCart: protectedProcedure .query(async ({ ctx }): Promise => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('Unauthorized', 401) + } return await getCartData(userId); }), @@ -74,7 +59,10 @@ export const cartRouter = router({ quantity: z.number().int().positive(), })) .mutation(async ({ input, ctx }): Promise => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('Unauthorized', 401) + } const { productId, quantity } = input; // Validate input @@ -83,33 +71,21 @@ export const cartRouter = router({ } // Check if product exists - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, productId), - }); + const product = await userCartDbService.getProductById(productId) if (!product) { throw new ApiError("Product not found", 404); } // Check if item already exists in cart - const existingItem = await db.query.cartItems.findFirst({ - where: and(eq(cartItems.userId, userId), eq(cartItems.productId, productId)), - }); + const existingItem = await userCartDbService.getCartItemByUserAndProduct(userId, productId) if (existingItem) { // Update quantity - await db.update(cartItems) - .set({ - quantity: sql`${cartItems.quantity} + ${quantity}`, - }) - .where(eq(cartItems.id, existingItem.id)); + await userCartDbService.incrementCartItemQuantity(existingItem.id, quantity) } else { // Insert new item - await db.insert(cartItems).values({ - userId, - productId, - quantity: quantity.toString(), - }); + await userCartDbService.createCartItem(userId, productId, quantity) } // Return updated cart @@ -122,20 +98,17 @@ export const cartRouter = router({ quantity: z.number().int().min(0), })) .mutation(async ({ input, ctx }): Promise => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('Unauthorized', 401) + } const { itemId, quantity } = input; if (!quantity || quantity <= 0) { throw new ApiError("Positive quantity required", 400); } - const [updatedItem] = await db.update(cartItems) - .set({ quantity: quantity.toString() }) - .where(and( - eq(cartItems.id, itemId), - eq(cartItems.userId, userId) - )) - .returning(); + const updatedItem = await userCartDbService.updateCartItemQuantity(itemId, userId, quantity) if (!updatedItem) { throw new ApiError("Cart item not found", 404); @@ -150,15 +123,13 @@ export const cartRouter = router({ itemId: z.number().int().positive(), })) .mutation(async ({ input, ctx }): Promise => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('Unauthorized', 401) + } const { itemId } = input; - const [deletedItem] = await db.delete(cartItems) - .where(and( - eq(cartItems.id, itemId), - eq(cartItems.userId, userId) - )) - .returning(); + const deletedItem = await userCartDbService.deleteCartItem(itemId, userId) if (!deletedItem) { throw new ApiError("Cart item not found", 404); @@ -170,9 +141,12 @@ export const cartRouter = router({ clearCart: protectedProcedure .mutation(async ({ ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('Unauthorized', 401) + } - await db.delete(cartItems).where(eq(cartItems.userId, userId)); + await userCartDbService.clearCart(userId) return { items: [], diff --git a/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts b/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts index 8006f13..8d2be02 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/complaint.ts @@ -1,28 +1,17 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; -import { db } from '@/src/db/db_index'; -import { complaints } from '@/src/db/schema'; -import { eq } from 'drizzle-orm'; import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'; +import { userComplaintDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' export const complaintRouter = router({ getAll: protectedProcedure .query(async ({ ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new Error('Unauthorized') + } - const userComplaints = await db - .select({ - id: complaints.id, - complaintBody: complaints.complaintBody, - response: complaints.response, - isResolved: complaints.isResolved, - createdAt: complaints.createdAt, - orderId: complaints.orderId, - images: complaints.images, - }) - .from(complaints) - .where(eq(complaints.userId, userId)) - .orderBy(complaints.createdAt); + const userComplaints = await userComplaintDbService.getComplaintsByUserId(userId) return { complaints: userComplaints.map(c => ({ @@ -44,10 +33,13 @@ export const complaintRouter = router({ imageKeys: z.array(z.string()).optional(), })) .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new Error('Unauthorized') + } const { orderId, complaintBody, imageKeys } = input; - await db.insert(complaints).values({ + await userComplaintDbService.createComplaint({ userId, orderId: orderId || null, complaintBody: complaintBody.trim(), diff --git a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts index 6eab804..3b18986 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/coupon.ts @@ -1,16 +1,9 @@ import { router, protectedProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; -import { db } from '@/src/db/db_index'; -import { coupons, couponUsage, couponApplicableUsers, reservedCoupons, couponApplicableProducts } from '@/src/db/schema'; -import { eq, and, or, gt, isNull, sql } from 'drizzle-orm'; import { ApiError } from '@/src/lib/api-error'; +import { userCouponDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' -import { users } from '@/src/db/schema'; - -type CouponWithRelations = typeof coupons.$inferSelect & { - applicableUsers: (typeof couponApplicableUsers.$inferSelect & { user: typeof users.$inferSelect })[]; - usages: typeof couponUsage.$inferSelect[]; -}; +type CouponWithRelations = import('@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-coupon-db-service.interface').CouponWithRelations export interface EligibleCoupon { id: number; @@ -65,33 +58,13 @@ export const userCouponRouter = router({ .query(async ({ ctx }) => { try { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('User not authenticated', 401) + } // Get all active, non-expired coupons - const allCoupons = await db.query.coupons.findMany({ - where: and( - eq(coupons.isInvalidated, false), - or( - isNull(coupons.validTill), - gt(coupons.validTill, new Date()) - ) - ), - with: { - usages: { - where: eq(couponUsage.userId, userId) - }, - applicableUsers: { - with: { - user: true - } - }, - applicableProducts: { - with: { - product: true - } - }, - } - }); + const allCoupons = await userCouponDbService.getActiveCouponsForUser(userId) // Filter to only coupons applicable to current user const applicableCoupons = allCoupons.filter(coupon => { @@ -111,34 +84,14 @@ export const userCouponRouter = router({ getProductCoupons: protectedProcedure .input(z.object({ productId: z.number().int().positive() })) .query(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('User not authenticated', 401) + } const { productId } = input; // Get all active, non-expired coupons - const allCoupons = await db.query.coupons.findMany({ - where: and( - eq(coupons.isInvalidated, false), - or( - isNull(coupons.validTill), - gt(coupons.validTill, new Date()) - ) - ), - with: { - usages: { - where: eq(couponUsage.userId, userId) - }, - applicableUsers: { - with: { - user: true - } - }, - applicableProducts: { - with: { - product: true - } - }, - } - }); + const allCoupons = await userCouponDbService.getActiveCouponsForUser(userId) // Filter to only coupons applicable to current user and product const applicableCoupons = allCoupons.filter(coupon => { @@ -156,21 +109,13 @@ export const userCouponRouter = router({ getMyCoupons: protectedProcedure .query(async ({ ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('User not authenticated', 401) + } // Get all coupons - const allCoupons = await db.query.coupons.findMany({ - with: { - usages: { - where: eq(couponUsage.userId, userId) - }, - applicableUsers: { - with: { - user: true - } - } - } - }); + const allCoupons = await userCouponDbService.getAllCouponsForUser(userId) // Filter coupons in JS: not invalidated, applicable to user, and not expired const applicableCoupons = (allCoupons as CouponWithRelations[]).filter(coupon => { @@ -226,16 +171,14 @@ export const userCouponRouter = router({ redeemReservedCoupon: protectedProcedure .input(z.object({ secretCode: z.string() })) .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('User not authenticated', 401) + } const { secretCode } = input; // Find the reserved coupon - const reservedCoupon = await db.query.reservedCoupons.findFirst({ - where: and( - eq(reservedCoupons.secretCode, secretCode.toUpperCase()), - eq(reservedCoupons.isRedeemed, false) - ), - }); + const reservedCoupon = await userCouponDbService.getReservedCouponBySecretCode(secretCode) if (!reservedCoupon) { throw new ApiError("Invalid or already redeemed coupon code", 400); @@ -247,49 +190,7 @@ export const userCouponRouter = router({ } // Create the coupon in the main table - const couponResult = await db.transaction(async (tx) => { - // Insert into coupons - const couponInsert = await tx.insert(coupons).values({ - couponCode: reservedCoupon.couponCode, - isUserBased: true, - discountPercent: reservedCoupon.discountPercent, - flatDiscount: reservedCoupon.flatDiscount, - minOrder: reservedCoupon.minOrder, - productIds: reservedCoupon.productIds, - maxValue: reservedCoupon.maxValue, - isApplyForAll: false, - validTill: reservedCoupon.validTill, - maxLimitForUser: reservedCoupon.maxLimitForUser, - exclusiveApply: reservedCoupon.exclusiveApply, - createdBy: reservedCoupon.createdBy, - }).returning(); - - const coupon = couponInsert[0]; - - // Insert into couponApplicableUsers - await tx.insert(couponApplicableUsers).values({ - couponId: coupon.id, - userId, - }); - - // Copy applicable products - if (reservedCoupon.productIds && Array.isArray(reservedCoupon.productIds) && reservedCoupon.productIds.length > 0) { - // Assuming productIds are the IDs, but wait, in schema, productIds is jsonb, but in relations, couponApplicableProducts has productId - // For simplicity, since reservedCoupons has productIds as jsonb, but to match, perhaps insert into couponApplicableProducts if needed - // But in createReservedCoupon, I inserted applicableProducts into couponApplicableProducts - // So for reserved, perhaps do the same, but since it's jsonb, maybe not. - // For now, skip, as the coupon will have productIds in coupons table. - } - - // Update reserved coupon as redeemed - await tx.update(reservedCoupons).set({ - isRedeemed: true, - redeemedBy: userId, - redeemedAt: new Date(), - }).where(eq(reservedCoupons.id, reservedCoupon.id)); - - return coupon; - }); + const couponResult = await userCouponDbService.redeemReservedCoupon(userId, reservedCoupon) return { success: true, coupon: couponResult }; }), diff --git a/apps/backend/src/trpc/apis/user-apis/apis/order.ts b/apps/backend/src/trpc/apis/user-apis/apis/order.ts index 8118683..198f2c2 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/order.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/order.ts @@ -1,33 +1,22 @@ -import { router, protectedProcedure } from "@/src/trpc/trpc-index"; -import { z } from "zod"; -import { db } from "@/src/db/db_index"; -import { - orders, - orderItems, - orderStatus, - addresses, - productInfo, - paymentInfoTable, - coupons, - couponUsage, - payments, - cartItems, - refunds, - units, - userDetails, -} from "@/src/db/schema"; -import { eq, and, inArray, desc, gte, lte } from "drizzle-orm"; -import { scaffoldAssetUrl } from "@/src/lib/s3-client"; -import { ApiError } from "@/src/lib/api-error"; +import { z } from 'zod' +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { userOrderDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' +import type { + OrderCoupon, + OrderInsert, + OrderItemInsert, + OrderStatusInsert, +} from '@/src/trpc/apis/user-apis/dataAccessors/main' +import { scaffoldAssetUrl } from '@/src/lib/s3-client' +import { ApiError } from '@/src/lib/api-error' import { sendOrderPlacedNotification, sendOrderCancelledNotification, -} from "@/src/lib/notif-job"; -import { RazorpayPaymentService } from "@/src/lib/payments-utils"; -import { getNextDeliveryDate } from "@/src/trpc/apis/common-apis/common"; -import { CONST_KEYS, getConstant, getConstants } from "@/src/lib/const-store"; -import { publishFormattedOrder, publishCancellation } from "@/src/lib/post-order-handler"; -import { getSlotById } from "@/src/stores/slot-store"; +} from '@/src/lib/notif-job' +import { getNextDeliveryDate } from '@/src/trpc/apis/common-apis/common' +import { CONST_KEYS, getConstant, getConstants } from '@/src/lib/const-store' +import { publishFormattedOrder, publishCancellation } from '@/src/lib/post-order-handler' +import { getSlotById } from '@/src/stores/slot-store' const validateAndGetCoupon = async ( @@ -35,40 +24,35 @@ const validateAndGetCoupon = async ( userId: number, totalAmount: number ) => { - if (!couponId) return null; + if (!couponId) return null - const coupon = await db.query.coupons.findFirst({ - where: eq(coupons.id, couponId), - with: { - usages: { where: eq(couponUsage.userId, userId) }, - }, - }); + const coupon = await userOrderDbService.getCouponWithUsage(couponId, userId) - if (!coupon) throw new ApiError("Invalid coupon", 400); + if (!coupon) throw new ApiError('Invalid coupon', 400) if (coupon.isInvalidated) - throw new ApiError("Coupon is no longer valid", 400); + 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); + throw new ApiError('Coupon has expired', 400) if ( coupon.maxLimitForUser && coupon.usages.length >= coupon.maxLimitForUser ) - throw new ApiError("Coupon usage limit exceeded", 400); + 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", + 'Order amount does not meet coupon minimum requirement', 400 - ); + ) - return coupon; + return coupon }; const applyDiscountToOrder = ( orderTotal: number, - appliedCoupon: typeof coupons.$inferSelect | null, + appliedCoupon: OrderCoupon | null, proportion: number ) => { let finalOrderTotal = orderTotal; @@ -140,11 +124,9 @@ const placeOrderUtil = async (params: { const orderGroupId = `${Date.now()}-${userId}`; - const address = await db.query.addresses.findFirst({ - where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)), - }); + const address = await userOrderDbService.getAddressByUserId(userId, addressId) if (!address) { - throw new ApiError("Invalid address", 400); + throw new ApiError('Invalid address', 400) } const ordersBySlot = new Map< @@ -158,11 +140,9 @@ const placeOrderUtil = async (params: { >(); for (const item of selectedItems) { - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, item.productId), - }); + const product = await userOrderDbService.getProductById(item.productId) if (!product) { - throw new ApiError(`Product ${item.productId} not found`, 400); + throw new ApiError(`Product ${item.productId} not found`, 400) } if (!ordersBySlot.has(item.slotId)) { @@ -173,11 +153,12 @@ const placeOrderUtil = async (params: { if (params.isFlash) { for (const item of selectedItems) { - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, item.productId), - }); + const product = await userOrderDbService.getProductById(item.productId) if (!product?.isFlashAvailable) { - throw new ApiError(`Product ${item.productId} is not available for flash delivery`, 400); + throw new ApiError( + `Product ${item.productId} is not available for flash delivery`, + 400 + ) } } } @@ -204,10 +185,10 @@ const placeOrderUtil = async (params: { const totalWithDelivery = totalAmount + expectedDeliveryCharge; type OrderData = { - order: Omit; - orderItems: Omit[]; - orderStatus: Omit; - }; + order: Omit + orderItems: Omit[] + orderStatus: Omit + } const ordersData: OrderData[] = []; let isFirstOrder = true; @@ -233,7 +214,7 @@ const placeOrderUtil = async (params: { orderGroupProportion ); - const order: Omit = { + const order: Omit = { userId, addressId, slotId: params.isFlash ? null : slotId, @@ -249,7 +230,7 @@ const placeOrderUtil = async (params: { isFlashDelivery: params.isFlash, }; - const orderItemsData: Omit[] = items.map( + const orderItemsData: Omit[] = items.map( (item) => ({ orderId: 0, productId: item.productId, @@ -265,7 +246,7 @@ const placeOrderUtil = async (params: { }) ); - const orderStatusData: Omit = { + const orderStatusData: Omit = { userId, orderId: 0, paymentStatus: paymentMethod === "cod" ? "cod" : "pending", @@ -275,79 +256,22 @@ const placeOrderUtil = async (params: { isFirstOrder = false; } - const createdOrders = await db.transaction(async (tx) => { - 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 = await userOrderDbService.createOrdersWithItems({ + ordersData, + paymentMethod, + }) - const ordersToInsert: Omit[] = ordersData.map( - (od) => ({ - ...od.order, - paymentInfoId: sharedPaymentInfoId, - }) - ); - - const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning(); - - const allOrderItems: Omit[] = []; - const allOrderStatuses: Omit[] = []; - - insertedOrders.forEach((order, index) => { - const od = ordersData[index]; - od.orderItems.forEach((item) => { - allOrderItems.push({ ...item, orderId: order.id as number }); - }); - allOrderStatuses.push({ - ...od.orderStatus, - orderId: order.id as number, - }); - }); - - await tx.insert(orderItems).values(allOrderItems); - await tx.insert(orderStatus).values(allOrderStatuses); - - if (paymentMethod === "online" && sharedPaymentInfoId) { - const razorpayOrder = await RazorpayPaymentService.createOrder( - sharedPaymentInfoId, - totalWithDelivery.toString() - ); - await RazorpayPaymentService.insertPaymentRecord( - sharedPaymentInfoId, - razorpayOrder, - tx - ); - } - - return insertedOrders; - }); - - await db.delete(cartItems).where( - and( - eq(cartItems.userId, userId), - inArray( - cartItems.productId, - selectedItems.map((item) => item.productId) - ) - ) - ); + await userOrderDbService.deleteCartItemsByUserAndProductIds( + userId, + selectedItems.map((item) => item.productId) + ) if (appliedCoupon && createdOrders.length > 0) { - await db.insert(couponUsage).values({ + await userOrderDbService.createCouponUsage({ userId, couponId: appliedCoupon.id, orderId: createdOrders[0].id as number, - orderItemId: null, - usedAt: new Date(), - }); + }) } for (const order of createdOrders) { @@ -378,12 +302,13 @@ export const orderRouter = router({ }) ) .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId + if (!userId) { + throw new ApiError('Unauthorized', 401) + } // Check if user is suspended from placing orders - const userDetail = await db.query.userDetails.findFirst({ - where: eq(userDetails.userId, userId), - }); + const userDetail = await userOrderDbService.getUserDetailByUserId(userId) if (userDetail?.isSuspended) { throw new ApiError("Unable to place order", 403); @@ -402,7 +327,10 @@ export const orderRouter = router({ if (isFlashDelivery) { const isFlashDeliveryEnabled = await getConstant(CONST_KEYS.isFlashDeliveryEnabled); if (!isFlashDeliveryEnabled) { - throw new ApiError("Flash delivery is currently unavailable. Please opt for scheduled delivery.", 403); + throw new ApiError( + 'Flash delivery is currently unavailable. Please opt for scheduled delivery.', + 403 + ) } } @@ -410,9 +338,12 @@ export const orderRouter = router({ if (!isFlashDelivery) { const slotIds = [...new Set(selectedItems.filter(i => i.slotId !== null).map(i => i.slotId as number))]; for (const slotId of slotIds) { - const slot = await getSlotById(slotId); + const slot = await getSlotById(slotId) if (slot?.isCapacityFull) { - throw new ApiError("Selected delivery slot is at full capacity. Please choose another slot.", 403); + throw new ApiError( + 'Selected delivery slot is at full capacity. Please choose another slot.', + 403 + ) } } } @@ -422,10 +353,10 @@ export const orderRouter = router({ // Handle flash delivery slot resolution if (isFlashDelivery) { // For flash delivery, set slotId to null (no specific slot assigned) - processedItems = selectedItems.map(item => ({ + processedItems = selectedItems.map((item) => ({ ...item, slotId: null as any, // Type override for flash delivery - })); + })) } return await placeOrderUtil({ @@ -450,33 +381,20 @@ export const orderRouter = router({ ) .query(async ({ input, ctx }) => { const { page = 1, pageSize = 10 } = input || {}; - const userId = ctx.user.userId; + const userId = ctx.user?.userId + if (!userId) { + throw new ApiError('Unauthorized', 401) + } const offset = (page - 1) * pageSize; // Get total count for pagination - const totalCountResult = await db.$count( - orders, - eq(orders.userId, userId) - ); - const totalCount = totalCountResult; + const totalCount = await userOrderDbService.getOrdersCount(userId) - 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 userOrders = await userOrderDbService.getOrdersWithRelations( + userId, + pageSize, + offset + ) const mappedOrders = await Promise.all( userOrders.map(async (order) => { @@ -574,38 +492,24 @@ export const orderRouter = router({ .input(z.object({ orderId: z.string() })) .query(async ({ input, ctx }) => { const { orderId } = input; - const userId = ctx.user.userId; + const userId = ctx.user?.userId + if (!userId) { + throw new ApiError('Unauthorized', 401) + } - 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, - }, - }); + const order = await userOrderDbService.getOrderWithDetailsById( + parseInt(orderId), + userId + ) 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, - }, - }); + const couponUsageData = await userOrderDbService.getCouponUsagesByOrderId( + order.id + ) let couponData = null; if (couponUsageData.length > 0) { @@ -734,16 +638,14 @@ export const orderRouter = router({ ) .mutation(async ({ input, ctx }) => { try { - const userId = ctx.user.userId; + const userId = ctx.user?.userId + if (!userId) { + throw new ApiError('Unauthorized', 401) + } const { id, reason } = input; // Check if order exists and belongs to user - const order = await db.query.orders.findFirst({ - where: eq(orders.id, Number(id)), - with: { - orderStatus: true, - }, - }); + const order = await userOrderDbService.getOrderWithStatus(Number(id)) if (!order) { console.error("Order not found:", id); @@ -777,29 +679,17 @@ export const orderRouter = router({ } // 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' - // Determine refund status based on payment method - const refundStatus = order.isCod ? "na" : "pending"; + await userOrderDbService.cancelOrderTransaction({ + statusId: status.id, + reason, + orderId: order.id, + refundStatus, + }) - // Insert refund record - await tx.insert(refunds).values({ - orderId: order.id, - refundStatus, - }); - - return { orderId: order.id, userId }; - }); + const result = { orderId: order.id, userId } // Send notification outside transaction (idempotent operation) await sendOrderCancelledNotification( @@ -810,10 +700,10 @@ export const orderRouter = router({ // Publish to Redis for Telegram notification await publishCancellation(result.orderId, 'user', reason); - return { success: true, message: "Order cancelled successfully" }; + return { success: true, message: 'Order cancelled successfully' } } catch (e) { console.log(e); - throw new ApiError("failed to cancel order"); + throw new ApiError('failed to cancel order') } }), @@ -825,7 +715,10 @@ export const orderRouter = router({ }) ) .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId + if (!userId) { + throw new ApiError('Unauthorized', 401) + } const { id, userNotes } = input; // Extract readable ID from orderId (e.g., ORD001 -> 1) @@ -837,12 +730,7 @@ export const orderRouter = router({ // 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, - }, - }); + const order = await userOrderDbService.getOrderWithStatus(Number(id)) if (!order) { console.error("Order not found:", id); @@ -876,14 +764,9 @@ export const orderRouter = router({ } // Update user notes - await db - .update(orders) - .set({ - userNotes: userNotes || null, - }) - .where(eq(orders.id, order.id)); + await userOrderDbService.updateOrderNotes(order.id, userNotes || null) - return { success: true, message: "Notes updated successfully" }; + return { success: true, message: 'Notes updated successfully' } }), getRecentlyOrderedProducts: protectedProcedure @@ -896,25 +779,20 @@ export const orderRouter = router({ ) .query(async ({ input, ctx }) => { const { limit = 20 } = input || {}; - const userId = ctx.user.userId; + const userId = ctx.user?.userId + if (!userId) { + throw new ApiError('Unauthorized', 401) + } // 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 + const recentOrders = await userOrderDbService.getRecentDeliveredOrderIds( + userId, + thirtyDaysAgo, + 10 + ) if (recentOrders.length === 0) { return { success: true, products: [] }; @@ -923,10 +801,9 @@ export const orderRouter = router({ 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 orderItemsResult = await userOrderDbService.getProductIdsByOrderIds( + orderIds + ) const productIds = [ ...new Set(orderItemsResult.map((item) => item.productId)), @@ -937,27 +814,10 @@ export const orderRouter = router({ } // 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); + const productsWithUnits = await userOrderDbService.getProductsWithUnitsByIds( + productIds, + limit + ) // Generate signed URLs for product images const formattedProducts = await Promise.all( diff --git a/apps/backend/src/trpc/apis/user-apis/apis/payments.ts b/apps/backend/src/trpc/apis/user-apis/apis/payments.ts deleted file mode 100644 index 0adb0bd..0000000 --- a/apps/backend/src/trpc/apis/user-apis/apis/payments.ts +++ /dev/null @@ -1,158 +0,0 @@ - -import { router, protectedProcedure } from '@/src/trpc/trpc-index'; -import { z } from 'zod'; -import { db } from '@/src/db/db_index'; -import { orders, payments, orderStatus } from '@/src/db/schema'; -import { eq } from 'drizzle-orm'; -import { ApiError } from '@/src/lib/api-error'; -import crypto from 'crypto'; -import { razorpayId, razorpaySecret } from "@/src/lib/env-exporter"; -import { DiskPersistedSet } from "@/src/lib/disk-persisted-set"; -import { RazorpayPaymentService } from "@/src/lib/payments-utils"; - - - - -export const paymentRouter = router({ - createRazorpayOrder: protectedProcedure //either create a new payment order or return the existing one - .input(z.object({ - orderId: z.string(), - })) - .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; - const { orderId } = input; - - // Validate order exists and belongs to user - const order = await db.query.orders.findFirst({ - where: eq(orders.id, parseInt(orderId)), - }); - - if (!order) { - throw new ApiError("Order not found", 404); - } - - if (order.userId !== userId) { - throw new ApiError("Order does not belong to user", 403); - } - - // Check for existing pending payment - const existingPayment = await db.query.payments.findFirst({ - where: eq(payments.orderId, parseInt(orderId)), - }); - - if (existingPayment && existingPayment.status === 'pending') { - return { - razorpayOrderId: existingPayment.merchantOrderId, - key: razorpayId, - }; - } - - // Create Razorpay order and insert payment record - const razorpayOrder = await RazorpayPaymentService.createOrder(parseInt(orderId), order.totalAmount); - await RazorpayPaymentService.insertPaymentRecord(parseInt(orderId), razorpayOrder); - - return { - razorpayOrderId: razorpayOrder.id, - key: razorpayId, - }; - }), - - - - verifyPayment: protectedProcedure - .input(z.object({ - razorpay_payment_id: z.string(), - razorpay_order_id: z.string(), - razorpay_signature: z.string(), - })) - .mutation(async ({ input, ctx }) => { - const { razorpay_payment_id, razorpay_order_id, razorpay_signature } = input; - - // Verify signature - const expectedSignature = crypto - .createHmac('sha256', razorpaySecret) - .update(razorpay_order_id + '|' + razorpay_payment_id) - .digest('hex'); - - if (expectedSignature !== razorpay_signature) { - throw new ApiError("Invalid payment signature", 400); - } - - // Get current payment record - const currentPayment = await db.query.payments.findFirst({ - where: eq(payments.merchantOrderId, razorpay_order_id), - }); - - if (!currentPayment) { - throw new ApiError("Payment record not found", 404); - } - - // Update payment status and payload - const updatedPayload = { - ...((currentPayment.payload as any) || {}), - payment_id: razorpay_payment_id, - signature: razorpay_signature, - }; - - const [updatedPayment] = await db - .update(payments) - .set({ - status: 'success', - payload: updatedPayload, - }) - .where(eq(payments.merchantOrderId, razorpay_order_id)) - .returning(); - - // Update order status to mark payment as processed - await db - .update(orderStatus) - .set({ - paymentStatus: 'success', - }) - .where(eq(orderStatus.orderId, updatedPayment.orderId)); - - return { - success: true, - message: "Payment verified successfully", - }; - }), - - markPaymentFailed: protectedProcedure - .input(z.object({ - merchantOrderId: z.string(), - })) - .mutation(async ({ input, ctx }) => { - const userId = ctx.user.userId; - const { merchantOrderId } = input; - - // Find payment by merchantOrderId - const payment = await db.query.payments.findFirst({ - where: eq(payments.merchantOrderId, merchantOrderId), - }); - - if (!payment) { - throw new ApiError("Payment not found", 404); - } - - // Check if payment belongs to user's order - const order = await db.query.orders.findFirst({ - where: eq(orders.id, payment.orderId), - }); - - if (!order || order.userId !== userId) { - throw new ApiError("Payment does not belong to user", 403); - } - - // Update payment status to failed - await db - .update(payments) - .set({ status: 'failed' }) - .where(eq(payments.id, payment.id)); - - return { - success: true, - message: "Payment marked as failed", - }; - }), - -}); diff --git a/apps/backend/src/trpc/apis/user-apis/apis/product.ts b/apps/backend/src/trpc/apis/user-apis/apis/product.ts index 0fc86ee..0d4ea47 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/product.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/product.ts @@ -1,12 +1,10 @@ import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; -import { db } from '@/src/db/db_index'; -import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTagInfo, productTags, productReviews, users } from '@/src/db/schema'; import { claimUploadUrl, extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'; import { ApiError } from '@/src/lib/api-error'; -import { eq, and, gt, sql, inArray, desc } from 'drizzle-orm'; import { getProductById as getProductByIdFromCache, getAllProducts as getAllProductsFromCache } from '@/src/stores/product-store'; import dayjs from 'dayjs'; +import { userProductDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' // Uniform Product Type interface Product { @@ -60,75 +58,20 @@ export const productRouter = router({ } // 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); + const product = await userProductDbService.getProductById(productId) - if (productData.length === 0) { + if (!product) { 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; + const storeData = product.storeId ? await userProductDbService.getStoreBasicById(product.storeId) : 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); + const deliverySlotsData = await userProductDbService.getDeliverySlotsForProduct(productId) // 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); + const specialDealsData = await userProductDbService.getSpecialDealsForProduct(productId) // Generate signed URLs for images const signedImages = scaffoldAssetUrl((product.images as string[]) || []); @@ -140,7 +83,7 @@ export const productRouter = router({ longDescription: product.longDescription, price: product.price.toString(), marketPrice: product.marketPrice?.toString() || null, - unitNotation: product.unitShortNotation, + unitNotation: product.unitShortNotation || '', images: signedImages, isOutOfStock: product.isOutOfStock, store: storeData ? { @@ -168,21 +111,7 @@ export const productRouter = router({ .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); + const reviews = await userProductDbService.getProductReviews(productId, limit, offset) // Generate signed URLs for images const reviewsWithSignedUrls = await Promise.all( @@ -193,12 +122,7 @@ export const productRouter = router({ ); // 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 totalCount = await userProductDbService.getReviewCount(productId) const hasMore = offset + limit < totalCount; return { reviews: reviewsWithSignedUrls, hasMore }; @@ -214,24 +138,25 @@ export const productRouter = router({ })) .mutation(async ({ input, ctx }) => { const { productId, reviewBody, ratings, imageUrls, uploadUrls } = input; - const userId = ctx.user.userId; + const userId = ctx.user?.userId; + if (!userId) { + throw new ApiError('User not authenticated', 401) + } // Optional: Check if product exists - const product = await db.query.productInfo.findFirst({ - where: eq(productInfo.id, productId), - }); + const product = await userProductDbService.getProductById(productId) if (!product) { throw new ApiError('Product not found', 404); } // Insert review - const [newReview] = await db.insert(productReviews).values({ + const newReview = await userProductDbService.createReview({ userId, productId, reviewBody, ratings, imageUrls: uploadUrls.map(item => extractKeyFromPresignedUrl(item)), - }).returning(); + }) // Claim upload URLs if (uploadUrls && uploadUrls.length > 0) { diff --git a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts index 92dd37f..429f3f0 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/slots.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/slots.ts @@ -1,15 +1,8 @@ import { router, publicProcedure } from "@/src/trpc/trpc-index"; import { z } from "zod"; -import { db } from "@/src/db/db_index"; -import { - deliverySlotInfo, - productSlots, - productInfo, - units, -} from "@/src/db/schema"; -import { eq, and } from "drizzle-orm"; import { getAllSlots as getAllSlotsFromCache, getSlotById as getSlotByIdFromCache } from "@/src/stores/slot-store"; import dayjs from 'dayjs'; +import { userSlotDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' // Helper method to get formatted slot data by ID async function getSlotData(slotId: number) { @@ -44,15 +37,7 @@ export async function scaffoldSlotsWithProducts() { .sort((a, b) => dayjs(a.deliveryTime).valueOf() - dayjs(b.deliveryTime).valueOf()); // Fetch all products for availability info - const allProducts = await db - .select({ - id: productInfo.id, - name: productInfo.name, - isOutOfStock: productInfo.isOutOfStock, - isFlashAvailable: productInfo.isFlashAvailable, - }) - .from(productInfo) - .where(eq(productInfo.isSuspended, false)); + const allProducts = await userSlotDbService.getProductAvailability() const productAvailability = allProducts.map(product => ({ id: product.id, @@ -70,9 +55,7 @@ export async function scaffoldSlotsWithProducts() { export const slotsRouter = router({ getSlots: publicProcedure.query(async () => { - const slots = await db.query.deliverySlotInfo.findMany({ - where: eq(deliverySlotInfo.isActive, true), - }); + const slots = await userSlotDbService.getActiveSlots() return { slots, count: slots.length, diff --git a/apps/backend/src/trpc/apis/user-apis/apis/stores.ts b/apps/backend/src/trpc/apis/user-apis/apis/stores.ts index 9522b7a..c7464cb 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/stores.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/stores.ts @@ -1,27 +1,12 @@ import { router, publicProcedure } from '@/src/trpc/trpc-index'; import { z } from 'zod'; -import { db } from '@/src/db/db_index'; -import { storeInfo, productInfo, units } from '@/src/db/schema'; -import { eq, and, sql } from 'drizzle-orm'; import { scaffoldAssetUrl } from '@/src/lib/s3-client'; import { ApiError } from '@/src/lib/api-error'; import { getTagsByStoreId } from '@/src/stores/product-tag-store'; +import { userStoreDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' export async function scaffoldStores() { - const storesData = await db - .select({ - id: storeInfo.id, - name: storeInfo.name, - description: storeInfo.description, - imageUrl: storeInfo.imageUrl, - productCount: sql`count(${productInfo.id})`.as('productCount'), - }) - .from(storeInfo) - .leftJoin( - productInfo, - and(eq(productInfo.storeId, storeInfo.id), eq(productInfo.isSuspended, false)) - ) - .groupBy(storeInfo.id); + const storesData = await userStoreDbService.getStoresWithProductCount() // Generate signed URLs for store images and fetch sample products const storesWithDetails = await Promise.all( @@ -29,15 +14,7 @@ export async function scaffoldStores() { const signedImageUrl = store.imageUrl ? scaffoldAssetUrl(store.imageUrl) : null; // Fetch up to 3 products for this store - const sampleProducts = await db - .select({ - id: productInfo.id, - name: productInfo.name, - images: productInfo.images, - }) - .from(productInfo) - .where(and(eq(productInfo.storeId, store.id), eq(productInfo.isSuspended, false))) - .limit(3); + const sampleProducts = await userStoreDbService.getSampleProductsByStoreId(store.id, 3) // Generate signed URLs for product images const productsWithSignedUrls = await Promise.all( @@ -69,15 +46,7 @@ export async function scaffoldStores() { export async function scaffoldStoreWithProducts(storeId: number) { // Fetch store info - const storeData = await db.query.storeInfo.findFirst({ - where: eq(storeInfo.id, storeId), - columns: { - id: true, - name: true, - description: true, - imageUrl: true, - }, - }); + const storeData = await userStoreDbService.getStoreById(storeId) if (!storeData) { throw new ApiError('Store not found', 404); @@ -87,23 +56,7 @@ export async function scaffoldStoreWithProducts(storeId: number) { const signedImageUrl = storeData.imageUrl ? scaffoldAssetUrl(storeData.imageUrl) : null; // Fetch products for this store - const productsData = await db - .select({ - id: productInfo.id, - name: productInfo.name, - shortDescription: productInfo.shortDescription, - price: productInfo.price, - marketPrice: productInfo.marketPrice, - images: productInfo.images, - isOutOfStock: productInfo.isOutOfStock, - incrementStep: productInfo.incrementStep, - unitShortNotation: units.shortNotation, - unitNotation: units.shortNotation, - productQuantity: productInfo.productQuantity, - }) - .from(productInfo) - .innerJoin(units, eq(productInfo.unitId, units.id)) - .where(and(eq(productInfo.storeId, storeId), eq(productInfo.isSuspended, false))); + const productsData = await userStoreDbService.getStoreProductsWithUnits(storeId) // Generate signed URLs for product images diff --git a/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts b/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts index 52e6531..0b48629 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/user-trpc-index.ts @@ -9,7 +9,6 @@ import { productRouter } from '@/src/trpc/apis/user-apis/apis/product'; import { slotsRouter } from '@/src/trpc/apis/user-apis/apis/slots'; import { userRouter as userDataRouter } from '@/src/trpc/apis/user-apis/apis/user'; import { userCouponRouter } from '@/src/trpc/apis/user-apis/apis/coupon'; -import { paymentRouter } from '@/src/trpc/apis/user-apis/apis/payments'; import { storesRouter } from '@/src/trpc/apis/user-apis/apis/stores'; import { fileUploadRouter } from '@/src/trpc/apis/user-apis/apis/file-upload'; import { tagsRouter } from '@/src/trpc/apis/user-apis/apis/tags'; @@ -25,7 +24,6 @@ export const userRouter = router({ slots: slotsRouter, user: userDataRouter, coupon: userCouponRouter, - payment: paymentRouter, stores: storesRouter, fileUpload: fileUploadRouter, tags: tagsRouter, diff --git a/apps/backend/src/trpc/apis/user-apis/apis/user.ts b/apps/backend/src/trpc/apis/user-apis/apis/user.ts index 057697a..b688330 100644 --- a/apps/backend/src/trpc/apis/user-apis/apis/user.ts +++ b/apps/backend/src/trpc/apis/user-apis/apis/user.ts @@ -1,11 +1,9 @@ import { router, protectedProcedure, publicProcedure } from '@/src/trpc/trpc-index'; -import { eq, and } from 'drizzle-orm'; import { z } from 'zod'; -import { db } from '@/src/db/db_index'; -import { users, userDetails, userCreds, notifCreds, unloggedUserTokens } from '@/src/db/schema'; import { ApiError } from '@/src/lib/api-error'; import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'; import { signToken } from '@/src/lib/jwt-utils'; +import { userProfileDbService } from '@/src/trpc/apis/user-apis/dataAccessors/main' interface AuthResponse { token: string; @@ -29,28 +27,20 @@ const generateToken = async (userId: number): Promise => { export const userRouter = router({ getSelfData: protectedProcedure .query(async ({ ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; if (!userId) { throw new ApiError('User not authenticated', 401); } - const [user] = await db - .select() - .from(users) - .where(eq(users.id, userId)) - .limit(1); + const user = await userProfileDbService.getUserById(userId) if (!user) { throw new ApiError('User not found', 404); } // Get user details for profile image - const [userDetail] = await db - .select() - .from(userDetails) - .where(eq(userDetails.userId, userId)) - .limit(1); + const userDetail = await userProfileDbService.getUserDetailByUserId(userId) // Generate signed URL for profile image if it exists const profileImageSignedUrl = userDetail?.profileImage @@ -79,24 +69,19 @@ export const userRouter = router({ checkProfileComplete: protectedProcedure .query(async ({ ctx }) => { - const userId = ctx.user.userId; + const userId = ctx.user?.userId; if (!userId) { throw new ApiError('User not authenticated', 401); } - const result = await db - .select() - .from(users) - .leftJoin(userCreds, eq(users.id, userCreds.userId)) - .where(eq(users.id, userId)) - .limit(1); + const result = await userProfileDbService.getUserWithCreds(userId) - if (result.length === 0) { + if (!result) { throw new ApiError('User not found', 404); } - const { users: user, user_creds: creds } = result[0]; + const { user, creds } = result return { isComplete: !!(user.name && user.email && creds), @@ -112,50 +97,28 @@ export const userRouter = router({ if (userId) { // AUTHENTICATED USER // Check if token exists in notif_creds for this user - const existing = await db.query.notifCreds.findFirst({ - where: and( - eq(notifCreds.userId, userId), - eq(notifCreds.token, token) - ), - }); + const existing = await userProfileDbService.getNotifCredByUserAndToken(userId, token) if (existing) { // Update lastVerified timestamp - await db - .update(notifCreds) - .set({ lastVerified: new Date() }) - .where(eq(notifCreds.id, existing.id)); + await userProfileDbService.updateNotifCredLastVerified(existing.id) } else { // Insert new token into notif_creds - await db.insert(notifCreds).values({ - userId, - token, - lastVerified: new Date(), - }); + await userProfileDbService.insertNotifCred(userId, token) } // Remove from unlogged_user_tokens if it exists - await db - .delete(unloggedUserTokens) - .where(eq(unloggedUserTokens.token, token)); + await userProfileDbService.deleteUnloggedToken(token) } else { // UNAUTHENTICATED USER // Save/update in unlogged_user_tokens - const existing = await db.query.unloggedUserTokens.findFirst({ - where: eq(unloggedUserTokens.token, token), - }); + const existing = await userProfileDbService.getUnloggedToken(token) if (existing) { - await db - .update(unloggedUserTokens) - .set({ lastVerified: new Date() }) - .where(eq(unloggedUserTokens.id, existing.id)); + await userProfileDbService.updateUnloggedTokenLastVerified(existing.id) } else { - await db.insert(unloggedUserTokens).values({ - token, - lastVerified: new Date(), - }); + await userProfileDbService.insertUnloggedToken(token) } } diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-address-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-address-db-service.interface.ts new file mode 100644 index 0000000..32c7609 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-address-db-service.interface.ts @@ -0,0 +1,15 @@ +import { addresses, orders, orderStatus, deliverySlotInfo } from '@/src/db/schema' + +export type Address = typeof addresses.$inferSelect +export type NewAddress = typeof addresses.$inferInsert + +export interface IUserAddressDbService { + getDefaultAddress(userId: number): Promise
+ getUserAddresses(userId: number): Promise + unsetDefaultForUser(userId: number): Promise + createAddress(data: NewAddress): Promise
+ getAddressByIdForUser(addressId: number, userId: number): Promise
+ updateAddressForUser(addressId: number, userId: number, data: Partial): Promise
+ deleteAddressForUser(addressId: number, userId: number): Promise + hasOngoingOrdersForAddress(addressId: number): Promise +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-auth-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-auth-db-service.interface.ts new file mode 100644 index 0000000..a763432 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-auth-db-service.interface.ts @@ -0,0 +1,23 @@ +import { users, userCreds, userDetails, addresses, cartItems, complaints, couponApplicableUsers, couponUsage, notifCreds, notifications, orderItems, orderStatus, orders, payments, refunds, productReviews, reservedCoupons } from '@/src/db/schema' + +export type User = typeof users.$inferSelect +export type UserCred = typeof userCreds.$inferSelect +export type UserDetail = typeof userDetails.$inferSelect + +export interface IUserAuthDbService { + getUserByEmail(email: string): Promise + getUserByMobile(mobile: string): Promise + getUserById(userId: number): Promise + getUserCredsByUserId(userId: number): Promise + getUserDetailsByUserId(userId: number): Promise + + createUserWithCredsAndDetails(data: { name: string | null; email: string | null; mobile: string; passwordHash: string; imageKey?: string | null }): Promise + createUser(data: { name: string | null; email: string | null; mobile: string }): Promise + + upsertUserCreds(userId: number, passwordHash: string): Promise + updateUserName(userId: number, name: string): Promise + updateUserEmail(userId: number, email: string): Promise + upsertUserDetails(userId: number, data: Partial): Promise + + deleteAccountByUserId(userId: number): Promise +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-banner-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-banner-db-service.interface.ts new file mode 100644 index 0000000..53ba8a4 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-banner-db-service.interface.ts @@ -0,0 +1,7 @@ +import { homeBanners } from '@/src/db/schema' + +export type UserBanner = typeof homeBanners.$inferSelect + +export interface IUserBannerDbService { + getActiveBanners(): Promise +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-cart-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-cart-db-service.interface.ts new file mode 100644 index 0000000..8269e19 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-cart-db-service.interface.ts @@ -0,0 +1,25 @@ +import { cartItems, productInfo, units } from '@/src/db/schema' + +export type CartItem = typeof cartItems.$inferSelect + +export interface IUserCartDbService { + getCartItemsWithProducts(userId: number): Promise> + getProductById(productId: number): Promise + getCartItemByUserAndProduct(userId: number, productId: number): Promise + incrementCartItemQuantity(cartItemId: number, quantity: number): Promise + createCartItem(userId: number, productId: number, quantity: number): Promise + updateCartItemQuantity(itemId: number, userId: number, quantity: number): Promise + deleteCartItem(itemId: number, userId: number): Promise + clearCart(userId: number): Promise +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-complaint-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-complaint-db-service.interface.ts new file mode 100644 index 0000000..05c1ec8 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-complaint-db-service.interface.ts @@ -0,0 +1,17 @@ +import { complaints } from '@/src/db/schema' + +export type Complaint = typeof complaints.$inferSelect +export type NewComplaint = typeof complaints.$inferInsert + +export interface IUserComplaintDbService { + getComplaintsByUserId(userId: number): Promise> + createComplaint(data: NewComplaint): Promise +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-coupon-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-coupon-db-service.interface.ts new file mode 100644 index 0000000..308376e --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-coupon-db-service.interface.ts @@ -0,0 +1,20 @@ +import { coupons, couponUsage, couponApplicableUsers, couponApplicableProducts, reservedCoupons } from '@/src/db/schema' + +export type Coupon = typeof coupons.$inferSelect +export type CouponUsage = typeof couponUsage.$inferSelect +export type CouponApplicableUser = typeof couponApplicableUsers.$inferSelect +export type CouponApplicableProduct = typeof couponApplicableProducts.$inferSelect +export type ReservedCoupon = typeof reservedCoupons.$inferSelect + +export type CouponWithRelations = Coupon & { + usages: CouponUsage[] + applicableUsers: Array + applicableProducts: Array +} + +export interface IUserCouponDbService { + getActiveCouponsForUser(userId: number): Promise + getAllCouponsForUser(userId: number): Promise + getReservedCouponBySecretCode(secretCode: string): Promise + redeemReservedCoupon(userId: number, reservedCoupon: ReservedCoupon): Promise +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-order-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-order-db-service.interface.ts new file mode 100644 index 0000000..302c212 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-order-db-service.interface.ts @@ -0,0 +1,95 @@ +import { + orders, + orderItems, + orderStatus, + addresses, + productInfo, + paymentInfoTable, + coupons, + couponUsage, + refunds, + units, + userDetails, + deliverySlotInfo, +} from '@/src/db/schema' + +export type Order = typeof orders.$inferSelect +export type OrderInsert = typeof orders.$inferInsert +export type OrderItem = typeof orderItems.$inferSelect +export type OrderItemInsert = typeof orderItems.$inferInsert +export type OrderStatus = typeof orderStatus.$inferSelect +export type OrderStatusInsert = typeof orderStatus.$inferInsert +export type Address = typeof addresses.$inferSelect +export type Product = typeof productInfo.$inferSelect +export type PaymentInfo = typeof paymentInfoTable.$inferSelect +export type Coupon = typeof coupons.$inferSelect +export type CouponUsage = typeof couponUsage.$inferSelect +export type Refund = typeof refunds.$inferSelect +export type Unit = typeof units.$inferSelect +export type UserDetail = typeof userDetails.$inferSelect +export type Slot = typeof deliverySlotInfo.$inferSelect + +export type CouponWithUsages = Coupon & { + usages: CouponUsage[] +} + +export type OrderWithRelations = Order & { + orderItems: Array + slot: Slot | null + paymentInfo: PaymentInfo | null + orderStatus: OrderStatus[] + refunds: Refund[] +} + +export type OrderWithDetails = Order & { + orderItems: Array + slot: Slot | null + paymentInfo: PaymentInfo | null + orderStatus: Array + refunds: Refund[] +} + +export interface IUserOrderDbService { + getUserDetailByUserId(userId: number): Promise + getAddressByUserId(userId: number, addressId: number): Promise
+ getProductById(productId: number): Promise + getCouponWithUsage(couponId: number, userId: number): Promise + createOrdersWithItems(params: { + ordersData: Array<{ + order: Omit + orderItems: Omit[] + orderStatus: Omit + }> + paymentMethod: 'online' | 'cod' + }): Promise + deleteCartItemsByUserAndProductIds(userId: number, productIds: number[]): Promise + createCouponUsage(params: { + userId: number + couponId: number + orderId: number + }): Promise + getOrdersCount(userId: number): Promise + getOrdersWithRelations(userId: number, limit: number, offset: number): Promise + getOrderWithDetailsById(orderId: number, userId: number): Promise + getCouponUsagesByOrderId(orderId: number): Promise> + getOrderWithStatus(orderId: number): Promise<(Order & { orderStatus: OrderStatus[] }) | undefined> + cancelOrderTransaction(params: { + statusId: number + reason: string + orderId: number + refundStatus: string + }): Promise + updateOrderNotes(orderId: number, userNotes: string | null): Promise + getRecentDeliveredOrderIds(userId: number, since: Date, limit: number): Promise> + getProductIdsByOrderIds(orderIds: number[]): Promise> + getProductsWithUnitsByIds(productIds: number[], limit: number): Promise> +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-product-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-product-db-service.interface.ts new file mode 100644 index 0000000..d4c0ec1 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-product-db-service.interface.ts @@ -0,0 +1,32 @@ +import { productInfo, units, storeInfo, productSlots, deliverySlotInfo, specialDeals, productReviews, users } from '@/src/db/schema' + +export type Product = typeof productInfo.$inferSelect +export type Store = typeof storeInfo.$inferSelect +export type Review = typeof productReviews.$inferSelect + +export type ProductWithUnit = { + id: number + name: string + shortDescription: string | null + longDescription: string | null + price: string + marketPrice: string | null + images: unknown + isOutOfStock: boolean + storeId: number | null + unitShortNotation: string | null + incrementStep: number + productQuantity: number + isFlashAvailable: boolean + flashPrice: string | null +} + +export interface IUserProductDbService { + getProductById(productId: number): Promise + getStoreBasicById(storeId: number): Promise<{ id: number; name: string; description: string | null } | undefined> + getDeliverySlotsForProduct(productId: number): Promise> + getSpecialDealsForProduct(productId: number): Promise> + getProductReviews(productId: number, limit: number, offset: number): Promise> + getReviewCount(productId: number): Promise + createReview(data: { userId: number; productId: number; reviewBody: string; ratings: number; imageUrls: string[] }): Promise +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-profile-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-profile-db-service.interface.ts new file mode 100644 index 0000000..e063bac --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-profile-db-service.interface.ts @@ -0,0 +1,22 @@ +import { users, userDetails, userCreds, notifCreds, unloggedUserTokens } from '@/src/db/schema' + +export type User = typeof users.$inferSelect +export type UserDetail = typeof userDetails.$inferSelect +export type UserCred = typeof userCreds.$inferSelect +export type NotifCred = typeof notifCreds.$inferSelect +export type UnloggedToken = typeof unloggedUserTokens.$inferSelect + +export interface IUserProfileDbService { + getUserById(userId: number): Promise + getUserDetailByUserId(userId: number): Promise + getUserWithCreds(userId: number): Promise<{ user: User; creds: UserCred | null } | undefined> + + getNotifCredByUserAndToken(userId: number, token: string): Promise + updateNotifCredLastVerified(id: number): Promise + insertNotifCred(userId: number, token: string): Promise + deleteUnloggedToken(token: string): Promise + + getUnloggedToken(token: string): Promise + updateUnloggedTokenLastVerified(id: number): Promise + insertUnloggedToken(token: string): Promise +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-slot-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-slot-db-service.interface.ts new file mode 100644 index 0000000..f692d2b --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-slot-db-service.interface.ts @@ -0,0 +1,8 @@ +import { deliverySlotInfo, productInfo } from '@/src/db/schema' + +export type Slot = typeof deliverySlotInfo.$inferSelect + +export interface IUserSlotDbService { + getActiveSlots(): Promise + getProductAvailability(): Promise> +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-store-db-service.interface.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-store-db-service.interface.ts new file mode 100644 index 0000000..b846120 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/interfaces/user-store-db-service.interface.ts @@ -0,0 +1,28 @@ +import { storeInfo } from '@/src/db/schema' + +export type Store = typeof storeInfo.$inferSelect +export type StoreBasic = { + id: number + name: string + description: string | null + imageUrl: string | null +} + +export interface IUserStoreDbService { + getStoresWithProductCount(): Promise> + getStoreById(storeId: number): Promise + getSampleProductsByStoreId(storeId: number, limit: number): Promise> + getStoreProductsWithUnits(storeId: number): Promise> +} diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/main.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/main.ts new file mode 100644 index 0000000..56d4a3d --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/main.ts @@ -0,0 +1,32 @@ +export type { IUserBannerDbService, UserBanner } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-banner-db-service.interface' +export { userBannerDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-banner-queries' + +export type { IUserStoreDbService, Store as UserStore, StoreBasic } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-store-db-service.interface' +export { userStoreDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-store-queries' + +export type { IUserAddressDbService, Address, NewAddress } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-address-db-service.interface' +export { userAddressDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-address-queries' + +export type { IUserCartDbService, CartItem } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-cart-db-service.interface' +export { userCartDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-cart-queries' + +export type { IUserComplaintDbService, Complaint, NewComplaint } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-complaint-db-service.interface' +export { userComplaintDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-complaint-queries' + +export type { IUserProductDbService, Product, Store as ProductStore, Review, ProductWithUnit } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-product-db-service.interface' +export { userProductDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-product-queries' + +export type { IUserAuthDbService, User, UserCred, UserDetail } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-auth-db-service.interface' +export { userAuthDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-auth-queries' + +export type { IUserProfileDbService, User as ProfileUser, UserDetail as ProfileUserDetail, UserCred as ProfileUserCred, NotifCred, UnloggedToken } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-profile-db-service.interface' +export { userProfileDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-profile-queries' + +export type { IUserSlotDbService, Slot } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-slot-db-service.interface' +export { userSlotDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-slot-queries' + +export type { IUserCouponDbService, Coupon, CouponWithRelations, ReservedCoupon } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-coupon-db-service.interface' +export { userCouponDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-coupon-queries' + +export type { IUserOrderDbService, Order, OrderInsert, OrderItemInsert, OrderStatusInsert, Coupon as OrderCoupon } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-order-db-service.interface' +export { userOrderDbService } from '@/src/trpc/apis/user-apis/dataAccessors/postgres/user-order-queries' diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-address-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-address-queries.ts new file mode 100644 index 0000000..e10ac73 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-address-queries.ts @@ -0,0 +1,71 @@ +import { db } from '@/src/db/db_index' +import { addresses, orders, orderStatus, deliverySlotInfo } from '@/src/db/schema' +import { eq, and, gte } from 'drizzle-orm' +import { IUserAddressDbService, Address, NewAddress } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-address-db-service.interface' + +export class UserAddressDbService implements IUserAddressDbService { + async getDefaultAddress(userId: number): Promise
{ + const [defaultAddress] = await db + .select() + .from(addresses) + .where(and(eq(addresses.userId, userId), eq(addresses.isDefault, true))) + .limit(1) + return defaultAddress + } + + async getUserAddresses(userId: number): Promise { + return db.select().from(addresses).where(eq(addresses.userId, userId)) + } + + async unsetDefaultForUser(userId: number): Promise { + await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId)) + } + + async createAddress(data: NewAddress): Promise
{ + const [newAddress] = await db.insert(addresses).values(data).returning() + return newAddress + } + + async getAddressByIdForUser(addressId: number, userId: number): Promise
{ + const [address] = await db + .select() + .from(addresses) + .where(and(eq(addresses.id, addressId), eq(addresses.userId, userId))) + .limit(1) + return address + } + + async updateAddressForUser(addressId: number, userId: number, data: Partial): Promise
{ + const [updated] = await db + .update(addresses) + .set(data) + .where(and(eq(addresses.id, addressId), eq(addresses.userId, userId))) + .returning() + return updated + } + + async deleteAddressForUser(addressId: number, userId: number): Promise { + await db.delete(addresses).where(and(eq(addresses.id, addressId), eq(addresses.userId, userId))) + } + + async hasOngoingOrdersForAddress(addressId: number): Promise { + const ongoingOrders = await db + .select({ + orderId: orders.id, + }) + .from(orders) + .innerJoin(orderStatus, eq(orders.id, orderStatus.orderId)) + .innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id)) + .where( + and( + eq(orders.addressId, addressId), + eq(orderStatus.isCancelled, false), + gte(deliverySlotInfo.deliveryTime, new Date()) + ) + ) + .limit(1) + return ongoingOrders.length > 0 + } +} + +export const userAddressDbService: IUserAddressDbService = new UserAddressDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-auth-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-auth-queries.ts new file mode 100644 index 0000000..790d411 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-auth-queries.ts @@ -0,0 +1,122 @@ +import { db } from '@/src/db/db_index' +import { users, userCreds, userDetails, addresses, cartItems, complaints, couponApplicableUsers, couponUsage, notifCreds, notifications, orderItems, orderStatus, orders, payments, refunds, productReviews, reservedCoupons } from '@/src/db/schema' +import { eq } from 'drizzle-orm' +import { IUserAuthDbService, User, UserCred, UserDetail } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-auth-db-service.interface' + +export class UserAuthDbService implements IUserAuthDbService { + async getUserByEmail(email: string): Promise { + const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1) + return user + } + + async getUserByMobile(mobile: string): Promise { + const [user] = await db.select().from(users).where(eq(users.mobile, mobile)).limit(1) + return user + } + + async getUserById(userId: number): Promise { + const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1) + return user + } + + async getUserCredsByUserId(userId: number): Promise { + const [creds] = await db.select().from(userCreds).where(eq(userCreds.userId, userId)).limit(1) + return creds + } + + async getUserDetailsByUserId(userId: number): Promise { + const [detail] = await db.select().from(userDetails).where(eq(userDetails.userId, userId)).limit(1) + return detail + } + + async createUserWithCredsAndDetails(data: { name: string | null; email: string | null; mobile: string; passwordHash: string; imageKey?: string | null }): Promise { + const { name, email, mobile, passwordHash, imageKey } = data + return db.transaction(async (tx) => { + const [user] = await tx + .insert(users) + .values({ name, email, mobile }) + .returning() + + await tx + .insert(userCreds) + .values({ userId: user.id, userPassword: passwordHash }) + + if (imageKey) { + await tx.insert(userDetails).values({ userId: user.id, profileImage: imageKey }) + } + + return user + }) + } + + async createUser(data: { name: string | null; email: string | null; mobile: string }): Promise { + const [user] = await db.insert(users).values(data).returning() + return user + } + + async upsertUserCreds(userId: number, passwordHash: string): Promise { + await db + .insert(userCreds) + .values({ userId, userPassword: passwordHash }) + .onConflictDoUpdate({ + target: userCreds.userId, + set: { userPassword: passwordHash }, + }) + } + + async updateUserName(userId: number, name: string): Promise { + await db.update(users).set({ name }).where(eq(users.id, userId)) + } + + async updateUserEmail(userId: number, email: string): Promise { + await db.update(users).set({ email }).where(eq(users.id, userId)) + } + + async upsertUserDetails(userId: number, data: Partial): Promise { + await db + .insert(userDetails) + .values({ userId, ...data }) + .onConflictDoUpdate({ + target: userDetails.userId, + set: data, + }) + } + + async deleteAccountByUserId(userId: number): Promise { + await db.transaction(async (tx) => { + await tx.delete(notifCreds).where(eq(notifCreds.userId, userId)) + await tx.delete(couponApplicableUsers).where(eq(couponApplicableUsers.userId, userId)) + await tx.delete(couponUsage).where(eq(couponUsage.userId, userId)) + await tx.delete(complaints).where(eq(complaints.userId, userId)) + await tx.delete(cartItems).where(eq(cartItems.userId, userId)) + await tx.delete(notifications).where(eq(notifications.userId, userId)) + await tx.delete(productReviews).where(eq(productReviews.userId, userId)) + + await tx.update(reservedCoupons) + .set({ redeemedBy: null }) + .where(eq(reservedCoupons.redeemedBy, userId)) + + const userOrders = await tx + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.userId, userId)) + + for (const order of userOrders) { + await tx.delete(orderItems).where(eq(orderItems.orderId, order.id)) + await tx.delete(orderStatus).where(eq(orderStatus.orderId, order.id)) + await tx.delete(payments).where(eq(payments.orderId, order.id)) + await tx.delete(refunds).where(eq(refunds.orderId, order.id)) + await tx.delete(couponUsage).where(eq(couponUsage.orderId, order.id)) + await tx.delete(complaints).where(eq(complaints.orderId, order.id)) + } + + await tx.delete(orders).where(eq(orders.userId, userId)) + await tx.delete(addresses).where(eq(addresses.userId, userId)) + await tx.delete(userDetails).where(eq(userDetails.userId, userId)) + await tx.delete(userCreds).where(eq(userCreds.userId, userId)) + await tx.delete(users).where(eq(users.id, userId)) + }) + } +} + +export const userAuthDbService: IUserAuthDbService = new UserAuthDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-banner-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-banner-queries.ts new file mode 100644 index 0000000..a969359 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-banner-queries.ts @@ -0,0 +1,15 @@ +import { db } from '@/src/db/db_index' +import { homeBanners } from '@/src/db/schema' +import { isNotNull, asc } from 'drizzle-orm' +import { IUserBannerDbService, UserBanner } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-banner-db-service.interface' + +export class UserBannerDbService implements IUserBannerDbService { + async getActiveBanners(): Promise { + return db.query.homeBanners.findMany({ + where: isNotNull(homeBanners.serialNum), + orderBy: asc(homeBanners.serialNum), + }) + } +} + +export const userBannerDbService: IUserBannerDbService = new UserBannerDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-cart-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-cart-queries.ts new file mode 100644 index 0000000..55fabff --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-cart-queries.ts @@ -0,0 +1,75 @@ +import { db } from '@/src/db/db_index' +import { cartItems, productInfo, units } from '@/src/db/schema' +import { eq, and, sql } from 'drizzle-orm' +import { IUserCartDbService, CartItem } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-cart-db-service.interface' + +export class UserCartDbService implements IUserCartDbService { + async getCartItemsWithProducts(userId: number) { + return db + .select({ + cartId: cartItems.id, + productId: productInfo.id, + productName: productInfo.name, + productPrice: productInfo.price, + productImages: productInfo.images, + productQuantity: productInfo.productQuantity, + isOutOfStock: productInfo.isOutOfStock, + unitShortNotation: units.shortNotation, + quantity: cartItems.quantity, + addedAt: cartItems.addedAt, + }) + .from(cartItems) + .innerJoin(productInfo, eq(cartItems.productId, productInfo.id)) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(eq(cartItems.userId, userId)) + } + + async getProductById(productId: number) { + return db.query.productInfo.findFirst({ + where: eq(productInfo.id, productId), + }) + } + + async getCartItemByUserAndProduct(userId: number, productId: number): Promise { + return db.query.cartItems.findFirst({ + where: and(eq(cartItems.userId, userId), eq(cartItems.productId, productId)), + }) + } + + async incrementCartItemQuantity(cartItemId: number, quantity: number): Promise { + await db.update(cartItems) + .set({ + quantity: sql`${cartItems.quantity} + ${quantity}`, + }) + .where(eq(cartItems.id, cartItemId)) + } + + async createCartItem(userId: number, productId: number, quantity: number): Promise { + await db.insert(cartItems).values({ + userId, + productId, + quantity: quantity.toString(), + }) + } + + async updateCartItemQuantity(itemId: number, userId: number, quantity: number): Promise { + const [updatedItem] = await db.update(cartItems) + .set({ quantity: quantity.toString() }) + .where(and(eq(cartItems.id, itemId), eq(cartItems.userId, userId))) + .returning() + return updatedItem + } + + async deleteCartItem(itemId: number, userId: number): Promise { + const [deletedItem] = await db.delete(cartItems) + .where(and(eq(cartItems.id, itemId), eq(cartItems.userId, userId))) + .returning() + return deletedItem + } + + async clearCart(userId: number): Promise { + await db.delete(cartItems).where(eq(cartItems.userId, userId)) + } +} + +export const userCartDbService: IUserCartDbService = new UserCartDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-complaint-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-complaint-queries.ts new file mode 100644 index 0000000..697a6e7 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-complaint-queries.ts @@ -0,0 +1,28 @@ +import { db } from '@/src/db/db_index' +import { complaints } from '@/src/db/schema' +import { eq, asc } from 'drizzle-orm' +import { IUserComplaintDbService } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-complaint-db-service.interface' + +export class UserComplaintDbService implements IUserComplaintDbService { + async getComplaintsByUserId(userId: number) { + return db + .select({ + id: complaints.id, + complaintBody: complaints.complaintBody, + response: complaints.response, + isResolved: complaints.isResolved, + createdAt: complaints.createdAt, + orderId: complaints.orderId, + images: complaints.images, + }) + .from(complaints) + .where(eq(complaints.userId, userId)) + .orderBy(asc(complaints.createdAt)) + } + + async createComplaint(data: { userId: number; orderId?: number | null; complaintBody: string; images: string[] }) { + await db.insert(complaints).values(data) + } +} + +export const userComplaintDbService: IUserComplaintDbService = new UserComplaintDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-coupon-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-coupon-queries.ts new file mode 100644 index 0000000..54bb491 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-coupon-queries.ts @@ -0,0 +1,88 @@ +import { db } from '@/src/db/db_index' +import { coupons, couponUsage, couponApplicableUsers, couponApplicableProducts, reservedCoupons } from '@/src/db/schema' +import { eq, and, or, gt, isNull } from 'drizzle-orm' +import { IUserCouponDbService, Coupon, ReservedCoupon, CouponWithRelations } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-coupon-db-service.interface' + +export class UserCouponDbService implements IUserCouponDbService { + async getActiveCouponsForUser(userId: number): Promise { + return db.query.coupons.findMany({ + where: and( + eq(coupons.isInvalidated, false), + or( + isNull(coupons.validTill), + gt(coupons.validTill, new Date()) + ) + ), + with: { + usages: { + where: eq(couponUsage.userId, userId), + }, + applicableUsers: { + with: { user: true }, + }, + applicableProducts: { + with: { product: true }, + }, + }, + }) as Promise + } + + async getAllCouponsForUser(userId: number): Promise { + return db.query.coupons.findMany({ + with: { + usages: { + where: eq(couponUsage.userId, userId), + }, + applicableUsers: { + with: { user: true }, + }, + applicableProducts: { + with: { product: true }, + }, + }, + }) as Promise + } + + async getReservedCouponBySecretCode(secretCode: string): Promise { + return db.query.reservedCoupons.findFirst({ + where: and( + eq(reservedCoupons.secretCode, secretCode.toUpperCase()), + eq(reservedCoupons.isRedeemed, false) + ), + }) + } + + async redeemReservedCoupon(userId: number, reservedCoupon: ReservedCoupon): Promise { + return db.transaction(async (tx) => { + const [coupon] = await tx.insert(coupons).values({ + couponCode: reservedCoupon.couponCode, + isUserBased: true, + discountPercent: reservedCoupon.discountPercent, + flatDiscount: reservedCoupon.flatDiscount, + minOrder: reservedCoupon.minOrder, + productIds: reservedCoupon.productIds, + maxValue: reservedCoupon.maxValue, + isApplyForAll: false, + validTill: reservedCoupon.validTill, + maxLimitForUser: reservedCoupon.maxLimitForUser, + exclusiveApply: reservedCoupon.exclusiveApply, + createdBy: reservedCoupon.createdBy, + }).returning() + + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId, + }) + + await tx.update(reservedCoupons).set({ + isRedeemed: true, + redeemedBy: userId, + redeemedAt: new Date(), + }).where(eq(reservedCoupons.id, reservedCoupon.id)) + + return coupon + }) + } +} + +export const userCouponDbService: IUserCouponDbService = new UserCouponDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-order-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-order-queries.ts new file mode 100644 index 0000000..fefc0c1 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-order-queries.ts @@ -0,0 +1,265 @@ +import { db } from '@/src/db/db_index' +import { + orders, + orderItems, + orderStatus, + addresses, + productInfo, + paymentInfoTable, + coupons, + couponUsage, + cartItems, + refunds, + units, + userDetails, +} from '@/src/db/schema' +import { and, desc, eq, gte, inArray } from 'drizzle-orm' +import { + IUserOrderDbService, + Order, +} from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-order-db-service.interface' + +export class UserOrderDbService implements IUserOrderDbService { + async getUserDetailByUserId(userId: number) { + return db.query.userDetails.findFirst({ + where: eq(userDetails.userId, userId), + }) + } + + async getAddressByUserId(userId: number, addressId: number) { + return db.query.addresses.findFirst({ + where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)), + }) + } + + async getProductById(productId: number) { + return db.query.productInfo.findFirst({ + where: eq(productInfo.id, productId), + }) + } + + async getCouponWithUsage(couponId: number, userId: number) { + return db.query.coupons.findFirst({ + where: eq(coupons.id, couponId), + with: { + usages: { where: eq(couponUsage.userId, userId) }, + }, + }) + } + + async createOrdersWithItems(params: { + ordersData: Array<{ + order: Omit + orderItems: Omit[] + orderStatus: Omit + }> + paymentMethod: 'online' | 'cod' + }): Promise { + const { ordersData, paymentMethod } = params + return db.transaction(async (tx) => { + 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 ordersToInsert: Omit[] = ordersData.map( + (od) => ({ + ...od.order, + paymentInfoId: sharedPaymentInfoId, + }) + ) + + const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning() + + const allOrderItems: Omit[] = [] + const allOrderStatuses: Omit[] = [] + + insertedOrders.forEach((order, index) => { + const od = ordersData[index] + od.orderItems.forEach((item) => { + allOrderItems.push({ ...item, orderId: order.id as number }) + }) + allOrderStatuses.push({ + ...od.orderStatus, + orderId: order.id as number, + }) + }) + + await tx.insert(orderItems).values(allOrderItems) + await tx.insert(orderStatus).values(allOrderStatuses) + + return insertedOrders + }) + } + + async deleteCartItemsByUserAndProductIds(userId: number, productIds: number[]) { + await db.delete(cartItems).where( + and(eq(cartItems.userId, userId), inArray(cartItems.productId, productIds)) + ) + } + + async createCouponUsage(params: { userId: number; couponId: number; orderId: number }) { + const { userId, couponId, orderId } = params + await db.insert(couponUsage).values({ + userId, + couponId, + orderId, + orderItemId: null, + usedAt: new Date(), + }) + } + + async getOrdersCount(userId: number) { + return db.$count(orders, eq(orders.userId, userId)) + } + + async getOrdersWithRelations(userId: number, limit: number, offset: number) { + return db.query.orders.findMany({ + where: eq(orders.userId, userId), + with: { + orderItems: { + with: { + product: true, + }, + }, + slot: true, + paymentInfo: true, + orderStatus: true, + refunds: true, + }, + orderBy: (ordersRef, { desc }) => [desc(ordersRef.createdAt)], + limit, + offset, + }) + } + + async getOrderWithDetailsById(orderId: number, userId: number) { + return db.query.orders.findFirst({ + where: and(eq(orders.id, orderId), eq(orders.userId, userId)), + with: { + orderItems: { + with: { + product: true, + }, + }, + slot: true, + paymentInfo: true, + orderStatus: { + with: { + refundCoupon: true, + }, + }, + refunds: true, + }, + }) + } + + async getCouponUsagesByOrderId(orderId: number) { + return db.query.couponUsage.findMany({ + where: eq(couponUsage.orderId, orderId), + with: { + coupon: true, + }, + }) + } + + async getOrderWithStatus(orderId: number) { + return db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + orderStatus: true, + }, + }) + } + + async cancelOrderTransaction(params: { + statusId: number + reason: string + orderId: number + refundStatus: string + }) { + const { statusId, reason, orderId, refundStatus } = params + await db.transaction(async (tx) => { + await tx + .update(orderStatus) + .set({ + isCancelled: true, + cancelReason: reason, + cancellationUserNotes: reason, + cancellationReviewed: false, + }) + .where(eq(orderStatus.id, statusId)) + + await tx.insert(refunds).values({ + orderId, + refundStatus, + }) + }) + } + + async updateOrderNotes(orderId: number, userNotes: string | null) { + await db + .update(orders) + .set({ + userNotes, + }) + .where(eq(orders.id, orderId)) + } + + async getRecentDeliveredOrderIds(userId: number, since: Date, limit: number) { + return 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, since) + ) + ) + .orderBy(desc(orders.createdAt)) + .limit(limit) + } + + async getProductIdsByOrderIds(orderIds: number[]) { + return db + .select({ productId: orderItems.productId }) + .from(orderItems) + .where(inArray(orderItems.orderId, orderIds)) + } + + async getProductsWithUnitsByIds(productIds: number[], limit: number) { + return 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) + } +} + +export const userOrderDbService: IUserOrderDbService = new UserOrderDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-product-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-product-queries.ts new file mode 100644 index 0000000..2b925dc --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-product-queries.ts @@ -0,0 +1,109 @@ +import { db } from '@/src/db/db_index' +import { productInfo, units, storeInfo, productSlots, deliverySlotInfo, specialDeals, productReviews, users } from '@/src/db/schema' +import { eq, and, gt, sql, desc } from 'drizzle-orm' +import { IUserProductDbService, Review } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-product-db-service.interface' + +export class UserProductDbService implements IUserProductDbService { + async getProductById(productId: number) { + const result = 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) + return result[0] + } + + async getStoreBasicById(storeId: number) { + return db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, storeId), + columns: { id: true, name: true, description: true }, + }) + } + + async getDeliverySlotsForProduct(productId: number) { + return 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) + } + + async getSpecialDealsForProduct(productId: number) { + return 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) + } + + async getProductReviews(productId: number, limit: number, offset: number) { + return 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) + } + + async getReviewCount(productId: number): Promise { + const result = await db + .select({ count: sql`count(*)` }) + .from(productReviews) + .where(eq(productReviews.productId, productId)) + return Number(result[0].count) + } + + async createReview(data: { userId: number; productId: number; reviewBody: string; ratings: number; imageUrls: string[] }): Promise { + const [newReview] = await db.insert(productReviews).values(data).returning() + return newReview + } +} + +export const userProductDbService: IUserProductDbService = new UserProductDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-profile-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-profile-queries.ts new file mode 100644 index 0000000..303aa4d --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-profile-queries.ts @@ -0,0 +1,74 @@ +import { db } from '@/src/db/db_index' +import { users, userDetails, userCreds, notifCreds, unloggedUserTokens } from '@/src/db/schema' +import { eq, and } from 'drizzle-orm' +import { IUserProfileDbService, User, UserDetail, UserCred, NotifCred, UnloggedToken } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-profile-db-service.interface' + +export class UserProfileDbService implements IUserProfileDbService { + async getUserById(userId: number): Promise { + const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1) + return user + } + + async getUserDetailByUserId(userId: number): Promise { + const [detail] = await db.select().from(userDetails).where(eq(userDetails.userId, userId)).limit(1) + return detail + } + + async getUserWithCreds(userId: number): Promise<{ user: User; creds: UserCred | null } | undefined> { + const result = await db + .select() + .from(users) + .leftJoin(userCreds, eq(users.id, userCreds.userId)) + .where(eq(users.id, userId)) + .limit(1) + + if (result.length === 0) return undefined + + const row = result[0] as any + return { + user: row.users, + creds: row.user_creds || null, + } + } + + async getNotifCredByUserAndToken(userId: number, token: string): Promise { + return db.query.notifCreds.findFirst({ + where: and(eq(notifCreds.userId, userId), eq(notifCreds.token, token)), + }) + } + + async updateNotifCredLastVerified(id: number): Promise { + await db.update(notifCreds).set({ lastVerified: new Date() }).where(eq(notifCreds.id, id)) + } + + async insertNotifCred(userId: number, token: string): Promise { + await db.insert(notifCreds).values({ + userId, + token, + lastVerified: new Date(), + }) + } + + async deleteUnloggedToken(token: string): Promise { + await db.delete(unloggedUserTokens).where(eq(unloggedUserTokens.token, token)) + } + + async getUnloggedToken(token: string): Promise { + return db.query.unloggedUserTokens.findFirst({ + where: eq(unloggedUserTokens.token, token), + }) + } + + async updateUnloggedTokenLastVerified(id: number): Promise { + await db.update(unloggedUserTokens).set({ lastVerified: new Date() }).where(eq(unloggedUserTokens.id, id)) + } + + async insertUnloggedToken(token: string): Promise { + await db.insert(unloggedUserTokens).values({ + token, + lastVerified: new Date(), + }) + } +} + +export const userProfileDbService: IUserProfileDbService = new UserProfileDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-slot-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-slot-queries.ts new file mode 100644 index 0000000..52963e4 --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-slot-queries.ts @@ -0,0 +1,26 @@ +import { db } from '@/src/db/db_index' +import { deliverySlotInfo, productInfo } from '@/src/db/schema' +import { eq } from 'drizzle-orm' +import { IUserSlotDbService, Slot } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-slot-db-service.interface' + +export class UserSlotDbService implements IUserSlotDbService { + async getActiveSlots(): Promise { + return db.query.deliverySlotInfo.findMany({ + where: eq(deliverySlotInfo.isActive, true), + }) + } + + async getProductAvailability(): Promise> { + return db + .select({ + id: productInfo.id, + name: productInfo.name, + isOutOfStock: productInfo.isOutOfStock, + isFlashAvailable: productInfo.isFlashAvailable, + }) + .from(productInfo) + .where(eq(productInfo.isSuspended, false)) + } +} + +export const userSlotDbService: IUserSlotDbService = new UserSlotDbService() diff --git a/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-store-queries.ts b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-store-queries.ts new file mode 100644 index 0000000..fa7556f --- /dev/null +++ b/apps/backend/src/trpc/apis/user-apis/dataAccessors/postgres/user-store-queries.ts @@ -0,0 +1,69 @@ +import { db } from '@/src/db/db_index' +import { storeInfo, productInfo, units } from '@/src/db/schema' +import { eq, and, sql } from 'drizzle-orm' +import { IUserStoreDbService } from '@/src/trpc/apis/user-apis/dataAccessors/interfaces/user-store-db-service.interface' + +export class UserStoreDbService implements IUserStoreDbService { + async getStoresWithProductCount(): Promise> { + return db + .select({ + id: storeInfo.id, + name: storeInfo.name, + description: storeInfo.description, + imageUrl: storeInfo.imageUrl, + productCount: sql`count(${productInfo.id})`.as('productCount'), + }) + .from(storeInfo) + .leftJoin( + productInfo, + and(eq(productInfo.storeId, storeInfo.id), eq(productInfo.isSuspended, false)) + ) + .groupBy(storeInfo.id) + } + + async getStoreById(storeId: number) { + return db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, storeId), + columns: { + id: true, + name: true, + description: true, + imageUrl: true, + }, + }) + } + + async getSampleProductsByStoreId(storeId: number, limit: number) { + return db + .select({ + id: productInfo.id, + name: productInfo.name, + images: productInfo.images, + }) + .from(productInfo) + .where(and(eq(productInfo.storeId, storeId), eq(productInfo.isSuspended, false))) + .limit(limit) + } + + async getStoreProductsWithUnits(storeId: number) { + return db + .select({ + id: productInfo.id, + name: productInfo.name, + shortDescription: productInfo.shortDescription, + price: productInfo.price, + marketPrice: productInfo.marketPrice, + images: productInfo.images, + isOutOfStock: productInfo.isOutOfStock, + incrementStep: productInfo.incrementStep, + unitShortNotation: units.shortNotation, + unitNotation: units.shortNotation, + productQuantity: productInfo.productQuantity, + }) + .from(productInfo) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(and(eq(productInfo.storeId, storeId), eq(productInfo.isSuspended, false))) + } +} + +export const userStoreDbService: IUserStoreDbService = new UserStoreDbService() diff --git a/apps/backend/src/trpc/trpc-index.ts b/apps/backend/src/trpc/trpc-index.ts index 85c5457..3e8db53 100644 --- a/apps/backend/src/trpc/trpc-index.ts +++ b/apps/backend/src/trpc/trpc-index.ts @@ -1,10 +1,14 @@ import { initTRPC, TRPCError } from '@trpc/server'; -import { type CreateExpressContextOptions } from '@trpc/server/adapters/express'; export interface Context { - req: CreateExpressContextOptions['req']; - res: CreateExpressContextOptions['res']; - user?: any; + req: Request; + res: Response; + user?: { + userId: number; + name: string; + email: string; + mobile: string; + } | null; staffUser?: { id: number; name: string; diff --git a/apps/backend/src/uv-apis/uv-router.ts b/apps/backend/src/uv-apis/uv-router.ts deleted file mode 100644 index 4fa47a2..0000000 --- a/apps/backend/src/uv-apis/uv-router.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Router } from "express"; - -const router = Router(); - -// All user APIs migrated to tRPC - -const uvRouter = router; -export default uvRouter; diff --git a/apps/backend/src/v1-router.ts b/apps/backend/src/v1-router.ts index 91a2e71..622f6a9 100644 --- a/apps/backend/src/v1-router.ts +++ b/apps/backend/src/v1-router.ts @@ -1,15 +1,9 @@ - import { Router } from "express"; - import avRouter from "@/src/apis/admin-apis/apis/av-router" - import commonRouter from "@/src/apis/common-apis/apis/common.router" - import uvRouter from "@/src/uv-apis/uv-router" +import { Hono } from 'hono' +import commonRouter from '@/src/apis/common-apis/apis/common.router' -const router = Router(); +const app = new Hono() - router.use('/av', avRouter); - router.use('/cm', commonRouter); - router.use('/uv', uvRouter); +// Mount common routes at /cm +app.route('/cm', commonRouter) - -const v1Router = router; - -export default v1Router; \ No newline at end of file +export default app diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/address.ts b/verifier/admin-apis/apis/address.ts similarity index 100% rename from apps/backend/src/trpc/apis/admin-apis/apis/address.ts rename to verifier/admin-apis/apis/address.ts diff --git a/verifier/admin-apis/apis/admin-trpc-index.ts b/verifier/admin-apis/apis/admin-trpc-index.ts new file mode 100644 index 0000000..4e23b84 --- /dev/null +++ b/verifier/admin-apis/apis/admin-trpc-index.ts @@ -0,0 +1,39 @@ +// import { router } from '@/src/trpc/trpc-index'; +import { router } from '@/src/trpc/trpc-index' +import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint' +import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon' +import { cancelledOrdersRouter } from '@/src/trpc/apis/admin-apis/apis/cancelled-orders' +import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order' +import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets' +import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots' +import { productRouter } from '@/src/trpc/apis/admin-apis/apis/product' +import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user' +import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store' +import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments' +import addressRouter from '@/src/trpc/apis/admin-apis/apis/address' +import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner' +import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user' +import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const' +import { productAvailabilitySchedulesRouter } from '@/src/trpc/apis/admin-apis/apis/product-availability-schedules' +import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag' + +export const adminRouter = router({ + complaint: complaintRouter, + coupon: couponRouter, + cancelledOrders: cancelledOrdersRouter, + order: orderRouter, + vendorSnippets: vendorSnippetsRouter, + slots: slotsRouter, + product: productRouter, + staffUser: staffUserRouter, + store: storeRouter, + payments: adminPaymentsRouter, + address: addressRouter, + banner: bannerRouter, + user: userRouter, + const: constRouter, + productAvailabilitySchedules: productAvailabilitySchedulesRouter, + tag: tagRouter, +}); + +export type AdminRouter = typeof adminRouter; diff --git a/verifier/admin-apis/apis/banner.ts b/verifier/admin-apis/apis/banner.ts new file mode 100644 index 0000000..5794143 --- /dev/null +++ b/verifier/admin-apis/apis/banner.ts @@ -0,0 +1,176 @@ +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { homeBanners } from '@/src/db/schema' +import { eq, and, desc, sql } from 'drizzle-orm'; +import { protectedProcedure, router } from '@/src/trpc/trpc-index' +import { extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client' +import { ApiError } from '@/src/lib/api-error'; +import { scheduleStoreInitialization } from '@/src/stores/store-initializer' + + +export const bannerRouter = router({ + // Get all banners + getBanners: protectedProcedure + .query(async () => { + try { + + const banners = await db.query.homeBanners.findMany({ + orderBy: desc(homeBanners.createdAt), // Order by creation date instead + // Removed product relationship since we now use productIds array + }); + + // Convert S3 keys to signed URLs for client + const bannersWithSignedUrls = await Promise.all( + banners.map(async (banner) => { + try { + return { + ...banner, + imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl, + // Ensure productIds is always an array + productIds: banner.productIds || [], + }; + } catch (error) { + console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); + return { + ...banner, + imageUrl: banner.imageUrl, // Keep original on error + // Ensure productIds is always an array + productIds: banner.productIds || [], + }; + } + }) + ); + + return { + banners: bannersWithSignedUrls, + }; + } + catch(e:any) { + console.log(e) + + throw new ApiError(e.message); + } + }), + + // Get single banner by ID + getBanner: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const banner = await db.query.homeBanners.findFirst({ + where: eq(homeBanners.id, input.id), + // Removed product relationship since we now use productIds array + }); + + if (banner) { + try { + // Convert S3 key to signed URL for client + if (banner.imageUrl) { + banner.imageUrl = scaffoldAssetUrl(banner.imageUrl); + } + } catch (error) { + console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); + // Keep original imageUrl on error + } + + // Ensure productIds is always an array (handle migration compatibility) + if (!banner.productIds) { + banner.productIds = []; + } + } + + return banner; + }), + + // Create new banner + createBanner: protectedProcedure + .input(z.object({ + name: z.string().min(1), + imageUrl: z.string(), + description: z.string().optional(), + productIds: z.array(z.number()).optional(), + redirectUrl: z.string().url().optional(), + // serialNum removed completely + })) + .mutation(async ({ input }) => { + try { + const imageUrl = extractKeyFromPresignedUrl(input.imageUrl) + // const imageUrl = input.imageUrl + const [banner] = await db.insert(homeBanners).values({ + name: input.name, + imageUrl: imageUrl, + description: input.description, + productIds: input.productIds || [], + redirectUrl: input.redirectUrl, + serialNum: 999, // Default value, not used + isActive: false, // Default to inactive + }).returning(); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return banner; + } catch (error) { + console.error('Error creating banner:', error); + throw error; // Re-throw to maintain tRPC error handling + } + }), + + // Update banner + updateBanner: protectedProcedure + .input(z.object({ + id: z.number(), + name: z.string().min(1).optional(), + imageUrl: z.string().url().optional(), + description: z.string().optional(), + productIds: z.array(z.number()).optional(), + redirectUrl: z.string().url().optional(), + serialNum: z.number().nullable().optional(), + isActive: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + try { + + const { id, ...updateData } = input; + const incomingProductIds = input.productIds; + // Extract S3 key from presigned URL if imageUrl is provided + const processedData = { + ...updateData, + ...(updateData.imageUrl && { + imageUrl: extractKeyFromPresignedUrl(updateData.imageUrl) + }), + }; + + // Handle serialNum null case + const finalData: any = { ...processedData }; + if ('serialNum' in finalData && finalData.serialNum === null) { + // Set to null explicitly + finalData.serialNum = null; + } + + const [banner] = await db.update(homeBanners) + .set({ ...finalData, lastUpdated: new Date(), }) + .where(eq(homeBanners.id, id)) + .returning(); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return banner; + } catch (error) { + console.error('Error updating banner:', error); + throw error; + } + }), + + // Delete banner + deleteBanner: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + await db.delete(homeBanners).where(eq(homeBanners.id, input.id)); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { success: true }; + }), +}); diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/cancelled-orders.ts b/verifier/admin-apis/apis/cancelled-orders.ts similarity index 100% rename from apps/backend/src/trpc/apis/admin-apis/apis/cancelled-orders.ts rename to verifier/admin-apis/apis/cancelled-orders.ts diff --git a/verifier/admin-apis/apis/complaint.ts b/verifier/admin-apis/apis/complaint.ts new file mode 100644 index 0000000..b1791a6 --- /dev/null +++ b/verifier/admin-apis/apis/complaint.ts @@ -0,0 +1,80 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { complaints, users } from '@/src/db/schema' +import { eq, desc, lt, and } from 'drizzle-orm'; +import { scaffoldAssetUrl } from '@/src/lib/s3-client' + +export const complaintRouter = router({ + getAll: protectedProcedure + .input(z.object({ + cursor: z.number().optional(), + limit: z.number().default(20), + })) + .query(async ({ input }) => { + const { cursor, limit } = input; + + let whereCondition = cursor + ? lt(complaints.id, cursor) + : undefined; + + const complaintsData = await db + .select({ + id: complaints.id, + complaintBody: complaints.complaintBody, + userId: complaints.userId, + orderId: complaints.orderId, + isResolved: complaints.isResolved, + createdAt: complaints.createdAt, + userName: users.name, + userMobile: users.mobile, + images: complaints.images, + }) + .from(complaints) + .leftJoin(users, eq(complaints.userId, users.id)) + .where(whereCondition) + .orderBy(desc(complaints.id)) + .limit(limit + 1); + + const hasMore = complaintsData.length > limit; + const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData; + + const complaintsWithSignedImages = await Promise.all( + complaintsToReturn.map(async (c) => { + const signedImages = c.images + ? scaffoldAssetUrl(c.images as string[]) + : []; + + return { + id: c.id, + text: c.complaintBody, + userId: c.userId, + userName: c.userName, + userMobile: c.userMobile, + orderId: c.orderId, + status: c.isResolved ? 'resolved' : 'pending', + createdAt: c.createdAt, + images: signedImages, + }; + }) + ); + + return { + complaints: complaintsWithSignedImages, + nextCursor: hasMore + ? complaintsToReturn[complaintsToReturn.length - 1].id + : undefined, + }; + }), + + resolve: protectedProcedure + .input(z.object({ id: z.string(), response: z.string().optional() })) + .mutation(async ({ input }) => { + await db + .update(complaints) + .set({ isResolved: true, response: input.response }) + .where(eq(complaints.id, parseInt(input.id))); + + return { message: 'Complaint resolved successfully' }; + }), +}); \ No newline at end of file diff --git a/verifier/admin-apis/apis/const.ts b/verifier/admin-apis/apis/const.ts new file mode 100644 index 0000000..a426087 --- /dev/null +++ b/verifier/admin-apis/apis/const.ts @@ -0,0 +1,61 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { keyValStore } from '@/src/db/schema' +import { computeConstants } from '@/src/lib/const-store' +import { CONST_KEYS } from '@/src/lib/const-keys' + +export const constRouter = router({ + getConstants: protectedProcedure + .query(async () => { + + const constants = await db.select().from(keyValStore); + + const resp = constants.map(c => ({ + key: c.key, + value: c.value, + })); + + return resp; + }), + + updateConstants: protectedProcedure + .input(z.object({ + constants: z.array(z.object({ + key: z.string(), + value: z.any(), + })), + })) + .mutation(async ({ input }) => { + const { constants } = input; + + const validKeys = Object.values(CONST_KEYS) as string[]; + const invalidKeys = constants + .filter(c => !validKeys.includes(c.key)) + .map(c => c.key); + + if (invalidKeys.length > 0) { + throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`); + } + + await db.transaction(async (tx) => { + for (const { key, value } of constants) { + await tx.insert(keyValStore) + .values({ key, value }) + .onConflictDoUpdate({ + target: keyValStore.key, + set: { value }, + }); + } + }); + + // Refresh all constants in Redis after database update + await computeConstants(); + + return { + success: true, + updatedCount: constants.length, + keys: constants.map(c => c.key), + }; + }), +}); \ No newline at end of file diff --git a/verifier/admin-apis/apis/coupon.ts b/verifier/admin-apis/apis/coupon.ts new file mode 100644 index 0000000..4eb3017 --- /dev/null +++ b/verifier/admin-apis/apis/coupon.ts @@ -0,0 +1,711 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema' +import { eq, and, like, or, inArray, lt } from 'drizzle-orm'; +import dayjs from 'dayjs'; + +const createCouponBodySchema = z.object({ + couponCode: z.string().optional(), + isUserBased: z.boolean().optional(), + discountPercent: z.number().optional(), + flatDiscount: z.number().optional(), + minOrder: z.number().optional(), + targetUser: z.number().optional(), + productIds: z.array(z.number()).optional().nullable(), + applicableUsers: z.array(z.number()).optional(), + applicableProducts: z.array(z.number()).optional(), + maxValue: z.number().optional(), + isApplyForAll: z.boolean().optional(), + validTill: z.string().optional(), + maxLimitForUser: z.number().optional(), + exclusiveApply: z.boolean().optional(), +}); + +const validateCouponBodySchema = z.object({ + code: z.string(), + userId: z.number(), + orderAmount: z.number(), +}); + +export const couponRouter = router({ + create: protectedProcedure + .input(createCouponBodySchema) + .mutation(async ({ input, ctx }) => { + const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; + + // Validation: ensure at least one discount type is provided + if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { + throw new Error("Either discountPercent or flatDiscount must be provided (but not both)"); + } + + // If user-based, applicableUsers is required (unless it's apply for all) + if (isUserBased && (!applicableUsers || applicableUsers.length === 0) && !isApplyForAll) { + throw new Error("applicableUsers is required for user-based coupons (or set isApplyForAll to true)"); + } + + // Cannot be both user-based and apply for all + if (isUserBased && isApplyForAll) { + throw new Error("Cannot be both user-based and apply for all users"); + } + + // If applicableUsers is provided, verify users exist + if (applicableUsers && applicableUsers.length > 0) { + const existingUsers = await db.query.users.findMany({ + where: inArray(users.id, applicableUsers), + columns: { id: true }, + }); + if (existingUsers.length !== applicableUsers.length) { + throw new Error("Some applicable users not found"); + } + } + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Generate coupon code if not provided + let finalCouponCode = couponCode; + if (!finalCouponCode) { + // Generate a unique coupon code + const timestamp = Date.now().toString().slice(-6); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + finalCouponCode = `MF${timestamp}${random}`; + } + + // Check if coupon code already exists + const existingCoupon = await db.query.coupons.findFirst({ + where: eq(coupons.couponCode, finalCouponCode), + }); + + if (existingCoupon) { + throw new Error("Coupon code already exists"); + } + + const result = await db.insert(coupons).values({ + couponCode: finalCouponCode, + isUserBased: isUserBased || false, + discountPercent: discountPercent?.toString(), + flatDiscount: flatDiscount?.toString(), + minOrder: minOrder?.toString(), + productIds: productIds || null, + createdBy: staffUserId, + maxValue: maxValue?.toString(), + isApplyForAll: isApplyForAll || false, + validTill: validTill ? dayjs(validTill).toDate() : undefined, + maxLimitForUser: maxLimitForUser, + exclusiveApply: exclusiveApply || false, + }).returning(); + + const coupon = result[0]; + + // Insert applicable users + if (applicableUsers && applicableUsers.length > 0) { + await db.insert(couponApplicableUsers).values( + applicableUsers.map(userId => ({ + couponId: coupon.id, + userId, + })) + ); + } + + // Insert applicable products + if (applicableProducts && applicableProducts.length > 0) { + await db.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: coupon.id, + productId, + })) + ); + } + + return coupon; + }), + + getAll: protectedProcedure + .input(z.object({ + cursor: z.number().optional(), + limit: z.number().default(50), + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { cursor, limit, search } = input; + + let whereCondition = undefined; + const conditions = []; + + if (cursor) { + conditions.push(lt(coupons.id, cursor)); + } + + if (search && search.trim()) { + conditions.push(like(coupons.couponCode, `%${search}%`)); + } + + if (conditions.length > 0) { + whereCondition = and(...conditions); + } + + const result = await db.query.coupons.findMany({ + where: whereCondition, + with: { + creator: true, + applicableUsers: { + with: { + user: true, + }, + }, + applicableProducts: { + with: { + product: true, + }, + }, + }, + orderBy: (coupons, { desc }) => [desc(coupons.createdAt)], + limit: limit + 1, + }); + + const hasMore = result.length > limit; + const couponsList = hasMore ? result.slice(0, limit) : result; + const nextCursor = hasMore ? result[result.length - 1].id : undefined; + + return { coupons: couponsList, nextCursor }; + }), + + getById: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const couponId = input.id; + + const result = await db.query.coupons.findFirst({ + where: eq(coupons.id, couponId), + with: { + creator: true, + applicableUsers: { + with: { + user: true, + }, + }, + applicableProducts: { + with: { + product: true, + }, + }, + }, + }); + + if (!result) { + throw new Error("Coupon not found"); + } + + return { + ...result, + productIds: (result.productIds as number[]) || undefined, + applicableUsers: result.applicableUsers.map(au => au.user), + applicableProducts: result.applicableProducts.map(ap => ap.product), + }; + }), + + update: protectedProcedure + .input(z.object({ + id: z.number(), + updates: createCouponBodySchema.extend({ + isInvalidated: z.boolean().optional(), + }), + })) + .mutation(async ({ input }) => { + const { id, updates } = input; + + // Validation: ensure discount types are valid + if (updates.discountPercent !== undefined && updates.flatDiscount !== undefined) { + if (updates.discountPercent && updates.flatDiscount) { + throw new Error("Cannot have both discountPercent and flatDiscount"); + } + } + + // If updating to user-based, applicableUsers is required + if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) { + const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id)); + if (existingCount === 0) { + throw new Error("applicableUsers is required for user-based coupons"); + } + } + + // If applicableUsers is provided, verify users exist + if (updates.applicableUsers && updates.applicableUsers.length > 0) { + const existingUsers = await db.query.users.findMany({ + where: inArray(users.id, updates.applicableUsers), + columns: { id: true }, + }); + if (existingUsers.length !== updates.applicableUsers.length) { + throw new Error("Some applicable users not found"); + } + } + + const updateData: any = { ...updates }; + delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table + if (updates.discountPercent !== undefined) { + updateData.discountPercent = updates.discountPercent?.toString(); + } + if (updates.flatDiscount !== undefined) { + updateData.flatDiscount = updates.flatDiscount?.toString(); + } + if (updates.minOrder !== undefined) { + updateData.minOrder = updates.minOrder?.toString(); + } + if (updates.maxValue !== undefined) { + updateData.maxValue = updates.maxValue?.toString(); + } + if (updates.validTill !== undefined) { + updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null; + } + + const result = await db.update(coupons) + .set(updateData) + .where(eq(coupons.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Coupon not found"); + } + + console.log('updated coupon successfully') + + // Update applicable users: delete existing and insert new + if (updates.applicableUsers !== undefined) { + await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id)); + if (updates.applicableUsers.length > 0) { + await db.insert(couponApplicableUsers).values( + updates.applicableUsers.map(userId => ({ + couponId: id, + userId, + })) + ); + } + } + + // Update applicable products: delete existing and insert new + if (updates.applicableProducts !== undefined) { + await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id)); + if (updates.applicableProducts.length > 0) { + await db.insert(couponApplicableProducts).values( + updates.applicableProducts.map(productId => ({ + couponId: id, + productId, + })) + ); + } + } + + return result[0]; + }), + + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const { id } = input; + + const result = await db.update(coupons) + .set({ isInvalidated: true }) + .where(eq(coupons.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Coupon not found"); + } + + return { message: "Coupon invalidated successfully" }; + }), + + validate: protectedProcedure + .input(validateCouponBodySchema) + .query(async ({ input }) => { + const { code, userId, orderAmount } = input; + + if (!code || typeof code !== 'string') { + return { valid: false, message: "Invalid coupon code" }; + } + + const coupon = await db.query.coupons.findFirst({ + where: and( + eq(coupons.couponCode, code.toUpperCase()), + eq(coupons.isInvalidated, false) + ), + }); + + if (!coupon) { + return { valid: false, message: "Coupon not found or invalidated" }; + } + + // Check expiry date + if (coupon.validTill && new Date(coupon.validTill) < new Date()) { + return { valid: false, message: "Coupon has expired" }; + } + + // Check if coupon applies to all users or specific user + if (!coupon.isApplyForAll && !coupon.isUserBased) { + return { valid: false, message: "Coupon is not available for use" }; + } + + // Check minimum order amount + const minOrderValue = coupon.minOrder ? parseFloat(coupon.minOrder) : 0; + if (minOrderValue > 0 && orderAmount < minOrderValue) { + return { valid: false, message: `Minimum order amount is ${minOrderValue}` }; + } + + // Calculate discount + let discountAmount = 0; + if (coupon.discountPercent) { + const percent = parseFloat(coupon.discountPercent); + discountAmount = (orderAmount * percent) / 100; + } else if (coupon.flatDiscount) { + discountAmount = parseFloat(coupon.flatDiscount); + } + + // Apply max value limit + const maxValueLimit = coupon.maxValue ? parseFloat(coupon.maxValue) : 0; + if (maxValueLimit > 0 && discountAmount > maxValueLimit) { + discountAmount = maxValueLimit; + } + + return { + valid: true, + discountAmount, + coupon: { + id: coupon.id, + discountPercent: coupon.discountPercent, + flatDiscount: coupon.flatDiscount, + maxValue: coupon.maxValue, + } + }; + }), + + generateCancellationCoupon: protectedProcedure + .input( + z.object({ + orderId: z.number(), + }) + ) + .mutation(async ({ input, ctx }) => { + const { orderId } = input; + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Find the order with user and order status information + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + orderStatus: true, + }, + }); + + if (!order) { + throw new Error("Order not found"); + } + + // Check if order is cancelled (check if any status entry has isCancelled: true) + // const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false; + // if (!isOrderCancelled) { + // throw new Error("Order is not cancelled"); + // } + + // // Check if payment method is COD + // if (order.isCod) { + // throw new Error("Can't generate refund coupon for CoD Order"); + // } + + // Verify user exists + if (!order.user) { + throw new Error("User not found for this order"); + } + + // Generate coupon code: first 3 letters of user name or mobile + orderId + const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase(); + const couponCode = `${userNamePrefix}${orderId}`; + + // Check if coupon code already exists + const existingCoupon = await db.query.coupons.findFirst({ + where: eq(coupons.couponCode, couponCode), + }); + + if (existingCoupon) { + throw new Error("Coupon code already exists"); + } + + // Get order total amount + const orderAmount = parseFloat(order.totalAmount); + + // Calculate expiry date (30 days from now) + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + 30); + + // Create the coupon and update order status in a transaction + const coupon = await db.transaction(async (tx) => { + // Create the coupon + const result = await tx.insert(coupons).values({ + couponCode, + isUserBased: true, + flatDiscount: orderAmount.toString(), + minOrder: orderAmount.toString(), + maxValue: orderAmount.toString(), + validTill: expiryDate, + maxLimitForUser: 1, + createdBy: staffUserId, + isApplyForAll: false, + }).returning(); + + const coupon = result[0]; + + // Insert applicable users + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId: order.userId, + }); + + // Update order_status with refund coupon ID + await tx.update(orderStatus) + .set({ refundCouponId: coupon.id }) + .where(eq(orderStatus.orderId, orderId)); + + return coupon; + }); + + return coupon; + }), + + getReservedCoupons: protectedProcedure + .input(z.object({ + cursor: z.number().optional(), + limit: z.number().default(50), + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { cursor, limit, search } = input; + + let whereCondition = undefined; + const conditions = []; + + if (cursor) { + conditions.push(lt(reservedCoupons.id, cursor)); + } + + if (search && search.trim()) { + conditions.push(or( + like(reservedCoupons.secretCode, `%${search}%`), + like(reservedCoupons.couponCode, `%${search}%`) + )); + } + + if (conditions.length > 0) { + whereCondition = and(...conditions); + } + + const result = await db.query.reservedCoupons.findMany({ + where: whereCondition, + with: { + redeemedUser: true, + creator: true, + }, + orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)], + limit: limit + 1, // Fetch one extra to check if there's more + }); + + const hasMore = result.length > limit; + const coupons = hasMore ? result.slice(0, limit) : result; + const nextCursor = hasMore ? result[result.length - 1].id : undefined; + + return { + coupons, + nextCursor, + }; + }), + + createReservedCoupon: protectedProcedure + .input(createCouponBodySchema) + .mutation(async ({ input, ctx }) => { + const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; + + // Validation: ensure at least one discount type is provided + if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { + throw new Error("Either discountPercent or flatDiscount must be provided (but not both)"); + } + + // For reserved coupons, applicableUsers is not used, as it's redeemed by one user + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Generate secret code if not provided (use couponCode as base) + let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`; + + // Check if secret code already exists + const existing = await db.query.reservedCoupons.findFirst({ + where: eq(reservedCoupons.secretCode, secretCode), + }); + + if (existing) { + throw new Error("Secret code already exists"); + } + + const result = await db.insert(reservedCoupons).values({ + secretCode, + couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`, + discountPercent: discountPercent?.toString(), + flatDiscount: flatDiscount?.toString(), + minOrder: minOrder?.toString(), + productIds, + maxValue: maxValue?.toString(), + validTill: validTill ? dayjs(validTill).toDate() : undefined, + maxLimitForUser, + exclusiveApply: exclusiveApply || false, + createdBy: staffUserId, + }).returning(); + + const coupon = result[0]; + + // Insert applicable products if provided + if (applicableProducts && applicableProducts.length > 0) { + await db.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: coupon.id, + productId, + })) + ); + } + + return coupon; + }), + + getUsersMiniInfo: protectedProcedure + .input(z.object({ + search: z.string().optional(), + limit: z.number().min(1).max(50).default(20), + offset: z.number().min(0).default(0), + })) + .query(async ({ input }) => { + const { search, limit } = input; + + let whereCondition = undefined; + if (search && search.trim()) { + whereCondition = or( + like(users.name, `%${search}%`), + like(users.mobile, `%${search}%`) + ); + } + + const userList = await db.query.users.findMany({ + where: whereCondition, + columns: { + id: true, + name: true, + mobile: true, + }, + limit: limit, + offset: input.offset, + orderBy: (users, { asc }) => [asc(users.name)], + }); + + return { + users: userList.map(user => ({ + id: user.id, + name: user.name || 'Unknown', + mobile: user.mobile, + })) + }; + }), + + createCoupon: protectedProcedure + .input(z.object({ + mobile: z.string().min(1, 'Mobile number is required'), + })) + .mutation(async ({ input, ctx }) => { + const { mobile } = input; + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Clean mobile number (remove non-digits) + const cleanMobile = mobile.replace(/\D/g, ''); + + // Validate: exactly 10 digits + if (cleanMobile.length !== 10) { + throw new Error("Mobile number must be exactly 10 digits"); + } + + // Check if user exists, create if not + let user = await db.query.users.findFirst({ + where: eq(users.mobile, cleanMobile), + }); + + if (!user) { + // Create new user + const [newUser] = await db.insert(users).values({ + name: null, + email: null, + mobile: cleanMobile, + }).returning(); + user = newUser; + } + + // Generate unique coupon code + const timestamp = Date.now().toString().slice(-6); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`; + + // Check if coupon code already exists (very unlikely but safe) + const existingCode = await db.query.coupons.findFirst({ + where: eq(coupons.couponCode, couponCode), + }); + + if (existingCode) { + throw new Error("Generated coupon code already exists - please try again"); + } + + // Create the coupon + const [coupon] = await db.insert(coupons).values({ + couponCode, + isUserBased: true, + discountPercent: "20", // 20% discount + minOrder: "1000", // ₹1000 minimum order + maxValue: "500", // ₹500 maximum discount + maxLimitForUser: 1, // One-time use + isApplyForAll: false, + exclusiveApply: false, + createdBy: staffUserId, + validTill: dayjs().add(90, 'days').toDate(), // 90 days from now + }).returning(); + + // Associate coupon with user + await db.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId: user.id, + }); + + return { + success: true, + coupon: { + id: coupon.id, + couponCode: coupon.couponCode, + userId: user.id, + userMobile: user.mobile, + discountPercent: 20, + minOrder: 1000, + maxValue: 500, + maxLimitForUser: 1, + }, + }; + }), +}); diff --git a/verifier/admin-apis/apis/order.ts b/verifier/admin-apis/apis/order.ts new file mode 100644 index 0000000..707f642 --- /dev/null +++ b/verifier/admin-apis/apis/order.ts @@ -0,0 +1,1018 @@ +import { router, protectedProcedure } from "@/src/trpc/trpc-index" +import { z } from "zod"; +import { db } from "@/src/db/db_index" +import { + orders, + orderItems, + orderStatus, + users, + addresses, + refunds, + coupons, + couponUsage, + complaints, + payments, +} from "@/src/db/schema"; +import { eq, and, gte, lt, desc, SQL, inArray } from "drizzle-orm"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { ApiError } from "@/src/lib/api-error" +import { + sendOrderPackagedNotification, + sendOrderDeliveredNotification, +} from "@/src/lib/notif-job"; +import { publishCancellation } from "@/src/lib/post-order-handler" +import { getMultipleUserNegativityScores } from "@/src/stores/user-negativity-store" + +const updateOrderNotesSchema = z.object({ + orderId: z.number(), + adminNotes: z.string(), +}); + +const getFullOrderSchema = z.object({ + orderId: z.number(), +}); + +const getOrderDetailsSchema = z.object({ + orderId: z.number(), +}); + +const updatePackagedSchema = z.object({ + orderId: z.string(), + isPackaged: z.boolean(), +}); + +const updateDeliveredSchema = z.object({ + orderId: z.string(), + isDelivered: z.boolean(), +}); + +const updateOrderItemPackagingSchema = z.object({ + orderItemId: z.number(), + isPackaged: z.boolean().optional(), + isPackageVerified: z.boolean().optional(), +}); + +const getSlotOrdersSchema = z.object({ + slotId: z.string(), +}); + +const getTodaysOrdersSchema = z.object({ + slotId: z.string().optional(), +}); + +const getAllOrdersSchema = z.object({ + cursor: z.number().optional(), + limit: z.number().default(20), + slotId: z.number().optional().nullable(), + packagedFilter: z + .enum(["all", "packaged", "not_packaged"]) + .optional() + .default("all"), + deliveredFilter: z + .enum(["all", "delivered", "not_delivered"]) + .optional() + .default("all"), + cancellationFilter: z + .enum(["all", "cancelled", "not_cancelled"]) + .optional() + .default("all"), + flashDeliveryFilter: z + .enum(["all", "flash", "regular"]) + .optional() + .default("all"), +}); + +export const orderRouter = router({ + updateNotes: protectedProcedure + .input(updateOrderNotesSchema) + .mutation(async ({ input }) => { + const { orderId, adminNotes } = input; + + const result = await db + .update(orders) + .set({ + adminNotes: adminNotes || null, + }) + .where(eq(orders.id, orderId)) + .returning(); + + if (result.length === 0) { + throw new Error("Order not found"); + } + + return result[0]; + }), + + getFullOrder: protectedProcedure + .input(getFullOrderSchema) + .query(async ({ input }) => { + const { orderId } = input; + + const orderData = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + payment: true, + paymentInfo: true, + }, + }); + + if (!orderData) { + throw new Error("Order not found"); + } + + // Get order status separately + const statusRecord = await db.query.orderStatus.findFirst({ + where: eq(orderStatus.orderId, orderId), + }); + + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + // Get refund details if order is cancelled + let refund = null; + if (status === "cancelled") { + refund = await db.query.refunds.findFirst({ + where: eq(refunds.orderId, orderId), + }); + } + + return { + id: orderData.id, + readableId: orderData.id, + customerName: `${orderData.user.name}`, + customerEmail: orderData.user.email, + customerMobile: orderData.user.mobile, + address: { + line1: orderData.address.addressLine1, + line2: orderData.address.addressLine2, + city: orderData.address.city, + state: orderData.address.state, + pincode: orderData.address.pincode, + phone: orderData.address.phone, + }, + slotInfo: orderData.slot + ? { + time: orderData.slot.deliveryTime.toISOString(), + sequence: orderData.slot.deliverySequence, + } + : null, + isCod: orderData.isCod, + isOnlinePayment: orderData.isOnlinePayment, + totalAmount: orderData.totalAmount, + adminNotes: orderData.adminNotes, + userNotes: orderData.userNotes, + createdAt: orderData.createdAt, + status, + isPackaged: + orderData.orderItems.every((item) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + items: orderData.orderItems.map((item) => ({ + id: item.id, + name: item.product.name, + quantity: item.quantity, + price: item.price, + unit: item.product.unit?.shortNotation, + amount: + parseFloat(item.price.toString()) * + parseFloat(item.quantity || "0"), + })), + payment: orderData.payment + ? { + status: orderData.payment.status, + gateway: orderData.payment.gateway, + merchantOrderId: orderData.payment.merchantOrderId, + } + : null, + paymentInfo: orderData.paymentInfo + ? { + status: orderData.paymentInfo.status, + gateway: orderData.paymentInfo.gateway, + merchantOrderId: orderData.paymentInfo.merchantOrderId, + } + : null, + // Cancellation details (only present for cancelled orders) + cancelReason: statusRecord?.cancelReason || null, + cancellationReviewed: statusRecord?.cancellationReviewed || false, + isRefundDone: refund?.refundStatus === "processed" || false, + }; + }), + + getOrderDetails: protectedProcedure + .input(getOrderDetailsSchema) + .query(async ({ input }) => { + const { orderId } = input; + + // Single optimized query with all relations + const orderData = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + payment: true, + paymentInfo: true, + orderStatus: true, // Include in main query + refunds: true, // Include in main query + }, + }); + + if (!orderData) { + 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, orderData.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(orderData.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, + }; + } + + // Status determination from included relation + const statusRecord = orderData.orderStatus?.[0]; + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + // Always include refund data (will be null/undefined if not cancelled) + const refund = orderData.refunds?.[0]; + + return { + id: orderData.id, + readableId: orderData.id, + userId: orderData.user.id, + customerName: `${orderData.user.name}`, + customerEmail: orderData.user.email, + customerMobile: orderData.user.mobile, + address: { + name: orderData.address.name, + line1: orderData.address.addressLine1, + line2: orderData.address.addressLine2, + city: orderData.address.city, + state: orderData.address.state, + pincode: orderData.address.pincode, + phone: orderData.address.phone, + }, + slotInfo: orderData.slot + ? { + time: orderData.slot.deliveryTime.toISOString(), + sequence: orderData.slot.deliverySequence, + } + : null, + isCod: orderData.isCod, + isOnlinePayment: orderData.isOnlinePayment, + totalAmount: parseFloat(orderData.totalAmount?.toString() || '0') - parseFloat(orderData.deliveryCharge?.toString() || '0'), + deliveryCharge: parseFloat(orderData.deliveryCharge?.toString() || '0'), + adminNotes: orderData.adminNotes, + userNotes: orderData.userNotes, + createdAt: orderData.createdAt, + status, + isPackaged: statusRecord?.isPackaged || false, + isDelivered: statusRecord?.isDelivered || false, + items: orderData.orderItems.map((item) => ({ + id: item.id, + name: item.product.name, + quantity: item.quantity, + productSize: item.product.productQuantity, + price: item.price, + unit: item.product.unit?.shortNotation, + amount: + parseFloat(item.price.toString()) * + parseFloat(item.quantity || "0"), + isPackaged: item.is_packaged, + isPackageVerified: item.is_package_verified, + })), + payment: orderData.payment + ? { + status: orderData.payment.status, + gateway: orderData.payment.gateway, + merchantOrderId: orderData.payment.merchantOrderId, + } + : null, + paymentInfo: orderData.paymentInfo + ? { + status: orderData.paymentInfo.status, + gateway: orderData.paymentInfo.gateway, + merchantOrderId: orderData.paymentInfo.merchantOrderId, + } + : null, + // Cancellation details (always included, null if not cancelled) + cancelReason: statusRecord?.cancelReason || null, + cancellationReviewed: statusRecord?.cancellationReviewed || false, + isRefundDone: refund?.refundStatus === "processed" || false, + refundStatus: refund?.refundStatus as RefundStatus, + refundAmount: refund?.refundAmount + ? parseFloat(refund.refundAmount.toString()) + : null, + // Coupon information + couponData: couponData, + couponCode: couponData?.couponCode || null, + couponDescription: couponData?.couponDescription || null, + discountAmount: couponData?.discountAmount || null, + orderStatus: statusRecord, + refundRecord: refund, + isFlashDelivery: orderData.isFlashDelivery, + }; + }), + + updatePackaged: protectedProcedure + .input(updatePackagedSchema) + .mutation(async ({ input }) => { + const { orderId, isPackaged } = input; + + // Update all order items to the specified packaged state + await db + .update(orderItems) + .set({ is_packaged: isPackaged }) + .where(eq(orderItems.orderId, parseInt(orderId))); + + // Also update the order status table for backward compatibility + if (!isPackaged) { + await db + .update(orderStatus) + .set({ isPackaged, isDelivered: false }) + .where(eq(orderStatus.orderId, parseInt(orderId))); + } else { + await db + .update(orderStatus) + .set({ isPackaged }) + .where(eq(orderStatus.orderId, parseInt(orderId))); + } + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, parseInt(orderId)), + }); + if (order) await sendOrderPackagedNotification(order.userId, orderId); + + return { success: true }; + }), + + updateDelivered: protectedProcedure + .input(updateDeliveredSchema) + .mutation(async ({ input }) => { + const { orderId, isDelivered } = input; + + await db + .update(orderStatus) + .set({ isDelivered }) + .where(eq(orderStatus.orderId, parseInt(orderId))); + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, parseInt(orderId)), + }); + if (order) await sendOrderDeliveredNotification(order.userId, orderId); + + return { success: true }; + }), + + updateOrderItemPackaging: protectedProcedure + .input(updateOrderItemPackagingSchema) + .mutation(async ({ input }) => { + const { orderItemId, isPackaged, isPackageVerified } = input; + + // Validate that orderItem exists + const orderItem = await db.query.orderItems.findFirst({ + where: eq(orderItems.id, orderItemId), + }); + + if (!orderItem) { + throw new ApiError("Order item not found", 404); + } + + // Build update object with only provided fields + const updateData: any = {}; + if (isPackaged !== undefined) { + updateData.is_packaged = isPackaged; + } + if (isPackageVerified !== undefined) { + updateData.is_package_verified = isPackageVerified; + } + + // Update the order item + await db + .update(orderItems) + .set(updateData) + .where(eq(orderItems.id, orderItemId)); + + return { success: true }; + }), + + removeDeliveryCharge: protectedProcedure + .input(z.object({ orderId: z.number() })) + .mutation(async ({ input }) => { + const { orderId } = input; + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + }); + + if (!order) { + throw new Error('Order not found'); + } + + const currentDeliveryCharge = parseFloat(order.deliveryCharge?.toString() || '0'); + const currentTotalAmount = parseFloat(order.totalAmount?.toString() || '0'); + const newTotalAmount = currentTotalAmount - currentDeliveryCharge; + + await db + .update(orders) + .set({ + deliveryCharge: '0', + totalAmount: newTotalAmount.toString() + }) + .where(eq(orders.id, orderId)); + + return { success: true, message: 'Delivery charge removed' }; + }), + + getSlotOrders: protectedProcedure + .input(getSlotOrdersSchema) + .query(async ({ input }) => { + const { slotId } = input; + + const slotOrders = await db.query.orders.findMany({ + where: eq(orders.slotId, parseInt(slotId)), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + }, + }); + + const filteredOrders = slotOrders.filter((order) => { + const statusRecord = order.orderStatus[0]; + return ( + order.isCod || + (statusRecord && statusRecord.paymentStatus === "success") + ); + }); + + const formattedOrders = filteredOrders.map((order) => { + const statusRecord = order.orderStatus[0]; // assuming one status per order + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + const items = order.orderItems.map((item) => ({ + id: item.id, + name: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || "", + isPackaged: item.is_packaged, + isPackageVerified: item.is_package_verified, + })); + + return { + id: order.id, + readableId: order.id, + customerName: order.user.name, + address: `${order.address.addressLine1}${ + order.address.addressLine2 ? `, ${order.address.addressLine2}` : "" + }, ${order.address.city}, ${order.address.state} - ${ + order.address.pincode + }, Phone: ${order.address.phone}`, + addressId: order.addressId, + latitude: order.address.adminLatitude ?? order.address.latitude, + longitude: order.address.adminLongitude ?? order.address.longitude, + totalAmount: parseFloat(order.totalAmount), + items, + deliveryTime: order.slot?.deliveryTime.toISOString() || null, + status, + isPackaged: + order.orderItems.every((item) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + isCod: order.isCod, + paymentMode: order.isCod ? "COD" : "Online", + paymentStatus: statusRecord?.paymentStatus || "pending", + slotId: order.slotId, + adminNotes: order.adminNotes, + userNotes: order.userNotes, + }; + }); + + return { success: true, data: formattedOrders }; + }), + + getTodaysOrders: protectedProcedure + .input(getTodaysOrdersSchema) + .query(async ({ input }) => { + const { slotId } = input; + const start = dayjs().startOf("day").toDate(); + const end = dayjs().endOf("day").toDate(); + + let whereCondition = and( + gte(orders.createdAt, start), + lt(orders.createdAt, end) + ); + + if (slotId) { + whereCondition = and( + whereCondition, + eq(orders.slotId, parseInt(slotId)) + ); + } + + const todaysOrders = await db.query.orders.findMany({ + where: whereCondition, + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + }, + }); + + const filteredOrders = todaysOrders.filter((order) => { + const statusRecord = order.orderStatus[0]; + return ( + order.isCod || + (statusRecord && statusRecord.paymentStatus === "success") + ); + }); + + const formattedOrders = filteredOrders.map((order) => { + const statusRecord = order.orderStatus[0]; // assuming one status per order + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + const items = order.orderItems.map((item) => ({ + name: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || "", + })); + + return { + orderId: order.id.toString(), + readableId: order.id, + customerName: order.user.name, + address: `${order.address.addressLine1}${ + order.address.addressLine2 ? `, ${order.address.addressLine2}` : "" + }, ${order.address.city}, ${order.address.state} - ${ + order.address.pincode + }`, + totalAmount: parseFloat(order.totalAmount), + items, + deliveryTime: order.slot?.deliveryTime.toISOString() || null, + status, + isPackaged: + order.orderItems.every((item) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + isCod: order.isCod, + paymentMode: order.isCod ? "COD" : "Online", + paymentStatus: statusRecord?.paymentStatus || "pending", + slotId: order.slotId, + adminNotes: order.adminNotes, + userNotes: order.userNotes, + }; + }); + + return { success: true, data: formattedOrders }; + }), + + updateAddressCoords: protectedProcedure + .input( + z.object({ + addressId: z.number(), + latitude: z.number(), + longitude: z.number(), + }) + ) + .mutation(async ({ input }) => { + const { addressId, latitude, longitude } = input; + + const result = await db + .update(addresses) + .set({ + adminLatitude: latitude, + adminLongitude: longitude, + }) + .where(eq(addresses.id, addressId)) + .returning(); + + if (result.length === 0) { + throw new ApiError("Address not found", 404); + } + + return { success: true }; + }), + + getAll: protectedProcedure + .input(getAllOrdersSchema) + .query(async ({ input }) => { + try { + const { + cursor, + limit, + slotId, + packagedFilter, + deliveredFilter, + cancellationFilter, + flashDeliveryFilter, + } = input; + + let whereCondition: SQL | undefined = eq(orders.id, orders.id); // always true + if (cursor) { + whereCondition = and(whereCondition, lt(orders.id, cursor)); + } + if (slotId) { + whereCondition = and(whereCondition, eq(orders.slotId, slotId)); + } + if (packagedFilter === "packaged") { + whereCondition = and( + whereCondition, + eq(orderStatus.isPackaged, true) + ); + } else if (packagedFilter === "not_packaged") { + whereCondition = and( + whereCondition, + eq(orderStatus.isPackaged, false) + ); + } + if (deliveredFilter === "delivered") { + whereCondition = and( + whereCondition, + eq(orderStatus.isDelivered, true) + ); + } else if (deliveredFilter === "not_delivered") { + whereCondition = and( + whereCondition, + eq(orderStatus.isDelivered, false) + ); + } + if (cancellationFilter === "cancelled") { + whereCondition = and( + whereCondition, + eq(orderStatus.isCancelled, true) + ); + } else if (cancellationFilter === "not_cancelled") { + whereCondition = and( + whereCondition, + eq(orderStatus.isCancelled, false) + ); + } + if (flashDeliveryFilter === "flash") { + whereCondition = and( + whereCondition, + eq(orders.isFlashDelivery, true) + ); + } else if (flashDeliveryFilter === "regular") { + whereCondition = and( + whereCondition, + eq(orders.isFlashDelivery, false) + ); + } + + const allOrders = await db.query.orders.findMany({ + where: whereCondition, + orderBy: desc(orders.createdAt), + limit: limit + 1, // fetch one extra to check if there's more + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + }, + }); + + const hasMore = allOrders.length > limit; + const ordersToReturn = hasMore ? allOrders.slice(0, limit) : allOrders; + + const userIds = [...new Set(ordersToReturn.map(o => o.userId))]; + const negativityScores = await getMultipleUserNegativityScores(userIds); + + const filteredOrders = ordersToReturn.filter((order) => { + const statusRecord = order.orderStatus[0]; + return ( + order.isCod || + (statusRecord && statusRecord.paymentStatus === "success") + ); + }); + + const formattedOrders = filteredOrders.map((order) => { + const statusRecord = order.orderStatus[0]; + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + const items = order.orderItems + .map((item) => ({ + id: item.id, + name: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + amount: + parseFloat(item.quantity) * parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || "", + productSize: item.product.productQuantity, + isPackaged: item.is_packaged, + isPackageVerified: item.is_package_verified, + })) + .sort((first, second) => first.id - second.id); + dayjs.extend(utc); + return { + id: order.id, + orderId: order.id.toString(), + readableId: order.id, + customerName: order.user.name, + customerMobile: order.user.mobile, + address: `${order.address.addressLine1}${ + order.address.addressLine2 + ? `, ${order.address.addressLine2}` + : "" + }, ${order.address.city}, ${order.address.state} - ${ + order.address.pincode + }, Phone: ${order.address.phone}`, + addressId: order.addressId, + latitude: order.address.adminLatitude ?? order.address.latitude, + longitude: order.address.adminLongitude ?? order.address.longitude, + totalAmount: parseFloat(order.totalAmount), + deliveryCharge: parseFloat(order.deliveryCharge || "0"), + items, + createdAt: order.createdAt, + // deliveryTime: order.slot ? dayjs.utc(order.slot.deliveryTime).format('ddd, MMM D • h:mm A') : 'Not scheduled', + deliveryTime: order.slot?.deliveryTime.toISOString() || null, + status, + isPackaged: + order.orderItems.every((item) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + isCod: order.isCod, + isFlashDelivery: order.isFlashDelivery, + userNotes: order.userNotes, + adminNotes: order.adminNotes, + userNegativityScore: negativityScores[order.userId] || 0, + }; + }); + + return { + orders: formattedOrders, + nextCursor: hasMore + ? ordersToReturn[ordersToReturn.length - 1].id + : undefined, + }; + } catch (e) { + console.log({ e }); + } + }), + + rebalanceSlots: protectedProcedure + .input(z.object({ slotIds: z.array(z.number()).min(1).max(50) })) + .mutation(async ({ input }) => { + const slotIds = input.slotIds; + + const ordersList = await db.query.orders.findMany({ + where: inArray(orders.slotId, slotIds), + with: { + orderItems: { + with: { + product: true + } + }, + couponUsages: { + with: { + coupon: true + } + }, + } + }); + + const processedOrdersData = ordersList.map((order) => { + + let newTotal = order.orderItems.reduce((acc,item) => { + const latestPrice = +item.product.price; + const amount = (latestPrice * Number(item.quantity)); + return acc+amount; + },0) + + order.orderItems.forEach(item => { + item.price = item.product.price; + item.discountedPrice = item.product.price + }) + + const coupon = order.couponUsages[0]?.coupon; + + let discount = 0; + if(coupon && !coupon.isInvalidated && (!coupon.validTill || new Date(coupon.validTill) > new Date())) { + const proportion = Number(order.orderGroupProportion || 1); + if(coupon.discountPercent) { + const maxDiscount = Number(coupon.maxValue || Infinity) * proportion; + discount = Math.min((newTotal * parseFloat(coupon.discountPercent)) / 100, maxDiscount); + } + else { + discount = Number(coupon.flatDiscount) * proportion; + } + } + newTotal -= discount + + const { couponUsages, orderItems: orderItemsRaw, ...rest} = order; + const updatedOrderItems = orderItemsRaw.map(item => { + const { product, ...rawOrderItem } = item; + return rawOrderItem; + }) + return {order: rest, updatedOrderItems, newTotal } + }) + + const updatedOrderIds: number[] = []; + await db.transaction(async (tx) => { + for (const { order, updatedOrderItems, newTotal } of processedOrdersData) { + await tx.update(orders).set({ totalAmount: newTotal.toString() }).where(eq(orders.id, order.id)); + updatedOrderIds.push(order.id); + + for (const item of updatedOrderItems) { + await tx.update(orderItems).set({ + price: item.price, + discountedPrice: item.discountedPrice + }).where(eq(orderItems.id, item.id)); + } + } + }); + + return { success: true, updatedOrders: updatedOrderIds, message: `Rebalanced ${updatedOrderIds.length} orders.` }; + }), + + cancelOrder: protectedProcedure + .input(z.object({ + orderId: z.number(), + reason: z.string().min(1, "Cancellation reason is required"), + })) + .mutation(async ({ input }) => { + const { orderId, reason } = input; + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + orderStatus: true, + }, + }); + + if (!order) { + throw new ApiError("Order not found", 404); + } + + const status = order.orderStatus[0]; + if (!status) { + throw new ApiError("Order status not found", 400); + } + + if (status.isCancelled) { + throw new ApiError("Order is already cancelled", 400); + } + + if (status.isDelivered) { + throw new ApiError("Cannot cancel delivered order", 400); + } + + const result = await db.transaction(async (tx) => { + await tx + .update(orderStatus) + .set({ + isCancelled: true, + isCancelledByAdmin: true, + cancelReason: reason, + cancellationAdminNotes: reason, + cancellationReviewed: true, + cancellationReviewedAt: new Date(), + }) + .where(eq(orderStatus.id, status.id)); + + const refundStatus = order.isCod ? "na" : "pending"; + + await tx.insert(refunds).values({ + orderId: order.id, + refundStatus, + }); + + return { orderId: order.id, userId: order.userId }; + }); + + // Publish to Redis for Telegram notification + await publishCancellation(result.orderId, 'admin', reason); + + return { success: true, message: "Order cancelled successfully" }; + }), +}); + +// {"id": "order_Rhh00qJNdjUp8o", "notes": {"retry": "true", "customerOrderId": "14"}, "amount": 21000, "entity": "order", "status": "created", "receipt": "order_14_retry", "attempts": 0, "currency": "INR", "offer_id": null, "signature": "6df20655021f1d6841340f2a2ef2ef9378cb3d43495ab09e85f08aea1a851583", "amount_due": 21000, "created_at": 1763575791, "payment_id": "pay_Rhh15cLL28YM7j", "amount_paid": 0} + +type RefundStatus = "success" | "pending" | "failed" | "none" | "na"; + +export async function deleteOrderById(orderId: number): Promise { + await db.transaction(async (tx) => { + await tx.delete(orderItems).where(eq(orderItems.orderId, orderId)); + await tx.delete(orderStatus).where(eq(orderStatus.orderId, orderId)); + await tx.delete(payments).where(eq(payments.orderId, orderId)); + await tx.delete(refunds).where(eq(refunds.orderId, orderId)); + await tx.delete(couponUsage).where(eq(couponUsage.orderId, orderId)); + await tx.delete(complaints).where(eq(complaints.orderId, orderId)); + await tx.delete(orders).where(eq(orders.id, orderId)); + }); +} + diff --git a/verifier/admin-apis/apis/payments.ts b/verifier/admin-apis/apis/payments.ts new file mode 100644 index 0000000..51de5fb --- /dev/null +++ b/verifier/admin-apis/apis/payments.ts @@ -0,0 +1,146 @@ +import { router, protectedProcedure } from "@/src/trpc/trpc-index" +import { z } from "zod"; +import { db } from "@/src/db/db_index" +import { + orders, + orderStatus, + payments, + refunds, +} from "@/src/db/schema"; +import { and, eq } from "drizzle-orm"; +import { ApiError } from "@/src/lib/api-error" +import { RazorpayPaymentService } from "@/src/lib/payments-utils" + +const initiateRefundSchema = z + .object({ + orderId: z.number(), + refundPercent: z.number().min(0).max(100).optional(), + refundAmount: z.number().min(0).optional(), + }) + .refine( + (data) => { + const hasPercent = data.refundPercent !== undefined; + const hasAmount = data.refundAmount !== undefined; + return (hasPercent && !hasAmount) || (!hasPercent && hasAmount); + }, + { + message: + "Provide either refundPercent or refundAmount, not both or neither", + } + ); + +export const adminPaymentsRouter = router({ + initiateRefund: protectedProcedure + .input(initiateRefundSchema) + .mutation(async ({ input }) => { + try { + const { orderId, refundPercent, refundAmount } = input; + + // Validate order exists + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + }); + + if (!order) { + throw new ApiError("Order not found", 404); + } + + // Check if order is paid + const orderStatusRecord = await db.query.orderStatus.findFirst({ + where: eq(orderStatus.orderId, orderId), + }); + + if(order.isCod) { + throw new ApiError("Order is a Cash On Delivery. Not eligible for refund") + } + + if ( + !orderStatusRecord || + (orderStatusRecord.paymentStatus !== "success" && + !(order.isCod && orderStatusRecord.isDelivered)) + ) { + throw new ApiError("Order payment not verified or not eligible for refund", 400); + } + + // Calculate refund amount + let calculatedRefundAmount: number; + if (refundPercent !== undefined) { + calculatedRefundAmount = + (parseFloat(order.totalAmount) * refundPercent) / 100; + } else if (refundAmount !== undefined) { + calculatedRefundAmount = refundAmount; + if (calculatedRefundAmount > parseFloat(order.totalAmount)) { + throw new ApiError("Refund amount cannot exceed order total", 400); + } + } else { + throw new ApiError("Invalid refund parameters", 400); + } + + let razorpayRefund = null; + let merchantRefundId = null; + + // Get payment record for online payments + const payment = await db.query.payments.findFirst({ + where: and( + eq(payments.orderId, orderId), + eq(payments.status, "success") + ), + }); + + if (!payment || payment.status !== "success") { + throw new ApiError("Payment not found or not successful", 404); + } + + const payload = payment.payload as any; + // Initiate Razorpay refund + razorpayRefund = await RazorpayPaymentService.initiateRefund( + payload.payment_id, + Math.round(calculatedRefundAmount * 100) // Convert to paisa + ); + merchantRefundId = razorpayRefund.id; + + + + // Check if refund already exists for this order + const existingRefund = await db.query.refunds.findFirst({ + where: eq(refunds.orderId, orderId), + }); + + const refundStatus = "initiated"; + + if (existingRefund) { + // Update existing refund + await db + .update(refunds) + .set({ + refundAmount: calculatedRefundAmount.toString(), + refundStatus, + merchantRefundId, + refundProcessedAt: order.isCod ? new Date() : null, + }) + .where(eq(refunds.id, existingRefund.id)); + } else { + // Insert new refund + await db + .insert(refunds) + .values({ + orderId, + refundAmount: calculatedRefundAmount.toString(), + refundStatus, + merchantRefundId, + }); + } + + return { + refundId: merchantRefundId || `cod_${orderId}`, + amount: calculatedRefundAmount, + status: refundStatus, + message: order.isCod ? "COD refund processed successfully" : "Refund initiated successfully", + }; + } + catch(e) { + console.log(e); + throw new ApiError("Failed to initiate refund") + } + }), +}); diff --git a/verifier/admin-apis/apis/product-availability-schedules.ts b/verifier/admin-apis/apis/product-availability-schedules.ts new file mode 100644 index 0000000..47cdc43 --- /dev/null +++ b/verifier/admin-apis/apis/product-availability-schedules.ts @@ -0,0 +1,154 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { productAvailabilitySchedules } from '@/src/db/schema' +import { eq } from 'drizzle-orm'; +import { refreshScheduleJobs } from '@/src/lib/automatedJobs'; + +const createScheduleSchema = z.object({ + scheduleName: z.string().min(1, "Schedule name is required"), + time: z.string().min(1, "Time is required").regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format. Use HH:MM"), + action: z.enum(['in', 'out']), + productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"), + groupIds: z.array(z.number().int().positive()).default([]), +}); + +const updateScheduleSchema = z.object({ + id: z.number().int().positive(), + updates: createScheduleSchema.partial().extend({ + scheduleName: z.string().min(1).optional(), + productIds: z.array(z.number().int().positive()).optional(), + groupIds: z.array(z.number().int().positive()).optional(), + }), +}); + +export const productAvailabilitySchedulesRouter = router({ + create: protectedProcedure + .input(createScheduleSchema) + .mutation(async ({ input, ctx }) => { + const { scheduleName, time, action, productIds, groupIds } = input; + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Check if schedule name already exists + const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.scheduleName, scheduleName), + }); + if (existingSchedule) { + throw new Error("Schedule name already exists"); + } + + // Create schedule with arrays + const scheduleResult = await db.insert(productAvailabilitySchedules).values({ + scheduleName, + time, + action, + productIds, + groupIds, + }).returning(); + + // Refresh cron jobs to include new schedule + await refreshScheduleJobs(); + + return scheduleResult[0]; + }), + + getAll: protectedProcedure + .query(async () => { + const schedules = await db.query.productAvailabilitySchedules.findMany({ + orderBy: (productAvailabilitySchedules, { desc }) => [desc(productAvailabilitySchedules.createdAt)], + }); + + return schedules.map(schedule => ({ + ...schedule, + productCount: schedule.productIds.length, + groupCount: schedule.groupIds.length, + })); + }), + + getById: protectedProcedure + .input(z.object({ id: z.number().int().positive() })) + .query(async ({ input }) => { + const { id } = input; + + const schedule = await db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.id, id), + }); + + if (!schedule) { + throw new Error("Schedule not found"); + } + + return schedule; + }), + + update: protectedProcedure + .input(updateScheduleSchema) + .mutation(async ({ input }) => { + const { id, updates } = input; + + // Check if schedule exists + const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.id, id), + }); + if (!existingSchedule) { + throw new Error("Schedule not found"); + } + + // Check schedule name uniqueness if being updated + if (updates.scheduleName && updates.scheduleName !== existingSchedule.scheduleName) { + const duplicateSchedule = await db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.scheduleName, updates.scheduleName), + }); + if (duplicateSchedule) { + throw new Error("Schedule name already exists"); + } + } + + // Update schedule + const updateData: any = {}; + if (updates.scheduleName !== undefined) updateData.scheduleName = updates.scheduleName; + if (updates.time !== undefined) updateData.time = updates.time; + if (updates.action !== undefined) updateData.action = updates.action; + if (updates.productIds !== undefined) updateData.productIds = updates.productIds; + if (updates.groupIds !== undefined) updateData.groupIds = updates.groupIds; + updateData.lastUpdated = new Date(); + + const result = await db.update(productAvailabilitySchedules) + .set(updateData) + .where(eq(productAvailabilitySchedules.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Failed to update schedule"); + } + + // Refresh cron jobs to reflect changes + await refreshScheduleJobs(); + + return result[0]; + }), + + delete: protectedProcedure + .input(z.object({ id: z.number().int().positive() })) + .mutation(async ({ input }) => { + const { id } = input; + + const result = await db.delete(productAvailabilitySchedules) + .where(eq(productAvailabilitySchedules.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Schedule not found"); + } + + // Refresh cron jobs to remove deleted schedule + await refreshScheduleJobs(); + + return { message: "Schedule deleted successfully" }; + }), +}); diff --git a/verifier/admin-apis/apis/product.ts b/verifier/admin-apis/apis/product.ts new file mode 100644 index 0000000..4677c40 --- /dev/null +++ b/verifier/admin-apis/apis/product.ts @@ -0,0 +1,758 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema' +import { eq, and, inArray, desc, sql } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error' +import { imageUploadS3, scaffoldAssetUrl, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client' +import { deleteS3Image } from '@/src/lib/delete-image' +import type { SpecialDeal } from '@/src/db/types' +import { scheduleStoreInitialization } from '@/src/stores/store-initializer' + + +type CreateDeal = { + quantity: number; + price: number; + validTill: string; +}; + +export const productRouter = router({ + getProducts: protectedProcedure + .query(async ({ ctx }) => { + const products = await db.query.productInfo.findMany({ + orderBy: productInfo.name, + with: { + unit: true, + store: true, + }, + }); + + // Generate signed URLs for all product images + const productsWithSignedUrls = await Promise.all( + products.map(async (product) => ({ + ...product, + images: scaffoldAssetUrl((product.images as string[]) || []), + })) + ); + + return { + products: productsWithSignedUrls, + count: productsWithSignedUrls.length, + }; + }), + + getProductById: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .query(async ({ input, ctx }) => { + const { id } = input; + + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + with: { + unit: true, + }, + }); + + if (!product) { + throw new ApiError("Product not found", 404); + } + + // Fetch special deals for this product + const deals = await db.query.specialDeals.findMany({ + where: eq(specialDeals.productId, id), + orderBy: specialDeals.quantity, + }); + + // Fetch associated tags for this product + const productTagsData = await db.query.productTags.findMany({ + where: eq(productTags.productId, id), + with: { + tag: true, + }, + }); + + // Generate signed URLs for product images + const productWithSignedUrls = { + ...product, + images: scaffoldAssetUrl((product.images as string[]) || []), + deals, + tags: productTagsData.map(pt => pt.tag), + }; + + return { + product: productWithSignedUrls, + }; + }), + + deleteProduct: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + const [deletedProduct] = await db + .delete(productInfo) + .where(eq(productInfo.id, id)) + .returning(); + + if (!deletedProduct) { + throw new ApiError("Product not found", 404); + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + message: "Product deleted successfully", + }; + }), + + createProduct: protectedProcedure + .input(z.object({ + name: z.string().min(1), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + unitId: z.number(), + storeId: z.number(), + price: z.number(), + marketPrice: z.number().optional(), + incrementStep: z.number().default(1), + productQuantity: z.number().default(1), + isSuspended: z.boolean().default(false), + isFlashAvailable: z.boolean().default(false), + flashPrice: z.number().optional(), + deals: z.array(z.object({ + quantity: z.number(), + price: z.number(), + validTill: z.string(), + })).optional(), + tagIds: z.array(z.number()).optional(), + imageKeys: z.array(z.string()).optional(), + })) + .mutation(async ({ input }) => { + const { + name, shortDescription, longDescription, unitId, storeId, + price, marketPrice, incrementStep, productQuantity, + isSuspended, isFlashAvailable, flashPrice, + deals, tagIds, imageKeys + } = input; + + // Validation + if (!name || !unitId || !storeId || !price) { + throw new ApiError("Name, unitId, storeId, and price are required", 400); + } + + // Check for duplicate name + const existingProduct = await db.query.productInfo.findFirst({ + where: eq(productInfo.name, name.trim()), + }); + if (existingProduct) { + throw new ApiError("A product with this name already exists", 400); + } + + // Check if unit exists + const unit = await db.query.units.findFirst({ + where: eq(units.id, unitId), + }); + if (!unit) { + throw new ApiError("Invalid unit ID", 400); + } + + console.log(imageKeys) + const [newProduct] = await db + .insert(productInfo) + .values({ + name: name.trim(), + shortDescription, + longDescription, + unitId, + storeId, + price: price.toString(), + marketPrice: marketPrice?.toString(), + incrementStep, + productQuantity, + isSuspended, + isFlashAvailable, + flashPrice: flashPrice?.toString(), + images: imageKeys || [], + }) + .returning(); + + // Handle deals + if (deals && deals.length > 0) { + const dealInserts = deals.map(deal => ({ + productId: newProduct.id, + quantity: deal.quantity.toString(), + price: deal.price.toString(), + validTill: new Date(deal.validTill), + })); + await db.insert(specialDeals).values(dealInserts); + } + + // Handle tags + if (tagIds && tagIds.length > 0) { + const tagAssociations = tagIds.map(tagId => ({ + productId: newProduct.id, + tagId, + })); + await db.insert(productTags).values(tagAssociations); + } + + // Claim upload URLs + if (imageKeys && imageKeys.length > 0) { + for (const key of imageKeys) { + try { + await claimUploadUrl(key); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${key}`, e); + } + } + } + + scheduleStoreInitialization(); + + return { + product: newProduct, + message: "Product created successfully", + }; + }), + + updateProduct: protectedProcedure + .input(z.object({ + id: z.number(), + name: z.string().min(1).optional(), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + unitId: z.number().optional(), + storeId: z.number().optional(), + price: z.number().optional(), + marketPrice: z.number().optional(), + incrementStep: z.number().optional(), + productQuantity: z.number().optional(), + isSuspended: z.boolean().optional(), + isFlashAvailable: z.boolean().optional(), + flashPrice: z.number().optional(), + deals: z.array(z.object({ + quantity: z.number(), + price: z.number(), + validTill: z.string(), + })).optional(), + tagIds: z.array(z.number()).optional(), + newImageKeys: z.array(z.string()).optional(), + imagesToDelete: z.array(z.string()).optional(), + })) + .mutation(async ({ input }) => { + const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input; + + // Get current product + const currentProduct = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + }); + if (!currentProduct) { + throw new ApiError("Product not found", 404); + } + + // Handle image deletions + let currentImages = (currentProduct.images as string[]) || []; + if (imagesToDelete && imagesToDelete.length > 0) { + for (const imageUrl of imagesToDelete) { + try { + await deleteS3Image(imageUrl); + } catch (e) { + console.error(`Failed to delete image: ${imageUrl}`, e); + } + } + currentImages = currentImages.filter(img => { + //!imagesToDelete.includes(img) + const isRemoved = imagesToDelete.some(item => item.includes(img)); + return !isRemoved; + }); + } + + // Add new images + if (newImageKeys && newImageKeys.length > 0) { + currentImages = [...currentImages, ...newImageKeys]; + + for (const key of newImageKeys) { + try { + await claimUploadUrl(key); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${key}`, e); + } + } + } + + // Update product - convert numeric fields to strings for PostgreSQL numeric type + const { price, marketPrice, flashPrice, ...otherData } = updateData; + const [updatedProduct] = await db + .update(productInfo) + .set({ + ...otherData, + ...(price !== undefined && { price: price.toString() }), + ...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }), + ...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }), + images: currentImages, + }) + .where(eq(productInfo.id, id)) + .returning(); + + // Handle deals update + if (deals !== undefined) { + await db.delete(specialDeals).where(eq(specialDeals.productId, id)); + if (deals.length > 0) { + const dealInserts = deals.map(deal => ({ + productId: id, + quantity: deal.quantity.toString(), + price: deal.price.toString(), + validTill: new Date(deal.validTill), + })); + await db.insert(specialDeals).values(dealInserts); + } + } + + // Handle tags update + if (tagIds !== undefined) { + await db.delete(productTags).where(eq(productTags.productId, id)); + if (tagIds.length > 0) { + const tagAssociations = tagIds.map(tagId => ({ + productId: id, + tagId, + })); + await db.insert(productTags).values(tagAssociations); + } + } + + scheduleStoreInitialization(); + + return { + product: updatedProduct, + message: "Product updated successfully", + }; + }), + + toggleOutOfStock: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + }); + + if (!product) { + throw new ApiError("Product not found", 404); + } + + const [updatedProduct] = await db + .update(productInfo) + .set({ + isOutOfStock: !product.isOutOfStock, + }) + .where(eq(productInfo.id, id)) + .returning(); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + product: updatedProduct, + message: `Product marked as ${updatedProduct.isOutOfStock ? 'out of stock' : 'in stock'}`, + }; + }), + + updateSlotProducts: protectedProcedure + .input(z.object({ + slotId: z.string(), + productIds: z.array(z.string()), + })) + .mutation(async ({ input, ctx }) => { + const { slotId, productIds } = input; + + if (!Array.isArray(productIds)) { + throw new ApiError("productIds must be an array", 400); + } + + // Get current associations + const currentAssociations = await db.query.productSlots.findMany({ + where: eq(productSlots.slotId, parseInt(slotId)), + columns: { + productId: true, + }, + }); + + const currentProductIds = currentAssociations.map(assoc => assoc.productId); + const newProductIds = productIds.map((id: string) => parseInt(id)); + + // 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, parseInt(slotId)), + inArray(productSlots.productId, productsToRemove) + ) + ); + } + + // Add associations for newly selected products + if (productsToAdd.length > 0) { + const newAssociations = productsToAdd.map(productId => ({ + productId, + slotId: parseInt(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, + }; + }), + + getSlotProductIds: protectedProcedure + .input(z.object({ + slotId: z.string(), + })) + .query(async ({ input, ctx }) => { + const { slotId } = input; + + const associations = await db.query.productSlots.findMany({ + where: eq(productSlots.slotId, parseInt(slotId)), + columns: { + productId: true, + }, + }); + + const productIds = associations.map(assoc => assoc.productId); + + return { + productIds, + }; + }), + + getSlotsProductIds: protectedProcedure + .input(z.object({ + slotIds: z.array(z.number()), + })) + .query(async ({ input, ctx }) => { + const { slotIds } = input; + + if (!Array.isArray(slotIds)) { + throw new ApiError("slotIds must be an array", 400); + } + + 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; + }), + + getProductReviews: protectedProcedure + .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, + adminResponse: productReviews.adminResponse, + adminResponseImages: productReviews.adminResponseImages, + 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[]) || []), + signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages 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 }; + }), + + respondToReview: protectedProcedure + .input(z.object({ + reviewId: z.number().int().positive(), + adminResponse: z.string().optional(), + adminResponseImages: z.array(z.string()).optional().default([]), + uploadUrls: z.array(z.string()).optional().default([]), + })) + .mutation(async ({ input }) => { + const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input; + + const [updatedReview] = await db + .update(productReviews) + .set({ + adminResponse, + adminResponseImages, + }) + .where(eq(productReviews.id, reviewId)) + .returning(); + + if (!updatedReview) { + throw new ApiError('Review not found', 404); + } + + // Claim upload URLs + if (uploadUrls && uploadUrls.length > 0) { + // const { claimUploadUrl } = await import('@/src/lib/s3-client'); + await Promise.all(uploadUrls.map(url => claimUploadUrl(url))); + } + + return { success: true, review: updatedReview }; + }), + + getGroups: protectedProcedure + .query(async ({ ctx }) => { + const groups = await db.query.productGroupInfo.findMany({ + with: { + memberships: { + with: { + product: true, + }, + }, + }, + orderBy: desc(productGroupInfo.createdAt), + }); + + return { + groups: groups.map(group => ({ + ...group, + products: group.memberships.map(m => m.product), + productCount: group.memberships.length, + })), + }; + }), + + createGroup: protectedProcedure + .input(z.object({ + group_name: z.string().min(1), + description: z.string().optional(), + product_ids: z.array(z.number()).default([]), + })) + .mutation(async ({ input, ctx }) => { + const { group_name, description, product_ids } = input; + + const [newGroup] = await db + .insert(productGroupInfo) + .values({ + groupName: group_name, + description, + }) + .returning(); + + if (product_ids.length > 0) { + const memberships = product_ids.map(productId => ({ + productId, + groupId: newGroup.id, + })); + + await db.insert(productGroupMembership).values(memberships); + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + group: newGroup, + message: 'Group created successfully', + }; + }), + + updateGroup: protectedProcedure + .input(z.object({ + id: z.number(), + group_name: z.string().optional(), + description: z.string().optional(), + product_ids: z.array(z.number()).optional(), + })) + .mutation(async ({ input, ctx }) => { + const { id, group_name, description, product_ids } = input; + + const updateData: any = {}; + if (group_name !== undefined) updateData.groupName = group_name; + if (description !== undefined) updateData.description = description; + + const [updatedGroup] = await db + .update(productGroupInfo) + .set(updateData) + .where(eq(productGroupInfo.id, id)) + .returning(); + + if (!updatedGroup) { + throw new ApiError('Group not found', 404); + } + + if (product_ids !== undefined) { + // Delete existing memberships + await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)); + + // Insert new memberships + if (product_ids.length > 0) { + const memberships = product_ids.map(productId => ({ + productId, + groupId: id, + })); + + await db.insert(productGroupMembership).values(memberships); + } + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + group: updatedGroup, + message: 'Group updated successfully', + }; + }), + + deleteGroup: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + // Delete memberships first + await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)); + + // Delete group + const [deletedGroup] = await db + .delete(productGroupInfo) + .where(eq(productGroupInfo.id, id)) + .returning(); + + if (!deletedGroup) { + throw new ApiError('Group not found', 404); + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + message: 'Group deleted successfully', + }; + }), + + updateProductPrices: protectedProcedure + .input(z.object({ + updates: z.array(z.object({ + productId: z.number(), + price: z.number().optional(), + marketPrice: z.number().nullable().optional(), + flashPrice: z.number().nullable().optional(), + isFlashAvailable: z.boolean().optional(), + })), + })) + .mutation(async ({ input, ctx }) => { + const { updates } = input; + + if (updates.length === 0) { + throw new ApiError('No updates provided', 400); + } + + // Validate that all productIds exist + const productIds = updates.map(u => u.productId); + const existingProducts = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + columns: { id: true }, + }); + + const existingIds = new Set(existingProducts.map(p => p.id)); + const invalidIds = productIds.filter(id => !existingIds.has(id)); + + if (invalidIds.length > 0) { + throw new ApiError(`Invalid product IDs: ${invalidIds.join(', ')}`, 400); + } + + // Perform batch update + const updatePromises = updates.map(async (update) => { + const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update; + const updateData: any = {}; + if (price !== undefined) updateData.price = price; + if (marketPrice !== undefined) updateData.marketPrice = marketPrice; + if (flashPrice !== undefined) updateData.flashPrice = flashPrice; + if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable; + + return db + .update(productInfo) + .set(updateData) + .where(eq(productInfo.id, productId)); + }); + + await Promise.all(updatePromises); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + message: `Updated prices for ${updates.length} product(s)`, + updatedCount: updates.length, + }; + }), + }); diff --git a/verifier/admin-apis/apis/slots.ts b/verifier/admin-apis/apis/slots.ts new file mode 100644 index 0000000..1cc40e8 --- /dev/null +++ b/verifier/admin-apis/apis/slots.ts @@ -0,0 +1,610 @@ +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); + + // 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'}`, + }; + }), +}); diff --git a/verifier/admin-apis/apis/staff-user.ts b/verifier/admin-apis/apis/staff-user.ts new file mode 100644 index 0000000..428f36e --- /dev/null +++ b/verifier/admin-apis/apis/staff-user.ts @@ -0,0 +1,242 @@ +import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { staffUsers, staffRoles, users, userDetails, orders } from '@/src/db/schema' +import { eq, or, ilike, and, lt, desc } from 'drizzle-orm'; +import bcrypt from 'bcryptjs'; +import { ApiError } from '@/src/lib/api-error' +import { signToken } from '@/src/lib/jwt-utils' + +export const staffUserRouter = router({ + login: publicProcedure + .input(z.object({ + name: z.string(), + password: z.string(), + })) + .mutation(async ({ input }) => { + const { name, password } = input; + + if (!name || !password) { + throw new ApiError('Name and password are required', 400); + } + + const staff = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }); + + if (!staff) { + throw new ApiError('Invalid credentials', 401); + } + + const isPasswordValid = await bcrypt.compare(password, staff.password); + if (!isPasswordValid) { + throw new ApiError('Invalid credentials', 401); + } + + const token = await signToken( + { staffId: staff.id, name: staff.name }, + '30d' + ); + + return { + message: 'Login successful', + token, + staff: { id: staff.id, name: staff.name }, + }; + }), + + getStaff: protectedProcedure + .query(async ({ ctx }) => { + const staff = await db.query.staffUsers.findMany({ + columns: { + id: true, + name: true, + }, + with: { + role: { + with: { + rolePermissions: { + with: { + permission: true, + }, + }, + }, + }, + }, + }); + + // Transform the data to include role and permissions in a cleaner format + const transformedStaff = staff.map((user) => ({ + id: user.id, + name: user.name, + role: user.role ? { + id: user.role.id, + name: user.role.roleName, + } : null, + permissions: user.role?.rolePermissions.map((rp) => ({ + id: rp.permission.id, + name: rp.permission.permissionName, + })) || [], + })); + + return { + staff: transformedStaff, + }; + }), + + getUsers: protectedProcedure + .input(z.object({ + cursor: z.number().optional(), + limit: z.number().default(20), + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { cursor, limit, search } = input; + + let whereCondition = undefined; + + if (search) { + whereCondition = or( + ilike(users.name, `%${search}%`), + ilike(users.email, `%${search}%`), + ilike(users.mobile, `%${search}%`) + ); + } + + if (cursor) { + const cursorCondition = lt(users.id, cursor); + whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition; + } + + const allUsers = await db.query.users.findMany({ + where: whereCondition, + with: { + userDetails: true, + }, + orderBy: desc(users.id), + limit: limit + 1, // fetch one extra to check if there's more + }); + + const hasMore = allUsers.length > limit; + const usersToReturn = hasMore ? allUsers.slice(0, limit) : allUsers; + + const formattedUsers = usersToReturn.map(user => ({ + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + image: user.userDetails?.profileImage || null, + })); + + return { + users: formattedUsers, + nextCursor: hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined, + }; + }), + + getUserDetails: protectedProcedure + .input(z.object({ userId: z.number() })) + .query(async ({ input }) => { + const { userId } = input; + + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + with: { + userDetails: true, + orders: { + orderBy: desc(orders.createdAt), + limit: 1, + }, + }, + }); + + if (!user) { + throw new ApiError("User not found", 404); + } + + const lastOrder = user.orders[0]; + + return { + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + addedOn: user.createdAt, + lastOrdered: lastOrder?.createdAt || null, + isSuspended: user.userDetails?.isSuspended || false, + }; + }), + + updateUserSuspension: protectedProcedure + .input(z.object({ userId: z.number(), isSuspended: z.boolean() })) + .mutation(async ({ input }) => { + const { userId, isSuspended } = input; + + await db + .insert(userDetails) + .values({ userId, isSuspended }) + .onConflictDoUpdate({ + target: userDetails.userId, + set: { isSuspended }, + }); + + return { success: true }; + }), + + createStaffUser: protectedProcedure + .input(z.object({ + name: z.string().min(1, 'Name is required'), + password: z.string().min(6, 'Password must be at least 6 characters'), + roleId: z.number().int().positive('Role is required'), + })) + .mutation(async ({ input, ctx }) => { + const { name, password, roleId } = input; + + // Check if staff user already exists + const existingUser = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }); + + if (existingUser) { + throw new ApiError('Staff user with this name already exists', 409); + } + + // Check if role exists + const role = await db.query.staffRoles.findFirst({ + where: eq(staffRoles.id, roleId), + }); + + if (!role) { + throw new ApiError('Invalid role selected', 400); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 12); + + // Create staff user + const [newUser] = await db.insert(staffUsers).values({ + name: name.trim(), + password: hashedPassword, + staffRoleId: roleId, + }).returning(); + + return { success: true, user: { id: newUser.id, name: newUser.name } }; + }), + + getRoles: protectedProcedure + .query(async ({ ctx }) => { + const roles = await db.query.staffRoles.findMany({ + columns: { + id: true, + roleName: true, + }, + }); + + return { + roles: roles.map(role => ({ + id: role.id, + name: role.roleName, + })), + }; + }), +}); \ No newline at end of file diff --git a/verifier/admin-apis/apis/store.ts b/verifier/admin-apis/apis/store.ts new file mode 100644 index 0000000..5925843 --- /dev/null +++ b/verifier/admin-apis/apis/store.ts @@ -0,0 +1,211 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { storeInfo, productInfo } from '@/src/db/schema' +import { eq, inArray } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error' + import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { scheduleStoreInitialization } from '@/src/stores/store-initializer' + + +export const storeRouter = router({ + getStores: protectedProcedure + .query(async ({ ctx }) => { + const stores = await db.query.storeInfo.findMany({ + with: { + owner: true, + }, + }); + + Promise.all(stores.map(async store => { + if(store.imageUrl) + store.imageUrl = scaffoldAssetUrl(store.imageUrl) + })).catch((e) => { + throw new ApiError("Unable to find store image urls") + } + ) + return { + stores, + count: stores.length, + }; + }), + + getStoreById: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .query(async ({ input, ctx }) => { + const { id } = input; + + const store = await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, id), + with: { + owner: true, + }, + }); + + if (!store) { + throw new ApiError("Store not found", 404); + } + store.imageUrl = scaffoldAssetUrl(store.imageUrl); + return { + store, + }; + }), + + createStore: protectedProcedure + .input(z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + imageUrl: z.string().optional(), + owner: z.number().min(1, "Owner is required"), + products: z.array(z.number()).optional(), + })) + .mutation(async ({ input, ctx }) => { + const { name, description, imageUrl, owner, products } = input; + + // const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined; + const imageKey = imageUrl + + const [newStore] = await db + .insert(storeInfo) + .values({ + name, + description, + imageUrl: imageKey, + owner, + }) + .returning(); + + // Assign selected products to this store + if (products && products.length > 0) { + await db + .update(productInfo) + .set({ storeId: newStore.id }) + .where(inArray(productInfo.id, products)); + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + store: newStore, + message: "Store created successfully", + }; + }), + + updateStore: protectedProcedure + .input(z.object({ + id: z.number(), + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + imageUrl: z.string().optional(), + owner: z.number().min(1, "Owner is required"), + products: z.array(z.number()).optional(), + })) + .mutation(async ({ input, ctx }) => { + const { id, name, description, imageUrl, owner, products } = input; + + const existingStore = await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, id), + }); + + if (!existingStore) { + throw new ApiError("Store not found", 404); + } + + const oldImageKey = existingStore.imageUrl; + const newImageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : oldImageKey; + + // Delete old image only if: + // 1. New image provided and keys are different, OR + // 2. No new image but old exists (clearing the image) + if (oldImageKey && ( + (newImageKey && newImageKey !== oldImageKey) || + (!newImageKey) + )) { + try { + await deleteImageUtil({keys: [oldImageKey]}); + } catch (error) { + console.error('Failed to delete old image:', error); + // Continue with update even if deletion fails + } + } + + const [updatedStore] = await db + .update(storeInfo) + .set({ + name, + description, + imageUrl: newImageKey, + owner, + }) + .where(eq(storeInfo.id, id)) + .returning(); + + if (!updatedStore) { + throw new ApiError("Store not found", 404); + } + + // Update products if provided + if (products) { + // First, set storeId to null for products not in the list but currently assigned to this store + await db + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, id)); + + // Then, assign the selected products to this store + if (products.length > 0) { + await db + .update(productInfo) + .set({ storeId: id }) + .where(inArray(productInfo.id, products)); + } + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + store: updatedStore, + message: "Store updated successfully", + }; + }), + + deleteStore: protectedProcedure + .input(z.object({ + storeId: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { storeId } = input; + + const result = await db.transaction(async (tx) => { + // First, update all products of this store to set storeId to null + await tx + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, storeId)); + + // Then delete the store + const [deletedStore] = await tx + .delete(storeInfo) + .where(eq(storeInfo.id, storeId)) + .returning(); + + if (!deletedStore) { + throw new ApiError("Store not found", 404); + } + + return { + message: "Store deleted successfully", + }; + }); + + // Reinitialize stores to reflect changes (outside transaction) + scheduleStoreInitialization() + + return result; + }), + }); diff --git a/verifier/admin-apis/apis/tag.ts b/verifier/admin-apis/apis/tag.ts new file mode 100644 index 0000000..66dac83 --- /dev/null +++ b/verifier/admin-apis/apis/tag.ts @@ -0,0 +1,214 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { productTagInfo } from '@/src/db/schema' +import { eq } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error' +import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client' +import { deleteS3Image } from '@/src/lib/delete-image' +import { scheduleStoreInitialization } from '@/src/stores/store-initializer' + +export const tagRouter = router({ + getTags: protectedProcedure + .query(async () => { + const tags = await db + .select() + .from(productTagInfo) + .orderBy(productTagInfo.tagName); + + // Generate asset URLs for tag images + const tagsWithUrls = tags.map(tag => ({ + ...tag, + imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null, + })); + + return { + tags: tagsWithUrls, + message: "Tags retrieved successfully", + }; + }), + + getTagById: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .query(async ({ input }) => { + const tag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, input.id), + }); + + if (!tag) { + throw new ApiError("Tag not found", 404); + } + + // Generate asset URL for tag image + const tagWithUrl = { + ...tag, + imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null, + }; + + return { + tag: tagWithUrl, + message: "Tag retrieved successfully", + }; + }), + + createTag: protectedProcedure + .input(z.object({ + tagName: z.string().min(1), + tagDescription: z.string().optional(), + isDashboardTag: z.boolean().default(false), + relatedStores: z.array(z.number()).default([]), + imageKey: z.string().optional(), + })) + .mutation(async ({ input }) => { + const { tagName, tagDescription, isDashboardTag, relatedStores, imageKey } = input; + + // Check for duplicate tag name + const existingTag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.tagName, tagName.trim()), + }); + + if (existingTag) { + throw new ApiError("A tag with this name already exists", 400); + } + + const [newTag] = await db + .insert(productTagInfo) + .values({ + tagName: tagName.trim(), + tagDescription, + imageUrl: imageKey || null, + isDashboardTag, + relatedStores, + }) + .returning(); + + // Claim upload URL if image was provided + if (imageKey) { + try { + await claimUploadUrl(imageKey); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${imageKey}`, e); + } + } + + scheduleStoreInitialization(); + + return { + tag: newTag, + message: "Tag created successfully", + }; + }), + + updateTag: protectedProcedure + .input(z.object({ + id: z.number(), + tagName: z.string().min(1), + tagDescription: z.string().optional(), + isDashboardTag: z.boolean(), + relatedStores: z.array(z.number()), + imageKey: z.string().optional(), + deleteExistingImage: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + const { id, imageKey, deleteExistingImage, ...updateData } = input; + + // Get current tag + const currentTag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, id), + }); + + if (!currentTag) { + throw new ApiError("Tag not found", 404); + } + + let newImageUrl = currentTag.imageUrl; + + // Handle image deletion + if (deleteExistingImage && currentTag.imageUrl) { + try { + await deleteS3Image(currentTag.imageUrl); + } catch (e) { + console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e); + } + newImageUrl = null; + } + + // Handle new image upload (only if different from existing) + if (imageKey && imageKey !== currentTag.imageUrl) { + // Delete old image if exists and not already deleted + if (currentTag.imageUrl && !deleteExistingImage) { + try { + await deleteS3Image(currentTag.imageUrl); + } catch (e) { + console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e); + } + } + newImageUrl = imageKey; + + // Claim upload URL + try { + await claimUploadUrl(imageKey); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${imageKey}`, e); + } + } + + const [updatedTag] = await db + .update(productTagInfo) + .set({ + tagName: updateData.tagName.trim(), + tagDescription: updateData.tagDescription, + isDashboardTag: updateData.isDashboardTag, + relatedStores: updateData.relatedStores, + imageUrl: newImageUrl, + }) + .where(eq(productTagInfo.id, id)) + .returning(); + + scheduleStoreInitialization(); + + return { + tag: updatedTag, + message: "Tag updated successfully", + }; + }), + + deleteTag: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input }) => { + const { id } = input; + + // Get tag to check for image + const tag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, id), + }); + + if (!tag) { + throw new ApiError("Tag not found", 404); + } + + // Delete image from S3 if exists + if (tag.imageUrl) { + try { + await deleteS3Image(tag.imageUrl); + } catch (e) { + console.error(`Failed to delete image: ${tag.imageUrl}`, e); + } + } + + // Delete tag (will fail if tag is assigned to products due to FK constraint) + await db.delete(productTagInfo).where(eq(productTagInfo.id, id)); + + scheduleStoreInitialization(); + + return { + message: "Tag deleted successfully", + }; + }), +}); + +export type TagRouter = typeof tagRouter; diff --git a/verifier/admin-apis/apis/user.ts b/verifier/admin-apis/apis/user.ts new file mode 100644 index 0000000..2b176b2 --- /dev/null +++ b/verifier/admin-apis/apis/user.ts @@ -0,0 +1,489 @@ +import { protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '@/src/db/schema'; +import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error'; +import { notificationQueue } from '@/src/lib/notif-job'; +import { recomputeUserNegativityScore } from '@/src/stores/user-negativity-store'; + +async function createUserByMobile(mobile: string): Promise { + // Clean mobile number (remove non-digits) + const cleanMobile = mobile.replace(/\D/g, ''); + + // Validate: exactly 10 digits + if (cleanMobile.length !== 10) { + throw new ApiError('Mobile number must be exactly 10 digits', 400); + } + + // Check if user already exists + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.mobile, cleanMobile)) + .limit(1); + + if (existingUser) { + throw new ApiError('User with this mobile number already exists', 409); + } + + // Create user + const [newUser] = await db + .insert(users) + .values({ + name: null, + email: null, + mobile: cleanMobile, + }) + .returning(); + + return newUser; +} + +export const userRouter = { + createUserByMobile: protectedProcedure + .input(z.object({ + mobile: z.string().min(1, 'Mobile number is required'), + })) + .mutation(async ({ input }) => { + const newUser = await createUserByMobile(input.mobile); + + return { + success: true, + data: newUser, + }; + }), + + getEssentials: protectedProcedure + .query(async () => { + const count = await db.$count(complaints, eq(complaints.isResolved, false)); + + return { + unresolvedComplaints: count || 0, + }; + }), + + getAllUsers: protectedProcedure + .input(z.object({ + limit: z.number().min(1).max(100).default(50), + cursor: z.number().optional(), + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { limit, cursor, search } = input; + + // Build where conditions + const whereConditions = []; + + if (search && search.trim()) { + whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`); + } + + if (cursor) { + whereConditions.push(sql`${users.id} > ${cursor}`); + } + + // Get users with filters applied + const usersList = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + createdAt: users.createdAt, + }) + .from(users) + .where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined) + .orderBy(asc(users.id)) + .limit(limit + 1); // Get one extra to determine if there's more + + // Check if there are more results + const hasMore = usersList.length > limit; + const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList; + + // Get order stats for each user + const userIds = usersToReturn.map(u => u.id); + + let orderCounts: { userId: number; totalOrders: number }[] = []; + let lastOrders: { userId: number; lastOrderDate: Date | null }[] = []; + let suspensionStatuses: { userId: number; isSuspended: boolean }[] = []; + + if (userIds.length > 0) { + // Get total orders per user + orderCounts = await db + .select({ + userId: orders.userId, + totalOrders: count(orders.id), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId); + + // Get last order date per user + lastOrders = await db + .select({ + userId: orders.userId, + lastOrderDate: max(orders.createdAt), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId); + + // Get suspension status for each user + suspensionStatuses = await db + .select({ + userId: userDetails.userId, + isSuspended: userDetails.isSuspended, + }) + .from(userDetails) + .where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`); + } + + // Create lookup maps + const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders])); + const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate])); + const suspensionMap = new Map(suspensionStatuses.map(s => [s.userId, s.isSuspended])); + + // Combine data + const usersWithStats = usersToReturn.map(user => ({ + ...user, + totalOrders: orderCountMap.get(user.id) || 0, + lastOrderDate: lastOrderMap.get(user.id) || null, + isSuspended: suspensionMap.get(user.id) ?? false, + })); + + // Get next cursor + const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined; + + return { + users: usersWithStats, + nextCursor, + hasMore, + }; + }), + + getUserDetails: protectedProcedure + .input(z.object({ + userId: z.number(), + })) + .query(async ({ input }) => { + const { userId } = input; + + // Get user info + const user = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user || user.length === 0) { + throw new ApiError('User not found', 404); + } + + // Get user suspension status + const userDetail = await db + .select({ + isSuspended: userDetails.isSuspended, + }) + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1); + + // Get all orders for this user with order items count + const userOrders = await db + .select({ + id: orders.id, + readableId: orders.readableId, + totalAmount: orders.totalAmount, + createdAt: orders.createdAt, + isFlashDelivery: orders.isFlashDelivery, + }) + .from(orders) + .where(eq(orders.userId, userId)) + .orderBy(desc(orders.createdAt)); + + // Get order status for each order + const orderIds = userOrders.map(o => o.id); + + let orderStatuses: { orderId: number; isDelivered: boolean; isCancelled: boolean }[] = []; + + if (orderIds.length > 0) { + const { orderStatus } = await import('@/src/db/schema'); + orderStatuses = await db + .select({ + orderId: orderStatus.orderId, + isDelivered: orderStatus.isDelivered, + isCancelled: orderStatus.isCancelled, + }) + .from(orderStatus) + .where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`); + } + + // Get item counts for each order + const itemCounts = await db + .select({ + orderId: orderItems.orderId, + itemCount: count(orderItems.id), + }) + .from(orderItems) + .where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`) + .groupBy(orderItems.orderId); + + // Create lookup maps + const statusMap = new Map(orderStatuses.map(s => [s.orderId, s])); + const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount])); + + // Determine status string + const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => { + if (!status) return 'pending'; + if (status.isCancelled) return 'cancelled'; + if (status.isDelivered) return 'delivered'; + return 'pending'; + }; + + // Combine data + const ordersWithDetails = userOrders.map(order => { + const status = statusMap.get(order.id); + return { + id: order.id, + readableId: order.readableId, + totalAmount: order.totalAmount, + createdAt: order.createdAt, + isFlashDelivery: order.isFlashDelivery, + status: getStatus(status), + itemCount: itemCountMap.get(order.id) || 0, + }; + }); + + return { + user: { + ...user[0], + isSuspended: userDetail[0]?.isSuspended ?? false, + }, + orders: ordersWithDetails, + }; + }), + + updateUserSuspension: protectedProcedure + .input(z.object({ + userId: z.number(), + isSuspended: z.boolean(), + })) + .mutation(async ({ input }) => { + const { userId, isSuspended } = input; + + // Check if user exists + const user = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user || user.length === 0) { + throw new ApiError('User not found', 404); + } + + // Check if user_details record exists + const existingDetail = await db + .select({ id: userDetails.id }) + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1); + + if (existingDetail.length > 0) { + // Update existing record + await db + .update(userDetails) + .set({ isSuspended }) + .where(eq(userDetails.userId, userId)); + } else { + // Insert new record + await db + .insert(userDetails) + .values({ + userId, + isSuspended, + }); + } + + return { + success: true, + message: `User ${isSuspended ? 'suspended' : 'unsuspended'} successfully`, + }; + }), + + getUsersForNotification: protectedProcedure + .input(z.object({ + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { search } = input; + + // Get all users + let usersList; + if (search && search.trim()) { + usersList = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + }) + .from(users) + .where(sql`${users.mobile} ILIKE ${`%${search.trim()}%`} OR ${users.name} ILIKE ${`%${search.trim()}%`}`); + } else { + usersList = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + }) + .from(users); + } + + // Get eligible users (have notif_creds entry) + const eligibleUsers = await db + .select({ userId: notifCreds.userId }) + .from(notifCreds); + + const eligibleSet = new Set(eligibleUsers.map(u => u.userId)); + + return { + users: usersList.map(user => ({ + id: user.id, + name: user.name, + mobile: user.mobile, + isEligibleForNotif: eligibleSet.has(user.id), + })), + }; + }), + + sendNotification: protectedProcedure + .input(z.object({ + userIds: z.array(z.number()).default([]), + title: z.string().min(1, 'Title is required'), + text: z.string().min(1, 'Message is required'), + imageUrl: z.string().optional(), + })) + .mutation(async ({ input }) => { + const { userIds, title, text, imageUrl } = input; + + let tokens: string[] = []; + + if (userIds.length === 0) { + // Send to all users - get tokens from both logged-in and unlogged users + const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds); + const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens); + + tokens = [ + ...loggedInTokens.map(t => t.token), + ...unloggedTokens.map(t => t.token) + ]; + } else { + // Send to specific users - get their tokens + const userTokens = await db + .select({ token: notifCreds.token }) + .from(notifCreds) + .where(inArray(notifCreds.userId, userIds)); + + tokens = userTokens.map(t => t.token); + } + + // Queue one job per token + let queuedCount = 0; + for (const token of tokens) { + try { + await notificationQueue.add('send-admin-notification', { + token, + title, + body: text, + imageUrl: imageUrl || null, + }, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + }); + queuedCount++; + } catch (error) { + console.error(`Failed to queue notification for token:`, error); + } + } + + return { + success: true, + message: `Notification queued for ${queuedCount} users`, + }; + }), + + getUserIncidents: protectedProcedure + .input(z.object({ + userId: z.number(), + })) + .query(async ({ input }) => { + const { userId } = input; + + const incidents = await db.query.userIncidents.findMany({ + where: eq(userIncidents.userId, userId), + with: { + order: { + with: { + orderStatus: true, + }, + }, + addedBy: true, + }, + orderBy: desc(userIncidents.dateAdded), + }); + + return { + incidents: incidents.map(incident => ({ + id: incident.id, + userId: incident.userId, + orderId: incident.orderId, + dateAdded: incident.dateAdded, + adminComment: incident.adminComment, + addedBy: incident.addedBy?.name || 'Unknown', + negativityScore: incident.negativityScore, + orderStatus: incident.order?.orderStatus?.[0]?.isCancelled ? 'cancelled' : 'active', + })), + }; + }), + + addUserIncident: protectedProcedure + .input(z.object({ + userId: z.number(), + orderId: z.number().optional(), + adminComment: z.string().optional(), + negativityScore: z.number().optional(), + })) + .mutation(async ({ input, ctx }) => { + const { userId, orderId, adminComment, negativityScore } = input; + + const adminUserId = ctx.staffUser?.id; + + if (!adminUserId) { + throw new ApiError('Admin user not authenticated', 401); + } + + + const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore }; + + const [incident] = await db.insert(userIncidents) + .values({ + ...incidentObj, + }) + .returning(); + + recomputeUserNegativityScore(userId); + + return { + success: true, + data: incident, + }; + }), +}; diff --git a/verifier/admin-apis/apis/vendor-snippets.ts b/verifier/admin-apis/apis/vendor-snippets.ts new file mode 100644 index 0000000..4a307d3 --- /dev/null +++ b/verifier/admin-apis/apis/vendor-snippets.ts @@ -0,0 +1,531 @@ +import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import dayjs from 'dayjs'; +import { db } from '@/src/db/db_index' +import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, users, orderStatus } from '@/src/db/schema' +import { eq, and, inArray, isNotNull, gt, sql, asc, ne } from 'drizzle-orm'; +import { appUrl } from '@/src/lib/env-exporter' + +const createSnippetSchema = z.object({ + snippetCode: z.string().min(1, "Snippet code is required"), + slotId: z.number().optional(), + productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"), + validTill: z.string().optional(), + isPermanent: z.boolean().default(false) +}); + +const updateSnippetSchema = z.object({ + id: z.number().int().positive(), + updates: createSnippetSchema.partial().extend({ + snippetCode: z.string().min(1).optional(), + productIds: z.array(z.number().int().positive()).optional(), + isPermanent: z.boolean().default(false) + }), +}); + +export const vendorSnippetsRouter = router({ + create: protectedProcedure + .input(createSnippetSchema) + .mutation(async ({ input, ctx }) => { + const { snippetCode, slotId, productIds, validTill, isPermanent } = input; + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Validate slot exists + if(slotId) { + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, slotId), + }); + if (!slot) { + throw new Error("Invalid slot ID"); + } + } + + // Validate products exist + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + }); + if (products.length !== productIds.length) { + throw new Error("One or more invalid product IDs"); + } + + // Check if snippet code already exists + const existingSnippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }); + if (existingSnippet) { + throw new Error("Snippet code already exists"); + } + + const result = await db.insert(vendorSnippets).values({ + snippetCode, + slotId, + productIds, + isPermanent, + validTill: validTill ? new Date(validTill) : undefined, + }).returning(); + + return result[0]; + }), + + getAll: protectedProcedure + .query(async () => { + console.log('from the vendor snipptes methods') + + try { + const result = await db.query.vendorSnippets.findMany({ + with: { + slot: true, + }, + orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)], + }); + + const snippetsWithProducts = await Promise.all( + result.map(async (snippet) => { + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, snippet.productIds), + columns: { id: true, name: true }, + }); + + return { + ...snippet, + accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`, + products: products.map(p => ({ id: p.id, name: p.name })), + }; + }) + ); + + return snippetsWithProducts; + } + catch(e) { + console.log(e) + } + return []; + }), + + getById: protectedProcedure + .input(z.object({ id: z.number().int().positive() })) + .query(async ({ input }) => { + const { id } = input; + + const result = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.id, id), + with: { + slot: true, + }, + }); + + if (!result) { + throw new Error("Vendor snippet not found"); + } + + return result; + }), + + update: protectedProcedure + .input(updateSnippetSchema) + .mutation(async ({ input }) => { + const { id, updates } = input; + + // Check if snippet exists + const existingSnippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.id, id), + }); + if (!existingSnippet) { + throw new Error("Vendor snippet not found"); + } + + // Validate slot if being updated + if (updates.slotId) { + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, updates.slotId), + }); + if (!slot) { + throw new Error("Invalid slot ID"); + } + } + + // Validate products if being updated + if (updates.productIds) { + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, updates.productIds), + }); + if (products.length !== updates.productIds.length) { + throw new Error("One or more invalid product IDs"); + } + } + + // Check snippet code uniqueness if being updated + if (updates.snippetCode && updates.snippetCode !== existingSnippet.snippetCode) { + const duplicateSnippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, updates.snippetCode), + }); + if (duplicateSnippet) { + throw new Error("Snippet code already exists"); + } + } + + const updateData: any = { ...updates }; + if (updates.validTill !== undefined) { + updateData.validTill = updates.validTill ? new Date(updates.validTill) : null; + } + + const result = await db.update(vendorSnippets) + .set(updateData) + .where(eq(vendorSnippets.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Failed to update vendor snippet"); + } + + return result[0]; + }), + + delete: protectedProcedure + .input(z.object({ id: z.number().int().positive() })) + .mutation(async ({ input }) => { + const { id } = input; + + const result = await db.delete(vendorSnippets) + .where(eq(vendorSnippets.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Vendor snippet not found"); + } + + return { message: "Vendor snippet deleted successfully" }; + }), + + getOrdersBySnippet: publicProcedure + .input(z.object({ + snippetCode: z.string().min(1, "Snippet code is required") + })) + .query(async ({ input }) => { + const { snippetCode } = input; + + // Find the snippet + const snippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }); + + if (!snippet) { + throw new Error("Vendor snippet not found"); + } + + // Check if snippet is still valid + if (snippet.validTill && new Date(snippet.validTill) < new Date()) { + throw new Error("Vendor snippet has expired"); + } + + // Query orders that match the snippet criteria + const matchingOrders = await db.query.orders.findMany({ + where: eq(orders.slotId, snippet.slotId!), + with: { + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + user: true, + slot: true, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + }); + + // Filter orders that contain at least one of the snippet's products + const filteredOrders = matchingOrders.filter(order => { + const status = order.orderStatus; + if (status[0].isCancelled) return false; + const orderProductIds = order.orderItems.map(item => item.productId); + return snippet.productIds.some(productId => orderProductIds.includes(productId)); + }); + + // Format the response + const formattedOrders = filteredOrders.map(order => { + // Filter orderItems to only include products attached to the snippet + const attachedOrderItems = order.orderItems.filter(item => + snippet.productIds.includes(item.productId) + ); + + const products = attachedOrderItems.map(item => ({ + orderItemId: item.id, + productId: item.productId, + productName: item.product.name, + quantity: parseFloat(item.quantity), + productSize: item.product.productQuantity, + price: parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || 'unit', + subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity), + is_packaged: item.is_packaged, + is_package_verified: item.is_package_verified, + })); + + const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0); + + return { + orderId: `ORD${order.id}`, + orderDate: order.createdAt.toISOString(), + customerName: order.user.name, + totalAmount: orderTotal, + slotInfo: order.slot ? { + time: order.slot.deliveryTime.toISOString(), + sequence: order.slot.deliverySequence, + } : null, + products, + matchedProducts: snippet.productIds, // All snippet products are considered matched + snippetCode: snippet.snippetCode, + }; + }); + + return { + success: true, + data: formattedOrders, + snippet: { + id: snippet.id, + snippetCode: snippet.snippetCode, + slotId: snippet.slotId, + productIds: snippet.productIds, + validTill: snippet.validTill?.toISOString(), + createdAt: snippet.createdAt.toISOString(), + isPermanent: snippet.isPermanent, + }, + }; + }), + + getVendorOrders: protectedProcedure + .query(async () => { + const vendorOrders = await db.query.orders.findMany({ + with: { + user: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + }); + + return vendorOrders.map(order => ({ + id: order.id, + status: 'pending', // Default status since orders table may not have status field + orderDate: order.createdAt.toISOString(), + totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0), + products: order.orderItems.map(item => ({ + name: item.product.name, + quantity: parseFloat(item.quantity || '0'), + unit: item.product.unit?.shortNotation || 'unit', + })), + })); + }), + + getUpcomingSlots: publicProcedure + .query(async () => { + const threeHoursAgo = dayjs().subtract(3, 'hour').toDate(); + const slots = await db.query.deliverySlotInfo.findMany({ + where: and( + eq(deliverySlotInfo.isActive, true), + gt(deliverySlotInfo.deliveryTime, threeHoursAgo) + ), + orderBy: asc(deliverySlotInfo.deliveryTime), + }); + + return { + success: true, + data: slots.map(slot => ({ + id: slot.id, + deliveryTime: slot.deliveryTime.toISOString(), + freezeTime: slot.freezeTime.toISOString(), + deliverySequence: slot.deliverySequence, + })), + }; + }), + + getOrdersBySnippetAndSlot: publicProcedure + .input(z.object({ + snippetCode: z.string().min(1, "Snippet code is required"), + slotId: z.number().int().positive("Valid slot ID is required"), + })) + .query(async ({ input }) => { + const { snippetCode, slotId } = input; + + // Find the snippet + const snippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }); + + if (!snippet) { + throw new Error("Vendor snippet not found"); + } + + // Find the slot + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, slotId), + }); + + if (!slot) { + throw new Error("Slot not found"); + } + + // Query orders that match the slot and snippet criteria + const matchingOrders = await db.query.orders.findMany({ + where: eq(orders.slotId, slotId), + with: { + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + user: true, + slot: true, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + }); + + // Filter orders that contain at least one of the snippet's products + const filteredOrders = matchingOrders.filter(order => { + const status = order.orderStatus; + if (status[0]?.isCancelled) return false; + const orderProductIds = order.orderItems.map(item => item.productId); + return snippet.productIds.some(productId => orderProductIds.includes(productId)); + }); + + // Format the response + const formattedOrders = filteredOrders.map(order => { + // Filter orderItems to only include products attached to the snippet + const attachedOrderItems = order.orderItems.filter(item => + snippet.productIds.includes(item.productId) + ); + + const products = attachedOrderItems.map(item => ({ + orderItemId: item.id, + productId: item.productId, + productName: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || 'unit', + subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity), + productSize: item.product.productQuantity, + is_packaged: item.is_packaged, + is_package_verified: item.is_package_verified, + })); + + const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0); + + return { + orderId: `ORD${order.id}`, + orderDate: order.createdAt.toISOString(), + customerName: order.user.name, + totalAmount: orderTotal, + slotInfo: order.slot ? { + time: order.slot.deliveryTime.toISOString(), + sequence: order.slot.deliverySequence, + } : null, + products, + matchedProducts: snippet.productIds, + snippetCode: snippet.snippetCode, + }; + }); + + return { + success: true, + data: formattedOrders, + snippet: { + id: snippet.id, + snippetCode: snippet.snippetCode, + slotId: snippet.slotId, + productIds: snippet.productIds, + validTill: snippet.validTill?.toISOString(), + createdAt: snippet.createdAt.toISOString(), + isPermanent: snippet.isPermanent, + }, + selectedSlot: { + id: slot.id, + deliveryTime: slot.deliveryTime.toISOString(), + freezeTime: slot.freezeTime.toISOString(), + deliverySequence: slot.deliverySequence, + }, + }; + }), + + updateOrderItemPackaging: publicProcedure + .input(z.object({ + orderItemId: z.number().int().positive("Valid order item ID required"), + is_packaged: z.boolean() + })) + .mutation(async ({ input, ctx }) => { + const { orderItemId, is_packaged } = input; + + // Get staff user ID from auth middleware + // const staffUserId = ctx.staffUser?.id; + // if (!staffUserId) { + // throw new Error("Unauthorized"); + // } + + // Check if order item exists and get related data + const orderItem = await db.query.orderItems.findFirst({ + where: eq(orderItems.id, orderItemId), + with: { + order: { + with: { + slot: true + } + } + } + }); + + if (!orderItem) { + throw new Error("Order item not found"); + } + + // Check if this order item belongs to a slot that has vendor snippets + // This ensures only order items from vendor-accessible orders can be updated + if (!orderItem.order.slotId) { + throw new Error("Order item not associated with a vendor slot"); + } + + const snippetExists = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.slotId, orderItem.order.slotId), + }); + + if (!snippetExists) { + throw new Error("No vendor snippet found for this order's slot"); + } + + // Update the is_packaged field + const result = await db.update(orderItems) + .set({ is_packaged }) + .where(eq(orderItems.id, orderItemId)) + .returning(); + + if (result.length === 0) { + throw new Error("Failed to update packaging status"); + } + + return { + success: true, + orderItemId, + is_packaged + }; + }), +}); \ No newline at end of file diff --git a/verifier/admin-apis/dataAccessors/demo.txt b/verifier/admin-apis/dataAccessors/demo.txt new file mode 100644 index 0000000..e69de29 diff --git a/verifier/apis/address.ts b/verifier/apis/address.ts new file mode 100644 index 0000000..019b895 --- /dev/null +++ b/verifier/apis/address.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { addressZones, addressAreas } from '@/src/db/schema' +import { eq, desc } from 'drizzle-orm'; +import { db } from '@/src/db/db_index' +import { router,protectedProcedure } from '@/src/trpc/trpc-index' + +const addressRouter = router({ + getZones: protectedProcedure.query(async () => { + const zones = await db.select().from(addressZones).orderBy(desc(addressZones.addedAt)); + return zones + }), + + getAreas: protectedProcedure.query(async () => { + const areas = await db.select().from(addressAreas).orderBy(desc(addressAreas.createdAt)); + return areas + }), + + createZone: protectedProcedure.input(z.object({ zoneName: z.string().min(1) })).mutation(async ({ input }) => { + + const zone = await db.insert(addressZones).values({ zoneName: input.zoneName }).returning(); + return {zone: zone}; + }), + + createArea: protectedProcedure.input(z.object({ placeName: z.string().min(1), zoneId: z.number().nullable() })).mutation(async ({ input }) => { + const area = await db.insert(addressAreas).values({ placeName: input.placeName, zoneId: input.zoneId }).returning(); + return {area}; + }), + + // TODO: Add update and delete mutations if needed +}); + +export default addressRouter; \ No newline at end of file diff --git a/verifier/apis/admin-trpc-index.ts b/verifier/apis/admin-trpc-index.ts new file mode 100644 index 0000000..4e23b84 --- /dev/null +++ b/verifier/apis/admin-trpc-index.ts @@ -0,0 +1,39 @@ +// import { router } from '@/src/trpc/trpc-index'; +import { router } from '@/src/trpc/trpc-index' +import { complaintRouter } from '@/src/trpc/apis/admin-apis/apis/complaint' +import { couponRouter } from '@/src/trpc/apis/admin-apis/apis/coupon' +import { cancelledOrdersRouter } from '@/src/trpc/apis/admin-apis/apis/cancelled-orders' +import { orderRouter } from '@/src/trpc/apis/admin-apis/apis/order' +import { vendorSnippetsRouter } from '@/src/trpc/apis/admin-apis/apis/vendor-snippets' +import { slotsRouter } from '@/src/trpc/apis/admin-apis/apis/slots' +import { productRouter } from '@/src/trpc/apis/admin-apis/apis/product' +import { staffUserRouter } from '@/src/trpc/apis/admin-apis/apis/staff-user' +import { storeRouter } from '@/src/trpc/apis/admin-apis/apis/store' +import { adminPaymentsRouter } from '@/src/trpc/apis/admin-apis/apis/payments' +import addressRouter from '@/src/trpc/apis/admin-apis/apis/address' +import { bannerRouter } from '@/src/trpc/apis/admin-apis/apis/banner' +import { userRouter } from '@/src/trpc/apis/admin-apis/apis/user' +import { constRouter } from '@/src/trpc/apis/admin-apis/apis/const' +import { productAvailabilitySchedulesRouter } from '@/src/trpc/apis/admin-apis/apis/product-availability-schedules' +import { tagRouter } from '@/src/trpc/apis/admin-apis/apis/tag' + +export const adminRouter = router({ + complaint: complaintRouter, + coupon: couponRouter, + cancelledOrders: cancelledOrdersRouter, + order: orderRouter, + vendorSnippets: vendorSnippetsRouter, + slots: slotsRouter, + product: productRouter, + staffUser: staffUserRouter, + store: storeRouter, + payments: adminPaymentsRouter, + address: addressRouter, + banner: bannerRouter, + user: userRouter, + const: constRouter, + productAvailabilitySchedules: productAvailabilitySchedulesRouter, + tag: tagRouter, +}); + +export type AdminRouter = typeof adminRouter; diff --git a/verifier/apis/banner.ts b/verifier/apis/banner.ts new file mode 100644 index 0000000..5794143 --- /dev/null +++ b/verifier/apis/banner.ts @@ -0,0 +1,176 @@ +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { homeBanners } from '@/src/db/schema' +import { eq, and, desc, sql } from 'drizzle-orm'; +import { protectedProcedure, router } from '@/src/trpc/trpc-index' +import { extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client' +import { ApiError } from '@/src/lib/api-error'; +import { scheduleStoreInitialization } from '@/src/stores/store-initializer' + + +export const bannerRouter = router({ + // Get all banners + getBanners: protectedProcedure + .query(async () => { + try { + + const banners = await db.query.homeBanners.findMany({ + orderBy: desc(homeBanners.createdAt), // Order by creation date instead + // Removed product relationship since we now use productIds array + }); + + // Convert S3 keys to signed URLs for client + const bannersWithSignedUrls = await Promise.all( + banners.map(async (banner) => { + try { + return { + ...banner, + imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl, + // Ensure productIds is always an array + productIds: banner.productIds || [], + }; + } catch (error) { + console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); + return { + ...banner, + imageUrl: banner.imageUrl, // Keep original on error + // Ensure productIds is always an array + productIds: banner.productIds || [], + }; + } + }) + ); + + return { + banners: bannersWithSignedUrls, + }; + } + catch(e:any) { + console.log(e) + + throw new ApiError(e.message); + } + }), + + // Get single banner by ID + getBanner: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const banner = await db.query.homeBanners.findFirst({ + where: eq(homeBanners.id, input.id), + // Removed product relationship since we now use productIds array + }); + + if (banner) { + try { + // Convert S3 key to signed URL for client + if (banner.imageUrl) { + banner.imageUrl = scaffoldAssetUrl(banner.imageUrl); + } + } catch (error) { + console.error(`Failed to generate signed URL for banner ${banner.id}:`, error); + // Keep original imageUrl on error + } + + // Ensure productIds is always an array (handle migration compatibility) + if (!banner.productIds) { + banner.productIds = []; + } + } + + return banner; + }), + + // Create new banner + createBanner: protectedProcedure + .input(z.object({ + name: z.string().min(1), + imageUrl: z.string(), + description: z.string().optional(), + productIds: z.array(z.number()).optional(), + redirectUrl: z.string().url().optional(), + // serialNum removed completely + })) + .mutation(async ({ input }) => { + try { + const imageUrl = extractKeyFromPresignedUrl(input.imageUrl) + // const imageUrl = input.imageUrl + const [banner] = await db.insert(homeBanners).values({ + name: input.name, + imageUrl: imageUrl, + description: input.description, + productIds: input.productIds || [], + redirectUrl: input.redirectUrl, + serialNum: 999, // Default value, not used + isActive: false, // Default to inactive + }).returning(); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return banner; + } catch (error) { + console.error('Error creating banner:', error); + throw error; // Re-throw to maintain tRPC error handling + } + }), + + // Update banner + updateBanner: protectedProcedure + .input(z.object({ + id: z.number(), + name: z.string().min(1).optional(), + imageUrl: z.string().url().optional(), + description: z.string().optional(), + productIds: z.array(z.number()).optional(), + redirectUrl: z.string().url().optional(), + serialNum: z.number().nullable().optional(), + isActive: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + try { + + const { id, ...updateData } = input; + const incomingProductIds = input.productIds; + // Extract S3 key from presigned URL if imageUrl is provided + const processedData = { + ...updateData, + ...(updateData.imageUrl && { + imageUrl: extractKeyFromPresignedUrl(updateData.imageUrl) + }), + }; + + // Handle serialNum null case + const finalData: any = { ...processedData }; + if ('serialNum' in finalData && finalData.serialNum === null) { + // Set to null explicitly + finalData.serialNum = null; + } + + const [banner] = await db.update(homeBanners) + .set({ ...finalData, lastUpdated: new Date(), }) + .where(eq(homeBanners.id, id)) + .returning(); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return banner; + } catch (error) { + console.error('Error updating banner:', error); + throw error; + } + }), + + // Delete banner + deleteBanner: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + await db.delete(homeBanners).where(eq(homeBanners.id, input.id)); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { success: true }; + }), +}); diff --git a/verifier/apis/cancelled-orders.ts b/verifier/apis/cancelled-orders.ts new file mode 100644 index 0000000..14c0b26 --- /dev/null +++ b/verifier/apis/cancelled-orders.ts @@ -0,0 +1,179 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { orders, orderStatus, users, addresses, orderItems, productInfo, units, refunds } from '@/src/db/schema' +import { eq, desc } from 'drizzle-orm'; + +const updateCancellationReviewSchema = z.object({ + orderId: z.number(), + cancellationReviewed: z.boolean(), + adminNotes: z.string().optional(), +}); + +const updateRefundSchema = z.object({ + orderId: z.number(), + isRefundDone: z.boolean(), +}); + +export const cancelledOrdersRouter = router({ + getAll: protectedProcedure + .query(async () => { + // First get cancelled order statuses with order details + const cancelledOrderStatuses = await db.query.orderStatus.findMany({ + where: eq(orderStatus.isCancelled, true), + with: { + order: { + with: { + user: true, + address: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + refunds: true, + }, + }, + }, + orderBy: [desc(orderStatus.orderTime)], + }); + + const filteredStatuses = cancelledOrderStatuses.filter(status => { + return status.order.isCod || status.paymentStatus === 'success'; + }); + + return filteredStatuses.map(status => { + const refund = status.order.refunds[0]; + return { + id: status.order.id, + readableId: status.order.id, + customerName: `${status.order.user.name}`, + address: `${status.order.address.addressLine1}, ${status.order.address.city}`, + totalAmount: status.order.totalAmount, + cancellationReviewed: status.cancellationReviewed || false, + isRefundDone: refund?.refundStatus === 'processed' || false, + adminNotes: status.order.adminNotes, + cancelReason: status.cancelReason, + paymentMode: status.order.isCod ? 'COD' : 'Online', + paymentStatus: status.paymentStatus || 'pending', + items: status.order.orderItems.map(item => ({ + name: item.product.name, + quantity: item.quantity, + price: item.price, + unit: item.product.unit?.shortNotation, + amount: parseFloat(item.price.toString()) * parseFloat(item.quantity || '0'), + })), + createdAt: status.order.createdAt, + }; + }); + }), + + updateReview: protectedProcedure + .input(updateCancellationReviewSchema) + .mutation(async ({ input }) => { + const { orderId, cancellationReviewed, adminNotes } = input; + + const result = await db.update(orderStatus) + .set({ + cancellationReviewed, + cancellationAdminNotes: adminNotes || null, + cancellationReviewedAt: new Date(), + }) + .where(eq(orderStatus.orderId, orderId)) + .returning(); + + if (result.length === 0) { + throw new Error("Cancellation record not found"); + } + + return result[0]; + }), + + getById: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const { id } = input; + + // Get cancelled order with full details + const cancelledOrderStatus = await db.query.orderStatus.findFirst({ + where: eq(orderStatus.id, id), + with: { + order: { + with: { + user: true, + address: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + }, + }, + }, + }); + + if (!cancelledOrderStatus || !cancelledOrderStatus.isCancelled) { + throw new Error("Cancelled order not found"); + } + + // Get refund details separately + const refund = await db.query.refunds.findFirst({ + where: eq(refunds.orderId, cancelledOrderStatus.orderId), + }); + + const order = cancelledOrderStatus.order; + + // Format the response similar to the getAll method + const formattedOrder = { + id: order.id, + readableId: order.id, + customerName: order.user.name, + address: `${order.address.addressLine1}${order.address.addressLine2 ? ', ' + order.address.addressLine2 : ''}, ${order.address.city}, ${order.address.state} ${order.address.pincode}`, + totalAmount: order.totalAmount, + cancellationReviewed: cancelledOrderStatus.cancellationReviewed || false, + isRefundDone: refund?.refundStatus === 'processed' || false, + adminNotes: cancelledOrderStatus.cancellationAdminNotes || null, + cancelReason: cancelledOrderStatus.cancelReason || null, + items: order.orderItems.map((item: any) => ({ + name: item.product.name, + quantity: item.quantity, + price: parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || 'unit', + amount: parseFloat(item.price.toString()) * parseFloat(item.quantity), + image: item.product.images?.[0] || null, + })), + createdAt: order.createdAt.toISOString(), + }; + + return { order: formattedOrder }; + }), + + updateRefund: protectedProcedure + .input(updateRefundSchema) + .mutation(async ({ input }) => { + const { orderId, isRefundDone } = input; + + const refundStatus = isRefundDone ? 'processed' : 'none'; + const result = await db.update(refunds) + .set({ + refundStatus, + refundProcessedAt: isRefundDone ? new Date() : null, + }) + .where(eq(refunds.orderId, orderId)) + .returning(); + + if (result.length === 0) { + throw new Error("Cancellation record not found"); + } + + return result[0]; + }), +}); \ No newline at end of file diff --git a/verifier/apis/complaint.ts b/verifier/apis/complaint.ts new file mode 100644 index 0000000..b1791a6 --- /dev/null +++ b/verifier/apis/complaint.ts @@ -0,0 +1,80 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { complaints, users } from '@/src/db/schema' +import { eq, desc, lt, and } from 'drizzle-orm'; +import { scaffoldAssetUrl } from '@/src/lib/s3-client' + +export const complaintRouter = router({ + getAll: protectedProcedure + .input(z.object({ + cursor: z.number().optional(), + limit: z.number().default(20), + })) + .query(async ({ input }) => { + const { cursor, limit } = input; + + let whereCondition = cursor + ? lt(complaints.id, cursor) + : undefined; + + const complaintsData = await db + .select({ + id: complaints.id, + complaintBody: complaints.complaintBody, + userId: complaints.userId, + orderId: complaints.orderId, + isResolved: complaints.isResolved, + createdAt: complaints.createdAt, + userName: users.name, + userMobile: users.mobile, + images: complaints.images, + }) + .from(complaints) + .leftJoin(users, eq(complaints.userId, users.id)) + .where(whereCondition) + .orderBy(desc(complaints.id)) + .limit(limit + 1); + + const hasMore = complaintsData.length > limit; + const complaintsToReturn = hasMore ? complaintsData.slice(0, limit) : complaintsData; + + const complaintsWithSignedImages = await Promise.all( + complaintsToReturn.map(async (c) => { + const signedImages = c.images + ? scaffoldAssetUrl(c.images as string[]) + : []; + + return { + id: c.id, + text: c.complaintBody, + userId: c.userId, + userName: c.userName, + userMobile: c.userMobile, + orderId: c.orderId, + status: c.isResolved ? 'resolved' : 'pending', + createdAt: c.createdAt, + images: signedImages, + }; + }) + ); + + return { + complaints: complaintsWithSignedImages, + nextCursor: hasMore + ? complaintsToReturn[complaintsToReturn.length - 1].id + : undefined, + }; + }), + + resolve: protectedProcedure + .input(z.object({ id: z.string(), response: z.string().optional() })) + .mutation(async ({ input }) => { + await db + .update(complaints) + .set({ isResolved: true, response: input.response }) + .where(eq(complaints.id, parseInt(input.id))); + + return { message: 'Complaint resolved successfully' }; + }), +}); \ No newline at end of file diff --git a/verifier/apis/const.ts b/verifier/apis/const.ts new file mode 100644 index 0000000..a426087 --- /dev/null +++ b/verifier/apis/const.ts @@ -0,0 +1,61 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { keyValStore } from '@/src/db/schema' +import { computeConstants } from '@/src/lib/const-store' +import { CONST_KEYS } from '@/src/lib/const-keys' + +export const constRouter = router({ + getConstants: protectedProcedure + .query(async () => { + + const constants = await db.select().from(keyValStore); + + const resp = constants.map(c => ({ + key: c.key, + value: c.value, + })); + + return resp; + }), + + updateConstants: protectedProcedure + .input(z.object({ + constants: z.array(z.object({ + key: z.string(), + value: z.any(), + })), + })) + .mutation(async ({ input }) => { + const { constants } = input; + + const validKeys = Object.values(CONST_KEYS) as string[]; + const invalidKeys = constants + .filter(c => !validKeys.includes(c.key)) + .map(c => c.key); + + if (invalidKeys.length > 0) { + throw new Error(`Invalid constant keys: ${invalidKeys.join(', ')}`); + } + + await db.transaction(async (tx) => { + for (const { key, value } of constants) { + await tx.insert(keyValStore) + .values({ key, value }) + .onConflictDoUpdate({ + target: keyValStore.key, + set: { value }, + }); + } + }); + + // Refresh all constants in Redis after database update + await computeConstants(); + + return { + success: true, + updatedCount: constants.length, + keys: constants.map(c => c.key), + }; + }), +}); \ No newline at end of file diff --git a/verifier/apis/coupon.ts b/verifier/apis/coupon.ts new file mode 100644 index 0000000..4eb3017 --- /dev/null +++ b/verifier/apis/coupon.ts @@ -0,0 +1,711 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { coupons, users, staffUsers, orders, couponApplicableUsers, couponApplicableProducts, orderStatus, reservedCoupons } from '@/src/db/schema' +import { eq, and, like, or, inArray, lt } from 'drizzle-orm'; +import dayjs from 'dayjs'; + +const createCouponBodySchema = z.object({ + couponCode: z.string().optional(), + isUserBased: z.boolean().optional(), + discountPercent: z.number().optional(), + flatDiscount: z.number().optional(), + minOrder: z.number().optional(), + targetUser: z.number().optional(), + productIds: z.array(z.number()).optional().nullable(), + applicableUsers: z.array(z.number()).optional(), + applicableProducts: z.array(z.number()).optional(), + maxValue: z.number().optional(), + isApplyForAll: z.boolean().optional(), + validTill: z.string().optional(), + maxLimitForUser: z.number().optional(), + exclusiveApply: z.boolean().optional(), +}); + +const validateCouponBodySchema = z.object({ + code: z.string(), + userId: z.number(), + orderAmount: z.number(), +}); + +export const couponRouter = router({ + create: protectedProcedure + .input(createCouponBodySchema) + .mutation(async ({ input, ctx }) => { + const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; + + // Validation: ensure at least one discount type is provided + if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { + throw new Error("Either discountPercent or flatDiscount must be provided (but not both)"); + } + + // If user-based, applicableUsers is required (unless it's apply for all) + if (isUserBased && (!applicableUsers || applicableUsers.length === 0) && !isApplyForAll) { + throw new Error("applicableUsers is required for user-based coupons (or set isApplyForAll to true)"); + } + + // Cannot be both user-based and apply for all + if (isUserBased && isApplyForAll) { + throw new Error("Cannot be both user-based and apply for all users"); + } + + // If applicableUsers is provided, verify users exist + if (applicableUsers && applicableUsers.length > 0) { + const existingUsers = await db.query.users.findMany({ + where: inArray(users.id, applicableUsers), + columns: { id: true }, + }); + if (existingUsers.length !== applicableUsers.length) { + throw new Error("Some applicable users not found"); + } + } + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Generate coupon code if not provided + let finalCouponCode = couponCode; + if (!finalCouponCode) { + // Generate a unique coupon code + const timestamp = Date.now().toString().slice(-6); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + finalCouponCode = `MF${timestamp}${random}`; + } + + // Check if coupon code already exists + const existingCoupon = await db.query.coupons.findFirst({ + where: eq(coupons.couponCode, finalCouponCode), + }); + + if (existingCoupon) { + throw new Error("Coupon code already exists"); + } + + const result = await db.insert(coupons).values({ + couponCode: finalCouponCode, + isUserBased: isUserBased || false, + discountPercent: discountPercent?.toString(), + flatDiscount: flatDiscount?.toString(), + minOrder: minOrder?.toString(), + productIds: productIds || null, + createdBy: staffUserId, + maxValue: maxValue?.toString(), + isApplyForAll: isApplyForAll || false, + validTill: validTill ? dayjs(validTill).toDate() : undefined, + maxLimitForUser: maxLimitForUser, + exclusiveApply: exclusiveApply || false, + }).returning(); + + const coupon = result[0]; + + // Insert applicable users + if (applicableUsers && applicableUsers.length > 0) { + await db.insert(couponApplicableUsers).values( + applicableUsers.map(userId => ({ + couponId: coupon.id, + userId, + })) + ); + } + + // Insert applicable products + if (applicableProducts && applicableProducts.length > 0) { + await db.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: coupon.id, + productId, + })) + ); + } + + return coupon; + }), + + getAll: protectedProcedure + .input(z.object({ + cursor: z.number().optional(), + limit: z.number().default(50), + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { cursor, limit, search } = input; + + let whereCondition = undefined; + const conditions = []; + + if (cursor) { + conditions.push(lt(coupons.id, cursor)); + } + + if (search && search.trim()) { + conditions.push(like(coupons.couponCode, `%${search}%`)); + } + + if (conditions.length > 0) { + whereCondition = and(...conditions); + } + + const result = await db.query.coupons.findMany({ + where: whereCondition, + with: { + creator: true, + applicableUsers: { + with: { + user: true, + }, + }, + applicableProducts: { + with: { + product: true, + }, + }, + }, + orderBy: (coupons, { desc }) => [desc(coupons.createdAt)], + limit: limit + 1, + }); + + const hasMore = result.length > limit; + const couponsList = hasMore ? result.slice(0, limit) : result; + const nextCursor = hasMore ? result[result.length - 1].id : undefined; + + return { coupons: couponsList, nextCursor }; + }), + + getById: protectedProcedure + .input(z.object({ id: z.number() })) + .query(async ({ input }) => { + const couponId = input.id; + + const result = await db.query.coupons.findFirst({ + where: eq(coupons.id, couponId), + with: { + creator: true, + applicableUsers: { + with: { + user: true, + }, + }, + applicableProducts: { + with: { + product: true, + }, + }, + }, + }); + + if (!result) { + throw new Error("Coupon not found"); + } + + return { + ...result, + productIds: (result.productIds as number[]) || undefined, + applicableUsers: result.applicableUsers.map(au => au.user), + applicableProducts: result.applicableProducts.map(ap => ap.product), + }; + }), + + update: protectedProcedure + .input(z.object({ + id: z.number(), + updates: createCouponBodySchema.extend({ + isInvalidated: z.boolean().optional(), + }), + })) + .mutation(async ({ input }) => { + const { id, updates } = input; + + // Validation: ensure discount types are valid + if (updates.discountPercent !== undefined && updates.flatDiscount !== undefined) { + if (updates.discountPercent && updates.flatDiscount) { + throw new Error("Cannot have both discountPercent and flatDiscount"); + } + } + + // If updating to user-based, applicableUsers is required + if (updates.isUserBased && (!updates.applicableUsers || updates.applicableUsers.length === 0)) { + const existingCount = await db.$count(couponApplicableUsers, eq(couponApplicableUsers.couponId, id)); + if (existingCount === 0) { + throw new Error("applicableUsers is required for user-based coupons"); + } + } + + // If applicableUsers is provided, verify users exist + if (updates.applicableUsers && updates.applicableUsers.length > 0) { + const existingUsers = await db.query.users.findMany({ + where: inArray(users.id, updates.applicableUsers), + columns: { id: true }, + }); + if (existingUsers.length !== updates.applicableUsers.length) { + throw new Error("Some applicable users not found"); + } + } + + const updateData: any = { ...updates }; + delete updateData.applicableUsers; // Remove since we use couponApplicableUsers table + if (updates.discountPercent !== undefined) { + updateData.discountPercent = updates.discountPercent?.toString(); + } + if (updates.flatDiscount !== undefined) { + updateData.flatDiscount = updates.flatDiscount?.toString(); + } + if (updates.minOrder !== undefined) { + updateData.minOrder = updates.minOrder?.toString(); + } + if (updates.maxValue !== undefined) { + updateData.maxValue = updates.maxValue?.toString(); + } + if (updates.validTill !== undefined) { + updateData.validTill = updates.validTill ? dayjs(updates.validTill).toDate() : null; + } + + const result = await db.update(coupons) + .set(updateData) + .where(eq(coupons.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Coupon not found"); + } + + console.log('updated coupon successfully') + + // Update applicable users: delete existing and insert new + if (updates.applicableUsers !== undefined) { + await db.delete(couponApplicableUsers).where(eq(couponApplicableUsers.couponId, id)); + if (updates.applicableUsers.length > 0) { + await db.insert(couponApplicableUsers).values( + updates.applicableUsers.map(userId => ({ + couponId: id, + userId, + })) + ); + } + } + + // Update applicable products: delete existing and insert new + if (updates.applicableProducts !== undefined) { + await db.delete(couponApplicableProducts).where(eq(couponApplicableProducts.couponId, id)); + if (updates.applicableProducts.length > 0) { + await db.insert(couponApplicableProducts).values( + updates.applicableProducts.map(productId => ({ + couponId: id, + productId, + })) + ); + } + } + + return result[0]; + }), + + delete: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const { id } = input; + + const result = await db.update(coupons) + .set({ isInvalidated: true }) + .where(eq(coupons.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Coupon not found"); + } + + return { message: "Coupon invalidated successfully" }; + }), + + validate: protectedProcedure + .input(validateCouponBodySchema) + .query(async ({ input }) => { + const { code, userId, orderAmount } = input; + + if (!code || typeof code !== 'string') { + return { valid: false, message: "Invalid coupon code" }; + } + + const coupon = await db.query.coupons.findFirst({ + where: and( + eq(coupons.couponCode, code.toUpperCase()), + eq(coupons.isInvalidated, false) + ), + }); + + if (!coupon) { + return { valid: false, message: "Coupon not found or invalidated" }; + } + + // Check expiry date + if (coupon.validTill && new Date(coupon.validTill) < new Date()) { + return { valid: false, message: "Coupon has expired" }; + } + + // Check if coupon applies to all users or specific user + if (!coupon.isApplyForAll && !coupon.isUserBased) { + return { valid: false, message: "Coupon is not available for use" }; + } + + // Check minimum order amount + const minOrderValue = coupon.minOrder ? parseFloat(coupon.minOrder) : 0; + if (minOrderValue > 0 && orderAmount < minOrderValue) { + return { valid: false, message: `Minimum order amount is ${minOrderValue}` }; + } + + // Calculate discount + let discountAmount = 0; + if (coupon.discountPercent) { + const percent = parseFloat(coupon.discountPercent); + discountAmount = (orderAmount * percent) / 100; + } else if (coupon.flatDiscount) { + discountAmount = parseFloat(coupon.flatDiscount); + } + + // Apply max value limit + const maxValueLimit = coupon.maxValue ? parseFloat(coupon.maxValue) : 0; + if (maxValueLimit > 0 && discountAmount > maxValueLimit) { + discountAmount = maxValueLimit; + } + + return { + valid: true, + discountAmount, + coupon: { + id: coupon.id, + discountPercent: coupon.discountPercent, + flatDiscount: coupon.flatDiscount, + maxValue: coupon.maxValue, + } + }; + }), + + generateCancellationCoupon: protectedProcedure + .input( + z.object({ + orderId: z.number(), + }) + ) + .mutation(async ({ input, ctx }) => { + const { orderId } = input; + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Find the order with user and order status information + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + orderStatus: true, + }, + }); + + if (!order) { + throw new Error("Order not found"); + } + + // Check if order is cancelled (check if any status entry has isCancelled: true) + // const isOrderCancelled = order.orderStatus?.some(status => status.isCancelled) || false; + // if (!isOrderCancelled) { + // throw new Error("Order is not cancelled"); + // } + + // // Check if payment method is COD + // if (order.isCod) { + // throw new Error("Can't generate refund coupon for CoD Order"); + // } + + // Verify user exists + if (!order.user) { + throw new Error("User not found for this order"); + } + + // Generate coupon code: first 3 letters of user name or mobile + orderId + const userNamePrefix = (order.user.name || order.user.mobile || 'USR').substring(0, 3).toUpperCase(); + const couponCode = `${userNamePrefix}${orderId}`; + + // Check if coupon code already exists + const existingCoupon = await db.query.coupons.findFirst({ + where: eq(coupons.couponCode, couponCode), + }); + + if (existingCoupon) { + throw new Error("Coupon code already exists"); + } + + // Get order total amount + const orderAmount = parseFloat(order.totalAmount); + + // Calculate expiry date (30 days from now) + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + 30); + + // Create the coupon and update order status in a transaction + const coupon = await db.transaction(async (tx) => { + // Create the coupon + const result = await tx.insert(coupons).values({ + couponCode, + isUserBased: true, + flatDiscount: orderAmount.toString(), + minOrder: orderAmount.toString(), + maxValue: orderAmount.toString(), + validTill: expiryDate, + maxLimitForUser: 1, + createdBy: staffUserId, + isApplyForAll: false, + }).returning(); + + const coupon = result[0]; + + // Insert applicable users + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId: order.userId, + }); + + // Update order_status with refund coupon ID + await tx.update(orderStatus) + .set({ refundCouponId: coupon.id }) + .where(eq(orderStatus.orderId, orderId)); + + return coupon; + }); + + return coupon; + }), + + getReservedCoupons: protectedProcedure + .input(z.object({ + cursor: z.number().optional(), + limit: z.number().default(50), + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { cursor, limit, search } = input; + + let whereCondition = undefined; + const conditions = []; + + if (cursor) { + conditions.push(lt(reservedCoupons.id, cursor)); + } + + if (search && search.trim()) { + conditions.push(or( + like(reservedCoupons.secretCode, `%${search}%`), + like(reservedCoupons.couponCode, `%${search}%`) + )); + } + + if (conditions.length > 0) { + whereCondition = and(...conditions); + } + + const result = await db.query.reservedCoupons.findMany({ + where: whereCondition, + with: { + redeemedUser: true, + creator: true, + }, + orderBy: (reservedCoupons, { desc }) => [desc(reservedCoupons.createdAt)], + limit: limit + 1, // Fetch one extra to check if there's more + }); + + const hasMore = result.length > limit; + const coupons = hasMore ? result.slice(0, limit) : result; + const nextCursor = hasMore ? result[result.length - 1].id : undefined; + + return { + coupons, + nextCursor, + }; + }), + + createReservedCoupon: protectedProcedure + .input(createCouponBodySchema) + .mutation(async ({ input, ctx }) => { + const { couponCode, isUserBased, discountPercent, flatDiscount, minOrder, productIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; + + // Validation: ensure at least one discount type is provided + if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { + throw new Error("Either discountPercent or flatDiscount must be provided (but not both)"); + } + + // For reserved coupons, applicableUsers is not used, as it's redeemed by one user + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Generate secret code if not provided (use couponCode as base) + let secretCode = couponCode || `SECRET${Date.now().toString().slice(-6)}${Math.random().toString(36).substring(2, 8).toUpperCase()}`; + + // Check if secret code already exists + const existing = await db.query.reservedCoupons.findFirst({ + where: eq(reservedCoupons.secretCode, secretCode), + }); + + if (existing) { + throw new Error("Secret code already exists"); + } + + const result = await db.insert(reservedCoupons).values({ + secretCode, + couponCode: couponCode || `RESERVED${Date.now().toString().slice(-6)}`, + discountPercent: discountPercent?.toString(), + flatDiscount: flatDiscount?.toString(), + minOrder: minOrder?.toString(), + productIds, + maxValue: maxValue?.toString(), + validTill: validTill ? dayjs(validTill).toDate() : undefined, + maxLimitForUser, + exclusiveApply: exclusiveApply || false, + createdBy: staffUserId, + }).returning(); + + const coupon = result[0]; + + // Insert applicable products if provided + if (applicableProducts && applicableProducts.length > 0) { + await db.insert(couponApplicableProducts).values( + applicableProducts.map(productId => ({ + couponId: coupon.id, + productId, + })) + ); + } + + return coupon; + }), + + getUsersMiniInfo: protectedProcedure + .input(z.object({ + search: z.string().optional(), + limit: z.number().min(1).max(50).default(20), + offset: z.number().min(0).default(0), + })) + .query(async ({ input }) => { + const { search, limit } = input; + + let whereCondition = undefined; + if (search && search.trim()) { + whereCondition = or( + like(users.name, `%${search}%`), + like(users.mobile, `%${search}%`) + ); + } + + const userList = await db.query.users.findMany({ + where: whereCondition, + columns: { + id: true, + name: true, + mobile: true, + }, + limit: limit, + offset: input.offset, + orderBy: (users, { asc }) => [asc(users.name)], + }); + + return { + users: userList.map(user => ({ + id: user.id, + name: user.name || 'Unknown', + mobile: user.mobile, + })) + }; + }), + + createCoupon: protectedProcedure + .input(z.object({ + mobile: z.string().min(1, 'Mobile number is required'), + })) + .mutation(async ({ input, ctx }) => { + const { mobile } = input; + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Clean mobile number (remove non-digits) + const cleanMobile = mobile.replace(/\D/g, ''); + + // Validate: exactly 10 digits + if (cleanMobile.length !== 10) { + throw new Error("Mobile number must be exactly 10 digits"); + } + + // Check if user exists, create if not + let user = await db.query.users.findFirst({ + where: eq(users.mobile, cleanMobile), + }); + + if (!user) { + // Create new user + const [newUser] = await db.insert(users).values({ + name: null, + email: null, + mobile: cleanMobile, + }).returning(); + user = newUser; + } + + // Generate unique coupon code + const timestamp = Date.now().toString().slice(-6); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + const couponCode = `MF${cleanMobile.slice(-4)}${timestamp}${random}`; + + // Check if coupon code already exists (very unlikely but safe) + const existingCode = await db.query.coupons.findFirst({ + where: eq(coupons.couponCode, couponCode), + }); + + if (existingCode) { + throw new Error("Generated coupon code already exists - please try again"); + } + + // Create the coupon + const [coupon] = await db.insert(coupons).values({ + couponCode, + isUserBased: true, + discountPercent: "20", // 20% discount + minOrder: "1000", // ₹1000 minimum order + maxValue: "500", // ₹500 maximum discount + maxLimitForUser: 1, // One-time use + isApplyForAll: false, + exclusiveApply: false, + createdBy: staffUserId, + validTill: dayjs().add(90, 'days').toDate(), // 90 days from now + }).returning(); + + // Associate coupon with user + await db.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId: user.id, + }); + + return { + success: true, + coupon: { + id: coupon.id, + couponCode: coupon.couponCode, + userId: user.id, + userMobile: user.mobile, + discountPercent: 20, + minOrder: 1000, + maxValue: 500, + maxLimitForUser: 1, + }, + }; + }), +}); diff --git a/verifier/apis/order.ts b/verifier/apis/order.ts new file mode 100644 index 0000000..707f642 --- /dev/null +++ b/verifier/apis/order.ts @@ -0,0 +1,1018 @@ +import { router, protectedProcedure } from "@/src/trpc/trpc-index" +import { z } from "zod"; +import { db } from "@/src/db/db_index" +import { + orders, + orderItems, + orderStatus, + users, + addresses, + refunds, + coupons, + couponUsage, + complaints, + payments, +} from "@/src/db/schema"; +import { eq, and, gte, lt, desc, SQL, inArray } from "drizzle-orm"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { ApiError } from "@/src/lib/api-error" +import { + sendOrderPackagedNotification, + sendOrderDeliveredNotification, +} from "@/src/lib/notif-job"; +import { publishCancellation } from "@/src/lib/post-order-handler" +import { getMultipleUserNegativityScores } from "@/src/stores/user-negativity-store" + +const updateOrderNotesSchema = z.object({ + orderId: z.number(), + adminNotes: z.string(), +}); + +const getFullOrderSchema = z.object({ + orderId: z.number(), +}); + +const getOrderDetailsSchema = z.object({ + orderId: z.number(), +}); + +const updatePackagedSchema = z.object({ + orderId: z.string(), + isPackaged: z.boolean(), +}); + +const updateDeliveredSchema = z.object({ + orderId: z.string(), + isDelivered: z.boolean(), +}); + +const updateOrderItemPackagingSchema = z.object({ + orderItemId: z.number(), + isPackaged: z.boolean().optional(), + isPackageVerified: z.boolean().optional(), +}); + +const getSlotOrdersSchema = z.object({ + slotId: z.string(), +}); + +const getTodaysOrdersSchema = z.object({ + slotId: z.string().optional(), +}); + +const getAllOrdersSchema = z.object({ + cursor: z.number().optional(), + limit: z.number().default(20), + slotId: z.number().optional().nullable(), + packagedFilter: z + .enum(["all", "packaged", "not_packaged"]) + .optional() + .default("all"), + deliveredFilter: z + .enum(["all", "delivered", "not_delivered"]) + .optional() + .default("all"), + cancellationFilter: z + .enum(["all", "cancelled", "not_cancelled"]) + .optional() + .default("all"), + flashDeliveryFilter: z + .enum(["all", "flash", "regular"]) + .optional() + .default("all"), +}); + +export const orderRouter = router({ + updateNotes: protectedProcedure + .input(updateOrderNotesSchema) + .mutation(async ({ input }) => { + const { orderId, adminNotes } = input; + + const result = await db + .update(orders) + .set({ + adminNotes: adminNotes || null, + }) + .where(eq(orders.id, orderId)) + .returning(); + + if (result.length === 0) { + throw new Error("Order not found"); + } + + return result[0]; + }), + + getFullOrder: protectedProcedure + .input(getFullOrderSchema) + .query(async ({ input }) => { + const { orderId } = input; + + const orderData = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + payment: true, + paymentInfo: true, + }, + }); + + if (!orderData) { + throw new Error("Order not found"); + } + + // Get order status separately + const statusRecord = await db.query.orderStatus.findFirst({ + where: eq(orderStatus.orderId, orderId), + }); + + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + // Get refund details if order is cancelled + let refund = null; + if (status === "cancelled") { + refund = await db.query.refunds.findFirst({ + where: eq(refunds.orderId, orderId), + }); + } + + return { + id: orderData.id, + readableId: orderData.id, + customerName: `${orderData.user.name}`, + customerEmail: orderData.user.email, + customerMobile: orderData.user.mobile, + address: { + line1: orderData.address.addressLine1, + line2: orderData.address.addressLine2, + city: orderData.address.city, + state: orderData.address.state, + pincode: orderData.address.pincode, + phone: orderData.address.phone, + }, + slotInfo: orderData.slot + ? { + time: orderData.slot.deliveryTime.toISOString(), + sequence: orderData.slot.deliverySequence, + } + : null, + isCod: orderData.isCod, + isOnlinePayment: orderData.isOnlinePayment, + totalAmount: orderData.totalAmount, + adminNotes: orderData.adminNotes, + userNotes: orderData.userNotes, + createdAt: orderData.createdAt, + status, + isPackaged: + orderData.orderItems.every((item) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + items: orderData.orderItems.map((item) => ({ + id: item.id, + name: item.product.name, + quantity: item.quantity, + price: item.price, + unit: item.product.unit?.shortNotation, + amount: + parseFloat(item.price.toString()) * + parseFloat(item.quantity || "0"), + })), + payment: orderData.payment + ? { + status: orderData.payment.status, + gateway: orderData.payment.gateway, + merchantOrderId: orderData.payment.merchantOrderId, + } + : null, + paymentInfo: orderData.paymentInfo + ? { + status: orderData.paymentInfo.status, + gateway: orderData.paymentInfo.gateway, + merchantOrderId: orderData.paymentInfo.merchantOrderId, + } + : null, + // Cancellation details (only present for cancelled orders) + cancelReason: statusRecord?.cancelReason || null, + cancellationReviewed: statusRecord?.cancellationReviewed || false, + isRefundDone: refund?.refundStatus === "processed" || false, + }; + }), + + getOrderDetails: protectedProcedure + .input(getOrderDetailsSchema) + .query(async ({ input }) => { + const { orderId } = input; + + // Single optimized query with all relations + const orderData = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + payment: true, + paymentInfo: true, + orderStatus: true, // Include in main query + refunds: true, // Include in main query + }, + }); + + if (!orderData) { + 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, orderData.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(orderData.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, + }; + } + + // Status determination from included relation + const statusRecord = orderData.orderStatus?.[0]; + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + // Always include refund data (will be null/undefined if not cancelled) + const refund = orderData.refunds?.[0]; + + return { + id: orderData.id, + readableId: orderData.id, + userId: orderData.user.id, + customerName: `${orderData.user.name}`, + customerEmail: orderData.user.email, + customerMobile: orderData.user.mobile, + address: { + name: orderData.address.name, + line1: orderData.address.addressLine1, + line2: orderData.address.addressLine2, + city: orderData.address.city, + state: orderData.address.state, + pincode: orderData.address.pincode, + phone: orderData.address.phone, + }, + slotInfo: orderData.slot + ? { + time: orderData.slot.deliveryTime.toISOString(), + sequence: orderData.slot.deliverySequence, + } + : null, + isCod: orderData.isCod, + isOnlinePayment: orderData.isOnlinePayment, + totalAmount: parseFloat(orderData.totalAmount?.toString() || '0') - parseFloat(orderData.deliveryCharge?.toString() || '0'), + deliveryCharge: parseFloat(orderData.deliveryCharge?.toString() || '0'), + adminNotes: orderData.adminNotes, + userNotes: orderData.userNotes, + createdAt: orderData.createdAt, + status, + isPackaged: statusRecord?.isPackaged || false, + isDelivered: statusRecord?.isDelivered || false, + items: orderData.orderItems.map((item) => ({ + id: item.id, + name: item.product.name, + quantity: item.quantity, + productSize: item.product.productQuantity, + price: item.price, + unit: item.product.unit?.shortNotation, + amount: + parseFloat(item.price.toString()) * + parseFloat(item.quantity || "0"), + isPackaged: item.is_packaged, + isPackageVerified: item.is_package_verified, + })), + payment: orderData.payment + ? { + status: orderData.payment.status, + gateway: orderData.payment.gateway, + merchantOrderId: orderData.payment.merchantOrderId, + } + : null, + paymentInfo: orderData.paymentInfo + ? { + status: orderData.paymentInfo.status, + gateway: orderData.paymentInfo.gateway, + merchantOrderId: orderData.paymentInfo.merchantOrderId, + } + : null, + // Cancellation details (always included, null if not cancelled) + cancelReason: statusRecord?.cancelReason || null, + cancellationReviewed: statusRecord?.cancellationReviewed || false, + isRefundDone: refund?.refundStatus === "processed" || false, + refundStatus: refund?.refundStatus as RefundStatus, + refundAmount: refund?.refundAmount + ? parseFloat(refund.refundAmount.toString()) + : null, + // Coupon information + couponData: couponData, + couponCode: couponData?.couponCode || null, + couponDescription: couponData?.couponDescription || null, + discountAmount: couponData?.discountAmount || null, + orderStatus: statusRecord, + refundRecord: refund, + isFlashDelivery: orderData.isFlashDelivery, + }; + }), + + updatePackaged: protectedProcedure + .input(updatePackagedSchema) + .mutation(async ({ input }) => { + const { orderId, isPackaged } = input; + + // Update all order items to the specified packaged state + await db + .update(orderItems) + .set({ is_packaged: isPackaged }) + .where(eq(orderItems.orderId, parseInt(orderId))); + + // Also update the order status table for backward compatibility + if (!isPackaged) { + await db + .update(orderStatus) + .set({ isPackaged, isDelivered: false }) + .where(eq(orderStatus.orderId, parseInt(orderId))); + } else { + await db + .update(orderStatus) + .set({ isPackaged }) + .where(eq(orderStatus.orderId, parseInt(orderId))); + } + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, parseInt(orderId)), + }); + if (order) await sendOrderPackagedNotification(order.userId, orderId); + + return { success: true }; + }), + + updateDelivered: protectedProcedure + .input(updateDeliveredSchema) + .mutation(async ({ input }) => { + const { orderId, isDelivered } = input; + + await db + .update(orderStatus) + .set({ isDelivered }) + .where(eq(orderStatus.orderId, parseInt(orderId))); + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, parseInt(orderId)), + }); + if (order) await sendOrderDeliveredNotification(order.userId, orderId); + + return { success: true }; + }), + + updateOrderItemPackaging: protectedProcedure + .input(updateOrderItemPackagingSchema) + .mutation(async ({ input }) => { + const { orderItemId, isPackaged, isPackageVerified } = input; + + // Validate that orderItem exists + const orderItem = await db.query.orderItems.findFirst({ + where: eq(orderItems.id, orderItemId), + }); + + if (!orderItem) { + throw new ApiError("Order item not found", 404); + } + + // Build update object with only provided fields + const updateData: any = {}; + if (isPackaged !== undefined) { + updateData.is_packaged = isPackaged; + } + if (isPackageVerified !== undefined) { + updateData.is_package_verified = isPackageVerified; + } + + // Update the order item + await db + .update(orderItems) + .set(updateData) + .where(eq(orderItems.id, orderItemId)); + + return { success: true }; + }), + + removeDeliveryCharge: protectedProcedure + .input(z.object({ orderId: z.number() })) + .mutation(async ({ input }) => { + const { orderId } = input; + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + }); + + if (!order) { + throw new Error('Order not found'); + } + + const currentDeliveryCharge = parseFloat(order.deliveryCharge?.toString() || '0'); + const currentTotalAmount = parseFloat(order.totalAmount?.toString() || '0'); + const newTotalAmount = currentTotalAmount - currentDeliveryCharge; + + await db + .update(orders) + .set({ + deliveryCharge: '0', + totalAmount: newTotalAmount.toString() + }) + .where(eq(orders.id, orderId)); + + return { success: true, message: 'Delivery charge removed' }; + }), + + getSlotOrders: protectedProcedure + .input(getSlotOrdersSchema) + .query(async ({ input }) => { + const { slotId } = input; + + const slotOrders = await db.query.orders.findMany({ + where: eq(orders.slotId, parseInt(slotId)), + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + }, + }); + + const filteredOrders = slotOrders.filter((order) => { + const statusRecord = order.orderStatus[0]; + return ( + order.isCod || + (statusRecord && statusRecord.paymentStatus === "success") + ); + }); + + const formattedOrders = filteredOrders.map((order) => { + const statusRecord = order.orderStatus[0]; // assuming one status per order + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + const items = order.orderItems.map((item) => ({ + id: item.id, + name: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || "", + isPackaged: item.is_packaged, + isPackageVerified: item.is_package_verified, + })); + + return { + id: order.id, + readableId: order.id, + customerName: order.user.name, + address: `${order.address.addressLine1}${ + order.address.addressLine2 ? `, ${order.address.addressLine2}` : "" + }, ${order.address.city}, ${order.address.state} - ${ + order.address.pincode + }, Phone: ${order.address.phone}`, + addressId: order.addressId, + latitude: order.address.adminLatitude ?? order.address.latitude, + longitude: order.address.adminLongitude ?? order.address.longitude, + totalAmount: parseFloat(order.totalAmount), + items, + deliveryTime: order.slot?.deliveryTime.toISOString() || null, + status, + isPackaged: + order.orderItems.every((item) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + isCod: order.isCod, + paymentMode: order.isCod ? "COD" : "Online", + paymentStatus: statusRecord?.paymentStatus || "pending", + slotId: order.slotId, + adminNotes: order.adminNotes, + userNotes: order.userNotes, + }; + }); + + return { success: true, data: formattedOrders }; + }), + + getTodaysOrders: protectedProcedure + .input(getTodaysOrdersSchema) + .query(async ({ input }) => { + const { slotId } = input; + const start = dayjs().startOf("day").toDate(); + const end = dayjs().endOf("day").toDate(); + + let whereCondition = and( + gte(orders.createdAt, start), + lt(orders.createdAt, end) + ); + + if (slotId) { + whereCondition = and( + whereCondition, + eq(orders.slotId, parseInt(slotId)) + ); + } + + const todaysOrders = await db.query.orders.findMany({ + where: whereCondition, + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + }, + }); + + const filteredOrders = todaysOrders.filter((order) => { + const statusRecord = order.orderStatus[0]; + return ( + order.isCod || + (statusRecord && statusRecord.paymentStatus === "success") + ); + }); + + const formattedOrders = filteredOrders.map((order) => { + const statusRecord = order.orderStatus[0]; // assuming one status per order + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + const items = order.orderItems.map((item) => ({ + name: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + amount: parseFloat(item.quantity) * parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || "", + })); + + return { + orderId: order.id.toString(), + readableId: order.id, + customerName: order.user.name, + address: `${order.address.addressLine1}${ + order.address.addressLine2 ? `, ${order.address.addressLine2}` : "" + }, ${order.address.city}, ${order.address.state} - ${ + order.address.pincode + }`, + totalAmount: parseFloat(order.totalAmount), + items, + deliveryTime: order.slot?.deliveryTime.toISOString() || null, + status, + isPackaged: + order.orderItems.every((item) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + isCod: order.isCod, + paymentMode: order.isCod ? "COD" : "Online", + paymentStatus: statusRecord?.paymentStatus || "pending", + slotId: order.slotId, + adminNotes: order.adminNotes, + userNotes: order.userNotes, + }; + }); + + return { success: true, data: formattedOrders }; + }), + + updateAddressCoords: protectedProcedure + .input( + z.object({ + addressId: z.number(), + latitude: z.number(), + longitude: z.number(), + }) + ) + .mutation(async ({ input }) => { + const { addressId, latitude, longitude } = input; + + const result = await db + .update(addresses) + .set({ + adminLatitude: latitude, + adminLongitude: longitude, + }) + .where(eq(addresses.id, addressId)) + .returning(); + + if (result.length === 0) { + throw new ApiError("Address not found", 404); + } + + return { success: true }; + }), + + getAll: protectedProcedure + .input(getAllOrdersSchema) + .query(async ({ input }) => { + try { + const { + cursor, + limit, + slotId, + packagedFilter, + deliveredFilter, + cancellationFilter, + flashDeliveryFilter, + } = input; + + let whereCondition: SQL | undefined = eq(orders.id, orders.id); // always true + if (cursor) { + whereCondition = and(whereCondition, lt(orders.id, cursor)); + } + if (slotId) { + whereCondition = and(whereCondition, eq(orders.slotId, slotId)); + } + if (packagedFilter === "packaged") { + whereCondition = and( + whereCondition, + eq(orderStatus.isPackaged, true) + ); + } else if (packagedFilter === "not_packaged") { + whereCondition = and( + whereCondition, + eq(orderStatus.isPackaged, false) + ); + } + if (deliveredFilter === "delivered") { + whereCondition = and( + whereCondition, + eq(orderStatus.isDelivered, true) + ); + } else if (deliveredFilter === "not_delivered") { + whereCondition = and( + whereCondition, + eq(orderStatus.isDelivered, false) + ); + } + if (cancellationFilter === "cancelled") { + whereCondition = and( + whereCondition, + eq(orderStatus.isCancelled, true) + ); + } else if (cancellationFilter === "not_cancelled") { + whereCondition = and( + whereCondition, + eq(orderStatus.isCancelled, false) + ); + } + if (flashDeliveryFilter === "flash") { + whereCondition = and( + whereCondition, + eq(orders.isFlashDelivery, true) + ); + } else if (flashDeliveryFilter === "regular") { + whereCondition = and( + whereCondition, + eq(orders.isFlashDelivery, false) + ); + } + + const allOrders = await db.query.orders.findMany({ + where: whereCondition, + orderBy: desc(orders.createdAt), + limit: limit + 1, // fetch one extra to check if there's more + with: { + user: true, + address: true, + slot: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + }, + }); + + const hasMore = allOrders.length > limit; + const ordersToReturn = hasMore ? allOrders.slice(0, limit) : allOrders; + + const userIds = [...new Set(ordersToReturn.map(o => o.userId))]; + const negativityScores = await getMultipleUserNegativityScores(userIds); + + const filteredOrders = ordersToReturn.filter((order) => { + const statusRecord = order.orderStatus[0]; + return ( + order.isCod || + (statusRecord && statusRecord.paymentStatus === "success") + ); + }); + + const formattedOrders = filteredOrders.map((order) => { + const statusRecord = order.orderStatus[0]; + let status: "pending" | "delivered" | "cancelled" = "pending"; + if (statusRecord?.isCancelled) { + status = "cancelled"; + } else if (statusRecord?.isDelivered) { + status = "delivered"; + } + + const items = order.orderItems + .map((item) => ({ + id: item.id, + name: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + amount: + parseFloat(item.quantity) * parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || "", + productSize: item.product.productQuantity, + isPackaged: item.is_packaged, + isPackageVerified: item.is_package_verified, + })) + .sort((first, second) => first.id - second.id); + dayjs.extend(utc); + return { + id: order.id, + orderId: order.id.toString(), + readableId: order.id, + customerName: order.user.name, + customerMobile: order.user.mobile, + address: `${order.address.addressLine1}${ + order.address.addressLine2 + ? `, ${order.address.addressLine2}` + : "" + }, ${order.address.city}, ${order.address.state} - ${ + order.address.pincode + }, Phone: ${order.address.phone}`, + addressId: order.addressId, + latitude: order.address.adminLatitude ?? order.address.latitude, + longitude: order.address.adminLongitude ?? order.address.longitude, + totalAmount: parseFloat(order.totalAmount), + deliveryCharge: parseFloat(order.deliveryCharge || "0"), + items, + createdAt: order.createdAt, + // deliveryTime: order.slot ? dayjs.utc(order.slot.deliveryTime).format('ddd, MMM D • h:mm A') : 'Not scheduled', + deliveryTime: order.slot?.deliveryTime.toISOString() || null, + status, + isPackaged: + order.orderItems.every((item) => item.is_packaged) || false, + isDelivered: statusRecord?.isDelivered || false, + isCod: order.isCod, + isFlashDelivery: order.isFlashDelivery, + userNotes: order.userNotes, + adminNotes: order.adminNotes, + userNegativityScore: negativityScores[order.userId] || 0, + }; + }); + + return { + orders: formattedOrders, + nextCursor: hasMore + ? ordersToReturn[ordersToReturn.length - 1].id + : undefined, + }; + } catch (e) { + console.log({ e }); + } + }), + + rebalanceSlots: protectedProcedure + .input(z.object({ slotIds: z.array(z.number()).min(1).max(50) })) + .mutation(async ({ input }) => { + const slotIds = input.slotIds; + + const ordersList = await db.query.orders.findMany({ + where: inArray(orders.slotId, slotIds), + with: { + orderItems: { + with: { + product: true + } + }, + couponUsages: { + with: { + coupon: true + } + }, + } + }); + + const processedOrdersData = ordersList.map((order) => { + + let newTotal = order.orderItems.reduce((acc,item) => { + const latestPrice = +item.product.price; + const amount = (latestPrice * Number(item.quantity)); + return acc+amount; + },0) + + order.orderItems.forEach(item => { + item.price = item.product.price; + item.discountedPrice = item.product.price + }) + + const coupon = order.couponUsages[0]?.coupon; + + let discount = 0; + if(coupon && !coupon.isInvalidated && (!coupon.validTill || new Date(coupon.validTill) > new Date())) { + const proportion = Number(order.orderGroupProportion || 1); + if(coupon.discountPercent) { + const maxDiscount = Number(coupon.maxValue || Infinity) * proportion; + discount = Math.min((newTotal * parseFloat(coupon.discountPercent)) / 100, maxDiscount); + } + else { + discount = Number(coupon.flatDiscount) * proportion; + } + } + newTotal -= discount + + const { couponUsages, orderItems: orderItemsRaw, ...rest} = order; + const updatedOrderItems = orderItemsRaw.map(item => { + const { product, ...rawOrderItem } = item; + return rawOrderItem; + }) + return {order: rest, updatedOrderItems, newTotal } + }) + + const updatedOrderIds: number[] = []; + await db.transaction(async (tx) => { + for (const { order, updatedOrderItems, newTotal } of processedOrdersData) { + await tx.update(orders).set({ totalAmount: newTotal.toString() }).where(eq(orders.id, order.id)); + updatedOrderIds.push(order.id); + + for (const item of updatedOrderItems) { + await tx.update(orderItems).set({ + price: item.price, + discountedPrice: item.discountedPrice + }).where(eq(orderItems.id, item.id)); + } + } + }); + + return { success: true, updatedOrders: updatedOrderIds, message: `Rebalanced ${updatedOrderIds.length} orders.` }; + }), + + cancelOrder: protectedProcedure + .input(z.object({ + orderId: z.number(), + reason: z.string().min(1, "Cancellation reason is required"), + })) + .mutation(async ({ input }) => { + const { orderId, reason } = input; + + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + with: { + orderStatus: true, + }, + }); + + if (!order) { + throw new ApiError("Order not found", 404); + } + + const status = order.orderStatus[0]; + if (!status) { + throw new ApiError("Order status not found", 400); + } + + if (status.isCancelled) { + throw new ApiError("Order is already cancelled", 400); + } + + if (status.isDelivered) { + throw new ApiError("Cannot cancel delivered order", 400); + } + + const result = await db.transaction(async (tx) => { + await tx + .update(orderStatus) + .set({ + isCancelled: true, + isCancelledByAdmin: true, + cancelReason: reason, + cancellationAdminNotes: reason, + cancellationReviewed: true, + cancellationReviewedAt: new Date(), + }) + .where(eq(orderStatus.id, status.id)); + + const refundStatus = order.isCod ? "na" : "pending"; + + await tx.insert(refunds).values({ + orderId: order.id, + refundStatus, + }); + + return { orderId: order.id, userId: order.userId }; + }); + + // Publish to Redis for Telegram notification + await publishCancellation(result.orderId, 'admin', reason); + + return { success: true, message: "Order cancelled successfully" }; + }), +}); + +// {"id": "order_Rhh00qJNdjUp8o", "notes": {"retry": "true", "customerOrderId": "14"}, "amount": 21000, "entity": "order", "status": "created", "receipt": "order_14_retry", "attempts": 0, "currency": "INR", "offer_id": null, "signature": "6df20655021f1d6841340f2a2ef2ef9378cb3d43495ab09e85f08aea1a851583", "amount_due": 21000, "created_at": 1763575791, "payment_id": "pay_Rhh15cLL28YM7j", "amount_paid": 0} + +type RefundStatus = "success" | "pending" | "failed" | "none" | "na"; + +export async function deleteOrderById(orderId: number): Promise { + await db.transaction(async (tx) => { + await tx.delete(orderItems).where(eq(orderItems.orderId, orderId)); + await tx.delete(orderStatus).where(eq(orderStatus.orderId, orderId)); + await tx.delete(payments).where(eq(payments.orderId, orderId)); + await tx.delete(refunds).where(eq(refunds.orderId, orderId)); + await tx.delete(couponUsage).where(eq(couponUsage.orderId, orderId)); + await tx.delete(complaints).where(eq(complaints.orderId, orderId)); + await tx.delete(orders).where(eq(orders.id, orderId)); + }); +} + diff --git a/verifier/apis/payments.ts b/verifier/apis/payments.ts new file mode 100644 index 0000000..51de5fb --- /dev/null +++ b/verifier/apis/payments.ts @@ -0,0 +1,146 @@ +import { router, protectedProcedure } from "@/src/trpc/trpc-index" +import { z } from "zod"; +import { db } from "@/src/db/db_index" +import { + orders, + orderStatus, + payments, + refunds, +} from "@/src/db/schema"; +import { and, eq } from "drizzle-orm"; +import { ApiError } from "@/src/lib/api-error" +import { RazorpayPaymentService } from "@/src/lib/payments-utils" + +const initiateRefundSchema = z + .object({ + orderId: z.number(), + refundPercent: z.number().min(0).max(100).optional(), + refundAmount: z.number().min(0).optional(), + }) + .refine( + (data) => { + const hasPercent = data.refundPercent !== undefined; + const hasAmount = data.refundAmount !== undefined; + return (hasPercent && !hasAmount) || (!hasPercent && hasAmount); + }, + { + message: + "Provide either refundPercent or refundAmount, not both or neither", + } + ); + +export const adminPaymentsRouter = router({ + initiateRefund: protectedProcedure + .input(initiateRefundSchema) + .mutation(async ({ input }) => { + try { + const { orderId, refundPercent, refundAmount } = input; + + // Validate order exists + const order = await db.query.orders.findFirst({ + where: eq(orders.id, orderId), + }); + + if (!order) { + throw new ApiError("Order not found", 404); + } + + // Check if order is paid + const orderStatusRecord = await db.query.orderStatus.findFirst({ + where: eq(orderStatus.orderId, orderId), + }); + + if(order.isCod) { + throw new ApiError("Order is a Cash On Delivery. Not eligible for refund") + } + + if ( + !orderStatusRecord || + (orderStatusRecord.paymentStatus !== "success" && + !(order.isCod && orderStatusRecord.isDelivered)) + ) { + throw new ApiError("Order payment not verified or not eligible for refund", 400); + } + + // Calculate refund amount + let calculatedRefundAmount: number; + if (refundPercent !== undefined) { + calculatedRefundAmount = + (parseFloat(order.totalAmount) * refundPercent) / 100; + } else if (refundAmount !== undefined) { + calculatedRefundAmount = refundAmount; + if (calculatedRefundAmount > parseFloat(order.totalAmount)) { + throw new ApiError("Refund amount cannot exceed order total", 400); + } + } else { + throw new ApiError("Invalid refund parameters", 400); + } + + let razorpayRefund = null; + let merchantRefundId = null; + + // Get payment record for online payments + const payment = await db.query.payments.findFirst({ + where: and( + eq(payments.orderId, orderId), + eq(payments.status, "success") + ), + }); + + if (!payment || payment.status !== "success") { + throw new ApiError("Payment not found or not successful", 404); + } + + const payload = payment.payload as any; + // Initiate Razorpay refund + razorpayRefund = await RazorpayPaymentService.initiateRefund( + payload.payment_id, + Math.round(calculatedRefundAmount * 100) // Convert to paisa + ); + merchantRefundId = razorpayRefund.id; + + + + // Check if refund already exists for this order + const existingRefund = await db.query.refunds.findFirst({ + where: eq(refunds.orderId, orderId), + }); + + const refundStatus = "initiated"; + + if (existingRefund) { + // Update existing refund + await db + .update(refunds) + .set({ + refundAmount: calculatedRefundAmount.toString(), + refundStatus, + merchantRefundId, + refundProcessedAt: order.isCod ? new Date() : null, + }) + .where(eq(refunds.id, existingRefund.id)); + } else { + // Insert new refund + await db + .insert(refunds) + .values({ + orderId, + refundAmount: calculatedRefundAmount.toString(), + refundStatus, + merchantRefundId, + }); + } + + return { + refundId: merchantRefundId || `cod_${orderId}`, + amount: calculatedRefundAmount, + status: refundStatus, + message: order.isCod ? "COD refund processed successfully" : "Refund initiated successfully", + }; + } + catch(e) { + console.log(e); + throw new ApiError("Failed to initiate refund") + } + }), +}); diff --git a/verifier/apis/product-availability-schedules.ts b/verifier/apis/product-availability-schedules.ts new file mode 100644 index 0000000..47cdc43 --- /dev/null +++ b/verifier/apis/product-availability-schedules.ts @@ -0,0 +1,154 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { productAvailabilitySchedules } from '@/src/db/schema' +import { eq } from 'drizzle-orm'; +import { refreshScheduleJobs } from '@/src/lib/automatedJobs'; + +const createScheduleSchema = z.object({ + scheduleName: z.string().min(1, "Schedule name is required"), + time: z.string().min(1, "Time is required").regex(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format. Use HH:MM"), + action: z.enum(['in', 'out']), + productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"), + groupIds: z.array(z.number().int().positive()).default([]), +}); + +const updateScheduleSchema = z.object({ + id: z.number().int().positive(), + updates: createScheduleSchema.partial().extend({ + scheduleName: z.string().min(1).optional(), + productIds: z.array(z.number().int().positive()).optional(), + groupIds: z.array(z.number().int().positive()).optional(), + }), +}); + +export const productAvailabilitySchedulesRouter = router({ + create: protectedProcedure + .input(createScheduleSchema) + .mutation(async ({ input, ctx }) => { + const { scheduleName, time, action, productIds, groupIds } = input; + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Check if schedule name already exists + const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.scheduleName, scheduleName), + }); + if (existingSchedule) { + throw new Error("Schedule name already exists"); + } + + // Create schedule with arrays + const scheduleResult = await db.insert(productAvailabilitySchedules).values({ + scheduleName, + time, + action, + productIds, + groupIds, + }).returning(); + + // Refresh cron jobs to include new schedule + await refreshScheduleJobs(); + + return scheduleResult[0]; + }), + + getAll: protectedProcedure + .query(async () => { + const schedules = await db.query.productAvailabilitySchedules.findMany({ + orderBy: (productAvailabilitySchedules, { desc }) => [desc(productAvailabilitySchedules.createdAt)], + }); + + return schedules.map(schedule => ({ + ...schedule, + productCount: schedule.productIds.length, + groupCount: schedule.groupIds.length, + })); + }), + + getById: protectedProcedure + .input(z.object({ id: z.number().int().positive() })) + .query(async ({ input }) => { + const { id } = input; + + const schedule = await db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.id, id), + }); + + if (!schedule) { + throw new Error("Schedule not found"); + } + + return schedule; + }), + + update: protectedProcedure + .input(updateScheduleSchema) + .mutation(async ({ input }) => { + const { id, updates } = input; + + // Check if schedule exists + const existingSchedule = await db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.id, id), + }); + if (!existingSchedule) { + throw new Error("Schedule not found"); + } + + // Check schedule name uniqueness if being updated + if (updates.scheduleName && updates.scheduleName !== existingSchedule.scheduleName) { + const duplicateSchedule = await db.query.productAvailabilitySchedules.findFirst({ + where: eq(productAvailabilitySchedules.scheduleName, updates.scheduleName), + }); + if (duplicateSchedule) { + throw new Error("Schedule name already exists"); + } + } + + // Update schedule + const updateData: any = {}; + if (updates.scheduleName !== undefined) updateData.scheduleName = updates.scheduleName; + if (updates.time !== undefined) updateData.time = updates.time; + if (updates.action !== undefined) updateData.action = updates.action; + if (updates.productIds !== undefined) updateData.productIds = updates.productIds; + if (updates.groupIds !== undefined) updateData.groupIds = updates.groupIds; + updateData.lastUpdated = new Date(); + + const result = await db.update(productAvailabilitySchedules) + .set(updateData) + .where(eq(productAvailabilitySchedules.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Failed to update schedule"); + } + + // Refresh cron jobs to reflect changes + await refreshScheduleJobs(); + + return result[0]; + }), + + delete: protectedProcedure + .input(z.object({ id: z.number().int().positive() })) + .mutation(async ({ input }) => { + const { id } = input; + + const result = await db.delete(productAvailabilitySchedules) + .where(eq(productAvailabilitySchedules.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Schedule not found"); + } + + // Refresh cron jobs to remove deleted schedule + await refreshScheduleJobs(); + + return { message: "Schedule deleted successfully" }; + }), +}); diff --git a/verifier/apis/product.ts b/verifier/apis/product.ts new file mode 100644 index 0000000..4677c40 --- /dev/null +++ b/verifier/apis/product.ts @@ -0,0 +1,758 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { productInfo, units, specialDeals, productSlots, productTags, productReviews, users, productGroupInfo, productGroupMembership } from '@/src/db/schema' +import { eq, and, inArray, desc, sql } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error' +import { imageUploadS3, scaffoldAssetUrl, getOriginalUrlFromSignedUrl, claimUploadUrl } from '@/src/lib/s3-client' +import { deleteS3Image } from '@/src/lib/delete-image' +import type { SpecialDeal } from '@/src/db/types' +import { scheduleStoreInitialization } from '@/src/stores/store-initializer' + + +type CreateDeal = { + quantity: number; + price: number; + validTill: string; +}; + +export const productRouter = router({ + getProducts: protectedProcedure + .query(async ({ ctx }) => { + const products = await db.query.productInfo.findMany({ + orderBy: productInfo.name, + with: { + unit: true, + store: true, + }, + }); + + // Generate signed URLs for all product images + const productsWithSignedUrls = await Promise.all( + products.map(async (product) => ({ + ...product, + images: scaffoldAssetUrl((product.images as string[]) || []), + })) + ); + + return { + products: productsWithSignedUrls, + count: productsWithSignedUrls.length, + }; + }), + + getProductById: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .query(async ({ input, ctx }) => { + const { id } = input; + + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + with: { + unit: true, + }, + }); + + if (!product) { + throw new ApiError("Product not found", 404); + } + + // Fetch special deals for this product + const deals = await db.query.specialDeals.findMany({ + where: eq(specialDeals.productId, id), + orderBy: specialDeals.quantity, + }); + + // Fetch associated tags for this product + const productTagsData = await db.query.productTags.findMany({ + where: eq(productTags.productId, id), + with: { + tag: true, + }, + }); + + // Generate signed URLs for product images + const productWithSignedUrls = { + ...product, + images: scaffoldAssetUrl((product.images as string[]) || []), + deals, + tags: productTagsData.map(pt => pt.tag), + }; + + return { + product: productWithSignedUrls, + }; + }), + + deleteProduct: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + const [deletedProduct] = await db + .delete(productInfo) + .where(eq(productInfo.id, id)) + .returning(); + + if (!deletedProduct) { + throw new ApiError("Product not found", 404); + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + message: "Product deleted successfully", + }; + }), + + createProduct: protectedProcedure + .input(z.object({ + name: z.string().min(1), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + unitId: z.number(), + storeId: z.number(), + price: z.number(), + marketPrice: z.number().optional(), + incrementStep: z.number().default(1), + productQuantity: z.number().default(1), + isSuspended: z.boolean().default(false), + isFlashAvailable: z.boolean().default(false), + flashPrice: z.number().optional(), + deals: z.array(z.object({ + quantity: z.number(), + price: z.number(), + validTill: z.string(), + })).optional(), + tagIds: z.array(z.number()).optional(), + imageKeys: z.array(z.string()).optional(), + })) + .mutation(async ({ input }) => { + const { + name, shortDescription, longDescription, unitId, storeId, + price, marketPrice, incrementStep, productQuantity, + isSuspended, isFlashAvailable, flashPrice, + deals, tagIds, imageKeys + } = input; + + // Validation + if (!name || !unitId || !storeId || !price) { + throw new ApiError("Name, unitId, storeId, and price are required", 400); + } + + // Check for duplicate name + const existingProduct = await db.query.productInfo.findFirst({ + where: eq(productInfo.name, name.trim()), + }); + if (existingProduct) { + throw new ApiError("A product with this name already exists", 400); + } + + // Check if unit exists + const unit = await db.query.units.findFirst({ + where: eq(units.id, unitId), + }); + if (!unit) { + throw new ApiError("Invalid unit ID", 400); + } + + console.log(imageKeys) + const [newProduct] = await db + .insert(productInfo) + .values({ + name: name.trim(), + shortDescription, + longDescription, + unitId, + storeId, + price: price.toString(), + marketPrice: marketPrice?.toString(), + incrementStep, + productQuantity, + isSuspended, + isFlashAvailable, + flashPrice: flashPrice?.toString(), + images: imageKeys || [], + }) + .returning(); + + // Handle deals + if (deals && deals.length > 0) { + const dealInserts = deals.map(deal => ({ + productId: newProduct.id, + quantity: deal.quantity.toString(), + price: deal.price.toString(), + validTill: new Date(deal.validTill), + })); + await db.insert(specialDeals).values(dealInserts); + } + + // Handle tags + if (tagIds && tagIds.length > 0) { + const tagAssociations = tagIds.map(tagId => ({ + productId: newProduct.id, + tagId, + })); + await db.insert(productTags).values(tagAssociations); + } + + // Claim upload URLs + if (imageKeys && imageKeys.length > 0) { + for (const key of imageKeys) { + try { + await claimUploadUrl(key); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${key}`, e); + } + } + } + + scheduleStoreInitialization(); + + return { + product: newProduct, + message: "Product created successfully", + }; + }), + + updateProduct: protectedProcedure + .input(z.object({ + id: z.number(), + name: z.string().min(1).optional(), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + unitId: z.number().optional(), + storeId: z.number().optional(), + price: z.number().optional(), + marketPrice: z.number().optional(), + incrementStep: z.number().optional(), + productQuantity: z.number().optional(), + isSuspended: z.boolean().optional(), + isFlashAvailable: z.boolean().optional(), + flashPrice: z.number().optional(), + deals: z.array(z.object({ + quantity: z.number(), + price: z.number(), + validTill: z.string(), + })).optional(), + tagIds: z.array(z.number()).optional(), + newImageKeys: z.array(z.string()).optional(), + imagesToDelete: z.array(z.string()).optional(), + })) + .mutation(async ({ input }) => { + const { id, newImageKeys, imagesToDelete, deals, tagIds, ...updateData } = input; + + // Get current product + const currentProduct = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + }); + if (!currentProduct) { + throw new ApiError("Product not found", 404); + } + + // Handle image deletions + let currentImages = (currentProduct.images as string[]) || []; + if (imagesToDelete && imagesToDelete.length > 0) { + for (const imageUrl of imagesToDelete) { + try { + await deleteS3Image(imageUrl); + } catch (e) { + console.error(`Failed to delete image: ${imageUrl}`, e); + } + } + currentImages = currentImages.filter(img => { + //!imagesToDelete.includes(img) + const isRemoved = imagesToDelete.some(item => item.includes(img)); + return !isRemoved; + }); + } + + // Add new images + if (newImageKeys && newImageKeys.length > 0) { + currentImages = [...currentImages, ...newImageKeys]; + + for (const key of newImageKeys) { + try { + await claimUploadUrl(key); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${key}`, e); + } + } + } + + // Update product - convert numeric fields to strings for PostgreSQL numeric type + const { price, marketPrice, flashPrice, ...otherData } = updateData; + const [updatedProduct] = await db + .update(productInfo) + .set({ + ...otherData, + ...(price !== undefined && { price: price.toString() }), + ...(marketPrice !== undefined && { marketPrice: marketPrice.toString() }), + ...(flashPrice !== undefined && { flashPrice: flashPrice.toString() }), + images: currentImages, + }) + .where(eq(productInfo.id, id)) + .returning(); + + // Handle deals update + if (deals !== undefined) { + await db.delete(specialDeals).where(eq(specialDeals.productId, id)); + if (deals.length > 0) { + const dealInserts = deals.map(deal => ({ + productId: id, + quantity: deal.quantity.toString(), + price: deal.price.toString(), + validTill: new Date(deal.validTill), + })); + await db.insert(specialDeals).values(dealInserts); + } + } + + // Handle tags update + if (tagIds !== undefined) { + await db.delete(productTags).where(eq(productTags.productId, id)); + if (tagIds.length > 0) { + const tagAssociations = tagIds.map(tagId => ({ + productId: id, + tagId, + })); + await db.insert(productTags).values(tagAssociations); + } + } + + scheduleStoreInitialization(); + + return { + product: updatedProduct, + message: "Product updated successfully", + }; + }), + + toggleOutOfStock: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + const product = await db.query.productInfo.findFirst({ + where: eq(productInfo.id, id), + }); + + if (!product) { + throw new ApiError("Product not found", 404); + } + + const [updatedProduct] = await db + .update(productInfo) + .set({ + isOutOfStock: !product.isOutOfStock, + }) + .where(eq(productInfo.id, id)) + .returning(); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + product: updatedProduct, + message: `Product marked as ${updatedProduct.isOutOfStock ? 'out of stock' : 'in stock'}`, + }; + }), + + updateSlotProducts: protectedProcedure + .input(z.object({ + slotId: z.string(), + productIds: z.array(z.string()), + })) + .mutation(async ({ input, ctx }) => { + const { slotId, productIds } = input; + + if (!Array.isArray(productIds)) { + throw new ApiError("productIds must be an array", 400); + } + + // Get current associations + const currentAssociations = await db.query.productSlots.findMany({ + where: eq(productSlots.slotId, parseInt(slotId)), + columns: { + productId: true, + }, + }); + + const currentProductIds = currentAssociations.map(assoc => assoc.productId); + const newProductIds = productIds.map((id: string) => parseInt(id)); + + // 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, parseInt(slotId)), + inArray(productSlots.productId, productsToRemove) + ) + ); + } + + // Add associations for newly selected products + if (productsToAdd.length > 0) { + const newAssociations = productsToAdd.map(productId => ({ + productId, + slotId: parseInt(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, + }; + }), + + getSlotProductIds: protectedProcedure + .input(z.object({ + slotId: z.string(), + })) + .query(async ({ input, ctx }) => { + const { slotId } = input; + + const associations = await db.query.productSlots.findMany({ + where: eq(productSlots.slotId, parseInt(slotId)), + columns: { + productId: true, + }, + }); + + const productIds = associations.map(assoc => assoc.productId); + + return { + productIds, + }; + }), + + getSlotsProductIds: protectedProcedure + .input(z.object({ + slotIds: z.array(z.number()), + })) + .query(async ({ input, ctx }) => { + const { slotIds } = input; + + if (!Array.isArray(slotIds)) { + throw new ApiError("slotIds must be an array", 400); + } + + 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; + }), + + getProductReviews: protectedProcedure + .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, + adminResponse: productReviews.adminResponse, + adminResponseImages: productReviews.adminResponseImages, + 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[]) || []), + signedAdminImageUrls: scaffoldAssetUrl((review.adminResponseImages 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 }; + }), + + respondToReview: protectedProcedure + .input(z.object({ + reviewId: z.number().int().positive(), + adminResponse: z.string().optional(), + adminResponseImages: z.array(z.string()).optional().default([]), + uploadUrls: z.array(z.string()).optional().default([]), + })) + .mutation(async ({ input }) => { + const { reviewId, adminResponse, adminResponseImages, uploadUrls } = input; + + const [updatedReview] = await db + .update(productReviews) + .set({ + adminResponse, + adminResponseImages, + }) + .where(eq(productReviews.id, reviewId)) + .returning(); + + if (!updatedReview) { + throw new ApiError('Review not found', 404); + } + + // Claim upload URLs + if (uploadUrls && uploadUrls.length > 0) { + // const { claimUploadUrl } = await import('@/src/lib/s3-client'); + await Promise.all(uploadUrls.map(url => claimUploadUrl(url))); + } + + return { success: true, review: updatedReview }; + }), + + getGroups: protectedProcedure + .query(async ({ ctx }) => { + const groups = await db.query.productGroupInfo.findMany({ + with: { + memberships: { + with: { + product: true, + }, + }, + }, + orderBy: desc(productGroupInfo.createdAt), + }); + + return { + groups: groups.map(group => ({ + ...group, + products: group.memberships.map(m => m.product), + productCount: group.memberships.length, + })), + }; + }), + + createGroup: protectedProcedure + .input(z.object({ + group_name: z.string().min(1), + description: z.string().optional(), + product_ids: z.array(z.number()).default([]), + })) + .mutation(async ({ input, ctx }) => { + const { group_name, description, product_ids } = input; + + const [newGroup] = await db + .insert(productGroupInfo) + .values({ + groupName: group_name, + description, + }) + .returning(); + + if (product_ids.length > 0) { + const memberships = product_ids.map(productId => ({ + productId, + groupId: newGroup.id, + })); + + await db.insert(productGroupMembership).values(memberships); + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + group: newGroup, + message: 'Group created successfully', + }; + }), + + updateGroup: protectedProcedure + .input(z.object({ + id: z.number(), + group_name: z.string().optional(), + description: z.string().optional(), + product_ids: z.array(z.number()).optional(), + })) + .mutation(async ({ input, ctx }) => { + const { id, group_name, description, product_ids } = input; + + const updateData: any = {}; + if (group_name !== undefined) updateData.groupName = group_name; + if (description !== undefined) updateData.description = description; + + const [updatedGroup] = await db + .update(productGroupInfo) + .set(updateData) + .where(eq(productGroupInfo.id, id)) + .returning(); + + if (!updatedGroup) { + throw new ApiError('Group not found', 404); + } + + if (product_ids !== undefined) { + // Delete existing memberships + await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)); + + // Insert new memberships + if (product_ids.length > 0) { + const memberships = product_ids.map(productId => ({ + productId, + groupId: id, + })); + + await db.insert(productGroupMembership).values(memberships); + } + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + group: updatedGroup, + message: 'Group updated successfully', + }; + }), + + deleteGroup: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + // Delete memberships first + await db.delete(productGroupMembership).where(eq(productGroupMembership.groupId, id)); + + // Delete group + const [deletedGroup] = await db + .delete(productGroupInfo) + .where(eq(productGroupInfo.id, id)) + .returning(); + + if (!deletedGroup) { + throw new ApiError('Group not found', 404); + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + message: 'Group deleted successfully', + }; + }), + + updateProductPrices: protectedProcedure + .input(z.object({ + updates: z.array(z.object({ + productId: z.number(), + price: z.number().optional(), + marketPrice: z.number().nullable().optional(), + flashPrice: z.number().nullable().optional(), + isFlashAvailable: z.boolean().optional(), + })), + })) + .mutation(async ({ input, ctx }) => { + const { updates } = input; + + if (updates.length === 0) { + throw new ApiError('No updates provided', 400); + } + + // Validate that all productIds exist + const productIds = updates.map(u => u.productId); + const existingProducts = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + columns: { id: true }, + }); + + const existingIds = new Set(existingProducts.map(p => p.id)); + const invalidIds = productIds.filter(id => !existingIds.has(id)); + + if (invalidIds.length > 0) { + throw new ApiError(`Invalid product IDs: ${invalidIds.join(', ')}`, 400); + } + + // Perform batch update + const updatePromises = updates.map(async (update) => { + const { productId, price, marketPrice, flashPrice, isFlashAvailable } = update; + const updateData: any = {}; + if (price !== undefined) updateData.price = price; + if (marketPrice !== undefined) updateData.marketPrice = marketPrice; + if (flashPrice !== undefined) updateData.flashPrice = flashPrice; + if (isFlashAvailable !== undefined) updateData.isFlashAvailable = isFlashAvailable; + + return db + .update(productInfo) + .set(updateData) + .where(eq(productInfo.id, productId)); + }); + + await Promise.all(updatePromises); + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + message: `Updated prices for ${updates.length} product(s)`, + updatedCount: updates.length, + }; + }), + }); diff --git a/verifier/apis/slots.ts b/verifier/apis/slots.ts new file mode 100644 index 0000000..1cc40e8 --- /dev/null +++ b/verifier/apis/slots.ts @@ -0,0 +1,610 @@ +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); + + // 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'}`, + }; + }), +}); diff --git a/verifier/apis/staff-user.ts b/verifier/apis/staff-user.ts new file mode 100644 index 0000000..428f36e --- /dev/null +++ b/verifier/apis/staff-user.ts @@ -0,0 +1,242 @@ +import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { staffUsers, staffRoles, users, userDetails, orders } from '@/src/db/schema' +import { eq, or, ilike, and, lt, desc } from 'drizzle-orm'; +import bcrypt from 'bcryptjs'; +import { ApiError } from '@/src/lib/api-error' +import { signToken } from '@/src/lib/jwt-utils' + +export const staffUserRouter = router({ + login: publicProcedure + .input(z.object({ + name: z.string(), + password: z.string(), + })) + .mutation(async ({ input }) => { + const { name, password } = input; + + if (!name || !password) { + throw new ApiError('Name and password are required', 400); + } + + const staff = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }); + + if (!staff) { + throw new ApiError('Invalid credentials', 401); + } + + const isPasswordValid = await bcrypt.compare(password, staff.password); + if (!isPasswordValid) { + throw new ApiError('Invalid credentials', 401); + } + + const token = await signToken( + { staffId: staff.id, name: staff.name }, + '30d' + ); + + return { + message: 'Login successful', + token, + staff: { id: staff.id, name: staff.name }, + }; + }), + + getStaff: protectedProcedure + .query(async ({ ctx }) => { + const staff = await db.query.staffUsers.findMany({ + columns: { + id: true, + name: true, + }, + with: { + role: { + with: { + rolePermissions: { + with: { + permission: true, + }, + }, + }, + }, + }, + }); + + // Transform the data to include role and permissions in a cleaner format + const transformedStaff = staff.map((user) => ({ + id: user.id, + name: user.name, + role: user.role ? { + id: user.role.id, + name: user.role.roleName, + } : null, + permissions: user.role?.rolePermissions.map((rp) => ({ + id: rp.permission.id, + name: rp.permission.permissionName, + })) || [], + })); + + return { + staff: transformedStaff, + }; + }), + + getUsers: protectedProcedure + .input(z.object({ + cursor: z.number().optional(), + limit: z.number().default(20), + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { cursor, limit, search } = input; + + let whereCondition = undefined; + + if (search) { + whereCondition = or( + ilike(users.name, `%${search}%`), + ilike(users.email, `%${search}%`), + ilike(users.mobile, `%${search}%`) + ); + } + + if (cursor) { + const cursorCondition = lt(users.id, cursor); + whereCondition = whereCondition ? and(whereCondition, cursorCondition) : cursorCondition; + } + + const allUsers = await db.query.users.findMany({ + where: whereCondition, + with: { + userDetails: true, + }, + orderBy: desc(users.id), + limit: limit + 1, // fetch one extra to check if there's more + }); + + const hasMore = allUsers.length > limit; + const usersToReturn = hasMore ? allUsers.slice(0, limit) : allUsers; + + const formattedUsers = usersToReturn.map(user => ({ + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + image: user.userDetails?.profileImage || null, + })); + + return { + users: formattedUsers, + nextCursor: hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined, + }; + }), + + getUserDetails: protectedProcedure + .input(z.object({ userId: z.number() })) + .query(async ({ input }) => { + const { userId } = input; + + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + with: { + userDetails: true, + orders: { + orderBy: desc(orders.createdAt), + limit: 1, + }, + }, + }); + + if (!user) { + throw new ApiError("User not found", 404); + } + + const lastOrder = user.orders[0]; + + return { + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + addedOn: user.createdAt, + lastOrdered: lastOrder?.createdAt || null, + isSuspended: user.userDetails?.isSuspended || false, + }; + }), + + updateUserSuspension: protectedProcedure + .input(z.object({ userId: z.number(), isSuspended: z.boolean() })) + .mutation(async ({ input }) => { + const { userId, isSuspended } = input; + + await db + .insert(userDetails) + .values({ userId, isSuspended }) + .onConflictDoUpdate({ + target: userDetails.userId, + set: { isSuspended }, + }); + + return { success: true }; + }), + + createStaffUser: protectedProcedure + .input(z.object({ + name: z.string().min(1, 'Name is required'), + password: z.string().min(6, 'Password must be at least 6 characters'), + roleId: z.number().int().positive('Role is required'), + })) + .mutation(async ({ input, ctx }) => { + const { name, password, roleId } = input; + + // Check if staff user already exists + const existingUser = await db.query.staffUsers.findFirst({ + where: eq(staffUsers.name, name), + }); + + if (existingUser) { + throw new ApiError('Staff user with this name already exists', 409); + } + + // Check if role exists + const role = await db.query.staffRoles.findFirst({ + where: eq(staffRoles.id, roleId), + }); + + if (!role) { + throw new ApiError('Invalid role selected', 400); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 12); + + // Create staff user + const [newUser] = await db.insert(staffUsers).values({ + name: name.trim(), + password: hashedPassword, + staffRoleId: roleId, + }).returning(); + + return { success: true, user: { id: newUser.id, name: newUser.name } }; + }), + + getRoles: protectedProcedure + .query(async ({ ctx }) => { + const roles = await db.query.staffRoles.findMany({ + columns: { + id: true, + roleName: true, + }, + }); + + return { + roles: roles.map(role => ({ + id: role.id, + name: role.roleName, + })), + }; + }), +}); \ No newline at end of file diff --git a/verifier/apis/store.ts b/verifier/apis/store.ts new file mode 100644 index 0000000..5925843 --- /dev/null +++ b/verifier/apis/store.ts @@ -0,0 +1,211 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { storeInfo, productInfo } from '@/src/db/schema' +import { eq, inArray } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error' + import { extractKeyFromPresignedUrl, deleteImageUtil, scaffoldAssetUrl } from '@/src/lib/s3-client' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { scheduleStoreInitialization } from '@/src/stores/store-initializer' + + +export const storeRouter = router({ + getStores: protectedProcedure + .query(async ({ ctx }) => { + const stores = await db.query.storeInfo.findMany({ + with: { + owner: true, + }, + }); + + Promise.all(stores.map(async store => { + if(store.imageUrl) + store.imageUrl = scaffoldAssetUrl(store.imageUrl) + })).catch((e) => { + throw new ApiError("Unable to find store image urls") + } + ) + return { + stores, + count: stores.length, + }; + }), + + getStoreById: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .query(async ({ input, ctx }) => { + const { id } = input; + + const store = await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, id), + with: { + owner: true, + }, + }); + + if (!store) { + throw new ApiError("Store not found", 404); + } + store.imageUrl = scaffoldAssetUrl(store.imageUrl); + return { + store, + }; + }), + + createStore: protectedProcedure + .input(z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + imageUrl: z.string().optional(), + owner: z.number().min(1, "Owner is required"), + products: z.array(z.number()).optional(), + })) + .mutation(async ({ input, ctx }) => { + const { name, description, imageUrl, owner, products } = input; + + // const imageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : undefined; + const imageKey = imageUrl + + const [newStore] = await db + .insert(storeInfo) + .values({ + name, + description, + imageUrl: imageKey, + owner, + }) + .returning(); + + // Assign selected products to this store + if (products && products.length > 0) { + await db + .update(productInfo) + .set({ storeId: newStore.id }) + .where(inArray(productInfo.id, products)); + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + store: newStore, + message: "Store created successfully", + }; + }), + + updateStore: protectedProcedure + .input(z.object({ + id: z.number(), + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + imageUrl: z.string().optional(), + owner: z.number().min(1, "Owner is required"), + products: z.array(z.number()).optional(), + })) + .mutation(async ({ input, ctx }) => { + const { id, name, description, imageUrl, owner, products } = input; + + const existingStore = await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, id), + }); + + if (!existingStore) { + throw new ApiError("Store not found", 404); + } + + const oldImageKey = existingStore.imageUrl; + const newImageKey = imageUrl ? extractKeyFromPresignedUrl(imageUrl) : oldImageKey; + + // Delete old image only if: + // 1. New image provided and keys are different, OR + // 2. No new image but old exists (clearing the image) + if (oldImageKey && ( + (newImageKey && newImageKey !== oldImageKey) || + (!newImageKey) + )) { + try { + await deleteImageUtil({keys: [oldImageKey]}); + } catch (error) { + console.error('Failed to delete old image:', error); + // Continue with update even if deletion fails + } + } + + const [updatedStore] = await db + .update(storeInfo) + .set({ + name, + description, + imageUrl: newImageKey, + owner, + }) + .where(eq(storeInfo.id, id)) + .returning(); + + if (!updatedStore) { + throw new ApiError("Store not found", 404); + } + + // Update products if provided + if (products) { + // First, set storeId to null for products not in the list but currently assigned to this store + await db + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, id)); + + // Then, assign the selected products to this store + if (products.length > 0) { + await db + .update(productInfo) + .set({ storeId: id }) + .where(inArray(productInfo.id, products)); + } + } + + // Reinitialize stores to reflect changes + scheduleStoreInitialization() + + return { + store: updatedStore, + message: "Store updated successfully", + }; + }), + + deleteStore: protectedProcedure + .input(z.object({ + storeId: z.number(), + })) + .mutation(async ({ input, ctx }) => { + const { storeId } = input; + + const result = await db.transaction(async (tx) => { + // First, update all products of this store to set storeId to null + await tx + .update(productInfo) + .set({ storeId: null }) + .where(eq(productInfo.storeId, storeId)); + + // Then delete the store + const [deletedStore] = await tx + .delete(storeInfo) + .where(eq(storeInfo.id, storeId)) + .returning(); + + if (!deletedStore) { + throw new ApiError("Store not found", 404); + } + + return { + message: "Store deleted successfully", + }; + }); + + // Reinitialize stores to reflect changes (outside transaction) + scheduleStoreInitialization() + + return result; + }), + }); diff --git a/verifier/apis/tag.ts b/verifier/apis/tag.ts new file mode 100644 index 0000000..66dac83 --- /dev/null +++ b/verifier/apis/tag.ts @@ -0,0 +1,214 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import { db } from '@/src/db/db_index' +import { productTagInfo } from '@/src/db/schema' +import { eq } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error' +import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client' +import { deleteS3Image } from '@/src/lib/delete-image' +import { scheduleStoreInitialization } from '@/src/stores/store-initializer' + +export const tagRouter = router({ + getTags: protectedProcedure + .query(async () => { + const tags = await db + .select() + .from(productTagInfo) + .orderBy(productTagInfo.tagName); + + // Generate asset URLs for tag images + const tagsWithUrls = tags.map(tag => ({ + ...tag, + imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null, + })); + + return { + tags: tagsWithUrls, + message: "Tags retrieved successfully", + }; + }), + + getTagById: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .query(async ({ input }) => { + const tag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, input.id), + }); + + if (!tag) { + throw new ApiError("Tag not found", 404); + } + + // Generate asset URL for tag image + const tagWithUrl = { + ...tag, + imageUrl: tag.imageUrl ? scaffoldAssetUrl(tag.imageUrl) : null, + }; + + return { + tag: tagWithUrl, + message: "Tag retrieved successfully", + }; + }), + + createTag: protectedProcedure + .input(z.object({ + tagName: z.string().min(1), + tagDescription: z.string().optional(), + isDashboardTag: z.boolean().default(false), + relatedStores: z.array(z.number()).default([]), + imageKey: z.string().optional(), + })) + .mutation(async ({ input }) => { + const { tagName, tagDescription, isDashboardTag, relatedStores, imageKey } = input; + + // Check for duplicate tag name + const existingTag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.tagName, tagName.trim()), + }); + + if (existingTag) { + throw new ApiError("A tag with this name already exists", 400); + } + + const [newTag] = await db + .insert(productTagInfo) + .values({ + tagName: tagName.trim(), + tagDescription, + imageUrl: imageKey || null, + isDashboardTag, + relatedStores, + }) + .returning(); + + // Claim upload URL if image was provided + if (imageKey) { + try { + await claimUploadUrl(imageKey); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${imageKey}`, e); + } + } + + scheduleStoreInitialization(); + + return { + tag: newTag, + message: "Tag created successfully", + }; + }), + + updateTag: protectedProcedure + .input(z.object({ + id: z.number(), + tagName: z.string().min(1), + tagDescription: z.string().optional(), + isDashboardTag: z.boolean(), + relatedStores: z.array(z.number()), + imageKey: z.string().optional(), + deleteExistingImage: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + const { id, imageKey, deleteExistingImage, ...updateData } = input; + + // Get current tag + const currentTag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, id), + }); + + if (!currentTag) { + throw new ApiError("Tag not found", 404); + } + + let newImageUrl = currentTag.imageUrl; + + // Handle image deletion + if (deleteExistingImage && currentTag.imageUrl) { + try { + await deleteS3Image(currentTag.imageUrl); + } catch (e) { + console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e); + } + newImageUrl = null; + } + + // Handle new image upload (only if different from existing) + if (imageKey && imageKey !== currentTag.imageUrl) { + // Delete old image if exists and not already deleted + if (currentTag.imageUrl && !deleteExistingImage) { + try { + await deleteS3Image(currentTag.imageUrl); + } catch (e) { + console.error(`Failed to delete old image: ${currentTag.imageUrl}`, e); + } + } + newImageUrl = imageKey; + + // Claim upload URL + try { + await claimUploadUrl(imageKey); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${imageKey}`, e); + } + } + + const [updatedTag] = await db + .update(productTagInfo) + .set({ + tagName: updateData.tagName.trim(), + tagDescription: updateData.tagDescription, + isDashboardTag: updateData.isDashboardTag, + relatedStores: updateData.relatedStores, + imageUrl: newImageUrl, + }) + .where(eq(productTagInfo.id, id)) + .returning(); + + scheduleStoreInitialization(); + + return { + tag: updatedTag, + message: "Tag updated successfully", + }; + }), + + deleteTag: protectedProcedure + .input(z.object({ + id: z.number(), + })) + .mutation(async ({ input }) => { + const { id } = input; + + // Get tag to check for image + const tag = await db.query.productTagInfo.findFirst({ + where: eq(productTagInfo.id, id), + }); + + if (!tag) { + throw new ApiError("Tag not found", 404); + } + + // Delete image from S3 if exists + if (tag.imageUrl) { + try { + await deleteS3Image(tag.imageUrl); + } catch (e) { + console.error(`Failed to delete image: ${tag.imageUrl}`, e); + } + } + + // Delete tag (will fail if tag is assigned to products due to FK constraint) + await db.delete(productTagInfo).where(eq(productTagInfo.id, id)); + + scheduleStoreInitialization(); + + return { + message: "Tag deleted successfully", + }; + }), +}); + +export type TagRouter = typeof tagRouter; diff --git a/verifier/apis/user.ts b/verifier/apis/user.ts new file mode 100644 index 0000000..2b176b2 --- /dev/null +++ b/verifier/apis/user.ts @@ -0,0 +1,489 @@ +import { protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { users, complaints, orders, orderItems, notifCreds, unloggedUserTokens, userDetails, userIncidents } from '@/src/db/schema'; +import { eq, sql, desc, asc, count, max, inArray } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error'; +import { notificationQueue } from '@/src/lib/notif-job'; +import { recomputeUserNegativityScore } from '@/src/stores/user-negativity-store'; + +async function createUserByMobile(mobile: string): Promise { + // Clean mobile number (remove non-digits) + const cleanMobile = mobile.replace(/\D/g, ''); + + // Validate: exactly 10 digits + if (cleanMobile.length !== 10) { + throw new ApiError('Mobile number must be exactly 10 digits', 400); + } + + // Check if user already exists + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.mobile, cleanMobile)) + .limit(1); + + if (existingUser) { + throw new ApiError('User with this mobile number already exists', 409); + } + + // Create user + const [newUser] = await db + .insert(users) + .values({ + name: null, + email: null, + mobile: cleanMobile, + }) + .returning(); + + return newUser; +} + +export const userRouter = { + createUserByMobile: protectedProcedure + .input(z.object({ + mobile: z.string().min(1, 'Mobile number is required'), + })) + .mutation(async ({ input }) => { + const newUser = await createUserByMobile(input.mobile); + + return { + success: true, + data: newUser, + }; + }), + + getEssentials: protectedProcedure + .query(async () => { + const count = await db.$count(complaints, eq(complaints.isResolved, false)); + + return { + unresolvedComplaints: count || 0, + }; + }), + + getAllUsers: protectedProcedure + .input(z.object({ + limit: z.number().min(1).max(100).default(50), + cursor: z.number().optional(), + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { limit, cursor, search } = input; + + // Build where conditions + const whereConditions = []; + + if (search && search.trim()) { + whereConditions.push(sql`${users.mobile} ILIKE ${`%${search.trim()}%`}`); + } + + if (cursor) { + whereConditions.push(sql`${users.id} > ${cursor}`); + } + + // Get users with filters applied + const usersList = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + createdAt: users.createdAt, + }) + .from(users) + .where(whereConditions.length > 0 ? sql.join(whereConditions, sql` AND `) : undefined) + .orderBy(asc(users.id)) + .limit(limit + 1); // Get one extra to determine if there's more + + // Check if there are more results + const hasMore = usersList.length > limit; + const usersToReturn = hasMore ? usersList.slice(0, limit) : usersList; + + // Get order stats for each user + const userIds = usersToReturn.map(u => u.id); + + let orderCounts: { userId: number; totalOrders: number }[] = []; + let lastOrders: { userId: number; lastOrderDate: Date | null }[] = []; + let suspensionStatuses: { userId: number; isSuspended: boolean }[] = []; + + if (userIds.length > 0) { + // Get total orders per user + orderCounts = await db + .select({ + userId: orders.userId, + totalOrders: count(orders.id), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId); + + // Get last order date per user + lastOrders = await db + .select({ + userId: orders.userId, + lastOrderDate: max(orders.createdAt), + }) + .from(orders) + .where(sql`${orders.userId} IN (${sql.join(userIds, sql`, `)})`) + .groupBy(orders.userId); + + // Get suspension status for each user + suspensionStatuses = await db + .select({ + userId: userDetails.userId, + isSuspended: userDetails.isSuspended, + }) + .from(userDetails) + .where(sql`${userDetails.userId} IN (${sql.join(userIds, sql`, `)})`); + } + + // Create lookup maps + const orderCountMap = new Map(orderCounts.map(o => [o.userId, o.totalOrders])); + const lastOrderMap = new Map(lastOrders.map(o => [o.userId, o.lastOrderDate])); + const suspensionMap = new Map(suspensionStatuses.map(s => [s.userId, s.isSuspended])); + + // Combine data + const usersWithStats = usersToReturn.map(user => ({ + ...user, + totalOrders: orderCountMap.get(user.id) || 0, + lastOrderDate: lastOrderMap.get(user.id) || null, + isSuspended: suspensionMap.get(user.id) ?? false, + })); + + // Get next cursor + const nextCursor = hasMore ? usersToReturn[usersToReturn.length - 1].id : undefined; + + return { + users: usersWithStats, + nextCursor, + hasMore, + }; + }), + + getUserDetails: protectedProcedure + .input(z.object({ + userId: z.number(), + })) + .query(async ({ input }) => { + const { userId } = input; + + // Get user info + const user = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + createdAt: users.createdAt, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user || user.length === 0) { + throw new ApiError('User not found', 404); + } + + // Get user suspension status + const userDetail = await db + .select({ + isSuspended: userDetails.isSuspended, + }) + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1); + + // Get all orders for this user with order items count + const userOrders = await db + .select({ + id: orders.id, + readableId: orders.readableId, + totalAmount: orders.totalAmount, + createdAt: orders.createdAt, + isFlashDelivery: orders.isFlashDelivery, + }) + .from(orders) + .where(eq(orders.userId, userId)) + .orderBy(desc(orders.createdAt)); + + // Get order status for each order + const orderIds = userOrders.map(o => o.id); + + let orderStatuses: { orderId: number; isDelivered: boolean; isCancelled: boolean }[] = []; + + if (orderIds.length > 0) { + const { orderStatus } = await import('@/src/db/schema'); + orderStatuses = await db + .select({ + orderId: orderStatus.orderId, + isDelivered: orderStatus.isDelivered, + isCancelled: orderStatus.isCancelled, + }) + .from(orderStatus) + .where(sql`${orderStatus.orderId} IN (${sql.join(orderIds, sql`, `)})`); + } + + // Get item counts for each order + const itemCounts = await db + .select({ + orderId: orderItems.orderId, + itemCount: count(orderItems.id), + }) + .from(orderItems) + .where(sql`${orderItems.orderId} IN (${sql.join(orderIds, sql`, `)})`) + .groupBy(orderItems.orderId); + + // Create lookup maps + const statusMap = new Map(orderStatuses.map(s => [s.orderId, s])); + const itemCountMap = new Map(itemCounts.map(c => [c.orderId, c.itemCount])); + + // Determine status string + const getStatus = (status: { isDelivered: boolean; isCancelled: boolean } | undefined) => { + if (!status) return 'pending'; + if (status.isCancelled) return 'cancelled'; + if (status.isDelivered) return 'delivered'; + return 'pending'; + }; + + // Combine data + const ordersWithDetails = userOrders.map(order => { + const status = statusMap.get(order.id); + return { + id: order.id, + readableId: order.readableId, + totalAmount: order.totalAmount, + createdAt: order.createdAt, + isFlashDelivery: order.isFlashDelivery, + status: getStatus(status), + itemCount: itemCountMap.get(order.id) || 0, + }; + }); + + return { + user: { + ...user[0], + isSuspended: userDetail[0]?.isSuspended ?? false, + }, + orders: ordersWithDetails, + }; + }), + + updateUserSuspension: protectedProcedure + .input(z.object({ + userId: z.number(), + isSuspended: z.boolean(), + })) + .mutation(async ({ input }) => { + const { userId, isSuspended } = input; + + // Check if user exists + const user = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user || user.length === 0) { + throw new ApiError('User not found', 404); + } + + // Check if user_details record exists + const existingDetail = await db + .select({ id: userDetails.id }) + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1); + + if (existingDetail.length > 0) { + // Update existing record + await db + .update(userDetails) + .set({ isSuspended }) + .where(eq(userDetails.userId, userId)); + } else { + // Insert new record + await db + .insert(userDetails) + .values({ + userId, + isSuspended, + }); + } + + return { + success: true, + message: `User ${isSuspended ? 'suspended' : 'unsuspended'} successfully`, + }; + }), + + getUsersForNotification: protectedProcedure + .input(z.object({ + search: z.string().optional(), + })) + .query(async ({ input }) => { + const { search } = input; + + // Get all users + let usersList; + if (search && search.trim()) { + usersList = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + }) + .from(users) + .where(sql`${users.mobile} ILIKE ${`%${search.trim()}%`} OR ${users.name} ILIKE ${`%${search.trim()}%`}`); + } else { + usersList = await db + .select({ + id: users.id, + name: users.name, + mobile: users.mobile, + }) + .from(users); + } + + // Get eligible users (have notif_creds entry) + const eligibleUsers = await db + .select({ userId: notifCreds.userId }) + .from(notifCreds); + + const eligibleSet = new Set(eligibleUsers.map(u => u.userId)); + + return { + users: usersList.map(user => ({ + id: user.id, + name: user.name, + mobile: user.mobile, + isEligibleForNotif: eligibleSet.has(user.id), + })), + }; + }), + + sendNotification: protectedProcedure + .input(z.object({ + userIds: z.array(z.number()).default([]), + title: z.string().min(1, 'Title is required'), + text: z.string().min(1, 'Message is required'), + imageUrl: z.string().optional(), + })) + .mutation(async ({ input }) => { + const { userIds, title, text, imageUrl } = input; + + let tokens: string[] = []; + + if (userIds.length === 0) { + // Send to all users - get tokens from both logged-in and unlogged users + const loggedInTokens = await db.select({ token: notifCreds.token }).from(notifCreds); + const unloggedTokens = await db.select({ token: unloggedUserTokens.token }).from(unloggedUserTokens); + + tokens = [ + ...loggedInTokens.map(t => t.token), + ...unloggedTokens.map(t => t.token) + ]; + } else { + // Send to specific users - get their tokens + const userTokens = await db + .select({ token: notifCreds.token }) + .from(notifCreds) + .where(inArray(notifCreds.userId, userIds)); + + tokens = userTokens.map(t => t.token); + } + + // Queue one job per token + let queuedCount = 0; + for (const token of tokens) { + try { + await notificationQueue.add('send-admin-notification', { + token, + title, + body: text, + imageUrl: imageUrl || null, + }, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + }); + queuedCount++; + } catch (error) { + console.error(`Failed to queue notification for token:`, error); + } + } + + return { + success: true, + message: `Notification queued for ${queuedCount} users`, + }; + }), + + getUserIncidents: protectedProcedure + .input(z.object({ + userId: z.number(), + })) + .query(async ({ input }) => { + const { userId } = input; + + const incidents = await db.query.userIncidents.findMany({ + where: eq(userIncidents.userId, userId), + with: { + order: { + with: { + orderStatus: true, + }, + }, + addedBy: true, + }, + orderBy: desc(userIncidents.dateAdded), + }); + + return { + incidents: incidents.map(incident => ({ + id: incident.id, + userId: incident.userId, + orderId: incident.orderId, + dateAdded: incident.dateAdded, + adminComment: incident.adminComment, + addedBy: incident.addedBy?.name || 'Unknown', + negativityScore: incident.negativityScore, + orderStatus: incident.order?.orderStatus?.[0]?.isCancelled ? 'cancelled' : 'active', + })), + }; + }), + + addUserIncident: protectedProcedure + .input(z.object({ + userId: z.number(), + orderId: z.number().optional(), + adminComment: z.string().optional(), + negativityScore: z.number().optional(), + })) + .mutation(async ({ input, ctx }) => { + const { userId, orderId, adminComment, negativityScore } = input; + + const adminUserId = ctx.staffUser?.id; + + if (!adminUserId) { + throw new ApiError('Admin user not authenticated', 401); + } + + + const incidentObj = { userId, orderId, adminComment, addedBy: adminUserId, negativityScore }; + + const [incident] = await db.insert(userIncidents) + .values({ + ...incidentObj, + }) + .returning(); + + recomputeUserNegativityScore(userId); + + return { + success: true, + data: incident, + }; + }), +}; diff --git a/verifier/apis/vendor-snippets.ts b/verifier/apis/vendor-snippets.ts new file mode 100644 index 0000000..4a307d3 --- /dev/null +++ b/verifier/apis/vendor-snippets.ts @@ -0,0 +1,531 @@ +import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index' +import { z } from 'zod'; +import dayjs from 'dayjs'; +import { db } from '@/src/db/db_index' +import { vendorSnippets, deliverySlotInfo, productInfo, orders, orderItems, users, orderStatus } from '@/src/db/schema' +import { eq, and, inArray, isNotNull, gt, sql, asc, ne } from 'drizzle-orm'; +import { appUrl } from '@/src/lib/env-exporter' + +const createSnippetSchema = z.object({ + snippetCode: z.string().min(1, "Snippet code is required"), + slotId: z.number().optional(), + productIds: z.array(z.number().int().positive()).min(1, "At least one product is required"), + validTill: z.string().optional(), + isPermanent: z.boolean().default(false) +}); + +const updateSnippetSchema = z.object({ + id: z.number().int().positive(), + updates: createSnippetSchema.partial().extend({ + snippetCode: z.string().min(1).optional(), + productIds: z.array(z.number().int().positive()).optional(), + isPermanent: z.boolean().default(false) + }), +}); + +export const vendorSnippetsRouter = router({ + create: protectedProcedure + .input(createSnippetSchema) + .mutation(async ({ input, ctx }) => { + const { snippetCode, slotId, productIds, validTill, isPermanent } = input; + + // Get staff user ID from auth middleware + const staffUserId = ctx.staffUser?.id; + if (!staffUserId) { + throw new Error("Unauthorized"); + } + + // Validate slot exists + if(slotId) { + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, slotId), + }); + if (!slot) { + throw new Error("Invalid slot ID"); + } + } + + // Validate products exist + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, productIds), + }); + if (products.length !== productIds.length) { + throw new Error("One or more invalid product IDs"); + } + + // Check if snippet code already exists + const existingSnippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }); + if (existingSnippet) { + throw new Error("Snippet code already exists"); + } + + const result = await db.insert(vendorSnippets).values({ + snippetCode, + slotId, + productIds, + isPermanent, + validTill: validTill ? new Date(validTill) : undefined, + }).returning(); + + return result[0]; + }), + + getAll: protectedProcedure + .query(async () => { + console.log('from the vendor snipptes methods') + + try { + const result = await db.query.vendorSnippets.findMany({ + with: { + slot: true, + }, + orderBy: (vendorSnippets, { desc }) => [desc(vendorSnippets.createdAt)], + }); + + const snippetsWithProducts = await Promise.all( + result.map(async (snippet) => { + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, snippet.productIds), + columns: { id: true, name: true }, + }); + + return { + ...snippet, + accessUrl: `${appUrl}/vendor-order-list?id=${snippet.snippetCode}`, + products: products.map(p => ({ id: p.id, name: p.name })), + }; + }) + ); + + return snippetsWithProducts; + } + catch(e) { + console.log(e) + } + return []; + }), + + getById: protectedProcedure + .input(z.object({ id: z.number().int().positive() })) + .query(async ({ input }) => { + const { id } = input; + + const result = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.id, id), + with: { + slot: true, + }, + }); + + if (!result) { + throw new Error("Vendor snippet not found"); + } + + return result; + }), + + update: protectedProcedure + .input(updateSnippetSchema) + .mutation(async ({ input }) => { + const { id, updates } = input; + + // Check if snippet exists + const existingSnippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.id, id), + }); + if (!existingSnippet) { + throw new Error("Vendor snippet not found"); + } + + // Validate slot if being updated + if (updates.slotId) { + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, updates.slotId), + }); + if (!slot) { + throw new Error("Invalid slot ID"); + } + } + + // Validate products if being updated + if (updates.productIds) { + const products = await db.query.productInfo.findMany({ + where: inArray(productInfo.id, updates.productIds), + }); + if (products.length !== updates.productIds.length) { + throw new Error("One or more invalid product IDs"); + } + } + + // Check snippet code uniqueness if being updated + if (updates.snippetCode && updates.snippetCode !== existingSnippet.snippetCode) { + const duplicateSnippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, updates.snippetCode), + }); + if (duplicateSnippet) { + throw new Error("Snippet code already exists"); + } + } + + const updateData: any = { ...updates }; + if (updates.validTill !== undefined) { + updateData.validTill = updates.validTill ? new Date(updates.validTill) : null; + } + + const result = await db.update(vendorSnippets) + .set(updateData) + .where(eq(vendorSnippets.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Failed to update vendor snippet"); + } + + return result[0]; + }), + + delete: protectedProcedure + .input(z.object({ id: z.number().int().positive() })) + .mutation(async ({ input }) => { + const { id } = input; + + const result = await db.delete(vendorSnippets) + .where(eq(vendorSnippets.id, id)) + .returning(); + + if (result.length === 0) { + throw new Error("Vendor snippet not found"); + } + + return { message: "Vendor snippet deleted successfully" }; + }), + + getOrdersBySnippet: publicProcedure + .input(z.object({ + snippetCode: z.string().min(1, "Snippet code is required") + })) + .query(async ({ input }) => { + const { snippetCode } = input; + + // Find the snippet + const snippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }); + + if (!snippet) { + throw new Error("Vendor snippet not found"); + } + + // Check if snippet is still valid + if (snippet.validTill && new Date(snippet.validTill) < new Date()) { + throw new Error("Vendor snippet has expired"); + } + + // Query orders that match the snippet criteria + const matchingOrders = await db.query.orders.findMany({ + where: eq(orders.slotId, snippet.slotId!), + with: { + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + user: true, + slot: true, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + }); + + // Filter orders that contain at least one of the snippet's products + const filteredOrders = matchingOrders.filter(order => { + const status = order.orderStatus; + if (status[0].isCancelled) return false; + const orderProductIds = order.orderItems.map(item => item.productId); + return snippet.productIds.some(productId => orderProductIds.includes(productId)); + }); + + // Format the response + const formattedOrders = filteredOrders.map(order => { + // Filter orderItems to only include products attached to the snippet + const attachedOrderItems = order.orderItems.filter(item => + snippet.productIds.includes(item.productId) + ); + + const products = attachedOrderItems.map(item => ({ + orderItemId: item.id, + productId: item.productId, + productName: item.product.name, + quantity: parseFloat(item.quantity), + productSize: item.product.productQuantity, + price: parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || 'unit', + subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity), + is_packaged: item.is_packaged, + is_package_verified: item.is_package_verified, + })); + + const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0); + + return { + orderId: `ORD${order.id}`, + orderDate: order.createdAt.toISOString(), + customerName: order.user.name, + totalAmount: orderTotal, + slotInfo: order.slot ? { + time: order.slot.deliveryTime.toISOString(), + sequence: order.slot.deliverySequence, + } : null, + products, + matchedProducts: snippet.productIds, // All snippet products are considered matched + snippetCode: snippet.snippetCode, + }; + }); + + return { + success: true, + data: formattedOrders, + snippet: { + id: snippet.id, + snippetCode: snippet.snippetCode, + slotId: snippet.slotId, + productIds: snippet.productIds, + validTill: snippet.validTill?.toISOString(), + createdAt: snippet.createdAt.toISOString(), + isPermanent: snippet.isPermanent, + }, + }; + }), + + getVendorOrders: protectedProcedure + .query(async () => { + const vendorOrders = await db.query.orders.findMany({ + with: { + user: true, + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + }); + + return vendorOrders.map(order => ({ + id: order.id, + status: 'pending', // Default status since orders table may not have status field + orderDate: order.createdAt.toISOString(), + totalQuantity: order.orderItems.reduce((sum, item) => sum + parseFloat(item.quantity || '0'), 0), + products: order.orderItems.map(item => ({ + name: item.product.name, + quantity: parseFloat(item.quantity || '0'), + unit: item.product.unit?.shortNotation || 'unit', + })), + })); + }), + + getUpcomingSlots: publicProcedure + .query(async () => { + const threeHoursAgo = dayjs().subtract(3, 'hour').toDate(); + const slots = await db.query.deliverySlotInfo.findMany({ + where: and( + eq(deliverySlotInfo.isActive, true), + gt(deliverySlotInfo.deliveryTime, threeHoursAgo) + ), + orderBy: asc(deliverySlotInfo.deliveryTime), + }); + + return { + success: true, + data: slots.map(slot => ({ + id: slot.id, + deliveryTime: slot.deliveryTime.toISOString(), + freezeTime: slot.freezeTime.toISOString(), + deliverySequence: slot.deliverySequence, + })), + }; + }), + + getOrdersBySnippetAndSlot: publicProcedure + .input(z.object({ + snippetCode: z.string().min(1, "Snippet code is required"), + slotId: z.number().int().positive("Valid slot ID is required"), + })) + .query(async ({ input }) => { + const { snippetCode, slotId } = input; + + // Find the snippet + const snippet = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.snippetCode, snippetCode), + }); + + if (!snippet) { + throw new Error("Vendor snippet not found"); + } + + // Find the slot + const slot = await db.query.deliverySlotInfo.findFirst({ + where: eq(deliverySlotInfo.id, slotId), + }); + + if (!slot) { + throw new Error("Slot not found"); + } + + // Query orders that match the slot and snippet criteria + const matchingOrders = await db.query.orders.findMany({ + where: eq(orders.slotId, slotId), + with: { + orderItems: { + with: { + product: { + with: { + unit: true, + }, + }, + }, + }, + orderStatus: true, + user: true, + slot: true, + }, + orderBy: (orders, { desc }) => [desc(orders.createdAt)], + }); + + // Filter orders that contain at least one of the snippet's products + const filteredOrders = matchingOrders.filter(order => { + const status = order.orderStatus; + if (status[0]?.isCancelled) return false; + const orderProductIds = order.orderItems.map(item => item.productId); + return snippet.productIds.some(productId => orderProductIds.includes(productId)); + }); + + // Format the response + const formattedOrders = filteredOrders.map(order => { + // Filter orderItems to only include products attached to the snippet + const attachedOrderItems = order.orderItems.filter(item => + snippet.productIds.includes(item.productId) + ); + + const products = attachedOrderItems.map(item => ({ + orderItemId: item.id, + productId: item.productId, + productName: item.product.name, + quantity: parseFloat(item.quantity), + price: parseFloat(item.price.toString()), + unit: item.product.unit?.shortNotation || 'unit', + subtotal: parseFloat(item.price.toString()) * parseFloat(item.quantity), + productSize: item.product.productQuantity, + is_packaged: item.is_packaged, + is_package_verified: item.is_package_verified, + })); + + const orderTotal = products.reduce((sum, p) => sum + p.subtotal, 0); + + return { + orderId: `ORD${order.id}`, + orderDate: order.createdAt.toISOString(), + customerName: order.user.name, + totalAmount: orderTotal, + slotInfo: order.slot ? { + time: order.slot.deliveryTime.toISOString(), + sequence: order.slot.deliverySequence, + } : null, + products, + matchedProducts: snippet.productIds, + snippetCode: snippet.snippetCode, + }; + }); + + return { + success: true, + data: formattedOrders, + snippet: { + id: snippet.id, + snippetCode: snippet.snippetCode, + slotId: snippet.slotId, + productIds: snippet.productIds, + validTill: snippet.validTill?.toISOString(), + createdAt: snippet.createdAt.toISOString(), + isPermanent: snippet.isPermanent, + }, + selectedSlot: { + id: slot.id, + deliveryTime: slot.deliveryTime.toISOString(), + freezeTime: slot.freezeTime.toISOString(), + deliverySequence: slot.deliverySequence, + }, + }; + }), + + updateOrderItemPackaging: publicProcedure + .input(z.object({ + orderItemId: z.number().int().positive("Valid order item ID required"), + is_packaged: z.boolean() + })) + .mutation(async ({ input, ctx }) => { + const { orderItemId, is_packaged } = input; + + // Get staff user ID from auth middleware + // const staffUserId = ctx.staffUser?.id; + // if (!staffUserId) { + // throw new Error("Unauthorized"); + // } + + // Check if order item exists and get related data + const orderItem = await db.query.orderItems.findFirst({ + where: eq(orderItems.id, orderItemId), + with: { + order: { + with: { + slot: true + } + } + } + }); + + if (!orderItem) { + throw new Error("Order item not found"); + } + + // Check if this order item belongs to a slot that has vendor snippets + // This ensures only order items from vendor-accessible orders can be updated + if (!orderItem.order.slotId) { + throw new Error("Order item not associated with a vendor slot"); + } + + const snippetExists = await db.query.vendorSnippets.findFirst({ + where: eq(vendorSnippets.slotId, orderItem.order.slotId), + }); + + if (!snippetExists) { + throw new Error("No vendor snippet found for this order's slot"); + } + + // Update the is_packaged field + const result = await db.update(orderItems) + .set({ is_packaged }) + .where(eq(orderItems.id, orderItemId)) + .returning(); + + if (result.length === 0) { + throw new Error("Failed to update packaging status"); + } + + return { + success: true, + orderItemId, + is_packaged + }; + }), +}); \ No newline at end of file diff --git a/verifier/dataAccessors/demo.txt b/verifier/dataAccessors/demo.txt new file mode 100644 index 0000000..e69de29 diff --git a/verifier/user-apis/apis/address.ts b/verifier/user-apis/apis/address.ts new file mode 100644 index 0000000..0022c7e --- /dev/null +++ b/verifier/user-apis/apis/address.ts @@ -0,0 +1,194 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { addresses, orders, orderStatus, deliverySlotInfo } from '@/src/db/schema'; +import { eq, and, gte } from 'drizzle-orm'; +import dayjs from 'dayjs'; +import { extractCoordsFromRedirectUrl } from '@/src/lib/license-util'; + +export const addressRouter = router({ + getDefaultAddress: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + const [defaultAddress] = await db + .select() + .from(addresses) + .where(and(eq(addresses.userId, userId), eq(addresses.isDefault, true))) + .limit(1); + + return { success: true, data: defaultAddress || null }; + }), + + getUserAddresses: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + const userAddresses = await db.select().from(addresses).where(eq(addresses.userId, userId)); + return { success: true, data: userAddresses }; + }), + + createAddress: protectedProcedure + .input(z.object({ + name: z.string().min(1, 'Name is required'), + phone: z.string().min(1, 'Phone is required'), + addressLine1: z.string().min(1, 'Address line 1 is required'), + addressLine2: z.string().optional(), + city: z.string().min(1, 'City is required'), + state: z.string().min(1, 'State is required'), + pincode: z.string().min(1, 'Pincode is required'), + isDefault: z.boolean().optional(), + latitude: z.number().optional(), + longitude: z.number().optional(), + googleMapsUrl: z.string().optional(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input; + + let { latitude, longitude } = input; + + if (googleMapsUrl && latitude === undefined && longitude === undefined) { + const coords = await extractCoordsFromRedirectUrl(googleMapsUrl); + if (coords) { + latitude = Number(coords.latitude); + longitude = Number(coords.longitude); + } + } + + // Validate required fields + if (!name || !phone || !addressLine1 || !city || !state || !pincode) { + throw new Error('Missing required fields'); + } + + // If setting as default, unset other defaults + if (isDefault) { + await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId)); + } + + const [newAddress] = await db.insert(addresses).values({ + userId, + name, + phone, + addressLine1, + addressLine2, + city, + state, + pincode, + isDefault: isDefault || false, + latitude, + longitude, + googleMapsUrl, + }).returning(); + + return { success: true, data: newAddress }; + }), + + updateAddress: protectedProcedure + .input(z.object({ + id: z.number().int().positive(), + name: z.string().min(1, 'Name is required'), + phone: z.string().min(1, 'Phone is required'), + addressLine1: z.string().min(1, 'Address line 1 is required'), + addressLine2: z.string().optional(), + city: z.string().min(1, 'City is required'), + state: z.string().min(1, 'State is required'), + pincode: z.string().min(1, 'Pincode is required'), + isDefault: z.boolean().optional(), + latitude: z.number().optional(), + longitude: z.number().optional(), + googleMapsUrl: z.string().optional(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { id, name, phone, addressLine1, addressLine2, city, state, pincode, isDefault, googleMapsUrl } = input; + + let { latitude, longitude } = input; + + if (googleMapsUrl && latitude === undefined && longitude === undefined) { + const coords = await extractCoordsFromRedirectUrl(googleMapsUrl); + if (coords) { + latitude = Number(coords.latitude); + longitude = Number(coords.longitude); + } + } + + // Check if address exists and belongs to user + const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1); + if (existingAddress.length === 0) { + throw new Error('Address not found'); + } + + // If setting as default, unset other defaults + if (isDefault) { + await db.update(addresses).set({ isDefault: false }).where(eq(addresses.userId, userId)); + } + + const updateData: any = { + name, + phone, + addressLine1, + addressLine2, + city, + state, + pincode, + isDefault: isDefault || false, + googleMapsUrl, + }; + + if (latitude !== undefined) { + updateData.latitude = latitude; + } + if (longitude !== undefined) { + updateData.longitude = longitude; + } + + const [updatedAddress] = await db.update(addresses).set(updateData).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).returning(); + + return { success: true, data: updatedAddress }; + }), + + deleteAddress: protectedProcedure + .input(z.object({ + id: z.number().int().positive(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { id } = input; + + // Check if address exists and belongs to user + const existingAddress = await db.select().from(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))).limit(1); + if (existingAddress.length === 0) { + throw new Error('Address not found or does not belong to user'); + } + + // Check if address is attached to any ongoing orders using joins + const ongoingOrders = await db.select({ + order: orders, + status: orderStatus, + slot: deliverySlotInfo + }) + .from(orders) + .innerJoin(orderStatus, eq(orders.id, orderStatus.orderId)) + .innerJoin(deliverySlotInfo, eq(orders.slotId, deliverySlotInfo.id)) + .where(and( + eq(orders.addressId, id), + eq(orderStatus.isCancelled, false), + gte(deliverySlotInfo.deliveryTime, new Date()) + )) + .limit(1); + + if (ongoingOrders.length > 0) { + throw new Error('Address is attached to an ongoing order. Please cancel the order first.'); + } + + // Prevent deletion of default address + if (existingAddress[0].isDefault) { + throw new Error('Cannot delete default address. Please set another address as default first.'); + } + + // Delete the address + await db.delete(addresses).where(and(eq(addresses.id, id), eq(addresses.userId, userId))); + + return { success: true, message: 'Address deleted successfully' }; + }), +}); diff --git a/verifier/user-apis/apis/auth.ts b/verifier/user-apis/apis/auth.ts new file mode 100644 index 0000000..986f8e8 --- /dev/null +++ b/verifier/user-apis/apis/auth.ts @@ -0,0 +1,581 @@ +import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { eq } from 'drizzle-orm'; +import { db } from '@/src/db/db_index'; +import { + users, userCreds, userDetails, addresses, cartItems, complaints, + couponApplicableUsers, couponUsage, notifCreds, notifications, + orderItems, orderStatus, orders, payments, refunds, + productReviews, reservedCoupons +} from '@/src/db/schema'; +import { generateSignedUrlFromS3Url, claimUploadUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'; +import { deleteS3Image } from '@/src/lib/delete-image'; +import { ApiError } from '@/src/lib/api-error'; +import catchAsync from '@/src/lib/catch-async'; +import { sendOtp, verifyOtpUtil, getOtpCreds } from '@/src/lib/otp-utils'; +import { signToken } from '@/src/lib/jwt-utils'; + +interface LoginRequest { + identifier: string; // email or mobile + password: string; +} + +interface RegisterRequest { + name: string; + email: string; + mobile: string; + password: string; +} + +interface AuthResponse { + token: string; + user: { + id: number; + name?: string | null; + email: string | null; + mobile: string | null; + createdAt: string; + profileImage: string | null; + bio?: string | null; + dateOfBirth?: string | null; + gender?: string | null; + occupation?: string | null; + }; +} + +const generateToken = async (userId: number): Promise => { + return signToken({ userId }); +}; + + + +export const authRouter = router({ + login: publicProcedure + .input(z.object({ + identifier: z.string().min(1, 'Email/mobile is required'), + password: z.string().min(1, 'Password is required'), + })) + .mutation(async ({ input }) => { + const { identifier, password }: LoginRequest = input; + + if (!identifier || !password) { + throw new ApiError('Email/mobile and password are required', 400); + } + + // Find user by email or mobile + const [user] = await db + .select() + .from(users) + .where(eq(users.email, identifier.toLowerCase())) + .limit(1); + + let foundUser = user; + + if (!foundUser) { + // Try mobile if email didn't work + const [userByMobile] = await db + .select() + .from(users) + .where(eq(users.mobile, identifier)) + .limit(1); + foundUser = userByMobile; + } + + if (!foundUser) { + throw new ApiError('Invalid credentials', 401); + } + + // Get user credentials + const [userCredentials] = await db + .select() + .from(userCreds) + .where(eq(userCreds.userId, foundUser.id)) + .limit(1); + + if (!userCredentials) { + throw new ApiError('Account setup incomplete. Please contact support.', 401); + } + + // Get user details for profile image + const [userDetail] = await db + .select() + .from(userDetails) + .where(eq(userDetails.userId, foundUser.id)) + .limit(1); + + // Generate signed URL for profile image if it exists + const profileImageSignedUrl = userDetail?.profileImage + ? await generateSignedUrlFromS3Url(userDetail.profileImage) + : null; + + // Verify password + const isPasswordValid = await bcrypt.compare(password, userCredentials.userPassword); + if (!isPasswordValid) { + throw new ApiError('Invalid credentials', 401); + } + + const token = await generateToken(foundUser.id); + + const response: AuthResponse = { + token, + user: { + id: foundUser.id, + name: foundUser.name, + email: foundUser.email, + mobile: foundUser.mobile, + createdAt: foundUser.createdAt.toISOString(), + profileImage: profileImageSignedUrl, + bio: userDetail?.bio || null, + dateOfBirth: userDetail?.dateOfBirth || null, + gender: userDetail?.gender || null, + occupation: userDetail?.occupation || null, + }, + }; + + return { + success: true, + data: response, + }; + }), + + register: publicProcedure + .input(z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email format'), + mobile: z.string().min(1, 'Mobile is required'), + password: z.string().min(1, 'Password is required'), + imageKey: z.string().optional(), + })) + .mutation(async ({ input }) => { + const { name, email, mobile, password, imageKey } = input; + + if (!name || !email || !mobile || !password) { + throw new ApiError('All fields are required', 400); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + throw new ApiError('Invalid email format', 400); + } + + // Validate mobile format (Indian mobile numbers) + const cleanMobile = mobile.replace(/\D/g, ''); + if (cleanMobile.length !== 10 || !/^[6-9]/.test(cleanMobile)) { + throw new ApiError('Invalid mobile number', 400); + } + + // Check if email already exists + const [existingEmail] = await db + .select() + .from(users) + .where(eq(users.email, email.toLowerCase())) + .limit(1); + + if (existingEmail) { + throw new ApiError('Email already registered', 409); + } + + // Check if mobile already exists + const [existingMobile] = await db + .select() + .from(users) + .where(eq(users.mobile, cleanMobile)) + .limit(1); + + if (existingMobile) { + throw new ApiError('Mobile number already registered', 409); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 12); + + // Create user and credentials in a transaction + const newUser = await db.transaction(async (tx) => { + // Create user + const [user] = await tx + .insert(users) + .values({ + name: name.trim(), + email: email.toLowerCase().trim(), + mobile: cleanMobile, + }) + .returning(); + + // Create user credentials + await tx + .insert(userCreds) + .values({ + userId: user.id, + userPassword: hashedPassword, + }); + + // Create user details with profile image if provided + if (imageKey) { + await tx.insert(userDetails).values({ + userId: user.id, + profileImage: imageKey, + }); + } + + return user; + }); + + // Claim upload URL if image was provided + if (imageKey) { + try { + await claimUploadUrl(imageKey); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${imageKey}`, e); + } + } + + const token = await generateToken(newUser.id); + + // Get user details for profile image + const [userDetail] = await db + .select() + .from(userDetails) + .where(eq(userDetails.userId, newUser.id)) + .limit(1); + + const profileImageUrl = userDetail?.profileImage + ? scaffoldAssetUrl(userDetail.profileImage) + : null; + + const response: AuthResponse = { + token, + user: { + id: newUser.id, + name: newUser.name, + email: newUser.email, + mobile: newUser.mobile, + createdAt: newUser.createdAt.toISOString(), + profileImage: profileImageUrl, + }, + }; + + return { + success: true, + data: response, + }; + }), + + sendOtp: publicProcedure + .input(z.object({ + mobile: z.string(), + })) + .mutation(async ({ input }) => { + + return await sendOtp(input.mobile); + }), + + verifyOtp: publicProcedure + .input(z.object({ + mobile: z.string(), + otp: z.string(), + })) + .mutation(async ({ input }) => { + const verificationId = getOtpCreds(input.mobile); + if (!verificationId) { + throw new ApiError("OTP not sent or expired", 400); + } + const isVerified = await verifyOtpUtil(input.mobile, input.otp, verificationId); + + if (!isVerified) { + throw new ApiError("Invalid OTP", 400); + } + + // Find user + let user = await db.query.users.findFirst({ + where: eq(users.mobile, input.mobile), + }); + + // If user doesn't exist, create one + if (!user) { + const [newUser] = await db + .insert(users) + .values({ + name: null, + email: null, + mobile: input.mobile, + }) + .returning(); + user = newUser; + } + + // Generate JWT + const token = await generateToken(user.id); + + return { + success: true, + token, + user: { + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + createdAt: user.createdAt.toISOString(), + profileImage: null, + }, + }; + }), + + updatePassword: protectedProcedure + .input(z.object({ + password: z.string().min(6, 'Password must be at least 6 characters'), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + const hashedPassword = await bcrypt.hash(input.password, 10); + + // Insert if not exists, then update if exists + try { + await db.insert(userCreds).values({ + userId: userId, + userPassword: hashedPassword, + }); + // Insert succeeded - new credentials created + } catch (error: any) { + // Insert failed - check if it's a unique constraint violation + if (error.code === '23505') { // PostgreSQL unique constraint violation + // Update existing credentials + await db.update(userCreds).set({ + userPassword: hashedPassword, + }).where(eq(userCreds.userId, userId)); + } else { + // Re-throw if it's a different error + throw error; + } + } + + return { success: true, message: 'Password updated successfully' }; + }), + + getProfile: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) { + throw new ApiError('User not found', 404); + } + + // Get user details for profile image + const [userDetail] = await db + .select() + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1); + + const profileImageUrl = userDetail?.profileImage + ? scaffoldAssetUrl(userDetail.profileImage) + : null; + + return { + success: true, + data: { + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + profileImage: profileImageUrl, + bio: userDetail?.bio || null, + dateOfBirth: userDetail?.dateOfBirth || null, + gender: userDetail?.gender || null, + occupation: userDetail?.occupation || null, + }, + }; + }), + + updateProfile: protectedProcedure + .input(z.object({ + name: z.string().min(1, 'Name is required').optional(), + email: z.string().email('Invalid email format').optional(), + bio: z.string().optional(), + dateOfBirth: z.string().optional(), + gender: z.string().optional(), + occupation: z.string().optional(), + imageKey: z.string().optional(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { imageKey, ...updateData } = input; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + // Get current user details + const currentDetail = await db.query.userDetails.findFirst({ + where: eq(userDetails.userId, userId), + }); + + let newImageUrl: string | null | undefined = currentDetail?.profileImage; + + // Handle new image upload (only if different from existing) + if (imageKey && imageKey !== currentDetail?.profileImage) { + // Delete old image if exists + if (currentDetail?.profileImage) { + try { + await deleteS3Image(currentDetail.profileImage); + } catch (e) { + console.error(`Failed to delete old image: ${currentDetail.profileImage}`, e); + } + } + newImageUrl = imageKey; + + // Claim upload URL + try { + await claimUploadUrl(imageKey); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${imageKey}`, e); + } + } + + // Update user name if provided + if (updateData.name) { + await db.update(users) + .set({ name: updateData.name.trim() }) + .where(eq(users.id, userId)); + } + + // Update user email if provided + if (updateData.email) { + // Check if email already exists (but belongs to different user) + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.email, updateData.email.toLowerCase().trim())) + .limit(1); + + if (existingUser && existingUser.id !== userId) { + throw new ApiError('Email already in use by another account', 409); + } + + await db.update(users) + .set({ email: updateData.email.toLowerCase().trim() }) + .where(eq(users.id, userId)); + } + + // Upsert user details + if (currentDetail) { + // Update existing + await db.update(userDetails) + .set({ + ...updateData, + profileImage: newImageUrl, + }) + .where(eq(userDetails.userId, userId)); + } else { + // Insert new + await db.insert(userDetails).values({ + userId: userId, + ...updateData, + profileImage: newImageUrl, + }); + } + + return { + success: true, + message: 'Profile updated successfully', + }; + }), + + deleteAccount: protectedProcedure + .input(z.object({ + mobile: z.string().min(10, 'Mobile number is required'), + })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user.userId; + const { mobile } = input; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + // Double-check: verify user exists and is the authenticated user + const existingUser = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { id: true, mobile: true }, + }); + + if (!existingUser) { + throw new ApiError('User not found', 404); + } + + // Additional verification: ensure we're not deleting someone else's data + // The JWT token should already ensure this, but double-checking + if (existingUser.id !== userId) { + throw new ApiError('Unauthorized: Cannot delete another user\'s account', 403); + } + + // Verify mobile number matches user's registered mobile + const cleanInputMobile = mobile.replace(/\D/g, ''); + const cleanUserMobile = existingUser.mobile?.replace(/\D/g, ''); + + if (cleanInputMobile !== cleanUserMobile) { + throw new ApiError('Mobile number does not match your registered number', 400); + } + + // Use transaction for atomic deletion + await db.transaction(async (tx) => { + // Phase 1: Direct references (safe to delete first) + await tx.delete(notifCreds).where(eq(notifCreds.userId, userId)); + await tx.delete(couponApplicableUsers).where(eq(couponApplicableUsers.userId, userId)); + await tx.delete(couponUsage).where(eq(couponUsage.userId, userId)); + await tx.delete(complaints).where(eq(complaints.userId, userId)); + await tx.delete(cartItems).where(eq(cartItems.userId, userId)); + await tx.delete(notifications).where(eq(notifications.userId, userId)); + await tx.delete(productReviews).where(eq(productReviews.userId, userId)); + + // Update reserved coupons (set redeemedBy to null) + await tx.update(reservedCoupons) + .set({ redeemedBy: null }) + .where(eq(reservedCoupons.redeemedBy, userId)); + + // Phase 2: Order dependencies + const userOrders = await tx + .select({ id: orders.id }) + .from(orders) + .where(eq(orders.userId, userId)); + + for (const order of userOrders) { + await tx.delete(orderItems).where(eq(orderItems.orderId, order.id)); + await tx.delete(orderStatus).where(eq(orderStatus.orderId, order.id)); + await tx.delete(payments).where(eq(payments.orderId, order.id)); + await tx.delete(refunds).where(eq(refunds.orderId, order.id)); + // Additional coupon usage entries linked to specific orders + await tx.delete(couponUsage).where(eq(couponUsage.orderId, order.id)); + await tx.delete(complaints).where(eq(complaints.orderId, order.id)); + } + + // Delete orders + await tx.delete(orders).where(eq(orders.userId, userId)); + + // Phase 3: Addresses (now safe since orders are deleted) + await tx.delete(addresses).where(eq(addresses.userId, userId)); + + // Phase 4: Core user data + await tx.delete(userDetails).where(eq(userDetails.userId, userId)); + await tx.delete(userCreds).where(eq(userCreds.userId, userId)); + await tx.delete(users).where(eq(users.id, userId)); + }); + + return { success: true, message: 'Account deleted successfully' }; + }), +}); diff --git a/verifier/user-apis/apis/banners.ts b/verifier/user-apis/apis/banners.ts new file mode 100644 index 0000000..8e6a001 --- /dev/null +++ b/verifier/user-apis/apis/banners.ts @@ -0,0 +1,30 @@ +import { db } from '@/src/db/db_index'; +import { homeBanners } from '@/src/db/schema'; +import { publicProcedure, router } from '@/src/trpc/trpc-index'; +import { scaffoldAssetUrl } from '@/src/lib/s3-client'; +import { isNotNull, asc } from 'drizzle-orm'; + +export async function scaffoldBanners() { + const banners = await db.query.homeBanners.findMany({ + where: isNotNull(homeBanners.serialNum), // Only show assigned banners + orderBy: asc(homeBanners.serialNum), // Order by slot number 1-4 + }); + + // Convert S3 keys to signed URLs for client + const bannersWithSignedUrls = banners.map((banner) => ({ + ...banner, + imageUrl: banner.imageUrl ? scaffoldAssetUrl(banner.imageUrl) : banner.imageUrl, + })); + + return { + banners: bannersWithSignedUrls, + }; +} + +export const bannerRouter = router({ + getBanners: publicProcedure + .query(async () => { + const response = await scaffoldBanners(); + return response; + }), +}); diff --git a/verifier/user-apis/apis/cart.ts b/verifier/user-apis/apis/cart.ts new file mode 100644 index 0000000..a2495bc --- /dev/null +++ b/verifier/user-apis/apis/cart.ts @@ -0,0 +1,244 @@ +import { router, protectedProcedure, publicProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { cartItems, productInfo, units, productSlots, deliverySlotInfo } from '@/src/db/schema'; +import { eq, and, sql, inArray, gt } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error'; +import { generateSignedUrlsFromS3Urls, scaffoldAssetUrl } from '@/src/lib/s3-client'; +import { getProductSlots, getMultipleProductsSlots } from '@/src/stores/slot-store'; + +interface CartResponse { + items: any[]; + totalItems: number; + totalAmount: number; +} + +const getCartData = async (userId: number): Promise => { + const cartItemsWithProducts = await db + .select({ + cartId: cartItems.id, + productId: productInfo.id, + productName: productInfo.name, + productPrice: productInfo.price, + productImages: productInfo.images, + productQuantity: productInfo.productQuantity, + isOutOfStock: productInfo.isOutOfStock, + unitShortNotation: units.shortNotation, + quantity: cartItems.quantity, + addedAt: cartItems.addedAt, + }) + .from(cartItems) + .innerJoin(productInfo, eq(cartItems.productId, productInfo.id)) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(eq(cartItems.userId, userId)); + + // Generate signed URLs for images + const cartWithSignedUrls = await Promise.all( + cartItemsWithProducts.map(async (item) => ({ + id: item.cartId, + productId: item.productId, + quantity: parseFloat(item.quantity), + addedAt: item.addedAt, + product: { + id: item.productId, + name: item.productName, + price: item.productPrice, + productQuantity: item.productQuantity, + unit: item.unitShortNotation, + isOutOfStock: item.isOutOfStock, + images: scaffoldAssetUrl((item.productImages as string[]) || []), + }, + subtotal: parseFloat(item.productPrice.toString()) * parseFloat(item.quantity), + })) + ); + + const totalAmount = cartWithSignedUrls.reduce((sum, item) => sum + item.subtotal, 0); + + return { + items: cartWithSignedUrls, + totalItems: cartWithSignedUrls.length, + totalAmount, + }; +}; + +export const cartRouter = router({ + getCart: protectedProcedure + .query(async ({ ctx }): Promise => { + const userId = ctx.user.userId; + return await getCartData(userId); + }), + + addToCart: protectedProcedure + .input(z.object({ + productId: z.number().int().positive(), + quantity: z.number().int().positive(), + })) + .mutation(async ({ input, ctx }): Promise => { + const userId = ctx.user.userId; + const { productId, quantity } = input; + + // Validate input + if (!productId || !quantity || quantity <= 0) { + throw new ApiError("Product ID and positive quantity required", 400); + } + + // 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); + } + + // Check if item already exists in cart + const existingItem = await db.query.cartItems.findFirst({ + where: and(eq(cartItems.userId, userId), eq(cartItems.productId, productId)), + }); + + if (existingItem) { + // Update quantity + await db.update(cartItems) + .set({ + quantity: sql`${cartItems.quantity} + ${quantity}`, + }) + .where(eq(cartItems.id, existingItem.id)); + } else { + // Insert new item + await db.insert(cartItems).values({ + userId, + productId, + quantity: quantity.toString(), + }); + } + + // Return updated cart + return await getCartData(userId); + }), + + updateCartItem: protectedProcedure + .input(z.object({ + itemId: z.number().int().positive(), + quantity: z.number().int().min(0), + })) + .mutation(async ({ input, ctx }): Promise => { + const userId = ctx.user.userId; + const { itemId, quantity } = input; + + if (!quantity || quantity <= 0) { + throw new ApiError("Positive quantity required", 400); + } + + const [updatedItem] = await db.update(cartItems) + .set({ quantity: quantity.toString() }) + .where(and( + eq(cartItems.id, itemId), + eq(cartItems.userId, userId) + )) + .returning(); + + if (!updatedItem) { + throw new ApiError("Cart item not found", 404); + } + + // Return updated cart + return await getCartData(userId); + }), + + removeFromCart: protectedProcedure + .input(z.object({ + itemId: z.number().int().positive(), + })) + .mutation(async ({ input, ctx }): Promise => { + const userId = ctx.user.userId; + const { itemId } = input; + + const [deletedItem] = await db.delete(cartItems) + .where(and( + eq(cartItems.id, itemId), + eq(cartItems.userId, userId) + )) + .returning(); + + if (!deletedItem) { + throw new ApiError("Cart item not found", 404); + } + + // Return updated cart + return await getCartData(userId); + }), + + clearCart: protectedProcedure + .mutation(async ({ ctx }) => { + const userId = ctx.user.userId; + + await db.delete(cartItems).where(eq(cartItems.userId, userId)); + + return { + items: [], + totalItems: 0, + totalAmount: 0, + message: "Cart cleared successfully", + }; + }), + + // Original DB-based getCartSlots (commented out) + // getCartSlots: publicProcedure + // .input(z.object({ + // productIds: z.array(z.number().int().positive()) + // })) + // .query(async ({ input }) => { + // const { productIds } = input; + // + // if (productIds.length === 0) { + // return {}; + // } + // + // // Get slots for these products where freeze time is after current time + // const slotsData = await db + // .select({ + // productId: productSlots.productId, + // slotId: deliverySlotInfo.id, + // deliveryTime: deliverySlotInfo.deliveryTime, + // freezeTime: deliverySlotInfo.freezeTime, + // isActive: deliverySlotInfo.isActive, + // }) + // .from(productSlots) + // .innerJoin(deliverySlotInfo, eq(productSlots.slotId, deliverySlotInfo.id)) + // .where(and( + // inArray(productSlots.productId, productIds), + // gt(deliverySlotInfo.freezeTime, sql`NOW()`), + // eq(deliverySlotInfo.isActive, true) + // )); + // + // // Group by productId + // const result: Record = {}; + // slotsData.forEach(slot => { + // if (!result[slot.productId]) { + // result[slot.productId] = []; + // } + // result[slot.productId].push({ + // id: slot.slotId, + // deliveryTime: slot.deliveryTime, + // freezeTime: slot.freezeTime, + // }); + // }); + // + // return result; + // }), + + // Cache-based getCartSlots + getCartSlots: publicProcedure + .input(z.object({ + productIds: z.array(z.number().int().positive()) + })) + .query(async ({ input }) => { + const { productIds } = input; + + if (productIds.length === 0) { + return {}; + } + + return await getMultipleProductsSlots(productIds); + }), +}); diff --git a/verifier/user-apis/apis/complaint.ts b/verifier/user-apis/apis/complaint.ts new file mode 100644 index 0000000..8006f13 --- /dev/null +++ b/verifier/user-apis/apis/complaint.ts @@ -0,0 +1,70 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { complaints } from '@/src/db/schema'; +import { eq } from 'drizzle-orm'; +import { scaffoldAssetUrl, claimUploadUrl } from '@/src/lib/s3-client'; + +export const complaintRouter = router({ + getAll: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + const userComplaints = await db + .select({ + id: complaints.id, + complaintBody: complaints.complaintBody, + response: complaints.response, + isResolved: complaints.isResolved, + createdAt: complaints.createdAt, + orderId: complaints.orderId, + images: complaints.images, + }) + .from(complaints) + .where(eq(complaints.userId, userId)) + .orderBy(complaints.createdAt); + + return { + complaints: userComplaints.map(c => ({ + id: c.id, + complaintBody: c.complaintBody, + response: c.response, + isResolved: c.isResolved, + createdAt: c.createdAt, + orderId: c.orderId, + images: c.images ? scaffoldAssetUrl(c.images as string[]) : [], + })), + }; + }), + + raise: protectedProcedure + .input(z.object({ + orderId: z.number().optional(), + complaintBody: z.string().min(1, 'Complaint body is required'), + imageKeys: z.array(z.string()).optional(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { orderId, complaintBody, imageKeys } = input; + + await db.insert(complaints).values({ + userId, + orderId: orderId || null, + complaintBody: complaintBody.trim(), + images: imageKeys || [], + }); + + // Claim upload URLs for images + if (imageKeys && imageKeys.length > 0) { + for (const key of imageKeys) { + try { + await claimUploadUrl(key); + } catch (e) { + console.warn(`Failed to claim upload URL for key: ${key}`, e); + } + } + } + + return { success: true, message: 'Complaint raised successfully' }; + }), +}); diff --git a/verifier/user-apis/apis/coupon.ts b/verifier/user-apis/apis/coupon.ts new file mode 100644 index 0000000..6eab804 --- /dev/null +++ b/verifier/user-apis/apis/coupon.ts @@ -0,0 +1,296 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { coupons, couponUsage, couponApplicableUsers, reservedCoupons, couponApplicableProducts } from '@/src/db/schema'; +import { eq, and, or, gt, isNull, sql } from 'drizzle-orm'; +import { ApiError } from '@/src/lib/api-error'; + +import { users } from '@/src/db/schema'; + +type CouponWithRelations = typeof coupons.$inferSelect & { + applicableUsers: (typeof couponApplicableUsers.$inferSelect & { user: typeof users.$inferSelect })[]; + usages: typeof couponUsage.$inferSelect[]; +}; + +export interface EligibleCoupon { + id: number; + code: string; + discountType: 'percentage' | 'flat'; + discountValue: number; + maxValue?: number; + minOrder?: number; + description: string; + exclusiveApply?: boolean; + isEligible: boolean; + ineligibilityReason?: string; +} + +const generateCouponDescription = (coupon: any): string => { + let desc = ''; + + if (coupon.discountPercent) { + desc += `${coupon.discountPercent}% off`; + } else if (coupon.flatDiscount) { + desc += `₹${coupon.flatDiscount} off`; + } + + if (coupon.minOrder) { + desc += ` on orders above ₹${coupon.minOrder}`; + } + + if (coupon.maxValue) { + desc += ` (max discount ₹${coupon.maxValue})`; + } + + return desc; +}; + +export interface CouponDisplay { + id: number; + code: string; + discountType: 'percentage' | 'flat'; + discountValue: number; + maxValue?: number; + minOrder?: number; + description: string; + validTill?: Date; + usageCount: number; + maxLimitForUser?: number; + isExpired: boolean; + isUsedUp: boolean; +} + +export const userCouponRouter = router({ + getEligible: protectedProcedure + .query(async ({ ctx }) => { + try { + + const userId = ctx.user.userId; + + // Get all active, non-expired coupons + const allCoupons = await db.query.coupons.findMany({ + where: and( + eq(coupons.isInvalidated, false), + or( + isNull(coupons.validTill), + gt(coupons.validTill, new Date()) + ) + ), + with: { + usages: { + where: eq(couponUsage.userId, userId) + }, + applicableUsers: { + with: { + user: true + } + }, + applicableProducts: { + with: { + product: true + } + }, + } + }); + + // Filter to only coupons applicable to current user + const applicableCoupons = allCoupons.filter(coupon => { + if(!coupon.isUserBased) return true; + const applicableUsers = coupon.applicableUsers || []; + return applicableUsers.some(au => au.userId === userId); + }); + + return { success: true, data: applicableCoupons }; + } + catch(e) { + console.log(e) + throw new ApiError("Unable to get coupons") + } + }), + + getProductCoupons: protectedProcedure + .input(z.object({ productId: z.number().int().positive() })) + .query(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { productId } = input; + + // Get all active, non-expired coupons + const allCoupons = await db.query.coupons.findMany({ + where: and( + eq(coupons.isInvalidated, false), + or( + isNull(coupons.validTill), + gt(coupons.validTill, new Date()) + ) + ), + with: { + usages: { + where: eq(couponUsage.userId, userId) + }, + applicableUsers: { + with: { + user: true + } + }, + applicableProducts: { + with: { + product: true + } + }, + } + }); + + // Filter to only coupons applicable to current user and product + const applicableCoupons = allCoupons.filter(coupon => { + const applicableUsers = coupon.applicableUsers || []; + const userApplicable = !coupon.isUserBased || applicableUsers.some(au => au.userId === userId); + + const applicableProducts = coupon.applicableProducts || []; + const productApplicable = applicableProducts.length === 0 || applicableProducts.some(ap => ap.productId === productId); + + return userApplicable && productApplicable; + }); + + return { success: true, data: applicableCoupons }; + }), + + getMyCoupons: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + // Get all coupons + const allCoupons = await db.query.coupons.findMany({ + with: { + usages: { + where: eq(couponUsage.userId, userId) + }, + applicableUsers: { + with: { + user: true + } + } + } + }); + + // Filter coupons in JS: not invalidated, applicable to user, and not expired + const applicableCoupons = (allCoupons as CouponWithRelations[]).filter(coupon => { + const isNotInvalidated = !coupon.isInvalidated; + const applicableUsers = coupon.applicableUsers || []; + const isApplicable = coupon.isApplyForAll || applicableUsers.some(au => au.userId === userId); + const isNotExpired = !coupon.validTill || new Date(coupon.validTill) > new Date(); + return isNotInvalidated && isApplicable && isNotExpired; + }); + + // Categorize coupons + const personalCoupons: CouponDisplay[] = []; + const generalCoupons: CouponDisplay[] = []; + + applicableCoupons.forEach(coupon => { + const usageCount = coupon.usages.length; + const isExpired = false; // Already filtered out expired coupons + const isUsedUp = Boolean(coupon.maxLimitForUser && usageCount >= coupon.maxLimitForUser); + + const couponDisplay: CouponDisplay = { + id: coupon.id, + code: coupon.couponCode, + discountType: coupon.discountPercent ? 'percentage' : 'flat', + discountValue: parseFloat(coupon.discountPercent || coupon.flatDiscount || '0'), + maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined, + minOrder: coupon.minOrder ? parseFloat(coupon.minOrder) : undefined, + description: generateCouponDescription(coupon), + validTill: coupon.validTill ? new Date(coupon.validTill) : undefined, + usageCount, + maxLimitForUser: coupon.maxLimitForUser ? parseInt(coupon.maxLimitForUser.toString()) : undefined, + isExpired, + isUsedUp, + }; + + if ((coupon.applicableUsers || []).some(au => au.userId === userId) && !coupon.isApplyForAll) { + // Personal coupon + personalCoupons.push(couponDisplay); + } else if (coupon.isApplyForAll) { + // General coupon + generalCoupons.push(couponDisplay); + } + }); + + return { + success: true, + data: { + personal: personalCoupons, + general: generalCoupons, + } + }; + }), + + redeemReservedCoupon: protectedProcedure + .input(z.object({ secretCode: z.string() })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + const { secretCode } = input; + + // Find the reserved coupon + const reservedCoupon = await db.query.reservedCoupons.findFirst({ + where: and( + eq(reservedCoupons.secretCode, secretCode.toUpperCase()), + eq(reservedCoupons.isRedeemed, false) + ), + }); + + if (!reservedCoupon) { + throw new ApiError("Invalid or already redeemed coupon code", 400); + } + + // Check if already redeemed by this user (in case of multiple attempts) + if (reservedCoupon.redeemedBy === userId) { + throw new ApiError("You have already redeemed this coupon", 400); + } + + // Create the coupon in the main table + const couponResult = await db.transaction(async (tx) => { + // Insert into coupons + const couponInsert = await tx.insert(coupons).values({ + couponCode: reservedCoupon.couponCode, + isUserBased: true, + discountPercent: reservedCoupon.discountPercent, + flatDiscount: reservedCoupon.flatDiscount, + minOrder: reservedCoupon.minOrder, + productIds: reservedCoupon.productIds, + maxValue: reservedCoupon.maxValue, + isApplyForAll: false, + validTill: reservedCoupon.validTill, + maxLimitForUser: reservedCoupon.maxLimitForUser, + exclusiveApply: reservedCoupon.exclusiveApply, + createdBy: reservedCoupon.createdBy, + }).returning(); + + const coupon = couponInsert[0]; + + // Insert into couponApplicableUsers + await tx.insert(couponApplicableUsers).values({ + couponId: coupon.id, + userId, + }); + + // Copy applicable products + if (reservedCoupon.productIds && Array.isArray(reservedCoupon.productIds) && reservedCoupon.productIds.length > 0) { + // Assuming productIds are the IDs, but wait, in schema, productIds is jsonb, but in relations, couponApplicableProducts has productId + // For simplicity, since reservedCoupons has productIds as jsonb, but to match, perhaps insert into couponApplicableProducts if needed + // But in createReservedCoupon, I inserted applicableProducts into couponApplicableProducts + // So for reserved, perhaps do the same, but since it's jsonb, maybe not. + // For now, skip, as the coupon will have productIds in coupons table. + } + + // Update reserved coupon as redeemed + await tx.update(reservedCoupons).set({ + isRedeemed: true, + redeemedBy: userId, + redeemedAt: new Date(), + }).where(eq(reservedCoupons.id, reservedCoupon.id)); + + return coupon; + }); + + return { success: true, coupon: couponResult }; + }), +}); diff --git a/verifier/user-apis/apis/file-upload.ts b/verifier/user-apis/apis/file-upload.ts new file mode 100644 index 0000000..bacf1f7 --- /dev/null +++ b/verifier/user-apis/apis/file-upload.ts @@ -0,0 +1,55 @@ +import { router, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { generateUploadUrl } from '@/src/lib/s3-client'; +import { ApiError } from '@/src/lib/api-error'; + +export const fileUploadRouter = router({ + generateUploadUrls: protectedProcedure + .input(z.object({ + contextString: z.enum(['review', 'product_info', 'notification']), + mimeTypes: z.array(z.string()), + })) + .mutation(async ({ input }): Promise<{ uploadUrls: string[] }> => { + const { contextString, mimeTypes } = input; + + const uploadUrls: string[] = []; + const keys: string[] = []; + + for (const mimeType of mimeTypes) { + // Generate key based on context and mime type + let folder: string; + if (contextString === 'review') { + folder = 'review-images'; + } else if(contextString === 'product_info') { + folder = 'product-images'; + } + // else if(contextString === 'review_response') { + // folder = 'review-response-images' + // } + else if(contextString === 'notification') { + folder = 'notification-images' + } else { + folder = ''; + } + + const extension = mimeType === 'image/jpeg' ? '.jpg' : + mimeType === 'image/png' ? '.png' : + mimeType === 'image/gif' ? '.gif' : '.jpg'; + const key = `${folder}/${Date.now()}${extension}`; + + try { + const uploadUrl = await generateUploadUrl(key, mimeType); + uploadUrls.push(uploadUrl); + keys.push(key); + + } catch (error) { + console.error('Error generating upload URL:', error); + throw new ApiError('Failed to generate upload URL', 500); + } + } + + return { uploadUrls }; + }), +}); + +export type FileUploadRouter = typeof fileUploadRouter; diff --git a/verifier/user-apis/apis/order.ts b/verifier/user-apis/apis/order.ts new file mode 100644 index 0000000..08ebabf --- /dev/null +++ b/verifier/user-apis/apis/order.ts @@ -0,0 +1,979 @@ +import { router, protectedProcedure } from "@/src/trpc/trpc-index"; +import { z } from "zod"; +import { db } from "@/src/db/db_index"; +import { + orders, + orderItems, + orderStatus, + addresses, + productInfo, + paymentInfoTable, + coupons, + couponUsage, + cartItems, + refunds, + units, + userDetails, +} from "@/src/db/schema"; +import { eq, and, inArray, desc, gte, lte } from "drizzle-orm"; +import { scaffoldAssetUrl } from "@/src/lib/s3-client"; +import { ApiError } from "@/src/lib/api-error"; +import { + sendOrderPlacedNotification, + sendOrderCancelledNotification, +} from "@/src/lib/notif-job"; +import { getNextDeliveryDate } from "@/src/trpc/apis/common-apis/common"; +import { CONST_KEYS, getConstant, getConstants } from "@/src/lib/const-store"; +import { publishFormattedOrder, publishCancellation } from "@/src/lib/post-order-handler"; +import { getSlotById } from "@/src/stores/slot-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, + 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; + isFlash?: boolean; +}) => { + const { + userId, + selectedItems, + addressId, + paymentMethod, + couponId, + userNotes, + } = params; + + const constants = await getConstants([ + CONST_KEYS.minRegularOrderValue, + CONST_KEYS.deliveryCharge, + CONST_KEYS.flashFreeDeliveryThreshold, + CONST_KEYS.flashDeliveryCharge, + ]); + + const isFlashDelivery = params.isFlash; + const minOrderValue = (isFlashDelivery ? constants[CONST_KEYS.flashFreeDeliveryThreshold] : constants[CONST_KEYS.minRegularOrderValue]) || 0; + const deliveryCharge = (isFlashDelivery ? constants[CONST_KEYS.flashDeliveryCharge] : constants[CONST_KEYS.deliveryCharge]) || 0; + + const orderGroupId = `${Date.now()}-${userId}`; + + 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); + } + + 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 }); + } + + if (params.isFlash) { + 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); + } + } + } + + 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); + + const expectedDeliveryCharge = + totalAmount < minOrderValue ? deliveryCharge : 0; + + const totalWithDelivery = totalAmount + expectedDeliveryCharge; + + type OrderData = { + order: Omit; + orderItems: Omit[]; + orderStatus: Omit; + }; + + const ordersData: OrderData[] = []; + let isFirstOrder = true; + + for (const [slotId, items] of ordersBySlot) { + const subOrderTotal = 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 subOrderTotalWithDelivery = subOrderTotal + expectedDeliveryCharge; + + const orderGroupProportion = subOrderTotal / totalAmount; + const orderTotalAmount = isFirstOrder ? subOrderTotalWithDelivery : subOrderTotal; + + const { finalOrderTotal: finalOrderAmount } = applyDiscountToOrder( + orderTotalAmount, + appliedCoupon, + orderGroupProportion + ); + + const order: Omit = { + userId, + addressId, + slotId: params.isFlash ? null : slotId, + isCod: paymentMethod === "cod", + isOnlinePayment: paymentMethod === "online", + paymentInfoId: null, + totalAmount: finalOrderAmount.toString(), + deliveryCharge: isFirstOrder ? expectedDeliveryCharge.toString() : "0", + readableId: -1, + userNotes: userNotes || null, + orderGroupId, + orderGroupProportion: orderGroupProportion.toString(), + isFlashDelivery: params.isFlash, + }; + + const orderItemsData: Omit[] = items.map( + (item) => ({ + orderId: 0, + 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(), + }) + ); + + const orderStatusData: Omit = { + userId, + orderId: 0, + paymentStatus: paymentMethod === "cod" ? "cod" : "pending", + }; + + ordersData.push({ order, orderItems: orderItemsData, orderStatus: orderStatusData }); + isFirstOrder = false; + } + + const createdOrders = await db.transaction(async (tx) => { + 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 ordersToInsert: Omit[] = ordersData.map( + (od) => ({ + ...od.order, + paymentInfoId: sharedPaymentInfoId, + }) + ); + + const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning(); + + const allOrderItems: Omit[] = []; + const allOrderStatuses: Omit[] = []; + + insertedOrders.forEach((order, index) => { + const od = ordersData[index]; + od.orderItems.forEach((item) => { + allOrderItems.push({ ...item, orderId: order.id as number }); + }); + allOrderStatuses.push({ + ...od.orderStatus, + orderId: order.id as number, + }); + }); + + await tx.insert(orderItems).values(allOrderItems); + await tx.insert(orderStatus).values(allOrderStatuses); + + if (paymentMethod === "online" && sharedPaymentInfoId) { + + } + + return insertedOrders; + }); + + await db.delete(cartItems).where( + and( + eq(cartItems.userId, userId), + inArray( + cartItems.productId, + selectedItems.map((item) => item.productId) + ) + ) + ); + + if (appliedCoupon && createdOrders.length > 0) { + await db.insert(couponUsage).values({ + userId, + couponId: appliedCoupon.id, + orderId: createdOrders[0].id as number, + orderItemId: null, + usedAt: new Date(), + }); + } + + for (const order of createdOrders) { + sendOrderPlacedNotification(userId, order.id.toString()); + } + + await publishFormattedOrder(createdOrders, ordersBySlot); + + 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(), + isFlashDelivery: z.boolean().optional().default(false), + }) + ) + .mutation(async ({ input, ctx }) => { + const userId = ctx.user.userId; + + // Check if user is suspended from placing orders + const userDetail = await db.query.userDetails.findFirst({ + where: eq(userDetails.userId, userId), + }); + + if (userDetail?.isSuspended) { + throw new ApiError("Unable to place order", 403); + } + + const { + selectedItems, + addressId, + paymentMethod, + couponId, + userNotes, + isFlashDelivery, + } = input; + + // Check if flash delivery is enabled when placing a flash delivery order + if (isFlashDelivery) { + const isFlashDeliveryEnabled = await getConstant(CONST_KEYS.isFlashDeliveryEnabled); + if (!isFlashDeliveryEnabled) { + throw new ApiError("Flash delivery is currently unavailable. Please opt for scheduled delivery.", 403); + } + } + + // Check if any selected slot is at full capacity (only for regular delivery) + if (!isFlashDelivery) { + const slotIds = [...new Set(selectedItems.filter(i => i.slotId !== null).map(i => i.slotId as number))]; + for (const slotId of slotIds) { + const slot = await getSlotById(slotId); + if (slot?.isCapacityFull) { + throw new ApiError("Selected delivery slot is at full capacity. Please choose another slot.", 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 + })); + } + + return await placeOrderUtil({ + userId, + selectedItems: processedItems, + addressId, + paymentMethod, + couponId, + userNotes, + 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 + ? scaffoldAssetUrl( + 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.id}`, + 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 + ? scaffoldAssetUrl( + 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.id}`, + 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; + + // 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() + ); + + // Publish to Redis for Telegram notification + await publishCancellation(result.orderId, 'user', reason); + + 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.number(), + 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: scaffoldAssetUrl( + (product.images as string[]) || [] + ), + }; + }) + ); + + return { + success: true, + products: formattedProducts, + }; + }), +}); diff --git a/verifier/user-apis/apis/product.ts b/verifier/user-apis/apis/product.ts new file mode 100644 index 0000000..0fc86ee --- /dev/null +++ b/verifier/user-apis/apis/product.ts @@ -0,0 +1,266 @@ +import { router, publicProcedure, protectedProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { productInfo, units, productSlots, deliverySlotInfo, specialDeals, storeInfo, productTagInfo, productTags, productReviews, users } from '@/src/db/schema'; +import { claimUploadUrl, extractKeyFromPresignedUrl, scaffoldAssetUrl } from '@/src/lib/s3-client'; +import { ApiError } from '@/src/lib/api-error'; +import { eq, and, gt, sql, inArray, desc } from 'drizzle-orm'; +import { getProductById as getProductByIdFromCache, getAllProducts as getAllProductsFromCache } from '@/src/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 => { + 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 => { + // 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; + }), + +}); diff --git a/verifier/user-apis/apis/slots.ts b/verifier/user-apis/apis/slots.ts new file mode 100644 index 0000000..92dd37f --- /dev/null +++ b/verifier/user-apis/apis/slots.ts @@ -0,0 +1,92 @@ +import { router, publicProcedure } from "@/src/trpc/trpc-index"; +import { z } from "zod"; +import { db } from "@/src/db/db_index"; +import { + deliverySlotInfo, + productSlots, + productInfo, + units, +} from "@/src/db/schema"; +import { eq, and } from "drizzle-orm"; +import { getAllSlots as getAllSlotsFromCache, getSlotById as getSlotByIdFromCache } from "@/src/stores/slot-store"; +import dayjs from 'dayjs'; + +// Helper method to get formatted slot data by ID +async function getSlotData(slotId: number) { + const slot = await getSlotByIdFromCache(slotId); + + if (!slot) { + return null; + } + + const currentTime = new Date(); + if (dayjs(slot.freezeTime).isBefore(currentTime)) { + return null; + } + + return { + deliveryTime: slot.deliveryTime, + freezeTime: slot.freezeTime, + slotId: slot.id, + products: slot.products.filter((product) => !product.isOutOfStock), + }; +} + +export async function scaffoldSlotsWithProducts() { + const allSlots = await getAllSlotsFromCache(); + const currentTime = new Date(); + const validSlots = allSlots + .filter((slot) => { + return dayjs(slot.freezeTime).isAfter(currentTime) && + dayjs(slot.deliveryTime).isAfter(currentTime) && + !slot.isCapacityFull; + }) + .sort((a, b) => dayjs(a.deliveryTime).valueOf() - dayjs(b.deliveryTime).valueOf()); + + // Fetch all products for availability info + const allProducts = await db + .select({ + id: productInfo.id, + name: productInfo.name, + isOutOfStock: productInfo.isOutOfStock, + isFlashAvailable: productInfo.isFlashAvailable, + }) + .from(productInfo) + .where(eq(productInfo.isSuspended, false)); + + const productAvailability = allProducts.map(product => ({ + id: product.id, + name: product.name, + isOutOfStock: product.isOutOfStock, + isFlashAvailable: product.isFlashAvailable, + })); + + return { + slots: validSlots, + productAvailability, + count: validSlots.length, + }; +} + +export const slotsRouter = router({ + getSlots: publicProcedure.query(async () => { + const slots = await db.query.deliverySlotInfo.findMany({ + where: eq(deliverySlotInfo.isActive, true), + }); + return { + slots, + count: slots.length, + }; + }), + + getSlotsWithProducts: publicProcedure.query(async () => { + const response = await scaffoldSlotsWithProducts(); + return response; + }), + + getSlotById: publicProcedure + .input(z.object({ slotId: z.number() })) + .query(async ({ input }) => { + return await getSlotData(input.slotId); + }), +}); diff --git a/verifier/user-apis/apis/stores.ts b/verifier/user-apis/apis/stores.ts new file mode 100644 index 0000000..9522b7a --- /dev/null +++ b/verifier/user-apis/apis/stores.ts @@ -0,0 +1,162 @@ +import { router, publicProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { storeInfo, productInfo, units } from '@/src/db/schema'; +import { eq, and, sql } from 'drizzle-orm'; +import { scaffoldAssetUrl } from '@/src/lib/s3-client'; +import { ApiError } from '@/src/lib/api-error'; +import { getTagsByStoreId } from '@/src/stores/product-tag-store'; + +export async function scaffoldStores() { + const storesData = await db + .select({ + id: storeInfo.id, + name: storeInfo.name, + description: storeInfo.description, + imageUrl: storeInfo.imageUrl, + productCount: sql`count(${productInfo.id})`.as('productCount'), + }) + .from(storeInfo) + .leftJoin( + productInfo, + and(eq(productInfo.storeId, storeInfo.id), eq(productInfo.isSuspended, false)) + ) + .groupBy(storeInfo.id); + + // Generate signed URLs for store images and fetch sample products + const storesWithDetails = await Promise.all( + storesData.map(async (store) => { + const signedImageUrl = store.imageUrl ? scaffoldAssetUrl(store.imageUrl) : null; + + // Fetch up to 3 products for this store + const sampleProducts = await db + .select({ + id: productInfo.id, + name: productInfo.name, + images: productInfo.images, + }) + .from(productInfo) + .where(and(eq(productInfo.storeId, store.id), eq(productInfo.isSuspended, false))) + .limit(3); + + // Generate signed URLs for product images + const productsWithSignedUrls = await Promise.all( + sampleProducts.map(async (product) => { + const images = product.images as string[]; + return { + id: product.id, + name: product.name, + signedImageUrl: (images && images.length > 0) ? scaffoldAssetUrl(images[0]) : null, + }; + }) + ); + + return { + id: store.id, + name: store.name, + description: store.description, + signedImageUrl, + productCount: store.productCount, + sampleProducts: productsWithSignedUrls, + }; + }) + ); + + return { + stores: storesWithDetails, + }; +} + +export async function scaffoldStoreWithProducts(storeId: number) { + // Fetch store info + const storeData = await db.query.storeInfo.findFirst({ + where: eq(storeInfo.id, storeId), + columns: { + id: true, + name: true, + description: true, + imageUrl: true, + }, + }); + + if (!storeData) { + throw new ApiError('Store not found', 404); + } + + // Generate signed URL for store image + const signedImageUrl = storeData.imageUrl ? scaffoldAssetUrl(storeData.imageUrl) : null; + + // Fetch products for this store + const productsData = await db + .select({ + id: productInfo.id, + name: productInfo.name, + shortDescription: productInfo.shortDescription, + price: productInfo.price, + marketPrice: productInfo.marketPrice, + images: productInfo.images, + isOutOfStock: productInfo.isOutOfStock, + incrementStep: productInfo.incrementStep, + unitShortNotation: units.shortNotation, + unitNotation: units.shortNotation, + productQuantity: productInfo.productQuantity, + }) + .from(productInfo) + .innerJoin(units, eq(productInfo.unitId, units.id)) + .where(and(eq(productInfo.storeId, storeId), eq(productInfo.isSuspended, false))); + + + // Generate signed URLs for product images + const productsWithSignedUrls = await Promise.all( + productsData.map(async (product) => ({ + id: product.id, + name: product.name, + shortDescription: product.shortDescription, + price: product.price, + marketPrice: product.marketPrice, + incrementStep: product.incrementStep, + unit: product.unitShortNotation, + unitNotation: product.unitNotation, + images: scaffoldAssetUrl((product.images as string[]) || []), + isOutOfStock: product.isOutOfStock, + productQuantity: product.productQuantity + })) + ); + + const tags = await getTagsByStoreId(storeId); + + return { + store: { + id: storeData.id, + name: storeData.name, + description: storeData.description, + signedImageUrl, + }, + products: productsWithSignedUrls, + tags: tags.map(tag => ({ + id: tag.id, + tagName: tag.tagName, + tagDescription: tag.tagDescription, + imageUrl: tag.imageUrl, + productIds: tag.productIds, + })), + }; +} + +export const storesRouter = router({ + getStores: publicProcedure + .query(async () => { + const response = await scaffoldStores(); + return response; + }), + + getStoreWithProducts: publicProcedure + .input(z.object({ + storeId: z.number(), + })) + .query(async ({ input }) => { + const { storeId } = input; + const response = await scaffoldStoreWithProducts(storeId); + return response; + }), +}); diff --git a/verifier/user-apis/apis/tags.ts b/verifier/user-apis/apis/tags.ts new file mode 100644 index 0000000..d21b229 --- /dev/null +++ b/verifier/user-apis/apis/tags.ts @@ -0,0 +1,28 @@ +import { router, publicProcedure } from '@/src/trpc/trpc-index'; +import { z } from 'zod'; +import { getTagsByStoreId } from '@/src/stores/product-tag-store'; +import { ApiError } from '@/src/lib/api-error'; + +export const tagsRouter = router({ + getTagsByStore: publicProcedure + .input(z.object({ + storeId: z.number(), + })) + .query(async ({ input }) => { + const { storeId } = input; + + // Get tags from cache that are related to this store + const tags = await getTagsByStoreId(storeId); + + + return { + tags: tags.map(tag => ({ + id: tag.id, + tagName: tag.tagName, + tagDescription: tag.tagDescription, + imageUrl: tag.imageUrl, + productIds: tag.productIds, + })), + }; + }), +}); diff --git a/verifier/user-apis/apis/user-trpc-index.ts b/verifier/user-apis/apis/user-trpc-index.ts new file mode 100644 index 0000000..0b48629 --- /dev/null +++ b/verifier/user-apis/apis/user-trpc-index.ts @@ -0,0 +1,32 @@ +import { router } from '@/src/trpc/trpc-index'; +import { addressRouter } from '@/src/trpc/apis/user-apis/apis/address'; +import { authRouter } from '@/src/trpc/apis/user-apis/apis/auth'; +import { bannerRouter } from '@/src/trpc/apis/user-apis/apis/banners'; +import { cartRouter } from '@/src/trpc/apis/user-apis/apis/cart'; +import { complaintRouter } from '@/src/trpc/apis/user-apis/apis/complaint'; +import { orderRouter } from '@/src/trpc/apis/user-apis/apis/order'; +import { productRouter } from '@/src/trpc/apis/user-apis/apis/product'; +import { slotsRouter } from '@/src/trpc/apis/user-apis/apis/slots'; +import { userRouter as userDataRouter } from '@/src/trpc/apis/user-apis/apis/user'; +import { userCouponRouter } from '@/src/trpc/apis/user-apis/apis/coupon'; +import { storesRouter } from '@/src/trpc/apis/user-apis/apis/stores'; +import { fileUploadRouter } from '@/src/trpc/apis/user-apis/apis/file-upload'; +import { tagsRouter } from '@/src/trpc/apis/user-apis/apis/tags'; + +export const userRouter = router({ + address: addressRouter, + auth: authRouter, + banner: bannerRouter, + cart: cartRouter, + complaint: complaintRouter, + order: orderRouter, + product: productRouter, + slots: slotsRouter, + user: userDataRouter, + coupon: userCouponRouter, + stores: storesRouter, + fileUpload: fileUploadRouter, + tags: tagsRouter, +}); + +export type UserRouter = typeof userRouter; diff --git a/verifier/user-apis/apis/user.ts b/verifier/user-apis/apis/user.ts new file mode 100644 index 0000000..057697a --- /dev/null +++ b/verifier/user-apis/apis/user.ts @@ -0,0 +1,164 @@ +import { router, protectedProcedure, publicProcedure } from '@/src/trpc/trpc-index'; +import { eq, and } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '@/src/db/db_index'; +import { users, userDetails, userCreds, notifCreds, unloggedUserTokens } from '@/src/db/schema'; +import { ApiError } from '@/src/lib/api-error'; +import { generateSignedUrlFromS3Url } from '@/src/lib/s3-client'; +import { signToken } from '@/src/lib/jwt-utils'; + +interface AuthResponse { + token: string; + user: { + id: number; + name: string | null; + email: string | null; + mobile: string | null; + profileImage?: string | null; + bio?: string | null; + dateOfBirth?: string | null; + gender?: string | null; + occupation?: string | null; + }; +} + +const generateToken = async (userId: number): Promise => { + return signToken({ userId }); +}; + +export const userRouter = router({ + getSelfData: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user) { + throw new ApiError('User not found', 404); + } + + // Get user details for profile image + const [userDetail] = await db + .select() + .from(userDetails) + .where(eq(userDetails.userId, userId)) + .limit(1); + + // Generate signed URL for profile image if it exists + const profileImageSignedUrl = userDetail?.profileImage + ? await generateSignedUrlFromS3Url(userDetail.profileImage) + : null; + + const response: Omit = { + user: { + id: user.id, + name: user.name, + email: user.email, + mobile: user.mobile, + profileImage: profileImageSignedUrl, + bio: userDetail?.bio || null, + dateOfBirth: userDetail?.dateOfBirth || null, + gender: userDetail?.gender || null, + occupation: userDetail?.occupation || null, + }, + }; + + return { + success: true, + data: response, + }; + }), + + checkProfileComplete: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.userId; + + if (!userId) { + throw new ApiError('User not authenticated', 401); + } + + const result = await db + .select() + .from(users) + .leftJoin(userCreds, eq(users.id, userCreds.userId)) + .where(eq(users.id, userId)) + .limit(1); + + if (result.length === 0) { + throw new ApiError('User not found', 404); + } + + const { users: user, user_creds: creds } = result[0]; + + return { + isComplete: !!(user.name && user.email && creds), + }; + }), + + savePushToken: publicProcedure + .input(z.object({ token: z.string() })) + .mutation(async ({ input, ctx }) => { + const { token } = input; + const userId = ctx.user?.userId; + + if (userId) { + // AUTHENTICATED USER + // Check if token exists in notif_creds for this user + const existing = await db.query.notifCreds.findFirst({ + where: and( + eq(notifCreds.userId, userId), + eq(notifCreds.token, token) + ), + }); + + if (existing) { + // Update lastVerified timestamp + await db + .update(notifCreds) + .set({ lastVerified: new Date() }) + .where(eq(notifCreds.id, existing.id)); + } else { + // Insert new token into notif_creds + await db.insert(notifCreds).values({ + userId, + token, + lastVerified: new Date(), + }); + } + + // Remove from unlogged_user_tokens if it exists + await db + .delete(unloggedUserTokens) + .where(eq(unloggedUserTokens.token, token)); + + } else { + // UNAUTHENTICATED USER + // Save/update in unlogged_user_tokens + const existing = await db.query.unloggedUserTokens.findFirst({ + where: eq(unloggedUserTokens.token, token), + }); + + if (existing) { + await db + .update(unloggedUserTokens) + .set({ lastVerified: new Date() }) + .where(eq(unloggedUserTokens.id, existing.id)); + } else { + await db.insert(unloggedUserTokens).values({ + token, + lastVerified: new Date(), + }); + } + } + + return { success: true }; + }), +}); diff --git a/verifier/user-apis/dataAccessors/demo.txt b/verifier/user-apis/dataAccessors/demo.txt new file mode 100644 index 0000000..e69de29