@mohasinac/appkit 4.46.0 → 4.48.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 (175) hide show
  1. package/dist/.build-stamp.json +4 -4
  2. package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
  3. package/dist/_internal/server/features/messages/actions.d.ts +68 -0
  4. package/dist/_internal/server/features/messages/actions.js +249 -0
  5. package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
  6. package/dist/_internal/server/features/parts/buildable.js +159 -0
  7. package/dist/_internal/server/features/products/list-public.d.ts +17 -0
  8. package/dist/_internal/server/features/products/list-public.js +92 -5
  9. package/dist/_internal/server/features/products/og.d.ts +7 -0
  10. package/dist/_internal/server/features/products/og.js +38 -0
  11. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  12. package/dist/_internal/server/features/site-settings/adapters.js +6 -0
  13. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
  14. package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
  15. package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
  16. package/dist/_internal/shared/actions/action-registry.js +8 -0
  17. package/dist/_internal/shared/features/notifications/action-url.js +8 -0
  18. package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
  19. package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
  20. package/dist/client.d.ts +10 -0
  21. package/dist/client.js +8 -0
  22. package/dist/constants/api-endpoints.d.ts +48 -0
  23. package/dist/constants/api-endpoints.js +30 -0
  24. package/dist/constants/notification-types.d.ts +1 -1
  25. package/dist/features/about/components/GstExplainerView.d.ts +28 -0
  26. package/dist/features/about/components/GstExplainerView.js +59 -0
  27. package/dist/features/about/components/GuidesHubView.d.ts +28 -0
  28. package/dist/features/about/components/GuidesHubView.js +58 -0
  29. package/dist/features/about/components/PublicProfileView.js +5 -0
  30. package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
  31. package/dist/features/about/components/SpotAFakeView.js +49 -0
  32. package/dist/features/admin/actions/notification-actions.js +4 -0
  33. package/dist/features/admin/schemas/firestore.d.ts +13 -3
  34. package/dist/features/admin/schemas/firestore.js +8 -0
  35. package/dist/features/blog/repository/blog.repository.js +21 -7
  36. package/dist/features/categories/api/route.js +41 -1
  37. package/dist/features/categories/components/BreadcrumbTrail.d.ts +58 -13
  38. package/dist/features/categories/components/BreadcrumbTrail.js +63 -26
  39. package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
  40. package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
  41. package/dist/features/categories/components/ContextualTrail.js +81 -0
  42. package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
  43. package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
  44. package/dist/features/categories/repository/categories.repository.d.ts +21 -0
  45. package/dist/features/categories/repository/categories.repository.js +36 -3
  46. package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
  47. package/dist/features/email/notification-templates.js +4 -0
  48. package/dist/features/events/repository/events.repository.js +10 -3
  49. package/dist/features/faq/constants/read-caps.d.ts +29 -0
  50. package/dist/features/faq/constants/read-caps.js +29 -0
  51. package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
  52. package/dist/features/faq/repository/faqs.repository.js +19 -16
  53. package/dist/features/homepage/components/AdSlot.js +10 -4
  54. package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
  55. package/dist/features/homepage/hooks/useActiveAd.js +42 -2
  56. package/dist/features/homepage/lib/section-renderer.js +12 -2
  57. package/dist/features/layout/AppLayoutShell.d.ts +4 -1
  58. package/dist/features/layout/AppLayoutShell.js +2 -2
  59. package/dist/features/layout/TitleBarLayout.d.ts +4 -1
  60. package/dist/features/layout/TitleBarLayout.js +8 -4
  61. package/dist/features/media/AvatarUpload.js +8 -1
  62. package/dist/features/media/MediaAvatar.js +1 -1
  63. package/dist/features/media/MediaImage.js +3 -1
  64. package/dist/features/media/MediaLightbox.js +4 -1
  65. package/dist/features/media/MediaPickerModal.js +6 -1
  66. package/dist/features/media/MediaVideo.js +3 -1
  67. package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
  68. package/dist/features/messages/components/ConversationThreadView.js +75 -0
  69. package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
  70. package/dist/features/messages/components/ConversationsInboxView.js +39 -0
  71. package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
  72. package/dist/features/messages/components/MessageSellerButton.js +33 -0
  73. package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
  74. package/dist/features/messages/hooks/useConversationChannel.js +89 -0
  75. package/dist/features/messages/hooks/useMessages.d.ts +347 -0
  76. package/dist/features/messages/hooks/useMessages.js +113 -0
  77. package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
  78. package/dist/features/messages/repository/conversations.repository.js +173 -0
  79. package/dist/features/messages/repository/messages.repository.d.ts +42 -0
  80. package/dist/features/messages/repository/messages.repository.js +142 -0
  81. package/dist/features/messages/schemas/firestore.d.ts +99 -0
  82. package/dist/features/messages/schemas/firestore.js +46 -0
  83. package/dist/features/orders/repository/orders.repository.js +21 -7
  84. package/dist/features/orders/schemas/firestore.d.ts +11 -0
  85. package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
  86. package/dist/features/products/components/CityCollectiblesView.js +82 -0
  87. package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
  88. package/dist/features/products/components/MarketPriceAnchor.js +62 -0
  89. package/dist/features/products/components/ProductDetailPageView.js +71 -5
  90. package/dist/features/products/constants/meetup-cities.d.ts +47 -0
  91. package/dist/features/products/constants/meetup-cities.js +49 -0
  92. package/dist/features/products/repository/category-assignment.d.ts +82 -11
  93. package/dist/features/products/repository/category-assignment.js +93 -12
  94. package/dist/features/products/repository/products.repository.d.ts +12 -1
  95. package/dist/features/products/repository/products.repository.js +66 -34
  96. package/dist/features/products/schemas/firestore.d.ts +24 -2
  97. package/dist/features/products/schemas/firestore.js +13 -1
  98. package/dist/features/products/schemas/index.d.ts +2 -2
  99. package/dist/features/promotions/repository/coupons.repository.js +11 -4
  100. package/dist/features/reviews/repository/reviews.repository.js +11 -4
  101. package/dist/features/scams/repository/scammer.repository.js +21 -7
  102. package/dist/features/seller/components/CategoryInlineSelect.d.ts +17 -1
  103. package/dist/features/seller/components/CategoryInlineSelect.js +14 -3
  104. package/dist/features/seller/components/SellerProductShell.d.ts +27 -2
  105. package/dist/features/seller/components/SellerProductShell.js +32 -3
  106. package/dist/features/seller/repository/offer.repository.js +21 -6
  107. package/dist/features/seller/schemas/product-form.d.ts +3 -3
  108. package/dist/features/seller/schemas/product-form.js +1 -1
  109. package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
  110. package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
  111. package/dist/features/server-errors/schemas/firestore.js +2 -0
  112. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
  113. package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
  114. package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
  115. package/dist/features/store-extensions/schemas/firestore.js +4 -0
  116. package/dist/features/stores/repository/store.repository.js +11 -3
  117. package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
  118. package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
  119. package/dist/features/tester/seed-data/authored/index.js +2 -0
  120. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
  121. package/dist/index.d.ts +8 -2
  122. package/dist/index.js +1 -1
  123. package/dist/next/routing/route-map.d.ts +14 -0
  124. package/dist/next/routing/route-map.js +26 -0
  125. package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
  126. package/dist/providers/db-firebase/rtdb-paths.js +8 -0
  127. package/dist/repositories/index.d.ts +1 -0
  128. package/dist/repositories/index.js +4 -0
  129. package/dist/schemas/registry.d.ts +3 -3
  130. package/dist/security/index.d.ts +1 -1
  131. package/dist/security/index.js +1 -1
  132. package/dist/security/pii-schemas.d.ts +17 -0
  133. package/dist/security/pii-schemas.js +15 -7
  134. package/dist/seed/_helpers/category-content-generated.d.ts +24 -0
  135. package/dist/seed/_helpers/category-content-generated.js +1853 -0
  136. package/dist/seed/_helpers/category-models.d.ts +1 -1
  137. package/dist/seed/_helpers/category-models.js +25 -1
  138. package/dist/seed/_helpers/category-names.d.ts +54 -0
  139. package/dist/seed/_helpers/category-names.js +118 -0
  140. package/dist/seed/_helpers/character-features.d.ts +1 -1
  141. package/dist/seed/_helpers/character-features.js +5 -4
  142. package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
  143. package/dist/seed/categories-seed-data.js +55 -1
  144. package/dist/seed/grouped-listings-seed-data.js +2 -2
  145. package/dist/seed/products-art-seed-data.js +2 -1
  146. package/dist/seed/products-auctions-seed-data.js +42 -22
  147. package/dist/seed/products-classifieds-seed-data.js +21 -3
  148. package/dist/seed/products-digital-codes-seed-data.js +2 -1
  149. package/dist/seed/products-live-items-seed-data.js +2 -1
  150. package/dist/seed/products-preorders-seed-data.js +26 -11
  151. package/dist/seed/products-prize-draws-seed-data.js +8 -1
  152. package/dist/seed/products-standard-seed-data.js +26 -25
  153. package/dist/seed/products-stickers-seed-data.js +12 -11
  154. package/dist/server.d.ts +8 -0
  155. package/dist/server.js +15 -0
  156. package/dist/styles.css +113 -1
  157. package/dist/tailwind-utilities.css +1 -1
  158. package/dist/tokens/themes/sunset.js +17 -0
  159. package/dist/tokens/tokens.css +5 -0
  160. package/dist/ui/components/Breadcrumb.d.ts +8 -1
  161. package/dist/ui/components/Breadcrumb.js +2 -2
  162. package/dist/ui/components/Breadcrumb.style.css +37 -0
  163. package/dist/ui/components/Details.d.ts +11 -1
  164. package/dist/ui/components/Details.js +2 -2
  165. package/dist/ui/components/ImageMosaic.d.ts +43 -0
  166. package/dist/ui/components/ImageMosaic.js +46 -0
  167. package/dist/ui/components/ImageMosaic.style.css +69 -0
  168. package/dist/ui/components/SplitTile.d.ts +34 -0
  169. package/dist/ui/components/SplitTile.js +28 -0
  170. package/dist/ui/components/index.style.css +1 -0
  171. package/dist/ui/index.d.ts +4 -0
  172. package/dist/ui/index.js +4 -0
  173. package/dist/utils/search-txt-query.d.ts +77 -0
  174. package/dist/utils/search-txt-query.js +50 -28
  175. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.46.0",
4
- "head": "34ce98d7f80659ca1052873c7444d69dde36c07b",
3
+ "version": "4.48.0",
4
+ "head": "237eb52ee20b10cd3726d750be7934aa9e0e262e",
5
5
  "branch": "main",
6
- "dirty": true,
7
- "builtAt": "2026-10-13T00:19:07.494Z"
6
+ "dirty": false,
7
+ "builtAt": "2026-10-14T02:31:05.049Z"
8
8
  }
@@ -87,6 +87,8 @@ export function expandCartLineToOrderRows(item, representative, productById) {
87
87
  ...(representative.brand ? { brand: representative.brand } : {}),
88
88
  ...(representative.listingType ? { listingType: representative.listingType } : {}),
89
89
  ...(representative.condition ? { condition: representative.condition } : {}),
90
+ // I9 — the physical variant, for the price index's colourway slice.
91
+ ...(representative.colourway ? { colourway: representative.colourway } : {}),
90
92
  // Legacy collapsed bundle rows (no groupMembers) keep their old shape so
91
93
  // orders already in flight and their UI stay untouched.
92
94
  ...(item.bundleCategorySlug && item.bundleProductIds
@@ -143,6 +145,7 @@ export function expandCartLineToOrderRows(item, representative, productById) {
143
145
  // Per MEMBER — a bundle can mix a sealed item with a loose one, and a
144
146
  // blended condition would describe neither.
145
147
  ...(product?.condition ? { condition: product.condition } : {}),
148
+ ...(product?.colourway ? { colourway: product.colourway } : {}),
146
149
  ...(gstRate != null ? { gstRate, ...(hsnCode ? { hsnCode } : {}) } : {}),
147
150
  };
148
151
  });
@@ -0,0 +1,68 @@
1
+ import { deleteAllMessages, listAllAttachmentUrls } from "../../../../features/messages/repository/messages.repository";
2
+ import type { ConversationDocument, MessageDocument } from "../../../../features/messages/schemas/firestore";
3
+ /**
4
+ * Strips the three obvious PII shapes from a chat message before it is
5
+ * written. Promoted here from the item-request replies route per D9's spec —
6
+ * same ordering fix (specific-before-general: UPI scheme, then anything with a
7
+ * TLD dot is an email, then any remaining `token@token` has no dot and is
8
+ * therefore a VPA, only then bare digit runs), because that ordering IS the
9
+ * whole fix (F1 #5 — the old order let the email rule eat UPI-shaped VPAs and
10
+ * the phone rule mangle `9876543210@upi` before the UPI rule ever saw it).
11
+ */
12
+ export declare function stripPii(input: string): string;
13
+ export declare function createOrOpenConversation(userId: string, otherUserId: string): Promise<ConversationDocument>;
14
+ /**
15
+ * D11 surfaced this gap: the backend had a full create/open path and no UI
16
+ * entry point ever called it — "Message Seller" did not exist anywhere. A
17
+ * product/store page knows `storeId` (the slug), never the seller's uid
18
+ * (Store Identity Architecture: storeId=storeSlug, ownerId=Auth UID), so the
19
+ * resolution happens here, server-side, rather than piping ownerId through
20
+ * every page that might want this button.
21
+ */
22
+ export declare function openConversationWithSeller(buyerId: string, storeId: string): Promise<ConversationDocument>;
23
+ export declare function listConversationsForUser(userId: string, opts?: {
24
+ limit?: number;
25
+ cursor?: string;
26
+ }): Promise<{
27
+ items: ConversationDocument[];
28
+ nextCursor: string | null;
29
+ }>;
30
+ export declare function listMessagesForConversation(userId: string, conversationId: string, opts?: {
31
+ limit?: number;
32
+ cursor?: string;
33
+ }): Promise<{
34
+ items: MessageDocument[];
35
+ nextCursor: string | null;
36
+ }>;
37
+ export interface SendMessageInput {
38
+ conversationId: string;
39
+ authorId: string;
40
+ body: string;
41
+ clientMessageId: string;
42
+ /** Already-uploaded `/media/` proxy URLs, still possibly in tmp/. */
43
+ attachments?: string[];
44
+ productId?: string;
45
+ }
46
+ /**
47
+ * The one writer path. Every mutation here runs through the Admin SDK — the
48
+ * old feature attempted client writes against `.write: false` and that is
49
+ * precisely what made it inert.
50
+ */
51
+ export declare function sendMessage(input: SendMessageInput): Promise<MessageDocument>;
52
+ export declare function markConversationRead(userId: string, conversationId: string): Promise<void>;
53
+ export declare function blockConversationParticipant(userId: string, conversationId: string): Promise<void>;
54
+ export declare function softDeleteOwnMessage(userId: string, conversationId: string, messageId: string): Promise<void>;
55
+ /**
56
+ * Mint a per-conversation custom token, matching `{ authEventId }` /
57
+ * `{ paymentEventId }` / `{ bulkJobId }` — no broad token, ever. The RTDB auth
58
+ * app's uid is synthetic and unrelated to the user's real uid, same as the
59
+ * payment-event precedent: the realtime channel's identity and the user's
60
+ * session identity are deliberately decoupled.
61
+ */
62
+ export declare function mintChatEventToken(userId: string, conversationId: string): Promise<{
63
+ customToken: string;
64
+ expiresAt: number;
65
+ }>;
66
+ /** Unread rollup for the nav badge — never a list query (D9 spec). */
67
+ export declare function getUnreadConversationCount(userId: string): Promise<number>;
68
+ export { deleteAllMessages, listAllAttachmentUrls };
@@ -0,0 +1,249 @@
1
+ import { normalizeError } from "../../../../errors/normalize.js";
2
+ import { AuthorizationError, NotFoundError, ValidationError } from "../../../../errors/index.js";
3
+ import { getAdminAuth, getAdminRealtimeDb } from "../../../../providers/db-firebase/index.js";
4
+ import { RTDB_PATHS } from "../../../../providers/db-firebase/rtdb-paths.js";
5
+ import { serverLogger } from "../../../../monitoring/index.js";
6
+ import { userRepository } from "../../../../features/auth/repository/user.repository.js";
7
+ import { isSoftBanned } from "../../../../features/auth/server/checkSoftBan.js";
8
+ import { isSellerUser } from "../../../../features/auth/role-predicates.js";
9
+ import { conversationsRepository } from "../../../../features/messages/repository/conversations.repository.js";
10
+ import { storeRepository } from "../../../../features/stores/repository/store.repository.js";
11
+ import { createMessage, listMessages, softDeleteMessage, deleteAllMessages, listAllAttachmentUrls, } from "../../../../features/messages/repository/messages.repository.js";
12
+ import { finalizeStagedMediaArray } from "../../../../features/media/finalize.js";
13
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions.js";
14
+ import { productRepository } from "../../../../features/products/repository/products.repository.js";
15
+ import { siteSettingsRepository } from "../../../../repositories/index.js";
16
+ const MESSAGE_MAX_LENGTH = 2000;
17
+ /**
18
+ * Off by default — `undefined` reads as OFF, never a default-true `??`. A
19
+ * real setting with a real cost: the feature was deleted once already for
20
+ * shipping broken, and re-enabling it site-wide without an explicit opt-in
21
+ * would repeat that without the proof-of-value this plan asks for first.
22
+ */
23
+ async function assertChatEnabled() {
24
+ const settings = await siteSettingsRepository.getSingleton();
25
+ if (settings?.chat?.enabled !== true) {
26
+ throw new AuthorizationError("Messaging is not currently available.");
27
+ }
28
+ }
29
+ /**
30
+ * Strips the three obvious PII shapes from a chat message before it is
31
+ * written. Promoted here from the item-request replies route per D9's spec —
32
+ * same ordering fix (specific-before-general: UPI scheme, then anything with a
33
+ * TLD dot is an email, then any remaining `token@token` has no dot and is
34
+ * therefore a VPA, only then bare digit runs), because that ordering IS the
35
+ * whole fix (F1 #5 — the old order let the email rule eat UPI-shaped VPAs and
36
+ * the phone rule mangle `9876543210@upi` before the UPI rule ever saw it).
37
+ */
38
+ export function stripPii(input) {
39
+ return input
40
+ .replace(/upi:\/\/\S+/gi, "[UPI removed]")
41
+ .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "[email removed]")
42
+ .replace(/[\w.-]{2,}@[\w-]{2,}/g, "[UPI removed]")
43
+ .replace(/\b\d{10,12}\b/g, "[number removed]");
44
+ }
45
+ function otherParticipant(conversation, userId) {
46
+ const other = conversation.participantIds.find((id) => id !== userId);
47
+ if (!other) {
48
+ throw new ValidationError("Conversation has no other participant.");
49
+ }
50
+ return other;
51
+ }
52
+ function assertParticipant(conversation, userId) {
53
+ if (!conversation.participantIds.includes(userId)) {
54
+ throw new AuthorizationError("You are not a participant in this conversation.");
55
+ }
56
+ }
57
+ /** Shared `findById` + not-found throw — every mutator below needs both. */
58
+ async function requireConversation(conversationId) {
59
+ const conversation = await conversationsRepository.findById(conversationId);
60
+ if (!conversation)
61
+ throw new NotFoundError("Conversation not found.");
62
+ return conversation;
63
+ }
64
+ /**
65
+ * 🛑 `send_messages` is a live `BannedAction` with no reader anywhere in the
66
+ * codebase (F1 #6) — an admin can set it today and nothing checks it. This is
67
+ * the missing reader, in the domain function per the same precedent
68
+ * `assertCanBid` set for `place_bids`: enforcement belongs here so every
69
+ * caller (route, future job) is covered by construction.
70
+ */
71
+ async function assertCanSendMessages(userId) {
72
+ const profile = await userRepository.findById(userId);
73
+ if (profile && isSoftBanned(profile, "send_messages")) {
74
+ const ban = profile.softBans?.find((b) => b.action === "send_messages");
75
+ throw new AuthorizationError(`You are restricted from sending messages. Reason: ${ban?.reason ?? "Policy violation"}.`);
76
+ }
77
+ }
78
+ export async function createOrOpenConversation(userId, otherUserId) {
79
+ await assertChatEnabled();
80
+ if (userId === otherUserId) {
81
+ throw new ValidationError("Cannot start a conversation with yourself.");
82
+ }
83
+ const existing = await conversationsRepository.findBetween(userId, otherUserId);
84
+ if (existing)
85
+ return existing;
86
+ const [me, other] = await Promise.all([
87
+ userRepository.findById(userId),
88
+ userRepository.findById(otherUserId),
89
+ ]);
90
+ if (!other) {
91
+ throw new NotFoundError("The other participant could not be found.");
92
+ }
93
+ const roleFor = (u) => (isSellerUser(u) ? "seller" : "buyer");
94
+ const roles = {
95
+ [userId]: roleFor(me),
96
+ [otherUserId]: roleFor(other),
97
+ };
98
+ return conversationsRepository.createBetween(userId, otherUserId, roles);
99
+ }
100
+ /**
101
+ * D11 surfaced this gap: the backend had a full create/open path and no UI
102
+ * entry point ever called it — "Message Seller" did not exist anywhere. A
103
+ * product/store page knows `storeId` (the slug), never the seller's uid
104
+ * (Store Identity Architecture: storeId=storeSlug, ownerId=Auth UID), so the
105
+ * resolution happens here, server-side, rather than piping ownerId through
106
+ * every page that might want this button.
107
+ */
108
+ export async function openConversationWithSeller(buyerId, storeId) {
109
+ const store = await storeRepository.findById(storeId);
110
+ if (!store)
111
+ throw new NotFoundError("Store not found.");
112
+ if (store.ownerId === buyerId) {
113
+ throw new ValidationError("You cannot message your own store.");
114
+ }
115
+ return createOrOpenConversation(buyerId, store.ownerId);
116
+ }
117
+ export async function listConversationsForUser(userId, opts = {}) {
118
+ return conversationsRepository.listForUser(userId, opts);
119
+ }
120
+ export async function listMessagesForConversation(userId, conversationId, opts = {}) {
121
+ const conversation = await requireConversation(conversationId);
122
+ assertParticipant(conversation, userId);
123
+ return listMessages(conversationId, opts);
124
+ }
125
+ /**
126
+ * The one writer path. Every mutation here runs through the Admin SDK — the
127
+ * old feature attempted client writes against `.write: false` and that is
128
+ * precisely what made it inert.
129
+ */
130
+ export async function sendMessage(input) {
131
+ await assertChatEnabled();
132
+ const conversation = await requireConversation(input.conversationId);
133
+ assertParticipant(conversation, input.authorId);
134
+ const recipientId = otherParticipant(conversation, input.authorId);
135
+ if (conversation.blockedBy?.includes(recipientId)) {
136
+ // The recipient blocked the sender — a blocked sender gets a clear reason,
137
+ // not a message that silently vanishes into a thread nobody reads.
138
+ throw new AuthorizationError("The other participant is not accepting messages from you.");
139
+ }
140
+ await assertCanSendMessages(input.authorId);
141
+ const trimmed = input.body.trim();
142
+ if (!trimmed)
143
+ throw new ValidationError("Message cannot be empty.");
144
+ if (trimmed.length > MESSAGE_MAX_LENGTH) {
145
+ throw new ValidationError(`Message cannot exceed ${MESSAGE_MAX_LENGTH} characters.`);
146
+ }
147
+ const cleaned = stripPii(trimmed);
148
+ // Attachments finalized BEFORE the write — the working pattern is
149
+ // review-actions.ts, not item-requests (which calls no finalize at all, F1
150
+ // #8) or supportTickets (which declares attachments and reads/finalizes
151
+ // nothing, F1 #7).
152
+ const finalizedAttachments = input.attachments?.length
153
+ ? await finalizeStagedMediaArray(input.attachments)
154
+ : undefined;
155
+ let productRef;
156
+ if (input.productId) {
157
+ const product = await productRepository.findById(input.productId);
158
+ if (product) {
159
+ productRef = {
160
+ productId: product.id,
161
+ title: product.title,
162
+ image: product.images?.[0],
163
+ price: product.price,
164
+ };
165
+ }
166
+ // A missing/deleted product is not an error — the card simply doesn't
167
+ // attach, same honesty rule as every other "snapshot at send time" field.
168
+ }
169
+ const message = await createMessage({
170
+ conversationId: input.conversationId,
171
+ authorId: input.authorId,
172
+ body: cleaned,
173
+ clientMessageId: input.clientMessageId,
174
+ attachments: finalizedAttachments,
175
+ productRef,
176
+ });
177
+ const preview = cleaned.length > 140 ? `${cleaned.slice(0, 140)}…` : cleaned;
178
+ await conversationsRepository.applyNewMessage(input.conversationId, input.authorId, recipientId, {
179
+ body: preview,
180
+ authorId: input.authorId,
181
+ at: message.createdAt,
182
+ hasAttachments: Boolean(finalizedAttachments?.length),
183
+ productId: productRef?.productId,
184
+ });
185
+ // RTDB ping — best-effort. A failed ping degrades to "the recipient notices
186
+ // on their next poll/visit", never to a lost message (the message is
187
+ // already durably in Firestore by this point).
188
+ try {
189
+ await getAdminRealtimeDb()
190
+ .ref(`${RTDB_PATHS.CHAT_EVENTS}/${input.conversationId}`)
191
+ .set({ updatedAt: Date.now() });
192
+ }
193
+ catch (rtdbErr) {
194
+ void normalizeError(rtdbErr);
195
+ serverLogger.warn("sendMessage: chat_events ping failed (non-fatal)", {
196
+ conversationId: input.conversationId,
197
+ });
198
+ }
199
+ // Bell-only (EMAIL_ELIGIBLE_TYPES.message_received === false) — the
200
+ // conversation lives in the UI.
201
+ await sendNotification({
202
+ userId: recipientId,
203
+ type: "message_received",
204
+ priority: "normal",
205
+ title: "New message",
206
+ message: preview,
207
+ relatedId: input.conversationId,
208
+ relatedType: "conversation",
209
+ });
210
+ return message;
211
+ }
212
+ export async function markConversationRead(userId, conversationId) {
213
+ const conversation = await requireConversation(conversationId);
214
+ assertParticipant(conversation, userId);
215
+ await conversationsRepository.markRead(conversationId, userId);
216
+ }
217
+ export async function blockConversationParticipant(userId, conversationId) {
218
+ const conversation = await requireConversation(conversationId);
219
+ assertParticipant(conversation, userId);
220
+ await conversationsRepository.blockParticipant(conversationId, userId);
221
+ }
222
+ export async function softDeleteOwnMessage(userId, conversationId, messageId) {
223
+ const conversation = await requireConversation(conversationId);
224
+ assertParticipant(conversation, userId);
225
+ await softDeleteMessage(conversationId, messageId, userId);
226
+ }
227
+ /**
228
+ * Mint a per-conversation custom token, matching `{ authEventId }` /
229
+ * `{ paymentEventId }` / `{ bulkJobId }` — no broad token, ever. The RTDB auth
230
+ * app's uid is synthetic and unrelated to the user's real uid, same as the
231
+ * payment-event precedent: the realtime channel's identity and the user's
232
+ * session identity are deliberately decoupled.
233
+ */
234
+ export async function mintChatEventToken(userId, conversationId) {
235
+ const conversation = await requireConversation(conversationId);
236
+ assertParticipant(conversation, userId);
237
+ const customToken = await getAdminAuth().createCustomToken(`chat_event_${conversationId}`, { conversationId });
238
+ return { customToken, expiresAt: Date.now() + 60 * 60 * 1000 };
239
+ }
240
+ /** Unread rollup for the nav badge — never a list query (D9 spec). */
241
+ export async function getUnreadConversationCount(userId) {
242
+ // listForUser is already bounded (<=50); summing its unread counts avoids a
243
+ // second query shape while staying well inside Rule #6's per-request budget.
244
+ // A user with more than 50 active threads undercounts here, which is an
245
+ // acceptable approximation for a bell badge, not a balance.
246
+ const { items } = await conversationsRepository.listForUser(userId, { limit: 50 });
247
+ return items.reduce((sum, c) => sum + (c.unread[userId] ?? 0), 0);
248
+ }
249
+ export { deleteAllMessages, listAllAttachmentUrls };
@@ -0,0 +1,47 @@
1
+ /** Part listings read per store. The same cap the assembly drawer uses. */
2
+ export declare const BUILDABLE_HOLDINGS_LIMIT = 50;
3
+ /**
4
+ * Candidate models considered. A seller holding common Metal-era parts can
5
+ * reach several hundred compositions through `usedBy`; this bounds the second
6
+ * `getAll` and the answer is truncated rather than partial-and-silent.
7
+ */
8
+ export declare const BUILDABLE_CANDIDATE_LIMIT = 60;
9
+ export interface BuildableSlot {
10
+ slotKey: string;
11
+ slotLabel: string;
12
+ partName: string;
13
+ partId?: string;
14
+ /** The colourway THIS model needs, when the corpus distinguishes one. */
15
+ expectedColourway?: string;
16
+ /** Complete units this slot alone could supply. */
17
+ buildable: number;
18
+ }
19
+ export interface BuildableModel {
20
+ compositionId: string;
21
+ name: string;
22
+ /** The named-model category, when the generator joined one. */
23
+ categoryId?: string;
24
+ /**
25
+ * How many COMPLETE units are buildable — the MINIMUM across slots, never a
26
+ * sum or an average: a bey missing one tip is not 80% of a bey.
27
+ */
28
+ buildable: number;
29
+ slots: BuildableSlot[];
30
+ /** Slot labels supplying zero. Named, because that is the shopping list. */
31
+ blockedBy: string[];
32
+ }
33
+ export interface BuildableResult {
34
+ models: BuildableModel[];
35
+ /** Distinct part listings considered. */
36
+ holdings: number;
37
+ /** A cap was hit, so the answer is a floor rather than the whole truth. */
38
+ truncated: boolean;
39
+ }
40
+ /**
41
+ * What this store can build right now.
42
+ *
43
+ * Sorted by `buildable` descending and then by how close a blocked model is to
44
+ * complete, so the useful answer ("you are one Spin Track away") is near the
45
+ * top rather than buried under everything that is fully blocked.
46
+ */
47
+ export declare function listBuildableModels(storeId: string): Promise<BuildableResult>;
@@ -0,0 +1,159 @@
1
+ /*
2
+ * WHY: D13's last item — "which complete beys can I build RIGHT NOW from the
3
+ * parts I hold", answered server-side for one store.
4
+ *
5
+ * 🛑 THE DEFECT THIS IS SHAPED AROUND (I9). Matching a composition slot on
6
+ * `partGlossaryId` alone reports *"you can build an L-Drago Guardian
7
+ * S130MB"* when the only Metal Ball on the shelf is the Poison Zurafa's
8
+ * transparent-black one. One glossary row, same mould, same weight, same
9
+ * stats — two physical units that are not interchangeable and differ wildly
10
+ * in value, with no stat to tell them apart. A confident, specific, wrong
11
+ * answer with no error, discovered by the seller holding the wrong part.
12
+ *
13
+ * So every match goes through `satisfiesSlot`, which keys on
14
+ * **(partGlossaryId, colourway)** and refuses a colour-unknown holding for a
15
+ * slot that names a colour — "unknown" is not evidence of the right colour.
16
+ *
17
+ * 🛑 THREE ROUND TRIPS, FLAT IN THE NUMBER OF PARTS HELD (Rule #6). The naive
18
+ * shape is one `getModelsUsingPart` per held part, which is N reads for a
19
+ * seller with N distinct parts. Instead: one bounded listing read, one
20
+ * `getAll` over the held glossary rows (whose `usedBy` the generator already
21
+ * wrote), one `getAll` over the candidate compositions. A seller with 3
22
+ * parts and a seller with 40 cost the same.
23
+ *
24
+ * @tag domain:parts
25
+ * @tag layer:server-data
26
+ * @tag access:server-only
27
+ * @tag sideEffects:firestore-read
28
+ */
29
+ import { normalizeError } from "../../../../errors/normalize.js";
30
+ import { serverLogger } from "../../../../monitoring/server-logger.js";
31
+ import { getAdminDb } from "../../../../providers/db-firebase/index.js";
32
+ import { MODEL_COMPOSITION_COLLECTION, PART_GLOSSARY_COLLECTION, } from "../../../../features/parts/schemas/firestore.js";
33
+ import { matchSlotToHoldings, } from "../../../../features/parts/utils/colourway-match.js";
34
+ import { ANY_STATUS, listPublicProducts } from "../products/list-public.js";
35
+ /** Part listings read per store. The same cap the assembly drawer uses. */
36
+ export const BUILDABLE_HOLDINGS_LIMIT = 50;
37
+ /**
38
+ * Candidate models considered. A seller holding common Metal-era parts can
39
+ * reach several hundred compositions through `usedBy`; this bounds the second
40
+ * `getAll` and the answer is truncated rather than partial-and-silent.
41
+ */
42
+ export const BUILDABLE_CANDIDATE_LIMIT = 60;
43
+ /** Slots that carry no glossary id cannot be matched and are not required. */
44
+ function requiredSlots(comp) {
45
+ return (comp.slots ?? []).filter((s) => typeof s.partId === "string" && s.partId.length > 0);
46
+ }
47
+ /**
48
+ * What this store can build right now.
49
+ *
50
+ * Sorted by `buildable` descending and then by how close a blocked model is to
51
+ * complete, so the useful answer ("you are one Spin Track away") is near the
52
+ * top rather than buried under everything that is fully blocked.
53
+ */
54
+ export async function listBuildableModels(storeId) {
55
+ const empty = { models: [], holdings: 0, truncated: false };
56
+ if (!storeId)
57
+ return empty;
58
+ try {
59
+ const db = getAdminDb();
60
+ /* 1 — the seller's own part listings, through the SHARED query. */
61
+ const listing = await listPublicProducts({
62
+ storeId,
63
+ status: ANY_STATUS,
64
+ page: 1,
65
+ pageSize: BUILDABLE_HOLDINGS_LIMIT,
66
+ });
67
+ const holdings = (listing?.items ?? [])
68
+ .filter((row) => typeof row.partGlossaryId === "string")
69
+ .map((row) => ({
70
+ productId: String(row.id),
71
+ partGlossaryId: String(row.partGlossaryId),
72
+ availableQuantity: typeof row.availableQuantity === "number" ? row.availableQuantity : 0,
73
+ ...(typeof row.colourway === "string" ? { colourway: row.colourway } : {}),
74
+ }))
75
+ // A listing with nothing available cannot contribute to a build, and
76
+ // keeping it would make `blockedBy` name a part the seller "has".
77
+ .filter((h) => h.availableQuantity > 0);
78
+ if (holdings.length === 0) {
79
+ return { ...empty, truncated: listing?.truncated === true };
80
+ }
81
+ /* 2 — one `getAll` over the held glossary rows for their `usedBy`. */
82
+ const heldIds = [...new Set(holdings.map((h) => h.partGlossaryId).filter((id) => !!id))];
83
+ const glossaryCol = db.collection(PART_GLOSSARY_COLLECTION);
84
+ const partSnaps = await db.getAll(...heldIds.map((id) => glossaryCol.doc(id)));
85
+ const candidateIds = new Set();
86
+ for (const snap of partSnaps) {
87
+ if (!snap.exists)
88
+ continue;
89
+ for (const id of snap.data()?.usedBy ?? []) {
90
+ if (id)
91
+ candidateIds.add(id);
92
+ }
93
+ }
94
+ if (candidateIds.size === 0) {
95
+ return { models: [], holdings: holdings.length, truncated: listing?.truncated === true };
96
+ }
97
+ const candidates = [...candidateIds];
98
+ const truncatedCandidates = candidates.length > BUILDABLE_CANDIDATE_LIMIT;
99
+ const take = truncatedCandidates ? candidates.slice(0, BUILDABLE_CANDIDATE_LIMIT) : candidates;
100
+ /* 3 — one `getAll` over those compositions. */
101
+ const compCol = db.collection(MODEL_COMPOSITION_COLLECTION);
102
+ const compSnaps = await db.getAll(...take.map((id) => compCol.doc(id)));
103
+ const models = [];
104
+ for (const snap of compSnaps) {
105
+ if (!snap.exists)
106
+ continue;
107
+ const comp = { id: snap.id, ...snap.data() };
108
+ const slots = requiredSlots(comp);
109
+ // A composition with no resolvable slot cannot be judged; claiming it is
110
+ // buildable from zero requirements would be the worst possible answer.
111
+ if (slots.length === 0)
112
+ continue;
113
+ const matched = slots.map((s) => {
114
+ const m = matchSlotToHoldings({
115
+ // `SlotRequirement` is deliberately narrow: the matcher's whole
116
+ // input is the (part, colourway) pair, so nothing else about the
117
+ // slot can accidentally influence a match.
118
+ ...(s.partId ? { partId: s.partId } : {}),
119
+ ...(s.expectedColourway ? { expectedColourway: s.expectedColourway } : {}),
120
+ }, holdings);
121
+ return {
122
+ slotKey: String(s.slotKey),
123
+ slotLabel: s.slotLabel,
124
+ partName: s.partName,
125
+ ...(s.partId ? { partId: s.partId } : {}),
126
+ ...(s.expectedColourway ? { expectedColourway: s.expectedColourway } : {}),
127
+ buildable: m.buildable,
128
+ };
129
+ });
130
+ models.push({
131
+ compositionId: comp.id,
132
+ name: comp.name,
133
+ ...(comp.categoryId ? { categoryId: comp.categoryId } : {}),
134
+ buildable: Math.min(...matched.map((m) => m.buildable)),
135
+ slots: matched,
136
+ blockedBy: matched.filter((m) => m.buildable === 0).map((m) => m.slotLabel),
137
+ });
138
+ }
139
+ models.sort((a, b) => b.buildable - a.buildable ||
140
+ a.blockedBy.length - b.blockedBy.length ||
141
+ a.name.localeCompare(b.name));
142
+ return {
143
+ models,
144
+ holdings: holdings.length,
145
+ truncated: listing?.truncated === true || truncatedCandidates,
146
+ };
147
+ }
148
+ catch (err) {
149
+ const e = normalizeError(err);
150
+ /*
151
+ * Fails CLOSED-but-loud: an empty list with `truncated: true`, so a caller
152
+ * renders "we could not work this out" rather than the much worse
153
+ * "you can build nothing" — which a seller holding a full set would read as
154
+ * a bug in their inventory (Root Cause #59's lesson, applied to a rollup).
155
+ */
156
+ serverLogger.warn("listBuildableModels failed", { storeId, error: e.message });
157
+ return { models: [], holdings: 0, truncated: true };
158
+ }
159
+ }
@@ -152,6 +152,23 @@ export interface PublicProductQuery {
152
152
  * rows.
153
153
  */
154
154
  search?: string;
155
+ /**
156
+ * Category ids to scope to, carried outside `filters` for the same reason
157
+ * `search` is — and set ONLY when both are present.
158
+ *
159
+ * 🛑 Firestore permits one array operator per query, and the category clause
160
+ * (`categorySlugs` array-contains) and the search clause (`searchTxt`
161
+ * array-contains) are both one. So when a visitor searches inside a
162
+ * category, the category leaves the filter string and arrives here, where
163
+ * the repository can refine it in memory over the search window.
164
+ *
165
+ * 🛑 Only the DEFAULT executor honours this. The colocated Function is
166
+ * handed a filter string and cannot refine anything, which is why
167
+ * `listPublicProducts` routes this one query shape to the default executor
168
+ * rather than hoping a remote executor forwards the option (Root Cause #85:
169
+ * one query, two paths, different capabilities).
170
+ */
171
+ categoriesIn?: readonly string[];
155
172
  }
156
173
  export interface ExecutorResult {
157
174
  items: unknown[];