@mohasinac/appkit 4.14.0 → 4.15.0

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 (157) hide show
  1. package/dist/_internal/server/features/auctions/actions.js +2 -2
  2. package/dist/_internal/server/features/cart/service.d.ts +3 -3
  3. package/dist/_internal/server/features/checkout/actions.js +19 -0
  4. package/dist/_internal/server/features/checkout/source-context.d.ts +5 -0
  5. package/dist/_internal/server/features/checkout/source-context.js +157 -0
  6. package/dist/_internal/server/features/offers/adapters.d.ts +103 -0
  7. package/dist/_internal/server/features/offers/adapters.js +88 -0
  8. package/dist/_internal/server/features/offers/index.d.ts +2 -0
  9. package/dist/_internal/server/features/offers/index.js +1 -0
  10. package/dist/_internal/server/features/orders/adapters.js +24 -0
  11. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -10
  12. package/dist/_internal/shared/actions/action-registry.js +3 -1
  13. package/dist/_internal/shared/features/auctions/schema.d.ts +14 -2
  14. package/dist/_internal/shared/features/auctions/schema.js +13 -1
  15. package/dist/_internal/shared/features/cart/schema.d.ts +14 -9
  16. package/dist/_internal/shared/features/cart/schema.js +15 -0
  17. package/dist/_internal/shared/features/products/schema.d.ts +2 -2
  18. package/dist/_internal/shared/history/history.d.ts +22 -0
  19. package/dist/_internal/shared/history/history.js +41 -1
  20. package/dist/client.d.ts +13 -2
  21. package/dist/client.js +20 -2
  22. package/dist/constants/field-names.d.ts +8 -6
  23. package/dist/constants/field-names.js +48 -6
  24. package/dist/features/account/components/UserBidsView.js +12 -4
  25. package/dist/features/account/components/UserReturnsView.d.ts +3 -0
  26. package/dist/features/account/components/UserReturnsView.js +17 -1
  27. package/dist/features/admin/components/AdminAuditLogView.js +1 -0
  28. package/dist/features/admin/components/AdminBidsView.js +15 -2
  29. package/dist/features/admin/components/AdminBlogEditorView.js +65 -33
  30. package/dist/features/admin/components/AdminFeatureEditorView.js +35 -6
  31. package/dist/features/admin/components/AdminNavEditorView.js +15 -5
  32. package/dist/features/admin/components/AdminNewsletterView.js +18 -2
  33. package/dist/features/admin/components/AdminOffersView.js +62 -18
  34. package/dist/features/admin/components/AdminPaymentMethodsView.js +24 -4
  35. package/dist/features/admin/components/AdminScammerEditorView.d.ts +2 -1
  36. package/dist/features/admin/components/AdminScammerEditorView.js +3 -13
  37. package/dist/features/admin/components/AdminScammersView.js +3 -8
  38. package/dist/features/admin/components/AdminSectionsView.js +32 -23
  39. package/dist/features/admin/components/AdminSessionsView.js +17 -2
  40. package/dist/features/admin/components/ViewAuditLogEntryModal.js +1 -0
  41. package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +4 -2
  42. package/dist/features/admin/components/sections/adminSectionsTypes.js +45 -27
  43. package/dist/features/admin/constants/filter-tabs.d.ts +3 -0
  44. package/dist/features/admin/constants/filter-tabs.js +1 -0
  45. package/dist/features/admin/schemas/homepage-section-form.d.ts +22 -0
  46. package/dist/features/admin/schemas/homepage-section-form.js +68 -0
  47. package/dist/features/admin/schemas/nav-item-form.d.ts +74 -0
  48. package/dist/features/admin/schemas/nav-item-form.js +72 -0
  49. package/dist/features/auctions/components/AuctionBidsTable.d.ts +10 -1
  50. package/dist/features/auctions/components/AuctionBidsTable.js +22 -4
  51. package/dist/features/auctions/components/bid-detail-fields.d.ts +29 -0
  52. package/dist/features/auctions/components/bid-detail-fields.js +103 -0
  53. package/dist/features/audit-log/schemas/firestore.d.ts +14 -0
  54. package/dist/features/audit-log/schemas/firestore.js +7 -0
  55. package/dist/features/blog/schemas/blog-form.d.ts +80 -0
  56. package/dist/features/blog/schemas/blog-form.js +100 -0
  57. package/dist/features/events/components/AdminEventEditorView.js +32 -54
  58. package/dist/features/events/components/AdminEventEntriesView.js +22 -3
  59. package/dist/features/events/schemas/event-form.d.ts +113 -0
  60. package/dist/features/events/schemas/event-form.js +82 -0
  61. package/dist/features/grouped/schemas/grouped-listing-form.d.ts +101 -0
  62. package/dist/features/grouped/schemas/grouped-listing-form.js +89 -0
  63. package/dist/features/homepage/actions/homepage-section-actions.d.ts +11 -3
  64. package/dist/features/homepage/actions/homepage-section-actions.js +33 -1
  65. package/dist/features/homepage/schemas/index.d.ts +31 -0
  66. package/dist/features/homepage/schemas/index.js +24 -0
  67. package/dist/features/orders/components/OrderStatusTimeline.d.ts +5 -1
  68. package/dist/features/orders/components/OrderStatusTimeline.js +39 -20
  69. package/dist/features/orders/repository/orders.repository.js +5 -0
  70. package/dist/features/orders/schemas/firestore.d.ts +36 -1
  71. package/dist/features/orders/types/index.d.ts +35 -7
  72. package/dist/features/products/components/GroupSettingsPanel.js +5 -1
  73. package/dist/features/products/schemas/index.d.ts +2 -2
  74. package/dist/features/products/schemas/product-features.validators.d.ts +73 -0
  75. package/dist/features/products/schemas/product-features.validators.js +63 -0
  76. package/dist/features/scams/constants/scammer-status-display.d.ts +28 -0
  77. package/dist/features/scams/constants/scammer-status-display.js +79 -0
  78. package/dist/features/seller/actions/offer-actions.js +32 -2
  79. package/dist/features/seller/components/OfferPhaseTimeline.d.ts +59 -0
  80. package/dist/features/seller/components/OfferPhaseTimeline.js +107 -0
  81. package/dist/features/seller/components/SellerBidsView.js +11 -4
  82. package/dist/features/seller/components/SellerCouponEditorView.js +17 -17
  83. package/dist/features/seller/components/SellerOffersView.js +2 -1
  84. package/dist/features/seller/components/SellerPayoutSettingsView.js +38 -14
  85. package/dist/features/seller/repository/offer.repository.d.ts +69 -8
  86. package/dist/features/seller/repository/offer.repository.js +127 -23
  87. package/dist/features/seller/schemas/coupon-form.d.ts +72 -0
  88. package/dist/features/seller/schemas/coupon-form.js +116 -0
  89. package/dist/features/seller/schemas/firestore.d.ts +56 -2
  90. package/dist/features/seller/schemas/firestore.js +27 -0
  91. package/dist/features/seller/schemas/offer-forms.d.ts +21 -0
  92. package/dist/features/seller/schemas/offer-forms.js +21 -0
  93. package/dist/features/shell/build-sections.d.ts +63 -0
  94. package/dist/features/shell/build-sections.js +207 -0
  95. package/dist/features/shell/field-ui-meta.d.ts +115 -0
  96. package/dist/features/shell/field-ui-meta.js +324 -0
  97. package/dist/features/shell/index.d.ts +4 -0
  98. package/dist/features/shell/index.js +2 -0
  99. package/dist/features/status-history/components/StatusTimeline.d.ts +74 -0
  100. package/dist/features/status-history/components/StatusTimeline.js +46 -0
  101. package/dist/features/status-history/index.d.ts +11 -0
  102. package/dist/features/status-history/index.js +10 -0
  103. package/dist/features/store-extensions/components/ReviewDecisionModal.d.ts +27 -0
  104. package/dist/features/store-extensions/components/ReviewDecisionModal.js +82 -0
  105. package/dist/features/store-extensions/index.d.ts +12 -0
  106. package/dist/features/store-extensions/index.js +12 -0
  107. package/dist/features/store-extensions/schemas/admin-notification-form.d.ts +36 -0
  108. package/dist/features/store-extensions/schemas/admin-notification-form.js +114 -0
  109. package/dist/features/store-extensions/schemas/analytics-forms.d.ts +57 -0
  110. package/dist/features/store-extensions/schemas/analytics-forms.js +158 -0
  111. package/dist/features/store-extensions/schemas/custom-role-form.d.ts +85 -0
  112. package/dist/features/store-extensions/schemas/custom-role-form.js +118 -0
  113. package/dist/features/store-extensions/schemas/firestore.d.ts +21 -1
  114. package/dist/features/store-extensions/schemas/firestore.js +60 -0
  115. package/dist/features/store-extensions/schemas/google-config-form.d.ts +53 -0
  116. package/dist/features/store-extensions/schemas/google-config-form.js +105 -0
  117. package/dist/features/store-extensions/schemas/item-request-create-form.d.ts +28 -0
  118. package/dist/features/store-extensions/schemas/item-request-create-form.js +80 -0
  119. package/dist/features/store-extensions/schemas/listing-template-form.d.ts +78 -0
  120. package/dist/features/store-extensions/schemas/listing-template-form.js +87 -0
  121. package/dist/features/store-extensions/schemas/moderation-review-form.d.ts +49 -0
  122. package/dist/features/store-extensions/schemas/moderation-review-form.js +101 -0
  123. package/dist/features/store-extensions/schemas/payout-method-form.d.ts +153 -0
  124. package/dist/features/store-extensions/schemas/payout-method-form.js +155 -0
  125. package/dist/features/store-extensions/schemas/report-create-form.d.ts +25 -0
  126. package/dist/features/store-extensions/schemas/report-create-form.js +79 -0
  127. package/dist/features/store-extensions/schemas/report-review-form.d.ts +47 -0
  128. package/dist/features/store-extensions/schemas/report-review-form.js +111 -0
  129. package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +148 -0
  130. package/dist/features/store-extensions/schemas/shipping-config-form.js +104 -0
  131. package/dist/features/store-extensions/schemas/store-category-form.d.ts +54 -0
  132. package/dist/features/store-extensions/schemas/store-category-form.js +73 -0
  133. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +326 -0
  134. package/dist/index.d.ts +12 -2
  135. package/dist/index.js +12 -2
  136. package/dist/next/routing/route-map.d.ts +16 -2
  137. package/dist/next/routing/route-map.js +9 -2
  138. package/dist/schemas/firestore-value.d.ts +8 -0
  139. package/dist/schemas/firestore-value.js +44 -0
  140. package/dist/schemas/index.d.ts +1 -1
  141. package/dist/schemas/index.js +1 -1
  142. package/dist/schemas/registry.d.ts +634 -1
  143. package/dist/schemas/registry.js +64 -1
  144. package/dist/seed/bids-seed-data.js +30 -0
  145. package/dist/seed/offers-seed-data.js +212 -7
  146. package/dist/seed/orders-seed-data.js +336 -1
  147. package/dist/seed/products-auctions-seed-data.js +41 -0
  148. package/dist/server.d.ts +2 -0
  149. package/dist/server.js +5 -0
  150. package/dist/styles.css +1 -1
  151. package/dist/tailwind-utilities.css +1 -1
  152. package/dist/ui/components/RecordDetailModal.d.ts +10 -1
  153. package/dist/ui/components/RecordDetailModal.js +2 -2
  154. package/package.json +2 -1
  155. package/scripts/node-esm-loader.mjs +65 -0
  156. package/scripts/seed-cli-loader.mjs +6 -49
  157. package/scripts/seed-cli.mjs +1 -1
@@ -2,13 +2,13 @@
2
2
  import { wrapAction } from "@mohasinac/appkit/server";
3
3
  import { bidRepository, productRepository, userRepository, siteSettingsRepository } from "../../../../repositories";
4
4
  import { requireRoleUser } from "../../../../providers/auth-firebase/helpers";
5
- import { placeBidSchema } from "../../../shared/features/auctions/schema";
5
+ import { placeBidActionSchema } from "../../../shared/features/auctions/schema";
6
6
  import { assertAuctionActive, assertBidAmount, assertNotAuctionOwner, shouldAutoExtend, computeExtendedEndDate, } from "./service";
7
7
  import { ValidationError } from "../../../shared/errors/index";
8
8
  export async function placeBidAction(input) {
9
9
  return wrapAction(async () => {
10
10
  const user = await requireRoleUser(["buyer", "seller", "admin"]);
11
- const parsed = placeBidSchema.safeParse(input);
11
+ const parsed = placeBidActionSchema.safeParse(input);
12
12
  if (!parsed.success)
13
13
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid bid input");
14
14
  const { auctionId, amount } = parsed.data;
@@ -1,8 +1,8 @@
1
1
  import type { z } from "zod";
2
2
  import type { addToCartSchema } from "../../../shared/features/cart/schema";
3
- type AddToCartInput = z.infer<typeof addToCartSchema>;
3
+ type AddToCartActionInput = z.infer<typeof addToCartSchema>;
4
4
  export declare function assertCartCapacity(userId: string, addingQty: number): Promise<void>;
5
5
  export declare function assertValidQuantity(quantity: number): void;
6
- export declare function upsertCartItem(userId: string, item: AddToCartInput): Promise<void>;
7
- export declare function mergeGuestItems(userId: string, guestItems: AddToCartInput[]): Promise<void>;
6
+ export declare function upsertCartItem(userId: string, item: AddToCartActionInput): Promise<void>;
7
+ export declare function mergeGuestItems(userId: string, guestItems: AddToCartActionInput[]): Promise<void>;
8
8
  export {};
@@ -1,4 +1,5 @@
1
1
  import { normalizeError } from "../../../../errors/normalize";
2
+ import { buildOrderSourceContext } from "./source-context";
2
3
  import { roundRupees } from "../../../../utils/number.formatter";
3
4
  /**
4
5
  * Checkout server actions (appkit).
@@ -488,6 +489,10 @@ async function createOrderForGroup(group, orderType, ctx) {
488
489
  const displayedUpiId = hasPaymentWindow
489
490
  ? await resolveDisplayedUpiId(firstItem.storeId, siteContactUpiVpa)
490
491
  : undefined;
492
+ // Both creation paths call the SAME builder — two hand-written copies is the
493
+ // create-vs-create axis of Root Cause #39, and these two functions have
494
+ // already drifted once before (add-on fees, Root Cause #65).
495
+ const sourceContext = await buildOrderSourceContext(firstItem, firstProduct, orderType);
491
496
  const order = await unitOfWork.orders.create({
492
497
  productId: firstItem.productId,
493
498
  productTitle: firstItem.productTitle,
@@ -502,6 +507,11 @@ async function createOrderForGroup(group, orderType, ctx) {
502
507
  storeName: firstItem.storeName || undefined,
503
508
  items: orderItems,
504
509
  orderType,
510
+ // How the buyer earned the right to buy at this price. `orderType` cannot
511
+ // answer that — it has five values, and both a settled win and a buyout
512
+ // are "auction". Written ONCE here and never recomputed: the auction and
513
+ // offer documents it summarises are mutable and eventually archived.
514
+ sourceContext,
505
515
  offerId: firstItem.offerId ?? undefined,
506
516
  // Distinguishes a Buy Now purchase from a settled auction win. Both are
507
517
  // orderType "auction"; only this one gets the 24h admin-review window.
@@ -1204,6 +1214,10 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
1204
1214
  const extraOrderFields = {
1205
1215
  ...groupRuleRzp.decorateOrderDoc(group[0].item, group[0].product),
1206
1216
  };
1217
+ // Both creation paths call the SAME builder — two hand-written copies is the
1218
+ // create-vs-create axis of Root Cause #39, and these two functions have
1219
+ // already drifted once before (add-on fees, Root Cause #65).
1220
+ const sourceContext = await buildOrderSourceContext(firstItem, group[0].product, orderType);
1207
1221
  const order = await unitOfWork.orders.create({
1208
1222
  productId: firstItem.productId,
1209
1223
  productTitle: firstItem.productTitle,
@@ -1218,6 +1232,11 @@ async function createRazorpayGroupOrder(group, orderType, ctx) {
1218
1232
  storeName: firstItem.storeName || undefined,
1219
1233
  items: orderItems,
1220
1234
  orderType,
1235
+ // How the buyer earned the right to buy at this price. `orderType` cannot
1236
+ // answer that — it has five values, and both a settled win and a buyout
1237
+ // are "auction". Written ONCE here and never recomputed: the auction and
1238
+ // offer documents it summarises are mutable and eventually archived.
1239
+ sourceContext,
1221
1240
  offerId: firstItem.offerId ?? undefined,
1222
1241
  // Distinguishes a Buy Now purchase from a settled auction win. Both are
1223
1242
  // orderType "auction"; only this one gets the 24h admin-review window.
@@ -0,0 +1,5 @@
1
+ import type { CartItemDocument } from "../../../../features/cart/schemas/firestore";
2
+ import type { ProductDocument } from "../../../../features/products/schemas/firestore";
3
+ import type { OrderSourceContext } from "../../../../features/orders/schemas/firestore";
4
+ import type { OrderType } from "../../../../features/orders/utils/order-splitter";
5
+ export declare function buildOrderSourceContext(item: CartItemDocument, product: ProductDocument, orderType: OrderType): Promise<OrderSourceContext | undefined>;
@@ -0,0 +1,157 @@
1
+ /*
2
+ * WHY: An order records WHAT was bought and WHAT it cost, and nothing about how
3
+ * the buyer earned the right to buy it at that price. `orderType` has five
4
+ * values and cannot even separate "won at ₹4,200 against 11 bids" from
5
+ * "bought out at ₹6,000 while the standing bid was ₹3,800" — both land as
6
+ * `orderType: "auction"`. Everything else was recoverable only by reading
7
+ * the auction or offer document, which is mutable and eventually archived.
8
+ * WHAT: `buildOrderSourceContext()` — the ONE builder for `OrderSourceContext`,
9
+ * called by both order-creation paths (manual/COD and Razorpay).
10
+ *
11
+ * ## Why one shared function and not two inline blocks
12
+ *
13
+ * `createOrderForGroup` and `createRazorpayGroupOrder` are near-identical
14
+ * ~200-line twins that already drifted once on add-on fees (Root Cause #65).
15
+ * Two hand-written copies of this would be the create-vs-create axis of Root
16
+ * Cause #39: one lane records provenance, the other silently doesn't, and the
17
+ * difference is invisible until someone compares two orders months later.
18
+ *
19
+ * ## Written once, never recomputed
20
+ *
21
+ * These numbers describe a moment. `product.price` moves, `currentBid` is a
22
+ * mirror that a pending buyout deliberately never writes to, and a settled
23
+ * auction is eventually archived. Recomputing later would silently rewrite
24
+ * history, so nothing outside order creation may write this field.
25
+ *
26
+ * ## Read cost
27
+ *
28
+ * Standard, pre-order and prize-draw cost **zero** extra reads — everything
29
+ * comes from the product already in hand. Only the auction and offer lanes pay:
30
+ * one `findByProductSorted` for auctions (bid count, winner, runner-up, the
31
+ * standing bid — all four from one query) and one `findById` for offers. Since
32
+ * a checkout is single-lane by construction, a standard cart never pays either.
33
+ *
34
+ * ## Failure is non-fatal, deliberately
35
+ *
36
+ * A missing bid or offer document returns `undefined` rather than throwing. The
37
+ * money path must not fail because a provenance record could not be assembled —
38
+ * an order with no `sourceContext` is a thinner record; a checkout that 500s
39
+ * after payment authorisation is a support ticket.
40
+ *
41
+ * EXPORTS:
42
+ * buildOrderSourceContext
43
+ *
44
+ * @tag domain:checkout,orders
45
+ * @tag layer:server-feature
46
+ * @tag pattern:none
47
+ * @tag access:server
48
+ * @tag consumers:checkout/actions.ts
49
+ * @tag sideEffects:firestore-read
50
+ */
51
+ import { bidRepository } from "../../../../features/auctions/repository/bid.repository";
52
+ import { offerRepository } from "../../../../features/seller/repository/offer.repository";
53
+ import { normalizeError } from "../../../../errors/normalize";
54
+ import { serverLogger } from "../../../../monitoring";
55
+ /** Bids that count toward "against N bidders" — a buyout is excluded. */
56
+ function competitiveBids(bids) {
57
+ return bids.filter((b) => !b.isBuyout).sort((a, b) => b.bidAmount - a.bidAmount);
58
+ }
59
+ export async function buildOrderSourceContext(item, product, orderType) {
60
+ try {
61
+ // The offer lane is checked BEFORE orderType, because an accepted offer on
62
+ // a standard listing arrives as orderType "offer" but an accepted offer on
63
+ // a classified does not — the cart line's own `offerId` is the reliable
64
+ // discriminator, exactly as `laneOf()` treats it.
65
+ if (item.offerId) {
66
+ const offer = await offerRepository.findById(item.offerId);
67
+ if (!offer)
68
+ return undefined;
69
+ return {
70
+ path: "offer-accepted",
71
+ offerId: offer.id,
72
+ offeredAmount: offer.offerAmount,
73
+ listPriceAtOffer: offer.listedPrice,
74
+ counterAmount: offer.counterAmount,
75
+ // Persisted since W2. Before the chain fields existed this number was
76
+ // genuinely unknowable — each counter created an unlinked document.
77
+ counterRounds: offer.counterRound ?? 1,
78
+ acceptedBy: offer.storeId,
79
+ acceptedAt: offer.acceptedAt ?? offer.respondedAt ?? new Date(),
80
+ checkoutDeadline: offer.checkoutDeadline,
81
+ };
82
+ }
83
+ if (item.bidId) {
84
+ const bids = await bidRepository.findByProductSorted(item.productId);
85
+ const rivals = competitiveBids(bids);
86
+ const auctionId = item.auctionId ?? item.productId;
87
+ if (item.isBuyout) {
88
+ return {
89
+ path: "auction-buy-now",
90
+ auctionId,
91
+ bidId: item.bidId,
92
+ buyNowPrice: item.lockedPrice ?? product.buyNowPrice ?? item.price,
93
+ listPrice: product.price,
94
+ // From the bids, not `product.currentBid` — a pending buyout never
95
+ // writes that mirror, so reading it would understate the auction.
96
+ standingBidAtBuyout: rivals[0]?.bidAmount ?? 0,
97
+ bidCount: rivals.length,
98
+ boughtAt: new Date(),
99
+ checkoutDeadline: item.checkoutDeadline ?? new Date(),
100
+ auctionEndDate: product.auctionEndDate,
101
+ };
102
+ }
103
+ const winning = bids.find((b) => b.id === item.bidId);
104
+ const winningAmount = winning?.bidAmount ?? item.lockedPrice ?? item.price;
105
+ const reserve = product.reservePrice;
106
+ return {
107
+ path: "auction-won",
108
+ auctionId,
109
+ bidId: item.bidId,
110
+ winningBidAmount: winningAmount,
111
+ bidCount: rivals.length,
112
+ startingBid: product.startingBid ?? 0,
113
+ reservePrice: reserve,
114
+ // No reserve set means the reserve cannot fail, so it is met.
115
+ reserveMet: reserve == null ? true : winningAmount >= reserve,
116
+ auctionEndedAt: product.auctionEndDate ?? new Date(),
117
+ runnerUpAmount: rivals.find((b) => b.id !== item.bidId)?.bidAmount,
118
+ };
119
+ }
120
+ if (orderType === "preorder") {
121
+ const deposit = product.preOrderDepositAmount ??
122
+ (product.preOrderDepositPercent != null
123
+ ? (product.price * product.preOrderDepositPercent) / 100
124
+ : 0);
125
+ return {
126
+ path: "pre-order",
127
+ depositAmount: deposit,
128
+ balanceDue: Math.max(0, product.price - deposit),
129
+ expectedDeliveryDate: product.preOrderDeliveryDate,
130
+ };
131
+ }
132
+ if (orderType === "prize-draw") {
133
+ return {
134
+ path: "prize-draw",
135
+ prizeDrawProductId: item.productId,
136
+ pricePerEntry: product.pricePerEntry ?? product.price,
137
+ entryCount: item.quantity,
138
+ // `prizeDrawMode` is reveal-vs-lottery, a different axis. The reveal
139
+ // TIMING is what this records: a draw with a reveal window is
140
+ // scheduled, one without reveals as soon as it sells out.
141
+ revealMode: product.prizeRevealWindowEnd ? "scheduled" : "instant",
142
+ };
143
+ }
144
+ return { path: "standard", listPrice: product.price };
145
+ }
146
+ catch (err) {
147
+ const normalized = normalizeError(err);
148
+ // Non-fatal by design — see the header. Logged loudly so a systematic
149
+ // failure shows up as a pattern rather than as quietly thinner orders.
150
+ serverLogger.warn("[checkout] could not build sourceContext", {
151
+ productId: item.productId,
152
+ orderType,
153
+ error: normalized.message,
154
+ });
155
+ return undefined;
156
+ }
157
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Offer document → API shape.
3
+ *
4
+ * Offers had **no adapter at all** before this: all three routes
5
+ * (`/api/user/offers`, `/api/store/offers`, `/api/admin/offers`) returned raw
6
+ * `OfferDocument`s straight from the repository, carrying live `Date`
7
+ * objects. `UserOffersPanel.relativeTime` still has a defensive
8
+ * `date instanceof Date ? … : new Date(…)` branch precisely because the shape
9
+ * it receives depends on whether the value survived JSON serialisation.
10
+ *
11
+ * Mirrors `orders/adapters.ts`, including its `toIsoOrUndefined` helper.
12
+ */
13
+ import type { OfferDocument } from "../../../../features/seller/schemas";
14
+ import type { FieldChange } from "../../../shared/history/index";
15
+ /** One entry on an offer's timeline — dates as ISO, ready for JSON. */
16
+ export interface OfferTimelineEntry {
17
+ at: string;
18
+ actorUid?: string;
19
+ actorRole: "buyer" | "seller" | "admin" | "system";
20
+ changes: Record<string, FieldChange>;
21
+ reason?: string;
22
+ note?: string;
23
+ trigger: string;
24
+ }
25
+ /** One round of a negotiation, as rendered in the chain rail. */
26
+ export interface OfferChainRound {
27
+ id: string;
28
+ counterRound: number;
29
+ status: OfferDocument["status"];
30
+ offerAmount: number;
31
+ counterAmount?: number;
32
+ /**
33
+ * `withdrawn` + a `supersededByOfferId` is NOT a walk-away — the buyer
34
+ * countered. The timeline renders it neutral ("Superseded") rather than
35
+ * negative ("Withdrawn"), which is why this flag is carried explicitly
36
+ * instead of being re-derived in the component.
37
+ */
38
+ superseded: boolean;
39
+ createdAt: string;
40
+ respondedAt?: string;
41
+ sellerNote?: string;
42
+ buyerNote?: string;
43
+ }
44
+ export interface Offer {
45
+ id: string;
46
+ productId: string;
47
+ productTitle: string;
48
+ productSlug?: string;
49
+ productImageUrl?: string;
50
+ storeId: string;
51
+ storeName: string;
52
+ offerAmount: number;
53
+ listedPrice: number;
54
+ counterAmount?: number;
55
+ lockedPrice?: number;
56
+ currency: string;
57
+ status: OfferDocument["status"];
58
+ buyerNote?: string;
59
+ sellerNote?: string;
60
+ expiresAt: string;
61
+ acceptedAt?: string;
62
+ checkoutDeadline?: string;
63
+ respondedAt?: string;
64
+ paidOrderId?: string;
65
+ paidAt?: string;
66
+ createdAt: string;
67
+ updatedAt: string;
68
+ timeline?: OfferTimelineEntry[];
69
+ timelineTruncated?: number;
70
+ previousOfferId?: string;
71
+ supersededByOfferId?: string;
72
+ chainRootOfferId?: string;
73
+ counterRound?: number;
74
+ /** True when this round was superseded by the buyer's own counter. */
75
+ superseded: boolean;
76
+ cancelledByAdminUid?: string;
77
+ cancelReason?: string;
78
+ buyerUid?: string;
79
+ buyerName?: string;
80
+ buyerEmail?: string;
81
+ }
82
+ export interface OfferAdapterOptions {
83
+ /**
84
+ * Include `buyerUid`/`buyerName`/`buyerEmail`.
85
+ *
86
+ * Default **false**, deliberately. The seller-facing route masks buyer
87
+ * identity, and a route that forgot to call `maskOfferForSeller` would
88
+ * previously have shipped it verbatim — there was no adapter to stop it.
89
+ * Making the omission the default means forgetting the flag under-shares
90
+ * rather than leaks.
91
+ */
92
+ includeBuyerIdentity?: boolean;
93
+ }
94
+ export declare function offerDocumentToOffer(doc: OfferDocument, opts?: OfferAdapterOptions): Offer;
95
+ /**
96
+ * A chain round — the compact shape the timeline needs, without re-sending
97
+ * the whole document for every round.
98
+ *
99
+ * Buyer identity is never included: the chain is rendered on all three
100
+ * portals from one component, and the seller's view must not gain identity
101
+ * it is masked from elsewhere.
102
+ */
103
+ export declare function offerToChainRound(doc: OfferDocument): OfferChainRound;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Offer document → API shape.
3
+ *
4
+ * Offers had **no adapter at all** before this: all three routes
5
+ * (`/api/user/offers`, `/api/store/offers`, `/api/admin/offers`) returned raw
6
+ * `OfferDocument`s straight from the repository, carrying live `Date`
7
+ * objects. `UserOffersPanel.relativeTime` still has a defensive
8
+ * `date instanceof Date ? … : new Date(…)` branch precisely because the shape
9
+ * it receives depends on whether the value survived JSON serialisation.
10
+ *
11
+ * Mirrors `orders/adapters.ts`, including its `toIsoOrUndefined` helper.
12
+ */
13
+ function toIsoOrUndefined(value) {
14
+ return value instanceof Date ? value.toISOString() : undefined;
15
+ }
16
+ function toIso(value) {
17
+ return value instanceof Date ? value.toISOString() : String(value);
18
+ }
19
+ export function offerDocumentToOffer(doc, opts = {}) {
20
+ const superseded = doc.status === "withdrawn" && Boolean(doc.supersededByOfferId);
21
+ return {
22
+ id: doc.id,
23
+ productId: doc.productId,
24
+ productTitle: doc.productTitle,
25
+ productSlug: doc.productSlug,
26
+ productImageUrl: doc.productImageUrl,
27
+ storeId: doc.storeId,
28
+ storeName: doc.storeName,
29
+ offerAmount: doc.offerAmount,
30
+ listedPrice: doc.listedPrice,
31
+ counterAmount: doc.counterAmount,
32
+ lockedPrice: doc.lockedPrice,
33
+ currency: doc.currency,
34
+ status: doc.status,
35
+ buyerNote: doc.buyerNote,
36
+ sellerNote: doc.sellerNote,
37
+ expiresAt: toIso(doc.expiresAt),
38
+ acceptedAt: toIsoOrUndefined(doc.acceptedAt),
39
+ checkoutDeadline: toIsoOrUndefined(doc.checkoutDeadline),
40
+ respondedAt: toIsoOrUndefined(doc.respondedAt),
41
+ paidOrderId: doc.paidOrderId,
42
+ paidAt: toIsoOrUndefined(doc.paidAt),
43
+ createdAt: toIso(doc.createdAt),
44
+ updatedAt: toIso(doc.updatedAt),
45
+ timeline: doc.statusHistory?.map((entry) => ({
46
+ at: entry.at instanceof Date ? entry.at.toISOString() : String(entry.at),
47
+ actorUid: entry.actorUid,
48
+ actorRole: entry.actorRole,
49
+ changes: entry.changes,
50
+ reason: entry.reason,
51
+ note: entry.note,
52
+ trigger: entry.trigger,
53
+ })),
54
+ timelineTruncated: doc.statusHistoryTruncated,
55
+ previousOfferId: doc.previousOfferId,
56
+ supersededByOfferId: doc.supersededByOfferId,
57
+ chainRootOfferId: doc.chainRootOfferId,
58
+ counterRound: doc.counterRound,
59
+ superseded,
60
+ cancelledByAdminUid: doc.cancelledByAdminUid,
61
+ cancelReason: doc.cancelReason,
62
+ ...(opts.includeBuyerIdentity
63
+ ? { buyerUid: doc.buyerUid, buyerName: doc.buyerName, buyerEmail: doc.buyerEmail }
64
+ : {}),
65
+ };
66
+ }
67
+ /**
68
+ * A chain round — the compact shape the timeline needs, without re-sending
69
+ * the whole document for every round.
70
+ *
71
+ * Buyer identity is never included: the chain is rendered on all three
72
+ * portals from one component, and the seller's view must not gain identity
73
+ * it is masked from elsewhere.
74
+ */
75
+ export function offerToChainRound(doc) {
76
+ return {
77
+ id: doc.id,
78
+ counterRound: doc.counterRound ?? 1,
79
+ status: doc.status,
80
+ offerAmount: doc.offerAmount,
81
+ counterAmount: doc.counterAmount,
82
+ superseded: doc.status === "withdrawn" && Boolean(doc.supersededByOfferId),
83
+ createdAt: toIso(doc.createdAt),
84
+ respondedAt: toIsoOrUndefined(doc.respondedAt),
85
+ sellerNote: doc.sellerNote,
86
+ buyerNote: doc.buyerNote,
87
+ };
88
+ }
@@ -0,0 +1,2 @@
1
+ export { offerDocumentToOffer, offerToChainRound, } from "./adapters";
2
+ export type { Offer, OfferAdapterOptions, OfferChainRound, OfferTimelineEntry, } from "./adapters";
@@ -0,0 +1 @@
1
+ export { offerDocumentToOffer, offerToChainRound, } from "./adapters";
@@ -104,6 +104,30 @@ export function orderDocumentToOrder(doc) {
104
104
  paymentReviewOutcome: doc.paymentReviewOutcome,
105
105
  paymentReviewNote: doc.paymentReviewNote,
106
106
  cancellationReason: doc.cancellationReason,
107
+ // ── W2: history + provenance ──────────────────────────────────────────
108
+ //
109
+ // `Order.timeline` has existed, been exported, and had ZERO writers since
110
+ // it was declared — `OrderStatusTimeline` synthesised its steps from four
111
+ // scalar dates instead, so a status change with no dedicated date field
112
+ // (payment reviewed, refund posted) was simply invisible.
113
+ //
114
+ // `statusHistory` is that field's real source. Dates are serialised to
115
+ // ISO here for the same reason every other date on this shape is: the
116
+ // client receives JSON, and a raw `Date` arrives as a string that no
117
+ // longer satisfies the type.
118
+ timeline: doc.statusHistory?.map((entry) => ({
119
+ at: entry.at instanceof Date ? entry.at.toISOString() : String(entry.at),
120
+ actorUid: entry.actorUid,
121
+ actorRole: entry.actorRole,
122
+ changes: entry.changes,
123
+ reason: entry.reason,
124
+ note: entry.note,
125
+ trigger: entry.trigger,
126
+ })),
127
+ /** Entries trimmed off the front — so the UI never implies "this is all of it". */
128
+ timelineTruncated: doc.statusHistoryTruncated,
129
+ /** How the buyer acquired the right to buy. Written once, at creation. */
130
+ sourceContext: doc.sourceContext,
107
131
  createdAt: doc.createdAt instanceof Date
108
132
  ? doc.createdAt.toISOString()
109
133
  : String(doc.createdAt),
@@ -21,10 +21,10 @@ export async function runOfferExpiry(ctx) {
21
21
  else {
22
22
  ctx.logger.info(`Found ${expiredOffers.length} expired offer(s) to process`);
23
23
  }
24
- const expiredIds = [];
24
+ const expired = [];
25
25
  for (const offer of expiredOffers) {
26
26
  try {
27
- expiredIds.push(offer.id);
27
+ expired.push(offer);
28
28
  await sendNotification({
29
29
  userId: offer.buyerUid,
30
30
  type: "offer_expired",
@@ -42,9 +42,9 @@ export async function runOfferExpiry(ctx) {
42
42
  });
43
43
  }
44
44
  }
45
- if (expiredIds.length > 0) {
45
+ if (expired.length > 0) {
46
46
  try {
47
- await offerRepository.expireMany(expiredIds);
47
+ await offerRepository.expireMany(expired, { trigger: "runOfferExpiry:active" });
48
48
  }
49
49
  catch (err) {
50
50
  ctx.logger.error("Failed to batch-expire offers", err);
@@ -54,8 +54,8 @@ export async function runOfferExpiry(ctx) {
54
54
  await expireAcceptedPastCheckoutDeadline(ctx);
55
55
  await lapseUnpaidAuctionWins(ctx);
56
56
  ctx.logger.info("Offer expiry complete", {
57
- processed: expiredIds.length,
58
- skipped: expiredOffers.length - expiredIds.length,
57
+ processed: expired.length,
58
+ skipped: expiredOffers.length - expired.length,
59
59
  });
60
60
  }
61
61
  /**
@@ -81,14 +81,14 @@ async function expireAcceptedPastCheckoutDeadline(ctx) {
81
81
  if (stale.length === 0)
82
82
  return;
83
83
  ctx.logger.info(`Found ${stale.length} accepted offer(s) past their checkout deadline`);
84
- const ids = [];
84
+ const lapsed = [];
85
85
  for (const offer of stale) {
86
86
  try {
87
87
  // Drop the locked cart line first — leaving it behind would keep the
88
88
  // buyer's offer lane non-empty and block their standard checkout on an
89
89
  // offer they can no longer act on.
90
90
  await cartRepository.removeItemsByOfferId(offer.buyerUid, offer.id);
91
- ids.push(offer.id);
91
+ lapsed.push(offer);
92
92
  await sendNotification({
93
93
  userId: offer.buyerUid,
94
94
  type: "offer_expired",
@@ -106,9 +106,9 @@ async function expireAcceptedPastCheckoutDeadline(ctx) {
106
106
  });
107
107
  }
108
108
  }
109
- if (ids.length > 0) {
109
+ if (lapsed.length > 0) {
110
110
  try {
111
- await offerRepository.expireMany(ids);
111
+ await offerRepository.expireMany(lapsed, { trigger: "runOfferExpiry:acceptedLapsed" });
112
112
  }
113
113
  catch (err) {
114
114
  void normalizeError(err);
@@ -1882,7 +1882,9 @@ export const ACTIONS = {
1882
1882
  ariaLabel: "Cancel this offer",
1883
1883
  description: "Expire the offer, clear it from the buyer's cart, and notify them. Admin cannot accept or counter on a seller's behalf.",
1884
1884
  kind: "danger",
1885
- permissions: ["admin", "moderator"],
1885
+ // Admin only. The route is ROLES_ADMIN_ONLY, so listing "moderator" here
1886
+ // showed a moderator an action that could only ever return 403.
1887
+ permissions: ["admin"],
1886
1888
  confirmation: {
1887
1889
  title: "Cancel this offer?",
1888
1890
  body: "The offer will be expired and removed from the buyer's cart, and they'll be notified. This cannot be undone — the buyer would need to make a new offer.",
@@ -1,5 +1,17 @@
1
1
  import { z } from "zod";
2
- export declare const placeBidSchema: z.ZodObject<{
2
+ /**
3
+ * Server-action input for placing a bid.
4
+ *
5
+ * Renamed from `placeBidSchema` 2026-08-24 (W3): a DIFFERENT `placeBidSchema`
6
+ * exists at `features/auctions/schemas/bid-input.ts`, and the two are not
7
+ * variants of one thing — that one is a client FORM factory taking the minimum
8
+ * bid and returning `{ bidAmount }`, this one is the action payload
9
+ * `{ auctionId, amount }`. Same name, different layer, different field names,
10
+ * incompatible shapes. Two same-named exports in one package is a defect even
11
+ * when both are correct, because the choice between them is invisible at the
12
+ * call site (Root Cause #36, one union over).
13
+ */
14
+ export declare const placeBidActionSchema: z.ZodObject<{
3
15
  auctionId: z.ZodString;
4
16
  amount: z.ZodNumber;
5
17
  }, "strip", z.ZodTypeAny, {
@@ -26,5 +38,5 @@ export declare const buyNowSchema: z.ZodObject<{
26
38
  }, {
27
39
  auctionId: string;
28
40
  }>;
29
- export type PlaceBidInput = z.infer<typeof placeBidSchema>;
41
+ export type PlaceBidActionInput = z.infer<typeof placeBidActionSchema>;
30
42
  export type CancelBidInput = z.infer<typeof cancelBidSchema>;
@@ -1,5 +1,17 @@
1
1
  import { z } from "zod";
2
- export const placeBidSchema = z.object({
2
+ /**
3
+ * Server-action input for placing a bid.
4
+ *
5
+ * Renamed from `placeBidSchema` 2026-08-24 (W3): a DIFFERENT `placeBidSchema`
6
+ * exists at `features/auctions/schemas/bid-input.ts`, and the two are not
7
+ * variants of one thing — that one is a client FORM factory taking the minimum
8
+ * bid and returning `{ bidAmount }`, this one is the action payload
9
+ * `{ auctionId, amount }`. Same name, different layer, different field names,
10
+ * incompatible shapes. Two same-named exports in one package is a defect even
11
+ * when both are correct, because the choice between them is invisible at the
12
+ * call site (Root Cause #36, one union over).
13
+ */
14
+ export const placeBidActionSchema = z.object({
3
15
  auctionId: z.string().min(1),
4
16
  amount: z.number().positive("Bid amount must be a positive number"),
5
17
  });