@mohasinac/appkit 3.0.6 → 3.1.1

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 (186) hide show
  1. package/dist/_internal/client/features/lottery/LotteryAdminEditView.d.ts +42 -0
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +86 -0
  3. package/dist/_internal/client/features/lottery/LotteryAdminSlotView.d.ts +14 -0
  4. package/dist/_internal/client/features/lottery/LotteryAdminSlotView.js +46 -0
  5. package/dist/_internal/client/features/lottery/LotteryDetailView.d.ts +28 -0
  6. package/dist/_internal/client/features/lottery/LotteryDetailView.js +17 -0
  7. package/dist/_internal/client/features/lottery/LotteryEntriesView.d.ts +23 -0
  8. package/dist/_internal/client/features/lottery/LotteryEntriesView.js +58 -0
  9. package/dist/_internal/client/features/lottery/LotteryListView.d.ts +21 -0
  10. package/dist/_internal/client/features/lottery/LotteryListView.js +25 -0
  11. package/dist/_internal/client/features/lottery/LotteryPullForm.d.ts +19 -0
  12. package/dist/_internal/client/features/lottery/LotteryPullForm.js +65 -0
  13. package/dist/_internal/client/features/lottery/LotterySlotGrid.d.ts +13 -0
  14. package/dist/_internal/client/features/lottery/LotterySlotGrid.js +21 -0
  15. package/dist/_internal/client/features/lottery/PrizeDrawLotteryDetailView.d.ts +28 -0
  16. package/dist/_internal/client/features/lottery/PrizeDrawLotteryDetailView.js +18 -0
  17. package/dist/_internal/server/features/auctions/__tests__/service.test.d.ts +1 -0
  18. package/dist/_internal/server/features/auctions/__tests__/service.test.js +140 -0
  19. package/dist/_internal/server/features/checkout/__tests__/actions.test.d.ts +1 -0
  20. package/dist/_internal/server/features/checkout/__tests__/actions.test.js +600 -0
  21. package/dist/_internal/server/features/lottery/actions.d.ts +43 -0
  22. package/dist/_internal/server/features/lottery/actions.js +243 -0
  23. package/dist/_internal/server/features/lottery/adapters.d.ts +9 -0
  24. package/dist/_internal/server/features/lottery/adapters.js +21 -0
  25. package/dist/_internal/server/features/lottery/data.d.ts +20 -0
  26. package/dist/_internal/server/features/lottery/data.js +41 -0
  27. package/dist/_internal/server/features/lottery/metadata.d.ts +6 -0
  28. package/dist/_internal/server/features/lottery/metadata.js +31 -0
  29. package/dist/_internal/server/features/refunds/__tests__/actions.test.d.ts +1 -0
  30. package/dist/_internal/server/features/refunds/__tests__/actions.test.js +263 -0
  31. package/dist/_internal/server/jobs/core/__tests__/auctionSettlement.test.d.ts +1 -0
  32. package/dist/_internal/server/jobs/core/__tests__/auctionSettlement.test.js +249 -0
  33. package/dist/_internal/server/jobs/core/__tests__/onBidPlaced.test.d.ts +1 -0
  34. package/dist/_internal/server/jobs/core/__tests__/onBidPlaced.test.js +201 -0
  35. package/dist/_internal/server/jobs/core/__tests__/onOrderCreate.test.d.ts +1 -0
  36. package/dist/_internal/server/jobs/core/__tests__/onOrderCreate.test.js +169 -0
  37. package/dist/_internal/server/jobs/core/__tests__/onOrderStatusChange.test.d.ts +1 -0
  38. package/dist/_internal/server/jobs/core/__tests__/onOrderStatusChange.test.js +148 -0
  39. package/dist/_internal/server/jobs/core/__tests__/onProductStockChange.test.d.ts +1 -0
  40. package/dist/_internal/server/jobs/core/__tests__/onProductStockChange.test.js +210 -0
  41. package/dist/_internal/server/jobs/core/__tests__/onReviewWrite.test.d.ts +1 -0
  42. package/dist/_internal/server/jobs/core/__tests__/onReviewWrite.test.js +190 -0
  43. package/dist/_internal/server/jobs/core/__tests__/onUserBanChange.test.d.ts +1 -0
  44. package/dist/_internal/server/jobs/core/__tests__/onUserBanChange.test.js +161 -0
  45. package/dist/_internal/server/jobs/core/__tests__/payoutBatch.test.d.ts +1 -0
  46. package/dist/_internal/server/jobs/core/__tests__/payoutBatch.test.js +302 -0
  47. package/dist/_internal/server/jobs/core/__tests__/triggerEventRaffle.test.d.ts +1 -0
  48. package/dist/_internal/server/jobs/core/__tests__/triggerEventRaffle.test.js +252 -0
  49. package/dist/_internal/server/jobs/core/__tests__/wrapJobHandler.test.d.ts +1 -0
  50. package/dist/_internal/server/jobs/core/__tests__/wrapJobHandler.test.js +129 -0
  51. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  52. package/dist/_internal/shared/actions/action-registry.js +46 -0
  53. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  54. package/dist/client.d.ts +9 -0
  55. package/dist/client.js +9 -0
  56. package/dist/constants/field-names.d.ts +5 -1
  57. package/dist/constants/field-names.js +5 -1
  58. package/dist/features/addresses/repository/__tests__/addresses.repository.test.d.ts +1 -0
  59. package/dist/features/addresses/repository/__tests__/addresses.repository.test.js +386 -0
  60. package/dist/features/admin/api/bids/route.d.ts +2 -2
  61. package/dist/features/admin/api/coupons/route.d.ts +2 -2
  62. package/dist/features/admin/api/products/route.d.ts +2 -2
  63. package/dist/features/admin/api/reviews/route.d.ts +2 -2
  64. package/dist/features/admin/repository/__tests__/notification.repository.test.d.ts +1 -0
  65. package/dist/features/admin/repository/__tests__/notification.repository.test.js +356 -0
  66. package/dist/features/admin/repository/__tests__/site-settings.repository.test.d.ts +1 -0
  67. package/dist/features/admin/repository/__tests__/site-settings.repository.test.js +298 -0
  68. package/dist/features/auctions/repository/__tests__/bid.repository.test.d.ts +1 -0
  69. package/dist/features/auctions/repository/__tests__/bid.repository.test.js +290 -0
  70. package/dist/features/auth/api/route.d.ts +2 -2
  71. package/dist/features/auth/repository/__tests__/session.repository.test.d.ts +1 -0
  72. package/dist/features/auth/repository/__tests__/session.repository.test.js +310 -0
  73. package/dist/features/auth/repository/__tests__/session.test.d.ts +1 -0
  74. package/dist/features/auth/repository/__tests__/session.test.js +146 -0
  75. package/dist/features/auth/repository/__tests__/sms-counter.repository.test.d.ts +1 -0
  76. package/dist/features/auth/repository/__tests__/sms-counter.repository.test.js +171 -0
  77. package/dist/features/auth/repository/__tests__/sms-counter.test.d.ts +1 -0
  78. package/dist/features/auth/repository/__tests__/sms-counter.test.js +88 -0
  79. package/dist/features/auth/repository/__tests__/token.repository.test.d.ts +1 -0
  80. package/dist/features/auth/repository/__tests__/token.repository.test.js +260 -0
  81. package/dist/features/auth/repository/__tests__/token.test.d.ts +1 -0
  82. package/dist/features/auth/repository/__tests__/token.test.js +140 -0
  83. package/dist/features/auth/repository/__tests__/user.repository.test.d.ts +1 -0
  84. package/dist/features/auth/repository/__tests__/user.repository.test.js +352 -0
  85. package/dist/features/auth/repository/__tests__/user.test.d.ts +1 -0
  86. package/dist/features/auth/repository/__tests__/user.test.js +226 -0
  87. package/dist/features/auth/repository/session.repository.js +10 -1
  88. package/dist/features/cart/repository/__tests__/cart.repository.test.d.ts +1 -0
  89. package/dist/features/cart/repository/__tests__/cart.repository.test.js +371 -0
  90. package/dist/features/cart/repository/cart.repository.js +2 -2
  91. package/dist/features/categories/api/[id]/route.d.ts +4 -4
  92. package/dist/features/categories/api/route.d.ts +2 -2
  93. package/dist/features/categories/repository/__tests__/categories.repository.test.d.ts +1 -0
  94. package/dist/features/categories/repository/__tests__/categories.repository.test.js +813 -0
  95. package/dist/features/events/components/EventCard.js +1 -0
  96. package/dist/features/events/repository/__tests__/event.repository.test.d.ts +1 -0
  97. package/dist/features/events/repository/__tests__/event.repository.test.js +299 -0
  98. package/dist/features/events/schemas/firestore.d.ts +3 -0
  99. package/dist/features/events/schemas/index.d.ts +834 -14
  100. package/dist/features/events/schemas/index.js +80 -1
  101. package/dist/features/events/types/index.d.ts +4 -1
  102. package/dist/features/faq/repository/__tests__/faqs.repository.test.d.ts +1 -0
  103. package/dist/features/faq/repository/__tests__/faqs.repository.test.js +311 -0
  104. package/dist/features/history/repository/__tests__/user-history.repository.test.d.ts +1 -0
  105. package/dist/features/history/repository/__tests__/user-history.repository.test.js +180 -0
  106. package/dist/features/homepage/api/[id]/route.d.ts +4 -4
  107. package/dist/features/homepage/api/carousel/[id]/route.d.ts +4 -4
  108. package/dist/features/homepage/api/carousel/route.d.ts +2 -2
  109. package/dist/features/homepage/api/route.d.ts +2 -2
  110. package/dist/features/homepage/repository/__tests__/carousel.repository.test.d.ts +1 -0
  111. package/dist/features/homepage/repository/__tests__/carousel.repository.test.js +182 -0
  112. package/dist/features/homepage/repository/__tests__/homepage-sections.repository.test.d.ts +1 -0
  113. package/dist/features/homepage/repository/__tests__/homepage-sections.repository.test.js +185 -0
  114. package/dist/features/lottery/repository/lottery-entry.repository.d.ts +33 -0
  115. package/dist/features/lottery/repository/lottery-entry.repository.js +130 -0
  116. package/dist/features/lottery/schemas/firestore.d.ts +49 -0
  117. package/dist/features/lottery/schemas/firestore.js +25 -0
  118. package/dist/features/lottery/schemas/zod.d.ts +113 -0
  119. package/dist/features/lottery/schemas/zod.js +39 -0
  120. package/dist/features/lottery/types/index.d.ts +67 -0
  121. package/dist/features/lottery/types/index.js +55 -0
  122. package/dist/features/messages/repository/__tests__/conversations.repository.test.d.ts +1 -0
  123. package/dist/features/messages/repository/__tests__/conversations.repository.test.js +257 -0
  124. package/dist/features/orders/repository/__tests__/orders.repository.test.d.ts +1 -0
  125. package/dist/features/orders/repository/__tests__/orders.repository.test.js +211 -0
  126. package/dist/features/payments/repository/__tests__/payout.repository.test.d.ts +1 -0
  127. package/dist/features/payments/repository/__tests__/payout.repository.test.js +292 -0
  128. package/dist/features/products/api/[id]/route.d.ts +4 -4
  129. package/dist/features/products/api/route.d.ts +2 -2
  130. package/dist/features/products/repository/__tests__/product-features.repository.test.d.ts +1 -0
  131. package/dist/features/products/repository/__tests__/product-features.repository.test.js +329 -0
  132. package/dist/features/products/repository/__tests__/products.repository.test.d.ts +1 -0
  133. package/dist/features/products/repository/__tests__/products.repository.test.js +607 -0
  134. package/dist/features/products/schemas/firestore.d.ts +15 -1
  135. package/dist/features/products/schemas/index.d.ts +12 -12
  136. package/dist/features/products/schemas/index.js +4 -2
  137. package/dist/features/products/types/index.d.ts +1 -1
  138. package/dist/features/promotions/repository/__tests__/claimed-coupons.repository.test.d.ts +1 -0
  139. package/dist/features/promotions/repository/__tests__/claimed-coupons.repository.test.js +219 -0
  140. package/dist/features/promotions/repository/__tests__/coupons.repository.test.d.ts +1 -0
  141. package/dist/features/promotions/repository/__tests__/coupons.repository.test.js +480 -0
  142. package/dist/features/reviews/api/[id]/route.d.ts +4 -4
  143. package/dist/features/reviews/api/route.d.ts +2 -2
  144. package/dist/features/reviews/repository/__tests__/reviews.repository.test.d.ts +1 -0
  145. package/dist/features/reviews/repository/__tests__/reviews.repository.test.js +327 -0
  146. package/dist/features/scams/repository/__tests__/scammer.repository.test.d.ts +1 -0
  147. package/dist/features/scams/repository/__tests__/scammer.repository.test.js +385 -0
  148. package/dist/features/search/repository/__tests__/search.repository.test.d.ts +1 -0
  149. package/dist/features/search/repository/__tests__/search.repository.test.js +369 -0
  150. package/dist/features/seller/api/coupons/route.d.ts +2 -2
  151. package/dist/features/seller/api/offers/route.d.ts +2 -2
  152. package/dist/features/seller/api/products/route.d.ts +2 -2
  153. package/dist/features/seller/api/store/route.d.ts +2 -2
  154. package/dist/features/seller/repository/__tests__/offer.repository.test.d.ts +1 -0
  155. package/dist/features/seller/repository/__tests__/offer.repository.test.js +458 -0
  156. package/dist/features/stores/repository/__tests__/store.repository.test.d.ts +1 -0
  157. package/dist/features/stores/repository/__tests__/store.repository.test.js +261 -0
  158. package/dist/features/support/repository/__tests__/support.repository.test.d.ts +1 -0
  159. package/dist/features/support/repository/__tests__/support.repository.test.js +312 -0
  160. package/dist/features/wishlist/repository/__tests__/user-wishlist.repository.test.d.ts +1 -0
  161. package/dist/features/wishlist/repository/__tests__/user-wishlist.repository.test.js +139 -0
  162. package/dist/index.d.ts +14 -0
  163. package/dist/index.js +17 -0
  164. package/dist/next/api/routeHandler.d.ts +1 -1
  165. package/dist/next/routing/route-map.d.ts +13 -0
  166. package/dist/next/routing/route-map.js +6 -0
  167. package/dist/repositories/index.d.ts +1 -0
  168. package/dist/repositories/index.js +2 -0
  169. package/dist/security/index.d.ts +1 -1
  170. package/dist/security/index.js +1 -1
  171. package/dist/security/pii-schemas.d.ts +2 -0
  172. package/dist/security/pii-schemas.js +6 -0
  173. package/dist/seed/actions/demo-seed-actions.d.ts +1 -1
  174. package/dist/seed/events-seed-data.js +85 -2
  175. package/dist/seed/grouped-listings-seed-data.d.ts +3 -1
  176. package/dist/seed/index.d.ts +1 -0
  177. package/dist/seed/index.js +1 -0
  178. package/dist/seed/lottery-entries-seed-data.d.ts +2 -0
  179. package/dist/seed/lottery-entries-seed-data.js +108 -0
  180. package/dist/seed/manifest.js +1 -0
  181. package/dist/seed/products-preorders-seed-data.d.ts +3 -1
  182. package/dist/server.d.ts +6 -0
  183. package/dist/server.js +6 -0
  184. package/dist/styles.css +1 -1
  185. package/dist/tailwind-utilities.css +1 -1
  186. package/package.json +1 -1
@@ -0,0 +1,243 @@
1
+ "use server";
2
+ import { wrapAction } from "@mohasinac/appkit/server";
3
+ import { getAdminDb } from "../../../../providers/db-firebase";
4
+ import { eventRepository } from "../../../../repositories";
5
+ import { lotteryEntryRepository } from "../../../../features/lottery/repository/lottery-entry.repository";
6
+ import { requireRoleUser } from "../../../../providers/auth-firebase/helpers";
7
+ import { ValidationError } from "../../../shared/errors/index";
8
+ import { submitLotteryPullSchema, flagLotteryEntrySchema, reopenLotterySlotSchema, } from "../../../../features/lottery/schemas/zod";
9
+ import { LotteryError, assignSlotWeights, pickWeightedSlot, } from "../../../../features/lottery/types";
10
+ import { LOTTERY_ENTRIES_COLLECTION } from "../../../../features/lottery/schemas/firestore";
11
+ // ── Helpers ──────────────────────────────────────────────────────────────────
12
+ function isDrawWindowOpen(config) {
13
+ // Draw window is implicitly open when there are unclaimed slots.
14
+ // A real implementation would also check if the event status is "active".
15
+ return config.slots.some((s) => !s.isBooked);
16
+ }
17
+ // ── Public actions ────────────────────────────────────────────────────────────
18
+ /**
19
+ * User-initiated lottery pull.
20
+ * Atomically:
21
+ * 1. Validates inputs + limits
22
+ * 2. Runs weighted slot selection on unclaimed slots
23
+ * 3. Marks the slot as booked
24
+ * 4. Creates the LotteryEntryDocument with assignedPrizeSlotNumber set
25
+ *
26
+ * Returns the assigned slot number so the user sees their prize immediately.
27
+ */
28
+ export async function submitLotteryPullAction(input) {
29
+ return wrapAction(async () => {
30
+ // Auth — must be logged in
31
+ const user = await requireRoleUser(["buyer", "seller", "admin", "moderator", "employee"]);
32
+ const parsed = submitLotteryPullSchema.safeParse(input);
33
+ if (!parsed.success) {
34
+ throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid lottery pull input");
35
+ }
36
+ const data = parsed.data;
37
+ const sourceId = data.sourceType === "event" ? data.eventId : data.productId;
38
+ const sourceField = data.sourceType === "event" ? "eventId" : "productId";
39
+ // Load the event/product to get the lotteryConfig
40
+ let lotteryConfig;
41
+ if (data.sourceType === "event") {
42
+ const event = await eventRepository.findById(sourceId).catch(() => null);
43
+ if (!event || event.type !== "lottery") {
44
+ throw new LotteryError("LOTTERY_WINDOW_CLOSED", "Lottery event not found or not of type lottery.");
45
+ }
46
+ if (event.status !== "active") {
47
+ throw new LotteryError("LOTTERY_WINDOW_CLOSED", "This lottery is not currently active.");
48
+ }
49
+ if (!event.lotteryConfig) {
50
+ throw new LotteryError("LOTTERY_WINDOW_CLOSED", "Lottery config not found on event.");
51
+ }
52
+ lotteryConfig = event.lotteryConfig;
53
+ }
54
+ else {
55
+ // Product lottery — load via product repository (not implemented here; caller ensures correct source)
56
+ throw new ValidationError("Product lottery pull must go through the product lottery pull route.");
57
+ }
58
+ // Compute weights
59
+ const weightedSlots = assignSlotWeights(lotteryConfig);
60
+ // Check draw window
61
+ if (!isDrawWindowOpen(lotteryConfig)) {
62
+ throw new LotteryError("LOTTERY_FULL", "All slots have been claimed.");
63
+ }
64
+ // Enforce maxPullsPerUser
65
+ const existingUserPulls = await lotteryEntryRepository.countByUser(sourceId, sourceField, user.uid);
66
+ if (existingUserPulls >= lotteryConfig.maxPullsPerUser) {
67
+ throw new LotteryError("USER_LIMIT_REACHED", "You have reached the maximum number of pulls for this lottery.");
68
+ }
69
+ // Enforce maxPullsPerTransaction
70
+ const existingTxPulls = await lotteryEntryRepository.countByTransactionId(sourceId, sourceField, data.transactionId);
71
+ if (existingTxPulls >= lotteryConfig.maxPullsPerTransaction) {
72
+ throw new LotteryError("TX_ALREADY_USED", "This transaction ID has already been used the maximum number of times.");
73
+ }
74
+ // Atomically: pick slot + create entry + mark slot booked
75
+ const db = getAdminDb();
76
+ const sourceDocRef = db.collection("events").doc(sourceId);
77
+ const { assignedSlot, userLotteryNumber } = await db.runTransaction(async (tx) => {
78
+ // Re-read lotteryConfig inside transaction for freshness
79
+ const sourceSnap = await tx.get(sourceDocRef);
80
+ if (!sourceSnap.exists)
81
+ throw new LotteryError("LOTTERY_WINDOW_CLOSED", "Lottery source not found.");
82
+ const freshConfig = sourceSnap.data().lotteryConfig;
83
+ if (!freshConfig)
84
+ throw new LotteryError("LOTTERY_WINDOW_CLOSED", "Lottery config missing.");
85
+ const freshWeighted = assignSlotWeights(freshConfig);
86
+ const unclaimed = freshWeighted.filter((s) => !s.isBooked);
87
+ if (unclaimed.length === 0)
88
+ throw new LotteryError("LOTTERY_FULL", "All slots have been claimed.");
89
+ // Weighted pick
90
+ const chosen = pickWeightedSlot(unclaimed);
91
+ // Compute next userLotteryNumber (count + 1 inside tx via collection size)
92
+ const countSnap = await db
93
+ .collection(LOTTERY_ENTRIES_COLLECTION)
94
+ .where(sourceField, "==", sourceId)
95
+ .get();
96
+ const nextNumber = countSnap.size + 1;
97
+ // Mark slot booked in the source document
98
+ const updatedSlots = freshConfig.slots.map((s) => s.slotNumber === chosen.slotNumber
99
+ ? {
100
+ ...s,
101
+ isBooked: true,
102
+ bookedByUserId: user.uid,
103
+ bookedByDisplayName: user.displayName ?? user.name ?? undefined,
104
+ bookedByUserLotteryNumber: nextNumber,
105
+ }
106
+ : s);
107
+ tx.update(sourceDocRef, {
108
+ "lotteryConfig.slots": updatedSlots,
109
+ });
110
+ return { assignedSlot: chosen, userLotteryNumber: nextNumber };
111
+ });
112
+ // Create the entry document outside the transaction (acceptable — slot is already booked)
113
+ const entryData = {
114
+ sourceType: data.sourceType,
115
+ ...(data.sourceType === "event" ? { eventId: sourceId } : { productId: sourceId }),
116
+ userId: user.uid,
117
+ userDisplayName: user.displayName ?? user.name ?? undefined,
118
+ userEmail: user.email ?? undefined,
119
+ userPhone: data.userPhone,
120
+ transactionId: data.transactionId,
121
+ paymentTime: new Date(data.paymentTime),
122
+ purchasedItemNumber: data.purchasedItemNumber,
123
+ userLotteryNumber,
124
+ assignedPrizeSlotNumber: assignedSlot.slotNumber,
125
+ status: "drawn",
126
+ isFlagged: false,
127
+ submittedAt: new Date(),
128
+ drawnAt: new Date(),
129
+ };
130
+ await lotteryEntryRepository.createEntry(entryData);
131
+ return {
132
+ userLotteryNumber,
133
+ assignedPrizeSlotNumber: assignedSlot.slotNumber,
134
+ slotName: assignedSlot.name,
135
+ };
136
+ });
137
+ }
138
+ /**
139
+ * Admin — flag a lottery entry as fraudulent.
140
+ * Slot stays booked; use reopenLotterySlotAction to free it.
141
+ */
142
+ export async function flagLotteryEntryAction(input) {
143
+ return wrapAction(async () => {
144
+ await requireRoleUser(["admin", "moderator"]);
145
+ const parsed = flagLotteryEntrySchema.safeParse(input);
146
+ if (!parsed.success) {
147
+ throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid flag input");
148
+ }
149
+ return lotteryEntryRepository.flagEntry(parsed.data.entryId, parsed.data.flagNote, parsed.data.flaggedByUserId);
150
+ });
151
+ }
152
+ /**
153
+ * Admin — reopen a slot from a flagged entry.
154
+ * Clears isBooked on the slot so it can be claimed in a future pull.
155
+ */
156
+ export async function reopenLotterySlotAction(input) {
157
+ return wrapAction(async () => {
158
+ await requireRoleUser(["admin"]);
159
+ const parsed = reopenLotterySlotSchema.safeParse(input);
160
+ if (!parsed.success) {
161
+ throw new ValidationError(parsed.error.issues[0]?.message ?? "Invalid reopen input");
162
+ }
163
+ const { sourceType, sourceId, slotNumber } = parsed.data;
164
+ const db = getAdminDb();
165
+ const collectionName = sourceType === "event" ? "events" : "products";
166
+ const docRef = db.collection(collectionName).doc(sourceId);
167
+ await db.runTransaction(async (tx) => {
168
+ const snap = await tx.get(docRef);
169
+ if (!snap.exists)
170
+ throw new ValidationError("Source document not found.");
171
+ const config = snap.data().lotteryConfig;
172
+ if (!config?.slots)
173
+ throw new ValidationError("Lottery config not found.");
174
+ const updatedSlots = config.slots.map((s) => s.slotNumber === slotNumber
175
+ ? {
176
+ ...s,
177
+ isBooked: false,
178
+ bookedByUserId: null,
179
+ bookedByDisplayName: null,
180
+ bookedByUserLotteryNumber: null,
181
+ }
182
+ : s);
183
+ tx.update(docRef, { "lotteryConfig.slots": updatedSlots });
184
+ });
185
+ });
186
+ }
187
+ /**
188
+ * Admin — create a new lottery event.
189
+ * Weights computed server-side; never returned to client.
190
+ */
191
+ export async function createLotteryEventAction(data) {
192
+ return wrapAction(async () => {
193
+ const user = await requireRoleUser(["admin", "moderator"]);
194
+ const rawData = data;
195
+ const lotteryConfig = rawData.lotteryConfig;
196
+ if (!lotteryConfig)
197
+ throw new ValidationError("lotteryConfig is required for lottery events.");
198
+ // Compute weights server-side
199
+ const withWeights = assignSlotWeights(lotteryConfig);
200
+ return eventRepository.createEvent({
201
+ ...rawData,
202
+ type: "lottery",
203
+ lotteryConfig: { ...lotteryConfig, slots: withWeights },
204
+ createdBy: user.uid,
205
+ status: rawData.status ?? "draft",
206
+ stats: { totalEntries: 0, approvedEntries: 0, flaggedEntries: 0 },
207
+ });
208
+ });
209
+ }
210
+ /**
211
+ * Admin — update a lottery event.
212
+ * Recomputes slot weights after update.
213
+ */
214
+ export async function updateLotteryEventAction(id, data) {
215
+ return wrapAction(async () => {
216
+ await requireRoleUser(["admin", "moderator"]);
217
+ const rawData = data;
218
+ const lotteryConfig = rawData.lotteryConfig;
219
+ if (lotteryConfig) {
220
+ const withWeights = assignSlotWeights(lotteryConfig);
221
+ return eventRepository.updateEvent(id, {
222
+ ...rawData,
223
+ lotteryConfig: { ...lotteryConfig, slots: withWeights },
224
+ });
225
+ }
226
+ return eventRepository.updateEvent(id, rawData);
227
+ });
228
+ }
229
+ /**
230
+ * Store owner or Admin — switch a prize-draw product into lottery mode.
231
+ * Store owners provide prizeSlotPrice but weight is NEVER returned to them.
232
+ */
233
+ export async function setProductLotteryModeAction(productId, lotteryConfig) {
234
+ return wrapAction(async () => {
235
+ await requireRoleUser(["admin", "moderator", "seller"]);
236
+ const withWeights = assignSlotWeights(lotteryConfig);
237
+ const db = getAdminDb();
238
+ await db.collection("products").doc(productId).update({
239
+ prizeDrawMode: "lottery",
240
+ lotteryConfig: { ...lotteryConfig, slots: withWeights },
241
+ });
242
+ });
243
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Lottery adapters — strips server-only fields (priceInPaise, weight)
3
+ * before any slot/config data leaves the server.
4
+ */
5
+ import type { LotteryConfig, LotterySlot, ClientLotteryConfig, ClientLotterySlot } from "../../../../features/lottery/types";
6
+ /** Strip priceInPaise and weight from a single slot for client consumption. */
7
+ export declare function toClientLotterySlot(slot: LotterySlot): ClientLotterySlot;
8
+ /** Strip price/weight from the entire config for client consumption. */
9
+ export declare function toClientLotteryConfig(config: LotteryConfig): ClientLotteryConfig;
@@ -0,0 +1,21 @@
1
+ /** Strip priceInPaise and weight from a single slot for client consumption. */
2
+ export function toClientLotterySlot(slot) {
3
+ return {
4
+ slotNumber: slot.slotNumber,
5
+ name: slot.name,
6
+ isBooked: slot.isBooked,
7
+ bookedByUserLotteryNumber: slot.bookedByUserLotteryNumber,
8
+ bookedByDisplayName: slot.bookedByDisplayName,
9
+ };
10
+ }
11
+ /** Strip price/weight from the entire config for client consumption. */
12
+ export function toClientLotteryConfig(config) {
13
+ return {
14
+ slots: config.slots.map(toClientLotterySlot),
15
+ totalSlots: config.totalSlots,
16
+ pricingMode: config.pricingMode,
17
+ drawWindowDurationMinutes: config.drawWindowDurationMinutes,
18
+ maxPullsPerTransaction: config.maxPullsPerTransaction,
19
+ maxPullsPerUser: config.maxPullsPerUser,
20
+ };
21
+ }
@@ -0,0 +1,20 @@
1
+ import type { SieveModel } from "../../../../providers/db-firebase";
2
+ import type { EventDocument } from "../../../../features/events/schemas/firestore";
3
+ import type { LotteryEntryDocument } from "../../../../features/lottery/schemas/firestore";
4
+ import type { ClientLotteryConfig } from "../../../../features/lottery/types";
5
+ /** Event shape with lottery config stripped of price/weight. */
6
+ export type LotteryEventClient = Omit<EventDocument, "lotteryConfig"> & {
7
+ lotteryConfig?: ClientLotteryConfig;
8
+ };
9
+ /** Fetch a lottery-type event with price/weight stripped for client use. */
10
+ export declare const getLotteryEventCached: (id: string) => Promise<LotteryEventClient | null>;
11
+ /** List all lottery-type events with price/weight stripped. */
12
+ export declare const listLotteryEvents: (opts?: {
13
+ status?: string;
14
+ page?: number;
15
+ pageSize?: number;
16
+ }) => Promise<LotteryEventClient[]>;
17
+ /** Fetch lottery entries for admin view (includes phone + txId via PII decrypt). */
18
+ export declare function getLotteryEntriesForAdmin(sourceType: "event" | "product", sourceId: string, model: SieveModel): Promise<import("../../../..").FirebaseSieveResult<LotteryEntryDocument>>;
19
+ /** Fetch lottery entries for a specific user (own entries only). */
20
+ export declare function getLotteryEntriesForUser(userId: string, model: SieveModel): Promise<import("../../../..").FirebaseSieveResult<LotteryEntryDocument>>;
@@ -0,0 +1,41 @@
1
+ import { cache } from "react";
2
+ import { eventRepository } from "../../../../repositories";
3
+ import { lotteryEntryRepository } from "../../../../features/lottery/repository/lottery-entry.repository";
4
+ import { toClientLotteryConfig } from "./adapters";
5
+ /** Fetch a lottery-type event with price/weight stripped for client use. */
6
+ export const getLotteryEventCached = cache(async (id) => {
7
+ const event = await eventRepository.findById(id).catch(() => null);
8
+ if (!event || event.type !== "lottery")
9
+ return null;
10
+ return {
11
+ ...event,
12
+ lotteryConfig: event.lotteryConfig ? toClientLotteryConfig(event.lotteryConfig) : undefined,
13
+ };
14
+ });
15
+ /** List all lottery-type events with price/weight stripped. */
16
+ export const listLotteryEvents = cache(async (opts) => {
17
+ // Build Sieve filter: type==lottery, optionally also status
18
+ let filters = "type==lottery";
19
+ if (opts?.status)
20
+ filters += `,status==${opts.status}`;
21
+ const result = await eventRepository.list({
22
+ filters,
23
+ sorts: "-startsAt",
24
+ page: opts?.page ?? 1,
25
+ pageSize: opts?.pageSize ?? 50,
26
+ });
27
+ const items = result.items ?? [];
28
+ return items.map((event) => ({
29
+ ...event,
30
+ lotteryConfig: event.lotteryConfig ? toClientLotteryConfig(event.lotteryConfig) : undefined,
31
+ }));
32
+ });
33
+ /** Fetch lottery entries for admin view (includes phone + txId via PII decrypt). */
34
+ export async function getLotteryEntriesForAdmin(sourceType, sourceId, model) {
35
+ const field = sourceType === "event" ? "eventId" : "productId";
36
+ return lotteryEntryRepository.listForSource(sourceId, field, model);
37
+ }
38
+ /** Fetch lottery entries for a specific user (own entries only). */
39
+ export async function getLotteryEntriesForUser(userId, model) {
40
+ return lotteryEntryRepository.listForUser(userId, model);
41
+ }
@@ -0,0 +1,6 @@
1
+ import type { Metadata } from "next";
2
+ import type { LotteryEventClient } from "./data";
3
+ export declare function buildLotteryMetadata(event: LotteryEventClient | null, opts?: {
4
+ siteName?: string;
5
+ baseUrl?: string;
6
+ }): Metadata;
@@ -0,0 +1,31 @@
1
+ export function buildLotteryMetadata(event, opts) {
2
+ if (!event) {
3
+ return {
4
+ title: "Lottery Not Found",
5
+ description: "This lottery no longer exists.",
6
+ };
7
+ }
8
+ const siteName = opts?.siteName ?? "the platform";
9
+ const title = `${event.title} — ${siteName} Lottery`;
10
+ const description = typeof event.description === "string" && event.description.length > 0
11
+ ? event.description.slice(0, 155)
12
+ : `Enter the ${event.title} lottery on ${siteName}.`;
13
+ const coverUrl = event.coverImage && typeof event.coverImage.url === "string"
14
+ ? event.coverImage.url
15
+ : event.coverImageUrl;
16
+ return {
17
+ title,
18
+ description,
19
+ openGraph: {
20
+ title,
21
+ description,
22
+ ...(coverUrl ? { images: [{ url: coverUrl }] } : {}),
23
+ },
24
+ twitter: {
25
+ card: "summary_large_image",
26
+ title,
27
+ description,
28
+ ...(coverUrl ? { images: [coverUrl] } : {}),
29
+ },
30
+ };
31
+ }
@@ -0,0 +1,263 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ // vi.hoisted: these must be declared before vi.mock factories reference them
3
+ const { mockOrderFindById, mockPostRefundEvent, mockPaymentRefund, mockApplyRefundDeduction, mockCodesGet, } = vi.hoisted(() => {
4
+ const mockCodesQuery = {
5
+ where: vi.fn(),
6
+ limit: vi.fn(),
7
+ get: vi.fn(),
8
+ };
9
+ mockCodesQuery.where.mockReturnValue(mockCodesQuery);
10
+ mockCodesQuery.limit.mockReturnValue(mockCodesQuery);
11
+ return {
12
+ mockOrderFindById: vi.fn(),
13
+ mockPostRefundEvent: vi.fn(),
14
+ mockPaymentRefund: vi.fn(),
15
+ mockApplyRefundDeduction: vi.fn(),
16
+ mockCodesGet: mockCodesQuery.get,
17
+ _mockCodesQuery: mockCodesQuery,
18
+ };
19
+ });
20
+ // Mock appkit/server BEFORE other imports to avoid loading broken dist/client.js
21
+ vi.mock("@mohasinac/appkit/server", () => ({
22
+ wrapAction: async (fn) => {
23
+ try {
24
+ const data = await fn();
25
+ return { ok: true, data };
26
+ }
27
+ catch (err) {
28
+ const msg = err instanceof Error ? err.message : String(err);
29
+ return { ok: false, error: msg };
30
+ }
31
+ },
32
+ isSoftBanned: vi.fn(() => false),
33
+ }));
34
+ vi.mock("../../../../../", () => ({
35
+ orderRepository: {
36
+ findById: mockOrderFindById,
37
+ postRefundEvent: mockPostRefundEvent,
38
+ },
39
+ }));
40
+ vi.mock("../../../../../contracts/registry", () => ({
41
+ getProviders: vi.fn(() => ({
42
+ payment: { refund: mockPaymentRefund },
43
+ })),
44
+ }));
45
+ // Mock the firebase-admin sub-module that provides getAdminDb
46
+ // Chain: db.collection(PRODUCTS).doc(productId).collection(CODES).where("orderId","==",orderId).limit(1).get()
47
+ vi.mock("../../../../../providers/db-firebase/admin", () => {
48
+ const mockCodesQuery = { where: vi.fn(), limit: vi.fn(), get: mockCodesGet };
49
+ mockCodesQuery.where.mockReturnValue(mockCodesQuery);
50
+ mockCodesQuery.limit.mockReturnValue(mockCodesQuery);
51
+ const mockCodesCollection = { where: vi.fn() };
52
+ mockCodesCollection.where.mockReturnValue(mockCodesQuery);
53
+ const mockDoc = { collection: vi.fn(() => mockCodesCollection) };
54
+ const mockTopCollection = { doc: vi.fn(() => mockDoc) };
55
+ const mockDb = { collection: vi.fn(() => mockTopCollection) };
56
+ return { getAdminDb: () => mockDb, getAdminAuth: vi.fn(), getAdminStorage: vi.fn() };
57
+ });
58
+ vi.mock("../../payouts/actions", () => ({
59
+ applyRefundDeductionAction: mockApplyRefundDeduction,
60
+ }));
61
+ vi.mock("../../../../../errors/normalize", () => ({ normalizeError: vi.fn() }));
62
+ vi.mock("../../../../../monitoring", () => ({
63
+ serverLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
64
+ }));
65
+ import { processRefundAction } from "../actions";
66
+ // ─── Helpers ────────────────────────────────────────────────────────────────
67
+ function makeOrder(overrides = {}) {
68
+ return {
69
+ id: "order-1",
70
+ storeId: "store-A",
71
+ totalPrice: 10000,
72
+ paymentMethod: "razorpay",
73
+ paymentId: "pay_abc123",
74
+ isNonRefundable: false,
75
+ items: [],
76
+ ...overrides,
77
+ };
78
+ }
79
+ function refundInput(overrides = {}) {
80
+ return {
81
+ orderId: "order-1",
82
+ type: "partial",
83
+ amountInPaise: 5000,
84
+ reason: "damaged item",
85
+ confirmIrrevocable: true,
86
+ refundedBy: "admin-uid",
87
+ method: "manual",
88
+ ...overrides,
89
+ };
90
+ }
91
+ beforeEach(() => {
92
+ vi.clearAllMocks();
93
+ mockOrderFindById.mockResolvedValue(makeOrder());
94
+ mockPostRefundEvent.mockResolvedValue(undefined);
95
+ mockPaymentRefund.mockResolvedValue({ id: "rfnd_123" });
96
+ mockApplyRefundDeduction.mockResolvedValue(undefined);
97
+ mockCodesGet.mockResolvedValue({ empty: true, docs: [] });
98
+ });
99
+ // ─── Tests ──────────────────────────────────────────────────────────────────
100
+ describe("processRefundAction — validation guards", () => {
101
+ it("order not found → ok: false", async () => {
102
+ mockOrderFindById.mockResolvedValue(null);
103
+ const result = await processRefundAction(refundInput());
104
+ expect(result.ok).toBe(false);
105
+ expect(result.error).toMatch(/not found/i);
106
+ });
107
+ it("amount <= 0 → ok: false (validation)", async () => {
108
+ const result = await processRefundAction(refundInput({ amountInPaise: 0 }));
109
+ expect(result.ok).toBe(false);
110
+ });
111
+ it("negative amount → ok: false (validation)", async () => {
112
+ const result = await processRefundAction(refundInput({ amountInPaise: -100 }));
113
+ expect(result.ok).toBe(false);
114
+ });
115
+ it("amount > order.totalPrice → ok: false", async () => {
116
+ const result = await processRefundAction(refundInput({ amountInPaise: 10001 }));
117
+ expect(result.ok).toBe(false);
118
+ expect(result.error).toMatch(/exceeds/i);
119
+ });
120
+ it("amount === order.totalPrice → ok: true (full refund, not exceeds)", async () => {
121
+ const result = await processRefundAction(refundInput({ amountInPaise: 10000 }));
122
+ expect(result.ok).toBe(true);
123
+ });
124
+ it("isNonRefundable: true → ok: false", async () => {
125
+ mockOrderFindById.mockResolvedValue(makeOrder({ isNonRefundable: true }));
126
+ const result = await processRefundAction(refundInput());
127
+ expect(result.ok).toBe(false);
128
+ expect(result.error).toMatch(/non-refundable/i);
129
+ });
130
+ });
131
+ describe("processRefundAction — digital code guard", () => {
132
+ it("digital code already claimed → ok: false", async () => {
133
+ mockOrderFindById.mockResolvedValue(makeOrder({
134
+ items: [{ productId: "digital-prod-1", listingType: "digital-code" }],
135
+ }));
136
+ mockCodesGet.mockResolvedValue({
137
+ empty: false,
138
+ docs: [{ data: () => ({ status: "claimed" }) }],
139
+ });
140
+ const result = await processRefundAction(refundInput());
141
+ expect(result.ok).toBe(false);
142
+ expect(result.error).toMatch(/digital code|revealed/i);
143
+ });
144
+ it("digital code with status 'available' → refund allowed", async () => {
145
+ mockOrderFindById.mockResolvedValue(makeOrder({
146
+ items: [{ productId: "digital-prod-1", listingType: "digital-code" }],
147
+ }));
148
+ mockCodesGet.mockResolvedValue({
149
+ empty: false,
150
+ docs: [{ data: () => ({ status: "available" }) }],
151
+ });
152
+ const result = await processRefundAction(refundInput());
153
+ expect(result.ok).toBe(true);
154
+ });
155
+ it("no digital code items → digital code check is skipped", async () => {
156
+ mockOrderFindById.mockResolvedValue(makeOrder({ items: [] }));
157
+ const result = await processRefundAction(refundInput());
158
+ expect(result.ok).toBe(true);
159
+ expect(mockCodesGet).not.toHaveBeenCalled();
160
+ });
161
+ it("digital code collection query returns empty → refund allowed", async () => {
162
+ mockOrderFindById.mockResolvedValue(makeOrder({
163
+ items: [{ productId: "digital-prod-1", listingType: "digital-code" }],
164
+ }));
165
+ mockCodesGet.mockResolvedValue({ empty: true, docs: [] });
166
+ const result = await processRefundAction(refundInput());
167
+ expect(result.ok).toBe(true);
168
+ });
169
+ });
170
+ describe("processRefundAction — Razorpay path", () => {
171
+ it("calls payment.refund with correct paymentId and amount", async () => {
172
+ const result = await processRefundAction(refundInput({
173
+ method: "razorpay",
174
+ razorpayPaymentId: "pay_abc123",
175
+ amountInPaise: 5000,
176
+ }));
177
+ expect(result.ok).toBe(true);
178
+ expect(mockPaymentRefund).toHaveBeenCalledWith("pay_abc123", 5000);
179
+ });
180
+ it("Razorpay refund API failure → ok: false, postRefundEvent NOT called", async () => {
181
+ mockPaymentRefund.mockRejectedValue(new Error("Razorpay API error"));
182
+ const result = await processRefundAction(refundInput({
183
+ method: "razorpay",
184
+ razorpayPaymentId: "pay_abc123",
185
+ }));
186
+ expect(result.ok).toBe(false);
187
+ expect(mockPostRefundEvent).not.toHaveBeenCalled();
188
+ });
189
+ it("razorpayRefundId from provider is stored in event", async () => {
190
+ mockPaymentRefund.mockResolvedValue({ id: "rfnd_xyz789" });
191
+ await processRefundAction(refundInput({
192
+ method: "razorpay",
193
+ razorpayPaymentId: "pay_abc123",
194
+ }));
195
+ expect(mockPostRefundEvent).toHaveBeenCalledWith("order-1", expect.objectContaining({ razorpayRefundId: "rfnd_xyz789" }), expect.any(Boolean));
196
+ });
197
+ });
198
+ describe("processRefundAction — manual path", () => {
199
+ it("does NOT call payment.refund for manual refunds", async () => {
200
+ await processRefundAction(refundInput({ method: "manual" }));
201
+ expect(mockPaymentRefund).not.toHaveBeenCalled();
202
+ });
203
+ it("calls postRefundEvent without razorpayRefundId", async () => {
204
+ await processRefundAction(refundInput({ method: "manual" }));
205
+ const event = mockPostRefundEvent.mock.calls[0][1];
206
+ expect(event.razorpayRefundId).toBeUndefined();
207
+ });
208
+ it("stores manualTransactionId when provided", async () => {
209
+ await processRefundAction(refundInput({ method: "manual", manualTransactionId: "TXN123" }));
210
+ const event = mockPostRefundEvent.mock.calls[0][1];
211
+ expect(event.manualTransactionId).toBe("TXN123");
212
+ });
213
+ });
214
+ describe("processRefundAction — success path", () => {
215
+ it("calls orderRepository.postRefundEvent with correct orderId", async () => {
216
+ await processRefundAction(refundInput());
217
+ expect(mockPostRefundEvent).toHaveBeenCalledWith("order-1", expect.any(Object), expect.any(Boolean));
218
+ });
219
+ it("partial amount: isFull = false passed to postRefundEvent", async () => {
220
+ await processRefundAction(refundInput({ amountInPaise: 5000 }));
221
+ const isFull = mockPostRefundEvent.mock.calls[0][2];
222
+ expect(isFull).toBe(false);
223
+ });
224
+ it("full amount: isFull = true passed to postRefundEvent", async () => {
225
+ await processRefundAction(refundInput({ amountInPaise: 10000 }));
226
+ const isFull = mockPostRefundEvent.mock.calls[0][2];
227
+ expect(isFull).toBe(true);
228
+ });
229
+ it("returns ok: true with success: true and a refundId UUID", async () => {
230
+ const result = await processRefundAction(refundInput());
231
+ expect(result.ok).toBe(true);
232
+ const data = result.data;
233
+ expect(data.success).toBe(true);
234
+ expect(data.refundId).toMatch(/^[0-9a-f-]{36}$/i);
235
+ });
236
+ it("event contains reason, refundedBy, and refundedAt", async () => {
237
+ await processRefundAction(refundInput({ reason: "broken product", refundedBy: "admin-1" }));
238
+ const event = mockPostRefundEvent.mock.calls[0][1];
239
+ expect(event.reason).toBe("broken product");
240
+ expect(event.refundedBy).toBe("admin-1");
241
+ expect(event.refundedAt).toBeInstanceOf(Date);
242
+ });
243
+ });
244
+ describe("processRefundAction — payout deduction", () => {
245
+ it("triggers applyRefundDeductionAction when order has storeId", async () => {
246
+ await processRefundAction(refundInput({ amountInPaise: 5000 }));
247
+ expect(mockApplyRefundDeduction).toHaveBeenCalledWith(expect.objectContaining({
248
+ storeId: "store-A",
249
+ orderId: "order-1",
250
+ refundedAmountInPaise: 5000,
251
+ }));
252
+ });
253
+ it("does NOT call applyRefundDeductionAction when order has no storeId", async () => {
254
+ mockOrderFindById.mockResolvedValue(makeOrder({ storeId: undefined }));
255
+ await processRefundAction(refundInput());
256
+ expect(mockApplyRefundDeduction).not.toHaveBeenCalled();
257
+ });
258
+ it("deduction failure does NOT fail the refund (fire-and-forget)", async () => {
259
+ mockApplyRefundDeduction.mockRejectedValue(new Error("Payout service unavailable"));
260
+ const result = await processRefundAction(refundInput());
261
+ expect(result.ok).toBe(true);
262
+ });
263
+ });