@mohasinac/appkit 4.62.1 โ 4.63.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/.build-stamp.json +3 -3
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +8 -29
- package/dist/_internal/client/features/layout/filterNavItems.d.ts +28 -0
- package/dist/_internal/client/features/layout/filterNavItems.js +35 -0
- package/dist/_internal/server/features/ads/booking.d.ts +118 -0
- package/dist/_internal/server/features/ads/booking.js +326 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +51 -11
- package/dist/_internal/server/features/checkout/actions.js +335 -80
- package/dist/_internal/server/features/checkout/expand-order-items.d.ts +1 -2
- package/dist/_internal/server/features/checkout/expand-order-items.js +32 -0
- package/dist/_internal/server/features/checkout/freeze-parts.d.ts +50 -0
- package/dist/_internal/server/features/checkout/freeze-parts.js +94 -0
- package/dist/_internal/server/features/checkout/value-otp-gate.d.ts +55 -0
- package/dist/_internal/server/features/checkout/value-otp-gate.js +60 -0
- package/dist/_internal/server/features/dashboard/attention.d.ts +128 -0
- package/dist/_internal/server/features/dashboard/attention.js +183 -0
- package/dist/_internal/server/features/dashboard/data.d.ts +47 -0
- package/dist/_internal/server/features/dashboard/data.js +82 -0
- package/dist/_internal/server/features/inventory/reservations.d.ts +99 -0
- package/dist/_internal/server/features/inventory/reservations.js +273 -0
- package/dist/_internal/server/features/offers/destinations.d.ts +40 -0
- package/dist/_internal/server/features/offers/destinations.js +65 -0
- package/dist/_internal/server/features/orders/adapters.js +21 -0
- package/dist/_internal/server/features/orders/amend-delivery.d.ts +56 -0
- package/dist/_internal/server/features/orders/amend-delivery.js +124 -0
- package/dist/_internal/server/features/orders/balance.d.ts +89 -0
- package/dist/_internal/server/features/orders/balance.js +256 -0
- package/dist/_internal/server/features/pricing/actions.d.ts +58 -0
- package/dist/_internal/server/features/pricing/actions.js +144 -0
- package/dist/_internal/server/features/shipping/book-split.d.ts +86 -0
- package/dist/_internal/server/features/shipping/book-split.js +130 -0
- package/dist/_internal/server/features/shipping/book.d.ts +61 -0
- package/dist/_internal/server/features/shipping/book.js +176 -0
- package/dist/_internal/server/features/shipping/courier-block.js +26 -1
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +14 -0
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +14 -0
- package/dist/_internal/server/functions/scheduled.d.ts +2 -1
- package/dist/_internal/server/functions/scheduled.js +57 -1
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +2 -0
- package/dist/_internal/server/jobs/core/jobRunners.js +45 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +4 -1
- package/dist/_internal/server/jobs/core/reservationExpiry.d.ts +17 -0
- package/dist/_internal/server/jobs/core/reservationExpiry.js +40 -0
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +15 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/reservationExpiry.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/reservationExpiry.js +2 -0
- package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
- package/dist/_internal/shared/actions/action-registry.js +268 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +31 -2
- package/dist/_internal/shared/checkout/order-math.js +22 -36
- package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
- package/dist/_internal/shared/checkout/rules/index.js +1 -1
- package/dist/_internal/shared/checkout/rules/payment-gate.d.ts +18 -0
- package/dist/_internal/shared/checkout/rules/payment-gate.js +25 -0
- package/dist/_internal/shared/checkout/split.d.ts +0 -12
- package/dist/_internal/shared/checkout/split.js +22 -2
- package/dist/_internal/shared/content/disclaimers.js +7 -7
- package/dist/_internal/shared/content/published-values.d.ts +6 -1
- package/dist/_internal/shared/content/published-values.js +8 -0
- package/dist/_internal/shared/features/admin/integrations.d.ts +32 -2
- package/dist/_internal/shared/features/admin/integrations.js +19 -9
- package/dist/_internal/shared/features/dashboard/hub-sections.d.ts +72 -0
- package/dist/_internal/shared/features/dashboard/hub-sections.js +75 -0
- package/dist/_internal/shared/features/emi/choice.d.ts +98 -0
- package/dist/_internal/shared/features/emi/choice.js +69 -0
- package/dist/_internal/shared/features/emi/group.d.ts +84 -0
- package/dist/_internal/shared/features/emi/group.js +126 -0
- package/dist/_internal/shared/features/ledger/posting.d.ts +107 -0
- package/dist/_internal/shared/features/ledger/posting.js +165 -0
- package/dist/_internal/shared/features/ledger/reports.d.ts +76 -0
- package/dist/_internal/shared/features/ledger/reports.js +127 -0
- package/dist/_internal/shared/features/ledger/rules.d.ts +170 -0
- package/dist/_internal/shared/features/ledger/rules.js +221 -0
- package/dist/_internal/shared/features/offers/coordination.d.ts +122 -0
- package/dist/_internal/shared/features/offers/coordination.js +80 -0
- package/dist/_internal/shared/features/orders/delivery-snapshot.d.ts +114 -0
- package/dist/_internal/shared/features/orders/delivery-snapshot.js +116 -0
- package/dist/_internal/shared/features/payouts/pay-period.d.ts +91 -0
- package/dist/_internal/shared/features/payouts/pay-period.js +104 -0
- package/dist/_internal/shared/features/points/accepts.d.ts +1 -8
- package/dist/_internal/shared/features/points/accepts.js +12 -6
- package/dist/_internal/shared/features/pricing/rate-registry.d.ts +150 -0
- package/dist/_internal/shared/features/pricing/rate-registry.js +246 -0
- package/dist/_internal/shared/features/shipping/parcel.d.ts +123 -0
- package/dist/_internal/shared/features/shipping/parcel.js +139 -0
- package/dist/_internal/shared/features/site-settings/fees.d.ts +16 -8
- package/dist/_internal/shared/features/site-settings/fees.js +12 -8
- package/dist/_internal/shared/fees/explain.d.ts +81 -0
- package/dist/_internal/shared/fees/explain.js +101 -0
- package/dist/_internal/shared/history/index.d.ts +1 -1
- package/dist/_internal/shared/history/index.js +1 -1
- package/dist/_internal/shared/history/types.d.ts +7 -1
- package/dist/_internal/shared/history/types.js +25 -0
- package/dist/client.d.ts +11 -1
- package/dist/client.js +13 -0
- package/dist/constants/api-endpoints.d.ts +72 -0
- package/dist/constants/api-endpoints.js +41 -0
- package/dist/constants/field-names.d.ts +41 -0
- package/dist/constants/field-names.js +47 -0
- package/dist/features/account/components/UserOrdersView.js +28 -2
- package/dist/features/admin/components/AdminIntegrationsView.d.ts +55 -0
- package/dist/features/admin/components/AdminIntegrationsView.js +74 -0
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +16 -1
- package/dist/features/admin/components/AdminOrderEditorView.js +72 -3
- package/dist/features/admin/components/AdminOrdersView.js +20 -1
- package/dist/features/admin/schemas/firestore.d.ts +32 -0
- package/dist/features/ads/schemas/booking-write.d.ts +120 -0
- package/dist/features/ads/schemas/booking-write.js +75 -0
- package/dist/features/ads/schemas/creative-form.d.ts +18 -0
- package/dist/features/ads/schemas/creative-form.js +51 -0
- package/dist/features/ads/schemas/firestore.d.ts +140 -0
- package/dist/features/ads/schemas/firestore.js +109 -0
- package/dist/features/auctions/repository/bid.repository.js +3 -1
- package/dist/features/audit-log/schemas/firestore.d.ts +110 -0
- package/dist/features/audit-log/schemas/firestore.js +74 -0
- package/dist/features/auth/actions/profile-actions.d.ts +69 -1
- package/dist/features/auth/actions/profile-actions.js +62 -1
- package/dist/features/auth/schemas/firestore.d.ts +16 -2
- package/dist/features/auth/schemas/firestore.js +2 -1
- package/dist/features/blog/actions/blog-actions.d.ts +6 -0
- package/dist/features/blog/actions/blog-actions.js +17 -2
- package/dist/features/blog/api/route.js +4 -1
- package/dist/features/blog/columns/index.js +5 -0
- package/dist/features/blog/components/BlogIndexPageView.js +2 -1
- package/dist/features/blog/repository/blog.repository.d.ts +4 -0
- package/dist/features/blog/repository/blog.repository.js +5 -0
- package/dist/features/blog/schemas/blog-form.d.ts +3 -0
- package/dist/features/blog/schemas/blog-form.js +1 -0
- package/dist/features/blog/schemas/firestore.d.ts +38 -1
- package/dist/features/blog/schemas/firestore.js +23 -0
- package/dist/features/blog/schemas/index.d.ts +6 -0
- package/dist/features/blog/schemas/index.js +2 -0
- package/dist/features/blog/types/index.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +12 -1
- package/dist/features/cart/components/CartPriceBreakdown.js +24 -5
- package/dist/features/cart/repository/cart.repository.d.ts +14 -0
- package/dist/features/cart/repository/cart.repository.js +20 -0
- package/dist/features/cart/schemas/firestore.d.ts +24 -0
- package/dist/features/dashboard/components/DashboardHubView.d.ts +83 -0
- package/dist/features/dashboard/components/DashboardHubView.js +56 -0
- package/dist/features/dashboard/components/DashboardSectionsClient.d.ts +23 -0
- package/dist/features/dashboard/components/DashboardSectionsClient.js +66 -0
- package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.d.ts +55 -0
- package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.js +175 -0
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +24 -0
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +28 -0
- package/dist/features/dispute-reasons/schemas/firestore.d.ts +3 -5
- package/dist/features/events/components/EventSaleBanner.js +1 -1
- package/dist/features/faq/components/FAQPageContent.js +2 -2
- package/dist/features/gateway-events/schemas/index.d.ts +2 -2
- package/dist/features/homepage/schemas/index.d.ts +8 -8
- package/dist/features/inventory/schemas/stock-reservation.d.ts +114 -0
- package/dist/features/inventory/schemas/stock-reservation.js +103 -0
- package/dist/features/layout/AppLayoutShell.d.ts +9 -1
- package/dist/features/layout/AppLayoutShell.js +12 -4
- package/dist/features/layout/BottomActions.js +2 -1
- package/dist/features/layout/BottomNavbar.d.ts +10 -2
- package/dist/features/layout/SideActionDock.js +52 -2
- package/dist/features/layout/TitleBarLayout.js +13 -4
- package/dist/features/layout/TitleBarLayout.style.css +59 -0
- package/dist/features/layout/useCtaReveal.d.ts +16 -1
- package/dist/features/layout/useCtaReveal.js +17 -26
- package/dist/features/ledger/repository/journal.repository.d.ts +110 -0
- package/dist/features/ledger/repository/journal.repository.js +312 -0
- package/dist/features/ledger/schemas/expenses.d.ts +87 -0
- package/dist/features/ledger/schemas/expenses.js +27 -0
- package/dist/features/ledger/schemas/firestore.d.ts +183 -0
- package/dist/features/ledger/schemas/firestore.js +69 -0
- package/dist/features/orders/components/BalancePaymentPanel.d.ts +12 -0
- package/dist/features/orders/components/BalancePaymentPanel.js +94 -0
- package/dist/features/orders/components/RaiseDisputeForm.js +19 -11
- package/dist/features/orders/repository/orders.repository.d.ts +28 -1
- package/dist/features/orders/repository/orders.repository.js +60 -6
- package/dist/features/orders/schemas/balance.d.ts +40 -0
- package/dist/features/orders/schemas/balance.js +47 -0
- package/dist/features/orders/schemas/firestore.d.ts +202 -0
- package/dist/features/orders/schemas/firestore.js +43 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +21 -0
- package/dist/features/payments/repository/payout.repository.js +3 -2
- package/dist/features/payments/schemas/firestore.d.ts +92 -1
- package/dist/features/payments/schemas/firestore.js +23 -0
- package/dist/features/pricing/components/AdminPricingView.d.ts +13 -0
- package/dist/features/pricing/components/AdminPricingView.js +157 -0
- package/dist/features/products/schemas/firestore.d.ts +26 -1
- package/dist/features/products/schemas/firestore.js +2 -0
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/reviews/repository/reviews.repository.d.ts +14 -0
- package/dist/features/reviews/repository/reviews.repository.js +16 -0
- package/dist/features/scams/repository/scammer.repository.js +2 -1
- package/dist/features/scams/schemas/firestore.d.ts +2 -1
- package/dist/features/scams/schemas/firestore.js +2 -1
- package/dist/features/seller/actions/offer-actions.js +56 -0
- package/dist/features/seller/actions/seller-actions.d.ts +1 -1
- package/dist/features/seller/actions/seller-actions.js +4 -0
- package/dist/features/seller/components/DispatchForm.d.ts +11 -0
- package/dist/features/seller/components/DispatchForm.js +190 -0
- package/dist/features/seller/components/SellerOrdersView.js +94 -2
- package/dist/features/seller/components/index.d.ts +1 -0
- package/dist/features/seller/components/index.js +1 -0
- package/dist/features/seller/repository/offer.repository.js +4 -3
- package/dist/features/seller/schemas/firestore.d.ts +23 -2
- package/dist/features/seller/schemas/firestore.js +3 -2
- package/dist/features/seller/schemas/index.d.ts +4 -4
- package/dist/features/seller/schemas/order-forms.d.ts +61 -4
- package/dist/features/seller/schemas/order-forms.js +41 -8
- package/dist/features/shipments/schemas/firestore.d.ts +25 -0
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +39 -0
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +55 -0
- package/dist/features/shipping-boxes/schemas/firestore.d.ts +2 -4
- package/dist/features/stores/repository/store.repository.d.ts +18 -0
- package/dist/features/stores/repository/store.repository.js +27 -1
- package/dist/features/stores/schemas/firestore.d.ts +41 -0
- package/dist/features/stores/schemas/firestore.js +20 -0
- package/dist/features/stores/schemas/index.d.ts +6 -6
- package/dist/features/support/repository/support.repository.js +72 -6
- package/dist/features/support/schemas/firestore.d.ts +17 -0
- package/dist/features/support/schemas/index.d.ts +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +19 -0
- package/dist/next/routing/route-map.d.ts +92 -0
- package/dist/next/routing/route-map.js +53 -0
- package/dist/react/contexts/SessionContext.d.ts +17 -1
- package/dist/schemas/registry.d.ts +22 -16
- package/dist/security/pii-schemas.d.ts +1 -1
- package/dist/security/pii-schemas.js +45 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +144 -0
- package/dist/styles.css +349 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/Button.d.ts +10 -1
- package/dist/ui/components/Button.js +25 -3
- package/dist/ui/components/Chart.d.ts +63 -0
- package/dist/ui/components/Chart.js +169 -0
- package/dist/ui/components/Chart.style.css +214 -0
- package/dist/ui/components/Disclaimer.js +10 -1
- package/dist/ui/components/OverflowRow.js +54 -2
- package/dist/ui/components/OverflowRow.style.css +38 -0
- package/dist/ui/components/RichTextEditor.js +99 -8
- package/dist/ui/components/chart-spec.d.ts +68 -0
- package/dist/ui/components/chart-spec.js +140 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/components/surface-tokens.d.ts +1 -1
- package/dist/ui/components/surface-tokens.js +9 -0
- package/dist/ui/components/toolbar-collapse.d.ts +13 -0
- package/dist/ui/components/toolbar-collapse.js +10 -1
- package/dist/ui/index.d.ts +6 -0
- package/dist/ui/index.js +3 -0
- package/dist/ui/rich-text/RichText.d.ts +10 -1
- package/dist/ui/rich-text/RichText.js +33 -2
- package/dist/ui/rich-text/RichText.style.css +35 -0
- package/dist/ui/rich-text/RichTextRenderer.d.ts +30 -1
- package/dist/ui/rich-text/RichTextRenderer.js +34 -2
- package/dist/ui/rich-text/chart-blocks.d.ts +62 -0
- package/dist/ui/rich-text/chart-blocks.js +87 -0
- package/package.json +1 -1
- package/scripts/seed-cli.mjs +3 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one read a dashboard hub or landing page needs.
|
|
3
|
+
*
|
|
4
|
+
* ## ๐ Read on the SERVER, not fetched from the client
|
|
5
|
+
*
|
|
6
|
+
* The plan named an endpoint โ `/api/dashboard/attention` โ and the shape it
|
|
7
|
+
* asked for is "one read covering every section", which this is. It is read
|
|
8
|
+
* here instead because every one of these pages is **already per-request**:
|
|
9
|
+
* `admin/layout.tsx` and `store/layout.tsx` `await getServerSessionUser()`, so
|
|
10
|
+
* the whole subtree is dynamic with or without an endpoint (ยง "โฆa dynamic API
|
|
11
|
+
* in a DASHBOARD layout is the correct lever").
|
|
12
|
+
*
|
|
13
|
+
* An endpoint would therefore have added a SECOND billed invocation per hub
|
|
14
|
+
* visit to fetch data the page's own render could already have, which is
|
|
15
|
+
* exactly the shape of Root Cause #94 โ a client fetch that reads as free in
|
|
16
|
+
* review and bills per mount. There is no endpoint, and one should not be added
|
|
17
|
+
* unless something outside a dashboard page needs these numbers.
|
|
18
|
+
*
|
|
19
|
+
* ## Two reads, and both fail OPEN
|
|
20
|
+
*
|
|
21
|
+
* `navConfig` decides which nav entries an admin has switched off, and the hub
|
|
22
|
+
* must agree with the sidebar about that โ so it is read rather than ignored.
|
|
23
|
+
* On failure it resolves to `undefined`, which means "nothing is disabled" and
|
|
24
|
+
* shows MORE cards than it should. That is the right direction: a disabled nav
|
|
25
|
+
* entry is a configuration choice rather than a permission, and the page behind
|
|
26
|
+
* every card still enforces its own route guard. Hiding sections because a
|
|
27
|
+
* settings read blipped would be the damaging failure.
|
|
28
|
+
*
|
|
29
|
+
* The counts fail to `null` per card โ see `attention.ts`, which exists to
|
|
30
|
+
* explain why `0` is the one answer a badge must never invent.
|
|
31
|
+
*/
|
|
32
|
+
import { type DashboardHubSection } from "../../../shared/features/dashboard/hub-sections";
|
|
33
|
+
import type { SidebarNavGroup } from "../../../shared/features/layout/types";
|
|
34
|
+
import { type AttentionCounts } from "./attention";
|
|
35
|
+
export interface DashboardHubData {
|
|
36
|
+
/** Permission- and toggle-filtered, in nav order. Never empty-group padded. */
|
|
37
|
+
sections: DashboardHubSection[];
|
|
38
|
+
counts: AttentionCounts;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the sections a viewer may see, plus their attention counts.
|
|
42
|
+
*
|
|
43
|
+
* @param groups the portal's nav groups, unfiltered โ this applies the filter
|
|
44
|
+
* @param permissions `null` means admin (the convention `filterNavGroups` owns)
|
|
45
|
+
* @param storeId the viewer's store SLUG, for seller-scoped counts
|
|
46
|
+
*/
|
|
47
|
+
export declare function getDashboardHubData(groups: readonly SidebarNavGroup[], permissions: string[] | null | undefined, storeId?: string): Promise<DashboardHubData>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one read a dashboard hub or landing page needs.
|
|
3
|
+
*
|
|
4
|
+
* ## ๐ Read on the SERVER, not fetched from the client
|
|
5
|
+
*
|
|
6
|
+
* The plan named an endpoint โ `/api/dashboard/attention` โ and the shape it
|
|
7
|
+
* asked for is "one read covering every section", which this is. It is read
|
|
8
|
+
* here instead because every one of these pages is **already per-request**:
|
|
9
|
+
* `admin/layout.tsx` and `store/layout.tsx` `await getServerSessionUser()`, so
|
|
10
|
+
* the whole subtree is dynamic with or without an endpoint (ยง "โฆa dynamic API
|
|
11
|
+
* in a DASHBOARD layout is the correct lever").
|
|
12
|
+
*
|
|
13
|
+
* An endpoint would therefore have added a SECOND billed invocation per hub
|
|
14
|
+
* visit to fetch data the page's own render could already have, which is
|
|
15
|
+
* exactly the shape of Root Cause #94 โ a client fetch that reads as free in
|
|
16
|
+
* review and bills per mount. There is no endpoint, and one should not be added
|
|
17
|
+
* unless something outside a dashboard page needs these numbers.
|
|
18
|
+
*
|
|
19
|
+
* ## Two reads, and both fail OPEN
|
|
20
|
+
*
|
|
21
|
+
* `navConfig` decides which nav entries an admin has switched off, and the hub
|
|
22
|
+
* must agree with the sidebar about that โ so it is read rather than ignored.
|
|
23
|
+
* On failure it resolves to `undefined`, which means "nothing is disabled" and
|
|
24
|
+
* shows MORE cards than it should. That is the right direction: a disabled nav
|
|
25
|
+
* entry is a configuration choice rather than a permission, and the page behind
|
|
26
|
+
* every card still enforces its own route guard. Hiding sections because a
|
|
27
|
+
* settings read blipped would be the damaging failure.
|
|
28
|
+
*
|
|
29
|
+
* The counts fail to `null` per card โ see `attention.ts`, which exists to
|
|
30
|
+
* explain why `0` is the one answer a badge must never invent.
|
|
31
|
+
*/
|
|
32
|
+
import { cache } from "react";
|
|
33
|
+
import { safeRead } from "../../../../errors/safe-read.js";
|
|
34
|
+
import { siteSettingsRepository } from "../../../../features/admin/repository/site-settings.repository.js";
|
|
35
|
+
import { filterNavGroups } from "../../../client/features/layout/filterNavItems.js";
|
|
36
|
+
import { hubSections, } from "../../../shared/features/dashboard/hub-sections.js";
|
|
37
|
+
import { collectAttentionCounts } from "./attention.js";
|
|
38
|
+
/**
|
|
39
|
+
* `navConfig` alone, cached per request.
|
|
40
|
+
*
|
|
41
|
+
* `React.cache` because a landing page renders the hub once but a hub page may
|
|
42
|
+
* also want the section list for its own heading, and the narrowest shared call
|
|
43
|
+
* is the one to cache (ยง "`React.cache` discipline" โ cache the repository
|
|
44
|
+
* read, not the viewer-specific wrapper around it, or two call sites with
|
|
45
|
+
* different arguments dedupe nothing).
|
|
46
|
+
*/
|
|
47
|
+
const getNavConfig = cache(async () => safeRead(async () => {
|
|
48
|
+
const settings = (await siteSettingsRepository.getSingleton());
|
|
49
|
+
return settings?.navConfig;
|
|
50
|
+
}, {
|
|
51
|
+
route: "dashboard-hub",
|
|
52
|
+
key: "navConfig",
|
|
53
|
+
fallback: undefined,
|
|
54
|
+
}));
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the sections a viewer may see, plus their attention counts.
|
|
57
|
+
*
|
|
58
|
+
* @param groups the portal's nav groups, unfiltered โ this applies the filter
|
|
59
|
+
* @param permissions `null` means admin (the convention `filterNavGroups` owns)
|
|
60
|
+
* @param storeId the viewer's store SLUG, for seller-scoped counts
|
|
61
|
+
*/
|
|
62
|
+
export async function getDashboardHubData(groups, permissions, storeId) {
|
|
63
|
+
const navConfig = await getNavConfig();
|
|
64
|
+
/*
|
|
65
|
+
* Filter FIRST, then count โ the visible ids are the scope (see
|
|
66
|
+
* `collectAttentionCounts`). Doing it the other way round costs an employee
|
|
67
|
+
* five queries to render one card, and computes figures from collections
|
|
68
|
+
* their permission set does not cover.
|
|
69
|
+
*
|
|
70
|
+
* Sequential rather than parallel with the settings read for the same
|
|
71
|
+
* reason: the second call's input is the first call's output. Two
|
|
72
|
+
* round trips, inside Rule #6's ~3.
|
|
73
|
+
*/
|
|
74
|
+
const sections = hubSections(filterNavGroups([...groups], navConfig, permissions));
|
|
75
|
+
const visibleItemIds = sections.flatMap((s) => s.items.map((i) => i.id).filter(Boolean));
|
|
76
|
+
const counts = await safeRead(() => collectAttentionCounts(visibleItemIds, storeId), {
|
|
77
|
+
route: "dashboard-hub",
|
|
78
|
+
key: "attention",
|
|
79
|
+
fallback: {},
|
|
80
|
+
});
|
|
81
|
+
return { sections, counts };
|
|
82
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hold stock for something that is not an order yet.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is for
|
|
5
|
+
*
|
|
6
|
+
* The plan's Part 6 (shared purchase) names this "the largest hidden cost",
|
|
7
|
+
* and it is the only one of its three prerequisites that had NOTHING behind
|
|
8
|
+
* it. See the schema file for why the existing order-keyed mechanism cannot
|
|
9
|
+
* serve a multi-day arrangement.
|
|
10
|
+
*
|
|
11
|
+
* ## ๐ Every function here moves the row and the counter TOGETHER
|
|
12
|
+
*
|
|
13
|
+
* `availableQuantity` is what a buyer can take; the gap between it and
|
|
14
|
+
* `stockQuantity` is the outstanding hold (`resolveStockResync`'s own words).
|
|
15
|
+
* A reservation row written without the decrement holds nothing at all, and a
|
|
16
|
+
* decrement written without a row is a hold nothing can expire โ so both
|
|
17
|
+
* happen inside ONE `runTransaction`, with the product read inside it so two
|
|
18
|
+
* concurrent reserves cannot both see the same `availableQuantity`.
|
|
19
|
+
*
|
|
20
|
+
* ## ๐ Firestore wants every READ before every WRITE
|
|
21
|
+
*
|
|
22
|
+
* So `endReservation` reads the reservation AND the product up front, even
|
|
23
|
+
* on the consume path where the product read is thrown away. The natural
|
|
24
|
+
* shape โ read the reservation, decide, then read the product โ throws
|
|
25
|
+
* `Firestore transactions require all reads to be executed before all
|
|
26
|
+
* writes`, and only at runtime, which is exactly the class of fault
|
|
27
|
+
* `restoreStockForOrder`'s header already warns about one directory over.
|
|
28
|
+
*
|
|
29
|
+
* ## Why it writes `availableQuantity` EXPLICITLY
|
|
30
|
+
*
|
|
31
|
+
* `ProductRepository.update`'s `resolveStockResync` returns `{}` the moment a
|
|
32
|
+
* write names `availableQuantity` โ deliberately, so the checkout decrement,
|
|
33
|
+
* the stock-restore job and `updateAvailableQuantity` stay authoritative over
|
|
34
|
+
* their own number. This is a fourth member of that set and takes the same
|
|
35
|
+
* deal: it is the one deciding the value, so the resync must keep its hands
|
|
36
|
+
* off it. (These writes go through the raw collection rather than the
|
|
37
|
+
* repository for the same reason `deriveTaxonomy` reads categories through
|
|
38
|
+
* `this.db` โ a transaction needs the ref, not a repository method.)
|
|
39
|
+
*/
|
|
40
|
+
import { type ReservationHolderKind, type StockReservationDocument } from "../../../../features/inventory/schemas/stock-reservation";
|
|
41
|
+
export interface ReserveStockInput {
|
|
42
|
+
productId: string;
|
|
43
|
+
quantity: number;
|
|
44
|
+
holderKind: ReservationHolderKind;
|
|
45
|
+
holderId: string;
|
|
46
|
+
createdBy: string;
|
|
47
|
+
/** Defaults to `RESERVATION_DEFAULT_TTL_HOURS`; capped at the 30-day ceiling. */
|
|
48
|
+
ttlHours?: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Take a hold. Returns the reservation id.
|
|
52
|
+
*
|
|
53
|
+
* ๐ Refuses rather than over-reserving. A seller's stock is a real number of
|
|
54
|
+
* real objects, so "reserve 5, only 3 left" is a **409** โ the request is
|
|
55
|
+
* well-formed and the world moved, which is the same distinction the lottery
|
|
56
|
+
* config write makes for a slot somebody has already pulled.
|
|
57
|
+
*/
|
|
58
|
+
export declare function reserveStock(input: ReserveStockInput): Promise<string>;
|
|
59
|
+
/**
|
|
60
|
+
* End a hold and PUT THE UNITS BACK โ the holder changed their mind, an admin
|
|
61
|
+
* took it back, or the sweep found it expired.
|
|
62
|
+
*/
|
|
63
|
+
export declare function releaseReservation(reservationId: string, opts?: {
|
|
64
|
+
status?: "released" | "expired";
|
|
65
|
+
reason?: string;
|
|
66
|
+
}): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* End a hold and KEEP the units off the shelf โ the arrangement completed and
|
|
69
|
+
* an order now owns them.
|
|
70
|
+
*
|
|
71
|
+
* ๐ This is the one path that must NOT restore. The order's own decrement is
|
|
72
|
+
* what holds the units from here, so restoring first would hand the same
|
|
73
|
+
* stock to the next buyer for the moment between the two writes โ and if the
|
|
74
|
+
* order write never lands, restoring would have been right. That race is why
|
|
75
|
+
* the caller should place the order and consume in one flow it can reason
|
|
76
|
+
* about; this function exists so it can do that rather than calling release
|
|
77
|
+
* and hoping.
|
|
78
|
+
*/
|
|
79
|
+
export declare function consumeReservation(reservationId: string): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Release every hold a holder owns. Used when a shared purchase is cancelled
|
|
82
|
+
* or abandoned โ the arrangement knows its own id, and should not have to
|
|
83
|
+
* remember which reservations it took.
|
|
84
|
+
*/
|
|
85
|
+
export declare function releaseReservationsForHolder(holderKind: ReservationHolderKind, holderId: string, reason?: string): Promise<number>;
|
|
86
|
+
/**
|
|
87
|
+
* The sweep. Expire every hold past its `expiresAt` and restore its units.
|
|
88
|
+
*
|
|
89
|
+
* ๐ Bounded, and the bound is REPORTED. A silent cap reads as "nothing left
|
|
90
|
+
* to expire" on the one run where it mattered โ the same lie a truncated
|
|
91
|
+
* recount tells (Root Cause #102).
|
|
92
|
+
*/
|
|
93
|
+
export declare function expireDueReservations(limit?: number): Promise<{
|
|
94
|
+
scanned: number;
|
|
95
|
+
expired: number;
|
|
96
|
+
truncated: boolean;
|
|
97
|
+
}>;
|
|
98
|
+
/** Units currently held on a product, for an admin explaining an unbuyable listing. */
|
|
99
|
+
export declare function activeReservationsForProduct(productId: string): Promise<StockReservationDocument[]>;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hold stock for something that is not an order yet.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is for
|
|
5
|
+
*
|
|
6
|
+
* The plan's Part 6 (shared purchase) names this "the largest hidden cost",
|
|
7
|
+
* and it is the only one of its three prerequisites that had NOTHING behind
|
|
8
|
+
* it. See the schema file for why the existing order-keyed mechanism cannot
|
|
9
|
+
* serve a multi-day arrangement.
|
|
10
|
+
*
|
|
11
|
+
* ## ๐ Every function here moves the row and the counter TOGETHER
|
|
12
|
+
*
|
|
13
|
+
* `availableQuantity` is what a buyer can take; the gap between it and
|
|
14
|
+
* `stockQuantity` is the outstanding hold (`resolveStockResync`'s own words).
|
|
15
|
+
* A reservation row written without the decrement holds nothing at all, and a
|
|
16
|
+
* decrement written without a row is a hold nothing can expire โ so both
|
|
17
|
+
* happen inside ONE `runTransaction`, with the product read inside it so two
|
|
18
|
+
* concurrent reserves cannot both see the same `availableQuantity`.
|
|
19
|
+
*
|
|
20
|
+
* ## ๐ Firestore wants every READ before every WRITE
|
|
21
|
+
*
|
|
22
|
+
* So `endReservation` reads the reservation AND the product up front, even
|
|
23
|
+
* on the consume path where the product read is thrown away. The natural
|
|
24
|
+
* shape โ read the reservation, decide, then read the product โ throws
|
|
25
|
+
* `Firestore transactions require all reads to be executed before all
|
|
26
|
+
* writes`, and only at runtime, which is exactly the class of fault
|
|
27
|
+
* `restoreStockForOrder`'s header already warns about one directory over.
|
|
28
|
+
*
|
|
29
|
+
* ## Why it writes `availableQuantity` EXPLICITLY
|
|
30
|
+
*
|
|
31
|
+
* `ProductRepository.update`'s `resolveStockResync` returns `{}` the moment a
|
|
32
|
+
* write names `availableQuantity` โ deliberately, so the checkout decrement,
|
|
33
|
+
* the stock-restore job and `updateAvailableQuantity` stay authoritative over
|
|
34
|
+
* their own number. This is a fourth member of that set and takes the same
|
|
35
|
+
* deal: it is the one deciding the value, so the resync must keep its hands
|
|
36
|
+
* off it. (These writes go through the raw collection rather than the
|
|
37
|
+
* repository for the same reason `deriveTaxonomy` reads categories through
|
|
38
|
+
* `this.db` โ a transaction needs the ref, not a repository method.)
|
|
39
|
+
*/
|
|
40
|
+
import { getAdminDb } from "../../../../providers/db-firebase/index.js";
|
|
41
|
+
import { ValidationError } from "../../../../errors/validation-error.js";
|
|
42
|
+
import { NotFoundError } from "../../../../errors/not-found-error.js";
|
|
43
|
+
import { ConflictError } from "../../../../errors/conflict-error.js";
|
|
44
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
45
|
+
import { serverLogger } from "../../../../monitoring/server-logger.js";
|
|
46
|
+
import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore.js";
|
|
47
|
+
import { STOCK_RESERVATION_FIELDS } from "../../../../constants/field-names.js";
|
|
48
|
+
import { RESERVATION_DEFAULT_TTL_HOURS, RESERVATION_MAX_TTL_HOURS, STOCK_RESERVATION_COLLECTION, } from "../../../../features/inventory/schemas/stock-reservation.js";
|
|
49
|
+
/**
|
|
50
|
+
* Take a hold. Returns the reservation id.
|
|
51
|
+
*
|
|
52
|
+
* ๐ Refuses rather than over-reserving. A seller's stock is a real number of
|
|
53
|
+
* real objects, so "reserve 5, only 3 left" is a **409** โ the request is
|
|
54
|
+
* well-formed and the world moved, which is the same distinction the lottery
|
|
55
|
+
* config write makes for a slot somebody has already pulled.
|
|
56
|
+
*/
|
|
57
|
+
export async function reserveStock(input) {
|
|
58
|
+
const quantity = Math.floor(input.quantity);
|
|
59
|
+
if (!Number.isFinite(quantity) || quantity < 1) {
|
|
60
|
+
throw new ValidationError("Reserve at least one unit.");
|
|
61
|
+
}
|
|
62
|
+
const ttlHours = clampTtl(input.ttlHours);
|
|
63
|
+
const db = getAdminDb();
|
|
64
|
+
const productRef = db.collection(PRODUCT_COLLECTION).doc(input.productId);
|
|
65
|
+
const reservationRef = db.collection(STOCK_RESERVATION_COLLECTION).doc();
|
|
66
|
+
await db.runTransaction(async (tx) => {
|
|
67
|
+
const snap = await tx.get(productRef);
|
|
68
|
+
if (!snap.exists)
|
|
69
|
+
throw new NotFoundError("Product not found.");
|
|
70
|
+
const available = readAvailable(snap.data());
|
|
71
|
+
if (available < quantity) {
|
|
72
|
+
throw new ConflictError(`Only ${Math.max(0, available)} left โ cannot hold ${quantity}.`);
|
|
73
|
+
}
|
|
74
|
+
const now = new Date();
|
|
75
|
+
const doc = {
|
|
76
|
+
productId: input.productId,
|
|
77
|
+
quantity,
|
|
78
|
+
holderKind: input.holderKind,
|
|
79
|
+
holderId: input.holderId,
|
|
80
|
+
createdBy: input.createdBy,
|
|
81
|
+
status: "held",
|
|
82
|
+
expiresAt: new Date(now.getTime() + ttlHours * 60 * 60 * 1000),
|
|
83
|
+
createdAt: now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
};
|
|
86
|
+
tx.set(reservationRef, doc);
|
|
87
|
+
tx.update(productRef, { availableQuantity: available - quantity, updatedAt: now });
|
|
88
|
+
});
|
|
89
|
+
return reservationRef.id;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* End a hold and PUT THE UNITS BACK โ the holder changed their mind, an admin
|
|
93
|
+
* took it back, or the sweep found it expired.
|
|
94
|
+
*/
|
|
95
|
+
export async function releaseReservation(reservationId, opts = {}) {
|
|
96
|
+
await endReservation(reservationId, opts.status ?? "released", true, opts.reason);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* End a hold and KEEP the units off the shelf โ the arrangement completed and
|
|
100
|
+
* an order now owns them.
|
|
101
|
+
*
|
|
102
|
+
* ๐ This is the one path that must NOT restore. The order's own decrement is
|
|
103
|
+
* what holds the units from here, so restoring first would hand the same
|
|
104
|
+
* stock to the next buyer for the moment between the two writes โ and if the
|
|
105
|
+
* order write never lands, restoring would have been right. That race is why
|
|
106
|
+
* the caller should place the order and consume in one flow it can reason
|
|
107
|
+
* about; this function exists so it can do that rather than calling release
|
|
108
|
+
* and hoping.
|
|
109
|
+
*/
|
|
110
|
+
export async function consumeReservation(reservationId) {
|
|
111
|
+
await endReservation(reservationId, "consumed", false);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Release every hold a holder owns. Used when a shared purchase is cancelled
|
|
115
|
+
* or abandoned โ the arrangement knows its own id, and should not have to
|
|
116
|
+
* remember which reservations it took.
|
|
117
|
+
*/
|
|
118
|
+
export async function releaseReservationsForHolder(holderKind, holderId, reason) {
|
|
119
|
+
const db = getAdminDb();
|
|
120
|
+
const snap = await db
|
|
121
|
+
.collection(STOCK_RESERVATION_COLLECTION)
|
|
122
|
+
.where(STOCK_RESERVATION_FIELDS.HOLDER_KIND, "==", holderKind)
|
|
123
|
+
.where(STOCK_RESERVATION_FIELDS.HOLDER_ID, "==", holderId)
|
|
124
|
+
.where(STOCK_RESERVATION_FIELDS.STATUS, "==", "held")
|
|
125
|
+
.get();
|
|
126
|
+
let released = 0;
|
|
127
|
+
for (const doc of snap.docs) {
|
|
128
|
+
try {
|
|
129
|
+
await endReservation(doc.id, "released", true, reason);
|
|
130
|
+
released += 1;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const normalized = normalizeError(error);
|
|
134
|
+
/*
|
|
135
|
+
* One bad row must not strand the rest. A holder with eight holds and
|
|
136
|
+
* one deleted product should still get the other seven back.
|
|
137
|
+
*/
|
|
138
|
+
serverLogger.warn("releaseReservationsForHolder: skipped one", {
|
|
139
|
+
reservationId: doc.id,
|
|
140
|
+
message: normalized.message,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return released;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The sweep. Expire every hold past its `expiresAt` and restore its units.
|
|
148
|
+
*
|
|
149
|
+
* ๐ Bounded, and the bound is REPORTED. A silent cap reads as "nothing left
|
|
150
|
+
* to expire" on the one run where it mattered โ the same lie a truncated
|
|
151
|
+
* recount tells (Root Cause #102).
|
|
152
|
+
*/
|
|
153
|
+
export async function expireDueReservations(limit = 200) {
|
|
154
|
+
const db = getAdminDb();
|
|
155
|
+
const snap = await db
|
|
156
|
+
.collection(STOCK_RESERVATION_COLLECTION)
|
|
157
|
+
.where(STOCK_RESERVATION_FIELDS.STATUS, "==", "held")
|
|
158
|
+
.where(STOCK_RESERVATION_FIELDS.EXPIRES_AT, "<=", new Date())
|
|
159
|
+
.orderBy(STOCK_RESERVATION_FIELDS.EXPIRES_AT, "asc")
|
|
160
|
+
.limit(limit)
|
|
161
|
+
.get();
|
|
162
|
+
let expired = 0;
|
|
163
|
+
for (const doc of snap.docs) {
|
|
164
|
+
try {
|
|
165
|
+
await endReservation(doc.id, "expired", true, "TTL elapsed");
|
|
166
|
+
expired += 1;
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const normalized = normalizeError(error);
|
|
170
|
+
serverLogger.warn("expireDueReservations: skipped one", {
|
|
171
|
+
reservationId: doc.id,
|
|
172
|
+
message: normalized.message,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { scanned: snap.size, expired, truncated: snap.size === limit };
|
|
177
|
+
}
|
|
178
|
+
/** Units currently held on a product, for an admin explaining an unbuyable listing. */
|
|
179
|
+
export async function activeReservationsForProduct(productId) {
|
|
180
|
+
const db = getAdminDb();
|
|
181
|
+
const snap = await db
|
|
182
|
+
.collection(STOCK_RESERVATION_COLLECTION)
|
|
183
|
+
.where(STOCK_RESERVATION_FIELDS.PRODUCT_ID, "==", productId)
|
|
184
|
+
.where(STOCK_RESERVATION_FIELDS.STATUS, "==", "held")
|
|
185
|
+
.get();
|
|
186
|
+
return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
|
187
|
+
}
|
|
188
|
+
function clampTtl(hours) {
|
|
189
|
+
if (hours === undefined)
|
|
190
|
+
return RESERVATION_DEFAULT_TTL_HOURS;
|
|
191
|
+
if (!Number.isFinite(hours) || hours <= 0) {
|
|
192
|
+
throw new ValidationError("A hold must last longer than zero hours.");
|
|
193
|
+
}
|
|
194
|
+
return Math.min(hours, RESERVATION_MAX_TTL_HOURS);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* ๐ `?? stockQuantity` and NOT `?? 0`.
|
|
198
|
+
*
|
|
199
|
+
* `availableQuantity` is optional on rows written before it existed, and
|
|
200
|
+
* treating an absent value as zero would refuse every reservation on a
|
|
201
|
+
* fully-stocked legacy listing โ a sold-out answer for a product that has
|
|
202
|
+
* stock, which reads as a stock bug rather than a schema gap.
|
|
203
|
+
*/
|
|
204
|
+
function readAvailable(data) {
|
|
205
|
+
const product = (data ?? {});
|
|
206
|
+
return product.availableQuantity ?? product.stockQuantity ?? 0;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The one write path. Every transition goes through here so the
|
|
210
|
+
* row-and-counter pairing cannot be forgotten by a new caller โ the same
|
|
211
|
+
* argument `deriveLinePrice` makes inside the cart repository.
|
|
212
|
+
*/
|
|
213
|
+
async function endReservation(reservationId, status, restore, reason) {
|
|
214
|
+
const db = getAdminDb();
|
|
215
|
+
const reservationRef = db.collection(STOCK_RESERVATION_COLLECTION).doc(reservationId);
|
|
216
|
+
await db.runTransaction(async (tx) => {
|
|
217
|
+
const snap = await tx.get(reservationRef);
|
|
218
|
+
if (!snap.exists)
|
|
219
|
+
throw new NotFoundError("Reservation not found.");
|
|
220
|
+
const reservation = snap.data();
|
|
221
|
+
/*
|
|
222
|
+
* ๐ Idempotent by STATUS, not by a flag. The sweep and a manual release
|
|
223
|
+
* can reach the same row in the same second; a second restore would
|
|
224
|
+
* invent units that do not exist, which is worse than a hold that lingers
|
|
225
|
+
* one sweep longer.
|
|
226
|
+
*/
|
|
227
|
+
if (reservation.status !== "held")
|
|
228
|
+
return;
|
|
229
|
+
/*
|
|
230
|
+
* Read the product BEFORE the first write, even when `restore` is false
|
|
231
|
+
* and the value is discarded โ see this file's header. One wasted read on
|
|
232
|
+
* the consume path buys a function whose transaction shape cannot be
|
|
233
|
+
* broken by a later edit reordering the branches.
|
|
234
|
+
*/
|
|
235
|
+
const productRef = db.collection(PRODUCT_COLLECTION).doc(reservation.productId);
|
|
236
|
+
const productSnap = restore ? await tx.get(productRef) : null;
|
|
237
|
+
const now = new Date();
|
|
238
|
+
tx.update(reservationRef, {
|
|
239
|
+
status,
|
|
240
|
+
endedAt: now,
|
|
241
|
+
updatedAt: now,
|
|
242
|
+
...(reason ? { endedReason: reason } : {}),
|
|
243
|
+
});
|
|
244
|
+
if (!restore)
|
|
245
|
+
return;
|
|
246
|
+
if (!productSnap?.exists) {
|
|
247
|
+
/*
|
|
248
|
+
* The product is gone, so there is no counter to restore โ but the row
|
|
249
|
+
* still has to close, or the sweep retries it forever. Recorded rather
|
|
250
|
+
* than thrown: an orphaned hold is a data problem, not a reason to fail
|
|
251
|
+
* the release of the others beside it.
|
|
252
|
+
*/
|
|
253
|
+
serverLogger.warn("endReservation: product missing, counter not restored", {
|
|
254
|
+
reservationId,
|
|
255
|
+
productId: reservation.productId,
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const product = productSnap.data();
|
|
260
|
+
const available = readAvailable(product);
|
|
261
|
+
const stock = product.stockQuantity ?? available;
|
|
262
|
+
/*
|
|
263
|
+
* ๐ Clamped at `stockQuantity`. A seller who lowered stock while units
|
|
264
|
+
* were held would otherwise get an `availableQuantity` ABOVE their own
|
|
265
|
+
* stock on release โ a listing offering more than exists, which is the
|
|
266
|
+
* one direction a stock bug must never go.
|
|
267
|
+
*/
|
|
268
|
+
tx.update(productRef, {
|
|
269
|
+
availableQuantity: Math.min(stock, available + reservation.quantity),
|
|
270
|
+
updatedAt: now,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side verification that a coordinated purchase's destinations are real
|
|
3
|
+
* and belong to the people naming them.
|
|
4
|
+
*
|
|
5
|
+
* ## ๐ Why this file exists at all
|
|
6
|
+
*
|
|
7
|
+
* `CoordinationMember.destinationAddressId` is a REFERENCE, which is the right
|
|
8
|
+
* shape โ copying the address inline would put a name, a phone number and a
|
|
9
|
+
* street into an offer document with no encryption story and no retention
|
|
10
|
+
* story. But a reference has a failure mode a copy does not:
|
|
11
|
+
*
|
|
12
|
+
* **An id is guessable in a way an address is not.** Without this check a
|
|
13
|
+
* member supplies any `addresses` document id and the parcel ships to a
|
|
14
|
+
* stranger's house, at the coordinator's expense, with every other part of
|
|
15
|
+
* the flow behaving correctly. Nothing errors; the order is valid; the goods
|
|
16
|
+
* simply go somewhere nobody chose.
|
|
17
|
+
*
|
|
18
|
+
* So the rule is: **an id that crosses the wire is never trusted, and is
|
|
19
|
+
* resolved against its claimed owner before anything is written.**
|
|
20
|
+
*/
|
|
21
|
+
import type { CoordinationMember } from "../../../shared/features/offers/coordination";
|
|
22
|
+
/**
|
|
23
|
+
* Every named destination must exist and belong to the member who named it.
|
|
24
|
+
*
|
|
25
|
+
* ๐ **Owned by THAT MEMBER, not merely by someone.** Checking only that the
|
|
26
|
+
* address exists and has `ownerType: "user"` would let a member ship to any
|
|
27
|
+
* other user's saved address โ the weaker check reads as a real one and
|
|
28
|
+
* closes nothing.
|
|
29
|
+
*
|
|
30
|
+
* ๐ **Fails the whole request, naming the member.** Dropping a bad
|
|
31
|
+
* destination and shipping that member's parcel to the coordinator instead
|
|
32
|
+
* would be a silent substitution of somebody's delivery address, which is
|
|
33
|
+
* exactly the class of quiet wrongness this check exists to prevent. Grouped
|
|
34
|
+
* cart lines take the same position for the same reason.
|
|
35
|
+
*
|
|
36
|
+
* One `getAll` over the distinct ids, not one read per member: five members
|
|
37
|
+
* sharing two destinations is two reads, and Rule #6's budget is per request
|
|
38
|
+
* rather than per member.
|
|
39
|
+
*/
|
|
40
|
+
export declare function assertOwnedAddresses(members: readonly CoordinationMember[]): Promise<void>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side verification that a coordinated purchase's destinations are real
|
|
3
|
+
* and belong to the people naming them.
|
|
4
|
+
*
|
|
5
|
+
* ## ๐ Why this file exists at all
|
|
6
|
+
*
|
|
7
|
+
* `CoordinationMember.destinationAddressId` is a REFERENCE, which is the right
|
|
8
|
+
* shape โ copying the address inline would put a name, a phone number and a
|
|
9
|
+
* street into an offer document with no encryption story and no retention
|
|
10
|
+
* story. But a reference has a failure mode a copy does not:
|
|
11
|
+
*
|
|
12
|
+
* **An id is guessable in a way an address is not.** Without this check a
|
|
13
|
+
* member supplies any `addresses` document id and the parcel ships to a
|
|
14
|
+
* stranger's house, at the coordinator's expense, with every other part of
|
|
15
|
+
* the flow behaving correctly. Nothing errors; the order is valid; the goods
|
|
16
|
+
* simply go somewhere nobody chose.
|
|
17
|
+
*
|
|
18
|
+
* So the rule is: **an id that crosses the wire is never trusted, and is
|
|
19
|
+
* resolved against its claimed owner before anything is written.**
|
|
20
|
+
*/
|
|
21
|
+
import { addressesRepository } from "../../../../features/addresses/repository/addresses.repository.js";
|
|
22
|
+
import { ValidationError } from "../../../../errors/validation-error.js";
|
|
23
|
+
/**
|
|
24
|
+
* Every named destination must exist and belong to the member who named it.
|
|
25
|
+
*
|
|
26
|
+
* ๐ **Owned by THAT MEMBER, not merely by someone.** Checking only that the
|
|
27
|
+
* address exists and has `ownerType: "user"` would let a member ship to any
|
|
28
|
+
* other user's saved address โ the weaker check reads as a real one and
|
|
29
|
+
* closes nothing.
|
|
30
|
+
*
|
|
31
|
+
* ๐ **Fails the whole request, naming the member.** Dropping a bad
|
|
32
|
+
* destination and shipping that member's parcel to the coordinator instead
|
|
33
|
+
* would be a silent substitution of somebody's delivery address, which is
|
|
34
|
+
* exactly the class of quiet wrongness this check exists to prevent. Grouped
|
|
35
|
+
* cart lines take the same position for the same reason.
|
|
36
|
+
*
|
|
37
|
+
* One `getAll` over the distinct ids, not one read per member: five members
|
|
38
|
+
* sharing two destinations is two reads, and Rule #6's budget is per request
|
|
39
|
+
* rather than per member.
|
|
40
|
+
*/
|
|
41
|
+
export async function assertOwnedAddresses(members) {
|
|
42
|
+
const named = members.filter((m) => !!m.destinationAddressId);
|
|
43
|
+
if (named.length === 0)
|
|
44
|
+
return;
|
|
45
|
+
const ids = [...new Set(named.map((m) => m.destinationAddressId))];
|
|
46
|
+
const docs = await Promise.all(ids.map((id) => addressesRepository.findById(id)));
|
|
47
|
+
const byId = new Map(ids.map((id, i) => [id, docs[i]]));
|
|
48
|
+
for (const member of named) {
|
|
49
|
+
const address = byId.get(member.destinationAddressId);
|
|
50
|
+
/*
|
|
51
|
+
* ๐ ONE message for "does not exist" and "is not yours".
|
|
52
|
+
*
|
|
53
|
+
* Distinguishing them turns this endpoint into an oracle for which
|
|
54
|
+
* `addresses` ids are real โ a caller probes ids and reads existence off
|
|
55
|
+
* the error text. The member who typed a genuinely stale id and the
|
|
56
|
+
* member probing somebody else's get the same answer, and only the first
|
|
57
|
+
* of those has a legitimate next step anyway (pick again).
|
|
58
|
+
*/
|
|
59
|
+
if (!address || address.ownerType !== "user" || address.ownerId !== member.uid) {
|
|
60
|
+
throw new ValidationError("A chosen delivery address is not available.", {
|
|
61
|
+
destinationAddressId: `${member.uid}: that address cannot be used.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -346,6 +346,27 @@ export function orderDocumentToOrder(doc) {
|
|
|
346
346
|
timelineTruncated: doc.statusHistoryTruncated,
|
|
347
347
|
/** How the buyer acquired the right to buy. Written once, at creation. */
|
|
348
348
|
sourceContext: doc.sourceContext,
|
|
349
|
+
/*
|
|
350
|
+
* D55 โ the pre-order balance, present only once a seller has opened it.
|
|
351
|
+
*
|
|
352
|
+
* ๐ `reviewedBy` is deliberately NOT mapped. It is the reviewing staff
|
|
353
|
+
* member's uid and no buyer surface reads it, so it stays server-side by
|
|
354
|
+
* default (ยง Public Data Projections โ a field is private until someone
|
|
355
|
+
* names the client that reads it).
|
|
356
|
+
*/
|
|
357
|
+
balance: doc.balance
|
|
358
|
+
? {
|
|
359
|
+
amount: doc.balance.amount,
|
|
360
|
+
method: doc.balance.method,
|
|
361
|
+
status: doc.balance.status,
|
|
362
|
+
dueBy: toIsoOrUndefined(doc.balance.dueBy),
|
|
363
|
+
proofUrl: doc.balance.proofUrl,
|
|
364
|
+
transactionId: doc.balance.transactionId,
|
|
365
|
+
submittedAt: toIsoOrUndefined(doc.balance.submittedAt),
|
|
366
|
+
reviewedAt: toIsoOrUndefined(doc.balance.reviewedAt),
|
|
367
|
+
reviewNote: doc.balance.reviewNote,
|
|
368
|
+
}
|
|
369
|
+
: undefined,
|
|
349
370
|
createdAt: doc.createdAt instanceof Date
|
|
350
371
|
? doc.createdAt.toISOString()
|
|
351
372
|
: String(doc.createdAt),
|