@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
|
@@ -19,8 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import type { CartItemDocument } from "../../../../features/cart/schemas/firestore";
|
|
21
21
|
import type { ProductDocument } from "../../../../features/products/schemas/firestore";
|
|
22
|
-
import type
|
|
23
|
-
/** A row before rule decoration — the shape `decorateOrderItem` receives. */
|
|
22
|
+
import { type OrderDocumentItem } from "../../../../features/orders/schemas/firestore";
|
|
24
23
|
export type BaseOrderLine = Omit<OrderDocumentItem, "listingType">;
|
|
25
24
|
/**
|
|
26
25
|
* Expand one cart line into the order rows it should produce.
|
|
@@ -17,8 +17,35 @@
|
|
|
17
17
|
* The buyer's receipt is unaffected: `groupOrderItemsByLine` collapses these
|
|
18
18
|
* rows back under one header, which it was already written to do.
|
|
19
19
|
*/
|
|
20
|
+
import { ORDER_ITEM_DESCRIPTION_MAX, ORDER_ITEM_SPECIFICATIONS_MAX, } from "../../../../features/orders/schemas/firestore.js";
|
|
20
21
|
import { lineTotalFor, allocateAcrossMembers } from "../../../shared/checkout/order-math.js";
|
|
21
22
|
import { isFinalSale } from "../../../../features/products/constants/final-sale.js";
|
|
23
|
+
function listingSnapshot(p) {
|
|
24
|
+
if (!p)
|
|
25
|
+
return {};
|
|
26
|
+
/*
|
|
27
|
+
* 🛑 A named Pick, not `Record<string, unknown>`. A bag of unknowns
|
|
28
|
+
* type-checks against anything, so a misspelled key — `descriptionTruncted`
|
|
29
|
+
* — would compile, spread onto the order line, and simply never be read.
|
|
30
|
+
* The field would look set and the truncation flag would be missing from
|
|
31
|
+
* every row (`audit-unknown-leakage` blocks exactly this).
|
|
32
|
+
*/
|
|
33
|
+
const out = {};
|
|
34
|
+
const desc = typeof p.description === "string" ? p.description.trim() : "";
|
|
35
|
+
if (desc) {
|
|
36
|
+
out.description = desc.slice(0, ORDER_ITEM_DESCRIPTION_MAX);
|
|
37
|
+
if (desc.length > ORDER_ITEM_DESCRIPTION_MAX)
|
|
38
|
+
out.descriptionTruncated = true;
|
|
39
|
+
}
|
|
40
|
+
if (p.specifications?.length) {
|
|
41
|
+
out.specifications = p.specifications.slice(0, ORDER_ITEM_SPECIFICATIONS_MAX).map((x) => ({
|
|
42
|
+
name: x.name,
|
|
43
|
+
value: x.value,
|
|
44
|
+
...(x.unit ? { unit: x.unit } : {}),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
22
49
|
/**
|
|
23
50
|
* Expand one cart line into the order rows it should produce.
|
|
24
51
|
*
|
|
@@ -89,6 +116,8 @@ export function expandCartLineToOrderRows(item, representative, productById) {
|
|
|
89
116
|
...(representative.condition ? { condition: representative.condition } : {}),
|
|
90
117
|
// I9 — the physical variant, for the price index's colourway slice.
|
|
91
118
|
...(representative.colourway ? { colourway: representative.colourway } : {}),
|
|
119
|
+
/* Part 14 — what the listing SAID, which is what a dispute argues over. */
|
|
120
|
+
...listingSnapshot(representative),
|
|
92
121
|
// Legacy collapsed bundle rows (no groupMembers) keep their old shape so
|
|
93
122
|
// orders already in flight and their UI stay untouched.
|
|
94
123
|
...(item.bundleCategorySlug && item.bundleProductIds
|
|
@@ -146,6 +175,9 @@ export function expandCartLineToOrderRows(item, representative, productById) {
|
|
|
146
175
|
// blended condition would describe neither.
|
|
147
176
|
...(product?.condition ? { condition: product.condition } : {}),
|
|
148
177
|
...(product?.colourway ? { colourway: product.colourway } : {}),
|
|
178
|
+
/* Per MEMBER, same as condition above: a bundle can mix a sealed item
|
|
179
|
+
* with a loose one, and one blended description would describe neither. */
|
|
180
|
+
...listingSnapshot(product),
|
|
149
181
|
...(gstRate != null ? { gstRate, ...(hsnCode ? { hsnCode } : {}) } : {}),
|
|
150
182
|
};
|
|
151
183
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freezing a composed listing's PARTS onto its order line — Part 14.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 Why this is a separate pass, and not inside `expand-order-items`
|
|
5
|
+
*
|
|
6
|
+
* `expandCartLineToOrderRows` is PURE: it takes a product map and returns
|
|
7
|
+
* rows, with no I/O. Composition is not on the product — it lives in
|
|
8
|
+
* `modelCompositions`, keyed by the model's CATEGORY leaf (a named model *is*
|
|
9
|
+
* a category leaf, per the parts schema). So freezing it needs a read, and a
|
|
10
|
+
* read inside that function would make the one piece of checkout arithmetic
|
|
11
|
+
* that can be reasoned about locally depend on Firestore.
|
|
12
|
+
*
|
|
13
|
+
* ## 🛑 Bounded, and usually free
|
|
14
|
+
*
|
|
15
|
+
* Two gates, in this order, because the first one is what keeps the budget:
|
|
16
|
+
*
|
|
17
|
+
* 1. **`LISTING_TYPE_HAS_COMPOSITION`** — most listing types have none, so a
|
|
18
|
+
* typical order does zero reads. This is checked before anything else.
|
|
19
|
+
* 2. **Distinct category leaves**, capped. Several lines of the same model
|
|
20
|
+
* share one read; `getCompositionsForCategory` is `React.cache`-wrapped, so
|
|
21
|
+
* a repeat within one request is free anyway.
|
|
22
|
+
*
|
|
23
|
+
* Rule #6 budgets ~3 sequential round trips for a server action. The cap is
|
|
24
|
+
* what stops a 20-line order of distinct models turning checkout into twenty.
|
|
25
|
+
*
|
|
26
|
+
* ## 🛑 Failure is SILENT BY DESIGN here, which is unusual and deliberate
|
|
27
|
+
*
|
|
28
|
+
* `getCompositionsForCategory` already swallows and warns. A composition we
|
|
29
|
+
* could not read must never fail a checkout: the buyer is paying, the parts
|
|
30
|
+
* list is descriptive, and an order placed without it is complete in every
|
|
31
|
+
* way that matters. The absence is recoverable — the live category still has
|
|
32
|
+
* the data — while a refused payment is not.
|
|
33
|
+
*/
|
|
34
|
+
import type { OrderDocumentItem } from "../../../../features/orders/schemas/firestore";
|
|
35
|
+
/**
|
|
36
|
+
* How many distinct models one order may resolve composition for.
|
|
37
|
+
*
|
|
38
|
+
* Past this the order is a bulk purchase and the parts list stops being the
|
|
39
|
+
* thing anyone disputes. Exceeding it drops the extra lines' parts rather
|
|
40
|
+
* than failing — see the header.
|
|
41
|
+
*/
|
|
42
|
+
export declare const COMPOSITION_LOOKUP_MAX = 8;
|
|
43
|
+
/**
|
|
44
|
+
* Attach `parts` to every line whose listing type has a composition.
|
|
45
|
+
*
|
|
46
|
+
* Returns a NEW array; the input rows are not mutated, because they are built
|
|
47
|
+
* by a pure function and keeping them pure is what lets that function be
|
|
48
|
+
* reasoned about on its own.
|
|
49
|
+
*/
|
|
50
|
+
export declare function freezePartsOnItems(items: readonly OrderDocumentItem[]): Promise<OrderDocumentItem[]>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Freezing a composed listing's PARTS onto its order line — Part 14.
|
|
3
|
+
*
|
|
4
|
+
* ## 🛑 Why this is a separate pass, and not inside `expand-order-items`
|
|
5
|
+
*
|
|
6
|
+
* `expandCartLineToOrderRows` is PURE: it takes a product map and returns
|
|
7
|
+
* rows, with no I/O. Composition is not on the product — it lives in
|
|
8
|
+
* `modelCompositions`, keyed by the model's CATEGORY leaf (a named model *is*
|
|
9
|
+
* a category leaf, per the parts schema). So freezing it needs a read, and a
|
|
10
|
+
* read inside that function would make the one piece of checkout arithmetic
|
|
11
|
+
* that can be reasoned about locally depend on Firestore.
|
|
12
|
+
*
|
|
13
|
+
* ## 🛑 Bounded, and usually free
|
|
14
|
+
*
|
|
15
|
+
* Two gates, in this order, because the first one is what keeps the budget:
|
|
16
|
+
*
|
|
17
|
+
* 1. **`LISTING_TYPE_HAS_COMPOSITION`** — most listing types have none, so a
|
|
18
|
+
* typical order does zero reads. This is checked before anything else.
|
|
19
|
+
* 2. **Distinct category leaves**, capped. Several lines of the same model
|
|
20
|
+
* share one read; `getCompositionsForCategory` is `React.cache`-wrapped, so
|
|
21
|
+
* a repeat within one request is free anyway.
|
|
22
|
+
*
|
|
23
|
+
* Rule #6 budgets ~3 sequential round trips for a server action. The cap is
|
|
24
|
+
* what stops a 20-line order of distinct models turning checkout into twenty.
|
|
25
|
+
*
|
|
26
|
+
* ## 🛑 Failure is SILENT BY DESIGN here, which is unusual and deliberate
|
|
27
|
+
*
|
|
28
|
+
* `getCompositionsForCategory` already swallows and warns. A composition we
|
|
29
|
+
* could not read must never fail a checkout: the buyer is paying, the parts
|
|
30
|
+
* list is descriptive, and an order placed without it is complete in every
|
|
31
|
+
* way that matters. The absence is recoverable — the live category still has
|
|
32
|
+
* the data — while a refused payment is not.
|
|
33
|
+
*/
|
|
34
|
+
import { getCompositionsForCategory } from "../parts/data.js";
|
|
35
|
+
import { LISTING_TYPE_HAS_COMPOSITION } from "../../../../features/parts/schemas/firestore.js";
|
|
36
|
+
/**
|
|
37
|
+
* How many distinct models one order may resolve composition for.
|
|
38
|
+
*
|
|
39
|
+
* Past this the order is a bulk purchase and the parts list stops being the
|
|
40
|
+
* thing anyone disputes. Exceeding it drops the extra lines' parts rather
|
|
41
|
+
* than failing — see the header.
|
|
42
|
+
*/
|
|
43
|
+
export const COMPOSITION_LOOKUP_MAX = 8;
|
|
44
|
+
/**
|
|
45
|
+
* Attach `parts` to every line whose listing type has a composition.
|
|
46
|
+
*
|
|
47
|
+
* Returns a NEW array; the input rows are not mutated, because they are built
|
|
48
|
+
* by a pure function and keeping them pure is what lets that function be
|
|
49
|
+
* reasoned about on its own.
|
|
50
|
+
*/
|
|
51
|
+
export async function freezePartsOnItems(items) {
|
|
52
|
+
const eligible = items.filter((i) => i.listingType && LISTING_TYPE_HAS_COMPOSITION[i.listingType] && i.categorySlug);
|
|
53
|
+
if (eligible.length === 0)
|
|
54
|
+
return [...items];
|
|
55
|
+
const leaves = [...new Set(eligible.map((i) => i.categorySlug))].slice(0, COMPOSITION_LOOKUP_MAX);
|
|
56
|
+
const byLeaf = new Map();
|
|
57
|
+
await Promise.all(leaves.map(async (leaf) => {
|
|
58
|
+
const comps = await getCompositionsForCategory(leaf);
|
|
59
|
+
/*
|
|
60
|
+
* One category can carry several compositions (a model released in
|
|
61
|
+
* variants). Flattened and de-duplicated by code: the order records
|
|
62
|
+
* which parts were CLAIMED, and two variants naming the same ring is
|
|
63
|
+
* one fact, not two.
|
|
64
|
+
*/
|
|
65
|
+
const seen = new Map();
|
|
66
|
+
for (const c of comps) {
|
|
67
|
+
/*
|
|
68
|
+
* 🛑 `slots`, not `parts`. The first draft read `c.parts` — a field
|
|
69
|
+
* `ModelCompositionDocument` does not have — which would have
|
|
70
|
+
* silently frozen an empty list onto every composed line while every
|
|
71
|
+
* other part of the flow behaved correctly. Measured against the
|
|
72
|
+
* schema before shipping, not after.
|
|
73
|
+
*/
|
|
74
|
+
for (const slot of c.slots ?? []) {
|
|
75
|
+
/*
|
|
76
|
+
* `slotKey` is the stable identity (`partId` is absent whenever a
|
|
77
|
+
* part did not resolve to a glossary row, which is common in the
|
|
78
|
+
* corpus). `partName` is what a human reads in a dispute.
|
|
79
|
+
*/
|
|
80
|
+
const code = String(slot.partId ?? slot.slotKey ?? "");
|
|
81
|
+
if (!code || seen.has(code))
|
|
82
|
+
continue;
|
|
83
|
+
seen.set(code, String(slot.partName ?? slot.slotLabel ?? code));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (seen.size > 0) {
|
|
87
|
+
byLeaf.set(leaf, [...seen].map(([code, name]) => ({ code, name })));
|
|
88
|
+
}
|
|
89
|
+
}));
|
|
90
|
+
return items.map((i) => {
|
|
91
|
+
const parts = i.categorySlug ? byLeaf.get(i.categorySlug) : undefined;
|
|
92
|
+
return parts?.length ? { ...i, parts } : i;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The high-value OTP gate — one implementation, taking the amount it judges.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists as a function rather than two `if`s
|
|
5
|
+
*
|
|
6
|
+
* There were two hand-rolled copies: one in `createOrderForGroup`'s lane
|
|
7
|
+
* block and one in `POST /api/payment/create-order`. They already disagreed
|
|
8
|
+
* on what they measured — the first summed `price × quantity` over the cart,
|
|
9
|
+
* the second used the gateway route's own `subtotalRs` — and neither could
|
|
10
|
+
* be reused by anything else, because the amount was computed inline rather
|
|
11
|
+
* than passed in. That is Root Cause #75's shape exactly: a rule reachable
|
|
12
|
+
* only by copy-paste gets copied, and a copy made before a branch was added
|
|
13
|
+
* never learns about it.
|
|
14
|
+
*
|
|
15
|
+
* ## 🛑 It takes an AMOUNT, and that is the Part 6 prerequisite
|
|
16
|
+
*
|
|
17
|
+
* `otpCheckoutThreshold` is per CHECKOUT, so three ₹4,000 checkouts clear
|
|
18
|
+
* ₹12,000 with no OTP — and splitting an order is precisely what a shared
|
|
19
|
+
* purchase does. The gate therefore cannot keep deriving its own figure from
|
|
20
|
+
* whatever cart happens to be in scope; the caller has to say what sum is
|
|
21
|
+
* being authorised. For an ordinary checkout that is the cart; for an
|
|
22
|
+
* N-party arrangement it is the ARRANGEMENT total, charged across several
|
|
23
|
+
* payments that are individually under the line.
|
|
24
|
+
*
|
|
25
|
+
* Passing the amount in is the whole change. There is no arrangement yet to
|
|
26
|
+
* pass one, which is why this lands as a prerequisite rather than as a
|
|
27
|
+
* feature — but the gate can no longer be dodged by a caller that did not
|
|
28
|
+
* exist when the rule was written.
|
|
29
|
+
*/
|
|
30
|
+
export interface CheckoutValueOtpGateInput {
|
|
31
|
+
uid: string;
|
|
32
|
+
/** The sum being authorised, in rupees. See the header: the CALLER decides. */
|
|
33
|
+
amount: number;
|
|
34
|
+
/** `siteSettings.payment.otpCheckoutThreshold`. Absent or ≤ 0 disables the gate. */
|
|
35
|
+
threshold: number | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* 🛑 COD is exempt, and that is a real decision rather than an oversight:
|
|
38
|
+
* a physical hand-off at the door is its own verification, and nothing has
|
|
39
|
+
* been captured when the gate would fire. Pass the method so the exemption
|
|
40
|
+
* lives here instead of being re-remembered at each call site.
|
|
41
|
+
*/
|
|
42
|
+
paymentMethod?: string;
|
|
43
|
+
/** The admin checkout bypass (`siteSettings.featureFlags.adminCheckoutBypass`). */
|
|
44
|
+
adminBypass?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Throws **403 `CHECKOUT_VALUE_OTP_REQUIRED`** when the amount clears the
|
|
48
|
+
* threshold and the buyer has not verified. Returns silently otherwise.
|
|
49
|
+
*
|
|
50
|
+
* 🛑 Call it BEFORE creating a gateway order, never after. Verifying
|
|
51
|
+
* post-payment means charging the buyer and then asking whether they were
|
|
52
|
+
* allowed to be charged — which is what the comment at the gateway call site
|
|
53
|
+
* has always said and is the reason that copy sits where it does.
|
|
54
|
+
*/
|
|
55
|
+
export declare function assertCheckoutValueOtp(input: CheckoutValueOtpGateInput): Promise<void>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The high-value OTP gate — one implementation, taking the amount it judges.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists as a function rather than two `if`s
|
|
5
|
+
*
|
|
6
|
+
* There were two hand-rolled copies: one in `createOrderForGroup`'s lane
|
|
7
|
+
* block and one in `POST /api/payment/create-order`. They already disagreed
|
|
8
|
+
* on what they measured — the first summed `price × quantity` over the cart,
|
|
9
|
+
* the second used the gateway route's own `subtotalRs` — and neither could
|
|
10
|
+
* be reused by anything else, because the amount was computed inline rather
|
|
11
|
+
* than passed in. That is Root Cause #75's shape exactly: a rule reachable
|
|
12
|
+
* only by copy-paste gets copied, and a copy made before a branch was added
|
|
13
|
+
* never learns about it.
|
|
14
|
+
*
|
|
15
|
+
* ## 🛑 It takes an AMOUNT, and that is the Part 6 prerequisite
|
|
16
|
+
*
|
|
17
|
+
* `otpCheckoutThreshold` is per CHECKOUT, so three ₹4,000 checkouts clear
|
|
18
|
+
* ₹12,000 with no OTP — and splitting an order is precisely what a shared
|
|
19
|
+
* purchase does. The gate therefore cannot keep deriving its own figure from
|
|
20
|
+
* whatever cart happens to be in scope; the caller has to say what sum is
|
|
21
|
+
* being authorised. For an ordinary checkout that is the cart; for an
|
|
22
|
+
* N-party arrangement it is the ARRANGEMENT total, charged across several
|
|
23
|
+
* payments that are individually under the line.
|
|
24
|
+
*
|
|
25
|
+
* Passing the amount in is the whole change. There is no arrangement yet to
|
|
26
|
+
* pass one, which is why this lands as a prerequisite rather than as a
|
|
27
|
+
* feature — but the gate can no longer be dodged by a caller that did not
|
|
28
|
+
* exist when the rule was written.
|
|
29
|
+
*/
|
|
30
|
+
import { ApiError } from "../../../../errors/api-error.js";
|
|
31
|
+
import { isCheckoutValueOtpVerified } from "../../../../features/checkout/actions/checkout-value-otp-actions.js";
|
|
32
|
+
/**
|
|
33
|
+
* Throws **403 `CHECKOUT_VALUE_OTP_REQUIRED`** when the amount clears the
|
|
34
|
+
* threshold and the buyer has not verified. Returns silently otherwise.
|
|
35
|
+
*
|
|
36
|
+
* 🛑 Call it BEFORE creating a gateway order, never after. Verifying
|
|
37
|
+
* post-payment means charging the buyer and then asking whether they were
|
|
38
|
+
* allowed to be charged — which is what the comment at the gateway call site
|
|
39
|
+
* has always said and is the reason that copy sits where it does.
|
|
40
|
+
*/
|
|
41
|
+
export async function assertCheckoutValueOtp(input) {
|
|
42
|
+
const { uid, amount, threshold, paymentMethod, adminBypass } = input;
|
|
43
|
+
if (adminBypass)
|
|
44
|
+
return;
|
|
45
|
+
if (paymentMethod === "cod")
|
|
46
|
+
return;
|
|
47
|
+
if (typeof threshold !== "number" || threshold <= 0)
|
|
48
|
+
return;
|
|
49
|
+
/*
|
|
50
|
+
* `>=`, not `>`. A threshold of ₹5,000 means a ₹5,000 checkout is gated —
|
|
51
|
+
* the schema comment on `otpCheckoutThreshold` calls it a MINIMUM, and
|
|
52
|
+
* both previous copies already read it that way.
|
|
53
|
+
*/
|
|
54
|
+
if (amount < threshold)
|
|
55
|
+
return;
|
|
56
|
+
const verified = await isCheckoutValueOtpVerified(uid);
|
|
57
|
+
if (!verified) {
|
|
58
|
+
throw new ApiError(403, "CHECKOUT_VALUE_OTP_REQUIRED");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attention counts for the dashboard hubs — "how many things here are waiting
|
|
3
|
+
* for me".
|
|
4
|
+
*
|
|
5
|
+
* ## 🛑 A count that fails renders ABSENT, never `0`
|
|
6
|
+
*
|
|
7
|
+
* This is the one rule the whole module is shaped around, and it is the
|
|
8
|
+
* opposite of what `dailyStatusDigest`'s `safeCount` does. The digest returns
|
|
9
|
+
* `0` on a failed query and says so: *"a stuck zero next to live numbers
|
|
10
|
+
* elsewhere is legible as 'that one broke'"*. That is true of a table of
|
|
11
|
+
* twenty figures in an email and false of a badge on a card — there, `0` reads
|
|
12
|
+
* as **"nothing needs attention"**, which is the one wrong answer that stops
|
|
13
|
+
* the operator looking.
|
|
14
|
+
*
|
|
15
|
+
* So a failed source resolves to `null` and the card renders no badge at all.
|
|
16
|
+
* Root Cause #72: `undefined` must mean "the count failed", never "zero", and
|
|
17
|
+
* every count fails OPEN.
|
|
18
|
+
*
|
|
19
|
+
* ## Keyed by nav item id, not by section
|
|
20
|
+
*
|
|
21
|
+
* A count belongs to a SCREEN ("2 payouts are pending"), not to a section, so
|
|
22
|
+
* the key is `navItemId(portal, href)` — already derived, already stable,
|
|
23
|
+
* already the `navConfig` key. A section card sums its own items' counts, so
|
|
24
|
+
* no second keying scheme exists and a renamed group cannot orphan a count.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 The ids are written as literals here and CROSS-CHECKED against the real
|
|
27
|
+
* nav by `audit-dashboard-hub-coverage`. A literal that matches no nav item is
|
|
28
|
+
* a count nothing can ever display — the silent half of Root Cause #62, where
|
|
29
|
+
* a filter is emitted on a field the allowlist omits and simply vanishes.
|
|
30
|
+
*
|
|
31
|
+
* ## 🛑 `nav-admin-admin-orders` is CORRECT. Do not "tidy" the repetition.
|
|
32
|
+
*
|
|
33
|
+
* `navItemId(portal, href)` is `nav-${portal}-${slugified href}`, and the href
|
|
34
|
+
* ALSO begins with the portal segment — so `/admin/orders` under the `admin`
|
|
35
|
+
* portal is `nav-admin-admin-orders`, and `/store/orders` is
|
|
36
|
+
* `nav-store-store-orders`. It reads like a mistake and is the live `navConfig`
|
|
37
|
+
* key.
|
|
38
|
+
*
|
|
39
|
+
* Every key in this registry was first written the way it reads — a single
|
|
40
|
+
* `nav-admin-orders` — and the consequence is worth recording because it is
|
|
41
|
+
* precisely the failure mode this registry's cross-check exists for:
|
|
42
|
+
* `collectAttentionCounts` looks each VISIBLE nav id up in here, so with the
|
|
43
|
+
* shortened keys **not one source would ever have matched**. No query would
|
|
44
|
+
* run, no badge would appear, and nothing anywhere would error — a feature
|
|
45
|
+
* that silently does nothing, which is indistinguishable from "nothing is
|
|
46
|
+
* waiting". `audit-dashboard-hub-coverage`'s R3 caught all six on its first
|
|
47
|
+
* clean run.
|
|
48
|
+
*
|
|
49
|
+
* ## Why these counts and not more
|
|
50
|
+
*
|
|
51
|
+
* Every source below is a query COPIED from one that already runs in
|
|
52
|
+
* production — four from `dailyStatusDigest`, which documents its own index
|
|
53
|
+
* story, plus the seller order count. None of them needed a new index:
|
|
54
|
+
* all are single-field equalities except the manual-payment pair (which reuses
|
|
55
|
+
* the existing `(status, paymentStatus, createdAt)` composite) and the seller
|
|
56
|
+
* pair (which is a prefix of `(storeId, status, createdAt)`). Verified against
|
|
57
|
+
* `appkit/firebase/base/firestore.indexes.json` rather than assumed.
|
|
58
|
+
*
|
|
59
|
+
* Inventing a count means choosing what "needs attention" means for a
|
|
60
|
+
* collection, and a wrong choice here is a badge that is confidently wrong
|
|
61
|
+
* forever. Adding one is a registry entry plus that decision, made once.
|
|
62
|
+
*
|
|
63
|
+
* **The user portal has no counts, deliberately.** Nothing on a buyer's
|
|
64
|
+
* dashboard is an obligation they are failing to meet — their unread count
|
|
65
|
+
* already lives in the notification bell, and duplicating it onto a card would
|
|
66
|
+
* be a second place for the same number to be stale. The registry simply has
|
|
67
|
+
* no `nav-user-*` key, so the user hubs run zero queries rather than running
|
|
68
|
+
* queries that return a number nobody acts on.
|
|
69
|
+
*/
|
|
70
|
+
import { getAdminDb } from "../../../../providers/db-firebase";
|
|
71
|
+
/**
|
|
72
|
+
* `null` is a FAILED read; a missing key is "no count is defined for this
|
|
73
|
+
* screen". Both render as no badge, and the distinction matters only here.
|
|
74
|
+
*/
|
|
75
|
+
export type AttentionCounts = Record<string, number | null>;
|
|
76
|
+
type CountableQuery = {
|
|
77
|
+
count(): {
|
|
78
|
+
get(): Promise<{
|
|
79
|
+
data(): {
|
|
80
|
+
count: number;
|
|
81
|
+
};
|
|
82
|
+
}>;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* What each count needs from the viewer. A source whose requirement cannot be
|
|
87
|
+
* met is not run — it is not reported as `0` either, it is simply absent.
|
|
88
|
+
*/
|
|
89
|
+
interface AttentionSource {
|
|
90
|
+
/**
|
|
91
|
+
* Build the query. `storeId` is the viewer's store SLUG when they have one.
|
|
92
|
+
*
|
|
93
|
+
* 🛑 A store SLUG, not an Auth uid — `OrderDocument.storeId` holds the slug
|
|
94
|
+
* (§ "Store Identity Architecture"). Passing a uid returns zero for every
|
|
95
|
+
* seller and raises nothing, which is Root Cause #98's second bug.
|
|
96
|
+
*/
|
|
97
|
+
query: (db: ReturnType<typeof getAdminDb>, storeId?: string) => CountableQuery | null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* nav item id → how to count what is waiting there.
|
|
101
|
+
*
|
|
102
|
+
* 🛑 Keys are cross-checked against the real nav by
|
|
103
|
+
* `audit-dashboard-hub-coverage`. A key matching no nav item is a count that
|
|
104
|
+
* can never be displayed — the silent half of Root Cause #62.
|
|
105
|
+
*/
|
|
106
|
+
export declare const ATTENTION_SOURCES: Record<string, AttentionSource>;
|
|
107
|
+
/**
|
|
108
|
+
* Count what is waiting, for the nav items this viewer can actually see.
|
|
109
|
+
*
|
|
110
|
+
* ## 🛑 `visibleItemIds` is the gate, and it is not an optimisation
|
|
111
|
+
*
|
|
112
|
+
* The caller passes the ids that SURVIVED permission filtering, so a count is
|
|
113
|
+
* only ever computed for a card that will be rendered. Two things fall out of
|
|
114
|
+
* that which a blanket "am I staff" boolean could not give:
|
|
115
|
+
*
|
|
116
|
+
* - An employee holding one permission pays for one count, not six. The first
|
|
117
|
+
* draft derived staffness from `permissions.length > 0` and ran all five
|
|
118
|
+
* admin queries for anyone with any grant at all — six reads to render one
|
|
119
|
+
* card, and a number computed from a collection that employee has no
|
|
120
|
+
* permission to read.
|
|
121
|
+
* - A seller and an admin need no separate branches. The store nav contains
|
|
122
|
+
* `nav-store-orders` and the admin nav does not, so the nav IS the scope.
|
|
123
|
+
*
|
|
124
|
+
* `Promise.all` over `.count()` aggregations: six reads worst case, none
|
|
125
|
+
* scaling with collection size, parallel rather than sequential (Rule #6).
|
|
126
|
+
*/
|
|
127
|
+
export declare function collectAttentionCounts(visibleItemIds: readonly string[], storeId?: string): Promise<AttentionCounts>;
|
|
128
|
+
export {};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attention counts for the dashboard hubs — "how many things here are waiting
|
|
3
|
+
* for me".
|
|
4
|
+
*
|
|
5
|
+
* ## 🛑 A count that fails renders ABSENT, never `0`
|
|
6
|
+
*
|
|
7
|
+
* This is the one rule the whole module is shaped around, and it is the
|
|
8
|
+
* opposite of what `dailyStatusDigest`'s `safeCount` does. The digest returns
|
|
9
|
+
* `0` on a failed query and says so: *"a stuck zero next to live numbers
|
|
10
|
+
* elsewhere is legible as 'that one broke'"*. That is true of a table of
|
|
11
|
+
* twenty figures in an email and false of a badge on a card — there, `0` reads
|
|
12
|
+
* as **"nothing needs attention"**, which is the one wrong answer that stops
|
|
13
|
+
* the operator looking.
|
|
14
|
+
*
|
|
15
|
+
* So a failed source resolves to `null` and the card renders no badge at all.
|
|
16
|
+
* Root Cause #72: `undefined` must mean "the count failed", never "zero", and
|
|
17
|
+
* every count fails OPEN.
|
|
18
|
+
*
|
|
19
|
+
* ## Keyed by nav item id, not by section
|
|
20
|
+
*
|
|
21
|
+
* A count belongs to a SCREEN ("2 payouts are pending"), not to a section, so
|
|
22
|
+
* the key is `navItemId(portal, href)` — already derived, already stable,
|
|
23
|
+
* already the `navConfig` key. A section card sums its own items' counts, so
|
|
24
|
+
* no second keying scheme exists and a renamed group cannot orphan a count.
|
|
25
|
+
*
|
|
26
|
+
* 🛑 The ids are written as literals here and CROSS-CHECKED against the real
|
|
27
|
+
* nav by `audit-dashboard-hub-coverage`. A literal that matches no nav item is
|
|
28
|
+
* a count nothing can ever display — the silent half of Root Cause #62, where
|
|
29
|
+
* a filter is emitted on a field the allowlist omits and simply vanishes.
|
|
30
|
+
*
|
|
31
|
+
* ## 🛑 `nav-admin-admin-orders` is CORRECT. Do not "tidy" the repetition.
|
|
32
|
+
*
|
|
33
|
+
* `navItemId(portal, href)` is `nav-${portal}-${slugified href}`, and the href
|
|
34
|
+
* ALSO begins with the portal segment — so `/admin/orders` under the `admin`
|
|
35
|
+
* portal is `nav-admin-admin-orders`, and `/store/orders` is
|
|
36
|
+
* `nav-store-store-orders`. It reads like a mistake and is the live `navConfig`
|
|
37
|
+
* key.
|
|
38
|
+
*
|
|
39
|
+
* Every key in this registry was first written the way it reads — a single
|
|
40
|
+
* `nav-admin-orders` — and the consequence is worth recording because it is
|
|
41
|
+
* precisely the failure mode this registry's cross-check exists for:
|
|
42
|
+
* `collectAttentionCounts` looks each VISIBLE nav id up in here, so with the
|
|
43
|
+
* shortened keys **not one source would ever have matched**. No query would
|
|
44
|
+
* run, no badge would appear, and nothing anywhere would error — a feature
|
|
45
|
+
* that silently does nothing, which is indistinguishable from "nothing is
|
|
46
|
+
* waiting". `audit-dashboard-hub-coverage`'s R3 caught all six on its first
|
|
47
|
+
* clean run.
|
|
48
|
+
*
|
|
49
|
+
* ## Why these counts and not more
|
|
50
|
+
*
|
|
51
|
+
* Every source below is a query COPIED from one that already runs in
|
|
52
|
+
* production — four from `dailyStatusDigest`, which documents its own index
|
|
53
|
+
* story, plus the seller order count. None of them needed a new index:
|
|
54
|
+
* all are single-field equalities except the manual-payment pair (which reuses
|
|
55
|
+
* the existing `(status, paymentStatus, createdAt)` composite) and the seller
|
|
56
|
+
* pair (which is a prefix of `(storeId, status, createdAt)`). Verified against
|
|
57
|
+
* `appkit/firebase/base/firestore.indexes.json` rather than assumed.
|
|
58
|
+
*
|
|
59
|
+
* Inventing a count means choosing what "needs attention" means for a
|
|
60
|
+
* collection, and a wrong choice here is a badge that is confidently wrong
|
|
61
|
+
* forever. Adding one is a registry entry plus that decision, made once.
|
|
62
|
+
*
|
|
63
|
+
* **The user portal has no counts, deliberately.** Nothing on a buyer's
|
|
64
|
+
* dashboard is an obligation they are failing to meet — their unread count
|
|
65
|
+
* already lives in the notification bell, and duplicating it onto a card would
|
|
66
|
+
* be a second place for the same number to be stale. The registry simply has
|
|
67
|
+
* no `nav-user-*` key, so the user hubs run zero queries rather than running
|
|
68
|
+
* queries that return a number nobody acts on.
|
|
69
|
+
*/
|
|
70
|
+
import { getAdminDb } from "../../../../providers/db-firebase/index.js";
|
|
71
|
+
import { normalizeError } from "../../../../errors/normalize.js";
|
|
72
|
+
import { ORDER_COLLECTION, OrderStatusValues } from "../../../../features/orders/schemas/firestore.js";
|
|
73
|
+
import { NOTIFICATION_FIELDS, ORDER_FIELDS } from "../../../../constants/field-names.js";
|
|
74
|
+
import { PAYOUT_COLLECTION, PAYOUT_FIELDS } from "../../../../features/payments/schemas/firestore.js";
|
|
75
|
+
import { SUPPORT_TICKET_COLLECTION, SUPPORT_TICKET_FIELDS, TicketStatusValues, } from "../../../../features/support/schemas/firestore.js";
|
|
76
|
+
import { ADMIN_NOTIFICATIONS_COLLECTION } from "../../../../features/store-extensions/schemas/rbac.js";
|
|
77
|
+
import { CATALOGUE_COLLECTION, CATALOGUE_FIELDS, } from "../../../../features/catalogue/schemas/firestore.js";
|
|
78
|
+
/** `null` on any failure — see the header. */
|
|
79
|
+
async function countOrNull(q) {
|
|
80
|
+
try {
|
|
81
|
+
const snap = await q.count().get();
|
|
82
|
+
return snap.data().count;
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
void normalizeError(err);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* nav item id → how to count what is waiting there.
|
|
91
|
+
*
|
|
92
|
+
* 🛑 Keys are cross-checked against the real nav by
|
|
93
|
+
* `audit-dashboard-hub-coverage`. A key matching no nav item is a count that
|
|
94
|
+
* can never be displayed — the silent half of Root Cause #62.
|
|
95
|
+
*/
|
|
96
|
+
export const ATTENTION_SOURCES = {
|
|
97
|
+
/*
|
|
98
|
+
* Orders awaiting a manual-payment decision. The digest's exact query —
|
|
99
|
+
* `status == pending AND paymentStatus == pending` — which is what
|
|
100
|
+
* § "Manual Payment Review Flow" calls the review queue's bounded
|
|
101
|
+
* pre-filter, and which reuses the existing
|
|
102
|
+
* `(status, paymentStatus, createdAt)` composite.
|
|
103
|
+
*/
|
|
104
|
+
"nav-admin-admin-orders": {
|
|
105
|
+
query: (db) => db
|
|
106
|
+
.collection(ORDER_COLLECTION)
|
|
107
|
+
.where(ORDER_FIELDS.STATUS, "==", OrderStatusValues.PENDING)
|
|
108
|
+
.where(ORDER_FIELDS.PAYMENT_STATUS, "==", "pending"),
|
|
109
|
+
},
|
|
110
|
+
"nav-admin-admin-payouts": {
|
|
111
|
+
query: (db) => db
|
|
112
|
+
.collection(PAYOUT_COLLECTION)
|
|
113
|
+
.where(PAYOUT_FIELDS.STATUS, "==", PAYOUT_FIELDS.STATUS_VALUES.PENDING),
|
|
114
|
+
},
|
|
115
|
+
"nav-admin-admin-support-tickets": {
|
|
116
|
+
query: (db) => db
|
|
117
|
+
.collection(SUPPORT_TICKET_COLLECTION)
|
|
118
|
+
.where(SUPPORT_TICKET_FIELDS.STATUS, "==", TicketStatusValues.OPEN),
|
|
119
|
+
},
|
|
120
|
+
"nav-admin-admin-notifications": {
|
|
121
|
+
/*
|
|
122
|
+
* 🛑 `NOTIFICATION_FIELDS.IS_READ`, not the literal `"isRead"`.
|
|
123
|
+
* `dailyStatusDigest` writes the bare string with an
|
|
124
|
+
* `audit-field-name-ok` marker and a long justification — that `isRead` is
|
|
125
|
+
* a `BaseDocument` field rather than a per-feature name — but the marker
|
|
126
|
+
* is not needed here because a real constant for it exists, and a
|
|
127
|
+
* constant that exists is always better than a waiver for not using it.
|
|
128
|
+
*/
|
|
129
|
+
query: (db) => db.collection(ADMIN_NOTIFICATIONS_COLLECTION).where(NOTIFICATION_FIELDS.IS_READ, "==", false),
|
|
130
|
+
},
|
|
131
|
+
"nav-admin-admin-catalogue-approvals": {
|
|
132
|
+
query: (db) => db
|
|
133
|
+
.collection(CATALOGUE_COLLECTION)
|
|
134
|
+
.where(CATALOGUE_FIELDS.LISTING_STATUS, "==", "pending_admin_approval"),
|
|
135
|
+
},
|
|
136
|
+
/* A prefix of the existing `(storeId, status, createdAt)` composite. */
|
|
137
|
+
"nav-store-store-orders": {
|
|
138
|
+
query: (db, storeId) => storeId
|
|
139
|
+
? db
|
|
140
|
+
.collection(ORDER_COLLECTION)
|
|
141
|
+
.where(ORDER_FIELDS.STORE_ID, "==", storeId)
|
|
142
|
+
.where(ORDER_FIELDS.STATUS, "==", OrderStatusValues.PENDING)
|
|
143
|
+
: null,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Count what is waiting, for the nav items this viewer can actually see.
|
|
148
|
+
*
|
|
149
|
+
* ## 🛑 `visibleItemIds` is the gate, and it is not an optimisation
|
|
150
|
+
*
|
|
151
|
+
* The caller passes the ids that SURVIVED permission filtering, so a count is
|
|
152
|
+
* only ever computed for a card that will be rendered. Two things fall out of
|
|
153
|
+
* that which a blanket "am I staff" boolean could not give:
|
|
154
|
+
*
|
|
155
|
+
* - An employee holding one permission pays for one count, not six. The first
|
|
156
|
+
* draft derived staffness from `permissions.length > 0` and ran all five
|
|
157
|
+
* admin queries for anyone with any grant at all — six reads to render one
|
|
158
|
+
* card, and a number computed from a collection that employee has no
|
|
159
|
+
* permission to read.
|
|
160
|
+
* - A seller and an admin need no separate branches. The store nav contains
|
|
161
|
+
* `nav-store-orders` and the admin nav does not, so the nav IS the scope.
|
|
162
|
+
*
|
|
163
|
+
* `Promise.all` over `.count()` aggregations: six reads worst case, none
|
|
164
|
+
* scaling with collection size, parallel rather than sequential (Rule #6).
|
|
165
|
+
*/
|
|
166
|
+
export async function collectAttentionCounts(visibleItemIds, storeId) {
|
|
167
|
+
const db = getAdminDb();
|
|
168
|
+
const runnable = [];
|
|
169
|
+
for (const id of visibleItemIds) {
|
|
170
|
+
const source = ATTENTION_SOURCES[id];
|
|
171
|
+
if (!source)
|
|
172
|
+
continue;
|
|
173
|
+
const q = source.query(db, storeId);
|
|
174
|
+
if (q)
|
|
175
|
+
runnable.push([id, q]);
|
|
176
|
+
}
|
|
177
|
+
const results = await Promise.all(runnable.map(([, q]) => countOrNull(q)));
|
|
178
|
+
const counts = {};
|
|
179
|
+
runnable.forEach(([key], i) => {
|
|
180
|
+
counts[key] = results[i] ?? null;
|
|
181
|
+
});
|
|
182
|
+
return counts;
|
|
183
|
+
}
|