@mohasinac/appkit 4.47.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.
- 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
|
@@ -6,7 +6,7 @@ import { increment } from "../../../contracts/field-ops.js";
|
|
|
6
6
|
import { withHistory } from "../../../_internal/shared/history/index.js";
|
|
7
7
|
import { SCAMMER_TRACKED_FIELDS, SCAMMER_HISTORY_PII_FIELDS } from "../schemas/firestore.js";
|
|
8
8
|
import { buildScammerSearchTxt } from "../../../utils/search-txt-builders.js";
|
|
9
|
-
import { planSearchTxt,
|
|
9
|
+
import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
|
|
10
10
|
import { SCAMMER_COLLECTION, SCAMMER_FIELDS, SCAMMER_INCIDENTS_SUBCOLLECTION, SCAMMER_COMMENTS_SUBCOLLECTION, DEFAULT_SCAMMER_DATA, } from "../schemas/firestore.js";
|
|
11
11
|
class ScammerRepository extends BaseRepository {
|
|
12
12
|
constructor() {
|
|
@@ -35,12 +35,19 @@ class ScammerRepository extends BaseRepository {
|
|
|
35
35
|
if (plan.head) {
|
|
36
36
|
baseQuery = baseQuery.where(SCAMMER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
/*
|
|
39
|
+
* One bounded window, one raised `maxPageSize`, one page slice — see
|
|
40
|
+
* `beginSearchWindow`. Hand-writing the three is how the other ten
|
|
41
|
+
* repositories each ended up with two of them, which reads as a working
|
|
42
|
+
* multi-word search and silently cannot reach page 2.
|
|
43
|
+
*/
|
|
44
|
+
const win = beginSearchWindow(model, plan);
|
|
45
|
+
const result = await this.sieveQuery(win.model, ScammerRepository.SIEVE_FIELDS, {
|
|
39
46
|
baseQuery,
|
|
40
47
|
defaultPageSize: 20,
|
|
41
|
-
maxPageSize: 50,
|
|
48
|
+
maxPageSize: win.maxPageSize(50),
|
|
42
49
|
});
|
|
43
|
-
return
|
|
50
|
+
return win.finish(result);
|
|
44
51
|
}
|
|
45
52
|
/** Derived on every write path via `applyWriteHooks`. */
|
|
46
53
|
buildSearchTxtFor(data) {
|
|
@@ -56,12 +63,19 @@ class ScammerRepository extends BaseRepository {
|
|
|
56
63
|
if (plan.head) {
|
|
57
64
|
baseQuery = baseQuery.where(SCAMMER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
58
65
|
}
|
|
59
|
-
|
|
66
|
+
/*
|
|
67
|
+
* One bounded window, one raised `maxPageSize`, one page slice — see
|
|
68
|
+
* `beginSearchWindow`. Hand-writing the three is how the other ten
|
|
69
|
+
* repositories each ended up with two of them, which reads as a working
|
|
70
|
+
* multi-word search and silently cannot reach page 2.
|
|
71
|
+
*/
|
|
72
|
+
const win = beginSearchWindow(model, plan);
|
|
73
|
+
const result = await this.sieveQuery(win.model, ScammerRepository.SIEVE_FIELDS, {
|
|
60
74
|
baseQuery,
|
|
61
75
|
defaultPageSize: 50,
|
|
62
|
-
maxPageSize: 200,
|
|
76
|
+
maxPageSize: win.maxPageSize(200),
|
|
63
77
|
});
|
|
64
|
-
return
|
|
78
|
+
return win.finish(result);
|
|
65
79
|
}
|
|
66
80
|
async findBySeoSlug(seoSlug) {
|
|
67
81
|
try {
|
|
@@ -388,7 +388,7 @@ const TYPE_SPECIFIC_SECTIONS = {
|
|
|
388
388
|
function StepPricing({ values, onChange, listingType, }) {
|
|
389
389
|
const listingPlugin = pluginForMode(listingType);
|
|
390
390
|
const priceLabel = listingPlugin.priceLabel;
|
|
391
|
-
return (_jsxs(Stack, { gap: "md", children: [_jsx(Alert, { variant: "info", children: "All prices in Indian Rupees (\u20B9)." }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "price", label: priceLabel, type: "number", value: toRupeesString(values.price), onChange: (v) => onChange({ price: fromRupeesString(v) }), placeholder: "999" }), _jsx(FormField, { name: "
|
|
391
|
+
return (_jsxs(Stack, { gap: "md", children: [_jsx(Alert, { variant: "info", children: "All prices in Indian Rupees (\u20B9)." }), _jsxs(FormGroup, { columns: 2, children: [_jsx(FormField, { name: "price", label: priceLabel, type: "number", value: toRupeesString(values.price), onChange: (v) => onChange({ price: fromRupeesString(v) }), placeholder: "999" }), _jsx(FormField, { name: "originalPrice", label: "Compare-at Price (\u20B9, optional)", type: "number", value: toRupeesString(values.originalPrice), onChange: (v) => onChange({ originalPrice: v ? fromRupeesString(v) : undefined }), placeholder: "1299", helpText: "Original price shown as strikethrough" })] }), listingPlugin.showsStockQuantity && (_jsx(FormField, { name: "stockQuantity", label: "Stock Quantity", type: "number", value: String(values.stockQuantity ?? ""), onChange: (v) => onChange({ stockQuantity: Number(v) }), placeholder: "1" })), _jsxs(FormGroup, { columns: 2, children: [_jsx(Toggle, { checked: !!values.featured, onChange: (checked) => onChange({ featured: checked }), label: "Mark as Featured" }), _jsx(Toggle, { checked: !!values.isNew, onChange: (checked) => onChange({ isNew: checked }), label: "Mark as New Arrival" })] }), _jsx(Toggle, { checked: !!values.allowOffers, onChange: (checked) => onChange({
|
|
392
392
|
allowOffers: checked,
|
|
393
393
|
minOfferPercent: checked
|
|
394
394
|
? values.minOfferPercent ?? DEFAULT_MIN_OFFER_PERCENT
|
|
@@ -643,7 +643,7 @@ productId, onSave, onPublish, onDiscard, isLoading = false, storeSlug, renderCat
|
|
|
643
643
|
required: true,
|
|
644
644
|
quick: true,
|
|
645
645
|
// `gstRate` / `hsnCode` deliberately absent — admin-only, see StepPricing.
|
|
646
|
-
fields: ["price", "
|
|
646
|
+
fields: ["price", "originalPrice", "stockQuantity", "allowOffers", "minOfferPercent"],
|
|
647
647
|
render: ({ values, onChange }) => (_jsx(StepPricing, { values: values, onChange: onChange, listingType: listingType })),
|
|
648
648
|
// Built per render because whether stock is required depends on the
|
|
649
649
|
// listing type's own plugin — a digital code has no stock to speak of.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Data access layer for the `offers` Firestore collection.
|
|
5
5
|
* Handles buyer make-an-offer, seller counter/accept/decline lifecycle.
|
|
6
6
|
*/
|
|
7
|
-
import { planSearchTxt,
|
|
7
|
+
import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
|
|
8
8
|
import { buildOfferSearchTxt } from "../../../utils/search-txt-builders.js";
|
|
9
9
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase/index.js";
|
|
10
10
|
import { OFFER_COLLECTION, OFFER_FIELDS, OFFER_TRACKED_FIELDS, createOfferId } from "../schemas/index.js";
|
|
@@ -43,12 +43,19 @@ class OfferRepository extends BaseRepository {
|
|
|
43
43
|
if (plan.head) {
|
|
44
44
|
baseQuery = baseQuery.where("searchTxt", "array-contains", plan.head);
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
/*
|
|
47
|
+
* One bounded window, one raised `maxPageSize`, one page slice — see
|
|
48
|
+
* `beginSearchWindow`. Hand-writing the three is how the other ten
|
|
49
|
+
* repositories each ended up with two of them, which reads as a working
|
|
50
|
+
* multi-word search and silently cannot reach page 2.
|
|
51
|
+
*/
|
|
52
|
+
const win = beginSearchWindow(model, plan);
|
|
53
|
+
const result = await this.sieveQuery(win.model, OfferRepository.SIEVE_FIELDS, {
|
|
47
54
|
baseQuery,
|
|
48
55
|
defaultPageSize: 50,
|
|
49
|
-
maxPageSize: 200,
|
|
56
|
+
maxPageSize: win.maxPageSize(200),
|
|
50
57
|
});
|
|
51
|
-
return
|
|
58
|
+
return win.finish(result);
|
|
52
59
|
}
|
|
53
60
|
// --- Create --------------------------------------------------------------
|
|
54
61
|
async create(input) {
|
|
@@ -113,10 +120,18 @@ class OfferRepository extends BaseRepository {
|
|
|
113
120
|
if (plan.head) {
|
|
114
121
|
baseQuery = baseQuery.where(OFFER_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
115
122
|
}
|
|
116
|
-
|
|
123
|
+
/*
|
|
124
|
+
* One bounded window, one raised `maxPageSize`, one page slice — see
|
|
125
|
+
* `beginSearchWindow`. Hand-writing the three is how the other ten
|
|
126
|
+
* repositories each ended up with two of them, which reads as a working
|
|
127
|
+
* multi-word search and silently cannot reach page 2.
|
|
128
|
+
*/
|
|
129
|
+
const win = beginSearchWindow(model, plan);
|
|
130
|
+
const result = await this.sieveQuery(win.model, OfferRepository.SIEVE_FIELDS, {
|
|
117
131
|
baseQuery,
|
|
132
|
+
maxPageSize: win.maxPageSize(100),
|
|
118
133
|
});
|
|
119
|
-
return
|
|
134
|
+
return win.finish(result);
|
|
120
135
|
}
|
|
121
136
|
const snap = await this.getCollection()
|
|
122
137
|
.where(OFFER_FIELDS.STORE_ID, "==", storeId)
|
|
@@ -65,7 +65,7 @@ export declare const productDraftSchema: z.ZodObject<{
|
|
|
65
65
|
youtubeId: z.ZodOptional<z.ZodString>;
|
|
66
66
|
externalVideoUrl: z.ZodOptional<z.ZodString>;
|
|
67
67
|
price: z.ZodOptional<z.ZodNumber>;
|
|
68
|
-
|
|
68
|
+
originalPrice: z.ZodOptional<z.ZodNumber>;
|
|
69
69
|
stockQuantity: z.ZodOptional<z.ZodNumber>;
|
|
70
70
|
featured: z.ZodOptional<z.ZodBoolean>;
|
|
71
71
|
isPromoted: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -155,7 +155,7 @@ export declare const productDraftSchema: z.ZodObject<{
|
|
|
155
155
|
youtubeId: z.ZodOptional<z.ZodString>;
|
|
156
156
|
externalVideoUrl: z.ZodOptional<z.ZodString>;
|
|
157
157
|
price: z.ZodOptional<z.ZodNumber>;
|
|
158
|
-
|
|
158
|
+
originalPrice: z.ZodOptional<z.ZodNumber>;
|
|
159
159
|
stockQuantity: z.ZodOptional<z.ZodNumber>;
|
|
160
160
|
featured: z.ZodOptional<z.ZodBoolean>;
|
|
161
161
|
isPromoted: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -245,7 +245,7 @@ export declare const productDraftSchema: z.ZodObject<{
|
|
|
245
245
|
youtubeId: z.ZodOptional<z.ZodString>;
|
|
246
246
|
externalVideoUrl: z.ZodOptional<z.ZodString>;
|
|
247
247
|
price: z.ZodOptional<z.ZodNumber>;
|
|
248
|
-
|
|
248
|
+
originalPrice: z.ZodOptional<z.ZodNumber>;
|
|
249
249
|
stockQuantity: z.ZodOptional<z.ZodNumber>;
|
|
250
250
|
featured: z.ZodOptional<z.ZodBoolean>;
|
|
251
251
|
isPromoted: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -64,7 +64,7 @@ export const productDraftSchema = z
|
|
|
64
64
|
externalVideoUrl: z.string().optional(),
|
|
65
65
|
// Pricing
|
|
66
66
|
price: z.number().optional(),
|
|
67
|
-
|
|
67
|
+
originalPrice: z.number().optional(),
|
|
68
68
|
stockQuantity: z.number().optional(),
|
|
69
69
|
featured: z.boolean().optional(),
|
|
70
70
|
isPromoted: z.boolean().optional(),
|
|
@@ -39,6 +39,17 @@ export class ServerErrorsRepository extends BaseRepository {
|
|
|
39
39
|
requestId: input.requestId,
|
|
40
40
|
...(input.userAgent ? { userAgent: input.userAgent } : {}),
|
|
41
41
|
...(input.bodyDigest ? { bodyDigest: input.bodyDigest } : {}),
|
|
42
|
+
/*
|
|
43
|
+
* 🛑 D10, and the G2 sweep is what found it: this hand-picked
|
|
44
|
+
* field list is exactly Root Cause #38's shape. `entityType` and
|
|
45
|
+
* `entityId` were on `ServerErrorDocument` AND accepted by this
|
|
46
|
+
* method's own `Omit<…>` input type — so a caller passing them
|
|
47
|
+
* type-checked clean and the fields died right here, on the one
|
|
48
|
+
* line nobody re-reads. A write that succeeds is not a field that
|
|
49
|
+
* persists.
|
|
50
|
+
*/
|
|
51
|
+
...(input.entityType ? { entityType: input.entityType } : {}),
|
|
52
|
+
...(input.entityId ? { entityId: input.entityId } : {}),
|
|
42
53
|
};
|
|
43
54
|
await ref.set(doc);
|
|
44
55
|
}
|
|
@@ -31,6 +31,21 @@ export type ServerErrorDocument = {
|
|
|
31
31
|
userAgent?: string;
|
|
32
32
|
/** sha256(requestBody) — for diagnosis without leaking PII. */
|
|
33
33
|
bodyDigest?: string;
|
|
34
|
+
/**
|
|
35
|
+
* D10 — WHAT the failure was about, when that is narrower than `route`.
|
|
36
|
+
*
|
|
37
|
+
* 🛑 Absent means UNKNOWN, never "not a page". Every row written before this
|
|
38
|
+
* field existed has no value, so any filter over it must treat a missing
|
|
39
|
+
* `entityType` as INCLUDED — a default-exclusive filter would hide every
|
|
40
|
+
* pre-existing row and manufacture a backfill nobody budgeted for.
|
|
41
|
+
*
|
|
42
|
+
* `"page"` is the one value a user-submitted report can produce (see
|
|
43
|
+
* `ReportEntityType`), which is what lets an operator line a visitor's
|
|
44
|
+
* "this page is broken" up against the server-side rows for the same route.
|
|
45
|
+
*/
|
|
46
|
+
entityType?: "page" | "product" | "store" | "order" | "listing";
|
|
47
|
+
/** The entity the failure was about — a path for `entityType: "page"`. */
|
|
48
|
+
entityId?: string;
|
|
34
49
|
};
|
|
35
50
|
export declare const SERVER_ERROR_FIELDS: {
|
|
36
51
|
readonly ID: "id";
|
|
@@ -46,6 +61,8 @@ export declare const SERVER_ERROR_FIELDS: {
|
|
|
46
61
|
readonly REQUEST_ID: "requestId";
|
|
47
62
|
readonly USER_AGENT: "userAgent";
|
|
48
63
|
readonly BODY_DIGEST: "bodyDigest";
|
|
64
|
+
readonly ENTITY_TYPE: "entityType";
|
|
65
|
+
readonly ENTITY_ID: "entityId";
|
|
49
66
|
};
|
|
50
67
|
export declare const SERVER_ERROR_STACK_MAX_BYTES = 4096;
|
|
51
68
|
export declare const SERVER_ERROR_RETENTION_DAYS = 30;
|
|
@@ -13,6 +13,8 @@ export const SERVER_ERROR_FIELDS = {
|
|
|
13
13
|
REQUEST_ID: "requestId",
|
|
14
14
|
USER_AGENT: "userAgent",
|
|
15
15
|
BODY_DIGEST: "bodyDigest",
|
|
16
|
+
ENTITY_TYPE: "entityType",
|
|
17
|
+
ENTITY_ID: "entityId",
|
|
16
18
|
};
|
|
17
19
|
export const SERVER_ERROR_STACK_MAX_BYTES = 4096;
|
|
18
20
|
export const SERVER_ERROR_RETENTION_DAYS = 30;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { BaseRepository } from "../../../providers/db-firebase";
|
|
16
16
|
import type { DocumentSnapshot } from "firebase-admin/firestore";
|
|
17
|
-
import { type AnalyticsAlertDocument, type AnalyticsCardDocument, type ItemRequestDocument, type ListingTemplateDocument, type ModerationQueueDocument, type PayoutMethodDocument, type ReportDocument, type ShippingConfigDocument, type StoreCategoryDocument, type StoreGoogleConfigDocument, type StoreWhatsAppConfigDocument } from "../schemas/firestore";
|
|
17
|
+
import { type AnalyticsAlertDocument, type AnalyticsCardDocument, type ItemRequestDocument, type ItemRequestReply, type ListingTemplateDocument, type ModerationQueueDocument, type PayoutMethodDocument, type ReportDocument, type ShippingConfigDocument, type StoreCategoryDocument, type StoreGoogleConfigDocument, type StoreWhatsAppConfigDocument } from "../schemas/firestore";
|
|
18
18
|
export declare class PayoutMethodsRepository extends BaseRepository<PayoutMethodDocument> {
|
|
19
19
|
/**
|
|
20
20
|
* Bank/UPI details, encrypted at rest by BaseRepository's write hooks.
|
|
@@ -86,6 +86,22 @@ export declare class ItemRequestsRepository extends BaseRepository<ItemRequestDo
|
|
|
86
86
|
listByOwner(opUserId: string): Promise<{
|
|
87
87
|
items: ItemRequestDocument[];
|
|
88
88
|
}>;
|
|
89
|
+
/**
|
|
90
|
+
* Append one reply atomically.
|
|
91
|
+
*
|
|
92
|
+
* 🛑 F1 #9 — the route did `replies: [...doc.replies, entry]` plus
|
|
93
|
+
* `replyCount: doc.replyCount + 1`. That is a read-modify-write on both
|
|
94
|
+
* fields: two people replying within the same second each read N replies and
|
|
95
|
+
* each wrote N+1, so the second write silently DISCARDED the first reply and
|
|
96
|
+
* the count agreed with it. Nothing errored, and the losing reply simply
|
|
97
|
+
* never existed.
|
|
98
|
+
*
|
|
99
|
+
* `arrayUnion` + `increment` are server-side operations, so the two writes
|
|
100
|
+
* compose instead of racing. `arrayUnion` de-duplicates by deep equality,
|
|
101
|
+
* which is harmless here and faintly useful: the entry carries a unique
|
|
102
|
+
* `id`, so a retried request appends once rather than twice.
|
|
103
|
+
*/
|
|
104
|
+
appendReply(id: string, reply: ItemRequestReply): Promise<void>;
|
|
89
105
|
}
|
|
90
106
|
export declare class StoreWhatsAppConfigRepository extends BaseRepository<StoreWhatsAppConfigDocument> {
|
|
91
107
|
constructor();
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* The other ten hold no PII and use the BaseRepository public API only.
|
|
14
14
|
*/
|
|
15
15
|
import { BaseRepository } from "../../../providers/db-firebase/index.js";
|
|
16
|
+
import { arrayUnion, increment } from "../../../contracts/field-ops.js";
|
|
16
17
|
import { decryptPiiFields } from "../../../security/pii-encrypt.js";
|
|
17
18
|
import { ANALYTICS_ALERTS_COLLECTION, ANALYTICS_CARDS_COLLECTION, ITEM_REQUESTS_COLLECTION, LISTING_TEMPLATES_COLLECTION, MODERATION_QUEUE_COLLECTION, PAYOUT_METHODS_COLLECTION, REPORTS_COLLECTION, SHIPPING_CONFIGS_COLLECTION, STORE_CATEGORIES_COLLECTION, STORE_GOOGLE_CONFIG_COLLECTION, STORE_WHATSAPP_CONFIG_COLLECTION, } from "../schemas/firestore.js";
|
|
18
19
|
export class PayoutMethodsRepository extends BaseRepository {
|
|
@@ -117,6 +118,36 @@ export class ItemRequestsRepository extends BaseRepository {
|
|
|
117
118
|
const items = await this.findBy("opUserId", opUserId);
|
|
118
119
|
return { items };
|
|
119
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Append one reply atomically.
|
|
123
|
+
*
|
|
124
|
+
* 🛑 F1 #9 — the route did `replies: [...doc.replies, entry]` plus
|
|
125
|
+
* `replyCount: doc.replyCount + 1`. That is a read-modify-write on both
|
|
126
|
+
* fields: two people replying within the same second each read N replies and
|
|
127
|
+
* each wrote N+1, so the second write silently DISCARDED the first reply and
|
|
128
|
+
* the count agreed with it. Nothing errored, and the losing reply simply
|
|
129
|
+
* never existed.
|
|
130
|
+
*
|
|
131
|
+
* `arrayUnion` + `increment` are server-side operations, so the two writes
|
|
132
|
+
* compose instead of racing. `arrayUnion` de-duplicates by deep equality,
|
|
133
|
+
* which is harmless here and faintly useful: the entry carries a unique
|
|
134
|
+
* `id`, so a retried request appends once rather than twice.
|
|
135
|
+
*/
|
|
136
|
+
async appendReply(id, reply) {
|
|
137
|
+
/*
|
|
138
|
+
* Written through the collection directly rather than `update()`, because
|
|
139
|
+
* `prepareForFirestore` would walk the sentinels as if they were plain
|
|
140
|
+
* values. Same shape `categories.repository` uses for its own
|
|
141
|
+
* `arrayUnion` writes.
|
|
142
|
+
*/
|
|
143
|
+
await this.getCollection()
|
|
144
|
+
.doc(id)
|
|
145
|
+
.update({
|
|
146
|
+
replies: arrayUnion(reply),
|
|
147
|
+
replyCount: increment(1),
|
|
148
|
+
updatedAt: new Date(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
120
151
|
}
|
|
121
152
|
export class StoreWhatsAppConfigRepository extends BaseRepository {
|
|
122
153
|
constructor() {
|
|
@@ -171,7 +171,7 @@ export interface ModerationQueueDocument extends BaseDocument {
|
|
|
171
171
|
export declare const MODERATION_QUEUE_COLLECTION: "moderationQueue";
|
|
172
172
|
export declare const MODERATION_QUEUE_INDEXED_FIELDS: readonly ["status", "mediaType", "entityType", "entityId", "storeId", "ownerId", "submittedAt", "createdAt"];
|
|
173
173
|
export declare const DEFAULT_MODERATION_QUEUE_DATA: Partial<ModerationQueueDocument>;
|
|
174
|
-
export type ReportEntityType = "product" | "store" | "review" | "event" | "user" | "blog" | "comment";
|
|
174
|
+
export type ReportEntityType = "product" | "store" | "review" | "event" | "user" | "blog" | "comment" | "page" | "message";
|
|
175
175
|
export type ReportStatus = "pending" | "under-review" | "actioned" | "dismissed";
|
|
176
176
|
export type ReportReason = "scam" | "counterfeit" | "prohibited" | "inappropriate" | "harassment" | "spam" | "ip-violation" | "other";
|
|
177
177
|
export interface ReportDocument extends BaseDocument {
|
|
@@ -139,6 +139,10 @@ const REPORT_ENTITY_TYPE_MAP = {
|
|
|
139
139
|
user: true,
|
|
140
140
|
blog: true,
|
|
141
141
|
comment: true,
|
|
142
|
+
// D10 — a broken PAGE. `entityId` carries the path.
|
|
143
|
+
page: true,
|
|
144
|
+
// D9 — a chat message.
|
|
145
|
+
message: true,
|
|
142
146
|
};
|
|
143
147
|
export const REPORT_ENTITY_TYPES = Object.keys(REPORT_ENTITY_TYPE_MAP);
|
|
144
148
|
const REPORT_REASON_MAP = {
|
|
@@ -12,7 +12,7 @@ import { normalizeError } from "../../../errors/normalize.js";
|
|
|
12
12
|
import { encryptSecret, decryptSecret, } from "../../../security/settings-encryption.js";
|
|
13
13
|
import { withHistory } from "../../../_internal/shared/history/index.js";
|
|
14
14
|
import { buildStoreSearchTxt } from "../../../utils/search-txt-builders.js";
|
|
15
|
-
import { planSearchTxt,
|
|
15
|
+
import { planSearchTxt, beginSearchWindow, emptySearchResult, } from "../../../utils/search-txt-query.js";
|
|
16
16
|
export class StoreRepository extends BaseRepository {
|
|
17
17
|
constructor() {
|
|
18
18
|
super(STORE_COLLECTION);
|
|
@@ -220,9 +220,17 @@ export class StoreRepository extends BaseRepository {
|
|
|
220
220
|
if (plan.head) {
|
|
221
221
|
baseQuery = baseQuery.where(STORE_FIELDS.SEARCH_TXT, "array-contains", plan.head);
|
|
222
222
|
}
|
|
223
|
+
/*
|
|
224
|
+
* One bounded window, one raised `maxPageSize`, one page slice — see
|
|
225
|
+
* `beginSearchWindow`. Hand-writing the three is how the other ten
|
|
226
|
+
* repositories each ended up with two of them, which reads as a working
|
|
227
|
+
* multi-word search and silently cannot reach page 2.
|
|
228
|
+
*/
|
|
229
|
+
const win = beginSearchWindow(model, plan);
|
|
223
230
|
const result = await applySieveToFirestore({
|
|
224
231
|
baseQuery,
|
|
225
|
-
model,
|
|
232
|
+
model: win.model,
|
|
233
|
+
options: { maxPageSize: win.maxPageSize(100) },
|
|
226
234
|
fields: sieveFields,
|
|
227
235
|
// Called DIRECTLY, not through `sieveQuery`, so the mapper has to be
|
|
228
236
|
// passed by hand — and it is the list mapper, never `mapDoc`. With
|
|
@@ -230,7 +238,7 @@ export class StoreRepository extends BaseRepository {
|
|
|
230
238
|
// WhatsApp bearer token and a list has no use for it.
|
|
231
239
|
mapDoc: (snap) => this.mapDocForList(snap),
|
|
232
240
|
});
|
|
233
|
-
const searched =
|
|
241
|
+
const searched = win.finish(result);
|
|
234
242
|
/*
|
|
235
243
|
* 🛑 The rating threshold is applied IN MEMORY, deliberately.
|
|
236
244
|
*
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* WHY: Authored six-part procedures for the D9 chat pages (/messages).
|
|
3
|
+
* WHAT: 10 case(s), keyed by full checklist id.
|
|
4
|
+
*
|
|
5
|
+
* 🛑 CHAT IS OFF BY DEFAULT AND `undefined` READS AS OFF.
|
|
6
|
+
*
|
|
7
|
+
* `siteSettings.chat.enabled` gates every write path server-side
|
|
8
|
+
* (`assertChatEnabled`) AND the nav icon AND the Message Seller button. So the
|
|
9
|
+
* FIRST case here turns it on, and every case after it depends on that having
|
|
10
|
+
* happened — which is deliberate: the off-state behaviour is itself a case
|
|
11
|
+
* worth having, because "the feature silently half-works when disabled" is the
|
|
12
|
+
* shape the original chat feature died of.
|
|
13
|
+
*
|
|
14
|
+
* 🛑 AND IT IS A REAL SETTING, NOT A FIXTURE. The seed does not write it, so a
|
|
15
|
+
* fresh run starts with chat OFF. A case that assumes the icon is present
|
|
16
|
+
* without having turned it on is testing the wrong state and will answer `no`
|
|
17
|
+
* for a reason that is not a defect.
|
|
18
|
+
*
|
|
19
|
+
* 🛑 THE OLD FEATURE'S TWO BUGS ARE THE TWO MOST VALUABLE CASES HERE, and
|
|
20
|
+
* neither is visible from the UI alone:
|
|
21
|
+
*
|
|
22
|
+
* 1. `useConversation`/`useConversations` never called `signInWithToken`, so
|
|
23
|
+
* every RTDB rule evaluated false and the live channel was permission-
|
|
24
|
+
* denied — swallowed into `setIsConnected(false)` and a literal
|
|
25
|
+
* `// ignore`. The channel LOOKED implemented and was inert. Case
|
|
26
|
+
* `live-ping-surfaces-failure` is the control: the error must be VISIBLE.
|
|
27
|
+
* 2. `listByBuyer`/`listByStore` had no `.limit()`, and every message wrote
|
|
28
|
+
* 2–3 pings each triggering an unpaginated refetch for both participants.
|
|
29
|
+
* Twenty messages ≈ 1,600 reads. Case `inbox-read-is-bounded` is the
|
|
30
|
+
* negative control the plan names explicitly.
|
|
31
|
+
*
|
|
32
|
+
* 🛑 TWO ACCOUNTS ARE REQUIRED and both are seeded: admin@letitrip.in (admin)
|
|
33
|
+
* and the owner of store-beyblade-arena, user-tyson-blader. A conversation is
|
|
34
|
+
* between two DIFFERENT users and the server refuses a self-conversation, so a
|
|
35
|
+
* single-account run cannot exercise anything past case 2.
|
|
36
|
+
*
|
|
37
|
+
* audit-tester-created-id: the conversation id is generated by Firestore and is
|
|
38
|
+
* therefore not knowable in advance — every case that needs it says to read it
|
|
39
|
+
* off the URL after the Message Seller button navigates, which is the only
|
|
40
|
+
* honest instruction. It is not a seed fixture and must not become one: the
|
|
41
|
+
* point is to exercise the create path.
|
|
42
|
+
*
|
|
43
|
+
* @tag domain:tester
|
|
44
|
+
* @tag layer:seed
|
|
45
|
+
* @tag pattern:none
|
|
46
|
+
* @tag access:server-only
|
|
47
|
+
* @tag consumers:seed-data/authored/index.ts
|
|
48
|
+
* @tag sideEffects:none
|
|
49
|
+
*/
|
|
50
|
+
export const authored = {
|
|
51
|
+
"checklist-community-support-messages-chat-off-hides-everything": {
|
|
52
|
+
roles: ["admin", "buyer", "guest"],
|
|
53
|
+
startPage: "/admin/site",
|
|
54
|
+
steps: [
|
|
55
|
+
"Sign in as admin@letitrip.in / TempPass123!.",
|
|
56
|
+
"Open /admin/site and confirm the Chat toggle is OFF (its stored value is absent on a fresh seed, which reads as off).",
|
|
57
|
+
"Open /products/product-beyblade-burst-valkyrie and look at the 'Sold by' card.",
|
|
58
|
+
"Look at the header icon row, between the notification bell and the wishlist heart.",
|
|
59
|
+
"Navigate directly to /messages.",
|
|
60
|
+
],
|
|
61
|
+
expectedBehaviour: "With chat off, nothing chat-related renders anywhere: no Message Seller button on the store card, no Messages icon in the header, and no /api/messages/unread-count request fires at all (the hook is gated on the setting, not merely rendered-and-empty). /messages itself still resolves — it is a real route — and shows the signed-out or empty state rather than 404ing.",
|
|
62
|
+
expectedUiState: "The 'Sold by' card shows ONLY 'Visit Store →'. The header icon row has no speech-bubble icon. /messages renders its own chrome with no conversation rows.",
|
|
63
|
+
endResult: "Nothing persists; this is the baseline state. Reload and the three absences hold.",
|
|
64
|
+
},
|
|
65
|
+
"checklist-community-support-messages-enable-chat-reveals-entry-points": {
|
|
66
|
+
roles: ["admin", "buyer"],
|
|
67
|
+
startPage: "/admin/site",
|
|
68
|
+
steps: [
|
|
69
|
+
"Sign in as admin@letitrip.in / TempPass123!.",
|
|
70
|
+
"Open /admin/site, turn the Chat toggle ON, and save.",
|
|
71
|
+
"Reload /products/product-beyblade-burst-valkyrie.",
|
|
72
|
+
"Look at the 'Sold by' card and at the header icon row.",
|
|
73
|
+
],
|
|
74
|
+
inputs: { "chat.enabled": true },
|
|
75
|
+
expectedBehaviour: "Turning it on makes both entry points appear WITHOUT a code change: the gate is a live settings read, not a build-time flag. The Messages icon appears in the header with no badge (zero unread).",
|
|
76
|
+
expectedUiState: "The 'Sold by' card now shows a 'Message Seller' button beside 'Visit Store →'. A speech-bubble icon appears in the header icon row.",
|
|
77
|
+
expectedData: { "siteSettings.chat.enabled": true },
|
|
78
|
+
endResult: "Reload: both still present. The setting is stored, so it survives a sign-out and back in.",
|
|
79
|
+
},
|
|
80
|
+
"checklist-community-support-messages-message-seller-creates-thread": {
|
|
81
|
+
roles: ["buyer", "seller"],
|
|
82
|
+
startPage: "/products/product-beyblade-burst-valkyrie",
|
|
83
|
+
steps: [
|
|
84
|
+
"With chat enabled (previous case), sign in as admin@letitrip.in / TempPass123!.",
|
|
85
|
+
"Open /products/product-beyblade-burst-valkyrie and click 'Message Seller' on the 'Sold by' card.",
|
|
86
|
+
"Read the URL you land on and note the conversation id — it is a Firestore-generated id, not a slug.",
|
|
87
|
+
"Click 'Message Seller' on the SAME product a second time (go back and click again).",
|
|
88
|
+
"Compare the URL to the one you noted.",
|
|
89
|
+
],
|
|
90
|
+
expectedBehaviour: "The first click resolves store-beyblade-arena's storeId to its owner's uid SERVER-SIDE (a product page knows the store slug, never the owner's Auth UID) and creates one conversation. The second click finds the existing thread and does NOT create a second one — the thread is between two USERS, not per listing, so a buyer with three questions about three items gets one thread.",
|
|
91
|
+
expectedUiState: "Both clicks land on /messages/{sameId} showing an empty thread with a composer and a 'Block' control in the header.",
|
|
92
|
+
expectedData: { "conversations: documents between this pair": 1 },
|
|
93
|
+
endResult: "The conversation persists. /messages now lists it. Clicking Message Seller again forever reuses it.",
|
|
94
|
+
},
|
|
95
|
+
"checklist-community-support-messages-send-and-recipient-sees-it": {
|
|
96
|
+
roles: ["buyer", "seller"],
|
|
97
|
+
startPage: "/messages",
|
|
98
|
+
steps: [
|
|
99
|
+
"Signed in as admin@letitrip.in, open the conversation created in the previous case.",
|
|
100
|
+
"Type 'Does this come with the original launcher?' and press Enter.",
|
|
101
|
+
"Watch the bubble while it sends.",
|
|
102
|
+
"Sign out, sign in as the owner of store-beyblade-arena (user-tyson-blader), and open /messages.",
|
|
103
|
+
"Open the conversation and read the message.",
|
|
104
|
+
],
|
|
105
|
+
inputs: { body: "Does this come with the original launcher?" },
|
|
106
|
+
expectedBehaviour: "The message appears immediately as a pending bubble (optimistic), then resolves to a sent one. Server-side the body is PII-stripped and encrypted at rest, a truncated preview is denormalised onto the conversation, the recipient's unread counter is incremented with a real Firestore increment (not a read-modify-write), and a bell-only `message_received` notification is written — no email, because EMAIL_ELIGIBLE_TYPES says false for it.",
|
|
107
|
+
expectedUiState: "Sender side: the bubble is right-aligned and reads 'Sending…' briefly, then settles. Recipient side: /messages shows the thread with an unread badge of 1 and the preview text; opening it shows the message left-aligned and clears the badge.",
|
|
108
|
+
expectedData: { "recipient unread before opening": 1, "recipient unread after opening": 0 },
|
|
109
|
+
endResult: "Reload on both sides: the message is still there, the badge stays cleared for the recipient, and the preview on the inbox row matches the message.",
|
|
110
|
+
},
|
|
111
|
+
"checklist-community-support-messages-pii-is-stripped-from-the-body": {
|
|
112
|
+
roles: ["buyer"],
|
|
113
|
+
startPage: "/messages",
|
|
114
|
+
steps: [
|
|
115
|
+
"Open any conversation you are a participant in.",
|
|
116
|
+
"Send exactly: 'call me on 9876543210 or pay to tyson@paytm or tyson@okhdfc'",
|
|
117
|
+
"Read the message as it appears in the thread.",
|
|
118
|
+
"Reload the page and read it again.",
|
|
119
|
+
],
|
|
120
|
+
inputs: { body: "call me on 9876543210 or pay to tyson@paytm or tyson@okhdfc" },
|
|
121
|
+
expectedBehaviour: "All three identifiers are replaced BEFORE the write, so the stored body never contains them. The rule order is specific-before-general and that ordering IS the fix: a bare `name@bank` VPA has no TLD dot so it matches neither the email nor the phone rule, and running phone first would mangle '9876543210@upi' into '[number removed]@upi' before the UPI rule ever saw it.",
|
|
122
|
+
expectedUiState: "The message renders as 'call me on [number removed] or pay to [email removed] or pay to [UPI removed]' — the ten-digit run, the dotted VPA and the bare VPA each replaced, none left through.",
|
|
123
|
+
expectedData: { "stored body contains '9876543210'": false, "stored body contains 'okhdfc'": false },
|
|
124
|
+
endResult: "Reload: the redacted form persists. The original text is not recoverable from the UI, which is the point — the redaction happened on the way in, not on the way out.",
|
|
125
|
+
},
|
|
126
|
+
"checklist-community-support-messages-retry-does-not-double-post": {
|
|
127
|
+
roles: ["buyer"],
|
|
128
|
+
startPage: "/messages",
|
|
129
|
+
steps: [
|
|
130
|
+
"Open a conversation and open DevTools → Network, set throttling to Offline.",
|
|
131
|
+
"Type 'retry test' and press Enter.",
|
|
132
|
+
"Observe the bubble's state.",
|
|
133
|
+
"Set throttling back to online and click the failed bubble's retry control.",
|
|
134
|
+
"Count the messages in the thread.",
|
|
135
|
+
],
|
|
136
|
+
inputs: { body: "retry test" },
|
|
137
|
+
expectedBehaviour: "The failed send keeps its ORIGINAL clientMessageId — generated once at compose time, not per attempt — so the retry resolves to the same message server-side rather than creating a second one. The server uses the clientMessageId as the message document id and returns the existing document on a replay.",
|
|
138
|
+
expectedUiState: "Offline: the bubble reads 'Failed — tap to retry' rather than disappearing or silently succeeding. After retry: exactly ONE 'retry test' bubble in the thread, no duplicate.",
|
|
139
|
+
expectedData: { "messages with body 'retry test'": 1 },
|
|
140
|
+
endResult: "Reload: one message. A silent failure or a duplicate would both be defects — the first is how the old feature hid, the second is what idempotency exists to prevent.",
|
|
141
|
+
},
|
|
142
|
+
"checklist-community-support-messages-soft-ban-blocks-sending": {
|
|
143
|
+
roles: ["admin", "buyer"],
|
|
144
|
+
startPage: "/admin/users",
|
|
145
|
+
steps: [
|
|
146
|
+
"Sign in as admin@letitrip.in and open /admin/users.",
|
|
147
|
+
"Find the account you have been testing with as the buyer, open its editor, and apply a soft ban with action 'Send messages' and a reason.",
|
|
148
|
+
"Sign in as that banned account and open a conversation you are a participant in.",
|
|
149
|
+
"Type anything and try to send.",
|
|
150
|
+
"Read the refusal.",
|
|
151
|
+
],
|
|
152
|
+
inputs: { "softBan.action": "send_messages", "softBan.reason": "QA test ban" },
|
|
153
|
+
expectedBehaviour: "The send is refused server-side with the ban's own reason quoted back. 🛑 This is the case that matters most about this flag: `send_messages` was a live BannedAction with NO READER anywhere in the codebase — an admin could set it and nothing checked it. The check lives in the domain function, so every caller (route, future job) is covered rather than just the one route someone remembered.",
|
|
154
|
+
expectedUiState: "An error surfaces naming the restriction and the reason — 'You are restricted from sending messages. Reason: QA test ban.' — not a generic failure and not a silently-dropped message.",
|
|
155
|
+
expectedData: { "messages added to the thread": 0 },
|
|
156
|
+
endResult: "Reload: no message was added. Remove the ban and the same send succeeds, which is what proves the ban was the cause rather than something else.",
|
|
157
|
+
},
|
|
158
|
+
"checklist-community-support-messages-block-stops-the-other-side": {
|
|
159
|
+
roles: ["buyer", "seller"],
|
|
160
|
+
startPage: "/messages",
|
|
161
|
+
steps: [
|
|
162
|
+
"Sign in as the seller (user-tyson-blader) and open the shared conversation.",
|
|
163
|
+
"Click 'Block' in the thread header and confirm.",
|
|
164
|
+
"Sign out, sign back in as the buyer (admin@letitrip.in), open the same conversation.",
|
|
165
|
+
"Try to send a message.",
|
|
166
|
+
],
|
|
167
|
+
expectedBehaviour: "Blocking records the blocker's uid on the conversation. A send from the blocked party is refused with a clear reason rather than silently vanishing — a blocked sender who thinks their message went through is worse than one who is told.",
|
|
168
|
+
expectedUiState: "Blocker side: a confirmation, then the block persists. Blocked side: the send fails with 'The other participant is not accepting messages from you.'",
|
|
169
|
+
expectedData: { "messages added after the block": 0 },
|
|
170
|
+
endResult: "Reload on both sides: the block holds and the thread's existing history is still readable to both. Blocking stops new messages; it does not delete the past.",
|
|
171
|
+
},
|
|
172
|
+
"checklist-community-support-messages-live-ping-surfaces-failure": {
|
|
173
|
+
roles: ["buyer"],
|
|
174
|
+
startPage: "/messages",
|
|
175
|
+
steps: [
|
|
176
|
+
"Open a conversation and leave it open.",
|
|
177
|
+
"Open DevTools → Network and block requests to the Firebase Realtime Database host (or set throttling to Offline then back, which drops the subscription).",
|
|
178
|
+
"Watch the top of the thread for a notice.",
|
|
179
|
+
"In a second browser profile signed in as the other participant, send a message.",
|
|
180
|
+
"Reload the first tab.",
|
|
181
|
+
],
|
|
182
|
+
expectedBehaviour: "🛑 THE CONTROL FOR THE BUG THAT KILLED THE OLD FEATURE. When the realtime channel cannot connect, the failure is SURFACED, never swallowed. The old hooks swallowed permission-denied into `setIsConnected(false)` and a literal `// ignore`, so the channel looked implemented and was inert for its entire life. The message is still delivered — it is durably in Firestore the moment the send returns — and the subscription only controls whether the open tab learns about it without a reload.",
|
|
183
|
+
expectedUiState: "A visible warning strip above the thread reading 'Live updates are unavailable for this conversation. New messages may not appear until you reload.' — NOT a silent nothing. After reload, the other participant's message is present.",
|
|
184
|
+
endResult: "The message was never lost; only the live notification was. That distinction is what the warning communicates, and a tester seeing no warning at all is the defect.",
|
|
185
|
+
},
|
|
186
|
+
"checklist-community-support-messages-inbox-read-is-bounded": {
|
|
187
|
+
roles: ["buyer"],
|
|
188
|
+
startPage: "/messages",
|
|
189
|
+
steps: [
|
|
190
|
+
"Open DevTools → Network and filter to /api/messages.",
|
|
191
|
+
"Load /messages and note the request and its response shape.",
|
|
192
|
+
"Open one conversation and note the thread request's response shape.",
|
|
193
|
+
"Scroll the thread to its oldest message.",
|
|
194
|
+
],
|
|
195
|
+
expectedBehaviour: "🛑 THE NEGATIVE CONTROL THE PLAN NAMES. Both list reads are bounded and cursor-paginated: the inbox returns at most 50 conversations with a `nextCursor`, the thread at most 50 messages (default 30) with its own `nextCursor`. The absence of a `.limit()` is literally what turned 20 messages into ~1,600 reads the first time this feature existed. The unread badge sums the conversation list the inbox already holds and never issues a list query of its own.",
|
|
196
|
+
expectedUiState: "Each response carries `{ items: [...], nextCursor }` with `items.length` at or below the limit — never an unbounded array, and never a response whose size grows with total thread count.",
|
|
197
|
+
expectedData: { "inbox items.length <= 50": true, "thread items.length <= 50": true },
|
|
198
|
+
endResult: "Reload: the same bounded shape. A response returning every message in a thread is the defect this case exists to catch, and it is invisible from the rendered page — only the network panel shows it.",
|
|
199
|
+
},
|
|
200
|
+
};
|
|
@@ -71,6 +71,7 @@ import { authored as a_buying__user_dashboard_extras } from "./buying__user-dash
|
|
|
71
71
|
import { authored as a_buying__offers } from "./buying__offers.js";
|
|
72
72
|
import { authored as a_buying__cart } from "./buying__cart.js";
|
|
73
73
|
import { authored as a_community_support__public_profile } from "./community-support__public-profile.js";
|
|
74
|
+
import { authored as a_community_support__messages } from "./community-support__messages.js";
|
|
74
75
|
import { authored as a_community_support__support_tickets } from "./community-support__support-tickets.js";
|
|
75
76
|
import { authored as a_content_discovery__blog } from "./content-discovery__blog.js";
|
|
76
77
|
import { authored as a_content_discovery__coupons } from "./content-discovery__coupons.js";
|
|
@@ -191,6 +192,7 @@ export const AUTHORED_CASES = {
|
|
|
191
192
|
...a_buying__offers,
|
|
192
193
|
...a_buying__cart,
|
|
193
194
|
...a_community_support__public_profile,
|
|
195
|
+
...a_community_support__messages,
|
|
194
196
|
...a_community_support__support_tickets,
|
|
195
197
|
...a_content_discovery__blog,
|
|
196
198
|
...a_content_discovery__coupons,
|