@mohasinac/appkit 4.29.1 → 4.30.1

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 (165) hide show
  1. package/dist/_internal/client/features/layout/SidebarCollapseToggle.js +1 -1
  2. package/dist/_internal/shared/features/blog/schema.d.ts +2 -2
  3. package/dist/_internal/shared/features/blog/schema.js +2 -1
  4. package/dist/_internal/shared/features/brands/schema.d.ts +2 -2
  5. package/dist/_internal/shared/features/brands/schema.js +3 -1
  6. package/dist/_internal/shared/features/categories/bundle-schemas.d.ts +3 -3
  7. package/dist/_internal/shared/features/categories/bundle-schemas.js +2 -1
  8. package/dist/_internal/shared/features/events/schema.d.ts +2 -2
  9. package/dist/_internal/shared/features/events/schema.js +2 -1
  10. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  11. package/dist/_internal/shared/features/products/schema.js +3 -2
  12. package/dist/_internal/shared/features/reviews/schema.d.ts +1 -1
  13. package/dist/_internal/shared/features/reviews/schema.js +2 -1
  14. package/dist/_internal/shared/types/action-result.d.ts +0 -2
  15. package/dist/client/api/surface-error.d.ts +9 -0
  16. package/dist/client/api/surface-error.js +29 -3
  17. package/dist/client/api/useApiMutation.d.ts +10 -0
  18. package/dist/client/api/useApiMutation.js +2 -1
  19. package/dist/core/server-action.d.ts +3 -14
  20. package/dist/core/server-action.js +11 -2
  21. package/dist/errors/error-envelope.d.ts +73 -0
  22. package/dist/errors/error-envelope.js +96 -0
  23. package/dist/errors/error-handler.js +14 -7
  24. package/dist/features/account/components/NotificationBell.js +1 -1
  25. package/dist/features/account/components/UserSidebar.js +2 -2
  26. package/dist/features/account/components/UserSupportView.js +2 -6
  27. package/dist/features/account/hooks/useProfile.d.ts +6 -0
  28. package/dist/features/account/hooks/useProfile.js +1 -0
  29. package/dist/features/admin/components/AdminAdEditorView.js +1 -3
  30. package/dist/features/admin/components/AdminAddressClustersView.js +1 -3
  31. package/dist/features/admin/components/AdminAddressEditorView.js +2 -4
  32. package/dist/features/admin/components/AdminAddressesView.js +1 -3
  33. package/dist/features/admin/components/AdminAllEventEntriesView.js +1 -3
  34. package/dist/features/admin/components/AdminBidsView.js +1 -3
  35. package/dist/features/admin/components/AdminBlogEditorView.js +2 -2
  36. package/dist/features/admin/components/AdminBrandEditorView.js +4 -5
  37. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +1 -3
  38. package/dist/features/admin/components/AdminCategoryEditorView.js +2 -4
  39. package/dist/features/admin/components/AdminContactEditorView.js +1 -3
  40. package/dist/features/admin/components/AdminContactView.js +2 -6
  41. package/dist/features/admin/components/AdminCouponEditorView.js +2 -4
  42. package/dist/features/admin/components/AdminEmployeeEditorView.js +3 -9
  43. package/dist/features/admin/components/AdminFaqEditorView.js +2 -4
  44. package/dist/features/admin/components/AdminFeatureEditorView.js +0 -9
  45. package/dist/features/admin/components/AdminFeatureFlagsView.js +2 -4
  46. package/dist/features/admin/components/AdminNavEditorView.js +1 -3
  47. package/dist/features/admin/components/AdminNavigationView.js +3 -3
  48. package/dist/features/admin/components/AdminNewsletterView.js +1 -3
  49. package/dist/features/admin/components/AdminNotificationsView.js +2 -6
  50. package/dist/features/admin/components/AdminOffersView.js +1 -3
  51. package/dist/features/admin/components/AdminOrderEditorView.js +1 -3
  52. package/dist/features/admin/components/AdminPaymentClustersView.js +1 -3
  53. package/dist/features/admin/components/AdminPaymentMethodsView.js +1 -3
  54. package/dist/features/admin/components/AdminPayoutMarkPaidModal.js +2 -4
  55. package/dist/features/admin/components/AdminProductEditorView.js +2 -4
  56. package/dist/features/admin/components/AdminReturnRequestsView.js +2 -6
  57. package/dist/features/admin/components/AdminReviewsView.js +2 -6
  58. package/dist/features/admin/components/AdminScammerEditorView.js +2 -6
  59. package/dist/features/admin/components/AdminSectionsView.js +2 -6
  60. package/dist/features/admin/components/AdminSessionsView.js +1 -3
  61. package/dist/features/admin/components/AdminSidebar.js +2 -2
  62. package/dist/features/admin/components/AdminSiteSettingsView.js +1 -1
  63. package/dist/features/admin/components/AdminStoreEditorView.js +1 -3
  64. package/dist/features/admin/components/AdminStoresView.js +2 -6
  65. package/dist/features/admin/components/AdminSublistingCategoryEditorView.js +2 -6
  66. package/dist/features/admin/components/AdminSupportTicketDetailView.js +7 -21
  67. package/dist/features/admin/components/AdminTesterChecklistItemEditorView.js +2 -4
  68. package/dist/features/admin/components/AdminUserEditorView.js +6 -18
  69. package/dist/features/admin/components/AdminUsersView.js +2 -6
  70. package/dist/features/admin/components/BrandQuickCreateForm.js +2 -4
  71. package/dist/features/admin/components/CategoryQuickCreateForm.js +2 -4
  72. package/dist/features/auctions/components/PlaceBidFormClient.js +1 -1
  73. package/dist/features/auth/components/ForgotPasswordView.js +1 -1
  74. package/dist/features/auth/components/LoginForm.js +1 -1
  75. package/dist/features/auth/components/RegisterForm.js +1 -1
  76. package/dist/features/auth/components/ResetPasswordView.js +1 -1
  77. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  78. package/dist/features/auth/repository/session.repository.js +13 -1
  79. package/dist/features/auth/repository/token.repository.d.ts +25 -2
  80. package/dist/features/auth/repository/token.repository.js +26 -18
  81. package/dist/features/blog/actions/blog-actions.d.ts +14 -14
  82. package/dist/features/blog/actions/blog-actions.js +6 -8
  83. package/dist/features/blog/schemas/index.d.ts +1 -1
  84. package/dist/features/blog/schemas/index.js +2 -4
  85. package/dist/features/categories/api/[id]/route.js +2 -1
  86. package/dist/features/categories/api/route.js +2 -1
  87. package/dist/features/events/schemas/index.d.ts +1 -1
  88. package/dist/features/events/schemas/index.js +2 -4
  89. package/dist/features/faq/components/ContactCTA.js +1 -1
  90. package/dist/features/filters/AsyncFacetSection.js +1 -1
  91. package/dist/features/filters/FilterFacetSection.js +1 -1
  92. package/dist/features/filters/RangeFilter.js +1 -1
  93. package/dist/features/filters/SwitchFilter.js +1 -1
  94. package/dist/features/homepage/api/carousel/[id]/route.js +4 -3
  95. package/dist/features/homepage/api/carousel/route.js +4 -3
  96. package/dist/features/homepage/api/route.js +3 -2
  97. package/dist/features/homepage/components/GoogleReviewsSection.js +1 -1
  98. package/dist/features/homepage/components/HeroBanner.js +1 -1
  99. package/dist/features/homepage/components/HeroCarousel.js +1 -1
  100. package/dist/features/homepage/components/SocialPostCard.js +1 -1
  101. package/dist/features/homepage/components/WhatsAppCommunitySection.js +1 -1
  102. package/dist/features/layout/AppLayoutShell.js +4 -4
  103. package/dist/features/layout/BottomNavbar.js +2 -2
  104. package/dist/features/layout/MainNavbar.js +1 -1
  105. package/dist/features/layout/TitleBarLayout.js +24 -12
  106. package/dist/features/media/AvatarUpload.js +28 -10
  107. package/dist/features/media/modals/ImageCropModal.js +1 -1
  108. package/dist/features/media/modals/ImageEditor.js +1 -1
  109. package/dist/features/products/components/MakeOfferButton.js +1 -1
  110. package/dist/features/reviews/api/[id]/route.js +3 -2
  111. package/dist/features/reviews/api/route.js +3 -2
  112. package/dist/features/search/components/Search.js +2 -2
  113. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +3 -3
  114. package/dist/features/seller/components/SellerSidebar.js +2 -2
  115. package/dist/features/store-extensions/components/ReviewDecisionModal.js +1 -1
  116. package/dist/features/stores/repository/store.repository.d.ts +21 -0
  117. package/dist/features/stores/repository/store.repository.js +32 -0
  118. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +0 -2
  119. package/dist/index.d.ts +3 -4
  120. package/dist/index.js +4 -7
  121. package/dist/next/api/api-response.d.ts +29 -12
  122. package/dist/next/api/api-response.js +9 -8
  123. package/dist/next/api/apiHandler.d.ts +6 -1
  124. package/dist/next/api/apiHandler.js +13 -1
  125. package/dist/next/api/routeHandler.js +6 -6
  126. package/dist/next/middleware/pii-scrubber.js +5 -2
  127. package/dist/providers/db-firebase/base.repository.d.ts +14 -0
  128. package/dist/providers/db-firebase/base.repository.js +22 -0
  129. package/dist/providers/db-firebase/sieve.d.ts +14 -1
  130. package/dist/providers/db-firebase/sieve.js +48 -4
  131. package/dist/security/browser.d.ts +1 -1
  132. package/dist/security/browser.js +1 -1
  133. package/dist/security/index.d.ts +2 -2
  134. package/dist/security/index.js +2 -2
  135. package/dist/security/pii-encrypt.d.ts +12 -10
  136. package/dist/security/pii-encrypt.js +91 -29
  137. package/dist/security/pii-mask.d.ts +39 -1
  138. package/dist/security/pii-mask.js +33 -4
  139. package/dist/security/pii-redact.d.ts +0 -13
  140. package/dist/security/pii-redact.js +3 -1
  141. package/dist/security/pii-schemas.d.ts +0 -6
  142. package/dist/security/pii-schemas.js +29 -6
  143. package/dist/security/settings-encryption.js +31 -3
  144. package/dist/seed/test-utils.js +6 -2
  145. package/dist/server.d.ts +1 -1
  146. package/dist/server.js +1 -1
  147. package/dist/ui/DataTable.js +2 -2
  148. package/dist/ui/components/CollapsibleSection.js +1 -1
  149. package/dist/ui/components/ImageGallery.js +1 -1
  150. package/dist/ui/components/ListingLayout.js +1 -1
  151. package/dist/ui/forms/FormShell.d.ts +0 -19
  152. package/dist/ui/forms/FormShell.js +4 -23
  153. package/dist/ui/forms/apply-zod-issues.d.ts +34 -0
  154. package/dist/ui/forms/apply-zod-issues.js +38 -0
  155. package/dist/ui/forms/index.d.ts +2 -1
  156. package/dist/ui/forms/index.js +2 -1
  157. package/dist/ui/icons/icon-registry.d.ts +8 -0
  158. package/dist/ui/icons/icon-registry.js +13 -1
  159. package/dist/utils/action-response.d.ts +1 -1
  160. package/dist/utils/media-url.d.ts +49 -0
  161. package/dist/utils/media-url.js +72 -1
  162. package/dist/validation/schemas.d.ts +15 -2
  163. package/dist/validation/schemas.js +18 -22
  164. package/package.json +1 -1
  165. package/scripts/seed-cli.mjs +21 -6
@@ -5,7 +5,7 @@ import { Button, Div, LoginRequiredModal, Modal, Span, Stack, Text, } from "../.
5
5
  import { Form } from "../../../ui/components/Form";
6
6
  import { FieldInput } from "../../../ui/forms/FieldInput";
7
7
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
8
- import { applyZodIssues } from "../../../ui/forms/FormShell";
8
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
9
9
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
10
10
  import { makeOfferFormSchema } from "../../seller/schemas/offer-forms";
11
11
  import { DEFAULT_MIN_OFFER_PERCENT, minOfferAmount, } from "../../../_internal/shared/features/offers/config";
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { NextResponse } from "next/server.js";
12
12
  import { z } from "zod";
13
+ import { mediaUrlSchema } from "../../../../validation/schemas";
13
14
  import { getProviders } from "../../../../contracts";
14
15
  import { createRouteHandler } from "../../../../next";
15
16
  import { normalizeError } from "../../../../errors/normalize";
@@ -51,8 +52,8 @@ const reviewUpdateSchema = z
51
52
  .optional(),
52
53
  title: z.string().min(1).max(100).optional(),
53
54
  comment: z.string().min(1).max(2000).optional(),
54
- images: z.array(z.string().url()).max(10).optional(),
55
- video: z.string().url().optional(),
55
+ images: z.array(mediaUrlSchema).max(10).optional(),
56
+ video: mediaUrlSchema.optional(),
56
57
  status: z.enum(["pending", "approved", "rejected"]).optional(),
57
58
  })
58
59
  .passthrough();
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { NextResponse } from "next/server.js";
18
18
  import { z } from "zod";
19
+ import { mediaUrlSchema } from "../../../validation/schemas";
19
20
  import { getProviders } from "../../../contracts";
20
21
  import { createRouteHandler } from "../../../next";
21
22
  import { ReviewStatusValues } from "../schemas";
@@ -147,8 +148,8 @@ const reviewCreateSchema = z
147
148
  ]),
148
149
  title: z.string().min(1).max(100).optional(),
149
150
  comment: z.string().min(1).max(2000).optional(),
150
- images: z.array(z.string().url()).max(10).optional(),
151
- video: z.string().url().optional(),
151
+ images: z.array(mediaUrlSchema).max(10).optional(),
152
+ video: mediaUrlSchema.optional(),
152
153
  })
153
154
  .passthrough();
154
155
  export const POST = createRouteHandler({
@@ -216,7 +216,7 @@ export function Search({ isOpen, onClose, onSearch, onOpen, value, onChange, pla
216
216
  if (inlineBlurRef.current)
217
217
  clearTimeout(inlineBlurRef.current);
218
218
  inlineBlurRef.current = setTimeout(() => setIsInlineOpen(false), 120);
219
- }, placeholder: placeholder ?? labels.placeholder, className: "w-full rounded-lg border bg-[var(--appkit-color-surface)] pl-9 text-zinc-900 placeholder:text-zinc-400 focus:border-primary focus:ring-2 focus:ring-primary/20 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] dark:text-white dark:placeholder:text-slate-500" }), query && (_jsx(Button, { rounded: "full", textColor: "faint", type: "button", variant: "ghost", onClick: handleClear, className: "absolute right-3 p-[0.125rem] hover:text-[var(--appkit-color-text-muted)] dark:hover:text-[var(--appkit-color-text)] transition-colors", "aria-label": labels.clearAriaLabel, children: _jsx("svg", { className: "w-4 h-4", "aria-hidden": "true", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) }))] }), resourceTypes && resourceTypes.length > 0 && (_jsx(Select, { options: resourceTypes, value: selectedType, onValueChange: handleTypeChange, "aria-label": labels.resourceTypeLabel ?? "Search in", wrapperClassName: "flex-shrink-0 max-w-[92px] sm:max-w-[150px]" })), deferred && (_jsx(Button, { rounded: "lg", paddingX: "sm", paddingY: "sm", type: "button", variant: "primary", onClick: handleDeferredSubmit, "aria-label": labels.ariaLabel, className: "flex-shrink-0", children: _jsx("svg", { className: "w-4 h-4", "aria-hidden": "true", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: SVG_PATH_SEARCH }) }) })), isInlineOpen && (filteredQuickLinks.length > 0 || query) && (_jsxs(Div, { rounded: "xl", border: "default", shadow: "lg", className: "absolute top-full left-0 right-0 mt-2 overflow-hidden bg-[var(--appkit-color-surface)] [z-index:var(--appkit-z-dropdown)]", onMouseDown: (event) => event.preventDefault(), children: [filteredQuickLinks.length > 0 && (_jsx(Ul, { children: filteredQuickLinks.map((link, index) => renderInlineQuickLink(link, index)) })), query && suggestionsLoading && (_jsx(Div, { padding: "inline", children: _jsx(Text, { variant: "secondary", size: "sm", children: labels.searching }) })), query &&
219
+ }, placeholder: placeholder ?? labels.placeholder, className: "w-full rounded-lg border bg-[var(--appkit-color-surface)] pl-9 text-zinc-900 placeholder:text-zinc-400 focus:border-primary focus:ring-2 focus:ring-primary/20 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] dark:text-white dark:placeholder:text-slate-500" }), query && (_jsx(Button, { rounded: "full", textColor: "faint", type: "button", variant: "ghost", onClick: handleClear, className: "absolute right-3 p-[0.125rem] hover:text-[var(--appkit-color-text-muted)] dark:hover:text-[var(--appkit-color-text)] transition-colors", "aria-label": labels.clearAriaLabel, children: _jsx("svg", { className: "w-4 h-4 shrink-0", "aria-hidden": "true", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) }))] }), resourceTypes && resourceTypes.length > 0 && (_jsx(Select, { options: resourceTypes, value: selectedType, onValueChange: handleTypeChange, "aria-label": labels.resourceTypeLabel ?? "Search in", wrapperClassName: "flex-shrink-0 max-w-[92px] sm:max-w-[150px]" })), deferred && (_jsx(Button, { rounded: "lg", paddingX: "sm", paddingY: "sm", type: "button", variant: "primary", onClick: handleDeferredSubmit, "aria-label": labels.ariaLabel, className: "flex-shrink-0", children: _jsx("svg", { className: "w-4 h-4 shrink-0", "aria-hidden": "true", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: SVG_PATH_SEARCH }) }) })), isInlineOpen && (filteredQuickLinks.length > 0 || query) && (_jsxs(Div, { rounded: "xl", border: "default", shadow: "lg", className: "absolute top-full left-0 right-0 mt-2 overflow-hidden bg-[var(--appkit-color-surface)] [z-index:var(--appkit-z-dropdown)]", onMouseDown: (event) => event.preventDefault(), children: [filteredQuickLinks.length > 0 && (_jsx(Ul, { children: filteredQuickLinks.map((link, index) => renderInlineQuickLink(link, index)) })), query && suggestionsLoading && (_jsx(Div, { padding: "inline", children: _jsx(Text, { variant: "secondary", size: "sm", children: labels.searching }) })), query &&
220
220
  suggestions.slice(0, 5).map((suggestion, suggestionIndex) => {
221
221
  const itemIndex = inlineQuickLinkItems.length + suggestionIndex;
222
222
  const isActive = activeIndex === itemIndex;
@@ -292,7 +292,7 @@ export function Search({ isOpen, onClose, onSearch, onOpen, value, onChange, pla
292
292
  };
293
293
  if (!isOpen)
294
294
  return null;
295
- return (_jsx(Div, { border: "bottom-subtle", className: "animate-[fadeIn_150ms_ease-out]", surface: "default", shadow: "md", children: _jsxs(Div, { className: "container mx-auto md:py-[1rem] relative", padding: "inline", children: [_jsxs(Row, { gap: "sm", className: "md:gap-[0.75rem]", children: [_jsx(Div, { className: "flex-1 relative min-w-0", children: _jsx(Input, { ref: inputRef, bare: true, type: "search", placeholder: placeholder ?? labels.placeholder, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: handleOverlayKeyDown, className: "w-full rounded-lg border bg-[var(--appkit-color-surface)] text-zinc-900 placeholder:text-zinc-400 focus:border-primary focus:ring-2 focus:ring-primary/20 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] dark:text-white dark:placeholder:text-slate-500" }) }), resourceTypes && resourceTypes.length > 0 && (_jsx(Select, { options: resourceTypes, value: selectedType, onValueChange: handleTypeChange, "aria-label": labels.resourceTypeLabel ?? "Search in", wrapperClassName: "flex-shrink-0 max-w-[92px] sm:max-w-[150px]" })), _jsxs(Button, { gap: "md", onClick: handleOverlaySearch, variant: "primary", size: "md", className: "hidden sm:", children: [_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: SVG_PATH_SEARCH }) }), labels.title] }), _jsx(Button, { rounded: "xl", variant: "ghost", onClick: () => onClose?.(), className: "p-[var(--appkit-space-2-5)] md:p-[var(--appkit-space-3)] transition-colors text-[var(--appkit-color-text-muted)] hover:text-zinc-700 text-[var(--appkit-color-text-muted)] hover:bg-surface-hover hover:text-[var(--appkit-color-text-muted)] flex-shrink-0", "aria-label": labels.closeAriaLabel, children: _jsx("svg", { className: "w-6 h-6 md:w-7 md:h-7", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), (filteredSiteLinks.length > 0 || query) && (_jsxs(Stack, { className: "absolute top-full left-0 right-0 z-50 pt-[0.5rem] pb-[1rem]", padding: "x-md", gap: "sm", children: [filteredSiteLinks.length > 0 && !suggestionsLoading && (_jsxs(Div, { className: `${__O.hidden}`, rounded: "xl", shadow: "lg", surface: "default", border: "default", children: [_jsx(Div, { border: "default", className: "border-b", paddingY: "y-xs", paddingX: "x-md", children: _jsx(Text, { variant: "secondary", size: "xs", className: "uppercase tracking-wider", weight: "semibold", children: labels.quickLinks }) }), _jsx(Ul, { children: filteredSiteLinks.map((link) => {
295
+ return (_jsx(Div, { border: "bottom-subtle", className: "animate-[fadeIn_150ms_ease-out]", surface: "default", shadow: "md", children: _jsxs(Div, { className: "container mx-auto md:py-[1rem] relative", padding: "inline", children: [_jsxs(Row, { gap: "sm", className: "md:gap-[0.75rem]", children: [_jsx(Div, { className: "flex-1 relative min-w-0", children: _jsx(Input, { ref: inputRef, bare: true, type: "search", placeholder: placeholder ?? labels.placeholder, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: handleOverlayKeyDown, className: "w-full rounded-lg border bg-[var(--appkit-color-surface)] text-zinc-900 placeholder:text-zinc-400 focus:border-primary focus:ring-2 focus:ring-primary/20 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] dark:text-white dark:placeholder:text-slate-500" }) }), resourceTypes && resourceTypes.length > 0 && (_jsx(Select, { options: resourceTypes, value: selectedType, onValueChange: handleTypeChange, "aria-label": labels.resourceTypeLabel ?? "Search in", wrapperClassName: "flex-shrink-0 max-w-[92px] sm:max-w-[150px]" })), _jsxs(Button, { gap: "md", onClick: handleOverlaySearch, variant: "primary", size: "md", className: "hidden sm:", children: [_jsx("svg", { className: "w-5 h-5 shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: SVG_PATH_SEARCH }) }), labels.title] }), _jsx(Button, { rounded: "xl", variant: "ghost", onClick: () => onClose?.(), className: "p-[var(--appkit-space-2-5)] md:p-[var(--appkit-space-3)] transition-colors text-[var(--appkit-color-text-muted)] hover:text-zinc-700 text-[var(--appkit-color-text-muted)] hover:bg-surface-hover hover:text-[var(--appkit-color-text-muted)] flex-shrink-0", "aria-label": labels.closeAriaLabel, children: _jsx("svg", { className: "w-6 h-6 md:w-7 md:h-7 shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), (filteredSiteLinks.length > 0 || query) && (_jsxs(Stack, { className: "absolute top-full left-0 right-0 z-50 pt-[0.5rem] pb-[1rem]", padding: "x-md", gap: "sm", children: [filteredSiteLinks.length > 0 && !suggestionsLoading && (_jsxs(Div, { className: `${__O.hidden}`, rounded: "xl", shadow: "lg", surface: "default", border: "default", children: [_jsx(Div, { border: "default", className: "border-b", paddingY: "y-xs", paddingX: "x-md", children: _jsx(Text, { variant: "secondary", size: "xs", className: "uppercase tracking-wider", weight: "semibold", children: labels.quickLinks }) }), _jsx(Ul, { children: filteredSiteLinks.map((link) => {
296
296
  const Icon = link.icon;
297
297
  const itemIndex = quickLinkItems.findIndex((item) => item.link.href === link.href);
298
298
  const isActive = activeIndex === itemIndex;
@@ -81,6 +81,7 @@ export function SellerAnalyticsAlertsView({ labels = {}, }) {
81
81
  });
82
82
  const alerts = data ?? [];
83
83
  const createMutation = useApiMutation({
84
+ errorMessage: "Failed to create alert",
84
85
  mutationFn: async () => {
85
86
  /*
86
87
  * Parsed against the SAME schema the route uses, before the request.
@@ -112,22 +113,21 @@ export function SellerAnalyticsAlertsView({ labels = {}, }) {
112
113
  setShowForm(false);
113
114
  setDraft(EMPTY_DRAFT);
114
115
  },
115
- onError: (err) => showToast(err?.message ?? "Failed to create alert", "error"),
116
116
  });
117
117
  const toggleMutation = useApiMutation({
118
+ errorMessage: "Failed to update alert",
118
119
  mutationFn: async ({ id, isActive }) => apiClient.patch(SELLER_ENDPOINTS.ANALYTICS_ALERT_BY_ID(id), { isActive }),
119
120
  onSuccess: () => {
120
121
  void queryClient.invalidateQueries({ queryKey: ["seller", "analytics-alerts"] });
121
122
  },
122
- onError: () => showToast("Failed to update alert", "error"),
123
123
  });
124
124
  const deleteMutation = useApiMutation({
125
+ errorMessage: "Failed to delete alert",
125
126
  mutationFn: async (id) => apiClient.delete(SELLER_ENDPOINTS.ANALYTICS_ALERT_BY_ID(id)),
126
127
  onSuccess: () => {
127
128
  showToast("Alert deleted", "success");
128
129
  void queryClient.invalidateQueries({ queryKey: ["seller", "analytics-alerts"] });
129
130
  },
130
- onError: () => showToast("Failed to delete alert", "error"),
131
131
  });
132
132
  const toggleChannel = useCallback((ch) => {
133
133
  setDraft((d) => ({
@@ -62,13 +62,13 @@ function GroupsContent({ groups, activeHref, storeName, storeLogoURL, onItemClic
62
62
  const hasActive = !!findActiveNavItem(group.items, activeHref);
63
63
  return (_jsxs(Div, { className: "mb-0.5", children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => toggle(group.title), paddingX: "md", paddingY: "sm", weight: "semibold", rounded: "none", className: `w-full text-[0.6875rem] uppercase tracking-widest transition-colors ${hasActive && !isOpen
64
64
  ? CLS_NAV_ICON_ACTIVE
65
- : "text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]"}`, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { children: group.title }), _jsx("svg", { className: `w-3 h-3 transition-transform duration-150 ${isOpen ? "rotate-180" : ""}`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M19 9l-7 7-7-7" }) })] }) }), isOpen && (_jsx(Ul, { paddingX: "x-sm", paddingY: "y-bottom-xs", spacing: "2xs", children: group.items.map((item) => (_jsx(Li, { children: _jsx(NavLink, { item: item, isActive: activeItem?.href === item.href, onClick: onItemClick }) }, item.href))) }))] }, group.title));
65
+ : "text-[var(--appkit-color-text-faint)] hover:text-[var(--appkit-color-text-muted)]"}`, children: _jsxs(Row, { align: "center", justify: "between", className: "w-full", children: [_jsx(Span, { children: group.title }), _jsx("svg", { className: `w-3 h-3 transition-transform duration-150 ${isOpen ? "rotate-180" : ""} shrink-0`, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2.5, d: "M19 9l-7 7-7-7" }) })] }) }), isOpen && (_jsx(Ul, { paddingX: "x-sm", paddingY: "y-bottom-xs", spacing: "2xs", children: group.items.map((item) => (_jsx(Li, { children: _jsx(NavLink, { item: item, isActive: activeItem?.href === item.href, onClick: onItemClick }) }, item.href))) }))] }, group.title));
66
66
  })] })] }));
67
67
  }
68
68
  function DrawerPanel({ title, onClose, children }) {
69
69
  const { hand } = useHandMode();
70
70
  const { edgeClass, borderClass } = getSidebarOverlayClasses(hand);
71
- return (_jsxs(Div, { className: "hidden lg:block", children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 z-40 backdrop-blur-sm", onClick: onClose, "aria-hidden": "true" }), _jsxs(Stack, { border: "default", shadow: "2xl", role: "dialog", "aria-modal": "true", "aria-label": title, className: `fixed top-0 ${edgeClass} z-50 h-full w-64 ${borderClass}`, surface: "default", children: [_jsxs(Row, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", align: "center", justify: "between", children: [_jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: title }), _jsx(IconButton, { "aria-label": "Close", variant: "ghost", size: "sm", onClick: onClose, icon: _jsx("svg", { className: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: children })] })] }));
71
+ return (_jsxs(Div, { className: "hidden lg:block", children: [_jsx(Div, { surface: "overlay-xs", className: "fixed inset-0 z-40 backdrop-blur-sm", onClick: onClose, "aria-hidden": "true" }), _jsxs(Stack, { border: "default", shadow: "2xl", role: "dialog", "aria-modal": "true", "aria-label": title, className: `fixed top-0 ${edgeClass} z-50 h-full w-64 ${borderClass}`, surface: "default", children: [_jsxs(Row, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", align: "center", justify: "between", children: [_jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: title }), _jsx(IconButton, { "aria-label": "Close", variant: "ghost", size: "sm", onClick: onClose, icon: _jsx("svg", { className: "w-4 h-4 shrink-0", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: children })] })] }));
72
72
  }
73
73
  export function StoreSidebar({ items, groups, activeHref, storeName, storeLogoURL, mobileOpen = false, desktopOpen = false, variant = "overlay", onCloseMobile, onToggle, renderFooter, }) {
74
74
  const close = onCloseMobile ?? (() => { });
@@ -44,7 +44,7 @@ import { Stack } from "../../../ui/components/Layout";
44
44
  import { Form } from "../../../ui/components/Form";
45
45
  import { FieldTextarea } from "../../../ui/forms/FieldTextarea";
46
46
  import { FormErrorSummary } from "../../../ui/forms/FormErrorSummary";
47
- import { applyZodIssues } from "../../../ui/forms/FormShell";
47
+ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues";
48
48
  import { useToast } from "../../../ui/components/Toast";
49
49
  import { normalizeError } from "../../../errors/normalize";
50
50
  export function ReviewDecisionModal({ isOpen, onClose, title, schema, status, noteField, noteLabel, noteHelp, notePlaceholder, confirmLabel, confirmVariant = "primary", onConfirm, }) {
@@ -19,6 +19,27 @@ export declare class StoreRepository extends BaseRepository<StoreDocument> {
19
19
  private encryptSecrets;
20
20
  private decryptSecrets;
21
21
  protected mapDoc<D = StoreDocument>(snap: DocumentSnapshot): D;
22
+ /**
23
+ * LIST reads never carry the WhatsApp token — not even encrypted.
24
+ *
25
+ * `mapDoc` above decrypts it, and `listStores(model, activeOnly)` backs the
26
+ * PUBLIC `/stores` page. Once `sieveQuery` started routing list rows through
27
+ * `mapDoc` (it previously mapped documents itself and skipped every
28
+ * override), that decrypt would have put a plaintext Meta OAuth bearer token
29
+ * on a public listing — the exact shape of Root Cause #70.
30
+ *
31
+ * So the list path deliberately does NOT call `decryptSecrets`, and drops
32
+ * `accessToken` outright: a list has no legitimate use for it, and a value
33
+ * that is never in the payload cannot be leaked by a projection someone
34
+ * forgets to write.
35
+ *
36
+ * Detail reads are unchanged — `findBySlug`/`findById` still decrypt, so the
37
+ * WhatsApp settings and catalog-sync routes keep working exactly as before.
38
+ * Removing the decrypt from `mapDoc` itself is a separate, riskier change
39
+ * that CLAUDE.md explicitly defers; this closes the public-exposure half
40
+ * without touching the encryption round-trip at all.
41
+ */
42
+ protected mapDocForList<D = StoreDocument>(snap: DocumentSnapshot): D;
22
43
  update(id: string, data: Partial<StoreDocument>): Promise<StoreDocument>;
23
44
  /**
24
45
  * Create a new store.
@@ -36,6 +36,33 @@ export class StoreRepository extends BaseRepository {
36
36
  mapDoc(snap) {
37
37
  return this.decryptSecrets(super.mapDoc(snap));
38
38
  }
39
+ /**
40
+ * LIST reads never carry the WhatsApp token — not even encrypted.
41
+ *
42
+ * `mapDoc` above decrypts it, and `listStores(model, activeOnly)` backs the
43
+ * PUBLIC `/stores` page. Once `sieveQuery` started routing list rows through
44
+ * `mapDoc` (it previously mapped documents itself and skipped every
45
+ * override), that decrypt would have put a plaintext Meta OAuth bearer token
46
+ * on a public listing — the exact shape of Root Cause #70.
47
+ *
48
+ * So the list path deliberately does NOT call `decryptSecrets`, and drops
49
+ * `accessToken` outright: a list has no legitimate use for it, and a value
50
+ * that is never in the payload cannot be leaked by a projection someone
51
+ * forgets to write.
52
+ *
53
+ * Detail reads are unchanged — `findBySlug`/`findById` still decrypt, so the
54
+ * WhatsApp settings and catalog-sync routes keep working exactly as before.
55
+ * Removing the decrypt from `mapDoc` itself is a separate, riskier change
56
+ * that CLAUDE.md explicitly defers; this closes the public-exposure half
57
+ * without touching the encryption round-trip at all.
58
+ */
59
+ mapDocForList(snap) {
60
+ const doc = super.mapDoc(snap);
61
+ if (!doc.whatsappConfig)
62
+ return doc;
63
+ const { accessToken: _dropped, ...safeConfig } = doc.whatsappConfig;
64
+ return { ...doc, whatsappConfig: safeConfig };
65
+ }
39
66
  async update(id, data) {
40
67
  return super.update(id, this.encryptSecrets(data));
41
68
  }
@@ -179,6 +206,11 @@ export class StoreRepository extends BaseRepository {
179
206
  baseQuery,
180
207
  model,
181
208
  fields: sieveFields,
209
+ // Called DIRECTLY, not through `sieveQuery`, so the mapper has to be
210
+ // passed by hand — and it is the list mapper, never `mapDoc`. With
211
+ // `activeOnly` this is the public /stores listing; `mapDoc` decrypts the
212
+ // WhatsApp bearer token and a list has no use for it.
213
+ mapDoc: (snap) => this.mapDocForList(snap),
182
214
  });
183
215
  }
184
216
  /**
@@ -123,7 +123,6 @@ export function SellerWhatsAppSettingsView({ hasCapability }) {
123
123
  },
124
124
  onError: (err) => {
125
125
  const msg = err instanceof Error ? err.message : "Failed to save settings";
126
- showToast(msg, "error");
127
126
  },
128
127
  });
129
128
  // Catalog sync mutation (push: site → WhatsApp)
@@ -136,7 +135,6 @@ export function SellerWhatsAppSettingsView({ hasCapability }) {
136
135
  },
137
136
  onError: (err) => {
138
137
  const msg = err instanceof Error ? err.message : "Sync failed";
139
- showToast(msg, "error");
140
138
  },
141
139
  });
142
140
  // Catalog import (pull: WhatsApp → site). Runs as the `whatsappCatalogImport`
package/dist/index.d.ts CHANGED
@@ -411,7 +411,7 @@ export { unitOfWork } from "./core/index";
411
411
  export { useSiteSettings } from "./core/index";
412
412
  export type { ActionMiddleware } from "./core/index";
413
413
  export type { ActionMiddlewareContext } from "./core/index";
414
- export type { ActionResult } from "./core/index";
414
+ export type { ActionResult } from "./_internal/shared/types/action-result";
415
415
  export { handleActionError } from "./utils/action-response";
416
416
  export type { CacheEntry } from "./core/index";
417
417
  export type { CacheOptions } from "./core/index";
@@ -444,8 +444,6 @@ export { Can } from "./security/index";
444
444
  export { DEFAULT_ROLES } from "./security/index";
445
445
  export { EVENT_ENTRY_PII_FIELDS } from "./security/index";
446
446
  export { LOTTERY_ENTRY_PII_FIELDS } from "./security/index";
447
- export { NEWSLETTER_PII_FIELDS } from "./security/index";
448
- export { NEWSLETTER_PII_INDEX_MAP } from "./security/index";
449
447
  export { OFFER_PII_FIELDS } from "./security/index";
450
448
  export { ORDER_PII_FIELDS } from "./security/index";
451
449
  export { PAYOUT_PII_FIELDS } from "./security/index";
@@ -799,6 +797,7 @@ export { createPiiRedactorMiddleware } from "./next/index";
799
797
  export { createRouteHandler } from "./next/index";
800
798
  export { createRouteMap } from "./next/index";
801
799
  export { errorResponse } from "./next/index";
800
+ export { buildErrorEnvelope, toApiIssues, codeForStatus, type ApiErrorEnvelope, } from "./errors/error-envelope";
802
801
  export { getBooleanParam } from "./next/index";
803
802
  export { getNumberParam } from "./next/index";
804
803
  export { getOptionalSessionCookie } from "./next/index";
@@ -3139,7 +3138,7 @@ export { formatMonthYear } from "./utils/date.formatter";
3139
3138
  export { generateMediaFilename } from "./utils/id-generators";
3140
3139
  export { deriveContextTypeFromFilename } from "./utils/id-generators";
3141
3140
  export type { MediaFilenameContext } from "./utils/id-generators";
3142
- export { resolveMediaUrl, resolveVideoUrl, FIREBASE_STORAGE_HOST, GCS_HOST } from "./utils/media-url";
3141
+ export { resolveMediaUrl, resolveVideoUrl, FIREBASE_STORAGE_HOST, GCS_HOST, isStoredMediaRef, MEDIA_PROXY_PREFIX, MEDIA_URL_MAX_LENGTH, MEDIA_URL_MESSAGE, APPROVED_MEDIA_DOMAINS, } from "./utils/media-url";
3143
3142
  export { generateFAQId } from "./utils/id-generators";
3144
3143
  export type { GenerateFAQIdInput } from "./utils/id-generators";
3145
3144
  export { generateCroppedImageFilename } from "./utils/id-generators";
package/dist/index.js CHANGED
@@ -787,12 +787,6 @@ export { EVENT_ENTRY_PII_FIELDS } from "./security/index";
787
787
  // LOTTERY_ENTRY_PII_FIELDS - PII fields for lottery entries.
788
788
  export { LOTTERY_ENTRY_PII_FIELDS } from "./security/index";
789
789
  // [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
790
- // NEWSLETTER_PII_FIELDS - Constant used across modules.
791
- export { NEWSLETTER_PII_FIELDS } from "./security/index";
792
- // [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
793
- // NEWSLETTER_PII_INDEX_MAP - Constant used across modules.
794
- export { NEWSLETTER_PII_INDEX_MAP } from "./security/index";
795
- // [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
796
790
  // OFFER_PII_FIELDS - Constant used across modules.
797
791
  export { OFFER_PII_FIELDS } from "./security/index";
798
792
  // [CLIENT-SSR]-Runs in both SSR and browser â€" React component or hook that does not depend on browser-only APIs.
@@ -1640,6 +1634,9 @@ export { createRouteMap } from "./next/index";
1640
1634
  // [SERVER-ONLY]-Server-only â€" uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
1641
1635
  // errorResponse - Model for error response.
1642
1636
  export { errorResponse } from "./next/index";
1637
+ // The one error-body shape, plus the Zod-issue narrower every route needs to
1638
+ // put field errors on the wire under the key ApiClientError actually reads.
1639
+ export { buildErrorEnvelope, toApiIssues, codeForStatus, } from "./errors/error-envelope";
1643
1640
  // [SERVER-ONLY]-Server-only â€" uses Node.js, Next.js server internals, or third-party server SDKs (auth, email, payment, shipping).
1644
1641
  // getBooleanParam - Helper for get boolean param.
1645
1642
  export { getBooleanParam } from "./next/index";
@@ -5550,7 +5547,7 @@ export { formatCurrency, formatFileSize } from "./utils/number.formatter";
5550
5547
  export { formatMonthYear } from "./utils/date.formatter";
5551
5548
  export { generateMediaFilename } from "./utils/id-generators"; // generateProductImageFilename already exported from "./utils/id-generators";
5552
5549
  export { deriveContextTypeFromFilename } from "./utils/id-generators";
5553
- export { resolveMediaUrl, resolveVideoUrl, FIREBASE_STORAGE_HOST, GCS_HOST } from "./utils/media-url";
5550
+ export { resolveMediaUrl, resolveVideoUrl, FIREBASE_STORAGE_HOST, GCS_HOST, isStoredMediaRef, MEDIA_PROXY_PREFIX, MEDIA_URL_MAX_LENGTH, MEDIA_URL_MESSAGE, APPROVED_MEDIA_DOMAINS, } from "./utils/media-url";
5554
5551
  export { generateFAQId } from "./utils/id-generators";
5555
5552
  export { generateCroppedImageFilename } from "./utils/id-generators";
5556
5553
  // Messages / Conversations feature schemas
@@ -6,25 +6,42 @@
6
6
  */
7
7
  import { NextResponse } from "next/server";
8
8
  import type { JsonValue } from "@mohasinac/appkit";
9
+ import type { ApiIssue } from "../../client/api/ApiError";
10
+ import { type ApiErrorEnvelope } from "../../errors/error-envelope";
9
11
  export interface ApiSuccessResponse<T = unknown> {
10
12
  success: true;
11
13
  data?: T;
12
14
  message?: string;
13
15
  meta?: Record<string, JsonValue>;
14
16
  }
15
- export interface ApiErrorResponse {
16
- success: false;
17
- error: string;
18
- details?: unknown;
19
- }
17
+ export type ApiErrorResponse = ApiErrorEnvelope;
20
18
  export declare function successResponse<T>(data?: T, message?: string, status?: number, meta?: Record<string, JsonValue>): NextResponse<ApiSuccessResponse<T>>;
21
- export declare function errorResponse(error: string, status?: number, details?: unknown): NextResponse<ApiErrorResponse>;
19
+ /**
20
+ * Options for an error response.
21
+ *
22
+ * This replaced a bare `details?: unknown` third positional argument that was
23
+ * carrying three mutually incompatible things across the codebase — a stable
24
+ * code string, an array of Zod issues, and arbitrary server-side context —
25
+ * all serialised under a `details` key that no client ever read. Narrowing the
26
+ * type is what let `tsc` enumerate every call site so each could be given the
27
+ * meaning it actually intended.
28
+ *
29
+ * Server-side context does NOT belong here: it goes to `serverLogger`. Only
30
+ * things the client can act on cross the wire.
31
+ */
32
+ export interface ErrorResponseOptions {
33
+ /** Stable machine code. Defaults to one derived from `status`. */
34
+ code?: string;
35
+ /** Field-level validation issues — routed to inline field errors by `surfaceError`. */
36
+ issues?: ApiIssue[];
37
+ requestId?: string;
38
+ }
39
+ export declare function errorResponse(error: string, status?: number, opts?: ErrorResponseOptions): NextResponse<ApiErrorResponse>;
22
40
  /** Pre-built shorthand factories for common HTTP error responses. */
23
41
  export declare const ApiErrors: {
24
- unauthorized: (message?: string) => NextResponse<ApiErrorResponse>;
25
- forbidden: (message?: string) => NextResponse<ApiErrorResponse>;
26
- notFound: (resource?: string) => NextResponse<ApiErrorResponse>;
27
- badRequest: (message?: string, details?: unknown) => NextResponse<ApiErrorResponse>;
28
- internalError: (message?: string) => NextResponse<ApiErrorResponse>;
29
- validationError: (details: Error) => NextResponse<ApiErrorResponse>;
42
+ unauthorized: (message?: string) => NextResponse<ApiErrorEnvelope>;
43
+ forbidden: (message?: string) => NextResponse<ApiErrorEnvelope>;
44
+ notFound: (resource?: string) => NextResponse<ApiErrorEnvelope>;
45
+ badRequest: (message?: string, opts?: ErrorResponseOptions) => NextResponse<ApiErrorEnvelope>;
46
+ internalError: (message?: string) => NextResponse<ApiErrorEnvelope>;
30
47
  };
@@ -5,6 +5,7 @@
5
5
  * No app-specific dependencies — safe to use in any project.
6
6
  */
7
7
  import { NextResponse } from "next/server";
8
+ import { buildErrorEnvelope, } from "../../errors/error-envelope";
8
9
  export function successResponse(data, message, status = 200, meta) {
9
10
  return NextResponse.json({
10
11
  success: true,
@@ -13,12 +14,8 @@ export function successResponse(data, message, status = 200, meta) {
13
14
  ...(meta && { meta }),
14
15
  }, { status });
15
16
  }
16
- export function errorResponse(error, status = 400, details) {
17
- return NextResponse.json({
18
- success: false,
19
- error,
20
- ...(details !== undefined && { details }),
21
- },
17
+ export function errorResponse(error, status = 400, opts) {
18
+ return NextResponse.json(buildErrorEnvelope({ message: error, status, ...opts }),
22
19
  // `no-store` — without this, Vercel's edge CDN can cache a transient
23
20
  // error response (cold-start 500, not-yet-synced 404) on a public GET
24
21
  // route indefinitely. Same root cause as the /media 404-caching bug.
@@ -29,7 +26,11 @@ export const ApiErrors = {
29
26
  unauthorized: (message = "Unauthorized") => errorResponse(message, 401),
30
27
  forbidden: (message = "Forbidden") => errorResponse(message, 403),
31
28
  notFound: (resource = "Resource") => errorResponse(`${resource} not found`, 404),
32
- badRequest: (message = "Bad request", details) => errorResponse(message, 400, details),
29
+ badRequest: (message = "Bad request", opts) => errorResponse(message, 400, opts),
33
30
  internalError: (message = "Internal server error") => errorResponse(message, 500),
34
- validationError: (details) => errorResponse("Validation failed", 422, details),
31
+ // `validationError` used to live here as a 422 taking a raw `Error`. It had
32
+ // ZERO call sites, and its status disagreed with every other validation
33
+ // producer (`apiHandler`, `routeHandler` and `mapToHttpError` all use 400).
34
+ // Deleted rather than aligned — a fourth spelling of "the body was invalid"
35
+ // is what this whole file is trying to stop having.
35
36
  };
@@ -1,3 +1,4 @@
1
+ import type { ApiIssue } from "../../client/api/ApiError";
1
2
  interface SafeParseSchema<TInput> {
2
3
  safeParse: (input: unknown) => {
3
4
  success: true;
@@ -40,7 +41,11 @@ export interface ApiHandlerFactoryDeps<TRole, TRateLimitConfig, TUser> {
40
41
  applyRateLimit: (request: Request, config: TRateLimitConfig) => Promise<ApiRateLimitResult>;
41
42
  requireAuthFromRequest: (request: Request) => Promise<TUser>;
42
43
  requireRoleFromRequest: (request: Request, roles: TRole[]) => Promise<TUser>;
43
- errorResponse: (message: string, status: number, issues?: unknown) => Response;
44
+ errorResponse: (message: string, status: number, opts?: {
45
+ code?: string;
46
+ issues?: ApiIssue[];
47
+ requestId?: string;
48
+ }) => Response;
44
49
  handleApiError: (error: unknown) => Response;
45
50
  getRateLimitExceededMessage: () => string;
46
51
  logTiming: (entry: {
@@ -1,4 +1,6 @@
1
1
  import { normalizeError } from "../../errors/normalize";
2
+ import { toApiIssues } from "../../errors/error-envelope";
3
+ import { HTTP_ERROR_CODES } from "../../errors/error-mapping";
2
4
  async function validateSchema(request, schema) {
3
5
  if (typeof schema.safeParse === "function") {
4
6
  const body = await request.json();
@@ -49,7 +51,17 @@ async function applyRateLimitCheck(request, config, applyRateLimit, getRateLimit
49
51
  async function applySchemaValidation(request, schema, errorResponse) {
50
52
  const schemaResult = await validateSchema(request, schema);
51
53
  if (schemaResult.validationError) {
52
- return { invalid: true, response: errorResponse("Validation failed", 400, schemaResult.issues) };
54
+ // The issues travel as `issues`, which is the key ApiClientError reads.
55
+ // They used to be passed positionally and serialised under `details`,
56
+ // where nothing read them — so every schema rejection on every route
57
+ // reached the user as a bare "Validation failed" with no field name.
58
+ return {
59
+ invalid: true,
60
+ response: errorResponse("Validation failed", 400, {
61
+ code: HTTP_ERROR_CODES.VALIDATION_FAILED,
62
+ issues: toApiIssues(schemaResult.issues),
63
+ }),
64
+ };
53
65
  }
54
66
  return { invalid: false, data: schemaResult.data };
55
67
  }
@@ -32,6 +32,7 @@ import { NextResponse } from "next/server.js";
32
32
  import { getProviders } from "../../contracts";
33
33
  import { isEffectiveAdminUser } from "../../features/auth/role-predicates";
34
34
  import { mapToHttpError, HTTP_ERROR_CODES } from "../../errors/error-mapping";
35
+ import { buildErrorEnvelope, toApiIssues } from "../../errors/error-envelope";
35
36
  import { serverErrorsRepository } from "../../features/server-errors/repository/server-errors.repository";
36
37
  import { userRepository } from "../../repositories";
37
38
  import { SCHEMAS } from "../../schemas/registry";
@@ -121,14 +122,13 @@ function resolveBodySchema(schema) {
121
122
  * this helper and the audit re-runs to enforce the cleanup.
122
123
  */
123
124
  function errorJson(status, code, message, requestId, issues) {
124
- return NextResponse.json({
125
- ok: false,
126
- success: false, // deprecated — for compat during the migration sweep
125
+ return NextResponse.json(buildErrorEnvelope({
126
+ message,
127
+ status,
127
128
  code,
128
- error: message,
129
- ...(issues ? { issues } : {}),
129
+ issues: toApiIssues(issues),
130
130
  requestId,
131
- }, {
131
+ }), {
132
132
  status,
133
133
  // `no-store` on every error envelope — without this, Vercel's edge CDN
134
134
  // can cache a transient failure (cold-start 500, not-yet-synced 404)
@@ -1,8 +1,11 @@
1
1
  import { normalizeError } from "../../errors/normalize";
2
2
  import { NextResponse } from "next/server";
3
- const ENC_PREFIX = "enc:v1:";
3
+ import { hasEncPrefix } from "../../security/pii-mask";
4
+ // Scrubbing wants EVERY `enc:v1:` value gone, whichever of the two crypto
5
+ // systems wrote it and even if malformed — so `hasEncPrefix`, not
6
+ // `isPiiEncrypted`, which is now specifically the PII format.
4
7
  function scrubValue(value) {
5
- if (typeof value === "string" && value.startsWith(ENC_PREFIX)) {
8
+ if (hasEncPrefix(value)) {
6
9
  return "[encrypted]";
7
10
  }
8
11
  if (Array.isArray(value))
@@ -7,6 +7,20 @@ export declare abstract class BaseRepository<T extends DocumentData> {
7
7
  protected get db(): Firestore;
8
8
  protected getCollection(): CollectionReference<DocumentData, DocumentData>;
9
9
  protected mapDoc<D = T>(snap: DocumentSnapshot): D;
10
+ /**
11
+ * How a row is mapped on a LIST read (anything through `sieveQuery`).
12
+ *
13
+ * Defaults to `mapDoc`, so a repository that overrides `mapDoc` to decrypt,
14
+ * project or normalise now gets that on list reads too — Sieve used to map
15
+ * documents itself and never call `mapDoc` at all, which is why
16
+ * `/api/admin/users` served ciphertext emails.
17
+ *
18
+ * It is a SEPARATE hook because a list is not a detail read. A repository
19
+ * whose `mapDoc` decrypts a secret must not hand that secret to a list —
20
+ * `StoreRepository` overrides this to drop the WhatsApp token, since
21
+ * `listStores(activeOnly)` backs the PUBLIC /stores page.
22
+ */
23
+ protected mapDocForList<D = T>(snap: DocumentSnapshot): D;
10
24
  findById(id: string): Promise<T | null>;
11
25
  findByIdOrFail(id: string): Promise<T>;
12
26
  findBy(field: string, value: unknown): Promise<T[]>;
@@ -34,6 +34,22 @@ export class BaseRepository {
34
34
  ...(snap.data() ?? {}),
35
35
  });
36
36
  }
37
+ /**
38
+ * How a row is mapped on a LIST read (anything through `sieveQuery`).
39
+ *
40
+ * Defaults to `mapDoc`, so a repository that overrides `mapDoc` to decrypt,
41
+ * project or normalise now gets that on list reads too — Sieve used to map
42
+ * documents itself and never call `mapDoc` at all, which is why
43
+ * `/api/admin/users` served ciphertext emails.
44
+ *
45
+ * It is a SEPARATE hook because a list is not a detail read. A repository
46
+ * whose `mapDoc` decrypts a secret must not hand that secret to a list —
47
+ * `StoreRepository` overrides this to drop the WhatsApp token, since
48
+ * `listStores(activeOnly)` backs the PUBLIC /stores page.
49
+ */
50
+ mapDocForList(snap) {
51
+ return this.mapDoc(snap);
52
+ }
37
53
  async findById(id) {
38
54
  try {
39
55
  const doc = await this.getCollection().doc(id).get();
@@ -219,6 +235,12 @@ export class BaseRepository {
219
235
  model,
220
236
  fields,
221
237
  options: sieveOptions,
238
+ // Load-bearing. Without this every Sieve-backed read silently skips the
239
+ // subclass's mapDoc — decryption, projection and normalisation all lost.
240
+ // audit-repository-fields asserts this argument stays here, because one
241
+ // edit removing it would unwire all 14 affected repositories at once
242
+ // while the per-file rule still passed.
243
+ mapDoc: (snap) => this.mapDocForList(snap),
222
244
  });
223
245
  }
224
246
  /**
@@ -66,7 +66,7 @@
66
66
  * Add nested-field indexes explicitly; Firestore does not auto-index nested
67
67
  * paths in composite indexes declared at the root level.
68
68
  */
69
- import type { CollectionReference, DocumentData, Query } from "firebase-admin/firestore";
69
+ import type { CollectionReference, DocumentData, Query, QueryDocumentSnapshot } from "firebase-admin/firestore";
70
70
  import { FirebaseRepository } from "./base";
71
71
  import { expandFilterAliases as _expandFilterAliases, type SieveFilterAlias, type SieveFilterAliases } from "./filter-aliases";
72
72
  export { _expandFilterAliases as expandFilterAliases, type SieveFilterAlias, type SieveFilterAliases, };
@@ -153,6 +153,19 @@ export declare function applySieveToFirestore<T extends DocumentData>(params: {
153
153
  model: SieveModel;
154
154
  fields: SieveFields;
155
155
  options?: SieveOptions;
156
+ /**
157
+ * Row mapper. Defaults to the same plain `deserializeTimestamps({id, ...data})`
158
+ * this function always did — so it is a provable no-op for the ~40
159
+ * repositories that don't override `mapDoc`.
160
+ *
161
+ * It exists because this function mapped documents ITSELF and never called
162
+ * the repository's `mapDoc`, so every read that went through Sieve skipped
163
+ * whatever that override did: decryption, projection, normalisation. The
164
+ * visible consequence was `/api/admin/users` serving `enc:v1:…` ciphertext
165
+ * as each user's email address, since `UserRepository.mapDoc` is what
166
+ * decrypts and `list()` goes through here.
167
+ */
168
+ mapDoc?: (snap: QueryDocumentSnapshot) => T;
156
169
  }): Promise<SieveResult<T>>;
157
170
  export declare abstract class FirebaseSieveRepository<T extends DocumentData> extends FirebaseRepository<T> {
158
171
  /**