@mohasinac/appkit 3.5.1 → 3.5.3
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/checkout/actions.d.ts +0 -13
- package/dist/_internal/server/features/checkout/actions.js +88 -67
- package/dist/_internal/server/jobs/core/jobRunners.d.ts +3 -2
- package/dist/_internal/server/jobs/core/onJobCreated.js +2 -2
- package/dist/features/admin/components/AdminNotificationsView.js +30 -5
- package/dist/features/admin/components/AdminUsersView.js +102 -26
- package/dist/features/homepage/components/CustomCardsSection.js +1 -1
- package/dist/features/homepage/components/SectionCarousel.js +1 -1
- package/dist/features/jobs/actions/enqueue-job.d.ts +2 -1
- package/dist/features/jobs/repository/jobs.repository.js +3 -3
- package/dist/features/jobs/schemas/firestore.d.ts +20 -2
- package/dist/features/jobs/schemas/firestore.js +12 -0
- package/dist/seed/addresses-seed-data.js +8 -99
- package/dist/seed/categories-seed-data.js +103 -2082
- package/dist/seed/homepage-sections-seed-data.js +9 -9
- package/dist/seed/products-art-seed-data.d.ts +1 -10
- package/dist/seed/products-art-seed-data.js +13 -122
- package/dist/seed/products-auctions-seed-data.js +39 -747
- package/dist/seed/products-classifieds-seed-data.d.ts +1 -9
- package/dist/seed/products-classifieds-seed-data.js +13 -195
- package/dist/seed/products-digital-codes-seed-data.d.ts +1 -9
- package/dist/seed/products-digital-codes-seed-data.js +13 -202
- package/dist/seed/products-live-items-seed-data.d.ts +1 -12
- package/dist/seed/products-live-items-seed-data.js +13 -188
- package/dist/seed/products-preorders-seed-data.js +18 -149
- package/dist/seed/products-prize-draws-seed-data.d.ts +1 -16
- package/dist/seed/products-prize-draws-seed-data.js +13 -385
- package/dist/seed/products-standard-seed-data.js +152 -1331
- package/dist/seed/products-stickers-seed-data.d.ts +1 -10
- package/dist/seed/products-stickers-seed-data.js +13 -122
- package/dist/seed/site-settings-seed-data.js +106 -108
- package/dist/seed/store-addresses-seed-data.js +14 -46
- package/dist/seed/stores-seed-data.js +13 -205
- package/dist/styles.css +19 -18
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/BulkActionBar.style.css +4 -4
- package/dist/ui/components/Button.style.css +14 -13
- package/dist/ui/components/HorizontalScroller.js +93 -18
- package/dist/ui/components/ListingToolbar.js +4 -4
- package/package.json +1 -1
|
@@ -59,17 +59,4 @@ export interface VerifyAndPlaceRazorpayOrderInput {
|
|
|
59
59
|
*/
|
|
60
60
|
outOfStockPolicy?: OutOfStockPolicy;
|
|
61
61
|
}
|
|
62
|
-
/**
|
|
63
|
-
* Place order(s) from the user's cart after a Razorpay payment is verified.
|
|
64
|
-
* Mirrors the existing /api/payment/verify route handler.
|
|
65
|
-
*
|
|
66
|
-
* Consumers must authenticate the user before calling. The action performs:
|
|
67
|
-
* 1. HMAC signature verification (rejects forged callbacks)
|
|
68
|
-
* 2. Cart re-validation against current product prices/stock
|
|
69
|
-
* 3. Amount cross-check against the Razorpay order record
|
|
70
|
-
* 4. Atomic stock decrement + cart clear via unitOfWork batch
|
|
71
|
-
* 5. Multi-coupon pro-rating per order group
|
|
72
|
-
* 6. order_placed notifications (buyer + seller)
|
|
73
|
-
* 7. Confirmation email + RTDB success signal (both fire-and-forget)
|
|
74
|
-
*/
|
|
75
62
|
export declare function verifyAndPlaceRazorpayOrderAction(input: VerifyAndPlaceRazorpayOrderInput): Promise<CheckoutOrderResult>;
|
|
@@ -709,6 +709,88 @@ export async function attachPaymentAction(input) {
|
|
|
709
709
|
* 6. order_placed notifications (buyer + seller)
|
|
710
710
|
* 7. Confirmation email + RTDB success signal (both fire-and-forget)
|
|
711
711
|
*/
|
|
712
|
+
/**
|
|
713
|
+
* Auto-refund the value of items dropped from a Razorpay checkout under the
|
|
714
|
+
* "skip_items" out-of-stock policy. Razorpay captures payment for the FULL
|
|
715
|
+
* cart before the stock check runs, so anything not placed as an order must
|
|
716
|
+
* be given back. Extracted from `verifyAndPlaceRazorpayOrderAction` — same
|
|
717
|
+
* behavior, kept as a named step so the parent function stays readable.
|
|
718
|
+
*
|
|
719
|
+
* Awaited by the caller (this moves money, never fire-and-forget). On
|
|
720
|
+
* failure the orders themselves are NOT rolled back (already validly
|
|
721
|
+
* created and paid); the failure is surfaced via `order.refundPending` + an
|
|
722
|
+
* admin notification fan-out, never silently dropped (Rule #8).
|
|
723
|
+
*/
|
|
724
|
+
async function refundDroppedItemsForRazorpayCheckout(input) {
|
|
725
|
+
const { unavailablePaid, orderIds, productByIdPaid, razorpayPaymentId } = input;
|
|
726
|
+
if (unavailablePaid.length === 0 || orderIds.length === 0)
|
|
727
|
+
return;
|
|
728
|
+
const droppedValueInPaise = unavailablePaid.reduce((sum, u) => sum + (productByIdPaid.get(u.productId)?.price ?? 0) * u.requestedQty, 0);
|
|
729
|
+
if (droppedValueInPaise <= 0)
|
|
730
|
+
return;
|
|
731
|
+
const primaryOrderId = orderIds[0];
|
|
732
|
+
try {
|
|
733
|
+
const primaryOrder = await unitOfWork.orders.findById(primaryOrderId);
|
|
734
|
+
// Cap defensively at the primary order's total — processRefundAction
|
|
735
|
+
// rejects any amount exceeding order.totalPrice, and a multi-order
|
|
736
|
+
// batch's dropped-items value isn't cleanly attributable to a single
|
|
737
|
+
// order (Razorpay refunds are keyed by paymentId, not orderId; the
|
|
738
|
+
// FIRST order created in this batch is used as the refund's book-
|
|
739
|
+
// keeping anchor).
|
|
740
|
+
const refundAmount = primaryOrder
|
|
741
|
+
? Math.min(droppedValueInPaise, primaryOrder.totalPrice)
|
|
742
|
+
: droppedValueInPaise;
|
|
743
|
+
const refundResult = await processRefundAction({
|
|
744
|
+
orderId: primaryOrderId,
|
|
745
|
+
type: "partial",
|
|
746
|
+
amountInPaise: refundAmount,
|
|
747
|
+
reason: `Automatic refund — ${unavailablePaid.length} item(s) unavailable at checkout: ${unavailablePaid.map((u) => u.productTitle).join(", ")}`,
|
|
748
|
+
method: "razorpay",
|
|
749
|
+
razorpayPaymentId,
|
|
750
|
+
confirmIrrevocable: true,
|
|
751
|
+
refundedBy: "system:checkout-auto-refund",
|
|
752
|
+
});
|
|
753
|
+
if (!refundResult.ok) {
|
|
754
|
+
throw new Error(refundResult.error ?? "Automatic refund failed");
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
catch (refundErr) {
|
|
758
|
+
void normalizeError(refundErr);
|
|
759
|
+
serverLogger.warn("verifyAndPlaceRazorpayOrderAction: automatic partial refund for dropped items failed — flagging for manual follow-up", {
|
|
760
|
+
orderId: primaryOrderId,
|
|
761
|
+
droppedValueInPaise,
|
|
762
|
+
err: refundErr instanceof Error ? refundErr.message : String(refundErr),
|
|
763
|
+
});
|
|
764
|
+
await unitOfWork.orders
|
|
765
|
+
.update(primaryOrderId, { refundPending: true })
|
|
766
|
+
.catch((updErr) => {
|
|
767
|
+
void normalizeError(updErr);
|
|
768
|
+
serverLogger.error("verifyAndPlaceRazorpayOrderAction: failed to flag refundPending after auto-refund failure", { orderId: primaryOrderId, err: updErr instanceof Error ? updErr.message : String(updErr) });
|
|
769
|
+
});
|
|
770
|
+
// Best-effort admin fan-out — mirrors onScamReportCreate's employee
|
|
771
|
+
// notification pattern. Never allowed to throw past this point.
|
|
772
|
+
try {
|
|
773
|
+
const admins = await userRepository.list({ filters: "role==admin", page: 1, pageSize: 100 });
|
|
774
|
+
await Promise.all(admins.items
|
|
775
|
+
.filter((a) => !!a.id)
|
|
776
|
+
.map((admin) => sendNotification({
|
|
777
|
+
userId: admin.id,
|
|
778
|
+
type: "system",
|
|
779
|
+
priority: "high",
|
|
780
|
+
title: "Automatic refund failed",
|
|
781
|
+
message: `Automatic refund failed for order ${primaryOrderId} (${unavailablePaid.length} unavailable item(s)) — manual refund required.`,
|
|
782
|
+
relatedId: primaryOrderId,
|
|
783
|
+
relatedType: "order",
|
|
784
|
+
userEmail: admin.email ?? undefined,
|
|
785
|
+
userPhone: admin.phoneNumber ?? undefined,
|
|
786
|
+
}).catch((notifErr) => serverLogger.error("Failed to notify admin of failed auto-refund (non-fatal)", notifErr))));
|
|
787
|
+
}
|
|
788
|
+
catch (notifyErr) {
|
|
789
|
+
void normalizeError(notifyErr);
|
|
790
|
+
serverLogger.error("verifyAndPlaceRazorpayOrderAction: failed to query admins for failed auto-refund notification", { orderId: primaryOrderId });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
712
794
|
export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
713
795
|
const { userId: uid, userName, userEmail, razorpay_order_id, razorpay_payment_id, razorpay_signature, addressId, notes, outOfStockPolicy = OutOfStockPolicyValues.CANCEL_ORDER, } = input;
|
|
714
796
|
const siteSettings = await siteSettingsRepository.getSingleton();
|
|
@@ -1086,73 +1168,12 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
|
|
|
1086
1168
|
// themselves are NOT rolled back (they're already validly created and
|
|
1087
1169
|
// paid); instead the failure is surfaced via order.refundPending + an
|
|
1088
1170
|
// admin notification, never silently dropped (Rule #8).
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
// Cap defensively at the primary order's total — processRefundAction
|
|
1096
|
-
// rejects any amount exceeding order.totalPrice, and a multi-order
|
|
1097
|
-
// batch's dropped-items value isn't cleanly attributable to a single
|
|
1098
|
-
// order (Razorpay refunds are keyed by paymentId, not orderId; the
|
|
1099
|
-
// FIRST order created in this batch is used as the refund's book-
|
|
1100
|
-
// keeping anchor).
|
|
1101
|
-
const refundAmount = primaryOrder
|
|
1102
|
-
? Math.min(droppedValueInPaise, primaryOrder.totalPrice)
|
|
1103
|
-
: droppedValueInPaise;
|
|
1104
|
-
const refundResult = await processRefundAction({
|
|
1105
|
-
orderId: primaryOrderId,
|
|
1106
|
-
type: "partial",
|
|
1107
|
-
amountInPaise: refundAmount,
|
|
1108
|
-
reason: `Automatic refund — ${unavailablePaid.length} item(s) unavailable at checkout: ${unavailablePaid.map((u) => u.productTitle).join(", ")}`,
|
|
1109
|
-
method: "razorpay",
|
|
1110
|
-
razorpayPaymentId: razorpay_payment_id,
|
|
1111
|
-
confirmIrrevocable: true,
|
|
1112
|
-
refundedBy: "system:checkout-auto-refund",
|
|
1113
|
-
});
|
|
1114
|
-
if (!refundResult.ok) {
|
|
1115
|
-
throw new Error(refundResult.error ?? "Automatic refund failed");
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
catch (refundErr) {
|
|
1119
|
-
void normalizeError(refundErr);
|
|
1120
|
-
serverLogger.warn("verifyAndPlaceRazorpayOrderAction: automatic partial refund for dropped items failed — flagging for manual follow-up", {
|
|
1121
|
-
orderId: primaryOrderId,
|
|
1122
|
-
droppedValueInPaise,
|
|
1123
|
-
err: refundErr instanceof Error ? refundErr.message : String(refundErr),
|
|
1124
|
-
});
|
|
1125
|
-
await unitOfWork.orders
|
|
1126
|
-
.update(primaryOrderId, { refundPending: true })
|
|
1127
|
-
.catch((updErr) => {
|
|
1128
|
-
void normalizeError(updErr);
|
|
1129
|
-
serverLogger.error("verifyAndPlaceRazorpayOrderAction: failed to flag refundPending after auto-refund failure", { orderId: primaryOrderId, err: updErr instanceof Error ? updErr.message : String(updErr) });
|
|
1130
|
-
});
|
|
1131
|
-
// Best-effort admin fan-out — mirrors onScamReportCreate's employee
|
|
1132
|
-
// notification pattern. Never allowed to throw past this point.
|
|
1133
|
-
try {
|
|
1134
|
-
const admins = await userRepository.list({ filters: "role==admin", page: 1, pageSize: 100 });
|
|
1135
|
-
await Promise.all(admins.items
|
|
1136
|
-
.filter((a) => !!a.id)
|
|
1137
|
-
.map((admin) => sendNotification({
|
|
1138
|
-
userId: admin.id,
|
|
1139
|
-
type: "system",
|
|
1140
|
-
priority: "high",
|
|
1141
|
-
title: "Automatic refund failed",
|
|
1142
|
-
message: `Automatic refund failed for order ${primaryOrderId} (${unavailablePaid.length} unavailable item(s)) — manual refund required.`,
|
|
1143
|
-
relatedId: primaryOrderId,
|
|
1144
|
-
relatedType: "order",
|
|
1145
|
-
userEmail: admin.email ?? undefined,
|
|
1146
|
-
userPhone: admin.phoneNumber ?? undefined,
|
|
1147
|
-
}).catch((notifErr) => serverLogger.error("Failed to notify admin of failed auto-refund (non-fatal)", notifErr))));
|
|
1148
|
-
}
|
|
1149
|
-
catch (notifyErr) {
|
|
1150
|
-
void normalizeError(notifyErr);
|
|
1151
|
-
serverLogger.error("verifyAndPlaceRazorpayOrderAction: failed to query admins for failed auto-refund notification", { orderId: primaryOrderId });
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1171
|
+
await refundDroppedItemsForRazorpayCheckout({
|
|
1172
|
+
unavailablePaid,
|
|
1173
|
+
orderIds,
|
|
1174
|
+
productByIdPaid,
|
|
1175
|
+
razorpayPaymentId: razorpay_payment_id,
|
|
1176
|
+
});
|
|
1156
1177
|
if (emailsToSend.length > 0) {
|
|
1157
1178
|
Promise.all(emailsToSend.map((e) => sendOrderConfirmationEmail(e))).catch((err) => serverLogger.error("Order confirmation email error:", err));
|
|
1158
1179
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* ping channel the client subscribes to via `useBulkEvent`.
|
|
10
10
|
*/
|
|
11
11
|
import type { JobContext } from "../runtime/types";
|
|
12
|
+
import type { JsonValue } from "@mohasinac/appkit";
|
|
12
13
|
export interface JobRunResult {
|
|
13
14
|
summary: {
|
|
14
15
|
total: number;
|
|
@@ -22,7 +23,7 @@ export interface JobRunResult {
|
|
|
22
23
|
id: string;
|
|
23
24
|
reason: string;
|
|
24
25
|
}[];
|
|
25
|
-
data?: Record<string,
|
|
26
|
+
data?: Record<string, JsonValue>;
|
|
26
27
|
}
|
|
27
|
-
export type JobRunner = (payload: Record<string,
|
|
28
|
+
export type JobRunner = (payload: Record<string, JsonValue>, ctx: JobContext) => Promise<JobRunResult>;
|
|
28
29
|
export declare const JOB_RUNNERS: Record<string, JobRunner>;
|
|
@@ -37,10 +37,10 @@ export async function handleJobCreated(input, ctx) {
|
|
|
37
37
|
await jobsRepository.markProcessing(jobId);
|
|
38
38
|
const runner = JOB_RUNNERS[job.jobType];
|
|
39
39
|
if (!runner) {
|
|
40
|
-
const error = `
|
|
40
|
+
const error = `No runner registered for jobType: ${job.jobType}`;
|
|
41
41
|
await jobsRepository.markFailed(jobId, error);
|
|
42
42
|
await pingBulkEvent(jobId, { status: "failed", action: job.jobType, error }, ctx);
|
|
43
|
-
ctx.logger.error("onJobCreated:
|
|
43
|
+
ctx.logger.error("onJobCreated: unregistered jobType", null, { jobId, jobType: job.jobType });
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
46
46
|
try {
|
|
@@ -9,6 +9,7 @@ import { ConfirmDeleteModal, FilterChipGroup, ListingLayout, RowActionMenu, useT
|
|
|
9
9
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
10
10
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
11
11
|
import { ROW_ACTION_META, ROW_ACTION_ID } from "../../products/constants/action-defs";
|
|
12
|
+
import { useBulkAction } from "../../../react";
|
|
12
13
|
import { toRecordArray, toRelativeDate, toStringValue, } from "../hooks/useAdminListingData";
|
|
13
14
|
import { DataListingView } from "./DataListingView";
|
|
14
15
|
import { apiClient } from "../../../http";
|
|
@@ -19,6 +20,17 @@ export function AdminNotificationsView({ children, ...props }) {
|
|
|
19
20
|
const queryClient = useQueryClient();
|
|
20
21
|
const { showToast } = useToast();
|
|
21
22
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
|
23
|
+
const [bulkDeleteIds, setBulkDeleteIds] = useState(null);
|
|
24
|
+
const bulkNotifs = useBulkAction({
|
|
25
|
+
mutationFn: (payload) => apiClient.post(ADMIN_ENDPOINTS.ADMIN_NOTIFICATIONS_BULK, payload),
|
|
26
|
+
onSuccess: (result) => {
|
|
27
|
+
showToast(`${result.summary.succeeded}/${result.summary.total} notifications updated.`, result.summary.failed > 0 ? "warning" : "success");
|
|
28
|
+
queryClient.invalidateQueries({ queryKey: ["admin", "notifications"] });
|
|
29
|
+
},
|
|
30
|
+
onError: () => {
|
|
31
|
+
showToast("Bulk action failed.", "error");
|
|
32
|
+
},
|
|
33
|
+
});
|
|
22
34
|
const deleteMutation = useApiMutation({
|
|
23
35
|
mutationFn: async (id) => {
|
|
24
36
|
await apiClient.delete(ADMIN_ENDPOINTS.ADMIN_NOTIFICATION_BY_ID(id));
|
|
@@ -77,13 +89,17 @@ export function AdminNotificationsView({ children, ...props }) {
|
|
|
77
89
|
id: ROW_ACTION_ID.MARK_READ,
|
|
78
90
|
label: ACTIONS.ADMIN["mark-read"].label,
|
|
79
91
|
variant: "primary",
|
|
80
|
-
|
|
92
|
+
loading: bulkNotifs.isLoading,
|
|
93
|
+
onClick: () => {
|
|
94
|
+
void bulkNotifs.execute({ action: "mark_read", ids: selection.selectedIds });
|
|
95
|
+
selection.clearSelection();
|
|
96
|
+
},
|
|
81
97
|
},
|
|
82
98
|
{
|
|
83
99
|
id: ROW_ACTION_ID.DELETE,
|
|
84
100
|
label: ACTIONS.ADMIN["delete-notification"].label,
|
|
85
101
|
variant: "secondary",
|
|
86
|
-
onClick: () => selection.
|
|
102
|
+
onClick: () => setBulkDeleteIds(selection.selectedIds),
|
|
87
103
|
},
|
|
88
104
|
],
|
|
89
105
|
renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [
|
|
@@ -99,8 +115,17 @@ export function AdminNotificationsView({ children, ...props }) {
|
|
|
99
115
|
] })),
|
|
100
116
|
renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsx(FilterChipGroup, { label: "Type", tabs: NOTIF_TYPES.map((opt) => ({ id: opt, label: opt })), value: pendingFilters.type || "All", onChange: (v) => setPendingFilters((p) => ({ ...p, type: v })) })),
|
|
101
117
|
};
|
|
102
|
-
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(ConfirmDeleteModal, { isOpen: Boolean(deleteTarget)
|
|
103
|
-
|
|
118
|
+
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(ConfirmDeleteModal, { isOpen: Boolean(deleteTarget) || Boolean(bulkDeleteIds), onClose: () => {
|
|
119
|
+
setDeleteTarget(null);
|
|
120
|
+
setBulkDeleteIds(null);
|
|
121
|
+
}, onConfirm: () => {
|
|
122
|
+
if (bulkDeleteIds) {
|
|
123
|
+
void bulkNotifs.execute({ action: "delete", ids: bulkDeleteIds }).then(() => setBulkDeleteIds(null));
|
|
124
|
+
}
|
|
125
|
+
else if (deleteTarget) {
|
|
104
126
|
deleteMutation.mutate(deleteTarget.id);
|
|
105
|
-
|
|
127
|
+
}
|
|
128
|
+
}, isDeleting: deleteMutation.isPending || bulkNotifs.isLoading, title: bulkDeleteIds ? "Delete these notifications?" : "Delete notification?", message: bulkDeleteIds
|
|
129
|
+
? `${bulkDeleteIds.length} notification(s) will be permanently removed.`
|
|
130
|
+
: "This notification will be permanently removed.", confirmText: "Delete", variant: "danger" })] }));
|
|
106
131
|
}
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
import { useApiMutation } from "@mohasinac/appkit/client";
|
|
4
4
|
import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
|
|
5
5
|
import { sortBy } from "@mohasinac/appkit";
|
|
6
|
-
import React, { useState } from "react";
|
|
6
|
+
import React, { useEffect, useState } from "react";
|
|
7
7
|
import { useQueryClient } from "@tanstack/react-query";
|
|
8
8
|
import { Button, FilterChipGroup, Form, FormActions, Input, ListingLayout, Modal, RowActionMenu, Text as AppText, useToast, } from "../../../ui";
|
|
9
9
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
@@ -14,6 +14,10 @@ import { toRecordArray, toRelativeDate, toStringValue, } from "../hooks/useAdmin
|
|
|
14
14
|
import { DataListingView } from "./DataListingView";
|
|
15
15
|
import { apiClient } from "../../../http";
|
|
16
16
|
import { AdminUserEditorView } from "./AdminUserEditorView";
|
|
17
|
+
import { useBulkAction } from "../../../react";
|
|
18
|
+
import { useBulkEvent } from "../../events/hooks/useBulkEvent";
|
|
19
|
+
import { RTDB_PATHS } from "../../../providers/db-firebase/rtdb-paths";
|
|
20
|
+
import { RealtimeEventStatus } from "../../../react/hooks/useRealtimeEvent";
|
|
17
21
|
export function AdminUsersView({ children, ...props }) {
|
|
18
22
|
const toast = useToast();
|
|
19
23
|
const queryClient = useQueryClient();
|
|
@@ -22,21 +26,50 @@ export function AdminUsersView({ children, ...props }) {
|
|
|
22
26
|
const [banModalOpen, setBanModalOpen] = useState(false);
|
|
23
27
|
const [banTargetId, setBanTargetId] = useState(null);
|
|
24
28
|
const [banReason, setBanReason] = useState("");
|
|
29
|
+
const [bulkConfirmTarget, setBulkConfirmTarget] = useState(null);
|
|
30
|
+
// Hard-ban runs as a Firebase Function job (cascade across address/payment
|
|
31
|
+
// clusters can exceed the Vercel Hobby 10s ceiling) — the route only
|
|
32
|
+
// enqueues it and returns {jobId, customToken} immediately. This event
|
|
33
|
+
// stream tracks that job through to completion via the bulk_events RTDB
|
|
34
|
+
// ping channel.
|
|
35
|
+
const banJobEvent = useBulkEvent({ rtdbPath: RTDB_PATHS.BULK_EVENTS });
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (banJobEvent.status === RealtimeEventStatus.SUCCESS) {
|
|
38
|
+
toast.showToast("User has been banned.", "success");
|
|
39
|
+
setBanModalOpen(false);
|
|
40
|
+
setBanTargetId(null);
|
|
41
|
+
setBanReason("");
|
|
42
|
+
void queryClient.invalidateQueries({ queryKey: ["admin", "users", "listing"] });
|
|
43
|
+
banJobEvent.reset();
|
|
44
|
+
}
|
|
45
|
+
else if (banJobEvent.status === RealtimeEventStatus.FAILED ||
|
|
46
|
+
banJobEvent.status === RealtimeEventStatus.TIMEOUT) {
|
|
47
|
+
toast.showToast(banJobEvent.error ?? "Failed to ban user.", "error");
|
|
48
|
+
banJobEvent.reset();
|
|
49
|
+
}
|
|
50
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
|
+
}, [banJobEvent.status]);
|
|
25
52
|
const banUser = useApiMutation({
|
|
26
53
|
mutationFn: () => {
|
|
27
54
|
if (!banTargetId)
|
|
28
55
|
throw new Error("No user selected");
|
|
29
56
|
return apiClient.post(ADMIN_ENDPOINTS.USER_HARD_BAN(banTargetId), { reason: banReason.trim() });
|
|
30
57
|
},
|
|
31
|
-
onSuccess: () => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
onSuccess: (result) => {
|
|
59
|
+
banJobEvent.subscribe(result.jobId, result.customToken);
|
|
60
|
+
},
|
|
61
|
+
onError: () => {
|
|
62
|
+
toast.showToast("Failed to start hard-ban.", "error");
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const bulkUsers = useBulkAction({
|
|
66
|
+
mutationFn: (payload) => apiClient.post(ADMIN_ENDPOINTS.USERS_BULK, payload),
|
|
67
|
+
onSuccess: (result) => {
|
|
68
|
+
toast.showToast(`${result.summary.succeeded}/${result.summary.total} users updated.`, result.summary.failed > 0 ? "warning" : "success");
|
|
36
69
|
void queryClient.invalidateQueries({ queryKey: ["admin", "users", "listing"] });
|
|
37
70
|
},
|
|
38
71
|
onError: () => {
|
|
39
|
-
toast.showToast("
|
|
72
|
+
toast.showToast("Bulk action failed.", "error");
|
|
40
73
|
},
|
|
41
74
|
});
|
|
42
75
|
const unbanUser = useApiMutation({
|
|
@@ -49,6 +82,19 @@ export function AdminUsersView({ children, ...props }) {
|
|
|
49
82
|
toast.showToast("Failed to lift ban.", "error");
|
|
50
83
|
},
|
|
51
84
|
});
|
|
85
|
+
const handleManageBulkClick = (selection) => {
|
|
86
|
+
const rowId = selection.selectedIds[0];
|
|
87
|
+
const row = selection.rows.find((r) => r.id === rowId) ?? null;
|
|
88
|
+
if (row) {
|
|
89
|
+
setSelectedRow(row);
|
|
90
|
+
setDrawerOpen(true);
|
|
91
|
+
}
|
|
92
|
+
selection.clearSelection();
|
|
93
|
+
};
|
|
94
|
+
const handleRestoreBulkClick = (selection) => {
|
|
95
|
+
void bulkUsers.execute({ action: "restore", ids: selection.selectedIds });
|
|
96
|
+
selection.clearSelection();
|
|
97
|
+
};
|
|
52
98
|
if (React.Children.count(children) > 0) {
|
|
53
99
|
return (_jsx(ListingLayout, { portal: "admin", ...props, children: children }));
|
|
54
100
|
}
|
|
@@ -109,25 +155,37 @@ export function AdminUsersView({ children, ...props }) {
|
|
|
109
155
|
setSelectedRow(row);
|
|
110
156
|
setDrawerOpen(true);
|
|
111
157
|
},
|
|
112
|
-
// Rule #7: bulk-action array sourced from the ADMIN_BULK_ACTIONS preset
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
buildBulkActions: (selection) => [ROW_ACTION_ID.MANAGE]
|
|
158
|
+
// Rule #7: bulk-action array sourced from the ADMIN_BULK_ACTIONS preset,
|
|
159
|
+
// backed by the real /api/admin/users/bulk endpoint via useBulkAction.
|
|
160
|
+
buildBulkActions: (selection) => [ROW_ACTION_ID.MANAGE, ROW_ACTION_ID.SUSPEND, ROW_ACTION_ID.RESTORE, ROW_ACTION_ID.DELETE]
|
|
116
161
|
.filter((id) => ADMIN_BULK_ACTIONS.users.includes(id))
|
|
117
|
-
.map((id) =>
|
|
118
|
-
id
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
162
|
+
.map((id) => {
|
|
163
|
+
if (id === ROW_ACTION_ID.MANAGE) {
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
label: ROW_ACTION_META[id].label,
|
|
167
|
+
variant: "primary",
|
|
168
|
+
onClick: () => handleManageBulkClick(selection),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (id === ROW_ACTION_ID.RESTORE) {
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
label: ROW_ACTION_META[id].label,
|
|
175
|
+
variant: "secondary",
|
|
176
|
+
loading: bulkUsers.isLoading,
|
|
177
|
+
onClick: () => handleRestoreBulkClick(selection),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const action = id === ROW_ACTION_ID.SUSPEND ? "suspend" : "delete";
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
label: ROW_ACTION_META[id].label,
|
|
184
|
+
variant: "danger",
|
|
185
|
+
loading: bulkUsers.isLoading,
|
|
186
|
+
onClick: () => setBulkConfirmTarget({ action, ids: selection.selectedIds }),
|
|
187
|
+
};
|
|
188
|
+
}),
|
|
131
189
|
renderRowActions: (row) => {
|
|
132
190
|
const isBanned = row.status === "Hard banned";
|
|
133
191
|
return (_jsx(RowActionMenu, { actions: [
|
|
@@ -195,5 +253,23 @@ export function AdminUsersView({ children, ...props }) {
|
|
|
195
253
|
setBanModalOpen(false);
|
|
196
254
|
setBanTargetId(null);
|
|
197
255
|
setBanReason("");
|
|
198
|
-
}, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "danger", isLoading: banUser.isPending
|
|
256
|
+
}, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "danger", isLoading: banUser.isPending || banJobEvent.status === RealtimeEventStatus.SUBSCRIBING || banJobEvent.status === RealtimeEventStatus.PENDING, disabled: !banReason.trim() ||
|
|
257
|
+
banUser.isPending ||
|
|
258
|
+
banJobEvent.status === RealtimeEventStatus.SUBSCRIBING ||
|
|
259
|
+
banJobEvent.status === RealtimeEventStatus.PENDING, children: ACTIONS.ADMIN["ban-user"].confirmation.confirmLabel })] })] })] }), _jsxs(Modal, { isOpen: Boolean(bulkConfirmTarget), onClose: () => setBulkConfirmTarget(null), title: bulkConfirmTarget?.action === "suspend"
|
|
260
|
+
? ACTIONS.ADMIN["bulk-suspend-users"].confirmation.title
|
|
261
|
+
: ACTIONS.ADMIN["bulk-delete-users"].confirmation.title, children: [_jsx(AppText, { size: "sm", color: "muted", className: "mb-4", children: bulkConfirmTarget?.action === "suspend"
|
|
262
|
+
? ACTIONS.ADMIN["bulk-suspend-users"].confirmation.body
|
|
263
|
+
: ACTIONS.ADMIN["bulk-delete-users"].confirmation.body }), _jsxs(FormActions, { children: [_jsx(Button, { type: "button", variant: "secondary", onClick: () => setBulkConfirmTarget(null), children: "Cancel" }), _jsx(Button, { type: "button", variant: "danger", isLoading: bulkUsers.isLoading, onClick: () => {
|
|
264
|
+
if (!bulkConfirmTarget)
|
|
265
|
+
return;
|
|
266
|
+
// bulkUsers.execute already surfaces success/failure via its
|
|
267
|
+
// own onSuccess/onError toasts — .finally() just closes the
|
|
268
|
+
// confirm dialog either way.
|
|
269
|
+
void bulkUsers
|
|
270
|
+
.execute({ action: bulkConfirmTarget.action, ids: bulkConfirmTarget.ids })
|
|
271
|
+
.finally(() => setBulkConfirmTarget(null));
|
|
272
|
+
}, children: bulkConfirmTarget?.action === "suspend"
|
|
273
|
+
? ACTIONS.ADMIN["bulk-suspend-users"].confirmation.confirmLabel
|
|
274
|
+
: ACTIONS.ADMIN["bulk-delete-users"].confirmation.confirmLabel })] })] })] }));
|
|
199
275
|
}
|
|
@@ -64,7 +64,7 @@ export function CustomCardsSection(config) {
|
|
|
64
64
|
}
|
|
65
65
|
// Row layout: horizontal scroller
|
|
66
66
|
if (layout === "row") {
|
|
67
|
-
return (_jsx(Section, { padding: "y-3xl", surface: "muted", children: _jsxs(Div, { className: CLS_CONTAINER, children: [_jsx(SectionHeader, { title: title }), _jsx(HorizontalScroller, { gap: 16, showArrows: true, snapToItems: true, loop: true,
|
|
67
|
+
return (_jsx(Section, { padding: "y-3xl", surface: "muted", children: _jsxs(Div, { className: CLS_CONTAINER, children: [_jsx(SectionHeader, { title: title }), _jsx(HorizontalScroller, { gap: 16, showArrows: true, snapToItems: true, loop: true, items: cards, keyExtractor: (card) => card.id, renderItem: (card) => (_jsx(Div, { className: "w-72 flex-shrink-0", children: _jsx(CardItem, { card: card }) })) })] }) }));
|
|
68
68
|
}
|
|
69
69
|
// Masonry layout: CSS columns
|
|
70
70
|
if (layout === "masonry") {
|
|
@@ -41,5 +41,5 @@ export function SectionCarousel({ title, description, headingVariant = "editoria
|
|
|
41
41
|
headingClass,
|
|
42
42
|
]
|
|
43
43
|
.filter(Boolean)
|
|
44
|
-
.join(" "), children: title }), headingVariant === "editorial" && (_jsxs(Row, { align: "center", justify: "center", gap: "sm", textSize: "xs", color: "faint", className: "mt-1 select-none", "aria-hidden": "true", children: [_jsx(Span, { className: "h-px w-6 bg-current" }), _jsx(Span, { size: "xs", children: "\u2736" }), _jsx(Span, { className: "h-px w-6 bg-current" })] })), description && (_jsx(Text, { className: `${descVariant} mt-2`, size: "base", children: description }))] }), isLoading ? (_jsx(CarouselSkeleton, { count: skeletonCount })) : (_jsx(HorizontalScroller, { items: items, renderItem: renderItem, perView: perView, gap: gap, autoScroll: autoScroll, autoScrollInterval: autoScrollInterval, loop:
|
|
44
|
+
.join(" "), children: title }), headingVariant === "editorial" && (_jsxs(Row, { align: "center", justify: "center", gap: "sm", textSize: "xs", color: "faint", className: "mt-1 select-none", "aria-hidden": "true", children: [_jsx(Span, { className: "h-px w-6 bg-current" }), _jsx(Span, { size: "xs", children: "\u2736" }), _jsx(Span, { className: "h-px w-6 bg-current" })] })), description && (_jsx(Text, { className: `${descVariant} mt-2`, size: "base", children: description }))] }), isLoading ? (_jsx(CarouselSkeleton, { count: skeletonCount })) : (_jsx(HorizontalScroller, { items: items, renderItem: renderItem, perView: perView, gap: gap, autoScroll: autoScroll, autoScrollInterval: autoScrollInterval, loop: true, keyExtractor: keyExtractor, rows: rows, minItemWidth: minItemWidth, showArrows: true, snapToItems: true, showFadeEdges: true, showScrollbar: false, pauseOnHover: true })), viewMoreHref && !isLoading && (_jsx(Row, { className: "mt-6", justify: "start", children: _jsx(TextLink, { rounded: "lg", paddingX: "xl", paddingY: "sm", href: viewMoreHref, className: `inline-flex items-[center] gap-[0.375rem] border transition-colors ${useLightText ? "border-white/40 text-white hover:bg-[rgba(255,255,255,0.1)]" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-surface-elevated)]"}`, size: "sm", weight: "medium", children: viewMoreLabel }) }))] })] }));
|
|
45
45
|
}
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* (`appkit/firebase/base/database.rules.json`) — the caller signs in with it
|
|
13
13
|
* client-side and subscribes via `useBulkEvent({ rtdbPath: RTDB_PATHS.BULK_EVENTS })`.
|
|
14
14
|
*/
|
|
15
|
+
import type { JsonValue } from "@mohasinac/appkit";
|
|
15
16
|
export interface EnqueueJobInput {
|
|
16
17
|
jobType: string;
|
|
17
|
-
payload: Record<string,
|
|
18
|
+
payload: Record<string, JsonValue>;
|
|
18
19
|
requestedBy: string;
|
|
19
20
|
}
|
|
20
21
|
export interface EnqueueJobResult {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* the async-job primitive's overall flow.
|
|
6
6
|
*/
|
|
7
7
|
import { BaseRepository } from "../../../providers/db-firebase";
|
|
8
|
-
import { JOBS_COLLECTION, JobStatusValues, } from "../schemas/firestore";
|
|
8
|
+
import { JOBS_COLLECTION, JOB_FIELDS, JobStatusValues, } from "../schemas/firestore";
|
|
9
9
|
export class JobsRepository extends BaseRepository {
|
|
10
10
|
constructor() {
|
|
11
11
|
super(JOBS_COLLECTION);
|
|
@@ -38,8 +38,8 @@ export class JobsRepository extends BaseRepository {
|
|
|
38
38
|
const cutoff = new Date();
|
|
39
39
|
cutoff.setDate(cutoff.getDate() - ttlDays);
|
|
40
40
|
const snap = await this.getCollection()
|
|
41
|
-
.where(
|
|
42
|
-
.where(
|
|
41
|
+
.where(JOB_FIELDS.STATUS, "in", [JobStatusValues.DONE, JobStatusValues.FAILED])
|
|
42
|
+
.where(JOB_FIELDS.UPDATED_AT, "<", cutoff)
|
|
43
43
|
.limit(500)
|
|
44
44
|
.get();
|
|
45
45
|
return snap.docs.map((d) => d.ref);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* exceed the Vercel Hobby 10s sync-function ceiling (CLAUDE.md Rule #6).
|
|
17
17
|
*/
|
|
18
18
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
19
|
+
import type { JsonValue } from "@mohasinac/appkit";
|
|
19
20
|
export declare const JOBS_COLLECTION = "jobs";
|
|
20
21
|
export declare const JobStatusValues: {
|
|
21
22
|
readonly PENDING: "pending";
|
|
@@ -37,14 +38,14 @@ export interface JobResult {
|
|
|
37
38
|
id: string;
|
|
38
39
|
reason: string;
|
|
39
40
|
}[];
|
|
40
|
-
data?: Record<string,
|
|
41
|
+
data?: Record<string, JsonValue>;
|
|
41
42
|
}
|
|
42
43
|
export interface JobDocument extends BaseDocument {
|
|
43
44
|
/** Key into JOB_RUNNERS — e.g. "payoutsWeekly", "hardBanCascade". */
|
|
44
45
|
jobType: string;
|
|
45
46
|
status: JobStatus;
|
|
46
47
|
/** Opaque input the runner receives — shape is jobType-specific. */
|
|
47
|
-
payload: Record<string,
|
|
48
|
+
payload: Record<string, JsonValue>;
|
|
48
49
|
/** uid of the admin/user who enqueued this job. */
|
|
49
50
|
requestedBy: string;
|
|
50
51
|
result?: JobResult;
|
|
@@ -52,3 +53,20 @@ export interface JobDocument extends BaseDocument {
|
|
|
52
53
|
startedAt?: Date;
|
|
53
54
|
finishedAt?: Date;
|
|
54
55
|
}
|
|
56
|
+
export declare const JOB_FIELDS: {
|
|
57
|
+
readonly JOB_TYPE: "jobType";
|
|
58
|
+
readonly STATUS: "status";
|
|
59
|
+
readonly PAYLOAD: "payload";
|
|
60
|
+
readonly REQUESTED_BY: "requestedBy";
|
|
61
|
+
readonly ERROR: "error";
|
|
62
|
+
readonly STARTED_AT: "startedAt";
|
|
63
|
+
readonly FINISHED_AT: "finishedAt";
|
|
64
|
+
readonly CREATED_AT: "createdAt";
|
|
65
|
+
readonly UPDATED_AT: "updatedAt";
|
|
66
|
+
readonly STATUS_VALUES: {
|
|
67
|
+
readonly PENDING: "pending";
|
|
68
|
+
readonly PROCESSING: "processing";
|
|
69
|
+
readonly DONE: "done";
|
|
70
|
+
readonly FAILED: "failed";
|
|
71
|
+
};
|
|
72
|
+
};
|
|
@@ -22,3 +22,15 @@ export const JobStatusValues = {
|
|
|
22
22
|
DONE: "done",
|
|
23
23
|
FAILED: "failed",
|
|
24
24
|
};
|
|
25
|
+
export const JOB_FIELDS = {
|
|
26
|
+
JOB_TYPE: "jobType",
|
|
27
|
+
STATUS: "status",
|
|
28
|
+
PAYLOAD: "payload",
|
|
29
|
+
REQUESTED_BY: "requestedBy",
|
|
30
|
+
ERROR: "error",
|
|
31
|
+
STARTED_AT: "startedAt",
|
|
32
|
+
FINISHED_AT: "finishedAt",
|
|
33
|
+
CREATED_AT: "createdAt",
|
|
34
|
+
UPDATED_AT: "updatedAt",
|
|
35
|
+
STATUS_VALUES: JobStatusValues,
|
|
36
|
+
};
|