@instockng/api-client 1.0.6 → 1.0.8

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