@mohasinac/appkit 4.47.0 → 4.48.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.
- package/dist/.build-stamp.json +4 -4
- package/dist/_internal/server/features/checkout/expand-order-items.js +3 -0
- package/dist/_internal/server/features/messages/actions.d.ts +68 -0
- package/dist/_internal/server/features/messages/actions.js +249 -0
- package/dist/_internal/server/features/parts/buildable.d.ts +47 -0
- package/dist/_internal/server/features/parts/buildable.js +159 -0
- package/dist/_internal/server/features/products/list-public.d.ts +17 -0
- package/dist/_internal/server/features/products/list-public.js +92 -5
- package/dist/_internal/server/features/products/og.d.ts +7 -0
- package/dist/_internal/server/features/products/og.js +38 -0
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +6 -0
- package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +28 -0
- package/dist/_internal/server/jobs/core/notificationPrune.js +87 -3
- package/dist/_internal/server/jobs/core/priceIndexRollup.js +36 -1
- package/dist/_internal/shared/actions/action-registry.js +8 -0
- package/dist/_internal/shared/features/notifications/action-url.js +8 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +3 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +4 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.js +8 -0
- package/dist/constants/api-endpoints.d.ts +48 -0
- package/dist/constants/api-endpoints.js +30 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/about/components/GstExplainerView.d.ts +28 -0
- package/dist/features/about/components/GstExplainerView.js +59 -0
- package/dist/features/about/components/GuidesHubView.d.ts +28 -0
- package/dist/features/about/components/GuidesHubView.js +58 -0
- package/dist/features/about/components/PublicProfileView.js +5 -0
- package/dist/features/about/components/SpotAFakeView.d.ts +29 -0
- package/dist/features/about/components/SpotAFakeView.js +49 -0
- package/dist/features/admin/actions/notification-actions.js +4 -0
- package/dist/features/admin/schemas/firestore.d.ts +13 -3
- package/dist/features/admin/schemas/firestore.js +8 -0
- package/dist/features/blog/repository/blog.repository.js +21 -7
- package/dist/features/categories/api/route.js +41 -1
- package/dist/features/categories/components/BreadcrumbTrail.d.ts +39 -17
- package/dist/features/categories/components/BreadcrumbTrail.js +46 -50
- package/dist/features/categories/components/CategoryDetailPageView.js +92 -38
- package/dist/features/categories/components/ContextualTrail.d.ts +62 -0
- package/dist/features/categories/components/ContextualTrail.js +81 -0
- package/dist/features/categories/components/LastCategoryBeacon.d.ts +18 -0
- package/dist/features/categories/components/LastCategoryBeacon.js +33 -0
- package/dist/features/categories/repository/categories.repository.d.ts +21 -0
- package/dist/features/categories/repository/categories.repository.js +36 -3
- package/dist/features/categories/repository/price-index.repository.d.ts +25 -0
- package/dist/features/email/notification-templates.js +4 -0
- package/dist/features/events/repository/events.repository.js +10 -3
- package/dist/features/faq/constants/read-caps.d.ts +29 -0
- package/dist/features/faq/constants/read-caps.js +29 -0
- package/dist/features/faq/repository/faqs.repository.d.ts +0 -2
- package/dist/features/faq/repository/faqs.repository.js +19 -16
- package/dist/features/homepage/components/AdSlot.js +10 -4
- package/dist/features/homepage/hooks/useActiveAd.d.ts +18 -1
- package/dist/features/homepage/hooks/useActiveAd.js +42 -2
- package/dist/features/homepage/lib/section-renderer.js +12 -2
- package/dist/features/layout/AppLayoutShell.d.ts +4 -1
- package/dist/features/layout/AppLayoutShell.js +2 -2
- package/dist/features/layout/TitleBarLayout.d.ts +4 -1
- package/dist/features/layout/TitleBarLayout.js +8 -4
- package/dist/features/media/AvatarUpload.js +8 -1
- package/dist/features/media/MediaAvatar.js +1 -1
- package/dist/features/media/MediaImage.js +3 -1
- package/dist/features/media/MediaLightbox.js +4 -1
- package/dist/features/media/MediaPickerModal.js +6 -1
- package/dist/features/media/MediaVideo.js +3 -1
- package/dist/features/messages/components/ConversationThreadView.d.ts +3 -0
- package/dist/features/messages/components/ConversationThreadView.js +75 -0
- package/dist/features/messages/components/ConversationsInboxView.d.ts +1 -0
- package/dist/features/messages/components/ConversationsInboxView.js +39 -0
- package/dist/features/messages/components/MessageSellerButton.d.ts +17 -0
- package/dist/features/messages/components/MessageSellerButton.js +33 -0
- package/dist/features/messages/hooks/useConversationChannel.d.ts +24 -0
- package/dist/features/messages/hooks/useConversationChannel.js +89 -0
- package/dist/features/messages/hooks/useMessages.d.ts +347 -0
- package/dist/features/messages/hooks/useMessages.js +113 -0
- package/dist/features/messages/repository/conversations.repository.d.ts +44 -0
- package/dist/features/messages/repository/conversations.repository.js +173 -0
- package/dist/features/messages/repository/messages.repository.d.ts +42 -0
- package/dist/features/messages/repository/messages.repository.js +142 -0
- package/dist/features/messages/schemas/firestore.d.ts +99 -0
- package/dist/features/messages/schemas/firestore.js +46 -0
- package/dist/features/orders/repository/orders.repository.js +21 -7
- package/dist/features/orders/schemas/firestore.d.ts +11 -0
- package/dist/features/products/components/CityCollectiblesView.d.ts +31 -0
- package/dist/features/products/components/CityCollectiblesView.js +82 -0
- package/dist/features/products/components/MarketPriceAnchor.d.ts +45 -0
- package/dist/features/products/components/MarketPriceAnchor.js +62 -0
- package/dist/features/products/components/ProductDetailPageView.js +71 -5
- package/dist/features/products/constants/meetup-cities.d.ts +47 -0
- package/dist/features/products/constants/meetup-cities.js +49 -0
- package/dist/features/products/repository/category-assignment.d.ts +82 -11
- package/dist/features/products/repository/category-assignment.js +93 -12
- package/dist/features/products/repository/products.repository.d.ts +12 -1
- package/dist/features/products/repository/products.repository.js +66 -34
- package/dist/features/products/schemas/firestore.d.ts +24 -2
- package/dist/features/products/schemas/firestore.js +13 -1
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/promotions/repository/coupons.repository.js +11 -4
- package/dist/features/reviews/repository/reviews.repository.js +11 -4
- package/dist/features/scams/repository/scammer.repository.js +21 -7
- package/dist/features/seller/components/SellerProductShell.d.ts +1 -1
- package/dist/features/seller/components/SellerProductShell.js +2 -2
- package/dist/features/seller/repository/offer.repository.js +21 -6
- package/dist/features/seller/schemas/product-form.d.ts +3 -3
- package/dist/features/seller/schemas/product-form.js +1 -1
- package/dist/features/server-errors/repository/server-errors.repository.js +11 -0
- package/dist/features/server-errors/schemas/firestore.d.ts +17 -0
- package/dist/features/server-errors/schemas/firestore.js +2 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +17 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +31 -0
- package/dist/features/store-extensions/schemas/firestore.d.ts +1 -1
- package/dist/features/store-extensions/schemas/firestore.js +4 -0
- package/dist/features/stores/repository/store.repository.js +11 -3
- package/dist/features/tester/seed-data/authored/community-support__messages.d.ts +2 -0
- package/dist/features/tester/seed-data/authored/community-support__messages.js +200 -0
- package/dist/features/tester/seed-data/authored/index.js +2 -0
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +64 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +1 -1
- package/dist/next/routing/route-map.d.ts +14 -0
- package/dist/next/routing/route-map.js +26 -0
- package/dist/providers/db-firebase/rtdb-paths.d.ts +8 -0
- package/dist/providers/db-firebase/rtdb-paths.js +8 -0
- package/dist/repositories/index.d.ts +1 -0
- package/dist/repositories/index.js +4 -0
- package/dist/schemas/registry.d.ts +3 -3
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/pii-schemas.d.ts +17 -0
- package/dist/security/pii-schemas.js +15 -7
- package/dist/seed/_helpers/category-content-generated.d.ts +3 -3
- package/dist/seed/_helpers/category-content-generated.js +23 -7
- package/dist/seed/_helpers/category-models.d.ts +1 -1
- package/dist/seed/_helpers/category-models.js +25 -1
- package/dist/seed/_helpers/category-names.d.ts +54 -0
- package/dist/seed/_helpers/category-names.js +118 -0
- package/dist/seed/_helpers/character-features.d.ts +1 -1
- package/dist/seed/_helpers/character-features.js +5 -4
- package/dist/seed/_helpers/final-sale-fixtures.js +1 -1
- package/dist/seed/categories-seed-data.js +55 -1
- package/dist/seed/grouped-listings-seed-data.js +2 -2
- package/dist/seed/products-art-seed-data.js +2 -1
- package/dist/seed/products-auctions-seed-data.js +42 -22
- package/dist/seed/products-classifieds-seed-data.js +21 -3
- package/dist/seed/products-digital-codes-seed-data.js +2 -1
- package/dist/seed/products-live-items-seed-data.js +2 -1
- package/dist/seed/products-preorders-seed-data.js +26 -11
- package/dist/seed/products-prize-draws-seed-data.js +8 -1
- package/dist/seed/products-standard-seed-data.js +26 -25
- package/dist/seed/products-stickers-seed-data.js +12 -11
- package/dist/server.d.ts +8 -0
- package/dist/server.js +15 -0
- package/dist/styles.css +113 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/themes/sunset.js +17 -0
- package/dist/tokens/tokens.css +5 -0
- package/dist/ui/components/Breadcrumb.d.ts +8 -1
- package/dist/ui/components/Breadcrumb.js +2 -2
- package/dist/ui/components/Breadcrumb.style.css +37 -0
- package/dist/ui/components/Details.d.ts +11 -1
- package/dist/ui/components/Details.js +2 -2
- package/dist/ui/components/ImageMosaic.d.ts +43 -0
- package/dist/ui/components/ImageMosaic.js +46 -0
- package/dist/ui/components/ImageMosaic.style.css +69 -0
- package/dist/ui/components/SplitTile.d.ts +34 -0
- package/dist/ui/components/SplitTile.js +28 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +4 -0
- package/dist/utils/search-txt-query.d.ts +77 -0
- package/dist/utils/search-txt-query.js +50 -28
- package/package.json +1 -1
package/dist/.build-stamp.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"stampVersion": 1,
|
|
3
|
-
"version": "4.
|
|
4
|
-
"head": "
|
|
3
|
+
"version": "4.48.1",
|
|
4
|
+
"head": "d711c224e6c53ee2cbca34d3b5764b448974087d",
|
|
5
5
|
"branch": "main",
|
|
6
|
-
"dirty":
|
|
7
|
-
"builtAt": "2026-10-
|
|
6
|
+
"dirty": false,
|
|
7
|
+
"builtAt": "2026-10-14T02:40:53.091Z"
|
|
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[];
|