@mohasinac/appkit 4.60.0 → 4.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/auctions/actions.d.ts +1 -1
  3. package/dist/_internal/server/features/auctions/actions.js +12 -1
  4. package/dist/_internal/server/features/blog/actions.d.ts +1 -1
  5. package/dist/_internal/server/features/blog/actions.js +12 -1
  6. package/dist/_internal/server/features/brands/actions.d.ts +1 -1
  7. package/dist/_internal/server/features/brands/actions.js +12 -1
  8. package/dist/_internal/server/features/cart/actions.d.ts +1 -1
  9. package/dist/_internal/server/features/cart/actions.js +12 -1
  10. package/dist/_internal/server/features/checkout/actions.js +0 -1
  11. package/dist/_internal/server/features/events/actions.d.ts +1 -1
  12. package/dist/_internal/server/features/events/actions.js +12 -1
  13. package/dist/_internal/server/features/gateway-events/redact.js +14 -2
  14. package/dist/_internal/server/features/history/actions.d.ts +1 -1
  15. package/dist/_internal/server/features/history/actions.js +12 -1
  16. package/dist/_internal/server/features/lottery/actions.d.ts +1 -1
  17. package/dist/_internal/server/features/lottery/actions.js +12 -1
  18. package/dist/_internal/server/features/orders/actions.d.ts +1 -1
  19. package/dist/_internal/server/features/orders/actions.js +12 -1
  20. package/dist/_internal/server/features/orders/adapters.d.ts +20 -2
  21. package/dist/_internal/server/features/orders/adapters.js +18 -0
  22. package/dist/_internal/server/features/orders/cancellation-refund.js +0 -1
  23. package/dist/_internal/server/features/orders/service.js +31 -1
  24. package/dist/_internal/server/features/payouts/actions.d.ts +1 -1
  25. package/dist/_internal/server/features/payouts/actions.js +12 -1
  26. package/dist/_internal/server/features/points/award.js +9 -1
  27. package/dist/_internal/server/features/pre-orders/actions.d.ts +1 -1
  28. package/dist/_internal/server/features/pre-orders/actions.js +12 -1
  29. package/dist/_internal/server/features/prize-draws/actions.d.ts +1 -1
  30. package/dist/_internal/server/features/prize-draws/actions.js +12 -1
  31. package/dist/_internal/server/features/products/actions.d.ts +1 -1
  32. package/dist/_internal/server/features/products/actions.js +12 -1
  33. package/dist/_internal/server/features/promotions/actions.d.ts +1 -1
  34. package/dist/_internal/server/features/promotions/actions.js +12 -1
  35. package/dist/_internal/server/features/protection-claims/actions.d.ts +2 -2
  36. package/dist/_internal/server/features/protection-claims/actions.js +21 -12
  37. package/dist/_internal/server/features/raffle/actions.d.ts +1 -1
  38. package/dist/_internal/server/features/raffle/actions.js +12 -1
  39. package/dist/_internal/server/features/refunds/actions.d.ts +1 -3
  40. package/dist/_internal/server/features/refunds/actions.js +14 -2
  41. package/dist/_internal/server/features/reviews/actions.d.ts +1 -1
  42. package/dist/_internal/server/features/reviews/actions.js +12 -1
  43. package/dist/_internal/server/features/wishlist/actions.d.ts +1 -1
  44. package/dist/_internal/server/features/wishlist/actions.js +12 -1
  45. package/dist/_internal/server/jobs/core/obligationSweep.js +0 -1
  46. package/dist/_internal/server/jobs/core/paymentReconcile.js +84 -57
  47. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  48. package/dist/_internal/server/jobs/core/testDataCleanup.js +22 -1
  49. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +16 -4
  50. package/dist/_internal/shared/features/promotions/schema.d.ts +66 -0
  51. package/dist/_internal/shared/features/promotions/schema.js +7 -0
  52. package/dist/_internal/shared/obligations/deadlines.js +11 -1
  53. package/dist/_internal/shared/tester/window.d.ts +25 -0
  54. package/dist/_internal/shared/tester/window.js +66 -0
  55. package/dist/client.d.ts +1 -0
  56. package/dist/client.js +3 -0
  57. package/dist/core/unit-of-work.js +15 -1
  58. package/dist/errors/firestore-codes.d.ts +37 -0
  59. package/dist/errors/firestore-codes.js +52 -0
  60. package/dist/errors/index.d.ts +0 -1
  61. package/dist/errors/index.js +12 -2
  62. package/dist/features/admin/components/AdminCouponEditorView.js +8 -0
  63. package/dist/features/admin/components/AdminGatewayEventsView.js +2 -2
  64. package/dist/features/admin/components/DataListingView.js +2 -0
  65. package/dist/features/admin/components/analytics/AdminStatCard.js +4 -1
  66. package/dist/features/admin/repository/site-settings.repository.d.ts +2 -1
  67. package/dist/features/admin/repository/site-settings.repository.js +7 -0
  68. package/dist/features/auctions/components/MarketplaceAuctionCard.js +1 -1
  69. package/dist/features/catalogue/actions/approve-catalogue-listing.d.ts +1 -1
  70. package/dist/features/catalogue/actions/approve-catalogue-listing.js +12 -1
  71. package/dist/features/catalogue/actions/list-from-catalogue.d.ts +1 -1
  72. package/dist/features/catalogue/actions/list-from-catalogue.js +12 -1
  73. package/dist/features/catalogue/actions/submit-for-approval.d.ts +1 -1
  74. package/dist/features/catalogue/actions/submit-for-approval.js +12 -1
  75. package/dist/features/categories/repository/price-observation.repository.d.ts +19 -0
  76. package/dist/features/categories/repository/price-observation.repository.js +21 -2
  77. package/dist/features/events/actions/event-actions.js +4 -1
  78. package/dist/features/events/components/AdminEventEditorView.js +72 -45
  79. package/dist/features/events/components/EventsListPageView.js +2 -1
  80. package/dist/features/events/schemas/firestore.d.ts +17 -0
  81. package/dist/features/events/schemas/firestore.js +1 -0
  82. package/dist/features/events/utils/sort-pinned.d.ts +33 -0
  83. package/dist/features/events/utils/sort-pinned.js +46 -0
  84. package/dist/features/gateway-events/repository/gateway-events.repository.js +3 -40
  85. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  86. package/dist/features/inventory/components/AdminStorageBoxesView.js +55 -39
  87. package/dist/features/inventory/components/ReceiveStationView.js +61 -17
  88. package/dist/features/inventory/repository/storage-boxes.repository.d.ts +3 -1
  89. package/dist/features/inventory/repository/storage-boxes.repository.js +36 -3
  90. package/dist/features/obligations/repository/obligations.repository.js +10 -1
  91. package/dist/features/orders/schemas/firestore.d.ts +19 -0
  92. package/dist/features/orders/schemas/firestore.js +19 -0
  93. package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +1 -0
  94. package/dist/features/payment-cases/repository/payment-cases.repository.js +3 -14
  95. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  96. package/dist/features/products/components/MarketplaceBundleCard.js +1 -1
  97. package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -1
  98. package/dist/features/products/components/ProductForm.js +2 -2
  99. package/dist/features/products/components/ProductGrid.js +1 -1
  100. package/dist/features/promotions/repository/claimed-coupons.repository.js +31 -1
  101. package/dist/features/promotions/schemas/firestore.d.ts +22 -0
  102. package/dist/features/promotions/schemas/firestore.js +22 -0
  103. package/dist/features/promotions/schemas/index.d.ts +479 -6
  104. package/dist/features/promotions/schemas/index.js +153 -0
  105. package/dist/features/protection-claims/repository/protection-claims.repository.js +22 -15
  106. package/dist/features/seller/schemas/coupon-form.d.ts +5 -0
  107. package/dist/features/seller/schemas/coupon-form.js +18 -0
  108. package/dist/features/shell/SectionForm.js +31 -9
  109. package/dist/features/sticker-trace/server.d.ts +2 -0
  110. package/dist/http/api-handler.js +4 -1
  111. package/dist/index.d.ts +3 -3
  112. package/dist/index.js +3 -3
  113. package/dist/next/routing/route-map.d.ts +0 -2
  114. package/dist/next/routing/route-map.js +10 -1
  115. package/dist/schemas/registry.d.ts +11 -6
  116. package/dist/server.js +7 -0
  117. package/dist/styles.css +51 -0
  118. package/dist/ui/components/PaginatedSelect.js +0 -0
  119. package/package.json +1 -1
  120. package/scripts/seed-cli.mjs +1822 -1812
  121. package/dist/features/tester/utils/phases.d.ts +0 -48
  122. package/dist/features/tester/utils/phases.js +0 -139
@@ -17,6 +17,22 @@ export interface EventDocument extends BaseDocument {
17
17
  title: string;
18
18
  description: string;
19
19
  status: EventStatus;
20
+ /**
21
+ * Lift this event to the top of `/events` and the homepage rail, ahead of
22
+ * the `startsAt` ASC default.
23
+ *
24
+ * 🛑 DELIBERATELY NOT IN `EVENT_INDEXED_FIELDS` AND NOT SORTABLE IN SIEVE.
25
+ * A Firestore `orderBy` EXCLUDES every document that lacks the ordering
26
+ * field (Root Cause #100), and `isPinned` is absent from all 1 live event
27
+ * and every seeded row that is not pinned — so ordering by it would empty
28
+ * the events page entirely while answering HTTP 200. The ordering is
29
+ * applied in memory by `sortPinnedFirst`; see the bound stated there.
30
+ *
31
+ * Absent means not pinned. Do not default it to `false` on write — a
32
+ * written `false` and an absent field must stay interchangeable, because
33
+ * every event that predates this field has the latter.
34
+ */
35
+ isPinned?: boolean;
20
36
  /** Test-data flag — set on the shared admin-seeded test event; swept by testDataCleanup. */
21
37
  isTestData?: boolean;
22
38
  /** When isTestData, the cutoff after which testDataCleanup deletes this doc. */
@@ -134,6 +150,7 @@ export declare const EVENT_FIELDS: {
134
150
  readonly WINNER_IMAGES: "winnerImages";
135
151
  readonly ADDITIONAL_IMAGES: "additionalImages";
136
152
  readonly TAGS: "tags";
153
+ readonly IS_PINNED: "isPinned";
137
154
  readonly ALLOW_GUEST_PARTICIPATION: "allowGuestParticipation";
138
155
  readonly CREATED_BY: "createdBy";
139
156
  readonly CREATED_AT: "createdAt";
@@ -38,6 +38,7 @@ export const EVENT_FIELDS = {
38
38
  WINNER_IMAGES: "winnerImages",
39
39
  ADDITIONAL_IMAGES: "additionalImages",
40
40
  TAGS: "tags",
41
+ IS_PINNED: "isPinned",
41
42
  ALLOW_GUEST_PARTICIPATION: "allowGuestParticipation",
42
43
  CREATED_BY: "createdBy",
43
44
  CREATED_AT: "createdAt",
@@ -0,0 +1,33 @@
1
+ /** Just the part of an event this ordering reads. */
2
+ export interface PinnableEvent {
3
+ isPinned?: boolean;
4
+ }
5
+ /**
6
+ * Lift pinned events to the front, preserving the relative order of both
7
+ * groups.
8
+ *
9
+ * 🛑 WHY THIS IS IN MEMORY AND NOT AN `orderBy`. Firestore's `orderBy`
10
+ * excludes every document that lacks the ordering field (Root Cause #100), and
11
+ * `isPinned` is absent from every event that is not pinned — which is all of
12
+ * them bar one. Sorting on it in the query would return an events page
13
+ * containing only the pinned row, with no error and a 200, and the failure
14
+ * reads as "the other events were deleted" rather than as a bad sort.
15
+ *
16
+ * A boolean is also a one-bit sort key, so it would need a composite index
17
+ * paired with `startsAt` for every filter combination the page offers, to buy
18
+ * an ordering a single pass over at most `pageSize` rows already gives.
19
+ *
20
+ * 🛑 THE BOUND, STATED RATHER THAN IMPLIED: this reorders WITHIN the page it
21
+ * is handed. A pinned event that falls on page 3 of a paginated result is
22
+ * lifted to the top of page 3, not onto page 1. That is sufficient while the
23
+ * whole catalogue fits one page — live holds 1 event against a pageSize of
24
+ * 24, and the homepage rail asks for 6 — and it is the reason to revisit this
25
+ * if events ever outgrow a single page. The fix then is a second bounded
26
+ * query on `isPinned == true` (an EQUALITY, which is index-friendly and does
27
+ * not have the missing-field problem an `orderBy` has) merged ahead of the
28
+ * main result, not an `orderBy` on this field.
29
+ *
30
+ * `Array.prototype.sort` is specified as stable, so the comparator only needs
31
+ * to express the pinned/unpinned difference to leave everything else alone.
32
+ */
33
+ export declare function sortPinnedFirst<T extends PinnableEvent>(items: readonly T[]): T[];
@@ -0,0 +1,46 @@
1
+ /*
2
+ * WHY: One implementation of "pinned events come first", shared by the two
3
+ * surfaces that render an event list.
4
+ * WHAT: `sortPinnedFirst` — a stable partition on `EventDocument.isPinned`.
5
+ *
6
+ * EXPORTS:
7
+ * sortPinnedFirst — readonly T[] -> T[], pinned first, order otherwise kept
8
+ *
9
+ * @tag domain:events
10
+ * @tag layer:util
11
+ * @tag pattern:none
12
+ * @tag access:isomorphic
13
+ * @tag consumers:features/events/components/EventsListPageView.tsx,features/events/actions/event-actions.ts
14
+ * @tag sideEffects:none
15
+ */
16
+ /**
17
+ * Lift pinned events to the front, preserving the relative order of both
18
+ * groups.
19
+ *
20
+ * 🛑 WHY THIS IS IN MEMORY AND NOT AN `orderBy`. Firestore's `orderBy`
21
+ * excludes every document that lacks the ordering field (Root Cause #100), and
22
+ * `isPinned` is absent from every event that is not pinned — which is all of
23
+ * them bar one. Sorting on it in the query would return an events page
24
+ * containing only the pinned row, with no error and a 200, and the failure
25
+ * reads as "the other events were deleted" rather than as a bad sort.
26
+ *
27
+ * A boolean is also a one-bit sort key, so it would need a composite index
28
+ * paired with `startsAt` for every filter combination the page offers, to buy
29
+ * an ordering a single pass over at most `pageSize` rows already gives.
30
+ *
31
+ * 🛑 THE BOUND, STATED RATHER THAN IMPLIED: this reorders WITHIN the page it
32
+ * is handed. A pinned event that falls on page 3 of a paginated result is
33
+ * lifted to the top of page 3, not onto page 1. That is sufficient while the
34
+ * whole catalogue fits one page — live holds 1 event against a pageSize of
35
+ * 24, and the homepage rail asks for 6 — and it is the reason to revisit this
36
+ * if events ever outgrow a single page. The fix then is a second bounded
37
+ * query on `isPinned == true` (an EQUALITY, which is index-friendly and does
38
+ * not have the missing-field problem an `orderBy` has) merged ahead of the
39
+ * main result, not an `orderBy` on this field.
40
+ *
41
+ * `Array.prototype.sort` is specified as stable, so the comparator only needs
42
+ * to express the pinned/unpinned difference to leave everything else alone.
43
+ */
44
+ export function sortPinnedFirst(items) {
45
+ return [...items].sort((a, b) => Number(b.isPinned === true) - Number(a.isPinned === true));
46
+ }
@@ -1,46 +1,9 @@
1
+ // One shared predicate — this file had its own copy; see firestore-codes.ts.
2
+ import { isAlreadyExistsError } from "../../../errors/firestore-codes.js";
1
3
  import { BaseRepository, applySieveToFirestore, parseSieveDateValue, prepareForFirestore, } from "../../../providers/db-firebase/index.js";
2
4
  import { normalizeError } from "../../../errors/normalize.js";
3
5
  import { GATEWAY_EVENTS_COLLECTION, GATEWAY_EVENT_ERROR_MAX_BYTES, GATEWAY_EVENT_FIELDS, GATEWAY_EVENT_LEASE_SECONDS, GATEWAY_EVENT_RETENTION_DAYS, createGatewayEventId, } from "../schemas/firestore.js";
4
6
  /** gRPC `ALREADY_EXISTS`. The one failure of `.create()` that means "duplicate". */
5
- const GRPC_ALREADY_EXISTS = 6;
6
- /**
7
- * Is this the specific "a document with that id already exists" failure, as
8
- * opposed to any other write failure?
9
- *
10
- * 🛑 THIS DISTINCTION IS THE WHOLE POINT, and it is where the inbox differs
11
- * from every other `.create()` claim in the codebase.
12
- *
13
- * `refunds/actions.ts` catches *every* error from its claim and treats it as a
14
- * duplicate — and that is correct THERE, because the cost of being wrong is
15
- * issuing a second real refund, so it must fail CLOSED.
16
- *
17
- * Here the cost of being wrong is inverted: treating a permissions error or a
18
- * Firestore outage as "already have it" would make the route ack 200 and the
19
- * event would be **silently lost** — which is the exact failure this collection
20
- * exists to prevent. So the inbox fails OPEN: anything that is not
21
- * ALREADY_EXISTS propagates, the route answers 503, and the gateway sends it
22
- * again.
23
- *
24
- * Same primitive, opposite failure direction, because the asymmetry of harm is
25
- * opposite. Do not "unify" these two call sites.
26
- */
27
- function isAlreadyExists(err) {
28
- if (typeof err !== "object" || err === null)
29
- return false;
30
- const code = err.code;
31
- if (code === GRPC_ALREADY_EXISTS)
32
- return true;
33
- /*
34
- * The Admin SDK has also surfaced this as a string code and as a plain
35
- * message depending on version and transport. Checked in addition to, never
36
- * instead of, the numeric code — a message test alone is the kind of
37
- * stringly-typed guard that breaks silently on an SDK bump.
38
- */
39
- if (code === "already-exists" || code === "ALREADY_EXISTS")
40
- return true;
41
- const message = err.message;
42
- return typeof message === "string" && message.includes("ALREADY_EXISTS");
43
- }
44
7
  function truncate(value, maxBytes) {
45
8
  if (!value)
46
9
  return undefined;
@@ -86,7 +49,7 @@ export class GatewayEventsRepository extends BaseRepository {
86
49
  return { claimed: true, id, doc };
87
50
  }
88
51
  catch (err) {
89
- if (isAlreadyExists(err)) {
52
+ if (isAlreadyExistsError(err)) {
90
53
  void normalizeError(err);
91
54
  return { claimed: false, id };
92
55
  }
@@ -34,12 +34,12 @@ export declare const gatewayEventFirestoreSchema: z.ZodObject<{
34
34
  createdAt: Date;
35
35
  updatedAt: Date;
36
36
  expiresAt: Date;
37
+ claimedAt: Date;
37
38
  gateway: string;
38
39
  receivedAt: Date;
39
40
  attempts: number;
40
41
  bodySha256: string;
41
42
  leaseExpiresAt: Date;
42
- claimedAt: Date;
43
43
  rawEvent: string;
44
44
  terminal?: boolean | undefined;
45
45
  amount?: number | undefined;
@@ -59,12 +59,12 @@ export declare const gatewayEventFirestoreSchema: z.ZodObject<{
59
59
  createdAt: Date;
60
60
  updatedAt: Date;
61
61
  expiresAt: Date;
62
+ claimedAt: Date;
62
63
  gateway: string;
63
64
  receivedAt: Date;
64
65
  attempts: number;
65
66
  bodySha256: string;
66
67
  leaseExpiresAt: Date;
67
- claimedAt: Date;
68
68
  rawEvent: string;
69
69
  terminal?: boolean | undefined;
70
70
  amount?: number | undefined;
@@ -16,9 +16,10 @@ import { Button, Div, Form, FieldInput, FieldSelect, Stack, Text } from "../../.
16
16
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
17
17
  import { apiClient } from "../../../http/index.js";
18
18
  import { sortBy } from "../../../constants/sort.js";
19
+ import { normalizeError } from "../../../errors/normalize.js";
19
20
  import { sieveFilter, SIEVE_OP } from "../../../utils/sieve-builder.js";
20
21
  import { operatorMessage } from "./operator-message.js";
21
- import { BoxTypeValues } from "../schemas/firestore.js";
22
+ import { BoxTypeValues, STORAGE_BOX_FIELDS, } from "../schemas/firestore.js";
22
23
  const TYPE_OPTIONS = BoxTypeValues.map((v) => ({ value: v, label: v }));
23
24
  /**
24
25
  * The register-a-box form.
@@ -26,47 +27,58 @@ const TYPE_OPTIONS = BoxTypeValues.map((v) => ({ value: v, label: v }));
26
27
  * Exported because it is a PAGE now, not a drawer — see the note on
27
28
  * `primaryAction` below for why create could not share the row affordance.
28
29
  */
30
+ // audit-form-sectionised-ok: a three-field quick create (code, type, location),
31
+ // not a multi-section editor. <SectionForm> derives its sections from a schema
32
+ // and pins a mobile action bar for a long scroll; neither has anything to do
33
+ // here, and sections of one field each would be chrome around three inputs.
29
34
  export function RegisterBoxForm({ onCreated }) {
30
35
  const [code, setCode] = React.useState("");
31
36
  const [boxType, setBoxType] = React.useState(BoxTypeValues[0]);
32
37
  const [location, setLocation] = React.useState("");
33
38
  const [busy, setBusy] = React.useState(false);
34
- return (_jsx(Form, { onSubmit: (e) => e.preventDefault(), className: "space-y-4", children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "code", label: "Box code", required: true, value: code, onChange: setCode, placeholder: "AC60-03", hint: "Exactly what is written on the physical label." }), _jsx(FieldSelect, { name: "boxType", label: "Box type", value: boxType, onChange: setBoxType, options: TYPE_OPTIONS }), _jsx(FieldInput, { name: "location", label: "Where it is", value: location, onChange: setLocation, placeholder: "Shelf B, second from top" }), _jsx(Text, { size: "xs", color: "muted", children: "A second box with the same code is the same box \u2014 the registry refuses a duplicate rather than merging two." }), _jsx(Div, { children: _jsx(Button, { type: "submit", variant: "primary", isLoading: busy, onClick: async () => {
35
- clearErrors();
36
- if (!code.trim()) {
37
- setFieldError("code", "A box needs a code");
38
- return;
39
- }
40
- setBusy(true);
41
- try {
42
- await apiClient.post(ADMIN_ENDPOINTS.INVENTORY_BOXES, {
43
- code: code.trim(),
44
- boxType,
45
- ...(location.trim() ? { location: location.trim() } : {}),
46
- });
47
- onCreated();
48
- }
49
- catch (err) {
50
- /*
51
- * 🛑 `operatorMessage`, not a raw fallback. This read
52
- * `toUserMessage(undefined, undefined, { fallback: e.message })`
53
- * under a comment claiming it never showed server text —
54
- * which is false twice over: `undefined` for the code means
55
- * the map never runs, so the fallback is ALWAYS what shows.
56
- * A comment asserting the fix while the code reintroduces the
57
- * bug is how Root Cause #86 would come back, and how the seed
58
- * `listingType` bug survived its own fix one field over.
59
- *
60
- * The split is on status: the 409 here ("Box AC60-03 already
61
- * exists") is authored for this operator and must survive; a
62
- * 5xx terminates in a constant.
63
- */
64
- setFieldError("code", operatorMessage(err));
65
- }
66
- finally {
67
- setBusy(false);
68
- }
69
- }, children: "Register box" }) })] })) }));
39
+ /*
40
+ * Lifted out of the submit button's `onClick`, where it sat five braces deep
41
+ * inside the render-prop's JSX. The two `<Form>` helpers arrive as arguments
42
+ * rather than being captured, which is what lets this live in the component
43
+ * body beside the state it writes.
44
+ */
45
+ const submitBox = async (setFieldError, clearErrors) => {
46
+ clearErrors();
47
+ if (!code.trim()) {
48
+ setFieldError("code", "A box needs a code");
49
+ return;
50
+ }
51
+ setBusy(true);
52
+ try {
53
+ await apiClient.post(ADMIN_ENDPOINTS.INVENTORY_BOXES, {
54
+ code: code.trim(),
55
+ boxType,
56
+ ...(location.trim() ? { location: location.trim() } : {}),
57
+ });
58
+ onCreated();
59
+ }
60
+ catch (err) {
61
+ /*
62
+ * 🛑 `operatorMessage`, not a raw fallback. This read
63
+ * `toUserMessage(undefined, undefined, { fallback: e.message })`
64
+ * under a comment claiming it never showed server text — which is
65
+ * false twice over: `undefined` for the code means the map never
66
+ * runs, so the fallback is ALWAYS what shows. A comment asserting
67
+ * the fix while the code reintroduces the bug is how Root Cause #86
68
+ * would come back, and how the seed `listingType` bug survived its
69
+ * own fix one field over.
70
+ *
71
+ * The split is on status: the 409 here ("Box AC60-03 already
72
+ * exists") is authored for this operator and must survive; a 5xx
73
+ * terminates in a constant.
74
+ */
75
+ setFieldError("code", operatorMessage(normalizeError(err)));
76
+ }
77
+ finally {
78
+ setBusy(false);
79
+ }
80
+ };
81
+ return (_jsx(Form, { onSubmit: (e) => e.preventDefault(), className: "space-y-4", children: ({ setFieldError, clearErrors }) => (_jsxs(Stack, { gap: "md", children: [_jsx(FieldInput, { name: "code", label: "Box code", required: true, value: code, onChange: setCode, placeholder: "AC60-03", hint: "Exactly what is written on the physical label." }), _jsx(FieldSelect, { name: "boxType", label: "Box type", value: boxType, onChange: setBoxType, options: TYPE_OPTIONS }), _jsx(FieldInput, { name: "location", label: "Where it is", value: location, onChange: setLocation, placeholder: "Shelf B, second from top" }), _jsx(Text, { size: "xs", color: "muted", children: "A second box with the same code is the same box \u2014 the registry refuses a duplicate rather than merging two." }), _jsx(Div, { children: _jsx(Button, { type: "submit", variant: "primary", isLoading: busy, onClick: () => submitBox(setFieldError, clearErrors), children: "Register box" }) })] })) }));
70
82
  }
71
83
  export function AdminStorageBoxesView() {
72
84
  const config = React.useMemo(() => ({
@@ -109,9 +121,13 @@ export function AdminStorageBoxesView() {
109
121
  * AC60-2 — the kind of ordering that makes an operator distrust the
110
122
  * screen — which is why `ordinal` is denormalised on write.
111
123
  */
112
- defaultSort: "ordinal",
124
+ defaultSort: sortBy(STORAGE_BOX_FIELDS.ORDINAL, "ASC"),
113
125
  sortOptions: [
114
- { value: "ordinal", label: "Box number" },
126
+ // Same expression as `defaultSort`, not a matching literal beside it —
127
+ // `sortBy(f, "ASC")` is the bare field, so the two are identical by
128
+ // construction. A default that is not one of the options opens the
129
+ // dropdown with nothing selected (Root Cause #63).
130
+ { value: sortBy(STORAGE_BOX_FIELDS.ORDINAL, "ASC"), label: "Box number" },
115
131
  { value: sortBy("createdAt", "DESC"), label: "Newest" },
116
132
  { value: sortBy("code", "ASC"), label: "Code A–Z" },
117
133
  ],
@@ -31,6 +31,8 @@ import { apiClient } from "../../../http/index.js";
31
31
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
32
32
  import { ROUTES } from "../../../next/routing/route-map.js";
33
33
  import { operatorMessage } from "./operator-message.js";
34
+ import { normalizeError } from "../../../errors/normalize.js";
35
+ import { trackError, ErrorCategory, ErrorSeverity, } from "../../../monitoring/error-tracking.js";
34
36
  import { MAX_RECEIVE_ROWS } from "../schemas/firestore.js";
35
37
  /** `boxId::productId` — the staging table's natural key, mirroring `placementId`. */
36
38
  function rowKey(boxId, productId) {
@@ -67,6 +69,21 @@ export function ReceiveStationView({ shipmentId, shipmentNumber, items, unlinked
67
69
  rowsRef.current = computed;
68
70
  setRows(computed);
69
71
  }
72
+ /**
73
+ * Drop one staged row. Named rather than inline in the table's Remove
74
+ * button, where the updater's block body sat a brace too deep.
75
+ */
76
+ function removeRow(key) {
77
+ applyRows((c) => {
78
+ const next = { ...c };
79
+ delete next[key];
80
+ return next;
81
+ });
82
+ }
83
+ /** Set one staged row's quantity. Same reason as `removeRow`. */
84
+ function setRowQuantity(key, quantity) {
85
+ applyRows((c) => ({ ...c, [key]: { ...c[key], quantity } }));
86
+ }
70
87
  const [scan, setScan] = useState("");
71
88
  const [committing, setCommitting] = useState(false);
72
89
  /** The in-flight guard. State renders the spinner; this prevents the second send. */
@@ -197,6 +214,12 @@ export function ReceiveStationView({ shipmentId, shipmentNumber, items, unlinked
197
214
  * It runs only on a miss, so the common case stays instant and a box-arm is
198
215
  * roughly one scan in twenty anyway.
199
216
  */
217
+ /*
218
+ * Three outcomes, and the third is the point: `BoxOption` (found), `null`
219
+ * (asked, no such box), `undefined` (could not ask). The caller used to see
220
+ * only the first two and told the operator "No box registered as …" for a
221
+ * network failure.
222
+ */
200
223
  async function resolveBoxFromServer(code) {
201
224
  const bare = code.startsWith("BOX-") ? code.slice(4) : code;
202
225
  try {
@@ -206,10 +229,26 @@ export function ReceiveStationView({ shipmentId, shipmentNumber, items, unlinked
206
229
  ? { id: hit.id, code: hit.code, location: hit.location ?? null }
207
230
  : null;
208
231
  }
209
- catch {
210
- // A failed lookup must not masquerade as "no such box" — the caller
211
- // distinguishes the two in what it tells the operator.
212
- return null;
232
+ catch (err) {
233
+ /*
234
+ * 🛑 The predecessor comment here claimed "a failed lookup must not
235
+ * masquerade as 'no such box' — the caller distinguishes the two". It
236
+ * could not: the success path returns `null` on a miss and this returned
237
+ * the same `null`, so the two were indistinguishable by construction and
238
+ * a network failure WAS reported to the operator as an unrecognised
239
+ * code. That is the exact confusion the function's own header says it
240
+ * exists to prevent.
241
+ *
242
+ * Classified and recorded rather than swallowed, and `undefined` now
243
+ * means "could not ask" while `null` keeps meaning "asked, no such box".
244
+ */
245
+ const n = normalizeError(err);
246
+ trackError(n.message, ErrorCategory.API, ErrorSeverity.MEDIUM, {
247
+ component: "ReceiveStationView",
248
+ action: "resolveBoxFromServer",
249
+ metadata: { code: bare },
250
+ });
251
+ return undefined;
213
252
  }
214
253
  }
215
254
  async function handleScan(raw) {
@@ -235,10 +274,18 @@ export function ReceiveStationView({ shipmentId, shipmentNumber, items, unlinked
235
274
  setLastScan({ text: `Armed ${fetched.code}`, tone: "ok" });
236
275
  return;
237
276
  }
238
- setLastScan({
239
- text: `No box registered as ${code.slice(4)} — register it first`,
240
- tone: "bad",
241
- });
277
+ setLastScan(fetched === undefined
278
+ ? {
279
+ // "Could not ask" — never "does not exist". The operator is
280
+ // holding the box; telling them it is unregistered sends them
281
+ // to re-register something that is already in the system.
282
+ text: `Could not check ${code.slice(4)} — connection problem, scan again`,
283
+ tone: "bad",
284
+ }
285
+ : {
286
+ text: `No box registered as ${code.slice(4)} — register it first`,
287
+ tone: "bad",
288
+ });
242
289
  return;
243
290
  }
244
291
  const item = byCode.get(code);
@@ -362,7 +409,11 @@ export function ReceiveStationView({ shipmentId, shipmentNumber, items, unlinked
362
409
  setLastScan({ text: "Committed. Carry on.", tone: "ok" });
363
410
  }
364
411
  catch (err) {
365
- showToast(operatorMessage(err), "error");
412
+ // Normalized first: `operatorMessage` duck-types `.status`/`.code`/
413
+ // `.message`, and the api/app branches of `NormalizedError` carry exactly
414
+ // those three — so the 4xx passthrough still works and every other kind
415
+ // correctly falls through to a constant instead of server text.
416
+ showToast(operatorMessage(normalizeError(err)), "error");
366
417
  }
367
418
  finally {
368
419
  committingRef.current = false;
@@ -382,14 +433,7 @@ export function ReceiveStationView({ shipmentId, shipmentNumber, items, unlinked
382
433
  return (_jsxs(Tr, { children: [_jsx(Td, { children: _jsx(Badge, { variant: "info", children: r.boxCode }) }), _jsx(Td, { children: r.productTitle }), _jsx(Td, { children: _jsx(Text, { numeric: true, size: "sm", children: r.code ?? "—" }) }), _jsx(Td, { children: _jsx(QuantityStepper, { value: r.quantity, min: 1, max: 9999,
383
434
  // Many steppers on one page, so a bare "Quantity"
384
435
  // names none of them.
385
- ariaLabel: `Quantity of ${r.productTitle} for ${r.boxCode}`, onChange: (n) => applyRows((c) => ({
386
- ...c,
387
- [key]: { ...c[key], quantity: n },
388
- })) }) }), _jsx(Td, { children: _jsx(Button, { variant: "ghost", onClick: () => applyRows((c) => {
389
- const next = { ...c };
390
- delete next[key];
391
- return next;
392
- }), children: "Remove" }) })] }, key));
436
+ ariaLabel: `Quantity of ${r.productTitle} for ${r.boxCode}`, onChange: (n) => setRowQuantity(key, n) }) }), _jsx(Td, { children: _jsx(Button, { variant: "ghost", onClick: () => removeRow(key), children: "Remove" }) })] }, key));
393
437
  }) })] }))] }) }), _jsx(Card, { variant: "default", children: _jsxs(CardBody, { children: [_jsx(Heading, { level: 2, size: "lg", children: "Add by hand" }), items.length === 0 ? (_jsx(Text, { color: "muted", children: "No linked items on this shipment yet." })) : (_jsxs(Table, { children: [_jsx(Thead, { children: _jsxs(Tr, { children: [_jsx(Th, { children: "Item" }), _jsx(Th, { children: "Scan code" }), _jsx(Th, { children: "Expected" }), _jsx(Th, { children: "Add" })] }) }), _jsx(Tbody, { children: items.map((it) => (_jsxs(Tr, { children: [_jsx(Td, { children: it.productTitle }), _jsx(Td, { children: _jsx(Text, { numeric: true, size: "sm", children: it.barcodeId ??
394
438
  it.sku ??
395
439
  "no code yet — add by hand, or run the SKU backfill" }) }), _jsx(Td, { children: it.expectedQuantity }), _jsx(Td, { children: _jsx(Button, { variant: "outline",
@@ -37,7 +37,9 @@ export declare class StorageBoxesRepository extends BaseRepository<StorageBoxDoc
37
37
  * recurse, which is the entire reason for the choice.
38
38
  */
39
39
  setRollup(id: string, distinctProducts: number, totalUnits: number): Promise<void>;
40
- listForAdmin(model: SieveModel): Promise<FirebaseSieveResult<StorageBoxDocument>>;
40
+ listForAdmin(model: SieveModel, opts?: {
41
+ search?: string;
42
+ }): Promise<FirebaseSieveResult<StorageBoxDocument>>;
41
43
  /** Every Timestamp field carries `parseSieveDateValue` — Root Cause #47. */
42
44
  static readonly SIEVE_FIELDS: {
43
45
  id: {
@@ -15,9 +15,13 @@
15
15
  * @tag access:server
16
16
  * @tag sideEffects:firestore
17
17
  */
18
- import { BaseRepository, parseSieveDateValue, } from "../../../providers/db-firebase/index.js";
18
+ import { BaseRepository, parseSieveDateValue, applySieveToFirestore, } from "../../../providers/db-firebase/index.js";
19
19
  import { generateStorageBoxId } from "../../../utils/id-generators.js";
20
20
  import { buildStorageBoxSearchTxt } from "../../../utils/search-txt-builders.js";
21
+ // The shared three — hand-writing them is how ten repositories each ended up
22
+ // with two of the three, which reads as a working multi-word search and
23
+ // silently cannot reach page 2.
24
+ import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
21
25
  import { BoxStatusValues, BoxTypeValues, STORAGE_BOXES_COLLECTION, STORAGE_BOX_FIELDS, } from "../schemas/firestore.js";
22
26
  /**
23
27
  * `AC60-03` → ordinal 3.
@@ -100,8 +104,37 @@ export class StorageBoxesRepository extends BaseRepository {
100
104
  updatedAt: new Date(),
101
105
  });
102
106
  }
103
- async listForAdmin(model) {
104
- return this.sieveQuery(model, StorageBoxesRepository.SIEVE_FIELDS, { defaultPageSize: 25, maxPageSize: 50 });
107
+ async listForAdmin(model, opts) {
108
+ /*
109
+ * 🛑 `searchTxt` HAD NO READER. `buildStorageBoxSearchTxt` wrote tokens on
110
+ * every create and update, `SIEVE_FIELDS` listed the field as filterable,
111
+ * and `AdminStorageBoxesView` rendered a search box promising "Search by
112
+ * code or location" — and this method ignored the term entirely. Typing in
113
+ * it returned the unfiltered list with a 200, which reads as "no box
114
+ * matches that" only if you already know the box exists.
115
+ *
116
+ * Same two-part shape as the other migrated collections: `plan.head` is
117
+ * one `array-contains` on the longest term, `plan.rest` is AND-refined in
118
+ * memory. Via `planSearchTxt` for the `empty` guard — a query that
119
+ * normalises to no usable token must return NOTHING, because emitting no
120
+ * clause returns the whole collection and calls it a search result.
121
+ */
122
+ const plan = planSearchTxt(opts?.search);
123
+ if (plan.empty)
124
+ return emptySearchResult();
125
+ if (!plan.head) {
126
+ return this.sieveQuery(model, StorageBoxesRepository.SIEVE_FIELDS, { defaultPageSize: 25, maxPageSize: 50 });
127
+ }
128
+ const baseQuery = this.getCollection().where(STORAGE_BOX_FIELDS.SEARCH_TXT, "array-contains", plan.head);
129
+ const win = beginSearchWindow(model, plan);
130
+ const result = await applySieveToFirestore({
131
+ baseQuery,
132
+ model: win.model,
133
+ options: { maxPageSize: win.maxPageSize(100) },
134
+ fields: StorageBoxesRepository.SIEVE_FIELDS,
135
+ mapDoc: (snap) => this.mapDoc(snap),
136
+ });
137
+ return win.finish(result);
105
138
  }
106
139
  }
107
140
  /** Every Timestamp field carries `parseSieveDateValue` — Root Cause #47. */
@@ -1,5 +1,6 @@
1
1
  import { BaseRepository, prepareForFirestore } from "../../../providers/db-firebase/index.js";
2
2
  import { normalizeError } from "../../../errors/normalize.js";
3
+ import { serverLogger } from "../../../monitoring/index.js";
3
4
  import { OBLIGATIONS_COLLECTION, OBLIGATION_ACTIVE_STATES, OBLIGATION_FIELDS, createObligationId, } from "../schemas/firestore.js";
4
5
  export class ObligationsRepository extends BaseRepository {
5
6
  /*
@@ -138,7 +139,15 @@ export class ObligationsRepository extends BaseRepository {
138
139
  });
139
140
  }
140
141
  catch (err) {
141
- void normalizeError(err);
142
+ /*
143
+ * Best effort, but RECORDED. `void normalizeError(err)` alone types the
144
+ * error and reports it nowhere — as silent as `catch {}` while looking
145
+ * handled, which is what audit-silent-degrade calls normalize theatre.
146
+ * Swallowing is still correct here (a warning marker is a derived hint; the next order write reconciles again); losing the reason is not.
147
+ */
148
+ serverLogger.warn("obligations.warn failed", {
149
+ error: normalizeError(err).message,
150
+ });
142
151
  }
143
152
  }
144
153
  /** Refs for a subject's obligations — used when an order is hard-deleted. */
@@ -1080,6 +1080,25 @@ export declare const ORDER_COLLECTION: "orders";
1080
1080
  * only list it belongs in. (I added it to both first; R1 would have failed.)
1081
1081
  */
1082
1082
  export declare const REFUND_CLAIMS_COLLECTION: "refundClaims";
1083
+ /**
1084
+ * Field names on a refund-claim row.
1085
+ *
1086
+ * 🛑 `STATE`, and it is NOT `ADDRESS_FIELDS.STATE`.
1087
+ * `audit-route-nav-field-constants` R3 suggested exactly that for the raw
1088
+ * `where("state", …)` in `paymentReconcile` — a POSTAL state, on an address.
1089
+ * Borrowing it would have made a refund-claim sweep read as though it filtered
1090
+ * addresses. The field names here are the claim's own lifecycle.
1091
+ *
1092
+ * The collection has no document interface (it is written ad hoc by
1093
+ * `processRefundAction` as an idempotency record), so this is the only place
1094
+ * its field names are stated.
1095
+ */
1096
+ export declare const REFUND_CLAIM_FIELDS: {
1097
+ /** `"claimed"` while a refund is in flight — the idempotency latch. */
1098
+ readonly STATE: "state";
1099
+ readonly CLAIMED_AT: "claimedAt";
1100
+ readonly ORDER_ID: "orderId";
1101
+ };
1083
1102
  export declare const ORDER_INDEXED_FIELDS: readonly ["searchTxt", "userId", "productId", "storeId", "status", "paymentStatus", "payoutStatus", "shippingMethod", "orderDate", "createdAt", "assignedWorkerId", "pickedAt", "packedAt"];
1084
1103
  export declare const DEFAULT_ORDER_DATA: Partial<OrderDocument>;
1085
1104
  export declare const ORDER_PUBLIC_FIELDS: readonly ["id", "productId", "productTitle", "quantity", "unitPrice", "totalPrice", "currency", "status", "paymentStatus", "shippingAddress", "trackingNumber", "notes", "orderDate", "shippingDate", "deliveryDate", "createdAt"];
@@ -73,6 +73,25 @@ export const ORDER_COLLECTION = "orders";
73
73
  * only list it belongs in. (I added it to both first; R1 would have failed.)
74
74
  */
75
75
  export const REFUND_CLAIMS_COLLECTION = "refundClaims";
76
+ /**
77
+ * Field names on a refund-claim row.
78
+ *
79
+ * 🛑 `STATE`, and it is NOT `ADDRESS_FIELDS.STATE`.
80
+ * `audit-route-nav-field-constants` R3 suggested exactly that for the raw
81
+ * `where("state", …)` in `paymentReconcile` — a POSTAL state, on an address.
82
+ * Borrowing it would have made a refund-claim sweep read as though it filtered
83
+ * addresses. The field names here are the claim's own lifecycle.
84
+ *
85
+ * The collection has no document interface (it is written ad hoc by
86
+ * `processRefundAction` as an idempotency record), so this is the only place
87
+ * its field names are stated.
88
+ */
89
+ export const REFUND_CLAIM_FIELDS = {
90
+ /** `"claimed"` while a refund is in flight — the idempotency latch. */
91
+ STATE: "state",
92
+ CLAIMED_AT: "claimedAt",
93
+ ORDER_ID: "orderId",
94
+ };
76
95
  export const ORDER_INDEXED_FIELDS = [
77
96
  "searchTxt",
78
97
  "userId",
@@ -2,6 +2,7 @@ import type { DocumentReference } from "firebase-admin/firestore";
2
2
  import type { FirebaseSieveFields, FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
3
3
  import { BaseRepository } from "../../../providers/db-firebase";
4
4
  import { type PaymentCaseDocument, type PaymentCaseKind, type PaymentCaseSeverity, type PaymentCaseStatus } from "../schemas/firestore";
5
+ /** gRPC `ALREADY_EXISTS` — the one `.create()` failure meaning "we have it". */
5
6
  export interface OpenCaseInput {
6
7
  kind: PaymentCaseKind;
7
8
  /** Identifies THE PROBLEM, not the observation — see `createPaymentCaseId`. */