@instockng/api-client 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/dist/apps/backend/src/generated/zod/index.d.ts +1114 -0
  2. package/dist/apps/backend/src/generated/zod/index.js +670 -0
  3. package/dist/apps/backend/src/http-app.d.ts +40 -0
  4. package/dist/apps/backend/src/http-app.js +106 -0
  5. package/dist/apps/backend/src/lib/brand-response.d.ts +14 -0
  6. package/dist/apps/backend/src/lib/brand-response.js +8 -0
  7. package/dist/apps/backend/src/lib/cart-helpers.d.ts +280 -0
  8. package/dist/apps/backend/src/lib/cart-helpers.js +93 -0
  9. package/dist/apps/backend/src/lib/cart-recovery.d.ts +30 -0
  10. package/dist/apps/backend/src/lib/cart-recovery.js +147 -0
  11. package/dist/apps/backend/src/lib/cart-response.d.ts +121 -0
  12. package/dist/apps/backend/src/lib/cart-response.js +150 -0
  13. package/dist/apps/backend/src/lib/clerk.d.ts +18 -0
  14. package/dist/apps/backend/src/lib/clerk.js +167 -0
  15. package/dist/apps/backend/src/lib/delivery-zone-response.d.ts +62 -0
  16. package/dist/apps/backend/src/lib/delivery-zone-response.js +24 -0
  17. package/dist/apps/backend/src/lib/discount-code-response.d.ts +42 -0
  18. package/dist/apps/backend/src/lib/discount-code-response.js +19 -0
  19. package/dist/apps/backend/src/lib/discount.d.ts +20 -0
  20. package/dist/apps/backend/src/lib/discount.js +35 -0
  21. package/dist/apps/backend/src/lib/inventory.d.ts +26 -0
  22. package/dist/apps/backend/src/lib/inventory.js +160 -0
  23. package/dist/apps/backend/src/lib/meta-capi.d.ts +48 -0
  24. package/dist/apps/backend/src/lib/meta-capi.js +120 -0
  25. package/dist/apps/backend/src/lib/openapi.d.ts +36 -0
  26. package/dist/apps/backend/src/lib/openapi.js +69 -0
  27. package/dist/apps/backend/src/lib/order-recovery.d.ts +453 -0
  28. package/dist/apps/backend/src/lib/order-recovery.js +378 -0
  29. package/dist/apps/backend/src/lib/order-response.d.ts +136 -0
  30. package/dist/apps/backend/src/lib/order-response.js +61 -0
  31. package/dist/apps/backend/src/lib/pricing.d.ts +39 -0
  32. package/dist/apps/backend/src/lib/pricing.js +62 -0
  33. package/dist/apps/backend/src/lib/prisma.d.ts +9 -0
  34. package/dist/apps/backend/src/lib/prisma.js +30 -0
  35. package/dist/apps/backend/src/lib/product-response.d.ts +82 -0
  36. package/dist/apps/backend/src/lib/product-response.js +29 -0
  37. package/dist/apps/backend/src/lib/utils.d.ts +32 -0
  38. package/dist/apps/backend/src/lib/utils.js +63 -0
  39. package/dist/apps/backend/src/middleware/clerk-auth.d.ts +8 -0
  40. package/dist/apps/backend/src/middleware/clerk-auth.js +89 -0
  41. package/dist/apps/backend/src/middleware/cors.d.ts +8 -0
  42. package/dist/apps/backend/src/middleware/cors.js +11 -0
  43. package/dist/apps/backend/src/notifications/producers/meta-capi-producer.d.ts +62 -0
  44. package/dist/apps/backend/src/notifications/producers/meta-capi-producer.js +166 -0
  45. package/dist/apps/backend/src/notifications/producers/order-notification.d.ts +9 -0
  46. package/dist/apps/backend/src/notifications/producers/order-notification.js +18 -0
  47. package/dist/apps/backend/src/notifications/producers/prospect-recovery-notification.d.ts +10 -0
  48. package/dist/apps/backend/src/notifications/producers/prospect-recovery-notification.js +11 -0
  49. package/dist/apps/backend/src/routes/admin/abandoned-carts.d.ts +605 -0
  50. package/dist/apps/backend/src/routes/admin/abandoned-carts.js +194 -0
  51. package/dist/apps/backend/src/routes/admin/brands.d.ts +175 -0
  52. package/dist/apps/backend/src/routes/admin/brands.js +118 -0
  53. package/dist/apps/backend/src/routes/admin/customers.d.ts +306 -0
  54. package/dist/apps/backend/src/routes/admin/customers.js +39 -0
  55. package/dist/apps/backend/src/routes/admin/delivery-zones.d.ts +438 -0
  56. package/dist/apps/backend/src/routes/admin/delivery-zones.js +300 -0
  57. package/dist/apps/backend/src/routes/admin/discount-codes.d.ts +478 -0
  58. package/dist/apps/backend/src/routes/admin/discount-codes.js +418 -0
  59. package/dist/apps/backend/src/routes/admin/inventory.d.ts +273 -0
  60. package/dist/apps/backend/src/routes/admin/inventory.js +189 -0
  61. package/dist/apps/backend/src/routes/admin/orders.d.ts +1768 -0
  62. package/dist/apps/backend/src/routes/admin/orders.js +527 -0
  63. package/dist/apps/backend/src/routes/admin/products.d.ts +860 -0
  64. package/dist/apps/backend/src/routes/admin/products.js +107 -0
  65. package/dist/apps/backend/src/routes/admin/stats.d.ts +288 -0
  66. package/dist/apps/backend/src/routes/admin/stats.js +55 -0
  67. package/dist/apps/backend/src/routes/admin/variants.d.ts +239 -0
  68. package/dist/apps/backend/src/routes/admin/variants.js +173 -0
  69. package/dist/apps/backend/src/routes/admin/warehouses.d.ts +373 -0
  70. package/dist/apps/backend/src/routes/admin/warehouses.js +123 -0
  71. package/dist/apps/backend/src/routes/public/brands.d.ts +40 -0
  72. package/dist/apps/backend/src/routes/public/brands.js +38 -0
  73. package/dist/apps/backend/src/routes/public/carts.d.ts +2655 -0
  74. package/dist/apps/backend/src/routes/public/carts.js +631 -0
  75. package/dist/apps/backend/src/routes/public/delivery-zones.d.ts +35 -0
  76. package/dist/apps/backend/src/routes/public/delivery-zones.js +62 -0
  77. package/dist/apps/backend/src/routes/public/orders.d.ts +605 -0
  78. package/dist/apps/backend/src/routes/public/orders.js +160 -0
  79. package/dist/apps/backend/src/routes/public/products.d.ts +449 -0
  80. package/dist/apps/backend/src/routes/public/products.js +133 -0
  81. package/dist/apps/backend/src/types/index.d.ts +42 -0
  82. package/dist/apps/backend/src/types/index.js +2 -0
  83. package/dist/apps/backend/src/validators/brand.d.ts +17 -0
  84. package/dist/apps/backend/src/validators/brand.js +15 -0
  85. package/dist/apps/backend/src/validators/delivery-zone.d.ts +31 -0
  86. package/dist/apps/backend/src/validators/delivery-zone.js +51 -0
  87. package/dist/apps/backend/src/validators/discount-code.d.ts +74 -0
  88. package/dist/apps/backend/src/validators/discount-code.js +50 -0
  89. package/dist/apps/backend/src/validators/inventory.d.ts +20 -0
  90. package/dist/apps/backend/src/validators/inventory.js +15 -0
  91. package/dist/apps/backend/src/validators/order.d.ts +56 -0
  92. package/dist/apps/backend/src/validators/order.js +60 -0
  93. package/dist/apps/backend/src/validators/product.d.ts +18 -0
  94. package/dist/apps/backend/src/validators/product.js +19 -0
  95. package/dist/apps/backend/src/validators/variant.d.ts +19 -0
  96. package/dist/apps/backend/src/validators/variant.js +19 -0
  97. package/dist/apps/backend/src/validators/warehouse.d.ts +15 -0
  98. package/dist/apps/backend/src/validators/warehouse.js +15 -0
  99. package/dist/fetchers/orders.d.ts +258 -1
  100. package/dist/hooks/admin/orders.d.ts +285 -3
  101. package/dist/hooks/admin/orders.js +7 -4
  102. package/dist/hooks/public/orders.d.ts +258 -1
  103. package/dist/packages/api-client/src/backend-types.d.ts +10 -0
  104. package/dist/packages/api-client/src/backend-types.js +10 -0
  105. package/dist/packages/api-client/src/client.d.ts +20 -0
  106. package/dist/packages/api-client/src/client.js +40 -0
  107. package/dist/packages/api-client/src/enum-types.d.ts +8 -0
  108. package/dist/packages/api-client/src/enum-types.js +5 -0
  109. package/dist/packages/api-client/src/fetchers/brands.d.ts +25 -0
  110. package/dist/packages/api-client/src/fetchers/brands.js +26 -0
  111. package/dist/packages/api-client/src/fetchers/carts.d.ts +2335 -0
  112. package/dist/packages/api-client/src/fetchers/carts.js +174 -0
  113. package/dist/packages/api-client/src/fetchers/delivery-zones.d.ts +28 -0
  114. package/dist/packages/api-client/src/fetchers/delivery-zones.js +26 -0
  115. package/dist/packages/api-client/src/fetchers/index.d.ts +22 -0
  116. package/dist/packages/api-client/src/fetchers/index.js +22 -0
  117. package/dist/packages/api-client/src/fetchers/orders.d.ts +540 -0
  118. package/dist/packages/api-client/src/fetchers/orders.js +44 -0
  119. package/dist/packages/api-client/src/fetchers/products.d.ts +386 -0
  120. package/dist/packages/api-client/src/fetchers/products.js +42 -0
  121. package/dist/packages/api-client/src/hooks/admin/abandoned-carts.d.ts +535 -0
  122. package/dist/packages/api-client/src/hooks/admin/abandoned-carts.js +83 -0
  123. package/dist/packages/api-client/src/hooks/admin/brands.d.ts +79 -0
  124. package/dist/packages/api-client/src/hooks/admin/brands.js +108 -0
  125. package/dist/packages/api-client/src/hooks/admin/customers.d.ts +278 -0
  126. package/dist/packages/api-client/src/hooks/admin/customers.js +26 -0
  127. package/dist/packages/api-client/src/hooks/admin/delivery-zones.d.ts +270 -0
  128. package/dist/packages/api-client/src/hooks/admin/delivery-zones.js +176 -0
  129. package/dist/packages/api-client/src/hooks/admin/discount-codes.d.ts +299 -0
  130. package/dist/packages/api-client/src/hooks/admin/discount-codes.js +165 -0
  131. package/dist/packages/api-client/src/hooks/admin/index.d.ts +16 -0
  132. package/dist/packages/api-client/src/hooks/admin/index.js +16 -0
  133. package/dist/packages/api-client/src/hooks/admin/inventory.d.ts +224 -0
  134. package/dist/packages/api-client/src/hooks/admin/inventory.js +107 -0
  135. package/dist/packages/api-client/src/hooks/admin/orders.d.ts +1662 -0
  136. package/dist/packages/api-client/src/hooks/admin/orders.js +178 -0
  137. package/dist/packages/api-client/src/hooks/admin/products.d.ts +374 -0
  138. package/dist/packages/api-client/src/hooks/admin/products.js +89 -0
  139. package/dist/packages/api-client/src/hooks/admin/stats.d.ts +277 -0
  140. package/dist/packages/api-client/src/hooks/admin/stats.js +25 -0
  141. package/dist/packages/api-client/src/hooks/admin/variants.d.ts +115 -0
  142. package/dist/packages/api-client/src/hooks/admin/variants.js +127 -0
  143. package/dist/packages/api-client/src/hooks/admin/warehouses.d.ts +277 -0
  144. package/dist/packages/api-client/src/hooks/admin/warehouses.js +108 -0
  145. package/dist/packages/api-client/src/hooks/public/brands.d.ts +33 -0
  146. package/dist/packages/api-client/src/hooks/public/brands.js +30 -0
  147. package/dist/packages/api-client/src/hooks/public/carts.d.ts +2405 -0
  148. package/dist/packages/api-client/src/hooks/public/carts.js +213 -0
  149. package/dist/packages/api-client/src/hooks/public/delivery-zones.d.ts +34 -0
  150. package/dist/packages/api-client/src/hooks/public/delivery-zones.js +28 -0
  151. package/dist/packages/api-client/src/hooks/public/index.d.ts +10 -0
  152. package/dist/packages/api-client/src/hooks/public/index.js +10 -0
  153. package/dist/packages/api-client/src/hooks/public/orders.d.ts +559 -0
  154. package/dist/packages/api-client/src/hooks/public/orders.js +50 -0
  155. package/dist/packages/api-client/src/hooks/public/products.d.ts +398 -0
  156. package/dist/packages/api-client/src/hooks/public/products.js +47 -0
  157. package/dist/packages/api-client/src/hooks/use-query-unwrapped.d.ts +20 -0
  158. package/dist/packages/api-client/src/hooks/use-query-unwrapped.js +22 -0
  159. package/dist/packages/api-client/src/hooks/useApiConfig.d.ts +12 -0
  160. package/dist/packages/api-client/src/hooks/useApiConfig.js +14 -0
  161. package/dist/packages/api-client/src/index.d.ts +20 -0
  162. package/dist/packages/api-client/src/index.js +25 -0
  163. package/dist/packages/api-client/src/provider.d.ts +36 -0
  164. package/dist/packages/api-client/src/provider.js +54 -0
  165. package/dist/packages/api-client/src/rpc-client.d.ts +9607 -0
  166. package/dist/packages/api-client/src/rpc-client.js +78 -0
  167. package/dist/packages/api-client/src/rpc-types.d.ts +76 -0
  168. package/dist/packages/api-client/src/rpc-types.js +7 -0
  169. package/dist/packages/api-client/src/types.d.ts +34 -0
  170. package/dist/packages/api-client/src/types.js +16 -0
  171. package/dist/packages/api-client/src/utils/query-keys.d.ts +106 -0
  172. package/dist/packages/api-client/src/utils/query-keys.js +108 -0
  173. package/dist/rpc-client.d.ts +891 -319
  174. package/dist/utils/query-keys.d.ts +1 -1
  175. package/dist/utils/query-keys.js +1 -1
  176. package/package.json +1 -1
@@ -0,0 +1,527 @@
1
+ import { Hono } from 'hono';
2
+ import { zValidator } from '@hono/zod-validator';
3
+ import { z } from 'zod';
4
+ import { createOrderSchema, updateOrderSchema, updateOrderStatusSchema } from '../../validators/order';
5
+ import { getPrismaClient } from '../../lib/prisma';
6
+ import { getPaginationParams, createPaginatedResponse } from '../../lib/utils';
7
+ import { adjustInventory } from '../../lib/inventory';
8
+ import { OrderStatus } from '@prisma/client';
9
+ import { formatOrderResponse } from '../../lib/order-response';
10
+ import { markOrderAsProspect } from '../../lib/order-recovery';
11
+ import { ORDER_INCLUDE_FULL } from '../../lib/cart-helpers';
12
+ const app = new Hono()
13
+ .post('/', zValidator('json', createOrderSchema), async (c) => {
14
+ try {
15
+ const input = c.req.valid('json');
16
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
17
+ // Validate brand exists and is active
18
+ const brand = await prisma.brand.findFirst({
19
+ where: { slug: input.brandSlug, deletedAt: null },
20
+ });
21
+ if (!brand) {
22
+ return c.json({ error: { code: 'INVALID_BRAND', message: 'Brand not found or inactive' } }, 404);
23
+ }
24
+ // Validate all variants exist and are active
25
+ const variants = await prisma.productVariant.findMany({
26
+ where: {
27
+ sku: { in: input.items.map((item) => item.sku) },
28
+ isActive: true,
29
+ deletedAt: null,
30
+ },
31
+ include: {
32
+ product: true,
33
+ },
34
+ });
35
+ if (variants.length !== input.items.length) {
36
+ return c.json({ error: { code: 'INVALID_VARIANT', message: 'One or more SKUs not found or inactive' } }, 404);
37
+ }
38
+ // Group items by product to calculate quantity discounts at product level
39
+ const itemsByProduct = new Map();
40
+ for (const item of input.items) {
41
+ const variant = variants.find((v) => v.sku === item.sku);
42
+ if (!variant)
43
+ throw new Error('Variant not found');
44
+ const productId = variant.product.id;
45
+ if (!itemsByProduct.has(productId)) {
46
+ itemsByProduct.set(productId, []);
47
+ }
48
+ itemsByProduct.get(productId).push(item);
49
+ }
50
+ // Calculate total price with product-level quantity discounts
51
+ let subtotal = 0;
52
+ const orderItems = input.items.map((item) => {
53
+ const variant = variants.find((v) => v.sku === item.sku);
54
+ if (!variant)
55
+ throw new Error('Variant not found');
56
+ const basePrice = Number(variant.price);
57
+ let discountPercent = 0;
58
+ // Calculate total quantity for this product across all variants
59
+ const productItems = itemsByProduct.get(variant.product.id) || [];
60
+ const totalProductQuantity = productItems.reduce((sum, i) => sum + i.quantity, 0);
61
+ // Check if product has quantity discounts and apply based on total product quantity
62
+ if (variant.product.quantityDiscounts && typeof variant.product.quantityDiscounts === 'object') {
63
+ const discounts = variant.product.quantityDiscounts;
64
+ // Find the highest quantity tier that applies to total product quantity
65
+ const applicableDiscounts = Object.entries(discounts)
66
+ .filter(([qty]) => totalProductQuantity >= parseInt(qty))
67
+ .sort(([a], [b]) => parseInt(b) - parseInt(a)); // Sort descending
68
+ if (applicableDiscounts.length > 0) {
69
+ discountPercent = applicableDiscounts[0][1];
70
+ }
71
+ }
72
+ // Apply discount to this variant's price
73
+ const discountedPrice = Math.round(basePrice * (1 - discountPercent / 100));
74
+ const itemTotal = discountedPrice * item.quantity;
75
+ subtotal += itemTotal;
76
+ return {
77
+ variantId: variant.id,
78
+ quantity: item.quantity,
79
+ priceAtPurchase: discountedPrice,
80
+ };
81
+ });
82
+ // Apply discount if provided
83
+ const discount = input.discount || 0;
84
+ const totalPrice = Math.max(0, subtotal + input.deliveryCharge - discount);
85
+ // Create order
86
+ const order = await prisma.order.create({
87
+ data: {
88
+ brandId: brand.id,
89
+ firstName: input.firstName,
90
+ lastName: input.lastName,
91
+ phone: input.phone,
92
+ address: input.address,
93
+ city: input.city,
94
+ deliveryZoneId: input.deliveryZoneId,
95
+ deliveryCharge: input.deliveryCharge,
96
+ totalPrice,
97
+ paymentMethod: input.paymentMethod,
98
+ paystackReference: input.paystackReference,
99
+ status: 'pending',
100
+ items: {
101
+ create: orderItems,
102
+ },
103
+ },
104
+ include: ORDER_INCLUDE_FULL
105
+ });
106
+ // Queue confirmation email
107
+ try {
108
+ const { enqueueOrderNotification } = await import('../../notifications/producers/order-notification');
109
+ await enqueueOrderNotification(c.env, 'order_confirmation', order.id);
110
+ }
111
+ catch (error) {
112
+ console.error('Failed to queue WhatsApp confirmation:', error);
113
+ // Don't fail the order creation if queuing fails
114
+ }
115
+ return c.json(formatOrderResponse(order), 201);
116
+ }
117
+ catch (error) {
118
+ console.error('Error creating order:', error);
119
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: error.message } }, 500);
120
+ }
121
+ }).get('/', async (c) => {
122
+ try {
123
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
124
+ const query = c.req.query();
125
+ const { page, limit, skip } = getPaginationParams({
126
+ page: query.page ? parseInt(query.page) : undefined,
127
+ limit: query.limit ? parseInt(query.limit) : undefined,
128
+ });
129
+ const where = { deletedAt: null };
130
+ if (query.brandId)
131
+ where.brandId = query.brandId;
132
+ if (query.status)
133
+ where.status = query.status;
134
+ if (query.paymentMethod)
135
+ where.paymentMethod = query.paymentMethod;
136
+ if (query.search) {
137
+ where.OR = [
138
+ { orderNumber: isNaN(parseInt(query.search)) ? undefined : parseInt(query.search) },
139
+ { phone: { contains: query.search } },
140
+ { firstName: { contains: query.search, mode: 'insensitive' } },
141
+ { lastName: { contains: query.search, mode: 'insensitive' } },
142
+ { address: { contains: query.search, mode: 'insensitive' } },
143
+ ].filter((item) => Object.values(item).some((v) => v !== undefined));
144
+ }
145
+ if (query.startDate)
146
+ where.createdAt = { ...where.createdAt, gte: new Date(query.startDate) };
147
+ if (query.endDate)
148
+ where.createdAt = { ...where.createdAt, lte: new Date(query.endDate) };
149
+ const [orders, total] = await Promise.all([
150
+ prisma.order.findMany({
151
+ where,
152
+ include: ORDER_INCLUDE_FULL,
153
+ skip,
154
+ take: limit,
155
+ orderBy: { createdAt: 'desc' },
156
+ }),
157
+ prisma.order.count({ where }),
158
+ ]);
159
+ return c.json(createPaginatedResponse(orders.map(formatOrderResponse), total, page, limit));
160
+ }
161
+ catch (error) {
162
+ console.error('List orders error:', error);
163
+ return c.json({
164
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to retrieve orders' },
165
+ }, 500);
166
+ }
167
+ }).get('/:id', zValidator('param', z.object({ id: z.string().uuid() })), async (c) => {
168
+ try {
169
+ const { id } = c.req.valid('param');
170
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
171
+ const order = await prisma.order.findFirst({
172
+ where: { id, deletedAt: null },
173
+ include: ORDER_INCLUDE_FULL,
174
+ });
175
+ if (!order) {
176
+ return c.json({
177
+ error: { code: 'ORDER_NOT_FOUND', message: 'Order not found' },
178
+ }, 404);
179
+ }
180
+ return c.json(formatOrderResponse(order));
181
+ }
182
+ catch (error) {
183
+ return c.json({
184
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to retrieve order' },
185
+ }, 500);
186
+ }
187
+ }).patch('/:id', zValidator('param', z.object({ id: z.string().uuid() })), zValidator('json', updateOrderSchema), async (c) => {
188
+ try {
189
+ const { id } = c.req.valid('param');
190
+ const input = c.req.valid('json');
191
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
192
+ const order = await prisma.order.findFirst({
193
+ where: { id, deletedAt: null },
194
+ include: { brand: true },
195
+ });
196
+ if (!order) {
197
+ return c.json({
198
+ error: { code: 'ORDER_NOT_FOUND', message: 'Order not found' },
199
+ }, 404);
200
+ }
201
+ // Handle items update if provided
202
+ if (input.items) {
203
+ // Delete existing items and create new ones
204
+ await prisma.orderItem.deleteMany({
205
+ where: { orderId: id },
206
+ });
207
+ // Fetch variants by SKU
208
+ const variants = await prisma.productVariant.findMany({
209
+ where: {
210
+ sku: { in: input.items.map((item) => item.sku) },
211
+ isActive: true,
212
+ deletedAt: null,
213
+ },
214
+ include: {
215
+ product: {
216
+ include: {
217
+ brand: true,
218
+ },
219
+ },
220
+ },
221
+ });
222
+ if (variants.length !== input.items.length) {
223
+ return c.json({
224
+ error: { code: 'INVALID_VARIANT', message: 'One or more SKUs not found or inactive' },
225
+ }, 404);
226
+ }
227
+ // Create order items with quantity discount calculation
228
+ const orderItemsData = input.items.map((item) => {
229
+ const variant = variants.find((v) => v.sku === item.sku);
230
+ const product = variant.product;
231
+ // Apply quantity discount if available
232
+ let finalPrice = Number(variant.price);
233
+ if (product.quantityDiscounts && typeof product.quantityDiscounts === 'object') {
234
+ const discounts = product.quantityDiscounts;
235
+ const applicableQuantities = Object.keys(discounts)
236
+ .map(Number)
237
+ .filter((qty) => item.quantity >= qty)
238
+ .sort((a, b) => b - a);
239
+ if (applicableQuantities.length > 0) {
240
+ const discountPercent = discounts[applicableQuantities[0].toString()];
241
+ finalPrice = Math.round(Number(variant.price) * (1 - discountPercent / 100));
242
+ }
243
+ }
244
+ return {
245
+ orderId: id,
246
+ variantId: variant.id,
247
+ quantity: item.quantity,
248
+ priceAtPurchase: finalPrice,
249
+ };
250
+ });
251
+ await prisma.orderItem.createMany({
252
+ data: orderItemsData,
253
+ });
254
+ // Calculate subtotal with quantity discounts
255
+ const subtotal = orderItemsData.reduce((sum, item) => {
256
+ return sum + Number(item.priceAtPurchase) * item.quantity;
257
+ }, 0);
258
+ const deliveryCharge = input.deliveryCharge ?? Number(order.deliveryCharge);
259
+ const discount = input.discount ?? 0;
260
+ const totalPrice = Math.max(0, subtotal + deliveryCharge - discount);
261
+ // Update order with new total and other fields
262
+ const updatedOrder = await prisma.order.update({
263
+ where: { id },
264
+ data: {
265
+ firstName: input.firstName ?? order.firstName,
266
+ lastName: input.lastName ?? order.lastName,
267
+ phone: input.phone ?? order.phone,
268
+ address: input.address ?? order.address,
269
+ city: input.city ?? order.city,
270
+ deliveryCharge,
271
+ paymentMethod: (input.paymentMethod ?? order.paymentMethod),
272
+ paystackReference: input.paystackReference ?? order.paystackReference,
273
+ totalPrice,
274
+ },
275
+ include: ORDER_INCLUDE_FULL
276
+ });
277
+ return c.json(updatedOrder);
278
+ }
279
+ // Update order without changing items (just customer info, delivery, payment method, etc.)
280
+ const deliveryCharge = input.deliveryCharge ?? Number(order.deliveryCharge);
281
+ // If discount changed, recalculate total
282
+ let totalPrice = Number(order.totalPrice);
283
+ if (input.discount !== undefined || input.deliveryCharge !== undefined) {
284
+ // Get current items to recalculate
285
+ const currentItems = await prisma.orderItem.findMany({
286
+ where: { orderId: id },
287
+ });
288
+ const subtotal = currentItems.reduce((sum, item) => {
289
+ return sum + Number(item.priceAtPurchase) * item.quantity;
290
+ }, 0);
291
+ const discount = input.discount ?? 0;
292
+ totalPrice = Math.max(0, subtotal + deliveryCharge - discount);
293
+ }
294
+ const updatedOrder = await prisma.order.update({
295
+ where: { id },
296
+ data: {
297
+ firstName: input.firstName,
298
+ lastName: input.lastName,
299
+ phone: input.phone,
300
+ address: input.address,
301
+ city: input.city,
302
+ deliveryCharge: input.deliveryCharge,
303
+ paymentMethod: input.paymentMethod,
304
+ paystackReference: input.paystackReference,
305
+ totalPrice,
306
+ },
307
+ include: ORDER_INCLUDE_FULL
308
+ });
309
+ return c.json(formatOrderResponse(updatedOrder));
310
+ }
311
+ catch (error) {
312
+ return c.json({
313
+ error: { code: 'INTERNAL_ERROR', message: error?.message || 'Failed to update order' },
314
+ }, 500);
315
+ }
316
+ }).patch('/:id/status', zValidator('param', z.object({ id: z.string().uuid() })), zValidator('json', updateOrderStatusSchema), async (c) => {
317
+ try {
318
+ const { id } = c.req.valid('param');
319
+ const input = c.req.valid('json');
320
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
321
+ const order = await prisma.order.findFirst({
322
+ where: { id, deletedAt: null },
323
+ include: {
324
+ items: true,
325
+ brand: true,
326
+ },
327
+ });
328
+ if (!order) {
329
+ return c.json({
330
+ error: { code: 'ORDER_NOT_FOUND', message: 'Order not found' },
331
+ }, 404);
332
+ }
333
+ // Track inventory adjustment transitions to prevent duplicate adjustments
334
+ const previousStatus = order.status;
335
+ const newStatus = input.status;
336
+ // Statuses where inventory has been deducted
337
+ const inventoryDeductedStatuses = ['shipped', 'delivered'];
338
+ const inventoryRestoredStatuses = ['pending', 'cancelled', 'returned'];
339
+ const wasInventoryDeducted = inventoryDeductedStatuses.includes(previousStatus);
340
+ const shouldRestoreInventory = wasInventoryDeducted && inventoryRestoredStatuses.includes(newStatus);
341
+ const shouldDeductInventory = newStatus === 'shipped' && !inventoryDeductedStatuses.includes(previousStatus);
342
+ // Restore inventory if transitioning from shipped/delivered to pending/cancelled/returned
343
+ if (shouldRestoreInventory) {
344
+ await prisma.$transaction(async (tx) => {
345
+ // Get all order items with warehouses
346
+ const orderItemsWithWarehouses = await tx.orderItem.findMany({
347
+ where: {
348
+ orderId: id,
349
+ warehouseId: { not: null }
350
+ },
351
+ include: {
352
+ variant: true,
353
+ },
354
+ });
355
+ for (const orderItem of orderItemsWithWarehouses) {
356
+ if (orderItem.variant && orderItem.variant.trackInventory && orderItem.warehouseId) {
357
+ // Credit inventory back (positive quantity)
358
+ await adjustInventory(tx, {
359
+ variantId: orderItem.variantId,
360
+ warehouseId: orderItem.warehouseId,
361
+ quantity: orderItem.quantity, // Positive to add back
362
+ type: 'adjustment',
363
+ reason: `Order #${order.orderNumber} status changed from ${previousStatus} to ${newStatus} - inventory restored`,
364
+ orderId: order.id,
365
+ userId: c.get('dbUser')?.id,
366
+ });
367
+ }
368
+ }
369
+ });
370
+ }
371
+ // If status is "shipped", require warehouse selection and decrement inventory
372
+ if (newStatus === 'shipped' && previousStatus !== 'shipped') {
373
+ if (!input.warehouses || input.warehouses.length === 0) {
374
+ return c.json({
375
+ error: { code: 'WAREHOUSE_REQUIRED', message: 'Warehouse selection required when shipping order' },
376
+ }, 400);
377
+ }
378
+ // Validate all order items have warehouse assigned
379
+ const orderItemIds = order.items.map((item) => item.id);
380
+ const providedItemIds = input.warehouses.map((w) => w.orderItemId);
381
+ if (orderItemIds.some((id) => !providedItemIds.includes(id))) {
382
+ return c.json({
383
+ error: { code: 'INCOMPLETE_WAREHOUSE_ASSIGNMENT', message: 'All order items must have warehouse assigned' },
384
+ }, 400);
385
+ }
386
+ // Update warehouse assignments and decrement inventory (only if transitioning TO shipped)
387
+ await prisma.$transaction(async (tx) => {
388
+ for (const warehouseAssignment of input.warehouses) {
389
+ const orderItem = order.items.find((item) => item.id === warehouseAssignment.orderItemId);
390
+ if (!orderItem)
391
+ continue;
392
+ // Update order item with warehouse
393
+ await tx.orderItem.update({
394
+ where: { id: orderItem.id },
395
+ data: { warehouseId: warehouseAssignment.warehouseId },
396
+ });
397
+ // Only adjust inventory if this is a NEW transition to shipped status
398
+ if (shouldDeductInventory) {
399
+ // Check if variant tracks inventory
400
+ const variant = await tx.productVariant.findUnique({
401
+ where: { id: orderItem.variantId },
402
+ });
403
+ if (variant && variant.trackInventory) {
404
+ // Decrement inventory
405
+ await adjustInventory(tx, {
406
+ variantId: orderItem.variantId,
407
+ warehouseId: warehouseAssignment.warehouseId,
408
+ quantity: -orderItem.quantity,
409
+ type: 'sale',
410
+ reason: `Order #${order.orderNumber} shipped - inventory deducted`,
411
+ orderId: order.id,
412
+ userId: c.get('dbUser')?.id,
413
+ });
414
+ }
415
+ }
416
+ }
417
+ // Update order status
418
+ await tx.order.update({
419
+ where: { id },
420
+ data: { status: input.status },
421
+ });
422
+ });
423
+ try {
424
+ const { enqueueOrderNotification } = await import('../../notifications/producers/order-notification');
425
+ await enqueueOrderNotification(c.env, 'order_shipped', order.id);
426
+ }
427
+ catch (error) {
428
+ console.error('Failed to queue order shipped notification:', error);
429
+ }
430
+ }
431
+ else if (newStatus === OrderStatus.prospect && previousStatus !== OrderStatus.prospect) {
432
+ // Handle prospect status with reason
433
+ if (!input.prospectReason) {
434
+ return c.json({
435
+ error: { code: 'PROSPECT_REASON_REQUIRED', message: 'Prospect reason is required when setting status to prospect' },
436
+ }, 400);
437
+ }
438
+ // Use the markOrderAsProspect function to handle all the logic
439
+ await markOrderAsProspect(prisma, id, input.prospectReason, input.prospectNote || '', c.get('dbUser')?.id);
440
+ // Queue the first prospect recovery email immediately
441
+ // (manual contact already attempted, so no delay needed)
442
+ try {
443
+ const { enqueueProspectRecoveryNotification } = await import('../../notifications/producers/prospect-recovery-notification');
444
+ await enqueueProspectRecoveryNotification(c.env, 'prospect_recovery_reminder', id, 1);
445
+ }
446
+ catch (error) {
447
+ console.error('Failed to queue prospect recovery email:', error);
448
+ // Don't fail the status update if queuing fails
449
+ }
450
+ }
451
+ else {
452
+ // For other status updates, just update the status
453
+ await prisma.order.update({
454
+ where: { id },
455
+ data: {
456
+ status: input.status,
457
+ cancellationReason: input.cancellationReason,
458
+ },
459
+ });
460
+ try {
461
+ let messageType = null;
462
+ if (newStatus === 'delivered' && previousStatus !== 'delivered') {
463
+ messageType = 'order_delivered';
464
+ }
465
+ else if ((newStatus === 'cancelled' && previousStatus !== 'cancelled') || (newStatus === 'returned' && previousStatus !== 'returned')) {
466
+ messageType = 'order_cancelled';
467
+ }
468
+ if (messageType) {
469
+ try {
470
+ const { enqueueOrderNotification } = await import('../../notifications/producers/order-notification');
471
+ await enqueueOrderNotification(c.env, messageType, order.id);
472
+ }
473
+ catch (error) {
474
+ console.error('Failed to queue order status update notification:', error);
475
+ }
476
+ }
477
+ }
478
+ catch (error) {
479
+ console.error('Failed to queue order status update notification:', error);
480
+ }
481
+ // Queue Meta CAPI ConfirmedPurchase event when order is delivered & paid
482
+ if (newStatus === 'delivered' && previousStatus !== 'delivered') {
483
+ try {
484
+ const { enqueueConfirmedPurchaseEvent } = await import('../../notifications/producers/meta-capi-producer');
485
+ // Fetch full order details with items for the CAPI event
486
+ const fullOrder = await prisma.order.findFirst({
487
+ where: { id, deletedAt: null },
488
+ include: ORDER_INCLUDE_FULL,
489
+ });
490
+ if (fullOrder) {
491
+ await enqueueConfirmedPurchaseEvent(c.env, fullOrder);
492
+ }
493
+ }
494
+ catch (error) {
495
+ console.error('Failed to queue Meta CAPI ConfirmedPurchase event:', error);
496
+ // Don't fail the status update if CAPI queuing fails
497
+ }
498
+ }
499
+ }
500
+ // Fetch updated order to return
501
+ const updatedOrder = await prisma.order.findFirst({
502
+ where: { id, deletedAt: null },
503
+ include: ORDER_INCLUDE_FULL,
504
+ });
505
+ return c.json(formatOrderResponse(updatedOrder));
506
+ }
507
+ catch (error) {
508
+ return c.json({
509
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to update order status' },
510
+ }, 500);
511
+ }
512
+ }).delete('/:id', zValidator('param', z.object({ id: z.string().uuid() })), async (c) => {
513
+ try {
514
+ const { id } = c.req.valid('param');
515
+ const prisma = getPrismaClient(c.env.DATABASE_URL);
516
+ await prisma.order.delete({
517
+ where: { id },
518
+ });
519
+ return c.status(204);
520
+ }
521
+ catch (error) {
522
+ return c.json({
523
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete order' },
524
+ }, 500);
525
+ }
526
+ });
527
+ export default app;