@mohasinac/appkit 2.7.43 → 2.7.45

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.
@@ -14,7 +14,7 @@ import { sendOrderConfirmationEmail } from "../../../../features/contact/server"
14
14
  import { splitCartIntoOrderGroups } from "../../../../features/orders/index";
15
15
  import { resolveDate } from "../../../../utils";
16
16
  import { getAdminDb, getAdminRealtimeDb, RTDB_PATHS, } from "../../../../providers/db-firebase";
17
- import { PRODUCT_COLLECTION } from "../../../../features/products/schemas/firestore";
17
+ import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION } from "../../../../features/products/schemas/firestore";
18
18
  import { CART_COLLECTION } from "../../../../features/cart/schemas/index";
19
19
  // S-SBUNI-RULES 2026-05-13 — bundle cart-line stock fan-out helpers.
20
20
  import { getCartItemMemberIds, getExpandedDecrements, validateCartItemStock, } from "./bundle-expansion";
@@ -27,6 +27,47 @@ import { formatShippingAddress } from "./data";
27
27
  import { enforceMaxPerUserForCart, computePrizeRevealDeadline, } from "./prize-bundle-gates";
28
28
  import { getListingRule, runSyncPreflight, } from "../../../shared/checkout/rules";
29
29
  import { cartIsDigitalOnly } from "../../../shared/listing-types/cart-shipping";
30
+ /**
31
+ * SB-UNI-N — Atomically claim the next available digital code for a confirmed
32
+ * order. Pre-fetches a candidate code outside the transaction then locks and
33
+ * verifies in a micro-transaction. Fire-and-forget safe (logs on exhaustion
34
+ * but never fails the already-created order).
35
+ */
36
+ async function claimDigitalCodeForOrder(db, productId, orderId, userId, opts) {
37
+ const codesRef = db
38
+ .collection(PRODUCT_COLLECTION)
39
+ .doc(productId)
40
+ .collection(PRODUCT_CODES_SUBCOLLECTION);
41
+ const snap = await codesRef.where("status", "==", "available").limit(1).get();
42
+ if (snap.empty) {
43
+ serverLogger.warn("claimDigitalCode: code pool exhausted", { productId, orderId });
44
+ return;
45
+ }
46
+ const codeRef = snap.docs[0].ref;
47
+ let claimed = false;
48
+ await db.runTransaction(async (tx) => {
49
+ const latest = await tx.get(codeRef);
50
+ if (!latest.exists || latest.data()?.status !== "available")
51
+ return;
52
+ tx.update(codeRef, {
53
+ status: "claimed",
54
+ orderId,
55
+ claimedByUserId: userId,
56
+ claimedAt: new Date(),
57
+ updatedAt: new Date(),
58
+ });
59
+ claimed = true;
60
+ });
61
+ if (claimed && opts?.userEmail) {
62
+ const { sendDigitalCodeClaimedEmail } = await import("../../../../features/contact/server");
63
+ sendDigitalCodeClaimedEmail({
64
+ to: opts.userEmail,
65
+ userName: opts.userName ?? "there",
66
+ productTitle: opts.productTitle ?? "your product",
67
+ orderId,
68
+ }).catch((e) => serverLogger.error("claimDigitalCode: email failed", e));
69
+ }
70
+ }
30
71
  /**
31
72
  * SB-UNI-O — Live-item jurisdiction guard.
32
73
  * Throws ValidationError if any live cart item's allowed states don't include
@@ -470,6 +511,15 @@ export async function createCheckoutOrderAction(input) {
470
511
  ...extraOrderFields,
471
512
  });
472
513
  orderIds.push(order.id);
514
+ // SB-UNI-N — claim a digital code for digital-code orders (fire-and-forget,
515
+ // order is already persisted so a claim failure is logged, not thrown).
516
+ if ((group[0]?.product?.listingType ?? "standard") === "digital-code") {
517
+ claimDigitalCodeForOrder(getAdminDb(), firstItem.productId, order.id, uid, {
518
+ userEmail: userEmail || undefined,
519
+ userName,
520
+ productTitle: firstItem.productTitle,
521
+ }).catch((e) => serverLogger.error("claimDigitalCode", e));
522
+ }
473
523
  for (const code of groupCouponCodes) {
474
524
  const entry = couponUsageAccumulator.get(code);
475
525
  if (entry)
@@ -840,6 +890,15 @@ export async function verifyAndPlaceRazorpayOrderAction(input) {
840
890
  ...extraOrderFields,
841
891
  });
842
892
  orderIds.push(order.id);
893
+ // SB-UNI-N — claim a digital code for digital-code orders (fire-and-forget,
894
+ // order is already persisted so a claim failure is logged, not thrown).
895
+ if ((group[0]?.product?.listingType ?? "standard") === "digital-code") {
896
+ claimDigitalCodeForOrder(getAdminDb(), firstItem.productId, order.id, uid, {
897
+ userEmail: userEmail || undefined,
898
+ userName,
899
+ productTitle: firstItem.productTitle,
900
+ }).catch((e) => serverLogger.error("claimDigitalCode", e));
901
+ }
843
902
  if (userEmail) {
844
903
  emailsToSend.push({
845
904
  to: userEmail,
@@ -63,6 +63,13 @@ export async function setProductStatusAction(input) {
63
63
  throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid input");
64
64
  const product = await assertProductOwnership(parsed.data.productId, user.uid);
65
65
  assertStatusTransition(product.status, parsed.data.status);
66
+ // SB-UNI-O — live listings must be admin-verified before a seller can publish them.
67
+ if (parsed.data.status === "published" &&
68
+ (product.listingType ?? "standard") === "live" &&
69
+ !product.liveItem?.vendorVerified &&
70
+ user.role !== "admin") {
71
+ throw new ValidationError("Live listings require admin verification before publishing. Contact support to request verification.");
72
+ }
66
73
  return productRepository.update(parsed.data.productId, { status: parsed.data.status });
67
74
  }
68
75
  export async function setProductFeaturedAction(input) {
@@ -17,7 +17,10 @@ import { randomUUID } from "crypto";
17
17
  import { getProviders } from "../../../../contracts/registry";
18
18
  import { orderRepository } from "../../../..";
19
19
  import { NotFoundError, ValidationError } from "../../../../errors";
20
+ import { serverLogger } from "../../../../monitoring";
20
21
  import { applyRefundDeductionAction } from "../payouts/actions";
22
+ import { getAdminDb } from "../../../../providers/db-firebase";
23
+ import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION, } from "../../../../features/products/schemas/firestore";
21
24
  export async function processRefundAction(input) {
22
25
  const order = await orderRepository.findById(input.orderId);
23
26
  if (!order)
@@ -31,6 +34,26 @@ export async function processRefundAction(input) {
31
34
  if (order.isNonRefundable) {
32
35
  throw new ValidationError("This order is marked non-refundable and cannot be refunded.");
33
36
  }
37
+ // SB-UNI-N — digital-code refund gate: block if code is already claimed (revealed).
38
+ const digitalCodeItems = (order.items ?? []).filter((i) => (i.listingType ?? "standard") === "digital-code");
39
+ if (digitalCodeItems.length > 0) {
40
+ const db = getAdminDb();
41
+ for (const item of digitalCodeItems) {
42
+ const codesSnap = await db
43
+ .collection(PRODUCT_COLLECTION)
44
+ .doc(item.productId)
45
+ .collection(PRODUCT_CODES_SUBCOLLECTION)
46
+ .where("orderId", "==", input.orderId)
47
+ .limit(1)
48
+ .get();
49
+ if (!codesSnap.empty) {
50
+ const codeStatus = codesSnap.docs[0].data()?.status;
51
+ if (codeStatus === "claimed") {
52
+ throw new ValidationError("The digital code for this order has already been revealed and cannot be refunded automatically. Please contact support.");
53
+ }
54
+ }
55
+ }
56
+ }
34
57
  const refundId = randomUUID();
35
58
  const now = new Date();
36
59
  let razorpayRefundId;
@@ -62,6 +85,27 @@ export async function processRefundAction(input) {
62
85
  : {}),
63
86
  };
64
87
  await orderRepository.postRefundEvent(input.orderId, event, isFull);
88
+ // SB-UNI-N — revoke any available (unclaimed) digital codes linked to this order.
89
+ if (digitalCodeItems.length > 0) {
90
+ const db = getAdminDb();
91
+ for (const item of digitalCodeItems) {
92
+ db.collection(PRODUCT_COLLECTION)
93
+ .doc(item.productId)
94
+ .collection(PRODUCT_CODES_SUBCOLLECTION)
95
+ .where("orderId", "==", input.orderId)
96
+ .where("status", "==", "available")
97
+ .limit(1)
98
+ .get()
99
+ .then((snap) => {
100
+ if (!snap.empty) {
101
+ snap.docs[0].ref
102
+ .update({ status: "revoked", updatedAt: new Date() })
103
+ .catch((e) => serverLogger.error("refund: code revoke failed", e));
104
+ }
105
+ })
106
+ .catch((e) => serverLogger.error("refund: code query failed", e));
107
+ }
108
+ }
65
109
  // Fire-and-forget: deduct from the store's pending payout if one exists.
66
110
  // Failure here must not roll back the already-posted refund event.
67
111
  if (order.storeId) {
@@ -49,6 +49,14 @@ export declare function sendContactEmail(params: {
49
49
  success: boolean;
50
50
  data?: unknown;
51
51
  }>;
52
+ export declare function sendDigitalCodeClaimedEmail(params: {
53
+ to: string;
54
+ userName: string;
55
+ productTitle: string;
56
+ orderId: string;
57
+ }): Promise<{
58
+ success: boolean;
59
+ }>;
52
60
  export declare function sendSiteSettingsChangedEmail(params: {
53
61
  adminEmails: string[];
54
62
  changedByEmail: string;
@@ -220,6 +220,66 @@ export async function sendContactEmail(params) {
220
220
  return { success: false };
221
221
  }
222
222
  }
223
+ export async function sendDigitalCodeClaimedEmail(params) {
224
+ const siteName = getSiteName();
225
+ const siteUrl = getSiteUrl();
226
+ const { to, userName, productTitle, orderId } = params;
227
+ const orderUrl = `${siteUrl}/user/orders/view/${orderId}`;
228
+ try {
229
+ const { error } = await sendConfiguredEmail({
230
+ to,
231
+ subject: `Your digital code for "${productTitle}" is ready`,
232
+ html: `
233
+ <!DOCTYPE html><html>
234
+ <head><meta charset="utf-8"><title>Digital Code Ready</title></head>
235
+ <body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background-color:#f5f5f5;">
236
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f5f5f5;padding:40px 0;">
237
+ <tr><td align="center">
238
+ <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.1);">
239
+ <tr><td style="background:linear-gradient(135deg,#6366f1 0%,#4f46e5 100%);padding:40px;text-align:center;">
240
+ <p style="font-size:48px;margin:0 0 12px;">&#x1F511;</p>
241
+ <h1 style="color:#ffffff;margin:0;font-size:26px;font-weight:600;">Your Digital Code Is Ready</h1>
242
+ </td></tr>
243
+ <tr><td style="padding:40px;">
244
+ <p style="color:#333;font-size:16px;line-height:1.6;margin:0 0 16px;">Hi ${userName},</p>
245
+ <p style="color:#333;font-size:16px;line-height:1.6;margin:0 0 24px;">
246
+ Your digital code for <strong>${productTitle}</strong> has been assigned to your order and is ready to reveal.
247
+ </p>
248
+ <table width="100%" cellpadding="12" cellspacing="0" border="0" style="background:#f8f9fa;border-radius:8px;margin-bottom:24px;">
249
+ <tr><td style="color:#666;font-size:14px;width:40%;">Order ID</td>
250
+ <td style="color:#333;font-size:14px;font-weight:600;">${orderId}</td></tr>
251
+ <tr><td style="color:#666;font-size:14px;">Product</td>
252
+ <td style="color:#333;font-size:14px;">${productTitle}</td></tr>
253
+ </table>
254
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
255
+ <tr><td align="center">
256
+ <a href="${orderUrl}" style="display:inline-block;padding:14px 40px;background:linear-gradient(135deg,#6366f1 0%,#4f46e5 100%);color:#ffffff;text-decoration:none;border-radius:6px;font-size:15px;font-weight:600;">
257
+ Reveal Your Code
258
+ </a>
259
+ </td></tr>
260
+ </table>
261
+ <p style="color:#999;font-size:13px;line-height:1.6;margin:0;">For security, the code is only shown inside your ${siteName} account.</p>
262
+ </td></tr>
263
+ <tr><td style="background-color:#f8f9fa;padding:24px;text-align:center;border-top:1px solid #eee;">
264
+ <p style="color:#9ca3af;font-size:12px;margin:0;">&#169; ${currentYear()} ${siteName}. All rights reserved.</p>
265
+ </td></tr>
266
+ </table>
267
+ </td></tr>
268
+ </table>
269
+ </body></html>`,
270
+ text: `Hi ${userName},\n\nYour digital code for "${productTitle}" (Order: ${orderId}) is ready to reveal.\n\nVisit your order: ${orderUrl}\n\nFor security, the code is only shown inside your ${siteName} account.\n\n© ${currentYear()} ${siteName}`,
271
+ });
272
+ if (error) {
273
+ serverLogger.error("Failed to send digital code claimed email", { error });
274
+ return { success: false };
275
+ }
276
+ return { success: true };
277
+ }
278
+ catch (error) {
279
+ serverLogger.error("Error sending digital code claimed email", { error });
280
+ return { success: false };
281
+ }
282
+ }
223
283
  export async function sendSiteSettingsChangedEmail(params) {
224
284
  const siteName = getSiteName();
225
285
  const siteUrl = getSiteUrl();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "2.7.43",
3
+ "version": "2.7.45",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"