@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.
Files changed (40) hide show
  1. package/dist/_internal/server/features/checkout/actions.d.ts +0 -13
  2. package/dist/_internal/server/features/checkout/actions.js +88 -67
  3. package/dist/_internal/server/jobs/core/jobRunners.d.ts +3 -2
  4. package/dist/_internal/server/jobs/core/onJobCreated.js +2 -2
  5. package/dist/features/admin/components/AdminNotificationsView.js +30 -5
  6. package/dist/features/admin/components/AdminUsersView.js +102 -26
  7. package/dist/features/homepage/components/CustomCardsSection.js +1 -1
  8. package/dist/features/homepage/components/SectionCarousel.js +1 -1
  9. package/dist/features/jobs/actions/enqueue-job.d.ts +2 -1
  10. package/dist/features/jobs/repository/jobs.repository.js +3 -3
  11. package/dist/features/jobs/schemas/firestore.d.ts +20 -2
  12. package/dist/features/jobs/schemas/firestore.js +12 -0
  13. package/dist/seed/addresses-seed-data.js +8 -99
  14. package/dist/seed/categories-seed-data.js +103 -2082
  15. package/dist/seed/homepage-sections-seed-data.js +9 -9
  16. package/dist/seed/products-art-seed-data.d.ts +1 -10
  17. package/dist/seed/products-art-seed-data.js +13 -122
  18. package/dist/seed/products-auctions-seed-data.js +39 -747
  19. package/dist/seed/products-classifieds-seed-data.d.ts +1 -9
  20. package/dist/seed/products-classifieds-seed-data.js +13 -195
  21. package/dist/seed/products-digital-codes-seed-data.d.ts +1 -9
  22. package/dist/seed/products-digital-codes-seed-data.js +13 -202
  23. package/dist/seed/products-live-items-seed-data.d.ts +1 -12
  24. package/dist/seed/products-live-items-seed-data.js +13 -188
  25. package/dist/seed/products-preorders-seed-data.js +18 -149
  26. package/dist/seed/products-prize-draws-seed-data.d.ts +1 -16
  27. package/dist/seed/products-prize-draws-seed-data.js +13 -385
  28. package/dist/seed/products-standard-seed-data.js +152 -1331
  29. package/dist/seed/products-stickers-seed-data.d.ts +1 -10
  30. package/dist/seed/products-stickers-seed-data.js +13 -122
  31. package/dist/seed/site-settings-seed-data.js +106 -108
  32. package/dist/seed/store-addresses-seed-data.js +14 -46
  33. package/dist/seed/stores-seed-data.js +13 -205
  34. package/dist/styles.css +19 -18
  35. package/dist/tailwind-utilities.css +1 -1
  36. package/dist/ui/components/BulkActionBar.style.css +4 -4
  37. package/dist/ui/components/Button.style.css +14 -13
  38. package/dist/ui/components/HorizontalScroller.js +93 -18
  39. package/dist/ui/components/ListingToolbar.js +4 -4
  40. 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
- if (unavailablePaid.length > 0 && orderIds.length > 0) {
1090
- const droppedValueInPaise = unavailablePaid.reduce((sum, u) => sum + (productByIdPaid.get(u.productId)?.price ?? 0) * u.requestedQty, 0);
1091
- const primaryOrderId = orderIds[0];
1092
- if (droppedValueInPaise > 0) {
1093
- try {
1094
- const primaryOrder = await unitOfWork.orders.findById(primaryOrderId);
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, unknown>;
26
+ data?: Record<string, JsonValue>;
26
27
  }
27
- export type JobRunner = (payload: Record<string, unknown>, ctx: JobContext) => Promise<JobRunResult>;
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 = `Unknown jobType: ${job.jobType}`;
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: unknown jobType", null, { jobId, jobType: job.jobType });
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
- onClick: () => selection.clearSelection(),
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.clearSelection(),
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), onClose: () => setDeleteTarget(null), onConfirm: () => {
103
- if (deleteTarget)
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
- }, isDeleting: deleteMutation.isPending, title: "Delete notification?", message: "This notification will be permanently removed.", confirmText: "Delete", variant: "danger" })] }));
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
- toast.showToast("User has been banned.", "success");
33
- setBanModalOpen(false);
34
- setBanTargetId(null);
35
- setBanReason("");
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("Failed to ban user.", "error");
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
- // Only MANAGE has a wired handler today; SUSPEND/RESTORE/DELETE are left
114
- // out until they have real bulk handlers.
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
- label: ROW_ACTION_META[id].label,
120
- variant: "primary",
121
- onClick: () => {
122
- const rowId = selection.selectedIds[0];
123
- const row = selection.rows.find((r) => r.id === rowId) ?? null;
124
- if (row) {
125
- setSelectedRow(row);
126
- setDrawerOpen(true);
127
- }
128
- selection.clearSelection();
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, disabled: !banReason.trim() || banUser.isPending, children: ACTIONS.ADMIN["ban-user"].confirmation.confirmLabel })] })] })] })] }));
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, children: cards.map((card) => (_jsx(Div, { className: "w-72 flex-shrink-0", children: _jsx(CardItem, { card: card }) }, card.id))) })] }) }));
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: rows === 1, 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 }) }))] })] }));
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, unknown>;
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("status", "in", [JobStatusValues.DONE, JobStatusValues.FAILED])
42
- .where("updatedAt", "<", cutoff)
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, unknown>;
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, unknown>;
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
+ };