@mohasinac/appkit 4.63.5 → 4.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/categories/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/categories/adapters.js +13 -0
  4. package/dist/_internal/server/features/events/like-actions.d.ts +25 -0
  5. package/dist/_internal/server/features/events/like-actions.js +67 -0
  6. package/dist/_internal/server/features/events/likes.d.ts +65 -0
  7. package/dist/_internal/server/features/events/likes.js +146 -0
  8. package/dist/_internal/server/features/seo/index.d.ts +3 -0
  9. package/dist/_internal/server/features/seo/index.js +2 -0
  10. package/dist/_internal/server/features/seo/product-detail-types.d.ts +1 -0
  11. package/dist/_internal/server/features/seo/product-detail-types.js +26 -0
  12. package/dist/_internal/server/features/seo/robots.js +20 -1
  13. package/dist/_internal/server/features/seo/sitemap.js +125 -27
  14. package/dist/_internal/server/features/seo/static-params.d.ts +107 -0
  15. package/dist/_internal/server/features/seo/static-params.js +228 -0
  16. package/dist/_internal/server/features/seo/taxonomy-links.d.ts +44 -0
  17. package/dist/_internal/server/features/seo/taxonomy-links.js +85 -0
  18. package/dist/_internal/server/features/stores/adapters.d.ts +2 -2
  19. package/dist/_internal/server/features/stores/adapters.js +87 -1
  20. package/dist/_internal/server/functions/scheduled.d.ts +16 -1
  21. package/dist/_internal/server/functions/scheduled.js +23 -1
  22. package/dist/_internal/server/jobs/core/sellerStandingRollup.d.ts +63 -0
  23. package/dist/_internal/server/jobs/core/sellerStandingRollup.js +412 -0
  24. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  25. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  26. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.d.ts +12 -0
  27. package/dist/_internal/server/jobs/handlers/sellerStandingRollup.js +14 -0
  28. package/dist/_internal/shared/actions/action-registry.js +61 -0
  29. package/dist/_internal/shared/features/seller-standing/config.d.ts +130 -0
  30. package/dist/_internal/shared/features/seller-standing/config.js +153 -0
  31. package/dist/constants/api-endpoints.d.ts +24 -0
  32. package/dist/constants/api-endpoints.js +8 -0
  33. package/dist/features/about/components/HelpPageView.js +9 -1
  34. package/dist/features/account/components/BecomeSellerView.d.ts +15 -3
  35. package/dist/features/account/components/BecomeSellerView.js +2 -2
  36. package/dist/features/admin/components/AdminStoreEditorView.d.ts +12 -1
  37. package/dist/features/admin/components/AdminStoreEditorView.js +30 -2
  38. package/dist/features/admin/components/AdminStoresGuideView.js +1 -1
  39. package/dist/features/admin/components/AdminStoresView.js +59 -2
  40. package/dist/features/admin/components/AdminTrustGuideView.js +10 -0
  41. package/dist/features/admin/schemas/admin-editor-forms.d.ts +10 -0
  42. package/dist/features/admin/schemas/admin-editor-forms.js +34 -0
  43. package/dist/features/blog/components/BlogIndexPageView.js +2 -2
  44. package/dist/features/blog/components/BlogListView.js +2 -1
  45. package/dist/features/blog/components/BlogPostView.js +2 -1
  46. package/dist/features/blog/constants/category-badge.d.ts +33 -0
  47. package/dist/features/blog/constants/category-badge.js +40 -0
  48. package/dist/features/categories/components/TaxonomyLinkIndex.d.ts +39 -0
  49. package/dist/features/categories/components/TaxonomyLinkIndex.js +11 -0
  50. package/dist/features/categories/schemas/firestore.d.ts +25 -0
  51. package/dist/features/categories/utils/load-category-options.d.ts +33 -0
  52. package/dist/features/categories/utils/load-category-options.js +50 -0
  53. package/dist/features/events/components/EventCard.js +4 -12
  54. package/dist/features/events/components/EventInsights.d.ts +27 -0
  55. package/dist/features/events/components/EventInsights.js +10 -0
  56. package/dist/features/events/components/EventLikeButton.d.ts +21 -0
  57. package/dist/features/events/components/EventLikeButton.js +59 -0
  58. package/dist/features/events/components/EventLikersRail.d.ts +23 -0
  59. package/dist/features/events/components/EventLikersRail.js +54 -0
  60. package/dist/features/events/components/EventTypeBadge.d.ts +26 -0
  61. package/dist/features/events/components/EventTypeBadge.js +7 -0
  62. package/dist/features/events/components/EventsListPageView.js +2 -2
  63. package/dist/features/events/components/SpinWheel.d.ts +41 -0
  64. package/dist/features/events/components/SpinWheel.js +128 -0
  65. package/dist/features/events/components/SpinWheelView.js +36 -15
  66. package/dist/features/events/constants/event-type-meta.d.ts +54 -0
  67. package/dist/features/events/constants/event-type-meta.js +109 -0
  68. package/dist/features/events/repository/event-likes.repository.d.ts +57 -0
  69. package/dist/features/events/repository/event-likes.repository.js +151 -0
  70. package/dist/features/events/schemas/firestore.d.ts +111 -0
  71. package/dist/features/events/schemas/likes.d.ts +81 -0
  72. package/dist/features/events/schemas/likes.js +59 -0
  73. package/dist/features/events/types/index.d.ts +7 -0
  74. package/dist/features/events/utils/carousel-slide.d.ts +42 -0
  75. package/dist/features/events/utils/carousel-slide.js +174 -0
  76. package/dist/features/events/utils/event-charts.d.ts +65 -0
  77. package/dist/features/events/utils/event-charts.js +172 -0
  78. package/dist/features/faq/constants/help-guides.js +9 -0
  79. package/dist/features/faq/schemas/firestore.js +1 -0
  80. package/dist/features/faq/types/index.d.ts +19 -0
  81. package/dist/features/homepage/components/BrandsSection.js +25 -1
  82. package/dist/features/homepage/components/HeroCarousel.js +47 -10
  83. package/dist/features/homepage/types/index.d.ts +7 -0
  84. package/dist/features/stores/components/InteractiveStoreCard.js +2 -1
  85. package/dist/features/stores/components/SellerStandingPanel.d.ts +31 -0
  86. package/dist/features/stores/components/SellerStandingPanel.js +25 -0
  87. package/dist/features/stores/components/SellerTierBadge.d.ts +57 -0
  88. package/dist/features/stores/components/SellerTierBadge.js +19 -0
  89. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +32 -2
  90. package/dist/features/stores/components/StoreCreateForm.d.ts +46 -0
  91. package/dist/features/stores/components/StoreCreateForm.js +107 -0
  92. package/dist/features/stores/components/StoreGuideHubView.js +17 -4
  93. package/dist/features/stores/components/StoreHeader.js +2 -1
  94. package/dist/features/stores/repository/store.repository.js +23 -0
  95. package/dist/features/stores/schemas/firestore.d.ts +60 -1
  96. package/dist/features/stores/schemas/firestore.js +18 -0
  97. package/dist/features/stores/schemas/index.d.ts +2 -2
  98. package/dist/features/stores/schemas/store-create.d.ts +47 -0
  99. package/dist/features/stores/schemas/store-create.js +86 -0
  100. package/dist/features/stores/types/index.d.ts +42 -0
  101. package/dist/next/routing/route-map.d.ts +3 -1
  102. package/dist/next/routing/route-map.js +13 -0
  103. package/dist/schemas/registry.d.ts +10 -0
  104. package/dist/server.d.ts +5 -0
  105. package/dist/server.js +10 -0
  106. package/dist/styles.css +36 -1
  107. package/dist/tailwind-utilities.css +1 -1
  108. package/dist/ui/components/HorizontalScroller.js +15 -2
  109. package/dist/ui/components/PageHeroHeading.d.ts +40 -0
  110. package/dist/ui/components/PageHeroHeading.js +15 -0
  111. package/dist/ui/components/Typography.d.ts +3 -3
  112. package/dist/ui/components/Typography.js +59 -31
  113. package/dist/ui/components/Typography.style.css +35 -0
  114. package/dist/ui/index.d.ts +2 -0
  115. package/dist/ui/index.js +1 -0
  116. package/package.json +1 -1
  117. package/scripts/seed-cli.mjs +4 -2
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Core: MONTHLY seller-standing rollup — grants and revokes the Trusted
3
+ * Seller tier, and with it early payout.
4
+ *
5
+ * ## What it writes
6
+ *
7
+ * `stores/{id}.standing` — the cached metrics (this IS the cache; the seller's
8
+ * progress panel, the public badge and the admin view all read it off the
9
+ * store document they already load, and none of them recomputes anything).
10
+ * On a transition it also writes `sellerTier` and `trustedSeller` TOGETHER.
11
+ *
12
+ * 🛑 `trustedSeller` moves MONEY. It is read by both payout sweeps
13
+ * (`weeklyPayoutEligibility`, `autoPayoutEligibility`) via
14
+ * `storeRepository.findTrustedStoreIds()`, and it means the store is paid at
15
+ * SHIPPING instead of at delivery — so the platform carries a lost parcel with
16
+ * the money already gone. That is why this job is the only automatic writer,
17
+ * why every transition gets an audit row, and why an admin can pin a store
18
+ * with `standing.tierLockedByAdmin`.
19
+ *
20
+ * 🛑 The flag's author wrote, at `store.repository.ts`: *"trust is granted by
21
+ * hand, one store at a time, on risk grounds. If this ever saturates, the
22
+ * grant has stopped being deliberate."* Automatic granting is exactly that
23
+ * case, accepted deliberately by the owner. The mitigations are real and must
24
+ * not be quietly dropped: both a distinct-BUYER and a distinct-ADDRESS floor,
25
+ * self-bought orders excluded, test data excluded, and the 500-store bound on
26
+ * `findTrustedStoreIds` left in place so saturation is visible.
27
+ */
28
+ import { storeRepository } from "../../../../repositories/index.js";
29
+ import { ORDER_FIELDS, REVIEW_FIELDS } from "../../../../constants/field-names.js";
30
+ import { recordAdminAction } from "../../features/audit-log/actions.js";
31
+ import { AdminAuditActionValues } from "../../../../features/audit-log/schemas/firestore.js";
32
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions.js";
33
+ import { normalizeError } from "../../../../errors/normalize.js";
34
+ import { evaluateSellerStanding, SELLER_STANDING_DEFAULTS, SELLER_TIER, } from "../../../shared/features/seller-standing/config.js";
35
+ const EMPTY_RESULT = {
36
+ examined: 0,
37
+ written: 0,
38
+ granted: [],
39
+ revoked: [],
40
+ locked: 0,
41
+ skippedNotMonthEnd: true,
42
+ };
43
+ /** Firestore page size for the two windowed scans. */
44
+ const SCAN_PAGE = 500;
45
+ // ---------------------------------------------------------------------------
46
+ // The month-end guard
47
+ // ---------------------------------------------------------------------------
48
+ /**
49
+ * 🛑 THIS IS WHY THE CRON IS `0 2 28-31 * *` AND NOT `0 0 31 * *`.
50
+ *
51
+ * Cloud Scheduler uses unix-cron, which has no `L` (last-day) token. A cron on
52
+ * day 31 **never fires in February, April, June, September or November** — it
53
+ * would silently skip 5 of 12 months, and the symptom is not an error but a
54
+ * tier that updates "sometimes". So the schedule fires on each of the last four
55
+ * candidate days and this guard keeps exactly the real one.
56
+ *
57
+ * Uses UTC parts throughout, matching the scheduler's declared `timeZone: UTC`.
58
+ * A local-time implementation would disagree with the trigger near midnight.
59
+ */
60
+ export function isLastDayOfMonth(d) {
61
+ const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1));
62
+ return next.getUTCMonth() !== d.getUTCMonth();
63
+ }
64
+ /** The next month-end at or after `d`, for the seller-facing `nextComputeAt`. */
65
+ export function nextLastDayOfMonth(d) {
66
+ // Day 0 of the following month is the last day of this one.
67
+ const endOfThis = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0));
68
+ if (endOfThis.getTime() > d.getTime())
69
+ return endOfThis;
70
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 2, 0));
71
+ }
72
+ const emptyTally = () => ({
73
+ deliveredOrders: 0,
74
+ buyers: new Set(),
75
+ addresses: new Set(),
76
+ ratingSum: 0,
77
+ ratingCount: 0,
78
+ });
79
+ function toMetrics(t) {
80
+ return {
81
+ deliveredOrders: t.deliveredOrders,
82
+ distinctBuyers: t.buyers.size,
83
+ distinctAddresses: t.addresses.size,
84
+ ratedOrders: t.ratingCount,
85
+ /*
86
+ * 0 when there are no ratings, never NaN. `ratingSum / 0` is NaN, and NaN
87
+ * fails every `>=` comparison — which happens to give the right verdict
88
+ * here but writes NaN into Firestore, where it is rejected outright on
89
+ * some paths and renders as "NaN" in the seller's panel on others.
90
+ */
91
+ averageRating: t.ratingCount === 0 ? 0 : t.ratingSum / t.ratingCount,
92
+ };
93
+ }
94
+ function isTestRow(data) {
95
+ /*
96
+ * Both spellings. `isTestData` is the legacy boolean and `seedPurpose` is
97
+ * the newer field it is derived from; a row may carry either, so testing one
98
+ * would let the other through and let a tester fixture count toward a real
99
+ * money-moving grant.
100
+ */
101
+ if (data.isTestData === true)
102
+ return true;
103
+ return data.seedPurpose === "test" || data.seedPurpose === "tester";
104
+ }
105
+ export async function runSellerStandingRollup(ctx, opts = {}) {
106
+ if (!opts.force && !isLastDayOfMonth(ctx.now)) {
107
+ ctx.logger.info("sellerStandingRollup: not month-end, skipping", {
108
+ now: ctx.now.toISOString(),
109
+ });
110
+ return { ...EMPTY_RESULT };
111
+ }
112
+ /*
113
+ * 🛑 The CONSTANT, not a settings read.
114
+ *
115
+ * This briefly folded an admin override from `siteSettings.sellerStanding`
116
+ * onto the defaults. That made the job disagree with `/seller-guide` and
117
+ * `/help/seller-badges`, which render their criteria from the code defaults
118
+ * — so retuning the numbers would have changed what the tier is granted on
119
+ * while both public pages went on promising the old figures. One constant,
120
+ * every surface, agreeing by construction.
121
+ */
122
+ const thresholds = SELLER_STANDING_DEFAULTS;
123
+ const cutoff = new Date(ctx.now.getTime() - thresholds.windowDays * 24 * 60 * 60 * 1000);
124
+ const nextComputeAt = nextLastDayOfMonth(ctx.now);
125
+ ctx.logger.info("sellerStandingRollup: starting", {
126
+ windowDays: thresholds.windowDays,
127
+ cutoff: cutoff.toISOString(),
128
+ forced: Boolean(opts.force),
129
+ });
130
+ // ── Stores ───────────────────────────────────────────────────────────────
131
+ /*
132
+ * Enumerate STORES, not the orders' distinct storeIds. A store that sold
133
+ * nothing this window must still be evaluated, because that is precisely the
134
+ * store whose tier should be REVOKED — a reconciler built from the events
135
+ * that exist can only ever add (Root Cause #102).
136
+ */
137
+ const stores = new Map();
138
+ const storeSnap = await ctx.db
139
+ .collection("stores")
140
+ .select("ownerId", "status", "sellerTier", "trustedSeller", "tierLockedByAdmin")
141
+ .get();
142
+ for (const doc of storeSnap.docs) {
143
+ const d = doc.data();
144
+ stores.set(doc.id, {
145
+ id: doc.id,
146
+ ownerId: d.ownerId,
147
+ status: d.status,
148
+ sellerTier: d.sellerTier,
149
+ trustedSeller: d.trustedSeller,
150
+ tierLockedByAdmin: d.tierLockedByAdmin === true,
151
+ });
152
+ }
153
+ const tallies = new Map();
154
+ const tally = (storeId) => {
155
+ let t = tallies.get(storeId);
156
+ if (!t) {
157
+ t = emptyTally();
158
+ tallies.set(storeId, t);
159
+ }
160
+ return t;
161
+ };
162
+ // ── Orders in the window ─────────────────────────────────────────────────
163
+ /*
164
+ * 🛑 Windowed on `orderDate`, which is REQUIRED, not on `deliveryDate`,
165
+ * which is optional and written by exactly one job (`obligationSweep`). A
166
+ * Firestore inequality EXCLUDES every document lacking the field, so a range
167
+ * on `deliveryDate` would silently drop every order a seller marked
168
+ * delivered by hand — returning a confident undercount with no error
169
+ * anywhere (Root Cause #100).
170
+ *
171
+ * One collection-wide windowed scan rather than a query per store: a
172
+ * per-store `storeId == X AND orderDate >= cutoff` needs a composite index,
173
+ * and a missing one fails as FAILED_PRECONDITION that every caller here
174
+ * would have to not-swallow (Root Cause #2). A single-field inequality is
175
+ * served by the automatic index. `status` is filtered in memory for the same
176
+ * reason.
177
+ */
178
+ let orderCursor = null;
179
+ let ordersScanned = 0;
180
+ for (;;) {
181
+ let q = ctx.db
182
+ .collection("orders")
183
+ .where(ORDER_FIELDS.ORDER_DATE, ">=", cutoff)
184
+ .orderBy(ORDER_FIELDS.ORDER_DATE, "asc")
185
+ .limit(SCAN_PAGE);
186
+ if (orderCursor)
187
+ q = q.startAfter(orderCursor);
188
+ const snap = await q.get();
189
+ if (snap.empty)
190
+ break;
191
+ for (const doc of snap.docs) {
192
+ const d = doc.data();
193
+ ordersScanned += 1;
194
+ if (d.status !== "delivered")
195
+ continue;
196
+ if (isTestRow(d))
197
+ continue;
198
+ const storeId = d.storeId;
199
+ if (!storeId)
200
+ continue;
201
+ const store = stores.get(storeId);
202
+ if (!store)
203
+ continue;
204
+ const userId = d.userId;
205
+ /*
206
+ * 🛑 Self-bought orders do not count. Without this a seller reaches the
207
+ * tier — and early payout — by buying from themselves 25 times, which is
208
+ * the cheapest possible attack on a criterion that releases money early.
209
+ */
210
+ if (userId && store.ownerId && userId === store.ownerId)
211
+ continue;
212
+ const t = tally(storeId);
213
+ t.deliveredOrders += 1;
214
+ if (userId)
215
+ t.buyers.add(userId);
216
+ /*
217
+ * 🛑 `deliveryAddressId` — the `addresses` row this order was taken
218
+ * from. It is a provenance pointer, NOT PII and NOT encrypted, so
219
+ * counting distinct values needs no decryption and no fingerprint of
220
+ * our own.
221
+ *
222
+ * Orders predating the delivery snapshot carry none and contribute
223
+ * nothing to this criterion. That is forward-only by design: inventing
224
+ * a key from `deliveryPincode` instead would count two different
225
+ * households in one pincode as one address, and the encrypted
226
+ * `deliveryAddressLine1` cannot be read here at all.
227
+ *
228
+ * Honest about its limit: a determined seller can save the same physical
229
+ * place as several address rows. The distinct-BUYER floor is the real
230
+ * defence; this one raises the cost.
231
+ */
232
+ if (d.deliveryAddressId)
233
+ t.addresses.add(d.deliveryAddressId);
234
+ }
235
+ orderCursor = snap.docs[snap.docs.length - 1];
236
+ if (snap.size < SCAN_PAGE)
237
+ break;
238
+ }
239
+ // ── Reviews in the window ────────────────────────────────────────────────
240
+ /*
241
+ * Windowed on `createdAt`, which every document carries via `BaseDocument`.
242
+ * `ReviewDocument` has no `publishedAt` field at all — CLAUDE.md's seed
243
+ * table says it does, and that is stale.
244
+ */
245
+ let reviewCursor = null;
246
+ for (;;) {
247
+ let q = ctx.db
248
+ .collection("reviews")
249
+ .where(REVIEW_FIELDS.CREATED_AT, ">=", cutoff)
250
+ .orderBy(REVIEW_FIELDS.CREATED_AT, "asc")
251
+ .limit(SCAN_PAGE);
252
+ if (reviewCursor)
253
+ q = q.startAfter(reviewCursor);
254
+ const snap = await q.get();
255
+ if (snap.empty)
256
+ break;
257
+ for (const doc of snap.docs) {
258
+ const d = doc.data();
259
+ if (d.status !== "approved")
260
+ continue;
261
+ if (isTestRow(d))
262
+ continue;
263
+ const storeId = d.storeId;
264
+ if (!storeId || !stores.has(storeId))
265
+ continue;
266
+ const rating = Number(d.rating);
267
+ if (!Number.isFinite(rating))
268
+ continue;
269
+ const t = tally(storeId);
270
+ t.ratingSum += rating;
271
+ t.ratingCount += 1;
272
+ }
273
+ reviewCursor = snap.docs[snap.docs.length - 1];
274
+ if (snap.size < SCAN_PAGE)
275
+ break;
276
+ }
277
+ // ── Evaluate + write ─────────────────────────────────────────────────────
278
+ /*
279
+ * 🛑 Through the REPOSITORY, not `ctx.db.update()`.
280
+ *
281
+ * `adminUpdate` runs `withStoreHistory`, and `sellerTier` / `trustedSeller`
282
+ * are both in `STORE_TRACKED_FIELDS` — so a transition earns a
283
+ * `statusHistory` entry stamped `actorRole: "system"`. A raw document write
284
+ * would move money with no timeline entry, which is the one thing the
285
+ * timeline exists for on this field.
286
+ *
287
+ * `prior` is the projected shell rather than a full document, which is safe
288
+ * and deliberate: `withHistory` diffs only `tracked` fields that appear in
289
+ * the PATCH, and the patch only ever carries `standing` (untracked),
290
+ * `sellerTier` and `trustedSeller` — both of which the shell selects. The
291
+ * cast says exactly that and nothing more.
292
+ */
293
+ const writeStore = (store, patch) => storeRepository.adminUpdate(store.id, patch, { actor: { role: "system" }, trigger: "sellerStandingRollup" }, store);
294
+ const granted = [];
295
+ const revoked = [];
296
+ let written = 0;
297
+ let locked = 0;
298
+ for (const store of stores.values()) {
299
+ const metrics = toMetrics(tallies.get(store.id) ?? emptyTally());
300
+ const verdict = evaluateSellerStanding(metrics, thresholds);
301
+ /*
302
+ * `standing` is a pure MEASUREMENT — no admin state in it. The pin lives
303
+ * at the top level precisely so this object can be rebuilt wholesale every
304
+ * month without carrying a decision forward or needing to be merged.
305
+ */
306
+ const standing = {
307
+ computedAt: ctx.now,
308
+ nextComputeAt,
309
+ windowDays: thresholds.windowDays,
310
+ ...metrics,
311
+ met: verdict.met,
312
+ };
313
+ /*
314
+ * An admin-pinned store still gets a REFRESHED `standing` — the seller
315
+ * should see their real figures — but its tier is left exactly where the
316
+ * admin put it. Writing the metrics while not acting on them is the whole
317
+ * point of the lock.
318
+ */
319
+ if (store.tierLockedByAdmin) {
320
+ locked += 1;
321
+ await writeStore(store, { standing });
322
+ written += 1;
323
+ continue;
324
+ }
325
+ /*
326
+ * 🛑 A store that is not ACTIVE can never hold the tier. Suspension
327
+ * already clears it immediately (see `storeRepository.setStatus`), and
328
+ * this is the belt: a suspended store must not be re-granted early payout
329
+ * by a month-end run just because its 90-day history still looks good.
330
+ */
331
+ const eligible = store.status === "active" && verdict.tier === SELLER_TIER.TRUSTED;
332
+ const currentTier = store.sellerTier ?? SELLER_TIER.SELLER;
333
+ const nextTier = eligible ? SELLER_TIER.TRUSTED : SELLER_TIER.SELLER;
334
+ const update = { standing };
335
+ const isTransition = nextTier !== currentTier || Boolean(store.trustedSeller) !== eligible;
336
+ if (isTransition) {
337
+ update.sellerTier = nextTier;
338
+ update.trustedSeller = eligible;
339
+ if (eligible)
340
+ granted.push(store.id);
341
+ else
342
+ revoked.push(store.id);
343
+ }
344
+ await writeStore(store, update);
345
+ written += 1;
346
+ if (!isTransition)
347
+ continue;
348
+ /*
349
+ * 🛑 Audit and notify on a REAL transition only. Re-saving the monthly
350
+ * `standing` on an unchanged store would otherwise write an audit row
351
+ * describing a decision nobody made and email a seller about nothing —
352
+ * and a log of non-events is how a log stops being read. Same rule the
353
+ * admin PATCH route applies to this exact field.
354
+ */
355
+ void recordAdminAction({
356
+ actorUid: "system",
357
+ action: AdminAuditActionValues.STORE_TRUST_CHANGE,
358
+ targetType: "store",
359
+ targetId: store.id,
360
+ targetLabel: store.id,
361
+ reason: eligible
362
+ ? "Met every Trusted Seller criterion at the monthly recompute"
363
+ : "Fell below a Trusted Seller criterion at the monthly recompute",
364
+ metadata: {
365
+ trustedSeller: eligible,
366
+ previous: Boolean(store.trustedSeller),
367
+ sellerTier: nextTier,
368
+ trigger: "sellerStandingRollup",
369
+ ...metrics,
370
+ },
371
+ });
372
+ if (!store.ownerId)
373
+ continue;
374
+ /*
375
+ * The seller is told, because the consequence is concrete in both
376
+ * directions: gaining the tier changes when they get paid, and losing it
377
+ * changes it back. Fire-and-forget — a notification failure must not
378
+ * abort a sweep that has already written the tier.
379
+ */
380
+ void sendNotification({
381
+ userId: store.ownerId,
382
+ type: "system",
383
+ priority: "normal",
384
+ title: eligible ? "You're now a Trusted Seller" : "Trusted Seller status paused",
385
+ message: eligible
386
+ ? "Your store met every Trusted Seller criterion this month. Your badge is live and your payouts now start when you ship, rather than on delivery."
387
+ : `Your store no longer meets every Trusted Seller criterion, so the badge is paused and payouts return to starting on delivery. ${verdict.unmetCount === 1 ? "One criterion" : `${verdict.unmetCount} criteria`} to regain it — see your seller guide for the current figures.`,
388
+ }).catch((err) => {
389
+ const e = normalizeError(err);
390
+ ctx.logger.warn("sellerStandingRollup: notification failed", {
391
+ storeId: store.id,
392
+ error: e.message,
393
+ });
394
+ });
395
+ }
396
+ ctx.logger.info("sellerStandingRollup: complete", {
397
+ examined: stores.size,
398
+ ordersScanned,
399
+ written,
400
+ granted: granted.length,
401
+ revoked: revoked.length,
402
+ locked,
403
+ });
404
+ return {
405
+ examined: stores.size,
406
+ written,
407
+ granted,
408
+ revoked,
409
+ locked,
410
+ skippedNotMonthEnd: false,
411
+ };
412
+ }
@@ -32,6 +32,7 @@ export { hardBanReinstatementHandler } from "./hardBanReinstatement";
32
32
  export { paymentReviewAutoApproveHandler } from "./paymentReviewAutoApprove";
33
33
  export { productStatsSyncHandler } from "./productStatsSync";
34
34
  export { revenueRollupHandler } from "./revenueRollup";
35
+ export { sellerStandingRollupHandler } from "./sellerStandingRollup";
35
36
  export { priceIndexRollupHandler } from "./priceIndexRollup";
36
37
  export { marketPriceRollupHandler } from "./marketPriceRollup";
37
38
  export { searchIndexBuildHandler } from "./searchIndexBuild";
@@ -32,6 +32,7 @@ export { hardBanReinstatementHandler } from "./hardBanReinstatement.js";
32
32
  export { paymentReviewAutoApproveHandler } from "./paymentReviewAutoApprove.js";
33
33
  export { productStatsSyncHandler } from "./productStatsSync.js";
34
34
  export { revenueRollupHandler } from "./revenueRollup.js";
35
+ export { sellerStandingRollupHandler } from "./sellerStandingRollup.js";
35
36
  export { priceIndexRollupHandler } from "./priceIndexRollup.js";
36
37
  export { marketPriceRollupHandler } from "./marketPriceRollup.js";
37
38
  export { searchIndexBuildHandler } from "./searchIndexBuild.js";
@@ -0,0 +1,12 @@
1
+ import type { ScheduleHandler } from "../runtime/types";
2
+ /**
3
+ * 🛑 No `force`. The schedule fires on days 28–31 and the core's month-end
4
+ * guard keeps exactly the real last day — see `isLastDayOfMonth` for why the
5
+ * cron cannot simply say "31".
6
+ *
7
+ * `ScheduleHandler` returns `void`, so the result object is dropped here. It
8
+ * is not lost: the core logs `granted`/`revoked`/`locked` counts, and it is
9
+ * returned for the admin recompute path and for verification, which call the
10
+ * core directly with `{ force: true }`.
11
+ */
12
+ export declare const sellerStandingRollupHandler: ScheduleHandler;
@@ -0,0 +1,14 @@
1
+ import { runSellerStandingRollup } from "../core/sellerStandingRollup.js";
2
+ /**
3
+ * 🛑 No `force`. The schedule fires on days 28–31 and the core's month-end
4
+ * guard keeps exactly the real last day — see `isLastDayOfMonth` for why the
5
+ * cron cannot simply say "31".
6
+ *
7
+ * `ScheduleHandler` returns `void`, so the result object is dropped here. It
8
+ * is not lost: the core logs `granted`/`revoked`/`locked` counts, and it is
9
+ * returned for the admin recompute path and for verification, which call the
10
+ * core directly with `{ force: true }`.
11
+ */
12
+ export const sellerStandingRollupHandler = async (ctx) => {
13
+ await runSellerStandingRollup(ctx);
14
+ };
@@ -771,6 +771,22 @@ export const ACTIONS = {
771
771
  },
772
772
  },
773
773
  STORE: {
774
+ /*
775
+ * Open a storefront — the submit button for the store-creation form.
776
+ *
777
+ * No `confirmation`: creating a store is additive, reversible by the
778
+ * seller, and produces a `pending` / non-public document that no buyer can
779
+ * see until an admin approves it. A dialog here would be friction on the
780
+ * one step the seller came to perform.
781
+ */
782
+ "create-store": {
783
+ iconKey: "create",
784
+ id: "store.create-store",
785
+ label: "Create my store",
786
+ ariaLabel: "Create your store",
787
+ description: "Create the seller's storefront. The store starts pending and private until an admin approves it.",
788
+ kind: "primary",
789
+ },
774
790
  "follow": {
775
791
  iconKey: "wishlist",
776
792
  id: "store.follow",
@@ -2228,6 +2244,51 @@ export const ACTIONS = {
2228
2244
  confirmKind: "danger",
2229
2245
  },
2230
2246
  },
2247
+ /*
2248
+ * 🛑 Approve/reject a seller's PENDING store — the step that was missing.
2249
+ *
2250
+ * A store is created `status: "pending"`, `isPublic: false`, and until
2251
+ * 2026-10-24 nothing in any admin UI could move it: `AdminStoresView` had
2252
+ * verify and suspend row actions only, and the one action named for this
2253
+ * job (`adminUpdateStoreStatus`) writes the USER-level `storeStatus`
2254
+ * mirror and never touches the store document — so wiring that would have
2255
+ * marked the applicant approved while leaving their store invisible, with
2256
+ * no error. Both of these go through the store PATCH route, i.e. through
2257
+ * `storeRepository.setStatus`, which is the only writer that keeps
2258
+ * `status` and `isPublic` in step (Root Cause #42).
2259
+ *
2260
+ * Approve carries a confirmation despite being `primary`: it is what makes
2261
+ * a storefront publicly visible, and that is outward-facing.
2262
+ */
2263
+ "approve-store": {
2264
+ iconKey: "approve",
2265
+ id: "admin.approve-store",
2266
+ label: "Approve store",
2267
+ ariaLabel: "Approve this store and make it public",
2268
+ description: "Approve a pending store — sets it active and makes its listings visible to buyers.",
2269
+ kind: "primary",
2270
+ permissions: ["admin"],
2271
+ confirmation: {
2272
+ title: "Approve this store?",
2273
+ body: "The store becomes active and its listings become visible to buyers. The seller will be notified.",
2274
+ confirmLabel: "Approve store",
2275
+ },
2276
+ },
2277
+ "reject-store": {
2278
+ iconKey: "reject",
2279
+ id: "admin.reject-store",
2280
+ label: "Reject store",
2281
+ ariaLabel: "Reject this store application",
2282
+ description: "Reject a pending store application — the store stays hidden from buyers.",
2283
+ kind: "danger",
2284
+ permissions: ["admin"],
2285
+ confirmation: {
2286
+ title: "Reject this store application?",
2287
+ body: "The store will not be published and the seller will be notified. You can approve it later if they resolve the issue.",
2288
+ confirmLabel: "Reject store",
2289
+ confirmKind: "danger",
2290
+ },
2291
+ },
2231
2292
  // ── Review moderation ──────────────────────────────────────────────────
2232
2293
  "approve-review": {
2233
2294
  iconKey: "approve",
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Seller standing — the ONE definition of what earns the Trusted Seller tier.
3
+ *
4
+ * ## Why this is a single pure module
5
+ *
6
+ * Four surfaces must agree about the criteria: the monthly rollup job that
7
+ * grants and revokes, the seller's in-dashboard progress panel, the public
8
+ * `/seller-guide` criteria table, and the admin trust guide. Four hand-written
9
+ * copies of "25 delivered orders in 90 days" is Root Cause #73/#75's shape —
10
+ * the copy that gets fixed is never the copy the reader believes. So the
11
+ * thresholds, the predicate AND the display rows all come from here.
12
+ *
13
+ * 🛑 It had a fifth copy before this existed, and it was prose:
14
+ * `AdminStoresGuideView` told admins the verified badge was "awarded when the
15
+ * store has a consistent track record (>=20 delivered orders, >=4.0 rating, 3+
16
+ * months active)" — numbers no code has ever read, attached to a field nothing
17
+ * computes. That sentence is what the whole feature was remembered as being.
18
+ *
19
+ * ## Pure, and that is load-bearing
20
+ *
21
+ * No Firestore, no clock, no `Date.now()`. The job passes in metrics it has
22
+ * already measured and the UI passes in metrics it has already loaded, so the
23
+ * same input always produces the same verdict and the panel can never disagree
24
+ * with the grant.
25
+ */
26
+ /**
27
+ * 🛑 A `Record<Criterion, true>`, not a string array.
28
+ *
29
+ * Adding a criterion then becomes a COMPILE error at every site that must
30
+ * display or evaluate it — the thresholds, the labels, the verdict's `met`
31
+ * map. An array lets a new criterion be evaluated while silently rendering
32
+ * nowhere, which is how ten hand-maintained enumerations of one union drifted
33
+ * in Root Cause #61.
34
+ */
35
+ declare const SELLER_STANDING_CRITERION_MAP: {
36
+ readonly deliveredOrders: true;
37
+ readonly distinctBuyers: true;
38
+ readonly distinctAddresses: true;
39
+ readonly ratedOrders: true;
40
+ readonly averageRating: true;
41
+ };
42
+ export type SellerStandingCriterion = keyof typeof SELLER_STANDING_CRITERION_MAP;
43
+ export declare const SELLER_STANDING_CRITERIA: SellerStandingCriterion[];
44
+ /** The two tiers. Ordered: `trusted` is strictly above `seller`. */
45
+ export type SellerTier = "seller" | "trusted";
46
+ export declare const SELLER_TIER: {
47
+ readonly SELLER: "seller";
48
+ readonly TRUSTED: "trusted";
49
+ };
50
+ export interface SellerStandingThresholds {
51
+ /** The rolling window every metric is measured over. */
52
+ windowDays: number;
53
+ minDeliveredOrders: number;
54
+ minDistinctBuyers: number;
55
+ minDistinctAddresses: number;
56
+ minRatedOrders: number;
57
+ /** A mean, 1–5. Compared with `>=`. */
58
+ minAverageRating: number;
59
+ }
60
+ /**
61
+ * The shipped defaults.
62
+ *
63
+ * 🛑 Every count is measured INSIDE `windowDays`, not lifetime. That is the
64
+ * strict reading and it was chosen deliberately: the tier grants early payout,
65
+ * so it has to describe a seller who is shipping reliably *now*, not one who
66
+ * did two years ago. The consequence worth knowing up front is that on a
67
+ * freshly seeded catalogue **nobody qualifies**, and that is the correct
68
+ * result rather than a broken job.
69
+ *
70
+ * Admin-overridable via `siteSettings.sellerStanding` so the bar can be tuned
71
+ * once real volume exists, without a deploy.
72
+ */
73
+ export declare const SELLER_STANDING_DEFAULTS: SellerStandingThresholds;
74
+ export interface SellerStandingMetrics {
75
+ /** Delivered orders inside the window, excluding self-bought and test rows. */
76
+ deliveredOrders: number;
77
+ /** Distinct buyer uids across those orders. */
78
+ distinctBuyers: number;
79
+ /** Distinct shipping-address fingerprints across those orders. */
80
+ distinctAddresses: number;
81
+ /** Approved reviews inside the window. */
82
+ ratedOrders: number;
83
+ /** Mean of those reviews' ratings, 1–5. `0` when there are none. */
84
+ averageRating: number;
85
+ }
86
+ export interface SellerStandingVerdict {
87
+ tier: SellerTier;
88
+ met: Record<SellerStandingCriterion, boolean>;
89
+ /** How many criteria are still short — drives "1 criterion to go". */
90
+ unmetCount: number;
91
+ }
92
+ /**
93
+ * Does this seller currently meet the bar?
94
+ *
95
+ * 🛑 ALL criteria must hold. There is no scoring and no partial credit: the
96
+ * reward is being paid before delivery, so "mostly reliable" is not a state
97
+ * this can grant on.
98
+ */
99
+ export declare function evaluateSellerStanding(metrics: SellerStandingMetrics, thresholds?: SellerStandingThresholds): SellerStandingVerdict;
100
+ export declare const SELLER_STANDING_CRITERION_LABELS: Record<SellerStandingCriterion, string>;
101
+ /**
102
+ * Why each criterion exists, in the seller's words. Rendered on the guide
103
+ * pages — a bar with no stated reason reads as arbitrary, and two of these
104
+ * exist specifically to make the tier hard to manufacture.
105
+ */
106
+ export declare const SELLER_STANDING_CRITERION_WHY: Record<SellerStandingCriterion, string>;
107
+ export type SellerStandingRowKind = "count" | "rating";
108
+ export interface SellerStandingRow {
109
+ criterion: SellerStandingCriterion;
110
+ label: string;
111
+ why: string;
112
+ kind: SellerStandingRowKind;
113
+ /** The seller's current figure. `null` when standing has never been computed. */
114
+ actual: number | null;
115
+ target: number;
116
+ met: boolean;
117
+ }
118
+ /**
119
+ * One row per criterion, for rendering.
120
+ *
121
+ * `metrics` may be null — a store whose standing has never been computed shows
122
+ * every target with no figure beside it, which is honest. 🛑 It must NOT show
123
+ * zeros: a confident "0 of 25" on a store that simply has not been measured
124
+ * yet is the same lie as a counter that has never been written reading as 0
125
+ * (Root Cause #72).
126
+ */
127
+ export declare function sellerStandingRows(metrics: SellerStandingMetrics | null | undefined, thresholds?: SellerStandingThresholds): SellerStandingRow[];
128
+ /** `4` → `"4.0"`, `25` → `"25"`. Keeps rating and count formatting in one place. */
129
+ export declare function formatSellerStandingValue(value: number, kind: SellerStandingRowKind): string;
130
+ export {};