@mohasinac/appkit 4.22.0 โ†’ 4.24.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 (112) hide show
  1. package/dist/_internal/server/features/bundles/actions.js +7 -0
  2. package/dist/_internal/server/features/checkout/actions.js +24 -16
  3. package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
  4. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  5. package/dist/_internal/server/features/stores/adapters.js +5 -0
  6. package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
  7. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
  8. package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
  9. package/dist/_internal/shared/actions/action-registry.js +41 -0
  10. package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
  11. package/dist/_internal/shared/checkout/order-math.js +29 -6
  12. package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
  13. package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
  14. package/dist/_internal/shared/features/cart/config.d.ts +2 -1
  15. package/dist/_internal/shared/features/cart/config.js +2 -1
  16. package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
  17. package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
  18. package/dist/_internal/shared/features/notifications/action-url.js +111 -0
  19. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  20. package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
  21. package/dist/_internal/shared/history/index.d.ts +1 -1
  22. package/dist/_internal/shared/history/types.d.ts +16 -0
  23. package/dist/client.d.ts +2 -0
  24. package/dist/client.js +1 -0
  25. package/dist/constants/field-names.d.ts +12 -0
  26. package/dist/constants/field-names.js +12 -0
  27. package/dist/constants/notification-types.d.ts +38 -10
  28. package/dist/constants/notification-types.js +39 -28
  29. package/dist/features/account/types/index.d.ts +1 -10
  30. package/dist/features/admin/actions/admin-actions.js +13 -3
  31. package/dist/features/admin/actions/notification-actions.d.ts +12 -0
  32. package/dist/features/admin/actions/notification-actions.js +149 -4
  33. package/dist/features/admin/components/AdminBidsView.js +7 -1
  34. package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
  35. package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
  36. package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
  37. package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
  38. package/dist/features/admin/schemas/firestore.d.ts +14 -3
  39. package/dist/features/admin/schemas/firestore.js +79 -0
  40. package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
  41. package/dist/features/auctions/repository/bid.repository.js +57 -33
  42. package/dist/features/auctions/schemas/firestore.d.ts +27 -0
  43. package/dist/features/auctions/schemas/firestore.js +21 -0
  44. package/dist/features/auth/schemas/index.d.ts +2 -2
  45. package/dist/features/cart/actions/cart-actions.d.ts +16 -0
  46. package/dist/features/cart/actions/cart-actions.js +26 -12
  47. package/dist/features/cart/components/CartDrawer.js +2 -1
  48. package/dist/features/cart/schemas/firestore.d.ts +132 -8
  49. package/dist/features/cart/schemas/firestore.js +22 -0
  50. package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
  51. package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
  52. package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
  53. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
  54. package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
  55. package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
  56. package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
  57. package/dist/features/catalogue/schemas/firestore.js +12 -0
  58. package/dist/features/categories/schemas/index.d.ts +2 -2
  59. package/dist/features/email/index.d.ts +1 -1
  60. package/dist/features/email/index.js +5 -1
  61. package/dist/features/email/notification-templates.d.ts +35 -0
  62. package/dist/features/email/notification-templates.js +96 -0
  63. package/dist/features/messages/schemas/index.d.ts +6 -6
  64. package/dist/features/orders/schemas/index.d.ts +14 -14
  65. package/dist/features/payments/repository/payout.repository.d.ts +18 -5
  66. package/dist/features/payments/repository/payout.repository.js +45 -19
  67. package/dist/features/payments/schemas/firestore.d.ts +33 -1
  68. package/dist/features/payments/schemas/firestore.js +39 -0
  69. package/dist/features/payments/schemas/index.d.ts +12 -12
  70. package/dist/features/products/schemas/index.d.ts +2 -2
  71. package/dist/features/promotions/schemas/index.d.ts +12 -12
  72. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  73. package/dist/features/scams/repository/scammer.repository.js +24 -2
  74. package/dist/features/scams/schemas/firestore.d.ts +24 -0
  75. package/dist/features/scams/schemas/firestore.js +30 -0
  76. package/dist/features/shipments/components/AdminShipmentEditorView.js +2 -1
  77. package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
  78. package/dist/features/shipments/repository/shipments.repository.js +23 -1
  79. package/dist/features/shipments/schemas/firestore.d.ts +14 -0
  80. package/dist/features/shipments/schemas/firestore.js +19 -0
  81. package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
  82. package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
  83. package/dist/features/status-history/index.d.ts +2 -0
  84. package/dist/features/status-history/index.js +1 -0
  85. package/dist/features/stores/repository/store.repository.d.ts +30 -2
  86. package/dist/features/stores/repository/store.repository.js +43 -4
  87. package/dist/features/stores/schemas/firestore.d.ts +29 -0
  88. package/dist/features/stores/schemas/firestore.js +31 -0
  89. package/dist/features/support/repository/support.repository.d.ts +47 -3
  90. package/dist/features/support/repository/support.repository.js +72 -12
  91. package/dist/features/support/schemas/firestore.d.ts +38 -1
  92. package/dist/features/support/schemas/firestore.js +34 -0
  93. package/dist/features/support/schemas/index.d.ts +2 -2
  94. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +84 -0
  95. package/dist/index.d.ts +7 -4
  96. package/dist/index.js +15 -2
  97. package/dist/schemas/registry.d.ts +34 -34
  98. package/dist/seed/bids-seed-data.js +8 -0
  99. package/dist/seed/factories/notification.factory.d.ts +2 -1
  100. package/dist/server-entry.d.ts +1 -0
  101. package/dist/server-entry.js +5 -0
  102. package/dist/server.d.ts +5 -1
  103. package/dist/server.js +10 -1
  104. package/dist/styles.css +58 -1
  105. package/dist/tailwind-utilities.css +1 -1
  106. package/dist/ui/components/QuantityStepper.d.ts +38 -0
  107. package/dist/ui/components/QuantityStepper.js +20 -0
  108. package/dist/ui/components/QuantityStepper.style.css +56 -0
  109. package/dist/ui/components/index.style.css +1 -0
  110. package/dist/ui/index.d.ts +2 -0
  111. package/dist/ui/index.js +1 -0
  112. package/package.json +1 -1
@@ -7,6 +7,30 @@ import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/no
7
7
  import { enqueueJob } from "../../jobs/actions/enqueue-job";
8
8
  import { serverLogger } from "../../../monitoring";
9
9
  import { decryptPii } from "../../../security/index";
10
+ import { resolveNotificationActionUrl, } from "../../../_internal/shared/features/notifications/action-url";
11
+ import { renderNotificationEmail } from "../../email/notification-templates";
12
+ /**
13
+ * A notification's `actionUrl` is a site-relative path โ€” correct for the
14
+ * in-app bell, useless in an email, where a bare `/user/orders/x` is not a
15
+ * link at all.
16
+ *
17
+ * `NEXT_PUBLIC_APP_URL` is the same source `ApiClient` uses for its own
18
+ * server-side base, so there is one answer to "what host is this" rather than
19
+ * a second one on `siteSettings` to drift from it.
20
+ *
21
+ * Falls back to returning the path unchanged rather than guessing a host: a
22
+ * wrong absolute URL sends the reader to someone else's site, while a
23
+ * relative one merely fails to be clickable. The template also drops the
24
+ * button entirely when this is undefined.
25
+ */
26
+ function absoluteActionUrl(path) {
27
+ if (!path)
28
+ return undefined;
29
+ if (/^https?:\/\//i.test(path))
30
+ return path;
31
+ const base = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/+$/, "");
32
+ return base ? `${base}${path}` : path;
33
+ }
10
34
  import { DEFAULT_NOTIFICATION_CHANNELS, meetsMinPriority, } from "../schemas/firestore";
11
35
  /**
12
36
  * Notification types whose WhatsApp send is gated behind the buyer's
@@ -24,7 +48,27 @@ const ORDER_GATED_TYPES = new Set([
24
48
  "order_cancelled",
25
49
  "refund_initiated",
26
50
  ]);
27
- /** Notification type โ†’ the flat `SiteSettingsCredentials` field holding its approved Meta template name. */
51
+ /**
52
+ * Notification type โ†’ the flat `SiteSettingsCredentials` field holding its
53
+ * approved Meta template name.
54
+ *
55
+ * ๐Ÿ›‘ **`Partial` here is CORRECT and is not the `typeToPrefsKey` mistake.**
56
+ *
57
+ * The distinction is what a missing key MEANS. A missing `typeToPrefsKey`
58
+ * entry silently ignored a user's opt-out โ€” a real setting that read as
59
+ * honoured and was not. A missing entry here means "no Meta-approved template
60
+ * exists for this type", which is a fact about the Meta account, not a bug:
61
+ * six of the 28 types have one, and the rest legitimately fall back to
62
+ * free-form (which Meta accepts inside the 24-hour customer-service window
63
+ * and rejects outside it โ€” the runner already logs that rejection).
64
+ *
65
+ * Getting six approved templates is a Meta Business review process, not a
66
+ * code change, so listing all 28 here with empty values would be a
67
+ * `Record<โ€ฆ>` that lies about coverage.
68
+ *
69
+ * The six that ARE approved are the order lifecycle, which is also the set
70
+ * gated behind the buyer's โ‚น10 addon above โ€” the two lists agree on purpose.
71
+ */
28
72
  const WHATSAPP_TEMPLATE_FIELD = {
29
73
  order_placed: "whatsappTemplateOrderPlaced",
30
74
  order_confirmed: "whatsappTemplateOrderConfirmed",
@@ -56,6 +100,46 @@ export async function listNotifications(userId, limit = 20) {
56
100
  export async function getUnreadNotificationCount(userId) {
57
101
  return notificationRepository.getUnreadCount(userId);
58
102
  }
103
+ /**
104
+ * Which portal each notification type belongs to.
105
+ *
106
+ * `Record<NotificationType, โ€ฆ>`, so a new type cannot compile without an
107
+ * answer โ€” the same discipline `typeToPrefsKey` needed after it silently
108
+ * omitted three types and ignored those users' opt-outs.
109
+ */
110
+ const TYPE_AUDIENCE = {
111
+ // Buyer-facing: the order lifecycle they are waiting on.
112
+ order_placed: "buyer",
113
+ order_confirmed: "buyer",
114
+ order_shipped: "buyer",
115
+ order_delivered: "buyer",
116
+ order_cancelled: "buyer",
117
+ refund_initiated: "buyer",
118
+ payment_review: "buyer",
119
+ emi_installment_due_soon: "buyer",
120
+ emi_installment_overdue: "buyer",
121
+ prize_won: "buyer",
122
+ prize_reveal_expired: "buyer",
123
+ product_available: "buyer",
124
+ bid_placed: "buyer",
125
+ bid_outbid: "buyer",
126
+ bid_won: "buyer",
127
+ bid_lost: "buyer",
128
+ offer_responded: "buyer",
129
+ offer_counter_accepted: "buyer",
130
+ offer_expired: "buyer",
131
+ review_replied: "buyer",
132
+ catalogue_images_stale: "buyer",
133
+ // Seller-facing.
134
+ auction_ended: "seller", // "your auction closed with no winner"
135
+ offer_received: "seller",
136
+ review_approved: "seller",
137
+ // Account-level, which lives in the user portal for everyone.
138
+ welcome: "buyer",
139
+ account_action: "buyer",
140
+ system: "buyer",
141
+ promotion: "buyer",
142
+ };
59
143
  /**
60
144
  * Create an in-app notification and fan out to enabled external channels.
61
145
  *
@@ -67,9 +151,26 @@ export async function getUnreadNotificationCount(userId) {
67
151
  * 5. The required credentials are set in siteSettings.credentials
68
152
  */
69
153
  export async function sendNotification(input) {
70
- const { userEmail, userPhone, emailHtml, ...notifInput } = input;
154
+ const { userEmail, userPhone, emailHtml, audience, ...notifInput } = input;
155
+ /*
156
+ * Fill `actionUrl` when the caller did not.
157
+ *
158
+ * It was set by 5 of 40 call sites, so 35 notifications arrived in the bell
159
+ * as text with nothing to click. Resolving it HERE rather than asking 35
160
+ * writers to remember means the destination is right by construction, and
161
+ * changing where a `bid` notification lands is one edit rather than a sweep.
162
+ *
163
+ * A caller-supplied value always wins โ€” a few writers legitimately point at
164
+ * something the relatedType cannot express (the payment-proof upload page
165
+ * rather than the order page).
166
+ */
167
+ const resolvedActionUrl = notifInput.actionUrl ??
168
+ resolveNotificationActionUrl(notifInput.relatedType, notifInput.relatedId, audience ?? TYPE_AUDIENCE[notifInput.type] ?? "buyer");
71
169
  // Always write the in-app notification first.
72
- const notification = await notificationRepository.create(notifInput);
170
+ const notification = await notificationRepository.create({
171
+ ...notifInput,
172
+ ...(resolvedActionUrl ? { actionUrl: resolvedActionUrl } : {}),
173
+ });
73
174
  // Load channel config + credentials (one Firestore read).
74
175
  let settings;
75
176
  try {
@@ -102,6 +203,20 @@ export async function sendNotification(input) {
102
203
  const resolvedPhone = userPhone ??
103
204
  (fetchedUserDoc?.phoneNumber ? decryptPii(fetchedUserDoc.phoneNumber) : undefined);
104
205
  // Map NotificationType to a NotificationTypePrefs key.
206
+ /*
207
+ * ๐Ÿ›‘ COMPLETE and compile-checked: `Record<NotificationType, โ€ฆ>`, not
208
+ * `Partial<Record<string, โ€ฆ>>`.
209
+ *
210
+ * The partial version silently omitted `emi_installment_due_soon`,
211
+ * `emi_installment_overdue` and `payment_review` โ€” and a missing key means
212
+ * `typeKey` is undefined, so the opt-out check below never fires and the
213
+ * user's preference is IGNORED. A silently-ignored opt-out is worse than a
214
+ * missing feature: the toggle is right there, it reads as honoured, and
215
+ * mail keeps arriving.
216
+ *
217
+ * Typed on the union so the next type added cannot compile without an
218
+ * answer to "which toggle silences this".
219
+ */
105
220
  const typeToPrefsKey = {
106
221
  order_placed: "orderUpdates", order_confirmed: "orderUpdates",
107
222
  order_shipped: "orderUpdates", order_delivered: "orderUpdates",
@@ -116,6 +231,19 @@ export async function sendNotification(input) {
116
231
  product_available: "system",
117
232
  prize_won: "orderUpdates",
118
233
  prize_reveal_expired: "orderUpdates",
234
+ // Money owed on an order. Under orderUpdates rather than a new toggle:
235
+ // an EMI installment IS an order update, and inventing an eighth
236
+ // preference key means an existing user's saved prefs have no value for
237
+ // it โ€” which reads as "off" or "on" depending on the default and is a
238
+ // migration nobody asked for.
239
+ emi_installment_due_soon: "orderUpdates",
240
+ emi_installment_overdue: "orderUpdates",
241
+ // The manual-payment review queue โ€” the buyer's proof was verified,
242
+ // rejected, or needs re-uploading. Squarely an order update.
243
+ payment_review: "orderUpdates",
244
+ // "Your catalogue photos are going stale" โ€” housekeeping about the user's
245
+ // own account, which is what `system` covers.
246
+ catalogue_images_stale: "system",
119
247
  };
120
248
  const typeKey = typeToPrefsKey[type];
121
249
  // If the user has explicitly disabled this notification type, skip all external channels.
@@ -138,7 +266,24 @@ export async function sendNotification(input) {
138
266
  await withChannelRetry("email", () => emailProvider.send({
139
267
  to: resolvedEmail,
140
268
  subject: title,
141
- html: emailHtml ?? `<p>${message}</p>`,
269
+ /*
270
+ * A real template, not `<p>{message}</p>`.
271
+ *
272
+ * `emailHtml` existed on the input from the day the channel was
273
+ * built and NOT ONE of the 40 call sites passed it, so all 28
274
+ * types shipped a bare paragraph โ€” no framing, no branding,
275
+ * nothing to click. Rendering here rather than asking 40 writers
276
+ * to remember is what makes that structurally impossible to
277
+ * regress: a caller-supplied `emailHtml` still wins, for the
278
+ * handful of flows with genuinely bespoke mail.
279
+ */
280
+ html: emailHtml ??
281
+ renderNotificationEmail(type, {
282
+ title,
283
+ message,
284
+ siteName: fromName,
285
+ actionUrl: absoluteActionUrl(resolvedActionUrl),
286
+ }),
142
287
  text: message,
143
288
  }));
144
289
  emailStatus = "sent";
@@ -14,6 +14,7 @@ import { buildBidDetailFields, bidStatusBadge } from "../../auctions/components/
14
14
  import { toRecordArray, toRelativeDate, toCurrency, toStringValue, } from "../hooks/useAdminListingData";
15
15
  import { DataListingView } from "./DataListingView";
16
16
  import { apiClient } from "../../../http";
17
+ import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
17
18
  export function AdminBidsView({ children, ...props }) {
18
19
  const queryClient = useQueryClient();
19
20
  const { showToast } = useToast();
@@ -98,7 +99,12 @@ export function AdminBidsView({ children, ...props }) {
98
99
  const detailBadge = detail ? bidStatusBadge(detail.status) : null;
99
100
  return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: toStringValue(detail?.detail?.productTitle, "Bid"), badges: detailBadge ? [{ label: detailBadge.label }] : undefined, fields: detail
100
101
  ? buildBidDetailFields(detail.detail, "admin")
101
- : undefined }), _jsx(ConfirmDeleteModal, { isOpen: cancelOpen, onClose: () => {
102
+ : undefined, extra:
103
+ // A bid moves through more states than any record a buyer owns, and
104
+ // every one of them happened inside a settlement batch with nothing
105
+ // recording it until W18. Absent history renders the empty label,
106
+ // never a step invented from `updatedAt`.
107
+ _jsx(RecordStatusTimeline, { entries: detail?.detail?.statusHistory, truncatedCount: detail?.detail?.statusHistoryTruncated }) }), _jsx(ConfirmDeleteModal, { isOpen: cancelOpen, onClose: () => {
102
108
  setCancelOpen(false);
103
109
  setSelectedRow(null);
104
110
  }, onConfirm: () => {
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
2
3
  import { type ScammerStatus } from "../../scams/schemas/firestore";
3
4
  export interface AdminScammerEditorViewProps {
4
5
  open: boolean;
@@ -11,7 +12,10 @@ export interface AdminScammerEditorViewProps {
11
12
  upiIds?: string[];
12
13
  currentStatus?: ScammerStatus;
13
14
  verificationNote?: string;
15
+ /** The profile's own decision history. Absent on profiles predating W18. */
16
+ statusHistory?: StatusChangeEntry[];
17
+ statusHistoryTruncated?: number;
14
18
  reportedBy?: string;
15
19
  reportedByAnon?: boolean;
16
20
  }
17
- export declare function AdminScammerEditorView({ open, onClose, scammerId, displayNames, scamType, description, phones, upiIds, currentStatus, verificationNote, reportedBy, reportedByAnon, }: AdminScammerEditorViewProps): React.JSX.Element;
21
+ export declare function AdminScammerEditorView({ open, onClose, scammerId, displayNames, scamType, description, phones, upiIds, currentStatus, verificationNote, statusHistory, statusHistoryTruncated, reportedBy, reportedByAnon, }: AdminScammerEditorViewProps): React.JSX.Element;
@@ -6,6 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
6
6
  import { Button, Code, Div, FormActions, HorizontalRule, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, useToast } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
9
+ import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
9
10
  import { ScammerStatusValues, } from "../../scams/schemas/firestore";
10
11
  import { SCAMMER_STATUS_BADGE, SCAMMER_STATUS_LABEL, SCAMMER_STATUS_OPTIONS, } from "../../scams/constants/scammer-status-display";
11
12
  const __P = {
@@ -13,7 +14,7 @@ const __P = {
13
14
  p4: "p-[var(--appkit-space-4)]",
14
15
  };
15
16
  const CLS_SECTION_LABEL = "text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] uppercase tracking-wide";
16
- export function AdminScammerEditorView({ open, onClose, scammerId, displayNames = [], scamType, description, phones = [], upiIds = [], currentStatus, verificationNote, reportedBy, reportedByAnon, }) {
17
+ export function AdminScammerEditorView({ open, onClose, scammerId, displayNames = [], scamType, description, phones = [], upiIds = [], currentStatus, verificationNote, statusHistory, statusHistoryTruncated, reportedBy, reportedByAnon, }) {
17
18
  const queryClient = useQueryClient();
18
19
  const { showToast } = useToast();
19
20
  const [status, setStatus] = React.useState(currentStatus ?? "pending_review");
@@ -53,5 +54,5 @@ export function AdminScammerEditorView({ open, onClose, scammerId, displayNames
53
54
  showToast(err?.message ?? "Failed to delete profile.", "error");
54
55
  },
55
56
  });
56
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: displayNames.length > 0 ? displayNames[0] : "Scammer Profile", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${SCAMMER_STATUS_BADGE[currentStatus ?? ScammerStatusValues.PENDING_REVIEW]}`, rounded: "full", padding: "pill-sm-tall", children: SCAMMER_STATUS_LABEL[currentStatus ?? ScammerStatusValues.PENDING_REVIEW] }), scamType && (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: scamType.replace(/_/g, " ") }))] }), displayNames.length > 0 && (_jsxs(Div, { children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Names / Aliases" }), _jsx(Row, { wrap: true, gap: "xs", children: displayNames.map((name, i) => (_jsx(Span, { size: "xs", rounded: "full", surface: "subtle", padding: "pill-sm", children: name }, i))) })] })), (phones.length > 0 || upiIds.length > 0) && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [phones.length > 0 && (_jsxs(Div, { className: "mb-2", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Phone numbers" }), _jsx(Row, { wrap: true, gap: "xs", children: phones.map((p, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: p }, i))) })] })), upiIds.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "UPI IDs" }), _jsx(Row, { wrap: true, gap: "xs", children: upiIds.map((u, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: u }, i))) })] }))] })), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), _jsxs(Div, { textSize: "xs", color: "muted", children: ["Reported by:", " ", _jsx(Span, { weight: "medium", color: "muted", children: reportedByAnon ? "Anonymous" : (reportedBy ?? "Unknown") })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: SCAMMER_STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Verification note (internal)" }), _jsx(Textarea, { value: note, onChange: (e) => setNote(e.target.value), rows: 3, placeholder: "e.g. Verified via 3 independent reports\u2026" })] }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "danger", size: "sm", disabled: !scammerId, isLoading: deleteMutation.isPending, onClick: () => deleteMutation.mutate(), children: "Delete" }), _jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", isLoading: updateMutation.isPending, disabled: !scammerId || updateMutation.isPending, onClick: () => updateMutation.mutate(), children: "Save changes" })] })] }) }));
57
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: displayNames.length > 0 ? displayNames[0] : "Scammer Profile", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsx(RecordStatusTimeline, { title: "Decision history", entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${SCAMMER_STATUS_BADGE[currentStatus ?? ScammerStatusValues.PENDING_REVIEW]}`, rounded: "full", padding: "pill-sm-tall", children: SCAMMER_STATUS_LABEL[currentStatus ?? ScammerStatusValues.PENDING_REVIEW] }), scamType && (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: scamType.replace(/_/g, " ") }))] }), displayNames.length > 0 && (_jsxs(Div, { children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Names / Aliases" }), _jsx(Row, { wrap: true, gap: "xs", children: displayNames.map((name, i) => (_jsx(Span, { size: "xs", rounded: "full", surface: "subtle", padding: "pill-sm", children: name }, i))) })] })), (phones.length > 0 || upiIds.length > 0) && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [phones.length > 0 && (_jsxs(Div, { className: "mb-2", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Phone numbers" }), _jsx(Row, { wrap: true, gap: "xs", children: phones.map((p, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: p }, i))) })] })), upiIds.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "UPI IDs" }), _jsx(Row, { wrap: true, gap: "xs", children: upiIds.map((u, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: u }, i))) })] }))] })), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), _jsxs(Div, { textSize: "xs", color: "muted", children: ["Reported by:", " ", _jsx(Span, { weight: "medium", color: "muted", children: reportedByAnon ? "Anonymous" : (reportedBy ?? "Unknown") })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: SCAMMER_STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Verification note (internal)" }), _jsx(Textarea, { value: note, onChange: (e) => setNote(e.target.value), rows: 3, placeholder: "e.g. Verified via 3 independent reports\u2026" })] }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "danger", size: "sm", disabled: !scammerId, isLoading: deleteMutation.isPending, onClick: () => deleteMutation.mutate(), children: "Delete" }), _jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", isLoading: updateMutation.isPending, disabled: !scammerId || updateMutation.isPending, onClick: () => updateMutation.mutate(), children: "Save changes" })] })] }) }));
57
58
  }
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
2
3
  interface TicketMessageClient {
3
4
  id?: string;
4
5
  authorId?: string;
@@ -28,6 +29,9 @@ export interface AdminSupportTicketDetailViewProps {
28
29
  orderId?: string;
29
30
  /** ST-6 โ€” current relatedParties snapshot from the ticket document. */
30
31
  relatedParties?: RelatedPartiesClient;
32
+ /** The ticket's `statusHistory`. Absent on tickets predating W18. */
33
+ statusHistory?: StatusChangeEntry[];
34
+ statusHistoryTruncated?: number;
31
35
  }
32
- export declare function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages, internalNotes, orderId, relatedParties, }: AdminSupportTicketDetailViewProps): React.JSX.Element;
36
+ export declare function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages, internalNotes, orderId, relatedParties, statusHistory, statusHistoryTruncated, }: AdminSupportTicketDetailViewProps): React.JSX.Element;
33
37
  export {};
@@ -6,6 +6,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
6
6
  import { Button, Div, FormActions, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ADMIN_ENDPOINTS, SUPPORT_ENDPOINTS } from "../../../constants/api-endpoints";
9
+ import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
9
10
  const __P = {
10
11
  p3: "p-[var(--appkit-space-3)]",
11
12
  p4: "p-[var(--appkit-space-4)]",
@@ -38,7 +39,7 @@ const ROLE_LABEL = {
38
39
  support: "Support",
39
40
  admin: "Admin",
40
41
  };
41
- export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages = [], internalNotes, orderId, relatedParties, }) {
42
+ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages = [], internalNotes, orderId, relatedParties, statusHistory, statusHistoryTruncated, }) {
42
43
  const queryClient = useQueryClient();
43
44
  const { showToast } = useToast();
44
45
  const [status, setStatus] = React.useState(currentStatus ?? "open");
@@ -259,7 +260,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
259
260
  showToast(err?.message ?? "Failed to update order items.", "error");
260
261
  },
261
262
  });
262
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsx(Select, { label: "Priority", options: PRIORITY_OPTIONS, value: priority, onValueChange: setPriority }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Internal notes (staff only)" }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), rows: 3, placeholder: "Notes visible only to admins and employees\u2026" })] }), _jsxs(Stack, { padding: "sm", gap: "sm", rounded: "lg", border: "default", children: [_jsx(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Linked parties" }), _jsx(Text, { size: "xs", color: "muted", children: "Tag the buyer / store / order / product / bid this ticket concerns." }), [
263
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsx(Select, { label: "Priority", options: PRIORITY_OPTIONS, value: priority, onValueChange: setPriority }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Internal notes (staff only)" }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), rows: 3, placeholder: "Notes visible only to admins and employees\u2026" })] }), _jsxs(Stack, { padding: "sm", gap: "sm", rounded: "lg", border: "default", children: [_jsx(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Linked parties" }), _jsx(Text, { size: "xs", color: "muted", children: "Tag the buyer / store / order / product / bid this ticket concerns." }), [
263
264
  ["userId", "User slug"],
264
265
  ["storeId", "Store slug"],
265
266
  ["orderId", "Order ID"],
@@ -4,7 +4,18 @@
4
4
  */
5
5
  import type { AboutContentDocument } from "../../about/schemas/firestore";
6
6
  export declare const ADMIN_CHECKOUT_BYPASS_FLAG_KEY: "adminCheckoutBypass";
7
- export type NotificationType = "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "promotion" | "system" | "welcome" | "account_action" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review";
7
+ export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
8
+ export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
9
+ /**
10
+ * What a notification points AT. Drives the shared `actionUrl` resolver, so a
11
+ * value here without a per-record page in some role is a notification that
12
+ * lands nowhere.
13
+ *
14
+ * Runtime array for the same reason as the type union above โ€” and
15
+ * `catalogueItem` was being sent with an `as never` cast rather than added.
16
+ */
17
+ export declare const NOTIFICATION_RELATED_TYPE_VALUES: readonly ["order", "product", "bid", "review", "blog", "user", "offer", "support_ticket", "scammer", "catalogueItem", "payout", "store"];
18
+ export type NotificationRelatedType = (typeof NOTIFICATION_RELATED_TYPE_VALUES)[number];
8
19
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
9
20
  export type NotificationPriority = "low" | "normal" | "high";
10
21
  export interface NotificationDocument extends BaseDocument {
@@ -19,7 +30,7 @@ export interface NotificationDocument extends BaseDocument {
19
30
  isRead: boolean;
20
31
  readAt?: Date;
21
32
  relatedId?: string;
22
- relatedType?: "order" | "product" | "bid" | "review" | "blog" | "user" | "offer" | "support_ticket" | "scammer";
33
+ relatedType?: NotificationRelatedType;
23
34
  /** Outcome of the async whatsappNotify job for this notification's WhatsApp send, if one was enqueued. */
24
35
  whatsappStatus?: "queued" | "sent" | "failed" | "skipped";
25
36
  /** jobs/{id} doc that ran (or is running) the WhatsApp send for this notification. */
@@ -82,7 +93,7 @@ export type NotificationUpdateInput = Partial<Pick<NotificationDocument, "isRead
82
93
  export declare const notificationQueryHelpers: {
83
94
  readonly byUser: (userId: string) => readonly ["userId", "==", string];
84
95
  readonly unread: (userId: string) => readonly [readonly ["userId", "==", string], readonly ["isRead", "==", false]];
85
- readonly byType: (type: NotificationType) => readonly ["type", "==", NotificationType];
96
+ readonly byType: (type: NotificationType) => readonly ["type", "==", "system" | "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "account_action" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale"];
86
97
  };
87
98
  export interface ChatRoomDocument extends BaseDocument {
88
99
  buyerId: string;
@@ -8,6 +8,85 @@ import { DEFAULT_AUCTION_BID_INCREMENT_TIERS } from "../../../_internal/shared/f
8
8
  // single occurrence in schemas. Splitting the literal makes the substring
9
9
  // audit-invisible while keeping the runtime key correct.
10
10
  export const ADMIN_CHECKOUT_BYPASS_FLAG_KEY = "adminCheckoutBypass";
11
+ /*
12
+ * ๐Ÿ›‘ The ONE notification-type union. Four existed until 2026-08-26:
13
+ *
14
+ * ยท this one (27) โ€” what `NotificationDocument.type` actually holds
15
+ * ยท `constants/notification-types.ts` (9) โ€” fed the admin filter chips and
16
+ * the per-channel allow-list, so the admin list could not filter 18 of
17
+ * the 27 real types and an admin literally could not allow-list
18
+ * `offer_received` or `payment_review` for email or WhatsApp
19
+ * ยท `features/account/types/index.ts` (5: order|offer|promo|system|message)
20
+ * โ€” a different vocabulary entirely, on a `UserNotification` type with
21
+ * zero real consumers
22
+ * ยท `seed/factories/notification.factory.ts` (9) โ€” and THIS is the one
23
+ * `appkit/src/index.ts` exported publicly as `NotificationType`, so every
24
+ * external consumer got the seed factory's guess
25
+ *
26
+ * Between them they invented FOUR values no notification has ever had:
27
+ * `review_posted`, `payout_processed`, `review_received`, `payout_completed`.
28
+ * Note the first two and the last two are different spellings of the same two
29
+ * imagined concepts, which is how independent copies drift.
30
+ *
31
+ * A runtime array with the type derived from it, so a Zod enum, a chip list
32
+ * and a `Record<NotificationType, โ€ฆ>` all resolve here and a new value is one
33
+ * edit. Same shape as `ALL_LISTING_TYPES` after Root Cause #61.
34
+ */
35
+ export const NOTIFICATION_TYPE_VALUES = [
36
+ "order_placed",
37
+ "order_confirmed",
38
+ "order_shipped",
39
+ "order_delivered",
40
+ "order_cancelled",
41
+ "bid_placed",
42
+ "bid_outbid",
43
+ "bid_won",
44
+ "bid_lost",
45
+ "auction_ended",
46
+ "review_approved",
47
+ "review_replied",
48
+ "product_available",
49
+ "promotion",
50
+ "system",
51
+ "welcome",
52
+ "account_action",
53
+ "offer_received",
54
+ "offer_responded",
55
+ "offer_expired",
56
+ "offer_counter_accepted",
57
+ "refund_initiated",
58
+ "prize_won",
59
+ "prize_reveal_expired",
60
+ "emi_installment_due_soon",
61
+ "emi_installment_overdue",
62
+ "payment_review",
63
+ // Was being SENT with an `as never` cast on the whole payload, which is how
64
+ // it stayed off this union: the cast silenced the one signal that would
65
+ // have said so, and with it every other field's type-check on that call.
66
+ "catalogue_images_stale",
67
+ ];
68
+ /**
69
+ * What a notification points AT. Drives the shared `actionUrl` resolver, so a
70
+ * value here without a per-record page in some role is a notification that
71
+ * lands nowhere.
72
+ *
73
+ * Runtime array for the same reason as the type union above โ€” and
74
+ * `catalogueItem` was being sent with an `as never` cast rather than added.
75
+ */
76
+ export const NOTIFICATION_RELATED_TYPE_VALUES = [
77
+ "order",
78
+ "product",
79
+ "bid",
80
+ "review",
81
+ "blog",
82
+ "user",
83
+ "offer",
84
+ "support_ticket",
85
+ "scammer",
86
+ "catalogueItem",
87
+ "payout",
88
+ "store",
89
+ ];
11
90
  export const NOTIFICATIONS_COLLECTION = "notifications";
12
91
  export const NOTIFICATIONS_INDEXED_FIELDS = [
13
92
  "userId",
@@ -7,6 +7,14 @@ import { BaseRepository, parseSieveDateValue } from "../../../providers/db-fireb
7
7
  import type { DocumentReference, WriteBatch } from "firebase-admin/firestore";
8
8
  import type { SieveModel, FirebaseSieveResult } from "../../../providers/db-firebase";
9
9
  import type { BidDocument, BidCreateInput, BidUpdateInput, BidAdminUpdateInput, BidStatus } from "../schemas";
10
+ import { type HistoryActor } from "../../../_internal/shared/history/index";
11
+ /** Who/why for a bid write that lands in `statusHistory`. */
12
+ export interface BidWriteContext {
13
+ actor?: HistoryActor;
14
+ trigger?: string;
15
+ reason?: string;
16
+ note?: string;
17
+ }
10
18
  export declare class BidRepository extends BaseRepository<BidDocument> {
11
19
  constructor();
12
20
  /** Override mapDoc to auto-decrypt PII on every bid read */
@@ -49,7 +57,7 @@ export declare class BidRepository extends BaseRepository<BidDocument> {
49
57
  ref: DocumentReference;
50
58
  data: BidDocument;
51
59
  } | null>;
52
- markWon(batch: WriteBatch, ref: DocumentReference): void;
60
+ markWon(batch: WriteBatch, ref: DocumentReference, ctx?: BidWriteContext, prior?: BidDocument | null): void;
53
61
  /**
54
62
  * Link a won bid to the order it eventually became.
55
63
  *
@@ -58,29 +66,34 @@ export declare class BidRepository extends BaseRepository<BidDocument> {
58
66
  * /user/bids could not link anywhere, and nothing could tell a settled win
59
67
  * from an unpaid one.
60
68
  */
61
- attachOrder(bidId: string, orderId: string): Promise<void>;
69
+ attachOrder(bidId: string, orderId: string, ctx?: BidWriteContext, prior?: BidDocument | null): Promise<void>;
62
70
  /**
63
71
  * The winner never paid within their checkout window. Distinct from "lost"
64
72
  * (outbid) โ€” this bidder won and then defaulted, which a seller may want to
65
73
  * act on.
66
74
  */
67
- markForfeited(bidId: string): Promise<void>;
68
- markLost(batch: WriteBatch, ref: DocumentReference): void;
75
+ markForfeited(bidId: string, ctx?: BidWriteContext, prior?: BidDocument | null): Promise<void>;
76
+ markLost(batch: WriteBatch, ref: DocumentReference, ctx?: BidWriteContext, prior?: BidDocument | null): void;
69
77
  /**
70
78
  * Batched "everyone else lost", for callers that are not already inside a
71
79
  * batch of their own โ€” a Buy Now purchase ends the auction outside the
72
80
  * settlement job, and the losing bids can be any number, so this writes them
73
81
  * in Firestore-batch chunks rather than one round-trip per bid.
74
82
  */
75
- markManyLost(refs: readonly DocumentReference[]): Promise<void>;
83
+ markManyLost(entries: readonly (DocumentReference | {
84
+ ref: DocumentReference;
85
+ data?: BidDocument;
86
+ })[], ctx?: BidWriteContext): Promise<void>;
76
87
  /**
77
88
  * A pending Buy Now claim that never became a sale. Not `forfeited` โ€” that
78
89
  * status means "won, then defaulted", and a lapsed claim never won anything;
79
90
  * the auction it was placed against carried on untouched.
80
91
  */
81
- markCancelled(bidId: string): Promise<void>;
82
- markOutbid(batch: WriteBatch, ref: DocumentReference): void;
83
- markWinning(batch: WriteBatch, ref: DocumentReference): void;
92
+ markCancelled(bidId: string, ctx?: BidWriteContext, prior?: BidDocument | null): Promise<void>;
93
+ markOutbid(batch: WriteBatch, ref: DocumentReference, ctx?: BidWriteContext, prior?: BidDocument | null): void;
94
+ markWinning(batch: WriteBatch, ref: DocumentReference, ctx?: BidWriteContext, prior?: BidDocument | null): void;
95
+ /** Append a timeline entry when the patch touches a tracked field. */
96
+ private withBidHistory;
84
97
  /**
85
98
  * Find current winning bid for a product
86
99
  */