@mohasinac/appkit 4.14.0 → 4.16.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.
- package/dist/_internal/server/features/auctions/actions.js +2 -2
- package/dist/_internal/server/features/cart/service.d.ts +3 -3
- package/dist/_internal/server/features/checkout/actions.js +19 -0
- package/dist/_internal/server/features/checkout/source-context.d.ts +5 -0
- package/dist/_internal/server/features/checkout/source-context.js +157 -0
- package/dist/_internal/server/features/offers/adapters.d.ts +103 -0
- package/dist/_internal/server/features/offers/adapters.js +88 -0
- package/dist/_internal/server/features/offers/index.d.ts +2 -0
- package/dist/_internal/server/features/offers/index.js +1 -0
- package/dist/_internal/server/features/orders/adapters.js +24 -0
- package/dist/_internal/server/jobs/core/offerExpiry.js +10 -10
- package/dist/_internal/shared/actions/action-registry.d.ts +9 -2
- package/dist/_internal/shared/actions/action-registry.js +458 -7
- package/dist/_internal/shared/features/auctions/schema.d.ts +14 -2
- package/dist/_internal/shared/features/auctions/schema.js +13 -1
- package/dist/_internal/shared/features/blog/schema.d.ts +4 -4
- package/dist/_internal/shared/features/cart/schema.d.ts +25 -20
- package/dist/_internal/shared/features/cart/schema.js +15 -0
- package/dist/_internal/shared/features/categories/bundle-schemas.d.ts +22 -22
- package/dist/_internal/shared/features/orders/schema.d.ts +4 -4
- package/dist/_internal/shared/features/products/schema.d.ts +27 -27
- package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
- package/dist/_internal/shared/features/reviews/schema.d.ts +2 -2
- package/dist/_internal/shared/history/history.d.ts +22 -0
- package/dist/_internal/shared/history/history.js +41 -1
- package/dist/client.d.ts +13 -2
- package/dist/client.js +20 -2
- package/dist/constants/field-names.d.ts +8 -6
- package/dist/constants/field-names.js +48 -6
- package/dist/contracts/extend.d.ts +29 -0
- package/dist/contracts/extend.js +20 -0
- package/dist/contracts/index.d.ts +2 -1
- package/dist/contracts/index.js +2 -0
- package/dist/features/account/components/UserBidsView.js +12 -4
- package/dist/features/account/components/UserReturnsView.d.ts +3 -0
- package/dist/features/account/components/UserReturnsView.js +17 -1
- package/dist/features/admin/components/AdminAuditLogView.js +1 -0
- package/dist/features/admin/components/AdminBidsView.js +15 -2
- package/dist/features/admin/components/AdminBlogEditorView.js +65 -33
- package/dist/features/admin/components/AdminFeatureEditorView.js +35 -6
- package/dist/features/admin/components/AdminNavEditorView.js +15 -5
- package/dist/features/admin/components/AdminNewsletterView.js +18 -2
- package/dist/features/admin/components/AdminOffersView.js +62 -18
- package/dist/features/admin/components/AdminPaymentMethodsView.js +24 -4
- package/dist/features/admin/components/AdminScammerEditorView.d.ts +2 -1
- package/dist/features/admin/components/AdminScammerEditorView.js +3 -13
- package/dist/features/admin/components/AdminScammersView.js +3 -8
- package/dist/features/admin/components/AdminSectionsView.js +32 -23
- package/dist/features/admin/components/AdminSessionsView.js +17 -2
- package/dist/features/admin/components/AdminViewCards.js +24 -17
- package/dist/features/admin/components/DataListingView.js +15 -1
- package/dist/features/admin/components/DataTable.js +7 -17
- package/dist/features/admin/components/ViewAuditLogEntryModal.js +1 -0
- package/dist/features/admin/components/sections/adminSectionsTypes.d.ts +4 -2
- package/dist/features/admin/components/sections/adminSectionsTypes.js +45 -27
- package/dist/features/admin/constants/filter-tabs.d.ts +3 -0
- package/dist/features/admin/constants/filter-tabs.js +1 -0
- package/dist/features/admin/schemas/homepage-section-form.d.ts +22 -0
- package/dist/features/admin/schemas/homepage-section-form.js +68 -0
- package/dist/features/admin/schemas/nav-item-form.d.ts +74 -0
- package/dist/features/admin/schemas/nav-item-form.js +72 -0
- package/dist/features/auctions/components/AuctionBidsTable.d.ts +10 -1
- package/dist/features/auctions/components/AuctionBidsTable.js +22 -4
- package/dist/features/auctions/components/bid-detail-fields.d.ts +29 -0
- package/dist/features/auctions/components/bid-detail-fields.js +103 -0
- package/dist/features/auctions/schemas/index.d.ts +14 -14
- package/dist/features/audit-log/schemas/firestore.d.ts +14 -0
- package/dist/features/audit-log/schemas/firestore.js +7 -0
- package/dist/features/auth/schemas/index.d.ts +2 -2
- package/dist/features/blog/actions/blog-actions.d.ts +7 -7
- package/dist/features/blog/schemas/blog-form.d.ts +80 -0
- package/dist/features/blog/schemas/blog-form.js +100 -0
- package/dist/features/blog/schemas/index.d.ts +10 -10
- package/dist/features/cart/schemas/index.d.ts +3 -3
- package/dist/features/catalogue/schemas/validation.d.ts +12 -12
- package/dist/features/categories/schemas/index.d.ts +16 -16
- package/dist/features/collections/schemas/index.d.ts +8 -8
- package/dist/features/events/components/AdminEventEditorView.js +32 -54
- package/dist/features/events/components/AdminEventEntriesView.js +22 -3
- package/dist/features/events/schemas/event-form.d.ts +113 -0
- package/dist/features/events/schemas/event-form.js +82 -0
- package/dist/features/events/schemas/index.d.ts +2 -2
- package/dist/features/faq/actions/faq-actions.d.ts +6 -6
- package/dist/features/faq/schemas/index.d.ts +6 -6
- package/dist/features/grouped/schemas/grouped-listing-form.d.ts +101 -0
- package/dist/features/grouped/schemas/grouped-listing-form.js +89 -0
- package/dist/features/grouped/schemas/index.d.ts +4 -4
- package/dist/features/homepage/actions/homepage-section-actions.d.ts +11 -3
- package/dist/features/homepage/actions/homepage-section-actions.js +33 -1
- package/dist/features/homepage/schemas/index.d.ts +71 -40
- package/dist/features/homepage/schemas/index.js +24 -0
- package/dist/features/messages/schemas/index.d.ts +2 -2
- package/dist/features/orders/components/OrderStatusTimeline.d.ts +5 -1
- package/dist/features/orders/components/OrderStatusTimeline.js +39 -20
- package/dist/features/orders/repository/orders.repository.js +5 -0
- package/dist/features/orders/schemas/firestore.d.ts +36 -1
- package/dist/features/orders/schemas/index.d.ts +24 -24
- package/dist/features/orders/types/index.d.ts +35 -7
- package/dist/features/pre-orders/schemas/index.d.ts +4 -4
- package/dist/features/products/components/GroupSettingsPanel.js +5 -1
- package/dist/features/products/schemas/index.d.ts +38 -38
- package/dist/features/products/schemas/product-features.validators.d.ts +75 -2
- package/dist/features/products/schemas/product-features.validators.js +63 -0
- package/dist/features/promotions/schemas/index.d.ts +14 -14
- package/dist/features/reviews/schemas/index.d.ts +14 -14
- package/dist/features/scams/constants/scammer-status-display.d.ts +28 -0
- package/dist/features/scams/constants/scammer-status-display.js +79 -0
- package/dist/features/scams/schemas/index.d.ts +2 -2
- package/dist/features/search/schemas/index.d.ts +2 -2
- package/dist/features/seller/actions/offer-actions.js +32 -2
- package/dist/features/seller/components/OfferPhaseTimeline.d.ts +59 -0
- package/dist/features/seller/components/OfferPhaseTimeline.js +107 -0
- package/dist/features/seller/components/SellerBidsView.js +11 -4
- package/dist/features/seller/components/SellerCouponEditorView.js +17 -17
- package/dist/features/seller/components/SellerOffersView.js +2 -1
- package/dist/features/seller/components/SellerPayoutSettingsView.js +38 -14
- package/dist/features/seller/repository/offer.repository.d.ts +69 -8
- package/dist/features/seller/repository/offer.repository.js +127 -23
- package/dist/features/seller/schemas/coupon-form.d.ts +72 -0
- package/dist/features/seller/schemas/coupon-form.js +116 -0
- package/dist/features/seller/schemas/firestore.d.ts +56 -2
- package/dist/features/seller/schemas/firestore.js +27 -0
- package/dist/features/seller/schemas/index.d.ts +2 -2
- package/dist/features/seller/schemas/offer-forms.d.ts +21 -0
- package/dist/features/seller/schemas/offer-forms.js +21 -0
- package/dist/features/shell/build-sections.d.ts +63 -0
- package/dist/features/shell/build-sections.js +207 -0
- package/dist/features/shell/field-ui-meta.d.ts +115 -0
- package/dist/features/shell/field-ui-meta.js +324 -0
- package/dist/features/shell/index.d.ts +4 -0
- package/dist/features/shell/index.js +2 -0
- package/dist/features/shipments/schemas/validation.d.ts +22 -22
- package/dist/features/status-history/components/StatusTimeline.d.ts +74 -0
- package/dist/features/status-history/components/StatusTimeline.js +46 -0
- package/dist/features/status-history/index.d.ts +11 -0
- package/dist/features/status-history/index.js +10 -0
- package/dist/features/store-extensions/components/ReviewDecisionModal.d.ts +27 -0
- package/dist/features/store-extensions/components/ReviewDecisionModal.js +82 -0
- package/dist/features/store-extensions/index.d.ts +12 -0
- package/dist/features/store-extensions/index.js +12 -0
- package/dist/features/store-extensions/schemas/admin-notification-form.d.ts +36 -0
- package/dist/features/store-extensions/schemas/admin-notification-form.js +114 -0
- package/dist/features/store-extensions/schemas/analytics-forms.d.ts +57 -0
- package/dist/features/store-extensions/schemas/analytics-forms.js +158 -0
- package/dist/features/store-extensions/schemas/custom-role-form.d.ts +85 -0
- package/dist/features/store-extensions/schemas/custom-role-form.js +118 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +21 -1
- package/dist/features/store-extensions/schemas/firestore.js +60 -0
- package/dist/features/store-extensions/schemas/google-config-form.d.ts +53 -0
- package/dist/features/store-extensions/schemas/google-config-form.js +105 -0
- package/dist/features/store-extensions/schemas/item-request-create-form.d.ts +28 -0
- package/dist/features/store-extensions/schemas/item-request-create-form.js +80 -0
- package/dist/features/store-extensions/schemas/listing-template-form.d.ts +78 -0
- package/dist/features/store-extensions/schemas/listing-template-form.js +87 -0
- package/dist/features/store-extensions/schemas/moderation-review-form.d.ts +49 -0
- package/dist/features/store-extensions/schemas/moderation-review-form.js +101 -0
- package/dist/features/store-extensions/schemas/payout-method-form.d.ts +153 -0
- package/dist/features/store-extensions/schemas/payout-method-form.js +155 -0
- package/dist/features/store-extensions/schemas/report-create-form.d.ts +25 -0
- package/dist/features/store-extensions/schemas/report-create-form.js +79 -0
- package/dist/features/store-extensions/schemas/report-review-form.d.ts +47 -0
- package/dist/features/store-extensions/schemas/report-review-form.js +111 -0
- package/dist/features/store-extensions/schemas/shipping-config-form.d.ts +148 -0
- package/dist/features/store-extensions/schemas/shipping-config-form.js +104 -0
- package/dist/features/store-extensions/schemas/store-category-form.d.ts +54 -0
- package/dist/features/store-extensions/schemas/store-category-form.js +73 -0
- package/dist/features/stores/schemas/index.d.ts +4 -4
- package/dist/features/support/schemas/index.d.ts +4 -4
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +350 -0
- package/dist/features/wishlist/hooks/useWishlistWithGuest.d.ts +1 -1
- package/dist/index.d.ts +12 -2
- package/dist/index.js +12 -2
- package/dist/next/routing/route-map.d.ts +16 -2
- package/dist/next/routing/route-map.js +9 -2
- package/dist/schemas/firestore-value.d.ts +8 -0
- package/dist/schemas/firestore-value.js +44 -0
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.js +1 -1
- package/dist/schemas/registry.d.ts +734 -101
- package/dist/schemas/registry.js +64 -1
- package/dist/seed/bids-seed-data.js +30 -0
- package/dist/seed/offers-seed-data.js +212 -7
- package/dist/seed/orders-seed-data.js +336 -1
- package/dist/seed/products-auctions-seed-data.js +41 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +5 -0
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/DataTable.js +4 -2
- package/dist/ui/columns/build-columns.d.ts +6 -1
- package/dist/ui/columns/build-columns.js +53 -0
- package/dist/ui/columns/cell-renderers.d.ts +68 -0
- package/dist/ui/columns/cell-renderers.js +111 -0
- package/dist/ui/columns/column-renderers.d.ts +6 -1
- package/dist/ui/columns/column-renderers.js +6 -1
- package/dist/ui/columns/index.d.ts +2 -1
- package/dist/ui/columns/index.js +5 -1
- package/dist/ui/components/Button.js +27 -2
- package/dist/ui/components/RecordDetailModal.d.ts +10 -1
- package/dist/ui/components/RecordDetailModal.js +2 -2
- package/dist/ui/icons/icon-registry.d.ts +65 -0
- package/dist/ui/icons/icon-registry.js +115 -0
- package/package.json +2 -1
- package/scripts/node-esm-loader.mjs +65 -0
- package/scripts/seed-cli-loader.mjs +6 -49
- package/scripts/seed-cli.mjs +1 -1
|
@@ -7,6 +7,17 @@
|
|
|
7
7
|
import { BaseRepository, parseSieveDateValue } from "../../../providers/db-firebase";
|
|
8
8
|
import type { SieveModel, FirebaseSieveResult } from "../../../providers/db-firebase";
|
|
9
9
|
import type { OfferDocument, OfferCreateInput, OfferUpdateInput } from "../schemas";
|
|
10
|
+
import { type HistoryActor } from "../../../_internal/shared/history/index";
|
|
11
|
+
/**
|
|
12
|
+
* Who/why for a write that lands in an offer's `statusHistory`.
|
|
13
|
+
* Optional throughout so existing callers keep compiling and record `system`.
|
|
14
|
+
*/
|
|
15
|
+
export interface OfferWriteContext {
|
|
16
|
+
actor?: HistoryActor;
|
|
17
|
+
trigger?: string;
|
|
18
|
+
reason?: string;
|
|
19
|
+
note?: string;
|
|
20
|
+
}
|
|
10
21
|
declare class OfferRepository extends BaseRepository<OfferDocument> {
|
|
11
22
|
constructor();
|
|
12
23
|
/** Override mapDoc to auto-decrypt PII on every offer read */
|
|
@@ -107,12 +118,32 @@ declare class OfferRepository extends BaseRepository<OfferDocument> {
|
|
|
107
118
|
* `product.updatedAt` advances past the previously-created offer dates.
|
|
108
119
|
*/
|
|
109
120
|
countByBuyerAndProduct(buyerUid: string, productId: string, since: Date): Promise<number>;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The single write choke point for offers, so `statusHistory` is appended
|
|
123
|
+
* in ONE place rather than at every call site — the same shape
|
|
124
|
+
* `OrderRepository.updateWithHistory` uses.
|
|
125
|
+
*
|
|
126
|
+
* `prior` is the already-fetched document when the caller has one. Every
|
|
127
|
+
* real caller does (`respondToOffer`, `acceptCounterOffer`, `withdrawOffer`,
|
|
128
|
+
* `counterOfferByBuyer`, `finalizeLockedLines`, the admin PATCH, both expiry
|
|
129
|
+
* sweeps), so threading it through means history costs **zero extra reads**.
|
|
130
|
+
*/
|
|
131
|
+
updateStatus(offerId: string, patch: OfferUpdateInput, ctx?: OfferWriteContext, prior?: OfferDocument | null): Promise<OfferDocument>;
|
|
132
|
+
accept(offerId: string, lockedPrice: number, sellerNote?: string, checkoutDeadline?: Date, ctx?: OfferWriteContext, prior?: OfferDocument | null): Promise<OfferDocument>;
|
|
133
|
+
decline(offerId: string, sellerNote?: string, ctx?: OfferWriteContext, prior?: OfferDocument | null): Promise<OfferDocument>;
|
|
134
|
+
counter(offerId: string, counterAmount: number, sellerNote?: string, ctx?: OfferWriteContext, prior?: OfferDocument | null): Promise<OfferDocument>;
|
|
135
|
+
acceptCounter(offerId: string, checkoutDeadline?: Date, ctx?: OfferWriteContext, prior?: OfferDocument | null): Promise<OfferDocument>;
|
|
136
|
+
/**
|
|
137
|
+
* Two distinct events share this method:
|
|
138
|
+
* - the buyer walks away → status alone
|
|
139
|
+
* - the buyer counters → status + `supersededByOfferId`
|
|
140
|
+
*
|
|
141
|
+
* The second is what makes the chain walkable forward, and it is ONE write
|
|
142
|
+
* with ONE history entry. A `withdrawn` document carrying
|
|
143
|
+
* `supersededByOfferId` renders as *Superseded* (neutral) rather than
|
|
144
|
+
* *Withdrawn* (negative) — the negotiation continued, it did not end.
|
|
145
|
+
*/
|
|
146
|
+
withdraw(offerId: string, supersededByOfferId?: string, ctx?: OfferWriteContext, prior?: OfferDocument | null): Promise<OfferDocument>;
|
|
116
147
|
/**
|
|
117
148
|
* Terminal transition, called once the buyer's order for this offer exists.
|
|
118
149
|
*
|
|
@@ -121,7 +152,21 @@ declare class OfferRepository extends BaseRepository<OfferDocument> {
|
|
|
121
152
|
* client-side patch in UserOffersPanel, which meant a reload showed the offer
|
|
122
153
|
* back at "accepted" and it could be added to the cart and ordered again.
|
|
123
154
|
*/
|
|
124
|
-
markPaid(offerId: string, orderId: string): Promise<OfferDocument>;
|
|
155
|
+
markPaid(offerId: string, orderId: string, prior?: OfferDocument | null): Promise<OfferDocument>;
|
|
156
|
+
/**
|
|
157
|
+
* Every round of one negotiation, oldest first.
|
|
158
|
+
*
|
|
159
|
+
* A single-field equality — served by Firestore's AUTOMATIC index, so this
|
|
160
|
+
* adds no composite index and none of the existing `offers` indexes is
|
|
161
|
+
* affected. The sort is done in memory because the result is at most 3 rows
|
|
162
|
+
* (the per-buyer offer limit), which is cheaper than the index a compound
|
|
163
|
+
* orderBy would demand.
|
|
164
|
+
*
|
|
165
|
+
* Offers written before `chainRootOfferId` existed return ZERO rows, not
|
|
166
|
+
* one — callers must fall back to `[offer]` and never read empty as
|
|
167
|
+
* "this offer is missing".
|
|
168
|
+
*/
|
|
169
|
+
findChain(chainRootOfferId: string): Promise<OfferDocument[]>;
|
|
125
170
|
/**
|
|
126
171
|
* Cloud Functions compatibility: pending/countered offers already expired.
|
|
127
172
|
*/
|
|
@@ -133,7 +178,23 @@ declare class OfferRepository extends BaseRepository<OfferDocument> {
|
|
|
133
178
|
* price remained claimable long after the seller expected it to lapse.
|
|
134
179
|
*/
|
|
135
180
|
findExpiredAccepted(now: Date): Promise<OfferDocument[]>;
|
|
136
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Bulk-expire, taking the DOCUMENTS rather than their ids.
|
|
183
|
+
*
|
|
184
|
+
* The signature changed deliberately. `arrayUnion` would let a batch append
|
|
185
|
+
* without a read, but it **cannot enforce the FIFO cap** — so history would
|
|
186
|
+
* grow unbounded on exactly the path that touches the most documents at
|
|
187
|
+
* once. Folding entries in memory and writing whole arrays keeps the cap,
|
|
188
|
+
* and costs **zero extra reads**: `findExpiredActive`, `findExpiredAccepted`
|
|
189
|
+
* and the admin cancel route all already hold full `OfferDocument`s.
|
|
190
|
+
*
|
|
191
|
+
* Also carries the admin path: a reasoned cancel is an expiry with an actor
|
|
192
|
+
* and a reason, recorded in the offer's own history as well as in
|
|
193
|
+
* `adminAuditLog`.
|
|
194
|
+
*/
|
|
195
|
+
expireMany(offers: OfferDocument[], ctx?: OfferWriteContext & {
|
|
196
|
+
cancelledByAdminUid?: string;
|
|
197
|
+
}): Promise<void>;
|
|
137
198
|
}
|
|
138
199
|
export declare const offerRepository: OfferRepository;
|
|
139
200
|
export { OfferRepository };
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
* Handles buyer make-an-offer, seller counter/accept/decline lifecycle.
|
|
6
6
|
*/
|
|
7
7
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
8
|
-
import { OFFER_COLLECTION, OFFER_FIELDS, createOfferId } from "../schemas";
|
|
8
|
+
import { OFFER_COLLECTION, OFFER_FIELDS, OFFER_TRACKED_FIELDS, createOfferId } from "../schemas";
|
|
9
|
+
import { withHistory } from "../../../_internal/shared/history/index";
|
|
9
10
|
import { encryptPiiFields, decryptPiiFields, OFFER_PII_FIELDS, } from "../../../security";
|
|
11
|
+
/** Max rounds in one negotiation chain — matches the per-buyer offer limit. */
|
|
12
|
+
const OFFER_CHAIN_MAX = 3;
|
|
10
13
|
class OfferRepository extends BaseRepository {
|
|
11
14
|
constructor() {
|
|
12
15
|
super(OFFER_COLLECTION);
|
|
@@ -47,6 +50,13 @@ class OfferRepository extends BaseRepository {
|
|
|
47
50
|
expiresAt,
|
|
48
51
|
createdAt: now,
|
|
49
52
|
updatedAt: now,
|
|
53
|
+
// The root of a chain stores its OWN id. Without this, round 1 is the
|
|
54
|
+
// only document with no `chainRootOfferId`, so `findChain(root.id)`
|
|
55
|
+
// would return rounds 2..N and silently omit the round the buyer
|
|
56
|
+
// actually started with. Set here rather than at the call site because
|
|
57
|
+
// the id is generated in this method.
|
|
58
|
+
chainRootOfferId: input.chainRootOfferId ?? id,
|
|
59
|
+
counterRound: input.counterRound ?? 1,
|
|
50
60
|
};
|
|
51
61
|
// Encrypt PII fields before persisting
|
|
52
62
|
const encrypted = encryptPiiFields({ ...data }, [...OFFER_PII_FIELDS]);
|
|
@@ -142,13 +152,35 @@ class OfferRepository extends BaseRepository {
|
|
|
142
152
|
return snap.size;
|
|
143
153
|
}
|
|
144
154
|
// --- Mutations -----------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The single write choke point for offers, so `statusHistory` is appended
|
|
157
|
+
* in ONE place rather than at every call site — the same shape
|
|
158
|
+
* `OrderRepository.updateWithHistory` uses.
|
|
159
|
+
*
|
|
160
|
+
* `prior` is the already-fetched document when the caller has one. Every
|
|
161
|
+
* real caller does (`respondToOffer`, `acceptCounterOffer`, `withdrawOffer`,
|
|
162
|
+
* `counterOfferByBuyer`, `finalizeLockedLines`, the admin PATCH, both expiry
|
|
163
|
+
* sweeps), so threading it through means history costs **zero extra reads**.
|
|
164
|
+
*/
|
|
165
|
+
async updateStatus(offerId, patch, ctx, prior) {
|
|
166
|
+
const full = { ...patch, updatedAt: new Date() };
|
|
167
|
+
const current = prior !== undefined ? prior : await this.findById(offerId);
|
|
168
|
+
const withEntry = withHistory(current, full, {
|
|
169
|
+
tracked: OFFER_TRACKED_FIELDS,
|
|
170
|
+
actor: ctx?.actor ?? { role: "system" },
|
|
171
|
+
trigger: ctx?.trigger ?? "offerRepository.updateStatus",
|
|
172
|
+
reason: ctx?.reason,
|
|
173
|
+
note: ctx?.note,
|
|
174
|
+
// buyerName/buyerEmail live on this document. `encryptPiiFields` is a
|
|
175
|
+
// flat top-level loop and never descends into arrays, so without this
|
|
176
|
+
// they would persist in PLAINTEXT inside statusHistory.
|
|
177
|
+
piiFields: OFFER_PII_FIELDS,
|
|
178
|
+
historyField: OFFER_FIELDS.STATUS_HISTORY,
|
|
179
|
+
truncatedField: OFFER_FIELDS.STATUS_HISTORY_TRUNCATED,
|
|
149
180
|
});
|
|
181
|
+
return this.update(offerId, (withEntry ?? full));
|
|
150
182
|
}
|
|
151
|
-
async accept(offerId, lockedPrice, sellerNote, checkoutDeadline) {
|
|
183
|
+
async accept(offerId, lockedPrice, sellerNote, checkoutDeadline, ctx, prior) {
|
|
152
184
|
// One write, not two. `checkoutDeadline` used to be missing from
|
|
153
185
|
// OfferUpdateInput, which forced callers to follow every accept() with a
|
|
154
186
|
// separate generic update() — leaving a window where an offer was
|
|
@@ -160,25 +192,28 @@ class OfferRepository extends BaseRepository {
|
|
|
160
192
|
acceptedAt: new Date(),
|
|
161
193
|
respondedAt: new Date(),
|
|
162
194
|
...(checkoutDeadline ? { checkoutDeadline } : {}),
|
|
163
|
-
});
|
|
195
|
+
}, { actor: { role: "seller" }, trigger: "respondToOffer:accept", note: sellerNote, ...ctx }, prior);
|
|
164
196
|
}
|
|
165
|
-
async decline(offerId, sellerNote) {
|
|
197
|
+
async decline(offerId, sellerNote, ctx, prior) {
|
|
166
198
|
return this.updateStatus(offerId, {
|
|
167
199
|
status: "declined",
|
|
168
200
|
sellerNote,
|
|
169
201
|
respondedAt: new Date(),
|
|
170
|
-
});
|
|
202
|
+
}, { actor: { role: "seller" }, trigger: "respondToOffer:decline", note: sellerNote, ...ctx }, prior);
|
|
171
203
|
}
|
|
172
|
-
async counter(offerId, counterAmount, sellerNote) {
|
|
204
|
+
async counter(offerId, counterAmount, sellerNote, ctx, prior) {
|
|
173
205
|
return this.updateStatus(offerId, {
|
|
174
206
|
status: "countered",
|
|
175
207
|
counterAmount,
|
|
176
208
|
sellerNote,
|
|
177
209
|
respondedAt: new Date(),
|
|
178
|
-
});
|
|
210
|
+
}, { actor: { role: "seller" }, trigger: "respondToOffer:counter", note: sellerNote, ...ctx }, prior);
|
|
179
211
|
}
|
|
180
|
-
async acceptCounter(offerId, checkoutDeadline) {
|
|
181
|
-
|
|
212
|
+
async acceptCounter(offerId, checkoutDeadline, ctx, prior) {
|
|
213
|
+
// Reuse the caller's document when supplied — this method used to always
|
|
214
|
+
// re-read, so passing `prior` makes the feature land one read CHEAPER
|
|
215
|
+
// than before rather than one more expensive.
|
|
216
|
+
const offer = prior !== undefined && prior !== null ? prior : await this.findById(offerId);
|
|
182
217
|
if (!offer || !offer.counterAmount)
|
|
183
218
|
throw new Error("Offer or counter not found");
|
|
184
219
|
return this.updateStatus(offerId, {
|
|
@@ -187,13 +222,29 @@ class OfferRepository extends BaseRepository {
|
|
|
187
222
|
acceptedAt: new Date(),
|
|
188
223
|
respondedAt: new Date(),
|
|
189
224
|
...(checkoutDeadline ? { checkoutDeadline } : {}),
|
|
190
|
-
});
|
|
225
|
+
}, { actor: { role: "buyer" }, trigger: "acceptCounterOffer", ...ctx }, offer);
|
|
191
226
|
}
|
|
192
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Two distinct events share this method:
|
|
229
|
+
* - the buyer walks away → status alone
|
|
230
|
+
* - the buyer counters → status + `supersededByOfferId`
|
|
231
|
+
*
|
|
232
|
+
* The second is what makes the chain walkable forward, and it is ONE write
|
|
233
|
+
* with ONE history entry. A `withdrawn` document carrying
|
|
234
|
+
* `supersededByOfferId` renders as *Superseded* (neutral) rather than
|
|
235
|
+
* *Withdrawn* (negative) — the negotiation continued, it did not end.
|
|
236
|
+
*/
|
|
237
|
+
async withdraw(offerId, supersededByOfferId, ctx, prior) {
|
|
193
238
|
return this.updateStatus(offerId, {
|
|
194
239
|
status: "withdrawn",
|
|
195
240
|
respondedAt: new Date(),
|
|
196
|
-
|
|
241
|
+
...(supersededByOfferId ? { supersededByOfferId } : {}),
|
|
242
|
+
}, {
|
|
243
|
+
actor: { role: "buyer" },
|
|
244
|
+
trigger: supersededByOfferId ? "counterOfferByBuyer:supersede" : "withdrawOffer",
|
|
245
|
+
...(supersededByOfferId ? { reason: "Superseded by the buyer's counter" } : {}),
|
|
246
|
+
...ctx,
|
|
247
|
+
}, prior);
|
|
197
248
|
}
|
|
198
249
|
/**
|
|
199
250
|
* Terminal transition, called once the buyer's order for this offer exists.
|
|
@@ -203,12 +254,35 @@ class OfferRepository extends BaseRepository {
|
|
|
203
254
|
* client-side patch in UserOffersPanel, which meant a reload showed the offer
|
|
204
255
|
* back at "accepted" and it could be added to the cart and ordered again.
|
|
205
256
|
*/
|
|
206
|
-
async markPaid(offerId, orderId) {
|
|
257
|
+
async markPaid(offerId, orderId, prior) {
|
|
207
258
|
return this.updateStatus(offerId, {
|
|
208
259
|
status: "paid",
|
|
209
260
|
paidOrderId: orderId,
|
|
210
261
|
paidAt: new Date(),
|
|
211
|
-
});
|
|
262
|
+
}, { actor: { role: "system" }, trigger: "finalizeLockedLines" }, prior);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Every round of one negotiation, oldest first.
|
|
266
|
+
*
|
|
267
|
+
* A single-field equality — served by Firestore's AUTOMATIC index, so this
|
|
268
|
+
* adds no composite index and none of the existing `offers` indexes is
|
|
269
|
+
* affected. The sort is done in memory because the result is at most 3 rows
|
|
270
|
+
* (the per-buyer offer limit), which is cheaper than the index a compound
|
|
271
|
+
* orderBy would demand.
|
|
272
|
+
*
|
|
273
|
+
* Offers written before `chainRootOfferId` existed return ZERO rows, not
|
|
274
|
+
* one — callers must fall back to `[offer]` and never read empty as
|
|
275
|
+
* "this offer is missing".
|
|
276
|
+
*/
|
|
277
|
+
async findChain(chainRootOfferId) {
|
|
278
|
+
const snapshot = await this.db
|
|
279
|
+
.collection(this.collection)
|
|
280
|
+
.where(OFFER_FIELDS.CHAIN_ROOT_OFFER_ID, "==", chainRootOfferId)
|
|
281
|
+
.limit(OFFER_CHAIN_MAX)
|
|
282
|
+
.get();
|
|
283
|
+
return snapshot.docs
|
|
284
|
+
.map((doc) => this.mapDoc(doc))
|
|
285
|
+
.sort((a, b) => (a.counterRound ?? 1) - (b.counterRound ?? 1));
|
|
212
286
|
}
|
|
213
287
|
/**
|
|
214
288
|
* Cloud Functions compatibility: pending/countered offers already expired.
|
|
@@ -237,14 +311,44 @@ class OfferRepository extends BaseRepository {
|
|
|
237
311
|
.get();
|
|
238
312
|
return snapshot.docs.map((doc) => this.mapDoc(doc));
|
|
239
313
|
}
|
|
240
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Bulk-expire, taking the DOCUMENTS rather than their ids.
|
|
316
|
+
*
|
|
317
|
+
* The signature changed deliberately. `arrayUnion` would let a batch append
|
|
318
|
+
* without a read, but it **cannot enforce the FIFO cap** — so history would
|
|
319
|
+
* grow unbounded on exactly the path that touches the most documents at
|
|
320
|
+
* once. Folding entries in memory and writing whole arrays keeps the cap,
|
|
321
|
+
* and costs **zero extra reads**: `findExpiredActive`, `findExpiredAccepted`
|
|
322
|
+
* and the admin cancel route all already hold full `OfferDocument`s.
|
|
323
|
+
*
|
|
324
|
+
* Also carries the admin path: a reasoned cancel is an expiry with an actor
|
|
325
|
+
* and a reason, recorded in the offer's own history as well as in
|
|
326
|
+
* `adminAuditLog`.
|
|
327
|
+
*/
|
|
328
|
+
async expireMany(offers, ctx) {
|
|
329
|
+
if (offers.length === 0)
|
|
330
|
+
return;
|
|
241
331
|
const batch = this.db.batch();
|
|
242
332
|
const now = new Date();
|
|
243
|
-
for (const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
333
|
+
for (const offer of offers) {
|
|
334
|
+
const patch = {
|
|
335
|
+
status: "expired",
|
|
336
|
+
updatedAt: now,
|
|
337
|
+
...(ctx?.cancelledByAdminUid
|
|
338
|
+
? { cancelledByAdminUid: ctx.cancelledByAdminUid, cancelReason: ctx.reason }
|
|
339
|
+
: {}),
|
|
340
|
+
};
|
|
341
|
+
const withEntry = withHistory(offer, patch, {
|
|
342
|
+
tracked: OFFER_TRACKED_FIELDS,
|
|
343
|
+
actor: ctx?.actor ?? { role: "system" },
|
|
344
|
+
trigger: ctx?.trigger ?? "runOfferExpiry",
|
|
345
|
+
reason: ctx?.reason,
|
|
346
|
+
piiFields: OFFER_PII_FIELDS,
|
|
347
|
+
historyField: OFFER_FIELDS.STATUS_HISTORY,
|
|
348
|
+
truncatedField: OFFER_FIELDS.STATUS_HISTORY_TRUNCATED,
|
|
349
|
+
now,
|
|
247
350
|
});
|
|
351
|
+
batch.update(this.db.collection(this.collection).doc(offer.id), (withEntry ?? patch));
|
|
248
352
|
}
|
|
249
353
|
await batch.commit();
|
|
250
354
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* 🛑 `annotate()` must be the OUTERMOST call on each field — it keys a WeakMap
|
|
4
|
+
* by schema instance and every zod wrapper returns a new one.
|
|
5
|
+
*/
|
|
6
|
+
export declare const sellerCouponFormSchema: z.ZodEffects<z.ZodObject<{
|
|
7
|
+
code: z.ZodString;
|
|
8
|
+
type: z.ZodEnum<["percentage", "fixed", "free_shipping"]>;
|
|
9
|
+
value: z.ZodEffects<z.ZodString, string, string>;
|
|
10
|
+
minPurchase: z.ZodEffects<z.ZodString, string, string>;
|
|
11
|
+
maxDiscount: z.ZodEffects<z.ZodString, string, string>;
|
|
12
|
+
totalLimit: z.ZodEffects<z.ZodString, string, string>;
|
|
13
|
+
perUserLimit: z.ZodEffects<z.ZodString, string, string>;
|
|
14
|
+
startDate: z.ZodString;
|
|
15
|
+
endDate: z.ZodString;
|
|
16
|
+
isActive: z.ZodBoolean;
|
|
17
|
+
applicableProducts: z.ZodArray<z.ZodString, "many">;
|
|
18
|
+
applicableCategories: z.ZodArray<z.ZodString, "many">;
|
|
19
|
+
}, "strip", z.ZodTypeAny, {
|
|
20
|
+
value: string;
|
|
21
|
+
code: string;
|
|
22
|
+
type: "fixed" | "percentage" | "free_shipping";
|
|
23
|
+
isActive: boolean;
|
|
24
|
+
endDate: string;
|
|
25
|
+
maxDiscount: string;
|
|
26
|
+
minPurchase: string;
|
|
27
|
+
applicableProducts: string[];
|
|
28
|
+
applicableCategories: string[];
|
|
29
|
+
totalLimit: string;
|
|
30
|
+
perUserLimit: string;
|
|
31
|
+
startDate: string;
|
|
32
|
+
}, {
|
|
33
|
+
value: string;
|
|
34
|
+
code: string;
|
|
35
|
+
type: "fixed" | "percentage" | "free_shipping";
|
|
36
|
+
isActive: boolean;
|
|
37
|
+
endDate: string;
|
|
38
|
+
maxDiscount: string;
|
|
39
|
+
minPurchase: string;
|
|
40
|
+
applicableProducts: string[];
|
|
41
|
+
applicableCategories: string[];
|
|
42
|
+
totalLimit: string;
|
|
43
|
+
perUserLimit: string;
|
|
44
|
+
startDate: string;
|
|
45
|
+
}>, {
|
|
46
|
+
value: string;
|
|
47
|
+
code: string;
|
|
48
|
+
type: "fixed" | "percentage" | "free_shipping";
|
|
49
|
+
isActive: boolean;
|
|
50
|
+
endDate: string;
|
|
51
|
+
maxDiscount: string;
|
|
52
|
+
minPurchase: string;
|
|
53
|
+
applicableProducts: string[];
|
|
54
|
+
applicableCategories: string[];
|
|
55
|
+
totalLimit: string;
|
|
56
|
+
perUserLimit: string;
|
|
57
|
+
startDate: string;
|
|
58
|
+
}, {
|
|
59
|
+
value: string;
|
|
60
|
+
code: string;
|
|
61
|
+
type: "fixed" | "percentage" | "free_shipping";
|
|
62
|
+
isActive: boolean;
|
|
63
|
+
endDate: string;
|
|
64
|
+
maxDiscount: string;
|
|
65
|
+
minPurchase: string;
|
|
66
|
+
applicableProducts: string[];
|
|
67
|
+
applicableCategories: string[];
|
|
68
|
+
totalLimit: string;
|
|
69
|
+
perUserLimit: string;
|
|
70
|
+
startDate: string;
|
|
71
|
+
}>;
|
|
72
|
+
export type SellerCouponFormValues = z.infer<typeof sellerCouponFormSchema>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: `SellerCouponEditorView` rendered `<Form onSubmit noValidate>` with no
|
|
3
|
+
* schema at all, while `AdminCouponEditorView` — the same entity, one
|
|
4
|
+
* portal over — has a Zod schema wired through `<Form schema>`. The
|
|
5
|
+
* seller side relied on four hand-rolled `if` checks that covered
|
|
6
|
+
* presence and date order and nothing else, so a coupon could be saved
|
|
7
|
+
* with a **500% discount**, a non-numeric value, a negative minimum spend
|
|
8
|
+
* or a per-user limit above the total limit. Every failure surfaced in a
|
|
9
|
+
* single banner rather than on the field that caused it.
|
|
10
|
+
* WHAT: The seller coupon form's schema.
|
|
11
|
+
*
|
|
12
|
+
* ## Everything is a string, on purpose
|
|
13
|
+
*
|
|
14
|
+
* `CouponEditorDraft` holds every numeric field as a string because they come
|
|
15
|
+
* from `<input>`s. Coercing inside the schema keeps that boundary in one place
|
|
16
|
+
* — the alternative is a `Number()` at each call site, which is how a `NaN`
|
|
17
|
+
* reaches Firestore the first time one is forgotten.
|
|
18
|
+
*
|
|
19
|
+
* ## Why the rules are conditional
|
|
20
|
+
*
|
|
21
|
+
* `free_shipping` has no discount value, `percentage` is bounded 1–100, and
|
|
22
|
+
* `fixed` is any positive amount. A flat per-field rule cannot say that, so
|
|
23
|
+
* the cross-field rules live in one `superRefine` keyed by type.
|
|
24
|
+
*
|
|
25
|
+
* EXPORTS:
|
|
26
|
+
* sellerCouponFormSchema, type SellerCouponFormValues
|
|
27
|
+
*
|
|
28
|
+
* @tag domain:seller,promotions
|
|
29
|
+
* @tag layer:schema
|
|
30
|
+
* @tag pattern:none
|
|
31
|
+
* @tag access:isomorphic
|
|
32
|
+
* @tag consumers:SellerCouponEditorView
|
|
33
|
+
* @tag sideEffects:none
|
|
34
|
+
*/
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import { annotate } from "../../shell/field-ui-meta";
|
|
37
|
+
/** A blank optional numeric string — the form's "not set". */
|
|
38
|
+
const numericString = (label) => z
|
|
39
|
+
.string()
|
|
40
|
+
.refine((v) => v.trim() === "" || (!Number.isNaN(Number(v)) && Number(v) >= 0), {
|
|
41
|
+
message: `${label} must be a number of zero or more.`,
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* 🛑 `annotate()` must be the OUTERMOST call on each field — it keys a WeakMap
|
|
45
|
+
* by schema instance and every zod wrapper returns a new one.
|
|
46
|
+
*/
|
|
47
|
+
export const sellerCouponFormSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
code: annotate(z
|
|
50
|
+
.string()
|
|
51
|
+
.min(2, "A coupon code needs at least 2 characters.")
|
|
52
|
+
.max(40, "Keep the code under 40 characters.")
|
|
53
|
+
.regex(/^[A-Z0-9_-]+$/i, "Use letters, numbers, hyphens and underscores only."), {
|
|
54
|
+
section: "basics", sectionLabel: "Coupon", sectionRequired: true,
|
|
55
|
+
quick: true, order: 1, row: "pair",
|
|
56
|
+
}),
|
|
57
|
+
type: annotate(z.enum(["percentage", "fixed", "free_shipping"]), {
|
|
58
|
+
section: "basics", quick: true, order: 2, row: "pair",
|
|
59
|
+
}),
|
|
60
|
+
value: annotate(numericString("The discount value"), {
|
|
61
|
+
section: "basics", quick: true, order: 3, row: "pair",
|
|
62
|
+
}),
|
|
63
|
+
minPurchase: annotate(numericString("Minimum purchase"), {
|
|
64
|
+
section: "limits", sectionLabel: "Limits", order: 1, row: "pair",
|
|
65
|
+
}),
|
|
66
|
+
maxDiscount: annotate(numericString("Maximum discount"), {
|
|
67
|
+
section: "limits", order: 2, row: "pair",
|
|
68
|
+
}),
|
|
69
|
+
totalLimit: annotate(numericString("Total uses"), {
|
|
70
|
+
section: "limits", order: 3, row: "pair",
|
|
71
|
+
}),
|
|
72
|
+
perUserLimit: annotate(numericString("Uses per customer"), {
|
|
73
|
+
section: "limits", order: 4, row: "pair",
|
|
74
|
+
}),
|
|
75
|
+
startDate: annotate(z.string().min(1, "A start date is required."), {
|
|
76
|
+
section: "validity", sectionLabel: "Validity", quick: true, order: 1, row: "pair", kind: "date",
|
|
77
|
+
}),
|
|
78
|
+
endDate: annotate(z.string().min(1, "An end date is required."), {
|
|
79
|
+
section: "validity", quick: true, order: 2, row: "pair", kind: "date",
|
|
80
|
+
}),
|
|
81
|
+
isActive: annotate(z.boolean(), {
|
|
82
|
+
section: "validity", order: 3, row: "quarter",
|
|
83
|
+
}),
|
|
84
|
+
applicableProducts: annotate(z.array(z.string()), {
|
|
85
|
+
section: "scope", sectionLabel: "Applies to", order: 1, row: "full", kind: "list",
|
|
86
|
+
}),
|
|
87
|
+
applicableCategories: annotate(z.array(z.string()), {
|
|
88
|
+
section: "scope", order: 2, row: "full", kind: "list",
|
|
89
|
+
}),
|
|
90
|
+
})
|
|
91
|
+
.superRefine((v, ctx) => {
|
|
92
|
+
const issue = (path, message) => ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
|
|
93
|
+
// A discount value is required for everything except free shipping, and
|
|
94
|
+
// a percentage above 100 is not a discount — it is the store paying the
|
|
95
|
+
// customer. Nothing bounded this before.
|
|
96
|
+
if (v.type !== "free_shipping") {
|
|
97
|
+
const amount = Number(v.value);
|
|
98
|
+
if (v.value.trim() === "")
|
|
99
|
+
issue("value", "A discount value is required.");
|
|
100
|
+
else if (amount <= 0)
|
|
101
|
+
issue("value", "The discount must be greater than zero.");
|
|
102
|
+
else if (v.type === "percentage" && amount > 100) {
|
|
103
|
+
issue("value", "A percentage discount cannot exceed 100%.");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (v.startDate && v.endDate && v.startDate > v.endDate) {
|
|
107
|
+
issue("endDate", "The end date must be after the start date.");
|
|
108
|
+
}
|
|
109
|
+
// A per-customer limit above the total makes the total unreachable — the
|
|
110
|
+
// coupon reads as more generous than it can ever be.
|
|
111
|
+
const total = Number(v.totalLimit);
|
|
112
|
+
const perUser = Number(v.perUserLimit);
|
|
113
|
+
if (v.totalLimit.trim() && v.perUserLimit.trim() && perUser > total) {
|
|
114
|
+
issue("perUserLimit", "Uses per customer cannot exceed the total uses.");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { type GenerateOfferIdInput } from "../../../utils/id-generators";
|
|
5
5
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
6
|
+
import type { StatusChangeEntry } from "../../../_internal/shared/history/index";
|
|
6
7
|
export type OfferStatus = "pending" | "accepted" | "declined" | "countered" | "expired" | "withdrawn" | "paid";
|
|
7
8
|
/** Runtime-accessible offer status values — use instead of bare string literals. */
|
|
8
9
|
export declare const OfferStatusValues: {
|
|
@@ -39,7 +40,52 @@ export interface OfferDocument extends BaseDocument {
|
|
|
39
40
|
/** Set by `markPaid` once the buyer's order exists — makes "paid" auditable. */
|
|
40
41
|
paidOrderId?: string;
|
|
41
42
|
paidAt?: Date;
|
|
43
|
+
/**
|
|
44
|
+
* Append-only delta log — the canonical record of when things happened to
|
|
45
|
+
* this offer. Shares `StatusChangeEntry` with `OrderDocument`; an offer is
|
|
46
|
+
* an order with more phases in front of it.
|
|
47
|
+
*
|
|
48
|
+
* `respondedAt` is deliberately kept but NOT extended: it means four
|
|
49
|
+
* different events (seller-accept, seller-decline, seller-counter,
|
|
50
|
+
* buyer-withdraw) and survives only as the legacy fallback for offers
|
|
51
|
+
* written before this field existed.
|
|
52
|
+
*/
|
|
53
|
+
statusHistory?: StatusChangeEntry[];
|
|
54
|
+
/** Entries dropped off the front — so the UI never implies "this is all of it". */
|
|
55
|
+
statusHistoryTruncated?: number;
|
|
56
|
+
/**
|
|
57
|
+
* The multi-round chain. A counter-offer is a NEW document, so without
|
|
58
|
+
* these three a three-round negotiation is three unrelated records —
|
|
59
|
+
* which is what it is today: `previousOfferId` currently exists only
|
|
60
|
+
* inside a `serverLogger.info` call and never reaches Firestore.
|
|
61
|
+
*/
|
|
62
|
+
previousOfferId?: string;
|
|
63
|
+
supersededByOfferId?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Denormalised root of the chain (the root stores its own id) so a list row
|
|
66
|
+
* renders "Round 2" with ZERO extra reads, and `findChain` is a single-field
|
|
67
|
+
* equality served by the automatic index — no composite index needed.
|
|
68
|
+
*/
|
|
69
|
+
chainRootOfferId?: string;
|
|
70
|
+
/** 1-based. Absent means round 1. */
|
|
71
|
+
counterRound?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Admin escalation. The store keeps sole authority to accept/counter/
|
|
74
|
+
* decline; an admin may only cancel, with a reason, and it is recorded
|
|
75
|
+
* here as well as in `adminAuditLog`.
|
|
76
|
+
*/
|
|
77
|
+
cancelledByAdminUid?: string;
|
|
78
|
+
cancelReason?: string;
|
|
42
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Fields whose changes are recorded in an offer's `statusHistory`.
|
|
82
|
+
*
|
|
83
|
+
* Excludes `buyerName`/`buyerEmail` (PII — `encryptPiiFields` never descends
|
|
84
|
+
* into arrays, so they would persist in plaintext inside the history),
|
|
85
|
+
* `buyerNote`/`sellerNote` (they ride in the entry's `note`), and
|
|
86
|
+
* `updatedAt`/`respondedAt` (noise that would burn the FIFO cap).
|
|
87
|
+
*/
|
|
88
|
+
export declare const OFFER_TRACKED_FIELDS: readonly ["status", "offerAmount", "counterAmount", "lockedPrice", "checkoutDeadline", "paidOrderId", "supersededByOfferId", "previousOfferId"];
|
|
43
89
|
export declare const OFFER_COLLECTION: "offers";
|
|
44
90
|
export declare const OFFER_INDEXED_FIELDS: readonly ["buyerUid", "storeId", "productId", "status", "createdAt", "expiresAt", "checkoutDeadline"];
|
|
45
91
|
export declare const OFFER_FIELDS: {
|
|
@@ -67,9 +113,17 @@ export declare const OFFER_FIELDS: {
|
|
|
67
113
|
readonly PAID_AT: "paidAt";
|
|
68
114
|
readonly CREATED_AT: "createdAt";
|
|
69
115
|
readonly UPDATED_AT: "updatedAt";
|
|
116
|
+
readonly STATUS_HISTORY: "statusHistory";
|
|
117
|
+
readonly STATUS_HISTORY_TRUNCATED: "statusHistoryTruncated";
|
|
118
|
+
readonly PREVIOUS_OFFER_ID: "previousOfferId";
|
|
119
|
+
readonly SUPERSEDED_BY_OFFER_ID: "supersededByOfferId";
|
|
120
|
+
readonly CHAIN_ROOT_OFFER_ID: "chainRootOfferId";
|
|
121
|
+
readonly COUNTER_ROUND: "counterRound";
|
|
122
|
+
readonly CANCELLED_BY_ADMIN_UID: "cancelledByAdminUid";
|
|
123
|
+
readonly CANCEL_REASON: "cancelReason";
|
|
70
124
|
};
|
|
71
|
-
export type OfferCreateInput = Pick<OfferDocument, "productId" | "productTitle" | "productSlug" | "productImageUrl" | "buyerUid" | "buyerName" | "buyerEmail" | "storeId" | "storeName" | "offerAmount" | "listedPrice" | "currency" | "buyerNote">;
|
|
72
|
-
export type OfferUpdateInput = Partial<Pick<OfferDocument, "status" | "counterAmount" | "lockedPrice" | "sellerNote" | "acceptedAt" | "checkoutDeadline" | "respondedAt" | "paidOrderId" | "paidAt" | "updatedAt">>;
|
|
125
|
+
export type OfferCreateInput = Pick<OfferDocument, "productId" | "productTitle" | "productSlug" | "productImageUrl" | "buyerUid" | "buyerName" | "buyerEmail" | "storeId" | "storeName" | "offerAmount" | "listedPrice" | "currency" | "buyerNote" | "previousOfferId" | "chainRootOfferId" | "counterRound">;
|
|
126
|
+
export type OfferUpdateInput = Partial<Pick<OfferDocument, "status" | "counterAmount" | "lockedPrice" | "sellerNote" | "acceptedAt" | "checkoutDeadline" | "respondedAt" | "paidOrderId" | "paidAt" | "updatedAt" | "statusHistory" | "statusHistoryTruncated" | "supersededByOfferId" | "cancelledByAdminUid" | "cancelReason">>;
|
|
73
127
|
export declare const offerQueryHelpers: {
|
|
74
128
|
readonly byBuyer: (buyerUid: string) => readonly ["buyerUid", "==", string];
|
|
75
129
|
readonly byStore: (storeId: string) => readonly ["storeId", "==", string];
|
|
@@ -12,6 +12,24 @@ export const OfferStatusValues = {
|
|
|
12
12
|
WITHDRAWN: "withdrawn",
|
|
13
13
|
PAID: "paid",
|
|
14
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* Fields whose changes are recorded in an offer's `statusHistory`.
|
|
17
|
+
*
|
|
18
|
+
* Excludes `buyerName`/`buyerEmail` (PII — `encryptPiiFields` never descends
|
|
19
|
+
* into arrays, so they would persist in plaintext inside the history),
|
|
20
|
+
* `buyerNote`/`sellerNote` (they ride in the entry's `note`), and
|
|
21
|
+
* `updatedAt`/`respondedAt` (noise that would burn the FIFO cap).
|
|
22
|
+
*/
|
|
23
|
+
export const OFFER_TRACKED_FIELDS = [
|
|
24
|
+
"status",
|
|
25
|
+
"offerAmount",
|
|
26
|
+
"counterAmount",
|
|
27
|
+
"lockedPrice",
|
|
28
|
+
"checkoutDeadline",
|
|
29
|
+
"paidOrderId",
|
|
30
|
+
"supersededByOfferId",
|
|
31
|
+
"previousOfferId",
|
|
32
|
+
];
|
|
15
33
|
export const OFFER_COLLECTION = "offers";
|
|
16
34
|
export const OFFER_INDEXED_FIELDS = [
|
|
17
35
|
"buyerUid",
|
|
@@ -47,6 +65,15 @@ export const OFFER_FIELDS = {
|
|
|
47
65
|
PAID_AT: "paidAt",
|
|
48
66
|
CREATED_AT: "createdAt",
|
|
49
67
|
UPDATED_AT: "updatedAt",
|
|
68
|
+
// Negotiation history + chain (W2)
|
|
69
|
+
STATUS_HISTORY: "statusHistory",
|
|
70
|
+
STATUS_HISTORY_TRUNCATED: "statusHistoryTruncated",
|
|
71
|
+
PREVIOUS_OFFER_ID: "previousOfferId",
|
|
72
|
+
SUPERSEDED_BY_OFFER_ID: "supersededByOfferId",
|
|
73
|
+
CHAIN_ROOT_OFFER_ID: "chainRootOfferId",
|
|
74
|
+
COUNTER_ROUND: "counterRound",
|
|
75
|
+
CANCELLED_BY_ADMIN_UID: "cancelledByAdminUid",
|
|
76
|
+
CANCEL_REASON: "cancelReason",
|
|
50
77
|
};
|
|
51
78
|
export const offerQueryHelpers = {
|
|
52
79
|
byBuyer: (buyerUid) => ["buyerUid", "==", buyerUid],
|
|
@@ -89,8 +89,8 @@ export declare const sellerStoreSchema: z.ZodObject<{
|
|
|
89
89
|
id: string;
|
|
90
90
|
createdAt: string;
|
|
91
91
|
updatedAt: string;
|
|
92
|
-
ownerId: string;
|
|
93
92
|
storeName: string;
|
|
93
|
+
ownerId: string;
|
|
94
94
|
storeSlug: string;
|
|
95
95
|
isPublic: boolean;
|
|
96
96
|
location?: string | undefined;
|
|
@@ -121,8 +121,8 @@ export declare const sellerStoreSchema: z.ZodObject<{
|
|
|
121
121
|
id: string;
|
|
122
122
|
createdAt: string;
|
|
123
123
|
updatedAt: string;
|
|
124
|
-
ownerId: string;
|
|
125
124
|
storeName: string;
|
|
125
|
+
ownerId: string;
|
|
126
126
|
storeSlug: string;
|
|
127
127
|
isPublic: boolean;
|
|
128
128
|
location?: string | undefined;
|