@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.
- package/dist/_internal/server/features/bundles/actions.js +7 -0
- package/dist/_internal/server/features/checkout/actions.js +24 -16
- package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +5 -0
- package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
- package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
- package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
- package/dist/_internal/shared/actions/action-registry.js +41 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
- package/dist/_internal/shared/checkout/order-math.js +29 -6
- package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
- package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
- package/dist/_internal/shared/features/cart/config.d.ts +2 -1
- package/dist/_internal/shared/features/cart/config.js +2 -1
- package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
- package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
- package/dist/_internal/shared/features/notifications/action-url.js +111 -0
- package/dist/_internal/shared/features/products/schema.d.ts +8 -8
- package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
- package/dist/_internal/shared/history/index.d.ts +1 -1
- package/dist/_internal/shared/history/types.d.ts +16 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +1 -0
- package/dist/constants/field-names.d.ts +12 -0
- package/dist/constants/field-names.js +12 -0
- package/dist/constants/notification-types.d.ts +38 -10
- package/dist/constants/notification-types.js +39 -28
- package/dist/features/account/types/index.d.ts +1 -10
- package/dist/features/admin/actions/admin-actions.js +13 -3
- package/dist/features/admin/actions/notification-actions.d.ts +12 -0
- package/dist/features/admin/actions/notification-actions.js +149 -4
- package/dist/features/admin/components/AdminBidsView.js +7 -1
- package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
- package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
- package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
- package/dist/features/admin/schemas/firestore.d.ts +14 -3
- package/dist/features/admin/schemas/firestore.js +79 -0
- package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
- package/dist/features/auctions/repository/bid.repository.js +57 -33
- package/dist/features/auctions/schemas/firestore.d.ts +27 -0
- package/dist/features/auctions/schemas/firestore.js +21 -0
- package/dist/features/auth/schemas/index.d.ts +2 -2
- package/dist/features/cart/actions/cart-actions.d.ts +16 -0
- package/dist/features/cart/actions/cart-actions.js +26 -12
- package/dist/features/cart/components/CartDrawer.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +132 -8
- package/dist/features/cart/schemas/firestore.js +22 -0
- package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
- package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
- package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
- package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
- package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
- package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
- package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
- package/dist/features/catalogue/schemas/firestore.js +12 -0
- package/dist/features/categories/schemas/index.d.ts +2 -2
- package/dist/features/email/index.d.ts +1 -1
- package/dist/features/email/index.js +5 -1
- package/dist/features/email/notification-templates.d.ts +35 -0
- package/dist/features/email/notification-templates.js +96 -0
- package/dist/features/messages/schemas/index.d.ts +6 -6
- package/dist/features/orders/schemas/index.d.ts +14 -14
- package/dist/features/payments/repository/payout.repository.d.ts +18 -5
- package/dist/features/payments/repository/payout.repository.js +45 -19
- package/dist/features/payments/schemas/firestore.d.ts +33 -1
- package/dist/features/payments/schemas/firestore.js +39 -0
- package/dist/features/payments/schemas/index.d.ts +12 -12
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/promotions/schemas/index.d.ts +12 -12
- package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
- package/dist/features/scams/repository/scammer.repository.js +24 -2
- package/dist/features/scams/schemas/firestore.d.ts +24 -0
- package/dist/features/scams/schemas/firestore.js +30 -0
- package/dist/features/shipments/components/AdminShipmentEditorView.js +2 -1
- package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
- package/dist/features/shipments/repository/shipments.repository.js +23 -1
- package/dist/features/shipments/schemas/firestore.d.ts +14 -0
- package/dist/features/shipments/schemas/firestore.js +19 -0
- package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
- package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
- package/dist/features/status-history/index.d.ts +2 -0
- package/dist/features/status-history/index.js +1 -0
- package/dist/features/stores/repository/store.repository.d.ts +30 -2
- package/dist/features/stores/repository/store.repository.js +43 -4
- package/dist/features/stores/schemas/firestore.d.ts +29 -0
- package/dist/features/stores/schemas/firestore.js +31 -0
- package/dist/features/support/repository/support.repository.d.ts +47 -3
- package/dist/features/support/repository/support.repository.js +72 -12
- package/dist/features/support/schemas/firestore.d.ts +38 -1
- package/dist/features/support/schemas/firestore.js +34 -0
- package/dist/features/support/schemas/index.d.ts +2 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +84 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +15 -2
- package/dist/schemas/registry.d.ts +34 -34
- package/dist/seed/bids-seed-data.js +8 -0
- package/dist/seed/factories/notification.factory.d.ts +2 -1
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +5 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.js +10 -1
- package/dist/styles.css +58 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/QuantityStepper.d.ts +38 -0
- package/dist/ui/components/QuantityStepper.js +20 -0
- package/dist/ui/components/QuantityStepper.style.css +56 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- 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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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?:
|
|
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", "==",
|
|
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(
|
|
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
|
*/
|