From df1ef75511fcbb21a3d798117a8fe9c7475b4f0b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:27:02 +0700 Subject: [PATCH 01/69] fix(importer): watch band size range or list before the band keys size-less (#1183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(importer): watch band size range and list before the band key size-less A band size range written with " - " or an en dash between two bare sizes ("Sport Loop 42mm - 46mm"), and two or more sizes listed right before a band word ("GPS 40mm, 44mm Dây Sport Band"), name the band's sizes, not the case. Both now key size-less. A dashed size with more text after it, a single size before the band and a part that names the case keep their key. Closes #1162 * fix(importer): a band-led size or a case word before the list keeps the watch case size * fix(importer): watch range follows the last listed size; a band naming the case keeps it Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 44 +++++++++++++++++ .../importer/src/shopee/product-normalizer.ts | 48 +++++++++++++++++-- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 0b66cf002..93980e9b5 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -503,6 +503,50 @@ async function runSuite() { for (const [title, expectedKey] of watchBandListKeys1137) { assert.equal(keyOf(title), expectedKey, title); } + // DOS/Rate#1162: a range of bare band sizes, or two or more sizes listed right before a + // band word, names no case. + const watchBandRangeKeys1162: Array<[string, string]> = [ + // The titles of the issue. + ['Apple Watch Series 10 Sport Loop 42mm - 46mm', 'apple:watch-series-10:unknown'], + ['Apple Watch SE 3 GPS 40mm, 44mm Dây Sport Band', 'apple:watch-se-3:unknown'], + // An en dash range keys like " - "; a pipe or longer list keys like a comma list. + ['Apple Watch Series 10 Sport Loop 42mm \u2013 46mm', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 GPS, 42mm, 46mm Dây Sport Loop', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 GPS 42mm | 46mm Dây Cao Su', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 GPS 46mm - 42mm Dây Sport Band', 'apple:watch-series-10:unknown'], + // A size after the dash with more text, a single size before the band, a size the + // case names or a size cut off by ")" is still the case's. + ['Apple Watch Series 10 Sport Loop 42mm - 46mm GPS', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 GPS, 44mm Dây Sport Band', 'apple:watch-series-10-44mm:unknown'], + [ + 'Apple Watch Series 10 Vỏ nhôm 46mm, 42mm Dây Sport Band', + 'apple:watch-series-10-46mm:unknown', + ], + ['Apple Watch Series 10 (GPS 46mm) 42mm Dây Sport', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 GPS 46mm, 46mm Dây Cao Su', 'apple:watch-series-10-46mm:unknown'], + [ + 'Apple Watch Series 10 (Vỏ nhôm) 46mm, 42mm Dây Sport Band', + 'apple:watch-series-10-46mm:unknown', + ], + [ + 'Apple Watch Series 10 GPS 46mm, 42mm Dây Sport Band mặt 46mm', + 'apple:watch-series-10-46mm:unknown', + ], + // A range runs on from a listed bare size, not from one with a fit after it. + ['Apple Watch Series 10 Sport Band 42mm S/M, 44mm - 46mm', 'apple:watch-series-10:unknown'], + [ + 'Apple Watch Series 10 Sport Band 42mm, 44mm M/L - 46mm', + 'apple:watch-series-10-46mm:unknown', + ], + // A size with a band of its own is no list item: the size before it is the case's. + [ + 'Apple Watch Series 10 GPS 46mm Dây Sport Band, 42mm Dây Cao Su', + 'apple:watch-series-10-46mm:unknown', + ], + ]; + for (const [title, expectedKey] of watchBandRangeKeys1162) { + assert.equal(keyOf(title), expectedKey, title); + } // The registry has one entry per line: a sized title keeps the line's benchmark, but // the entry's screen and battery describe only the size it was written for. assert.equal( diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index a0eb635b2..531136a4d 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1157,6 +1157,9 @@ const WATCH_BAND_WORD = /(? = { ipad: sizePatterns(IPAD_SIZE), @@ -1194,17 +1197,56 @@ function readAppleSize(model: string, title: string): string { // A band phrase with a size runs on over the parts after it that start with a size // and follow "," or "|": "Sport Loop 42mm | 46mm" or "Dây 42mm S/M, 46mm M/L" names // two band sizes, not a case (DOS/Rate#1137). A part that names the case ("46mm, 42mm - // Aluminum Case") ends the list. + // Aluminum Case") ends the list. An en dash or " - " between a band size and a bare + // size is a range of band sizes ("Sport Loop 42mm - 46mm"), and two or more sizes + // listed right before a band word are the band's ("GPS 40mm, 44mm Dây Sport Band"); a + // size after the dash with more text ("Dây Vải 45mm - 46mm GPS") or a single size + // before the band ("GPS 46mm, Dây Cao Su 46mm S/M") is still the case's (DOS/Rate#1162). const parts = own.split(/([),|;\u2013]| - )/); const outside: string[] = []; let bandList = false; + let bandEnd = false; for (let i = 0; i < parts.length; i += 2) { const part = parts[i]!; + const sep = parts[i - 1] ?? ''; const listed = LIST_WATCH_SIZE.test(part) && !WATCH_CASE_WORD.test(part.normalize('NFC')); - if (bandList && /^[,|]$/.test(parts[i - 1]!) && listed) continue; + const ranged = bandEnd && WATCH_RANGE_SEP.test(sep) && LONE_WATCH_SIZE.test(part); + if (bandList && ((/^[,|]$/.test(sep) && listed) || ranged)) { + outside.push(''); + // "42mm S/M, 44mm - 46mm" ranges on from the listed size; "44mm M/L - 46mm" does not. + bandEnd = LAST_WATCH_SIZE.test(part); + continue; + } const [before, ...band] = part.split(WATCH_BAND_WORD); - outside.push(before!); + // Walk back over the bare sizes listed before this band ("40mm, 44mm Dây"): k ends + // at the first of them, and the part before it may end in one more ("GPS 40mm") if + // it has no band of its own. A part before the list that names the case ("(Vỏ nhôm) + // 46mm, 42mm Dây") keeps the list's first size as the case's, and a band that names + // the case ("42mm Dây Sport Band mặt 46mm") starts no walk. + const sizeBeforeBand = + band.length > 0 && + LONE_WATCH_SIZE.test(before!) && + !WATCH_CASE_WORD.test(part.normalize('NFC')); + const listSepAt = (n: number) => + /^[,|]$/.test(parts[2 * n - 1]!) || WATCH_RANGE_SEP.test(parts[2 * n - 1]!); + let k = outside.length; + while (sizeBeforeBand && k > 0 && listSepAt(k) && LONE_WATCH_SIZE.test(outside[k - 1]!)) k--; + const head = k > 0 ? parts[2 * k - 2]! : ''; + const headCase = WATCH_CASE_WORD.test(head.normalize('NFC')); + const headListed = + sizeBeforeBand && + k > 0 && + listSepAt(k) && + LAST_WATCH_SIZE.test(head) && + !WATCH_BAND_WORD.test(head) && + !headCase; + if ((k < outside.length && !headCase) || headListed) { + for (let j = k; j < outside.length; j++) outside[j] = ''; + if (headListed) outside[k - 1] = outside[k - 1]!.replace(LAST_WATCH_SIZE, ''); + outside.push(''); + } else outside.push(before!); bandList = WATCH_SIZE.test(band.join(' ')); + bandEnd = LAST_WATCH_SIZE.test(band.join(' ')); } const caseSize = outside.join(' ').match(WATCH_SIZE)?.[1]; if (caseSize) return `${caseSize}mm`; From 6d0d72b34c99df5efe303e4652a03df4ca1e9517 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:28:22 +0700 Subject: [PATCH 02/69] fix(web): apply category spec templates to compare, leaderboard, export and api v1 (#1182) * fix(web): apply category spec templates to compare, leaderboard, export and api v1 /compare, the leaderboard rows, the spec export, the key spec line and the REST API v1 product, list and compare responses now read the category's spec view from applyCategorySpecTemplate, like the product page (#1142). An earbud shows no screen, RAM, ROM or camera there and a watch no 5G row. Display mode of applyCategorySpecTemplate also drops the 5G parts of a watch's free-text network fields ("LTE / 5G" becomes "LTE"); storage mode keeps them. Closes #1166 * fix(web): strip 5g qualifiers and use template headings on compare Review follow-up for #1166: the 5G stripping removes bracketed 5G notes and qualifiers such as "SA/NSA/Sub6", also cleans sim_type, and compare group headings come from specGroupHeading so a watch has no "5G" heading. An export row without a field entry stays visible, and the JSON export schema label no longer claims ten groups. Co-Authored-By: Claude Opus 5.5 --- .../app/[locale]/smartphones/[slug]/page.tsx | 3 +- apps/web/src/app/api/v1/rate/compare/route.ts | 4 +- .../app/api/v1/rate/products/[slug]/route.ts | 13 +- .../web/src/app/api/v1/rate/products/route.ts | 6 +- .../components/compare/CompareWorkspace.tsx | 77 +++++- .../leaderboard/LeaderboardExplorer.tsx | 9 +- .../leaderboard/ProductSpecsExportToolbar.tsx | 87 ++++++- .../src/lib/__tests__/spec-template.test.ts | 226 +++++++++++++++++- apps/web/src/lib/product-competitors.ts | 17 +- .../src/__tests__/shared-data.test.ts | 64 +++++ .../shared-data/src/rules/spec-template.ts | 56 ++++- 11 files changed, 527 insertions(+), 35 deletions(-) diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index e12b4ecb4..55d27fcc2 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -258,7 +258,7 @@ export async function ProductDetailView({ ? bayesianSummary.bayesianRating : rawUserRating; - const keySpecLine = buildKeySpecLine(product); + const keySpecLine = buildKeySpecLine(product, categorySlug); const mainImage = firstImageUrl(product.images); const canonicalUrl = `https://rate.vn${categoryBasePath}/${product.slug}`; @@ -1562,6 +1562,7 @@ export async function ProductDetailView({ brand={product.brand} specifications={specs || {}} keySpecs={keySpecs || {}} + categorySlug={categorySlug} radarSignal={radarSignal} /> diff --git a/apps/web/src/app/api/v1/rate/compare/route.ts b/apps/web/src/app/api/v1/rate/compare/route.ts index 7c8bfa1f2..7472de454 100644 --- a/apps/web/src/app/api/v1/rate/compare/route.ts +++ b/apps/web/src/app/api/v1/rate/compare/route.ts @@ -7,6 +7,7 @@ import { } from '@/server/rate'; import { computeProductRadarSignal } from '@/server/rate/deals'; import { radarExportFields } from '@/lib/deal-radar'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { PUBLIC_API_CORS_HEADERS, jsonApiError, @@ -61,7 +62,8 @@ export async function GET(request: NextRequest) { data: products.map((p) => { const offers = offersByMasterId.get(p.id) ?? []; return { - ...p, + // The category's spec view, as /compare shows it (DOS/Rate#1166). + ...applyCategorySpecTemplate(lockedCategory, p), canonical_href: getMasterProductHref(p), deal_radar: radarExportFields(computeProductRadarSignal(p, offers)), }; diff --git a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts index 37dea9fe0..c51fbed5a 100644 --- a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts +++ b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts @@ -8,6 +8,8 @@ import { } from '@/server/rate'; import { computeProductRadarSignal } from '@/server/rate/deals'; import { isOfferUnavailable } from '@/lib/product-market-stats'; +import { getMasterProductCategorySlug } from '@/lib/rate-domain'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { PUBLIC_API_CORS_HEADERS, jsonApiError, @@ -29,6 +31,9 @@ export async function OPTIONS() { * Returns canonical Master Product (10 Field Groups), cross-platform merchant offers, * time-series Price History + Fake Discount Detection, and JReviews Dual-Rating & AI Review Summary. * Designed for Rate Web UI, Rate Browser Extension (MV3), and ecosystem consumers. + * The product carries the category's spec view, as rate.vn shows it: no + * screen, RAM, storage or camera for earbuds and no 5G for a watch + * (DOS/Rate#1166). */ export async function GET(_request: NextRequest, context: RouteContext) { try { @@ -47,15 +52,19 @@ export async function GET(_request: NextRequest, context: RouteContext) { ); } - const product = await getCachedMasterProductBySlug(cleanSlug); + const storedProduct = await getCachedMasterProductBySlug(cleanSlug); - if (!product) { + if (!storedProduct) { return NextResponse.json( { ok: false, error: 'Product not found', code: 'NOT_FOUND' }, { status: 404, headers: PUBLIC_API_CORS_HEADERS } ); } + const product = applyCategorySpecTemplate( + getMasterProductCategorySlug(storedProduct), + storedProduct + ); const [offers, reviews] = await Promise.all([ getCachedMasterProductOffers(product.id), getCachedMasterProductReviews(product.id), diff --git a/apps/web/src/app/api/v1/rate/products/route.ts b/apps/web/src/app/api/v1/rate/products/route.ts index 4bb9be5fb..d88feabe2 100644 --- a/apps/web/src/app/api/v1/rate/products/route.ts +++ b/apps/web/src/app/api/v1/rate/products/route.ts @@ -7,6 +7,8 @@ import { } from '@/server/rate'; import { computeProductRadarSignal } from '@/server/rate/deals'; import { radarExportFields } from '@/lib/deal-radar'; +import { getMasterProductCategorySlug } from '@/lib/rate-domain'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { PUBLIC_API_CORS_HEADERS, jsonApiError, @@ -48,6 +50,8 @@ const VALID_PRODUCT_SORTS = new Set< * GET /api/v1/rate/products * Structured JSON Backend API for Web Client, Browser Extension MV3, and Partners. * Supports 10-Field-Groups multi-criteria filtering, sorting, and optional directory facets. + * Each product carries its category's spec view, as rate.vn shows it + * (DOS/Rate#1166); filters still match the stored columns. */ export async function GET(request: NextRequest) { try { @@ -145,7 +149,7 @@ export async function GET(request: NextRequest) { data: products.map((p) => { const offers = offersByMasterId.get(p.id) ?? []; return { - ...p, + ...applyCategorySpecTemplate(getMasterProductCategorySlug(p), p), deal_radar: radarExportFields(computeProductRadarSignal(p, offers)), }; }), diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index a43768000..01d8e9eb8 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -23,6 +23,13 @@ import type { MasterProduct, ProductSpecifications, } from '@repo/shared-data'; +import { + applyCategorySpecTemplate, + getCategorySpecTemplate, + specFieldApplies, + type CategorySpecTemplate, + type SpecGroupKey, +} from '@repo/shared-data/rules'; import { type CanonicalCategorySlug, MAX_VALID_VND_PRICE, @@ -55,7 +62,11 @@ import { setCompareTray, } from './compare-tray'; import { upsertWatchlistItem } from '@/components/watchlist/watchlist-store'; -import { getSpecUiDictionary, localizeSpecValue } from '@/lib/spec-localizer'; +import { + getSpecUiDictionary, + localizeSpecValue, + specGroupHeading, +} from '@/lib/spec-localizer'; import ContentText from '@/components/elementary/ContentText'; import { productImageUrl } from '@/lib/rate-domain-core'; @@ -75,6 +86,8 @@ interface SpecRowDef { renderValue: (p: MasterProduct) => string; numericValue?: (p: MasterProduct) => number | null; higherIsBetter?: boolean; + /** Whether the category's spec template has this row (DOS/Rate#1166). */ + appliesTo?: (template: CategorySpecTemplate) => boolean; } interface SpecGroupDef { @@ -167,7 +180,7 @@ function buildCategoryTabs(t: CompareTranslator): Array<{ ]; } -function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { +export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { return [ { id: 'market_trust', @@ -295,6 +308,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { { id: 'display_size', label: t('rowDisplaySize'), + appliesTo: (tpl) => tpl.screen, renderValue: (p) => { const size = resolveCompareDisplaySizeInch(p); return size != null ? `${size}"` : '-'; @@ -373,6 +387,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_cpu_gpu', + appliesTo: (tpl) => specFieldApplies(tpl, 'hardware', 'cpu'), label: t('rowHwCpuGpu'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; @@ -384,6 +399,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_ram', + appliesTo: (tpl) => tpl.ram, label: t('rowHwRam'), renderValue: (p) => { const ram = resolveCompareRamGb(p); @@ -394,6 +410,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_storage', + appliesTo: (tpl) => tpl.storage, label: t('rowHwStorage'), renderValue: (p) => { const rom = resolveCompareStorageGb(p); @@ -405,6 +422,9 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'hw_os_exp', + appliesTo: (tpl) => + specFieldApplies(tpl, 'hardware', 'os') || + specFieldApplies(tpl, 'hardware', 'storage_expansion'), label: t('rowHwOsExp'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; @@ -599,6 +619,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { rows: [ { id: 'cell_5g', + appliesTo: (tpl) => tpl.has5g, label: t('rowCell5g'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; @@ -655,6 +676,7 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { }, { id: 'conn_nfc', + appliesTo: (tpl) => specFieldApplies(tpl, 'connectivity', 'nfc'), label: t('rowConnNfc'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; @@ -768,6 +790,28 @@ function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { ]; } +/** + * The spec groups and rows a category's template has, in its order, so an + * earbud shows no screen, RAM or camera row and a watch no 5G row + * (DOS/Rate#1142, DOS/Rate#1166). The market group always comes first. + */ +export function filterSpecGroupsForCategory( + groups: readonly SpecGroupDef[], + categorySlug: string +): SpecGroupDef[] { + const template = getCategorySpecTemplate(categorySlug); + const order = (id: string) => + id === 'market_trust' ? -1 : template.groups.indexOf(id as SpecGroupKey); + return groups + .filter((group) => order(group.id) !== -1 || group.id === 'market_trust') + .sort((a, b) => order(a.id) - order(b.id)) + .map((group) => ({ + ...group, + rows: group.rows.filter((row) => row.appliesTo?.(template) ?? true), + })) + .filter((group) => group.rows.length > 0); +} + export default function CompareWorkspace({ initialProducts, catalogCandidates, @@ -779,14 +823,14 @@ export default function CompareWorkspace({ const router = useRouter(); const specDict = useMemo(() => getSpecUiDictionary(locale), [locale]); const categoryTabs = useMemo(() => buildCategoryTabs(t), [t]); - const specGroups = useMemo(() => buildSpecGroups(t), [t]); + const allSpecGroups = useMemo(() => buildSpecGroups(t), [t]); const [activeCategory, setActiveCategory] = useState( () => initialProducts.length > 0 ? getMasterProductCategorySlug(initialProducts[0]!) : initialCategory ); - const [products, setProducts] = useState(() => { + const [storedProducts, setProducts] = useState(() => { const targetCat = initialProducts.length > 0 ? getMasterProductCategorySlug(initialProducts[0]!) @@ -806,6 +850,17 @@ export default function CompareWorkspace({ const [highlightWinners, setHighlightWinners] = useState(true); const [copiedLink, setCopiedLink] = useState(false); const radarIndex = useDealRadarIndex(); + // Every row, verdict and export reads the category's view of a product, + // like the product page (DOS/Rate#1166). + const products = useMemo( + () => + storedProducts.map((p) => applyCategorySpecTemplate(activeCategory, p)), + [storedProducts, activeCategory] + ); + const specGroups = useMemo( + () => filterSpecGroupsForCategory(allSpecGroups, activeCategory), + [allSpecGroups, activeCategory] + ); const searchInputRef = useRef(null); const searchBoxRef = useRef(null); @@ -2202,7 +2257,7 @@ export default function CompareWorkspace({ - )} - {specGroups.map((group) => { + {specGroups.map((group, groupIndex) => { // Filter rows when `showDiffOnly` is enabled const visibleRows = group.rows.filter((row) => { if (!showDiffOnly || products.length < 2) return true; @@ -2222,11 +2277,17 @@ export default function CompareWorkspace({ 1 + (products.length < MAX_COMPARE_ITEMS ? 1 : 0); + // Numbered in the category's order, and a watch's + // cellular heading without "5G" (DOS/Rate#1166). The + // market group comes first, so spec groups start at 1. const groupTitle = group.id in specDict.groups - ? specDict.groups[ - group.id as keyof typeof specDict.groups - ] + ? specGroupHeading( + specDict, + group.id as SpecGroupKey, + groupIndex, + getCategorySpecTemplate(activeCategory).has5g + ) : group.title; return ( diff --git a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx index 5b0cf660c..57be9ac29 100644 --- a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx +++ b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx @@ -36,6 +36,7 @@ import { useDealRadarIndex } from '@/components/radar/use-deal-radar'; import CompareQuickToggle from '@/components/compare/CompareQuickToggle'; import { escapeCsvCell } from '@/lib/csv-safe'; import type { KeySpecs } from '@repo/shared-data'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { BsArrowLeftRight, BsAwardFill, @@ -409,10 +410,12 @@ export default function LeaderboardExplorer({ return map; }, [products]); + // Rows read the category's view of a product, like the product page: no + // RAM or storage pill for earbuds (DOS/Rate#1166). const categoryProducts = useMemo(() => { - return products.filter( - (p) => getMasterProductCategorySlug(p) === activeCategory - ); + return products + .filter((p) => getMasterProductCategorySlug(p) === activeCategory) + .map((p) => applyCategorySpecTemplate(activeCategory, p)); }, [products, activeCategory]); const availableBrands = useMemo(() => { diff --git a/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx b/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx index 2dc9724dd..25b6e3b1d 100644 --- a/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx +++ b/apps/web/src/components/leaderboard/ProductSpecsExportToolbar.tsx @@ -4,6 +4,13 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; import ExportMenu from '@/components/elementary/ExportMenu'; import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; +import { + applyCategorySpecTemplate, + getCategorySpecTemplate, + specFieldApplies, + type CategorySpecTemplate, + type SpecGroupKey, +} from '@repo/shared-data/rules'; import { RADAR_CSV_HEADERS, radarCsvCells, @@ -62,13 +69,55 @@ function joinSpecParts(parts: Array): string { return valid.join(' | ') || '-'; } +type SpecExportRow = [string, string, string]; + +// The template group each row reads, and the fields that make it a row the +// category has (any one of them is enough). +const EXPORT_ROW_FIELDS: ReadonlyArray<[SpecGroupKey, readonly string[]]> = [ + ['display', ['size_inch', 'type', 'refresh_rate_hz']], + ['display', ['resolution', 'protection']], + ['hardware', ['chipset', 'cpu', 'gpu']], + ['hardware', ['ram_gb', 'storage_gb', 'os']], + ['camera', ['main_camera']], + ['camera', ['video_recording', 'front_camera']], + ['battery', ['capacity_mah']], + ['design', ['dimensions', 'weight_g', 'resistance']], + ['cellular', ['technology', 'sim_type', 'data_speed']], + ['connectivity', ['wlan', 'bluetooth', 'usb']], + ['multimedia', ['loudspeaker', 'audio_features']], + ['availability', ['announced']], + ['regulatory', ['certifications']], +]; + +const exportRowApplies = ( + template: CategorySpecTemplate, + index: number +): boolean => { + const entry = EXPORT_ROW_FIELDS[index]; + // A row added without an entry stays visible rather than vanishing. + if (!entry) return true; + const [group, fields] = entry; + return fields.some((field) => specFieldApplies(template, group, field)); +}; + +/** + * The spec rows a product exports. With a category, only the rows and values + * its spec template has, as on the product page: no screen, RAM, ROM or + * camera for earbuds and no 5G for a watch (DOS/Rate#1166). + */ export function buildProductSpecExportRows( specifications?: ProductSpecifications | null, - keySpecs?: KeySpecs | null -): Array<[string, string, string]> { - const specs = specifications ?? {}; - const keys = keySpecs ?? {}; - return [ + keySpecs?: KeySpecs | null, + categorySlug?: string | null +): SpecExportRow[] { + const scoped = applyCategorySpecTemplate(categorySlug, { + specifications: specifications ?? {}, + key_specs: keySpecs ?? {}, + }); + const template = getCategorySpecTemplate(categorySlug); + const specs = (scoped.specifications ?? {}) as ProductSpecifications; + const keys = (scoped.key_specs ?? {}) as KeySpecs; + const rows: SpecExportRow[] = [ [ '1. Display', 'Size & Panel', @@ -195,6 +244,7 @@ export function buildProductSpecExportRows( ]), ], ]; + return rows.filter((_, index) => exportRowApplies(template, index)); } interface ProductSpecsExportToolbarProps { @@ -203,6 +253,8 @@ interface ProductSpecsExportToolbarProps { readonly brand: string; readonly specifications: ProductSpecifications; readonly keySpecs: KeySpecs; + /** Scopes the rows and the JSON to the category's spec template. */ + readonly categorySlug?: string | null; readonly radarSignal?: RadarSignal | null; } @@ -212,6 +264,7 @@ export default function ProductSpecsExportToolbar({ brand, specifications, keySpecs, + categorySlug, radarSignal, }: ProductSpecsExportToolbarProps) { const t = useTranslations('productDetail'); @@ -223,10 +276,14 @@ export default function ProductSpecsExportToolbar({ const handleCopySpecsMarkdown = async () => { if (typeof navigator === 'undefined') return; - const rows = buildProductSpecExportRows(specifications, keySpecs); + const rows = buildProductSpecExportRows( + specifications, + keySpecs, + categorySlug + ); const md = [ - `### 📋 Rate.vn 10-Field-Groups Technical Specifications: ${safeProductName} (${safeBrand})`, + `### 📋 Rate.vn Technical Specifications: ${safeProductName} (${safeBrand})`, `- **Price signal:** ${radarMarkdownCell(radarSignal)}`, '', '| Field Group | Attribute | Specification |', @@ -248,7 +305,11 @@ export default function ProductSpecsExportToolbar({ const handleDownloadSpecsCsv = () => { if (typeof document === 'undefined') return; - const rows = buildProductSpecExportRows(specifications, keySpecs); + const rows = buildProductSpecExportRows( + specifications, + keySpecs, + categorySlug + ); const headers = [ 'Slug', 'Product', @@ -289,15 +350,19 @@ export default function ProductSpecsExportToolbar({ const handleDownloadSpecsJson = () => { if (typeof document === 'undefined') return; + const scoped = applyCategorySpecTemplate(categorySlug, { + specifications, + key_specs: keySpecs, + }); const payload = { slug: safeSlug, name: safeProductName, brand: safeBrand, - schema: 'Rate.vn 10-Field-Groups (GSMArena + PhoneArena)', + schema: 'Rate.vn spec field groups (GSMArena + PhoneArena)', exported_at: new Date().toISOString(), deal_radar: radarExportFields(radarSignal), - key_specs: keySpecs, - specifications, + key_specs: scoped.key_specs, + specifications: scoped.specifications, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8;', diff --git a/apps/web/src/lib/__tests__/spec-template.test.ts b/apps/web/src/lib/__tests__/spec-template.test.ts index 3bc2a9727..6ed6529a1 100644 --- a/apps/web/src/lib/__tests__/spec-template.test.ts +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -23,7 +23,225 @@ const headings = (category: string, locale: string) => { ); }; -function main() { +// A stored earbud and a cellular watch the way the enricher filled them: +// phone-shaped, with a screen, RAM, ROM, cameras and 5G. +const phoneShapedSpecs = { + display: { size_inch: 1, type: 'OLED', refresh_rate_hz: 60, ppi: 326 }, + hardware: { chipset: 'H2', cpu: 'c', ram_gb: 1, storage_gb: 64, os: 'o' }, + camera: { main_camera: '2 mic', front_camera: 'f' }, + battery: { capacity_mah: 500 }, + cellular: { + has_5g: true, + technology: 'LTE / 5G', + data_speed: 'LTE, 5G', + bands_5g: 'n78', + sim_type: 'eSIM', + }, + connectivity: { bluetooth: '5.3', nfc: true }, + multimedia: { audio_features: 'ANC' }, +}; +const phoneShapedProduct = { + id: 'm1', + slug: 'phone-shaped', + name: 'Phone shaped', + brand: 'Apple', + ram_gb: 1, + storage_gb: 64, + screen_size_inch: 1, + has_5g: true, + specifications: phoneShapedSpecs, + key_specs: { + screen: '1 inch', + chip: 'H2', + camera: 'mic', + ram: '1GB', + storage: '64GB', + battery: '500 mAh', + }, +}; + +const SCREEN_RAM_CAMERA_RE = + /\b(?:\d+(?:\.\d+)?\s*(?:"|inch|Hz|ppi)|\d+\s*GB|RAM|ROM|mic|camera)\b/i; +const FIVE_G_RE = /\b5G\b/; + +async function testSurfaces() { + // /compare (DOS/Rate#1166): an earbud has no screen, hardware memory, + // camera or cellular row; a watch no 5G row and no 5G in its network text. + const nodeModule = await import('node:module'); + const extMap = ( + nodeModule.default as unknown as { + _extensions?: Record void>; + } + )._extensions; + if (extMap && !extMap['.css']) { + extMap['.css'] = (mod) => { + mod.exports = {}; + }; + } + const { buildSpecGroups, filterSpecGroupsForCategory } = + await import('../../components/compare/CompareWorkspace'); + const t = ((key: string) => key) as unknown as Parameters< + typeof buildSpecGroups + >[0]; + const all = buildSpecGroups(t); + const rowIds = (category: string) => + filterSpecGroupsForCategory(all, category).flatMap((g) => + g.rows.map((r) => r.id) + ); + const groupIds = (category: string) => + filterSpecGroupsForCategory(all, category).map((g) => g.id); + assert.deepEqual( + groupIds('smartphones'), + all.map((g) => g.id) + ); + assert.deepEqual(rowIds('smartphones'), rowIds('tablets')); + assert.deepEqual(groupIds('earbuds'), [ + 'market_trust', + 'multimedia', + 'battery', + 'connectivity', + 'hardware', + 'design', + 'availability', + 'regulatory', + ]); + const budRows = rowIds('earbuds'); + for (const id of [ + 'display_size', + 'display_refresh', + 'hw_cpu_gpu', + 'hw_ram', + 'hw_storage', + 'hw_os_exp', + 'cam_main', + 'cell_5g', + 'conn_nfc', + ]) { + assert.ok(!budRows.includes(id), `earbuds compare has no ${id}`); + } + assert.ok(budRows.includes('hw_chipset')); + const watchRows = rowIds('smartwatches'); + assert.ok(!watchRows.includes('cell_5g')); + assert.ok(!groupIds('smartwatches').includes('camera')); + assert.ok(watchRows.includes('hw_ram') && watchRows.includes('cell_sim')); + + const render = (category: string) => { + const product = applyCategorySpecTemplate( + category, + phoneShapedProduct as never + ); + return filterSpecGroupsForCategory(all, category) + .filter((g) => g.id !== 'market_trust') + .flatMap((g) => g.rows.map((r) => `${r.id}=${r.renderValue(product)}`)); + }; + const budCells = render('earbuds'); + assert.ok( + budCells.every( + (c) => c.startsWith('hw_chipset=') || !SCREEN_RAM_CAMERA_RE.test(c) + ), + budCells.join(' | ') + ); + const watchCells = render('smartwatches'); + assert.ok( + watchCells.every((c) => !FIVE_G_RE.test(c)), + watchCells.join(' | ') + ); + assert.ok(watchCells.includes('cell_sim=eSIM | LTE'), watchCells.join(' | ')); + + // Spec export (Markdown, CSV and JSON share the rows). + const { buildProductSpecExportRows } = + await import('../../components/leaderboard/ProductSpecsExportToolbar'); + const exportRows = (category?: string) => + buildProductSpecExportRows( + phoneShapedSpecs as never, + phoneShapedProduct.key_specs, + category + ); + assert.equal(exportRows().length, 13); + assert.equal(exportRows('smartphones').length, 13); + const budExport = exportRows('earbuds'); + assert.deepEqual( + budExport.map(([, attr]) => attr), + [ + 'Chipset & CPU/GPU', + 'Capacity & Fast Charging', + 'Dimensions, Weight & IP Rating', + 'WLAN, Bluetooth & USB', + 'Audio & Speakers', + 'Release & MSRP', + 'Certifications & SAR', + ] + ); + assert.equal(budExport[0]?.[2], 'H2'); + assert.ok( + budExport.every(([, , v]) => !SCREEN_RAM_CAMERA_RE.test(v)), + JSON.stringify(budExport) + ); + const watchExport = exportRows('smartwatches'); + assert.ok(!watchExport.some(([grp]) => grp.startsWith('3. Camera'))); + assert.ok( + watchExport.every(([, , v]) => !FIVE_G_RE.test(v)), + JSON.stringify(watchExport) + ); + assert.ok( + watchExport.some(([, , v]) => v === 'LTE | eSIM | LTE'), + JSON.stringify(watchExport) + ); + + // The key spec line outside the product page. + const { buildKeySpecLine } = await import('../product-competitors'); + assert.equal( + buildKeySpecLine(phoneShapedProduct as never, 'earbuds'), + 'H2 • 500 mAh' + ); + assert.equal( + buildKeySpecLine(phoneShapedProduct as never), + 'H2 • 1GB • 64GB • 500 mAh' + ); + + // The leaderboard rows, the export toolbar on the product page and REST + // API v1 read the scoped product. + const leaderboard = readSource( + '../../components/leaderboard/LeaderboardExplorer.tsx' + ); + assert.match( + leaderboard, + /\.map\(\(p\) => applyCategorySpecTemplate\(activeCategory, p\)\)/ + ); + const compare = readSource('../../components/compare/CompareWorkspace.tsx'); + assert.match( + compare, + /storedProducts\.map\(\(p\) => applyCategorySpecTemplate\(activeCategory, p\)\)/ + ); + // Compare headings come from the template too: no "5G" heading for a watch. + assert.match( + compare, + /specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*groupIndex,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/ + ); + const detail = readSource('../../app/[locale]/smartphones/[slug]/page.tsx'); + assert.match(detail, /categorySlug=\{categorySlug\}/); + assert.match(detail, /buildKeySpecLine\(product, categorySlug\)/); + const apiList = readSource('../../app/api/v1/rate/products/route.ts'); + assert.match( + apiList, + /\.\.\.applyCategorySpecTemplate\(getMasterProductCategorySlug\(p\), p\)/ + ); + const apiDetail = readSource( + '../../app/api/v1/rate/products/[slug]/route.ts' + ); + assert.match( + apiDetail, + /const product = applyCategorySpecTemplate\(\s*getMasterProductCategorySlug\(storedProduct\),\s*storedProduct\s*\)/ + ); + const apiCompare = readSource('../../app/api/v1/rate/compare/route.ts'); + assert.match( + apiCompare, + /\.\.\.applyCategorySpecTemplate\(lockedCategory, p\)/ + ); + console.warn(' ✅ Compare, leaderboard, export and API v1 scoped'); +} + +async function main() { console.warn('🧪 Category spec template suite (DOS/Rate#1142)'); // Phones keep the ten numbered groups, worded as before. @@ -114,7 +332,11 @@ function main() { /applyCategorySpecTemplate\(\s*getMasterProductCategorySlug/ ); + await testSurfaces(); console.warn(' ✅ Category spec templates passed'); } -main(); +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/web/src/lib/product-competitors.ts b/apps/web/src/lib/product-competitors.ts index 41c07c69f..9f5df65f5 100644 --- a/apps/web/src/lib/product-competitors.ts +++ b/apps/web/src/lib/product-competitors.ts @@ -3,6 +3,7 @@ import type { MasterProduct, ProductSpecifications, } from '@repo/shared-data'; +import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { resolveCompareBatteryMah, resolveCompareRamGb, @@ -103,16 +104,24 @@ export function peerPriceDelta( /** * "Chip • RAM • storage • battery" for the share card, from key_specs with a - * fallback to the 10-group specifications and top-level columns. + * fallback to the 10-group specifications and top-level columns. With a + * category it reads only what the category's spec template has, so an + * earbud never shows RAM or storage (DOS/Rate#1166). */ export function buildKeySpecLine( - product: Pick< + storedProduct: Pick< MasterProduct, 'key_specs' | 'specifications' | 'ram_gb' | 'storage_gb' | 'battery_mah' - > + >, + categorySlug?: string | null ): string { - if (!product || typeof product !== 'object' || Array.isArray(product)) + if ( + !storedProduct || + typeof storedProduct !== 'object' || + Array.isArray(storedProduct) + ) return ''; + const product = applyCategorySpecTemplate(categorySlug, storedProduct); const keySpecs = ( product.key_specs && typeof product.key_specs === 'object' && diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 869241bf4..9d854b6d5 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -942,6 +942,70 @@ function testCategorySpecTemplates(): void { assert.equal(watchStoredSpecs.hardware?.gpu, 'g'); assert.equal(watchStoredSpecs.camera, undefined); + // The free-text network fields lose their 5G parts on the page only + // (DOS/Rate#1166); a 5GHz Wi-Fi mention is not 5G. + const lteWatch = { + specifications: { + cellular: { + technology: 'LTE / 5G', + data_speed: 'LTE Cat 4, 5G NR', + sim_type: 'eSIM', + }, + connectivity: { wlan: 'Wi-Fi 802.11 b/g/n, 5GHz' }, + }, + }; + const lteWatchShown = rules.applyCategorySpecTemplate( + 'smartwatches', + lteWatch + ).specifications as unknown as Record>; + assert.deepEqual(lteWatchShown.cellular, { + technology: 'LTE', + data_speed: 'LTE Cat 4', + sim_type: 'eSIM', + }); + assert.deepEqual( + lteWatchShown.connectivity, + lteWatch.specifications.connectivity + ); + assert.deepEqual( + rules.applyCategorySpecTemplate('smartwatches', { + specifications: { cellular: { technology: '5G' } }, + }).specifications, + { cellular: {} } + ); + assert.deepEqual( + rules.applyCategorySpecTemplate('smartwatches', lteWatch, { + forStorage: true, + }).specifications, + lteWatch.specifications + ); + assert.equal(lteWatch.specifications.cellular.technology, 'LTE / 5G'); + // 5G qualifiers and bracketed notes go with it; other text stays. + const shownTech = (technology: string) => + ( + rules.applyCategorySpecTemplate('smartwatches', { + specifications: { cellular: { technology } }, + }).specifications as { cellular: { technology?: string } } + ).cellular.technology; + assert.equal(shownTech('HSPA, LTE-A, 5G SA/NSA/Sub6'), 'HSPA, LTE-A'); + assert.equal(shownTech('LTE / 5G NR Sub-6'), 'LTE'); + assert.equal(shownTech('LTE (5G RedCap)'), 'LTE'); + assert.equal(shownTech('5G-ready LTE'), 'LTE'); + assert.equal(shownTech('LTE (Cat 4) / 5G'), 'LTE (Cat 4)'); + assert.equal(shownTech('GSM / LTE'), 'GSM / LTE'); + assert.equal( + shownTech(shownTech('HSPA, LTE-A, 5G SA/NSA/Sub6') ?? ''), + 'HSPA, LTE-A' + ); + assert.equal( + ( + rules.applyCategorySpecTemplate('smartwatches', { + specifications: { cellular: { sim_type: 'eSIM (5G)' } }, + }).specifications as { cellular: { sim_type?: string } } + ).cellular.sim_type, + 'eSIM' + ); + // Idempotent, never mutates the input, and leaves absent columns absent. assert.deepEqual(rules.applyCategorySpecTemplate('earbuds', buds), buds); assert.equal(phoneShaped.ram_gb, 1); diff --git a/packages/shared-data/src/rules/spec-template.ts b/packages/shared-data/src/rules/spec-template.ts index 9c1b3a76b..b55b15373 100644 --- a/packages/shared-data/src/rules/spec-template.ts +++ b/packages/shared-data/src/rules/spec-template.ts @@ -185,11 +185,57 @@ export interface CategoryScopedSpecs { const isRecord = (v: unknown): v is Record => Boolean(v) && typeof v === 'object' && !Array.isArray(v); +// Free-text cellular fields the enricher writes for a watch, such as +// "LTE / 5G" (DOS/Rate#1166). +const FREE_TEXT_CELLULAR_FIELDS = ['technology', 'data_speed', 'sim_type']; +const CELLULAR_SEPARATOR_RE = /\s*[/,|;]\s*/; +// A bracketed 5G note, as in "LTE (5G RedCap)". +const FIVE_G_BRACKET_RE = /\s*\([^()]*\b5G\b[^()]*\)/gi; +// "5G" with its qualifiers, also slash-joined ones: "5G SA/NSA/Sub6", +// "5G NR Sub-6", "5G-ready". +const FIVE_G_PHRASE_RE = + /\b5G\b(?:(?:\s*\/\s*|[\s-]*)(?:NR|SA|NSA|Sub-?6(?:\s*GHz)?|mmWave|RedCap|ready)\b)*/gi; + +/** The text without its 5G parts, or null when nothing else is left. */ +const withoutFiveG = (text: string): string | null => { + const stripped = text + .replace(FIVE_G_BRACKET_RE, '') + .replace(FIVE_G_PHRASE_RE, ''); + const kept = stripped + .split(CELLULAR_SEPARATOR_RE) + .map((part) => + part + .replace(/\s+/g, ' ') + .replace(/^[\s-]+|[\s(-]+$/g, '') + .trim() + ) + .filter(Boolean); + if (kept.length === 0) return null; + const separator = CELLULAR_SEPARATOR_RE.exec(text)?.[0] ?? ' / '; + return kept.join(separator); +}; + +const withoutFiveGText = ( + cellular: Record +): Record => { + const out: Record = { ...cellular }; + for (const field of FREE_TEXT_CELLULAR_FIELDS) { + const value = out[field]; + if (typeof value !== 'string' || !/\b5G\b/i.test(value)) continue; + const cleaned = withoutFiveG(value); + if (cleaned === null) delete out[field]; + else out[field] = cleaned; + } + return out; +}; + /** * A copy of a product with the spec groups, fields and flat columns its * category does not have removed. Fields outside the ten groups (such as - * `category_slug`) are kept. Values the category has are never changed, so - * applying it twice gives the same result. It only narrows: removed flat + * `category_slug`) are kept. Values the category has are never changed, + * except that display mode drops the 5G parts of a free-text network field + * ("LTE / 5G" becomes "LTE") for a category that hides 5G, so applying it + * twice gives the same result. It only narrows: removed flat * columns become `null` (`has_5g` becomes `false`) and groups lose keys, so * callers should type those fields as nullable, as the row types do. * @@ -222,6 +268,12 @@ export function applyCategorySpecTemplate( specs[group] = value; } } + // The page hides 5G for the category, so it also leaves it out of the + // free-text network fields. + const shownCellular = specs.cellular; + if (!forStorage && !template.has5g && isRecord(shownCellular)) { + specs.cellular = withoutFiveGText(shownCellular); + } out.specifications = specs; } From aa77634c4817e2de6ef08847829b7e996daf93d7 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:24:35 +0700 Subject: [PATCH 03/69] fix(importer): kept variant keeps its item and the left item is refreshed (#1190) * fix(importer): kept variant keeps its own item and the left item is refreshed With RATE_ITEM_MODEL on, a kept stored variant (manual, or title/sku/ single_size evidence on a sizeless crawl) now takes item_id from that variant instead of the Item resolved from the title, and the Item a listing leaves gets refresh_item_stats like the variant it leaves. The stored listing is read in the hinted path too, for the refresh only; a caller hint is never overridden. A matcher without a mode reads as off. Flag off: unchanged. Refs #1185 * fix(importer): clear a stale item_id when the kept variant has no item Review follow-up: write item_id null explicitly in that case, and test the stored-listing and kept-variant read failures. Refs #1185 Co-Authored-By: Claude Opus 5.5 --- .../src/shopee/__tests__/item-matcher.test.ts | 104 +++++++++++++++++- .../importer/src/shopee/shopee-to-supabase.ts | 73 +++++++++--- 2 files changed, 160 insertions(+), 17 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/item-matcher.test.ts b/apps/importer/src/shopee/__tests__/item-matcher.test.ts index e9804691c..e8b9d9dae 100644 --- a/apps/importer/src/shopee/__tests__/item-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/item-matcher.test.ts @@ -538,7 +538,7 @@ async function run() { mode: 'off' | 'item' | 'nullable_variant', title: string, item: ItemDb, - extra: { hint?: Row | null; stored?: Row | null } = {}, + extra: { hint?: Row | null; stored?: Row | null; failOn?: 'stored' | 'kept' } = {}, options: { masterIdHint?: string; itemIdHint?: string } = {} ) => { const db = createDb(call => { @@ -550,9 +550,15 @@ async function run() { return { data: { id: 'product-1' }, error: null }; } if (call.table === 'products' && call.op === 'select') { + if (extra.failOn === 'stored') return { data: null, error: { message: 'boom' } }; return { data: extra.stored ?? null, error: null }; } if (call.table === 'record_price_observation') return { data: 'inserted', error: null }; + if (call.table === 'master_products' && call.op === 'select' && call.cols === 'id, item_id') { + if (extra.failOn === 'kept') return { data: null, error: { message: 'boom' } }; + const variant = (item.variants ?? []).find(v => v.id === filter(call, 'id')); + return { data: variant ? { id: variant.id, item_id: variant.item_id } : null, error: null }; + } if ( call.table === 'master_products' && call.op === 'select' && @@ -674,6 +680,102 @@ async function run() { ['master-zenbook-unknown'] ); } + { + // DOS/Rate#1185: a kept variant brings its own Item, never the title's. + const vivobook = { id: 'item-vivobook', item_key: 'asus:vivobook-15' }; + const vivobook512 = { + id: 'master-vivobook-512', + item_id: 'item-vivobook', + variant_key: '512gb', + }; + const twoItems = { items: [ZENBOOK_ITEM, vivobook], variants: [ZENBOOK_1TB, vivobook512] }; + const itemStats = (calls: Call[]) => + calls + .filter(c => c.op === 'rpc' && c.table === 'refresh_item_stats') + .map(c => c.payload?.p_item_id); + for (const mode of ['item', 'nullable_variant'] as const) { + // A repurposed listing: the title now names the Zenbook, the hand-set variant is a Vivobook. + const repurposed = await ingest(mode, bareTitle, twoItems, { + stored: { cluster_id: vivobook512.id, variant_source: 'manual', item_id: vivobook.id }, + }); + assert.equal(repurposed.listing?.cluster_id, vivobook512.id, mode); + assert.equal(repurposed.listing?.item_id, vivobook.id, mode); + assert.equal(repurposed.result.itemId, vivobook.id, mode); + assert.deepEqual(itemStats(repurposed.calls), [vivobook.id], mode); + + // Title evidence kept on a crawl without a size: same rule. + const evidence = await ingest(mode, bareTitle, twoItems, { + stored: { cluster_id: vivobook512.id, variant_source: 'title', item_id: vivobook.id }, + }); + assert.equal(evidence.listing?.cluster_id, vivobook512.id, mode); + assert.equal(evidence.listing?.item_id, vivobook.id, mode); + } + // A kept variant with no Item clears a stale item_id rather than write the title's. + const orphan = await ingest('item', bareTitle, zenbook, { + stored: { cluster_id: 'master-zenbook-512', variant_source: 'manual', item_id: vivobook.id }, + }); + assert.ok(orphan.listing && 'item_id' in orphan.listing); + assert.equal(orphan.listing?.item_id, null); + assert.equal(orphan.result.itemId, null); + assert.deepEqual(itemStats(orphan.calls), [vivobook.id]); + + // A failed read of the stored listing or the kept variant fails the ingest. + for (const failOn of ['stored', 'kept'] as const) { + await assert.rejects( + ingest('item', bareTitle, zenbook, { + stored: { cluster_id: ZENBOOK_1TB.id, variant_source: 'manual' }, + failOn, + }), + /boom/, + failOn + ); + } + + // DOS/Rate#1185: the Item a listing leaves is refreshed too. + const movedItem = await ingest('item', 'Laptop ASUS Zenbook S 14 UX5406AA 1TB', twoItems, { + stored: { cluster_id: vivobook512.id, variant_source: 'title', item_id: vivobook.id }, + }); + assert.equal(movedItem.listing?.item_id, ZENBOOK_ITEM.id); + assert.deepEqual(itemStats(movedItem.calls), [ZENBOOK_ITEM.id, vivobook.id]); + // ...also from a variant of A to no variant under B (nullable_variant). + const toNoVariant = await ingest('nullable_variant', bareTitle, twoItems, { + stored: { cluster_id: vivobook512.id, variant_source: 'legacy', item_id: vivobook.id }, + }); + assert.equal(toNoVariant.listing?.cluster_id, null); + assert.deepEqual(itemStats(toNoVariant.calls), [ZENBOOK_ITEM.id, vivobook.id]); + // ...and when a caller hint moves it. + const hintMove = await ingest( + 'item', + 'Laptop ASUS Zenbook S 14 UX5406AA 1TB', + twoItems, + { + hint: { + id: ZENBOOK_1TB.id, + specifications: { display: { size: 'x' } }, + status: 'published', + item_id: ZENBOOK_ITEM.id, + variant_key: '1024gb', + }, + stored: { cluster_id: vivobook512.id, variant_source: 'manual', item_id: vivobook.id }, + }, + { masterIdHint: ZENBOOK_1TB.id } + ); + assert.equal(hintMove.listing?.cluster_id, ZENBOOK_1TB.id, 'a hint is not overridden'); + assert.deepEqual(itemStats(hintMove.calls), [ZENBOOK_ITEM.id, vivobook.id]); + // Same Item: one refresh, no duplicate. + const sameItem = await ingest('item', 'Laptop ASUS Zenbook S 14 UX5406AA 1TB', zenbook, { + stored: { cluster_id: ZENBOOK_1TB.id, variant_source: 'title', item_id: ZENBOOK_ITEM.id }, + }); + assert.deepEqual(itemStats(sameItem.calls), [ZENBOOK_ITEM.id]); + + // Flag off: the stored listing is never read and no Item is refreshed. + const off = await ingest('off', 'Laptop ASUS Zenbook S 14 UX5406AA 1TB', twoItems, { + stored: { cluster_id: vivobook512.id, variant_source: 'manual', item_id: vivobook.id }, + }); + assert.ok(!off.calls.some(c => c.table === 'products' && c.op === 'select')); + assert.deepEqual(itemStats(off.calls), []); + assert.ok(off.listing && !('item_id' in off.listing)); + } { // An archived masterIdHint maps to its Item; a live one is kept. const archived = await ingest( diff --git a/apps/importer/src/shopee/shopee-to-supabase.ts b/apps/importer/src/shopee/shopee-to-supabase.ts index 85ddf2b7b..44381c517 100644 --- a/apps/importer/src/shopee/shopee-to-supabase.ts +++ b/apps/importer/src/shopee/shopee-to-supabase.ts @@ -451,7 +451,8 @@ export class ShopeeIngestionPipeline { }); // Normalize first: a title with no keyable brand or model is rejected - // (UnkeyableProductTitleError) before anything is written. + // (UnkeyableProductTitleError) before anything is written. With a masterIdHint + // whose row is gone, the matcher throws it after the merchant upsert instead. const candidate = this.normalizer.normalize(product); // With a masterIdHint the title's key is not used; the matcher still rejects // the candidate if the hint does not resolve. @@ -512,7 +513,8 @@ export class ShopeeIngestionPipeline { // A caller that already verified the master (coverage discovery, or a refresh of // a listing that is already clustered) keeps the listing on it instead of // re-matching every crawl, which can split it onto a new draft master. - const itemModel = this.matcher.itemModel; + // A matcher with no mode (e.g. a test double) reads as the flag's default, off. + const itemModel: ItemModelMode = this.matcher.itemModel ?? 'off'; let hinted: ItemMatchResult | null = null; // undefined = leave the stored variant_source as it is. let hintedVariantSource: ItemMatchResult['variantSource'] | undefined; @@ -594,18 +596,22 @@ export class ShopeeIngestionPipeline { product.title, { itemIdHint: safeItemIdHint || null } )); - const { itemId, isNew } = resolved; + const { isNew } = resolved; + let itemId = resolved.itemId; let masterId = resolved.masterId; let variantSourceToWrite = hinted ? hintedVariantSource : resolved.variantSource; - // The variant the listing had before this crawl, so its stats follow a move. + // The variant and Item the listing had before this crawl, so their stats follow a move. let previousClusterId: string | null = null; - if (itemModel !== 'off' && !hinted) { + let previousItemId: string | null = null; + // True when itemId comes from a kept variant: then it is written even when null. + let itemIdFromKeptVariant = false; + if (itemModel !== 'off') { // A variant set by hand is evidence, never undone by a later page that just // does not state the size (design doc 4.4): only listing evidence // (variant_source set) may move it. const { data: stored, error: storedErr } = await this.supabase .from('products') - .select('cluster_id, variant_source') + .select('cluster_id, variant_source, item_id') .eq('platform', platform) .eq('platform_item_id', product.itemId) .maybeSingle(); @@ -614,17 +620,44 @@ export class ShopeeIngestionPipeline { } const storedClusterId = sanitizeIngestionEntityId(stored?.cluster_id, 128); previousClusterId = storedClusterId; + previousItemId = sanitizeIngestionEntityId(stored?.item_id, 128); // A hand-set variant always stays. A variant backed by listing evidence stays // when this crawl has none; a `legacy` or NULL-source link (e.g. a #1105 - // fuzzy link) does not. + // fuzzy link) does not. A caller hint is not overridden. const storedSource = stored?.variant_source; const keepStored = - storedSource === 'manual' || - (!resolved.variantSource && - (storedSource === 'title' || storedSource === 'sku' || storedSource === 'single_size')); + !hinted && + (storedSource === 'manual' || + (!resolved.variantSource && + (storedSource === 'title' || + storedSource === 'sku' || + storedSource === 'single_size'))); if (storedClusterId && keepStored) { masterId = storedClusterId; variantSourceToWrite = undefined; + // The kept variant brings its own Item: the title may now resolve to another + // one (a repurposed listing), and a variant never sits under a foreign Item. + const { data: keptVariant, error: keptErr } = await this.supabase + .from('master_products') + .select('id, item_id') + .eq('id', storedClusterId) + .maybeSingle(); + if (keptErr) { + throw new Error(`Failed to read the kept variant's Item: ${keptErr.message}`); + } + const keptItemId = sanitizeIngestionEntityId( + (keptVariant as { item_id?: unknown } | null)?.item_id, + 128 + ); + if (keptItemId !== itemId) { + platLogger.info( + 'ingest.kept_variant_item', + `Kept variant ${storedClusterId} belongs to Item ${keptItemId ?? '(none)'}, not the title's ${itemId ?? '(none)'}`, + { masterId: storedClusterId, itemId: keptItemId, titleItemId: itemId } + ); + } + itemId = keptItemId; + itemIdFromKeptVariant = true; } } platLogger.info( @@ -635,9 +668,10 @@ export class ShopeeIngestionPipeline { await report?.('master'); // Item columns exist only once the DOS-Me Item migration is applied (DOS/Rate#1150). - const itemColumns: { item_id?: string; variant_source?: string | null } = {}; + const itemColumns: { item_id?: string | null; variant_source?: string | null } = {}; if (itemModel !== 'off') { - if (itemId) itemColumns.item_id = itemId; + // A kept variant with no Item clears a stale item_id rather than leave it. + if (itemId || itemIdFromKeptVariant) itemColumns.item_id = itemId; if (variantSourceToWrite !== undefined) itemColumns.variant_source = variantSourceToWrite; } @@ -703,16 +737,23 @@ export class ShopeeIngestionPipeline { ); } } - // Then the Item roll-up (DOS-Me M2 `refresh_item_stats`, design doc 4.1). - if (typeof this.supabase.rpc === 'function' && itemModel !== 'off' && itemId) { + // Then the Item roll-up (DOS-Me M2 `refresh_item_stats`, design doc 4.1), for the + // Item the listing just left too. + const statsItemIds = + itemModel === 'off' + ? [] + : [itemId, previousItemId].filter( + (id, i, all): id is string => Boolean(id) && all.indexOf(id) === i + ); + for (const statsItemId of typeof this.supabase.rpc === 'function' ? statsItemIds : []) { const { error: itemStatsErr } = await this.supabase.rpc('refresh_item_stats', { - p_item_id: itemId, + p_item_id: statsItemId, }); if (itemStatsErr) { platLogger.warn( 'ingest.item_stats_rpc_warn', `Could not refresh Item stats via RPC: ${itemStatsErr.message}`, - { itemId } + { itemId: statsItemId } ); } } From ec2bce5ccf662d3f8b88afdbeeeede8ba912d98d Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:56:48 +0700 Subject: [PATCH 04/69] fix(web): api v1 filters and extension lookup follow category spec templates (#1191) * fix(web): api v1 filters and extension lookup follow category spec templates GET /api/v1/rate/products answers 400 for a spec filter the category's template does not have (has5g on smartwatches; ramGb, storageGb, compactScreen, minRefreshHz, hasNfc on earbuds), so a filter never returns a product whose scoped body contradicts it. The extension lookup returns the category's spec view and no storage tier for earbuds. Behaviour tests run the list, detail and compare routes and the lookup against a stubbed network; the regex-only API v1 checks are gone. Refs #1184 * fix(web): reject cross-category spec filters on category=all `all` fell back to the phone template, so `category=all&has5g=1` still returned watches whose body says `has_5g: false`. On `all` a spec filter must now apply to every category. The extension lookup no longer flags an earbud listing as an ambiguous storage variant. Tests check the filters reach search_master_products and that phone bodies stay unchanged. Refs #1184 * fix(web): keep category=all spec filters to categories that have them Per PO acceptance, `category=all` no longer answers 400 for a spec filter some category lacks. It keeps only products whose own category has every requested filter (`has5g=1` returns 5G phones, tablets and laptops, never watches or earbuds), reading the catalogue in 500-row pages and paging after the cut so offset and limit stay exact. Refs #1184 * fix(web): bound the category=all spec filter scan to 40 pages Review of the previous commit: a deep offset could read up to 100k rows page by page, and the whole table per page on the RPC fallback. 20k rows is well above the catalogue. Adds an `all&nfc=1` case. Refs #1184 Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 2 +- .../api/v1/rate/__tests__/spec-scope.test.ts | 332 ++++++++++++++++++ .../web/src/app/api/v1/rate/products/route.ts | 59 +++- .../src/lib/__tests__/spec-template.test.ts | 25 +- apps/web/src/server/rate/api-handler.ts | 67 +++- apps/web/src/server/rate/extension.ts | 22 +- apps/web/src/server/rate/openapi.ts | 6 +- 7 files changed, 484 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index b0ff8e7c4..cdcb94030 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts new file mode 100644 index 000000000..cc51cddce --- /dev/null +++ b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts @@ -0,0 +1,332 @@ +/** + * Behaviour tests for the category spec view on REST API v1 (DOS/Rate#1184): + * `GET /api/v1/rate/products` filters agree with the scoped body, and the + * extension lookup returns no earbud screen, RAM, storage or camera and no + * watch 5G. + * + * The route handlers and the lookup run for real; only the network is + * stubbed, with an earbud and a cellular watch stored phone-shaped, the way + * the enricher filled them. + */ +import assert from 'node:assert/strict'; + +process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://stub.supabase.test'; +process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-test-key'; + +const phoneShaped = (id: string, slug: string, categorySlug: string) => ({ + id, + slug, + name: slug, + brand: 'Apple', + model: slug, + status: 'published', + min_price: 5_000_000, + max_price: 6_000_000, + merchant_count: 0, + ram_gb: 1, + storage_gb: 64, + screen_size_inch: 1, + has_5g: true, + specifications: { + category_slug: categorySlug, + display: { size_inch: 1, type: 'OLED', refresh_rate_hz: 60 }, + hardware: { chipset: 'H2', cpu: 'c', ram_gb: 1, storage_gb: 64 }, + camera: { main_camera: '2 mic', front_camera: 'f' }, + battery: { capacity_mah: 500 }, + cellular: { + has_5g: true, + technology: 'LTE / 5G', + bands_5g: 'n78', + sim_type: 'eSIM', + }, + connectivity: { bluetooth: '5.3', nfc: true }, + multimedia: { audio_features: 'ANC' }, + }, + key_specs: { + screen: '1 inch', + chip: 'H2', + camera: 'mic', + ram: '1GB', + storage: '64GB', + battery: '500 mAh', + }, +}); + +const EARBUD = phoneShaped( + '11111111-1111-4111-8111-111111111111', + 'apple-airpods-pro-2', + 'earbuds' +); +const WATCH = phoneShaped( + '22222222-2222-4222-8222-222222222222', + 'apple-watch-series-10-lte', + 'smartwatches' +); + +let catalog: unknown[] = []; +// What a `master_products` read answers (detail and compare routes). +let masterRows: unknown[] = []; +let calls: string[] = []; +// The last `search_master_products` arguments, to see the filters reach SQL. +let searchArgs: Record = {}; + +const jsonResponse = (status: number, json: unknown) => + new Response(JSON.stringify(json), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url; + calls.push(url); + const { pathname } = new URL(url); + if (pathname === '/rest/v1/categories') { + return jsonResponse(200, [{ id: 'cat-1' }]); + } + if (pathname === '/rest/v1/rpc/search_master_products') { + searchArgs = typeof init?.body === 'string' ? JSON.parse(init.body) : {}; + const from = Number(searchArgs.p_offset ?? 0); + return jsonResponse( + 200, + catalog.slice(from, from + Number(searchArgs.p_limit ?? 200)) + ); + } + if (pathname === '/rest/v1/master_products') { + return jsonResponse(200, masterRows); + } + return jsonResponse(200, []); +}) as typeof fetch; + +async function listProducts(query: string) { + const { GET } = await import('../products/route'); + const { NextRequest } = await import('next/server'); + calls = []; + const res = await GET( + new NextRequest(`http://localhost/api/v1/rate/products?${query}`) + ); + return { status: res.status, body: await res.json() }; +} + +async function testProductFilters() { + // A filter the category has no field for answers 400 before any query. + for (const query of [ + 'category=smartwatches&has5g=true', + 'category=smartwatches&5g=1', + 'category=earbuds&ram=1', + 'category=earbuds&rom=64', + 'category=earbuds&hz=60', + 'category=earbuds&compactScreen=true', + 'category=earbuds&nfc=1', + 'category=earbuds&highRefresh=1', + ]) { + const { status, body } = await listProducts(query); + assert.equal(status, 400, query); + assert.equal(body.ok, false, query); + assert.equal(calls.length, 0, `${query} must not query the catalog`); + } + const { body: mixed } = await listProducts( + 'category=earbuds&ramGb=1&storageGb=64&has5g=true' + ); + assert.match(mixed.error, /ramGb, storageGb, has5g/); + + // Filters the category has still run, and the body carries the scoped view. + catalog = [WATCH]; + let { status, body } = await listProducts( + 'category=smartwatches&ram=1&hz=60&compactScreen=2' + ); + assert.equal(status, 200); + assert.deepEqual(searchArgs.p_ram_gb, [1]); + assert.equal(searchArgs.p_min_refresh_rate_hz, 60); + assert.equal(searchArgs.p_max_screen_size_inch, 2); + assert.equal(body.data.length, 1); + assert.equal(body.data[0].ram_gb, 1); + assert.equal(body.data[0].has_5g, false); + assert.equal(body.data[0].specifications.cellular.technology, 'LTE'); + + catalog = [EARBUD]; + ({ status, body } = await listProducts('category=earbuds&battery=400')); + assert.equal(status, 200); + assert.equal(body.data.length, 1); + const bud = body.data[0]; + assert.equal(bud.ram_gb, null); + assert.equal(bud.storage_gb, null); + assert.equal(bud.screen_size_inch, null); + assert.equal(bud.has_5g, false); + assert.equal(bud.specifications.display, undefined); + assert.equal(bud.specifications.camera, undefined); + + // Filters every category has still run on `all`, over every category. + catalog = [EARBUD]; + ({ status, body } = await listProducts( + 'category=all&fastCharge=1&battery=400' + )); + assert.equal(status, 200); + assert.equal(body.data.length, 1); + + // On `all`, a filter some category lacks keeps only products whose own + // category has it, paged after that cut: no 400, no watch or earbud. + const PHONE = phoneShaped( + '33333333-3333-4333-8333-333333333333', + 'apple-iphone-16-128gb', + 'smartphones' + ); + const PHONE_2 = phoneShaped( + '44444444-4444-4444-8444-444444444444', + 'apple-iphone-16-256gb', + 'smartphones' + ); + catalog = [WATCH, PHONE, EARBUD, PHONE_2]; + ({ status, body } = await listProducts('category=all&has5g=1')); + assert.equal(status, 200); + assert.equal(searchArgs.p_has_5g, true); + assert.deepEqual( + body.data.map((p: { slug: string }) => p.slug), + [PHONE.slug, PHONE_2.slug] + ); + assert.ok(body.data.every((p: { has_5g: boolean }) => p.has_5g === true)); + ({ body } = await listProducts('category=all&has5g=1&offset=1&limit=1')); + assert.deepEqual( + body.data.map((p: { slug: string }) => p.slug), + [PHONE_2.slug] + ); + // The cut reads past the first 500-row page. + catalog = [...Array.from({ length: 500 }, () => WATCH), PHONE]; + ({ body } = await listProducts('category=all&has5g=1')); + assert.deepEqual( + body.data.map((p: { slug: string }) => p.slug), + [PHONE.slug] + ); + catalog = [WATCH, PHONE, EARBUD, PHONE_2]; + // Watches have NFC, earbuds do not. + ({ body } = await listProducts('category=all&nfc=1')); + assert.deepEqual( + body.data.map((p: { slug: string }) => p.slug), + [WATCH.slug, PHONE.slug, PHONE_2.slug] + ); + // A watch has RAM, earbuds do not. + ({ body } = await listProducts('category=all&ram=1')); + assert.deepEqual( + body.data.map((p: { slug: string }) => p.slug), + [WATCH.slug, PHONE.slug, PHONE_2.slug] + ); + assert.ok(body.data.every((p: { ram_gb: number }) => p.ram_gb === 1)); + + // Phones keep every filter and the stored body. + catalog = [PHONE]; + ({ status, body } = await listProducts( + 'category=smartphones&has5g=true&ram=1&rom=64&hz=60&compact=1&nfc=1' + )); + assert.equal(status, 200); + assert.equal(searchArgs.p_has_5g, true); + assert.deepEqual(searchArgs.p_ram_gb, [1]); + assert.deepEqual(searchArgs.p_storage_gb, [64]); + assert.equal(searchArgs.p_min_refresh_rate_hz, 60); + assert.equal(searchArgs.p_max_screen_size_inch, 6.2); + assert.equal(searchArgs.p_has_nfc, true); + assert.equal(body.data.length, 1); + assert.equal(body.data[0].has_5g, true); + assert.equal(body.data[0].ram_gb, 1); + assert.equal(body.data[0].screen_size_inch, 1); + assert.deepEqual(body.data[0].specifications, PHONE.specifications); + console.warn(' ✅ API v1 product filters agree with the scoped body'); +} + +// The detail and compare routes carry the same scoped view. +async function testDetailAndCompare() { + const { NextRequest } = await import('next/server'); + const detail = await import('../products/[slug]/route'); + masterRows = [EARBUD]; + const res = await detail.GET( + new NextRequest(`http://localhost/api/v1/rate/products/${EARBUD.slug}`), + { params: Promise.resolve({ slug: EARBUD.slug }) } as never + ); + assert.equal(res.status, 200); + const body = await res.json(); + const product = body.data.product; + assert.equal(product.slug, EARBUD.slug); + assert.equal(product.ram_gb, null); + assert.equal(product.screen_size_inch, null); + assert.equal(product.specifications.camera, undefined); + + const compare = await import('../compare/route'); + masterRows = [WATCH]; + const cmp = await compare.GET( + new NextRequest(`http://localhost/api/v1/rate/compare?slugs=${WATCH.slug}`) + ); + assert.equal(cmp.status, 200); + const cmpBody = await cmp.json(); + assert.equal(cmpBody.data.length, 1); + assert.equal(cmpBody.data[0].has_5g, false); + assert.equal(cmpBody.data[0].specifications.cellular.technology, 'LTE'); + masterRows = []; + console.warn(' ✅ API v1 detail and compare return the scoped view'); +} + +async function testExtensionLookup() { + const { lookupExtensionProduct } = await import('@/server/rate/extension'); + + catalog = [EARBUD]; + const bud = await lookupExtensionProduct({ + title: 'Apple AirPods Pro 2 64GB', + mode: 'full', + }); + assert.equal(bud.categorySlug, 'earbuds'); + const budProduct = bud.masterProduct!; + assert.ok(budProduct); + assert.equal(budProduct.ram_gb, null); + assert.equal(budProduct.storage_gb, null); + assert.equal(budProduct.screen_size_inch, null); + assert.equal(budProduct.has_5g, false); + const budSpecs = budProduct.specifications as Record; + assert.equal(budSpecs.display, undefined); + assert.equal(budSpecs.camera, undefined); + assert.equal(budSpecs.cellular, undefined); + assert.deepEqual(Object.keys(budProduct.key_specs ?? {}).sort(), [ + 'battery', + 'chip', + ]); + assert.equal(bud.detectedStorageGb, null); + assert.deepEqual(bud.variantCandidates, []); + + // Two sizes in an earbud title are not storage variants. + const twoSizes = await lookupExtensionProduct({ + title: 'Apple AirPods Pro 2 64GB 128GB', + mode: 'pill', + }); + assert.notEqual(twoSizes.matchStatus, 'ambiguous_variant'); + + catalog = [WATCH]; + const watch = await lookupExtensionProduct({ + title: 'Apple Watch Series 10 LTE', + mode: 'pill', + }); + assert.equal(watch.categorySlug, 'smartwatches'); + const watchProduct = watch.masterProduct!; + assert.equal(watchProduct.has_5g, false); + const cellular = (watchProduct.specifications as Record) + .cellular as Record; + assert.equal(cellular.has_5g, undefined); + assert.equal(cellular.bands_5g, undefined); + assert.equal(cellular.technology, 'LTE'); + // A watch keeps its RAM and storage. + assert.equal(watchProduct.ram_gb, 1); + assert.equal(watchProduct.storage_gb, 64); + console.warn(' ✅ Extension lookup returns the category spec view'); +} + +async function main() { + console.warn('🧪 API v1 category spec view suite (DOS/Rate#1184)'); + await testProductFilters(); + await testDetailAndCompare(); + await testExtensionLookup(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/web/src/app/api/v1/rate/products/route.ts b/apps/web/src/app/api/v1/rate/products/route.ts index d88feabe2..4cac0a62e 100644 --- a/apps/web/src/app/api/v1/rate/products/route.ts +++ b/apps/web/src/app/api/v1/rate/products/route.ts @@ -16,8 +16,11 @@ import { parseBoundedNumberParam, parseBoundedPositiveIntArrayParam, parseBoundedStringParam, + hasCategoryScopedSpecFilter, parseProductsFilterBounds, + specFiltersOutsideCategory, } from '@/server/rate/api-handler'; +import type { MasterProduct } from '@/lib/rate-api'; export async function OPTIONS() { return new NextResponse(null, { @@ -35,6 +38,38 @@ export function parseBooleanFlag(raw: string | null): boolean | undefined { return cleaned === '1' || cleaned === 'true' ? true : undefined; } +const SCOPED_ALL_PAGE_SIZE = 500; +// 20k rows, well above the catalogue; bounds the cost of a deep offset. +const SCOPED_ALL_MAX_PAGES = 40; + +/** + * `category=all` with a spec filter some category lacks: only products whose + * own category has every requested filter, paged after that cut so pages + * stay exact (DOS/Rate#1184). Reads the catalogue in pages of 500. + */ +async function getAllCategoryProductsWithSpecFilters( + filterParams: MasterProductFilterParams +): Promise { + const offset = filterParams.offset ?? 0; + const wanted = offset + (filterParams.limit ?? 24); + const kept: MasterProduct[] = []; + for (let page = 0; page < SCOPED_ALL_MAX_PAGES; page++) { + const rows = await getMasterProducts({ + ...filterParams, + offset: page * SCOPED_ALL_PAGE_SIZE, + limit: SCOPED_ALL_PAGE_SIZE, + }); + for (const p of rows) { + const slug = getMasterProductCategorySlug(p); + if (specFiltersOutsideCategory(slug, filterParams).length === 0) { + kept.push(p); + } + } + if (kept.length >= wanted || rows.length < SCOPED_ALL_PAGE_SIZE) break; + } + return kept.slice(offset, wanted); +} + const VALID_PRODUCT_SORTS = new Set< NonNullable >([ @@ -51,7 +86,12 @@ const VALID_PRODUCT_SORTS = new Set< * Structured JSON Backend API for Web Client, Browser Extension MV3, and Partners. * Supports 10-Field-Groups multi-criteria filtering, sorting, and optional directory facets. * Each product carries its category's spec view, as rate.vn shows it - * (DOS/Rate#1166); filters still match the stored columns. + * (DOS/Rate#1166). A spec filter the category's template does not have + * (`has5g` on smartwatches, `ramGb` or `compactScreen` on earbuds) answers + * 400, so a filter never returns a product whose body contradicts it + * (DOS/Rate#1184). On `category=all` such a filter keeps only products whose + * own category has it: `has5g=1` returns 5G phones, tablets and laptops, + * never watches or earbuds. */ export async function GET(request: NextRequest) { try { @@ -130,8 +170,23 @@ export async function GET(request: NextRequest) { offset, }; + const unsupportedFilters = specFiltersOutsideCategory( + categorySlug, + filterParams + ); + if (unsupportedFilters.length > 0) { + return jsonApiError( + 400, + `Filters not available for category ${categorySlug}: ${unsupportedFilters.join(', ')}`, + undefined, + PUBLIC_API_CORS_HEADERS + ); + } + const [products, facets] = await Promise.all([ - getMasterProducts(filterParams), + categorySlug === 'all' && hasCategoryScopedSpecFilter(filterParams) + ? getAllCategoryProductsWithSpecFilters(filterParams) + : getMasterProducts(filterParams), includeFacets ? getCachedCategoryDirectoryFacets(categorySlug) : Promise.resolve(null), diff --git a/apps/web/src/lib/__tests__/spec-template.test.ts b/apps/web/src/lib/__tests__/spec-template.test.ts index 6ed6529a1..94aa3dba2 100644 --- a/apps/web/src/lib/__tests__/spec-template.test.ts +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -199,8 +199,8 @@ async function testSurfaces() { 'H2 • 1GB • 64GB • 500 mAh' ); - // The leaderboard rows, the export toolbar on the product page and REST - // API v1 read the scoped product. + // The leaderboard rows and the export toolbar on the product page read the + // scoped product. const leaderboard = readSource( '../../components/leaderboard/LeaderboardExplorer.tsx' ); @@ -221,24 +221,9 @@ async function testSurfaces() { const detail = readSource('../../app/[locale]/smartphones/[slug]/page.tsx'); assert.match(detail, /categorySlug=\{categorySlug\}/); assert.match(detail, /buildKeySpecLine\(product, categorySlug\)/); - const apiList = readSource('../../app/api/v1/rate/products/route.ts'); - assert.match( - apiList, - /\.\.\.applyCategorySpecTemplate\(getMasterProductCategorySlug\(p\), p\)/ - ); - const apiDetail = readSource( - '../../app/api/v1/rate/products/[slug]/route.ts' - ); - assert.match( - apiDetail, - /const product = applyCategorySpecTemplate\(\s*getMasterProductCategorySlug\(storedProduct\),\s*storedProduct\s*\)/ - ); - const apiCompare = readSource('../../app/api/v1/rate/compare/route.ts'); - assert.match( - apiCompare, - /\.\.\.applyCategorySpecTemplate\(lockedCategory, p\)/ - ); - console.warn(' ✅ Compare, leaderboard, export and API v1 scoped'); + // REST API v1 and the extension lookup have behaviour tests in + // app/api/v1/rate/__tests__/spec-scope.test.ts (DOS/Rate#1184). + console.warn(' ✅ Compare, leaderboard and export scoped'); } async function main() { diff --git a/apps/web/src/server/rate/api-handler.ts b/apps/web/src/server/rate/api-handler.ts index f28a6e7a3..804a291ad 100644 --- a/apps/web/src/server/rate/api-handler.ts +++ b/apps/web/src/server/rate/api-handler.ts @@ -1,9 +1,15 @@ import 'server-only'; import { NextResponse } from 'next/server'; -import { plausibleMinPrice, plausibleOffers } from '@repo/shared-data/rules'; +import { + getCategorySpecTemplate, + plausibleMinPrice, + plausibleOffers, + specFieldApplies, +} from '@repo/shared-data/rules'; import { MAX_VALID_VND_PRICE, normalizeOfferOriginalPrice, + type MasterProductFilterParams, } from '@/lib/rate-api'; import { isOfferUnavailable, @@ -65,6 +71,65 @@ export function jsonApiError( ); } +/** + * The spec filters of `GET /api/v1/rate/products` that the category's spec + * template does not have, by their documented query names (DOS/Rate#1184). + * The body carries the category's spec view, so such a filter would match + * stored values the body no longer shows (`has5g` on a watch, `ramGb` on + * earbuds); the route answers 400 for them instead, and on `all` keeps + * only products whose category has every requested filter. + */ +export function specFiltersOutsideCategory( + categorySlug: string, + filters: MasterProductFilterParams +): string[] { + const template = getCategorySpecTemplate(categorySlug); + const used: [boolean, boolean, string][] = [ + [Boolean(filters.ramGb?.length), template.ram, 'ramGb'], + [Boolean(filters.storageGb?.length), template.storage, 'storageGb'], + [filters.has5g === true, template.has5g, 'has5g'], + [ + typeof filters.maxScreenSizeInch === 'number', + template.screen, + 'compactScreen', + ], + [ + typeof filters.minRefreshRateHz === 'number', + specFieldApplies(template, 'display', 'refresh_rate_hz'), + 'minRefreshHz', + ], + [ + filters.hasNfc === true, + specFieldApplies(template, 'connectivity', 'nfc'), + 'hasNfc', + ], + [ + typeof filters.minBatteryMah === 'number', + specFieldApplies(template, 'battery', 'capacity_mah'), + 'minBatteryMah', + ], + [ + typeof filters.minFastChargingW === 'number', + specFieldApplies(template, 'battery', 'fast_charging_w'), + 'minFastChargeW', + ], + ]; + return used + .filter(([requested, applies]) => requested && !applies) + .map(([, , name]) => name); +} + +/** Whether some category lacks one of the requested spec filters. */ +export function hasCategoryScopedSpecFilter( + filters: MasterProductFilterParams +): boolean { + for (const slug of VALID_PRODUCT_CATEGORY_SLUGS) { + if (slug === 'all') continue; + if (specFiltersOutsideCategory(slug, filters).length > 0) return true; + } + return false; +} + function parseFiniteDecimalInput(raw: unknown): number { if (typeof raw === 'number') { return Number.isFinite(raw) ? raw : Number.NaN; diff --git a/apps/web/src/server/rate/extension.ts b/apps/web/src/server/rate/extension.ts index 0d9e0e1ec..a4e6207cc 100644 --- a/apps/web/src/server/rate/extension.ts +++ b/apps/web/src/server/rate/extension.ts @@ -1,5 +1,7 @@ import 'server-only'; import { + applyCategorySpecTemplate, + getCategorySpecTemplate, isImplausiblyLowPrice, minimumListingPriceVnd, plausibleOffers, @@ -1205,9 +1207,19 @@ export async function lookupExtensionProduct( }; } + const categoryPath = getMasterProductCategorySlug(matchedMaster); + // Storage tiers only for a category that has storage: an earbud's stored + // "64GB" is enricher noise (DOS/Rate#1184). + const hasStorage = getCategorySpecTemplate(categoryPath).storage; + // Several sizes in an earbud title are not storage variants either. + if (!hasStorage && matchStatus === 'ambiguous_variant') { + matchStatus = 'canonical_model'; + } + // Build variant candidates across same model family const sameFamilyVariants = catalogCandidates.filter( (c) => + hasStorage && c.brand.toLowerCase() === matchedMaster!.brand.toLowerCase() && c.model.toLowerCase() === matchedMaster!.model.toLowerCase() && typeof c.storage_gb === 'number' @@ -1247,8 +1259,6 @@ export async function lookupExtensionProduct( matchStatus = 'exact_listing'; } - const categoryPath = getMasterProductCategorySlug(matchedMaster); - const [priceHistory, reviews, sameCategoryPeers] = await Promise.all([ getMasterProductPriceHistory(matchedMaster.id, offers), mode === 'full' @@ -1357,12 +1367,16 @@ export async function lookupExtensionProduct( platform, capability, canonicalUrl, - detectedStorageGb: storageGb ?? matchedMaster.storage_gb ?? null, + detectedStorageGb: hasStorage + ? (storageGb ?? matchedMaster.storage_gb ?? null) + : null, isAccessoryOrPart: false, accessoryReason: null, variantCandidates, priceAssessment, - masterProduct: matchedMaster, + // The category's spec view, as rate.vn shows it: no earbud screen, RAM, + // storage or camera, no watch 5G (DOS/Rate#1184). + masterProduct: applyCategorySpecTemplate(categoryPath, matchedMaster), matchedOffer, topCheaperOffers, allOffers: mode === 'full' ? offers : topCheaperOffers, diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index 2b874e3d9..c97ae5179 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -207,7 +207,7 @@ export function buildRateOpenApiSpec( summary: 'List standardized master products with 10-group spec filters', description: - 'Queries `rate.master_products` with multi-dimensional filtering across category, brand, price range, RAM/storage options, refresh rate, 5G, IP rating, charging wattage, and JReviews weighted score.', + 'Queries `rate.master_products` with multi-dimensional filtering across category, brand, price range, RAM/storage options, refresh rate, 5G, IP rating, charging wattage, and JReviews weighted score. Each product carries its category spec view (earbuds have no screen, RAM or storage; watches show no 5G). A spec filter the category does not have, such as `has5g` on `smartwatches` or `ramGb` and `compactScreen` on `earbuds`, returns 400. On `all` such a filter keeps only products whose own category has it (`has5g=1` returns 5G phones, tablets and laptops, never watches or earbuds).', tags: ['Products & Specs'], parameters: [ { @@ -364,6 +364,10 @@ export function buildRateOpenApiSpec( }, }, }, + '400': { + description: + 'Invalid category or price range, or a spec filter the category does not have.', + }, }, }, }, From 88f043d1487906e17112df00fcf685eaadd41958 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:25:36 +0700 Subject: [PATCH 05/69] fix(web): hide empty spec rows and groups on the product page (#1192) * fix(web): hide empty spec rows and groups on the product page The product page printed spec rows with no value as "-": a GPS watch's data speed, 4G bands and VoLTE, an earbud's FCC approval, and an earbud hardware card with a single row. The rows now come from buildProductSpecGroups, which leaves out rows with no value and groups with no row; group numbers and the badge count follow the shown groups. Compare leaves out a row that every column has empty, so "-" stays only beside a value. Refs #1186 * fix(web): compare hides rows by the localized cell text Review of the previous commit: compare checked the raw value, so a row whose every cell localizes "N/A" to "-" stayed. The JSON export now skips all-empty rows like the CSV export. Refs #1186 * test(web): e2e expects hidden empty spec groups The seeded earbud has values only for audio, battery, Bluetooth and the sale status, and the seeded phone read as a watch has no network value once 5G is dropped, so #1186 now hides their empty groups. The 4G-only bands label moves to product-spec-rows.test.ts. Refs #1186 * fix(web): no spec toggle without hidden groups; e2e measures the hidden part Hiding empty groups (#1186) left the phone toggle on products with three groups or fewer, where it opens nothing; PhoneCollapsible now takes `collapsible` and the spec section passes it only past three groups. The collapse e2e asserted collapsed < 60% of expanded, which held for ten full groups but not for the sparse seeded phone, now five short groups. It now checks that collapsing removes the height of the groups it hides. Refs #1186 Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/category-spec-template.spec.ts | 15 +- apps/web/e2e/product-specs-collapse.spec.ts | 13 +- apps/web/package.json | 2 +- .../app/[locale]/smartphones/[slug]/page.tsx | 621 ++---------------- .../components/compare/CompareWorkspace.tsx | 60 +- .../components/product/PhoneCollapsible.tsx | 12 + .../lib/__tests__/product-spec-rows.test.ts | 125 ++++ .../src/lib/__tests__/spec-template.test.ts | 7 +- apps/web/src/lib/product-spec-rows.ts | 275 ++++++++ 9 files changed, 515 insertions(+), 615 deletions(-) create mode 100644 apps/web/src/lib/__tests__/product-spec-rows.test.ts create mode 100644 apps/web/src/lib/product-spec-rows.ts diff --git a/apps/web/e2e/category-spec-template.spec.ts b/apps/web/e2e/category-spec-template.spec.ts index bf811d4ea..6839b7971 100644 --- a/apps/web/e2e/category-spec-template.spec.ts +++ b/apps/web/e2e/category-spec-template.spec.ts @@ -11,7 +11,10 @@ test('earbud page shows audio specs and no phone fields', async ({ page }) => { const specs = page.locator('#specs-section'); const headings = specs.locator('h3'); await expect(headings.first()).toHaveText(/^1\. Âm thanh/); - await expect(headings).toHaveCount(7); + // Groups with no value are hidden (DOS/Rate#1186): the seeded bud has + // audio, battery, Bluetooth and the default sale status, nothing else. + await expect(headings).toHaveCount(4); + await expect(headings.nth(1)).toHaveText(/^2\. Pin/); await expect(specs).toContainText('E2E 10.5mm driver'); await expect(specs).toContainText('E2E ANC'); for (const text of [ @@ -24,6 +27,8 @@ test('earbud page shows audio specs and no phone fields', async ({ page }) => { 'E2E Mic Array', 'E2E Audio Engine', '5G', + 'Phần cứng', + 'Pháp lý', ]) { await expect(specs).not.toContainText(text); } @@ -53,9 +58,11 @@ test('a watch page never shows 5G', async ({ page }) => { await expect(meta).toContainText('Apple'); await expect(meta).not.toContainText('5G'); const specs = page.locator('#specs-section'); - await expect(specs).toContainText('Mạng Di động (Cellular)'); - // The bands row stays, as 4G only. - await expect(specs).toContainText('Băng tần 4G'); + await expect(specs).toContainText('Màn hình (Display)'); + // The seeded phone has no network value left once 5G is dropped, so the + // cellular group is hidden (DOS/Rate#1186); the 4G-only bands label is + // covered by product-spec-rows.test.ts. + await expect(specs).not.toContainText('Mạng Di động'); await expect(specs).not.toContainText('5G'); await expect(specs).not.toContainText('Camera'); }); diff --git a/apps/web/e2e/product-specs-collapse.spec.ts b/apps/web/e2e/product-specs-collapse.spec.ts index 8129e8405..4c664f2d1 100644 --- a/apps/web/e2e/product-specs-collapse.spec.ts +++ b/apps/web/e2e/product-specs-collapse.spec.ts @@ -27,9 +27,18 @@ test('phone shows three spec groups and expands to all', async ({ page }) => { }); }).toPass(); await expect(groups.nth(total - 1)).toBeVisible(); - // Collapsed, the section is far shorter than the full ten-group stack. + // Collapsed, the section is shorter by the groups it hid. Empty groups are + // left out (DOS/Rate#1186), so the seeded phone has five short groups, not + // ten full ones, and a fixed ratio no longer says anything. const expandedHeight = (await section.boundingBox())?.height ?? 0; - expect(collapsedHeight).toBeLessThan(expandedHeight * 0.6); + let hiddenHeight = 0; + for (let i = 3; i < total; i++) { + hiddenHeight += (await groups.nth(i).boundingBox())?.height ?? 0; + } + expect(hiddenHeight).toBeGreaterThan(0); + expect(expandedHeight - collapsedHeight).toBeGreaterThanOrEqual( + hiddenHeight * 0.9 + ); }); test('desktop shows every spec group and no toggle', async ({ page }) => { diff --git a/apps/web/package.json b/apps/web/package.json index cdcb94030..68ae5ccc9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index 55d27fcc2..e9b67c671 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next'; -import { Fragment, type ReactNode } from 'react'; +import { Fragment } from 'react'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '@/lib/navigation'; import { notFound } from 'next/navigation'; @@ -57,12 +57,12 @@ import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; import { normalizeRateLocale } from '@/lib/rate-i18n'; import { getSpecUiDictionary, - localizeSpecValue, specGroupHeading, specSectionBadge, } from '@/lib/spec-localizer'; import { contentLang } from '@/lib/content-lang'; import ContentText from '@/components/elementary/ContentText'; +import { buildProductSpecGroups } from '@/lib/product-spec-rows'; import { computeProductStandingInCategory, scoreMasterProductForLeaderboard, @@ -76,8 +76,6 @@ import ClientMessages from '@/components/providers/ClientMessages'; import { applyCategorySpecTemplate, getCategorySpecTemplate, - specFieldApplies, - type SpecGroupKey, } from '@repo/shared-data/rules'; import { isOfferUnavailable, @@ -963,583 +961,16 @@ export async function ProductDetailView({ {/* SECTION #2: Spec groups, per category template (DOS/Rate#1142) */} {(() => { const specUi = getSpecUiDictionary(locale); - const loc = (v: string | number | boolean | null | undefined) => ( - - ); - const groupHeading = (group: SpecGroupKey) => - specGroupHeading( - specUi, - group, - specTemplate.groups.indexOf(group) + 1, - specTemplate.has5g - ); - const shows = (group: SpecGroupKey, field: string) => - specFieldApplies(specTemplate, group, field); - const groupNodes: Record = { - display: ( -
-
-

- {groupHeading('display')} -

-
-
- {specUi.fields.screenSize} -
-
- {specs?.display?.size_inch - ? `${specs.display.size_inch} inch` - : product.screen_size_inch - ? `${product.screen_size_inch} inch` - : '-'} -
-
- {specUi.fields.panelType} -
-
- {loc(specs?.display?.type || keySpecs?.screen)} -
-
- {specUi.fields.resolution} -
-
- {loc(specs?.display?.resolution)} -
-
- {specUi.fields.refreshRate} -
-
- {specs?.display?.refresh_rate_hz - ? `${specs.display.refresh_rate_hz} Hz` - : '-'} -
-
{specUi.fields.ppi}
-
- {specs?.display?.ppi ? `${specs.display.ppi} ppi` : '-'} -
-
- {specUi.fields.protection} -
-
- {loc(specs?.display?.protection)} -
-
- {specUi.fields.displayFeatures} -
-
- {loc(specs?.display?.features)} -
-
-
-
- ), - hardware: ( -
-
-

- {groupHeading('hardware')} -

-
-
- {specUi.fields.chipset} -
-
- {loc(specs?.hardware?.chipset || keySpecs?.chip)} -
- {shows('hardware', 'cpu') && ( - <> -
- {specUi.fields.cpu} -
-
- {loc(specs?.hardware?.cpu)} -
- - )} - {shows('hardware', 'gpu') && ( - <> -
- {specUi.fields.gpu} -
-
- {loc(specs?.hardware?.gpu)} -
- - )} - {shows('hardware', 'ram_gb') && ( - <> -
- {specUi.fields.ram} -
-
- {specs?.hardware?.ram_gb - ? `${specs.hardware.ram_gb} GB` - : product.ram_gb - ? `${product.ram_gb} GB` - : '-'} -
- - )} - {shows('hardware', 'storage_gb') && ( - <> -
- {specUi.fields.storage} -
-
- {specs?.hardware?.storage_gb - ? `${specs.hardware.storage_gb} GB` - : product.storage_gb - ? `${product.storage_gb} GB` - : '-'} -
- - )} - {shows('hardware', 'storage_expansion') && ( - <> -
- {specUi.fields.storageExpansion} -
-
- {loc(specs?.hardware?.storage_expansion)} -
- - )} - {shows('hardware', 'os') && ( - <> -
- {specUi.fields.os} -
-
- {loc(specs?.hardware?.os)} -
- - )} -
-
-
- ), - camera: ( -
-
-

- {groupHeading('camera')} -

-
-
- {specUi.fields.rearSetup} -
-
- {loc(specs?.camera?.rear_setup)} -
-
- {specUi.fields.mainCamera} -
-
- {loc(specs?.camera?.main_camera || keySpecs?.camera)} -
-
- {specUi.fields.secondCamera} -
-
- {loc(specs?.camera?.second_camera)} -
-
- {specUi.fields.thirdCamera} -
-
- {loc(specs?.camera?.third_camera)} -
- {specs?.camera?.fourth_camera && ( - <> -
- {specUi.fields.fourthCamera} -
-
- {loc(specs.camera.fourth_camera)} -
- - )} -
- {specUi.fields.frontCamera} -
-
- {loc(specs?.camera?.front_camera)} -
-
- {specUi.fields.videoAndFlash} -
-
- {loc( - [specs?.camera?.video_recording, specs?.camera?.flash] - .filter(Boolean) - .join(' | ') - )} -
-
-
-
- ), - battery: ( -
-
-

- {groupHeading('battery')} -

-
-
- {specUi.fields.batteryCapacity} -
-
- {specs?.battery?.capacity_mah - ? `${specs.battery.capacity_mah} mAh` - : product.battery_mah - ? `${product.battery_mah} mAh` - : loc(keySpecs?.battery)} -
-
- {specUi.fields.batteryType} -
-
- {loc(specs?.battery?.type)} -
-
- {specUi.fields.chargingTech} -
-
- {loc(specs?.battery?.charging)} -
-
- {specUi.fields.fastCharging} -
-
- {specs?.battery?.fast_charging_w - ? `${specs.battery.fast_charging_w} W` - : '-'} -
-
- {specUi.fields.reverseCharging} -
-
- {typeof specs?.battery?.reverse_charging === 'string' - ? loc(specs.battery.reverse_charging) - : specs?.battery?.reverse_charging === true - ? specUi.supported - : specs?.battery?.reverse_charging === false - ? specUi.no - : '-'} -
-
-
-
- ), - design: ( -
-
-

- {groupHeading('design')} -

-
-
- {specUi.fields.dimensions} -
-
- {loc(specs?.design?.dimensions)} -
-
- {specUi.fields.weight} -
-
- {specs?.design?.weight_g - ? `${specs.design.weight_g} g` - : '-'} -
-
- {specUi.fields.materialsAndBuild} -
-
- {loc( - [specs?.design?.materials, specs?.design?.build] - .filter(Boolean) - .join(' | ') - )} -
-
- {specUi.fields.resistance} -
-
- {loc(specs?.design?.resistance)} -
- {shows('design', 'biometrics') && ( - <> -
- {specUi.fields.biometrics} -
-
- {loc(specs?.design?.biometrics)} -
- - )} -
- {specUi.fields.physicalKeys} -
-
- {loc(specs?.design?.keys)} -
-
-
-
- ), - cellular: ( -
-
-

- {groupHeading('cellular')} -

-
- {shows('cellular', 'has_5g') && ( - <> -
- {specUi.fields.support5g} -
-
- {(specs?.cellular?.has_5g ?? product.has_5g) - ? specUi.has5gYes - : specUi.no} -
- - )} -
- {specUi.fields.networkTech} -
-
- {loc(specs?.cellular?.technology)} -
-
- {specUi.fields.simType} -
-
- {loc(specs?.cellular?.sim_type)} -
-
- {specUi.fields.dataSpeed} -
-
- {loc(specs?.cellular?.data_speed)} -
-
- {shows('cellular', 'bands_5g') - ? specUi.fields.bands5g4g - : specUi.fields.bands4g} -
-
- {loc( - [ - shows('cellular', 'bands_5g') - ? specs?.cellular?.bands_5g - : null, - specs?.cellular?.bands_4g, - ] - .filter(Boolean) - .join(' | ') - )} -
-
- {specUi.fields.volteVowifi} -
-
- {specs?.cellular?.volte || specs?.cellular?.vowifi - ? [ - specs?.cellular?.volte ? 'VoLTE' : null, - specs?.cellular?.vowifi ? 'VoWiFi' : null, - ] - .filter(Boolean) - .join(' + ') - : '-'} -
-
-
-
- ), - connectivity: ( -
-
-

- {groupHeading('connectivity')} -

-
- {shows('connectivity', 'wlan') && ( - <> -
- {specUi.fields.wlan} -
-
- {loc(specs?.connectivity?.wlan)} -
- - )} -
- {specUi.fields.bluetooth} -
-
- {loc(specs?.connectivity?.bluetooth)} -
-
- {specUi.fields.usbPort} -
-
- {loc(specs?.connectivity?.usb)} -
- {shows('connectivity', 'nfc') && ( - <> -
- {specUi.fields.nfc} -
-
- {specs?.connectivity?.nfc === true - ? specUi.yes - : specs?.connectivity?.nfc === false - ? specUi.no - : '-'} -
- - )} - {shows('connectivity', 'location') && ( - <> -
- {specUi.fields.locationGps} -
-
- {loc(specs?.connectivity?.location)} -
- - )} -
- {specUi.fields.sensorsAndOther} -
-
- {loc( - [ - specs?.connectivity?.sensors, - specs?.connectivity?.other, - ] - .filter(Boolean) - .join(' | ') - )} -
-
-
-
- ), - multimedia: ( -
-
-

- {groupHeading('multimedia')} -

-
-
- {specUi.fields.loudspeaker} -
-
- {loc(specs?.multimedia?.loudspeaker)} -
- {shows('multimedia', 'headphone_jack_35mm') && ( - <> -
- {specUi.fields.jack35mm} -
-
- {specs?.multimedia?.headphone_jack_35mm === true - ? specUi.yes - : specs?.multimedia?.headphone_jack_35mm === false - ? specUi.no - : '-'} -
- - )} -
- {specUi.fields.audioFeatures} -
-
- {loc(specs?.multimedia?.audio_features)} -
-
-
-
- ), - availability: ( -
-
-

- {groupHeading('availability')} -

-
-
- {specUi.fields.announcedDate} -
-
- {loc(specs?.availability?.announced)} -
-
- {specUi.fields.commercialStatus} -
-
- {specs?.availability?.status - ? loc(specs.availability.status) - : specUi.availableStatus} -
-
- {specUi.fields.msrpPrice} -
-
- {loc(specs?.availability?.prices_msrp)} -
-
-
-
- ), - regulatory: ( -
-
-

- {groupHeading('regulatory')} -

-
-
- {specUi.fields.certifications} -
-
- {loc(specs?.regulatory?.certifications)} -
-
- {specUi.fields.fccApproval} -
-
- {loc(specs?.regulatory?.fcc_approval)} -
-
- {specUi.fields.sarLevel} -
-
- {[ - specs?.regulatory?.sar_head - ? `${specUi.fields.sarHeadPrefix}: ${specs.regulatory.sar_head}` - : null, - specs?.regulatory?.sar_body - ? `${specUi.fields.sarBodyPrefix}: ${specs.regulatory.sar_body}` - : null, - ] - .filter(Boolean) - .join(' | ') || '-'} -
-
-
-
- ), - }; + // Rows with no value and groups with no row are left out + // (DOS/Rate#1186). + const specGroups = buildProductSpecGroups({ + specs, + keySpecs, + product, + template: specTemplate, + ui: specUi, + locale, + }); return (
- {specSectionBadge(specUi, specTemplate.groups.length)} + {specSectionBadge(specUi, specGroups.length)}
@@ -1576,9 +1007,31 @@ export async function ProductDetailView({ showMoreLabel={t('specsShowAll')} showLessLabel={t('specsShowLess')} openOnHash="#specs-section" + collapsible={specGroups.length > 3} > - {specTemplate.groups.map((group) => ( - {groupNodes[group]} + {specGroups.map(({ group, rows }, i) => ( +
+
+

+ {specGroupHeading( + specUi, + group, + i + 1, + specTemplate.has5g + )} +

+
+ {rows.map(({ label, value }) => ( + +
{label}
+
+ +
+
+ ))} +
+
+
))} diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index 01d8e9eb8..3aa09adb7 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -67,6 +67,7 @@ import { localizeSpecValue, specGroupHeading, } from '@/lib/spec-localizer'; +import { compareRowIsShown } from '@/lib/product-spec-rows'; import ContentText from '@/components/elementary/ContentText'; import { productImageUrl } from '@/lib/rate-domain-core'; @@ -862,6 +863,25 @@ export default function CompareWorkspace({ [allSpecGroups, activeCategory] ); + // No row that every column leaves empty, and with `showDiffOnly` no row + // where every column agrees (DOS/Rate#1186). + const visibleSpecGroups = specGroups + .map((group) => ({ + ...group, + rows: group.rows.filter((row) => + compareRowIsShown( + // The cells show the localized text, where "N/A" becomes "-". + products.map((p) => localizeSpecValue(row.renderValue(p), locale)), + showDiffOnly + ) + ), + })) + .filter((group) => group.rows.length > 0); + // Spec groups are numbered from 1 after the market group. + const numberedSpecGroups = visibleSpecGroups.filter( + (group) => group.id !== 'market_trust' + ); + const searchInputRef = useRef(null); const searchBoxRef = useRef(null); const restoredTrayRef = useRef(false); @@ -1274,13 +1294,21 @@ export default function CompareWorkspace({ matrix: specGroups.map((group) => ({ groupId: group.id, groupTitle: group.title, - rows: group.rows.map((row) => ({ - id: row.id, - label: row.label, - values: Object.fromEntries( - products.map((p) => [p.slug, row.renderValue(p)]) - ), - })), + // Like the CSV, no row that every product leaves empty. + rows: group.rows + .filter((row) => + compareRowIsShown( + products.map((p) => row.renderValue(p)), + false + ) + ) + .map((row) => ({ + id: row.id, + label: row.label, + values: Object.fromEntries( + products.map((p) => [p.slug, row.renderValue(p)]) + ), + })), })), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { @@ -2257,20 +2285,8 @@ export default function CompareWorkspace({ - )} - {specGroups.map((group, groupIndex) => { - // Filter rows when `showDiffOnly` is enabled - const visibleRows = group.rows.filter((row) => { - if (!showDiffOnly || products.length < 2) return true; - const rendered = products.map((p) => - row.renderValue(p).trim() - ); - const uniqueVals = new Set(rendered); - return uniqueVals.size > 1; - }); - - if (visibleRows.length === 0) { - return null; - } + {visibleSpecGroups.map((group) => { + const visibleRows = group.rows; const totalCols = products.length + @@ -2285,7 +2301,7 @@ export default function CompareWorkspace({ ? specGroupHeading( specDict, group.id as SpecGroupKey, - groupIndex, + numberedSpecGroups.indexOf(group) + 1, getCategorySpecTemplate(activeCategory).has5g ) : group.title; diff --git a/apps/web/src/components/product/PhoneCollapsible.tsx b/apps/web/src/components/product/PhoneCollapsible.tsx index fdf34e9e3..9edba20f9 100644 --- a/apps/web/src/components/product/PhoneCollapsible.tsx +++ b/apps/web/src/components/product/PhoneCollapsible.tsx @@ -12,6 +12,7 @@ export default function PhoneCollapsible({ showMoreLabel, showLessLabel, openOnHash, + collapsible = true, children, }: { className: string; @@ -19,6 +20,11 @@ export default function PhoneCollapsible({ showLessLabel: string; /** Expand when the page is opened or navigated to this hash, e.g. "#specs". */ openOnHash?: string; + /** + * False when there is nothing past the first three children: the list then + * shows whole, with no toggle (DOS/Rate#1186). + */ + collapsible?: boolean; children: ReactNode; }) { const [open, setOpen] = useState(false); @@ -46,6 +52,12 @@ export default function PhoneCollapsible({ } }; + if (!collapsible) { + return ( +
{children}
+ ); + } + return ( <>
, + keySpecs: Record = {}, + product: Record = {} +) => + buildProductSpecGroups({ + specs: specs as ProductSpecifications, + keySpecs: keySpecs as KeySpecs, + product, + template: getCategorySpecTemplate(category), + ui: getSpecUiDictionary('vi'), + locale: 'vi', + }); + +const labelsOf = (groups: ReturnType, group: string) => + groups.find((g) => g.group === group)?.rows.map((r) => r.label) ?? null; + +function testProductPage() { + const ui = getSpecUiDictionary('vi'); + + // A GPS watch: no cellular value at all, so no cellular group; empty rows + // of other groups are left out, filled ones kept. + const watch = groupsFor('smartwatches', { + display: { size_inch: 1.9, type: 'LTPO OLED', resolution: '' }, + hardware: { chipset: 'Apple S11' }, + battery: { capacity_mah: 327 }, + cellular: { technology: '', data_speed: null, bands_4g: '' }, + connectivity: { bluetooth: '5.3' }, + }); + assert.equal(labelsOf(watch, 'cellular'), null); + assert.deepEqual(labelsOf(watch, 'display'), [ + ui.fields.screenSize, + ui.fields.panelType, + ]); + assert.deepEqual(labelsOf(watch, 'hardware'), [ui.fields.chipset]); + // Availability keeps its default commercial status. + assert.deepEqual(labelsOf(watch, 'availability'), [ + ui.fields.commercialStatus, + ]); + // A cellular watch keeps its 4G bands under the 4G-only label. + const lte = groupsFor('smartwatches', { + cellular: { has_5g: true, bands_4g: 'B1/B3', bands_5g: 'n78' }, + }); + assert.deepEqual(lte.find((g) => g.group === 'cellular')?.rows, [ + { label: ui.fields.bands4g, value: 'B1/B3' }, + ]); + for (const { rows } of watch) { + for (const { value } of rows) { + assert.ok(!isEmptySpecValue(value), `empty row: ${value}`); + } + } + + // Earbuds: an empty FCC row is gone, certifications stay; the hardware + // card keeps only the audio chip. + const buds = groupsFor( + 'earbuds', + { + hardware: { chipset: 'H2' }, + regulatory: { certifications: 'CE', fcc_approval: '-' }, + multimedia: { audio_features: 'ANC' }, + }, + { battery: '6 giờ' } + ); + assert.deepEqual(labelsOf(buds, 'regulatory'), [ui.fields.certifications]); + assert.deepEqual(labelsOf(buds, 'hardware'), [ui.fields.chipset]); + assert.deepEqual( + buds.map((g) => g.group), + ['multimedia', 'battery', 'hardware', 'availability', 'regulatory'] + ); + + // A group with nothing but empty values disappears. + const bare = groupsFor('earbuds', { + design: { dimensions: '', weight_g: 0, keys: null }, + }); + assert.equal(labelsOf(bare, 'design'), null); + + // Phones keep their filled rows and flat-column fallbacks. + const phone = groupsFor( + 'smartphones', + { hardware: { chipset: 'A18' } }, + {}, + { ram_gb: 8, storage_gb: 128, has_5g: true } + ); + assert.deepEqual( + phone.find((g) => g.group === 'hardware')?.rows.map((r) => r.value), + ['A18', '8 GB', '128 GB'] + ); + assert.equal( + phone.find((g) => g.group === 'cellular')?.rows[0]?.value, + ui.has5gYes + ); + console.warn(' ✅ Product page leaves out empty rows and groups'); +} + +function testCompare() { + assert.equal(compareRowIsShown(['-', '-'], false), false); + assert.equal(compareRowIsShown(['', ' - '], true), false); + // "-" stays where another column has a value. + assert.equal(compareRowIsShown(['-', 'LTE'], false), true); + assert.equal(compareRowIsShown(['-', 'LTE'], true), true); + assert.equal(compareRowIsShown(['LTE', 'LTE'], true), false); + assert.equal(compareRowIsShown(['LTE', 'LTE'], false), true); + assert.equal(compareRowIsShown(['LTE'], true), true); + assert.equal(compareRowIsShown(['-'], false), false); + console.warn(' ✅ Compare keeps "-" only beside a value'); +} + +function main() { + console.warn('🧪 Product spec rows suite (DOS/Rate#1186)'); + testProductPage(); + testCompare(); +} + +main(); diff --git a/apps/web/src/lib/__tests__/spec-template.test.ts b/apps/web/src/lib/__tests__/spec-template.test.ts index 94aa3dba2..763772f3a 100644 --- a/apps/web/src/lib/__tests__/spec-template.test.ts +++ b/apps/web/src/lib/__tests__/spec-template.test.ts @@ -216,7 +216,7 @@ async function testSurfaces() { // Compare headings come from the template too: no "5G" heading for a watch. assert.match( compare, - /specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*groupIndex,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/ + /specGroupHeading\(\s*specDict,\s*group\.id as SpecGroupKey,\s*numberedSpecGroups\.indexOf\(group\) \+ 1,\s*getCategorySpecTemplate\(activeCategory\)\.has5g\s*\)/ ); const detail = readSource('../../app/[locale]/smartphones/[slug]/page.tsx'); assert.match(detail, /categorySlug=\{categorySlug\}/); @@ -304,7 +304,10 @@ async function main() { /const product = applyCategorySpecTemplate\(categorySlug, storedProduct\)/ ); assert.match(detail, /applyCategorySpecTemplate\(categorySlug, peer\)/); - assert.match(detail, /specTemplate\.groups\.map\(/); + assert.match( + detail, + /buildProductSpecGroups\(\{[^}]*template: specTemplate,/ + ); assert.doesNotMatch(detail, /specUi\.groups\.\w+\}/); const catalog = readSource('../../app/[locale]/smartphones/page.tsx'); assert.match( diff --git a/apps/web/src/lib/product-spec-rows.ts b/apps/web/src/lib/product-spec-rows.ts new file mode 100644 index 000000000..119aee691 --- /dev/null +++ b/apps/web/src/lib/product-spec-rows.ts @@ -0,0 +1,275 @@ +import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; +import { + specFieldApplies, + type CategorySpecTemplate, + type SpecGroupKey, +} from '@repo/shared-data/rules'; +import { localizeSpecValue, type SpecUiDictionary } from './spec-localizer'; + +export interface ProductSpecRow { + label: string; + value: string; +} + +export interface ProductSpecGroup { + group: SpecGroupKey; + rows: ProductSpecRow[]; +} + +/** The flat columns the spec section falls back to. */ +export interface ProductSpecFallbacks { + screen_size_inch?: number | null; + ram_gb?: number | null; + storage_gb?: number | null; + battery_mah?: number | null; + has_5g?: boolean | null; +} + +/** A value the page has nothing to show for: empty or a lone dash. */ +export function isEmptySpecValue(value: string | null | undefined): boolean { + const text = (value ?? '').trim(); + return text === '' || text === '-'; +} + +/** + * Whether compare shows a row (DOS/Rate#1186): not when no compared product + * has a value, so "-" stays only where another column has one. With + * `diffOnly`, rows where every column agrees are left out too. + */ +export function compareRowIsShown( + values: readonly string[], + diffOnly: boolean +): boolean { + if (values.length === 0) return true; + if (values.every(isEmptySpecValue)) return false; + if (!diffOnly || values.length < 2) return true; + return new Set(values.map((v) => v.trim())).size > 1; +} + +const joined = (parts: unknown[], separator: string): string => + parts.filter(Boolean).join(separator); + +/** + * The product page's spec groups in the category's template order, with the + * rows that have no value left out and a group with no row left out + * (DOS/Rate#1186). The page used to print those rows as "-". + */ +export function buildProductSpecGroups({ + specs, + keySpecs, + product, + template, + ui, + locale, +}: { + specs: ProductSpecifications | null | undefined; + keySpecs: KeySpecs | null | undefined; + product: ProductSpecFallbacks; + template: CategorySpecTemplate; + ui: SpecUiDictionary; + locale: string; +}): ProductSpecGroup[] { + const s = specs ?? ({} as ProductSpecifications); + const k = keySpecs ?? ({} as KeySpecs); + const f = ui.fields; + const loc = (v: string | number | boolean | null | undefined) => + localizeSpecValue(v, locale); + const shows = (group: SpecGroupKey, field: string) => + specFieldApplies(template, group, field); + const withUnit = (value: unknown, unit: string) => + value ? `${value} ${unit}` : ''; + const yesNo = (value: unknown, yes = ui.yes) => + value === true ? yes : value === false ? ui.no : ''; + const showBands5g = shows('cellular', 'bands_5g'); + + const groups: Record = { + display: [ + { + label: f.screenSize, + value: + withUnit(s.display?.size_inch, 'inch') || + withUnit(product.screen_size_inch, 'inch'), + }, + { label: f.panelType, value: loc(s.display?.type || k.screen) }, + { label: f.resolution, value: loc(s.display?.resolution) }, + { + label: f.refreshRate, + value: withUnit(s.display?.refresh_rate_hz, 'Hz'), + }, + { label: f.ppi, value: withUnit(s.display?.ppi, 'ppi') }, + { label: f.protection, value: loc(s.display?.protection) }, + { label: f.displayFeatures, value: loc(s.display?.features) }, + ], + hardware: [ + { label: f.chipset, value: loc(s.hardware?.chipset || k.chip) }, + shows('hardware', 'cpu') && { label: f.cpu, value: loc(s.hardware?.cpu) }, + shows('hardware', 'gpu') && { label: f.gpu, value: loc(s.hardware?.gpu) }, + shows('hardware', 'ram_gb') && { + label: f.ram, + value: + withUnit(s.hardware?.ram_gb, 'GB') || withUnit(product.ram_gb, 'GB'), + }, + shows('hardware', 'storage_gb') && { + label: f.storage, + value: + withUnit(s.hardware?.storage_gb, 'GB') || + withUnit(product.storage_gb, 'GB'), + }, + shows('hardware', 'storage_expansion') && { + label: f.storageExpansion, + value: loc(s.hardware?.storage_expansion), + }, + shows('hardware', 'os') && { label: f.os, value: loc(s.hardware?.os) }, + ], + camera: [ + { label: f.rearSetup, value: loc(s.camera?.rear_setup) }, + { + label: f.mainCamera, + value: loc(s.camera?.main_camera || k.camera), + }, + { label: f.secondCamera, value: loc(s.camera?.second_camera) }, + { label: f.thirdCamera, value: loc(s.camera?.third_camera) }, + { label: f.fourthCamera, value: loc(s.camera?.fourth_camera) }, + { label: f.frontCamera, value: loc(s.camera?.front_camera) }, + { + label: f.videoAndFlash, + value: loc(joined([s.camera?.video_recording, s.camera?.flash], ' | ')), + }, + ], + battery: [ + { + label: f.batteryCapacity, + value: + withUnit(s.battery?.capacity_mah, 'mAh') || + withUnit(product.battery_mah, 'mAh') || + loc(k.battery), + }, + { label: f.batteryType, value: loc(s.battery?.type) }, + { label: f.chargingTech, value: loc(s.battery?.charging) }, + { + label: f.fastCharging, + value: withUnit(s.battery?.fast_charging_w, 'W'), + }, + { + label: f.reverseCharging, + value: + typeof s.battery?.reverse_charging === 'string' + ? loc(s.battery.reverse_charging) + : yesNo(s.battery?.reverse_charging, ui.supported), + }, + ], + design: [ + { label: f.dimensions, value: loc(s.design?.dimensions) }, + { label: f.weight, value: withUnit(s.design?.weight_g, 'g') }, + { + label: f.materialsAndBuild, + value: loc(joined([s.design?.materials, s.design?.build], ' | ')), + }, + { label: f.resistance, value: loc(s.design?.resistance) }, + shows('design', 'biometrics') && { + label: f.biometrics, + value: loc(s.design?.biometrics), + }, + { label: f.physicalKeys, value: loc(s.design?.keys) }, + ], + cellular: [ + shows('cellular', 'has_5g') && { + label: f.support5g, + value: (s.cellular?.has_5g ?? product.has_5g) ? ui.has5gYes : ui.no, + }, + { label: f.networkTech, value: loc(s.cellular?.technology) }, + { label: f.simType, value: loc(s.cellular?.sim_type) }, + { label: f.dataSpeed, value: loc(s.cellular?.data_speed) }, + { + label: showBands5g ? f.bands5g4g : f.bands4g, + value: loc( + joined( + [showBands5g ? s.cellular?.bands_5g : null, s.cellular?.bands_4g], + ' | ' + ) + ), + }, + { + label: f.volteVowifi, + value: joined( + [ + s.cellular?.volte ? 'VoLTE' : null, + s.cellular?.vowifi ? 'VoWiFi' : null, + ], + ' + ' + ), + }, + ], + connectivity: [ + shows('connectivity', 'wlan') && { + label: f.wlan, + value: loc(s.connectivity?.wlan), + }, + { label: f.bluetooth, value: loc(s.connectivity?.bluetooth) }, + { label: f.usbPort, value: loc(s.connectivity?.usb) }, + shows('connectivity', 'nfc') && { + label: f.nfc, + value: yesNo(s.connectivity?.nfc), + }, + shows('connectivity', 'location') && { + label: f.locationGps, + value: loc(s.connectivity?.location), + }, + { + label: f.sensorsAndOther, + value: loc( + joined([s.connectivity?.sensors, s.connectivity?.other], ' | ') + ), + }, + ], + multimedia: [ + { label: f.loudspeaker, value: loc(s.multimedia?.loudspeaker) }, + shows('multimedia', 'headphone_jack_35mm') && { + label: f.jack35mm, + value: yesNo(s.multimedia?.headphone_jack_35mm), + }, + { label: f.audioFeatures, value: loc(s.multimedia?.audio_features) }, + ], + availability: [ + { label: f.announcedDate, value: loc(s.availability?.announced) }, + { + label: f.commercialStatus, + value: s.availability?.status + ? loc(s.availability.status) + : ui.availableStatus, + }, + { label: f.msrpPrice, value: loc(s.availability?.prices_msrp) }, + ], + regulatory: [ + { + label: f.certifications, + value: loc(s.regulatory?.certifications), + }, + { label: f.fccApproval, value: loc(s.regulatory?.fcc_approval) }, + { + label: f.sarLevel, + value: joined( + [ + s.regulatory?.sar_head + ? `${f.sarHeadPrefix}: ${s.regulatory.sar_head}` + : null, + s.regulatory?.sar_body + ? `${f.sarBodyPrefix}: ${s.regulatory.sar_body}` + : null, + ], + ' | ' + ), + }, + ], + }; + + return template.groups + .map((group) => ({ + group, + rows: groups[group].filter( + (row): row is ProductSpecRow => + row !== false && !isEmptySpecValue(row.value) + ), + })) + .filter(({ rows }) => rows.length > 0); +} From aa751fdb98b35ebe657cabadba59446b8f4bd13c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:32:06 +0700 Subject: [PATCH 06/69] test(normalizer): pin the thinkpad 21kc008pvn title to unknown, not 512gb (#1198) * test(normalizer): pin the thinkpad 21kc008pvn title to unknown, not 512gb The 2026-10-25 "Storage: 512GB" for CellphoneS SKU 21KC008PVN (a 1TB config) came from the registry default storage, removed by #1094 (#1101) on 2026-10-27. The current normalizer keys the storage-less title as :unknown and the title with its config as :1024gb. Refs #1189 Co-Authored-By: Claude Opus 5.5 --- .../shopee/__tests__/open-core-intelligence.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 93980e9b5..cf1d3d67d 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -843,6 +843,12 @@ async function runSuite() { ['Apple iPhone 18 Pro Max', 'apple:iphone-18-pro-max:unknown'], ['Apple iPhone 18 Pro', 'apple:iphone-18-pro:unknown'], ['Lenovo ThinkPad X1 Carbon Gen 12 Ultra 7 155U', 'lenovo:thinkpad-x1-carbon-gen-12:unknown'], + // DOS/Rate#1189: CellphoneS SKU 21KC008PVN is the 1TB config; before #1094 this + // title keyed at the registry default and logged "Storage: 512GB" on 2026-10-25. + [ + 'Laptop Lenovo ThinkPad X1 Carbon Gen 12 21KC008PVN', + 'lenovo:thinkpad-x1-carbon-gen-12:unknown', + ], ['Máy tính bảng iPad Pro M5 11 inch WiFi', 'apple:ipad-pro-m5-11:unknown'], ['Máy tính bảng Xiaomi Pad 7 Pro', 'xiaomi:pad-7-pro:unknown'], ]; @@ -854,6 +860,13 @@ async function runSuite() { assert.equal(storageless.is_benchmark_enriched, true, title); assert.equal(storageless.specifications.hardware?.storage_gb, undefined, title); } + // The same SKU with its configuration in the title keys at 1TB, never the 512GB master. + assert.equal( + keyOf( + 'Laptop Lenovo ThinkPad X1 Carbon Gen 12 21KC008PVN (Ultra 7 155H/ 32GB/ 1TB SSD/ 14 inch 2.8K OLED/ Win 11 Pro)' + ), + 'lenovo:thinkpad-x1-carbon-gen-12:1024gb' + ); const storagelessZenbook = normalizer.normalize( createDummyShopeeProduct('Laptop ASUS Zenbook S 14 UX5406AA-SU348W') ); From 94ca73f1328f69c28f4b1df4811e2cca4fbf5541 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:34:14 +0700 Subject: [PATCH 07/69] fix(web): show unknown specs as empty instead of invented values (#1199) * fix(web): show unknown specs as empty instead of invented values Compare printed made-up facts for unknown specs: "4G LTE" when 5G is unknown, stereo speakers, "USB Type-C", an official-sale status and a Vietnam MIC certification. The product page printed "No" for an unknown 5G flag and a default commercial status. Unknown is now empty, so the copy is removed from the locales and the spec dictionary. Refs #1193 * fix(web): read text 5g flags and cover known spec values Review of the previous commit: requiring a strict boolean hid 5G for older rows that store "true" or "yes" as text. known5g reads boolean or text flags, nested spec first, and treats anything else as unknown. Tests cover nested-over-flat precedence, text flags, a hidden all-unknown compare row and known availability and certification values. Refs #1193 * test(web): e2e earbud page has no invented sale status Without the default commercial status (#1193) the seeded earbud keeps three spec groups: audio, battery and Bluetooth. Refs #1193 Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/category-spec-template.spec.ts | 8 +- apps/web/locales/cs.json | 3 - apps/web/locales/en.json | 3 - apps/web/locales/vi.json | 3 - .../components/compare/CompareWorkspace.tsx | 22 ++- .../lib/__tests__/product-spec-rows.test.ts | 130 +++++++++++++++++- apps/web/src/lib/product-spec-rows.ts | 24 +++- apps/web/src/lib/spec-localizer.ts | 4 - 8 files changed, 158 insertions(+), 39 deletions(-) diff --git a/apps/web/e2e/category-spec-template.spec.ts b/apps/web/e2e/category-spec-template.spec.ts index 6839b7971..a23b08eef 100644 --- a/apps/web/e2e/category-spec-template.spec.ts +++ b/apps/web/e2e/category-spec-template.spec.ts @@ -11,9 +11,10 @@ test('earbud page shows audio specs and no phone fields', async ({ page }) => { const specs = page.locator('#specs-section'); const headings = specs.locator('h3'); await expect(headings.first()).toHaveText(/^1\. Âm thanh/); - // Groups with no value are hidden (DOS/Rate#1186): the seeded bud has - // audio, battery, Bluetooth and the default sale status, nothing else. - await expect(headings).toHaveCount(4); + // Groups with no value are hidden (DOS/Rate#1186) and no sale status is + // invented (DOS/Rate#1193): the seeded bud has audio, battery and + // Bluetooth, nothing else. + await expect(headings).toHaveCount(3); await expect(headings.nth(1)).toHaveText(/^2\. Pin/); await expect(specs).toContainText('E2E 10.5mm driver'); await expect(specs).toContainText('E2E ANC'); @@ -29,6 +30,7 @@ test('earbud page shows audio specs and no phone fields', async ({ page }) => { '5G', 'Phần cứng', 'Pháp lý', + 'Đang kinh doanh', ]) { await expect(specs).not.toContainText(text); } diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 2da9e16dc..346446ed6 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -239,15 +239,12 @@ "rowConnNfc": "Bezkontaktní platby NFC", "rowConnUsbGps": "Konektor USB, navigace a senzory", "rowMmSpeaker": "Systém reproduktorů", - "valDefaultStereo": "Stereo reproduktory", "rowMmJackAudio": "3.5mm jack a zvukové standardy", "rowAvailAnnounced": "Datum představení a dostupnost", - "valDefaultAvailable": "V oficiálním prodeji", "rowAvailMsrp": "Doporučená cena (MSRP) a kód modelu", "rowRegCert": "Certifikace kvality a hodnota SAR", "valSarHeadPrefix": "SAR hlava: {value}", "valSarBodyPrefix": "SAR tělo: {value}", - "valDefaultRegulatory": "Certifikováno MIC a CE", "categoryLockLabel": "Aktivní zámek kategorie (porovnává pouze zařízení stejného typu):", "categoryTabsAria": "Vyberte kategorii porovnání", "replacingDeviceLabel": "Výběr {unit} jako náhrady za \"{name}\"", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 6cf633002..500281008 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -239,15 +239,12 @@ "rowConnNfc": "Contactless NFC", "rowConnUsbGps": "USB port, positioning and sensors", "rowMmSpeaker": "Loudspeaker system", - "valDefaultStereo": "Dual stereo speakers", "rowMmJackAudio": "3.5mm jack and audio standards", "rowAvailAnnounced": "Announced date and market status", - "valDefaultAvailable": "Available via authorized retailers", "rowAvailMsrp": "Launch MSRP and model ID", "rowRegCert": "Quality certifications and SAR rating", "valSarHeadPrefix": "Head SAR: {value}", "valSarBodyPrefix": "Body SAR: {value}", - "valDefaultRegulatory": "MIC Vietnam & CE certified", "categoryLockLabel": "Active category lock (compares only same-category devices):", "categoryTabsAria": "Select comparison category", "replacingDeviceLabel": "Selecting a {unit} to replace \"{name}\"", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 4893124ca..178141b08 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -239,15 +239,12 @@ "rowConnNfc": "Thanh toán không chạm NFC", "rowConnUsbGps": "Cổng USB, định vị và cảm biến", "rowMmSpeaker": "Hệ thống loa ngoài", - "valDefaultStereo": "Loa kép stereo", "rowMmJackAudio": "Giắc tai nghe 3.5mm và chuẩn âm thanh", "rowAvailAnnounced": "Thời điểm công bố và trạng thái", - "valDefaultAvailable": "Đang mở bán chính hãng", "rowAvailMsrp": "Giá niêm yết gốc (MSRP) và mã mẫu máy", "rowRegCert": "Chứng nhận chất lượng và mức bức xạ SAR", "valSarHeadPrefix": "SAR đầu: {value}", "valSarBodyPrefix": "SAR thân: {value}", - "valDefaultRegulatory": "Đạt chuẩn hợp quy Bộ TT&TT Việt Nam", "categoryLockLabel": "Danh mục đang khóa đối chiếu (chỉ so sánh các thiết bị cùng loại):", "categoryTabsAria": "Chọn danh mục đối chiếu", "replacingDeviceLabel": "Đang chọn {unit} thay thế cho \"{name}\"", diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index 3aa09adb7..bdd17dc5f 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -67,7 +67,7 @@ import { localizeSpecValue, specGroupHeading, } from '@/lib/spec-localizer'; -import { compareRowIsShown } from '@/lib/product-spec-rows'; +import { compareRowIsShown, known5g } from '@/lib/product-spec-rows'; import ContentText from '@/components/elementary/ContentText'; import { productImageUrl } from '@/lib/rate-domain-core'; @@ -624,8 +624,10 @@ export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { label: t('rowCell5g'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; - const has5g = specs.cellular?.has_5g ?? p.has_5g; - return has5g ? t('valHas5g') : t('val4gLte'); + // Unknown stays empty, not "4G LTE" (DOS/Rate#1193). + const has5g = known5g(specs.cellular?.has_5g, p.has_5g); + if (has5g === true) return t('valHas5g'); + return has5g === false ? t('val4gLte') : '-'; }, }, { @@ -690,11 +692,11 @@ export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; const parts = [ - specs.connectivity?.usb || 'USB Type-C', + specs.connectivity?.usb, specs.connectivity?.location, specs.connectivity?.sensors, ].filter(Boolean); - return parts.join(' | '); + return parts.join(' | ') || '-'; }, }, ], @@ -709,7 +711,7 @@ export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { label: t('rowMmSpeaker'), renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; - return specs.multimedia?.loudspeaker || t('valDefaultStereo'); + return specs.multimedia?.loudspeaker || '-'; }, }, { @@ -743,9 +745,7 @@ export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { specs.availability?.announced, specs.availability?.status, ].filter(Boolean); - return parts.length > 0 - ? parts.join(' | ') - : t('valDefaultAvailable'); + return parts.length > 0 ? parts.join(' | ') : '-'; }, }, { @@ -781,9 +781,7 @@ export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { ? t('valSarBodyPrefix', { value: specs.regulatory.sar_body }) : null, ].filter(Boolean); - return parts.length > 0 - ? parts.join(' | ') - : t('valDefaultRegulatory'); + return parts.length > 0 ? parts.join(' | ') : '-'; }, }, ], diff --git a/apps/web/src/lib/__tests__/product-spec-rows.test.ts b/apps/web/src/lib/__tests__/product-spec-rows.test.ts index 4df84d152..8f38cefb9 100644 --- a/apps/web/src/lib/__tests__/product-spec-rows.test.ts +++ b/apps/web/src/lib/__tests__/product-spec-rows.test.ts @@ -44,10 +44,9 @@ function testProductPage() { ui.fields.panelType, ]); assert.deepEqual(labelsOf(watch, 'hardware'), [ui.fields.chipset]); - // Availability keeps its default commercial status. - assert.deepEqual(labelsOf(watch, 'availability'), [ - ui.fields.commercialStatus, - ]); + // No invented commercial status: an empty availability group is gone + // (DOS/Rate#1193). + assert.equal(labelsOf(watch, 'availability'), null); // A cellular watch keeps its 4G bands under the 4G-only label. const lte = groupsFor('smartwatches', { cellular: { has_5g: true, bands_4g: 'B1/B3', bands_5g: 'n78' }, @@ -76,7 +75,7 @@ function testProductPage() { assert.deepEqual(labelsOf(buds, 'hardware'), [ui.fields.chipset]); assert.deepEqual( buds.map((g) => g.group), - ['multimedia', 'battery', 'hardware', 'availability', 'regulatory'] + ['multimedia', 'battery', 'hardware', 'regulatory'] ); // A group with nothing but empty values disappears. @@ -100,6 +99,36 @@ function testProductPage() { phone.find((g) => g.group === 'cellular')?.rows[0]?.value, ui.has5gYes ); + + // 5G: unknown is no row, a known false still reads "No". + const unknown5g = groupsFor('smartphones', { cellular: {} }, {}, {}); + assert.equal(labelsOf(unknown5g, 'cellular'), null); + const no5g = groupsFor( + 'smartphones', + { cellular: {} }, + {}, + { has_5g: false } + ); + assert.deepEqual( + no5g.find((g) => g.group === 'cellular')?.rows.map((r) => r.value), + [ui.no] + ); + const nestedYes = groupsFor( + 'smartphones', + { cellular: { has_5g: 'yes' } }, + {}, + { has_5g: false } + ); + assert.equal( + nestedYes.find((g) => g.group === 'cellular')?.rows[0]?.value, + ui.has5gYes + ); + const status = groupsFor('smartphones', { + availability: { status: 'Available' }, + }); + assert.deepEqual(labelsOf(status, 'availability'), [ + ui.fields.commercialStatus, + ]); console.warn(' ✅ Product page leaves out empty rows and groups'); } @@ -116,10 +145,97 @@ function testCompare() { console.warn(' ✅ Compare keeps "-" only beside a value'); } -function main() { +// Compare prints no invented value for an unknown spec (DOS/Rate#1193). +async function testCompareUnknownSpecs() { + const nodeModule = await import('node:module'); + const extMap = ( + nodeModule.default as unknown as { + _extensions?: Record void>; + } + )._extensions; + if (extMap && !extMap['.css']) { + extMap['.css'] = (mod) => { + mod.exports = {}; + }; + } + const { buildSpecGroups } = + await import('../../components/compare/CompareWorkspace'); + const t = ((key: string) => key) as unknown as Parameters< + typeof buildSpecGroups + >[0]; + const rows = new Map( + buildSpecGroups(t).flatMap((g) => g.rows.map((r) => [r.id, r])) + ); + const render = (id: string, product: Record) => + rows.get(id)!.renderValue(product as never); + const unknown = { slug: 'x', specifications: {} }; + for (const id of [ + 'cell_5g', + 'mm_speaker', + 'conn_usb_gps', + 'avail_announced', + 'reg_cert', + ]) { + assert.equal(render(id, unknown), '-', id); + } + // An all-unknown row is hidden in compare. + assert.equal( + compareRowIsShown( + [render('mm_speaker', unknown), render('mm_speaker', unknown)], + false + ), + false + ); + // Known values are unchanged; the nested 5G flag wins over the flat one, + // and older text flags still read. + assert.equal( + render('cell_5g', { + specifications: { cellular: { has_5g: false } }, + has_5g: true, + }), + 'val4gLte' + ); + assert.equal( + render('cell_5g', { specifications: { cellular: { has_5g: 'true' } } }), + 'valHas5g' + ); + assert.equal( + render('avail_announced', { + specifications: { availability: { announced: '2025' } }, + }), + '2025' + ); + assert.equal( + render('reg_cert', { + specifications: { regulatory: { certifications: 'CE' } }, + }), + 'CE' + ); + assert.equal(render('cell_5g', { ...unknown, has_5g: false }), 'val4gLte'); + assert.equal(render('cell_5g', { ...unknown, has_5g: true }), 'valHas5g'); + assert.equal( + render('mm_speaker', { + specifications: { multimedia: { loudspeaker: 'Mono' } }, + }), + 'Mono' + ); + assert.equal( + render('conn_usb_gps', { + specifications: { connectivity: { usb: 'USB-C 3.2', location: 'GPS' } }, + }), + 'USB-C 3.2 | GPS' + ); + console.warn(' ✅ Compare shows no invented value for unknown specs'); +} + +async function main() { console.warn('🧪 Product spec rows suite (DOS/Rate#1186)'); testProductPage(); testCompare(); + await testCompareUnknownSpecs(); } -main(); +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/web/src/lib/product-spec-rows.ts b/apps/web/src/lib/product-spec-rows.ts index 119aee691..43591be36 100644 --- a/apps/web/src/lib/product-spec-rows.ts +++ b/apps/web/src/lib/product-spec-rows.ts @@ -46,6 +46,23 @@ export function compareRowIsShown( return new Set(values.map((v) => v.trim())).size > 1; } +/** + * A stored 5G flag as known true, known false or unknown (null). Older + * enricher rows may hold "true" / "yes" as text; anything else is unknown + * (DOS/Rate#1193). The nested spec wins over the flat column. + */ +export function known5g(nested: unknown, flat: unknown): boolean | null { + const read = (v: unknown): boolean | null => { + if (typeof v === 'boolean') return v; + if (typeof v !== 'string') return null; + const text = v.trim().toLowerCase(); + if (['true', 'yes', 'có'].includes(text)) return true; + if (['false', 'no', 'không'].includes(text)) return false; + return null; + }; + return read(nested) ?? read(flat); +} + const joined = (parts: unknown[], separator: string): string => parts.filter(Boolean).join(separator); @@ -173,9 +190,10 @@ export function buildProductSpecGroups({ { label: f.physicalKeys, value: loc(s.design?.keys) }, ], cellular: [ + // Unknown is empty, not "No" (DOS/Rate#1193). shows('cellular', 'has_5g') && { label: f.support5g, - value: (s.cellular?.has_5g ?? product.has_5g) ? ui.has5gYes : ui.no, + value: yesNo(known5g(s.cellular?.has_5g, product.has_5g), ui.has5gYes), }, { label: f.networkTech, value: loc(s.cellular?.technology) }, { label: f.simType, value: loc(s.cellular?.sim_type) }, @@ -234,9 +252,7 @@ export function buildProductSpecGroups({ { label: f.announcedDate, value: loc(s.availability?.announced) }, { label: f.commercialStatus, - value: s.availability?.status - ? loc(s.availability.status) - : ui.availableStatus, + value: loc(s.availability?.status), }, { label: f.msrpPrice, value: loc(s.availability?.prices_msrp) }, ], diff --git a/apps/web/src/lib/spec-localizer.ts b/apps/web/src/lib/spec-localizer.ts index ed27b2983..78d8f4aec 100644 --- a/apps/web/src/lib/spec-localizer.ts +++ b/apps/web/src/lib/spec-localizer.ts @@ -8,7 +8,6 @@ export interface SpecUiDictionary { yes: string; no: string; supported: string; - availableStatus: string; has5gYes: string; /** The cellular group heading of a category without 5G (smartwatches). */ cellularNo5gGroup: string; @@ -92,7 +91,6 @@ const SPEC_UI_DICTS: Record = { yes: 'Có', no: 'Không', supported: 'Có hỗ trợ', - availableStatus: 'Đang kinh doanh', has5gYes: 'Có (5G Sub-6 / mmWave)', cellularNo5gGroup: '6. Mạng Di động (Cellular)', groups: { @@ -173,7 +171,6 @@ const SPEC_UI_DICTS: Record = { yes: 'Yes', no: 'No', supported: 'Supported', - availableStatus: 'Available', has5gYes: 'Yes (5G Sub-6 / mmWave)', cellularNo5gGroup: '6. Cellular network', groups: { @@ -254,7 +251,6 @@ const SPEC_UI_DICTS: Record = { yes: 'Ano', no: 'Ne', supported: 'Podporováno', - availableStatus: 'V prodeji', has5gYes: 'Ano (5G Sub-6 / mmWave)', cellularNo5gGroup: '6. Mobilní sítě', groups: { From 081e59b8709f14f8bc13e3bd6b5bbfd2fdd150a5 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:46:21 +0700 Subject: [PATCH 08/69] fix(web): no half-translated spec values in vietnamese (#1201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): no half-translated spec values in vietnamese localizeSpecValue replaced known English phrases inside longer values and left the rest in English, as in "không dây fast charging". A value the phrase map cannot cover now stays whole in English; one it covers is translated whole. Values already stored partly in Vietnamese still get their known phrases. The map gains pixels, performance/efficiency cores and photo/panorama, which covers every resolution, CPU and flash value in the catalogue. Refs #1187 * fix(web): check for untranslated words before the month rewrite Review of the previous commit: the month was translated before the whole-English check, so "release September 2024" came out half translated. The check now runs on the raw value. ĩ and ũ mark a value stored in Vietnamese; cd, in and oz are units; "ppi density" and "dual-band" are translated, which covers the usual resolution and Wi-Fi strings. Refs #1187 Co-Authored-By: Claude Opus 5.5 --- .../lib/__tests__/product-detail-copy.test.ts | 70 ++++++++++++++++++- apps/web/src/lib/spec-localizer.ts | 70 +++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/__tests__/product-detail-copy.test.ts b/apps/web/src/lib/__tests__/product-detail-copy.test.ts index 42db350dd..a19c3f641 100644 --- a/apps/web/src/lib/__tests__/product-detail-copy.test.ts +++ b/apps/web/src/lib/__tests__/product-detail-copy.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { computeProductRadarSignal } from '../../server/rate/deals'; import { bayesianTierKey, radarExplanation } from '../product-detail-copy'; -import { localizeSpecValue } from '../spec-localizer'; +import { localizeSpecValue, untranslatedViWords } from '../spec-localizer'; import { computeBayesianRatingSummary } from '../rate-api'; type Messages = Record>; @@ -163,6 +163,74 @@ function main() { assert.equal(localizeSpecValue('Quad\u0000Camera', 'vi'), 'Cụm 4 camera'); assert.equal(localizeSpecValue('x'.repeat(3_000), 'en').length, 2_000); + // No half-translated value (DOS/Rate#1187): a value the phrase map covers + // is translated whole, any other stays whole in English. + for (const raw of [ + 'Wireless fast charging (0-80% in 30 min)', + 'IP54 dust, sweat, and water resistant (both earbuds and MagSafe case)', + 'accelerometer, gyro, heart rate, barometer, always-on altimeter', + 'Glass front, ceramic/sapphire crystal back, aluminum or titanium frame', + ]) { + assert.equal(localizeSpecValue(raw, 'vi'), raw); + } + assert.equal( + localizeSpecValue('Li-Ion 345 mAh, non-removable', 'vi'), + 'Li-Ion 345 mAh, liền máy' + ); + assert.equal( + localizeSpecValue('1320 x 2868 pixels, 19.5:9 ratio (~460 ppi)', 'vi'), + '1320 x 2868 pixel, 19.5:9 tỷ lệ (~460 ppi)' + ); + assert.equal( + localizeSpecValue( + 'Hexa-core (2x4.05 GHz performance + 4x2.42 GHz efficiency)', + 'vi' + ), + '6 nhân (Hexa-core) (2x4.05 GHz hiệu năng + 4x2.42 GHz tiết kiệm điện)' + ); + assert.equal( + localizeSpecValue( + '1080 x 2400 pixels, 20:9 ratio (~395 ppi density)', + 'vi' + ), + '1080 x 2400 pixel, 20:9 tỷ lệ (~395 ppi)' + ); + assert.equal( + localizeSpecValue('2000 nits (peak), 1000 cd/m² (typ)', 'vi'), + '2000 nits (đỉnh), 1000 cd/m² (tiêu chuẩn)' + ); + // The month is not translated inside a value that stays English. + assert.equal( + localizeSpecValue('Coming soon. Exp. release September 2024', 'vi'), + 'Coming soon. Exp. release September 2024' + ); + assert.equal( + localizeSpecValue('Released September 2024', 'vi'), + 'Released Tháng 09/2024' + ); + // ĩ and ũ mark a value stored in Vietnamese. + assert.equal( + localizeSpecValue('Sạc tĩnh, wireless charger', 'vi'), + 'Sạc tĩnh, không dây charger' + ); + // Capitalised names are not untranslated words. + assert.equal( + localizeSpecValue('Wireless (Qi2), MagSafe', 'vi'), + 'không dây (Qi2), MagSafe' + ); + // A value stored partly in Vietnamese still gets its known phrases. + assert.equal( + localizeSpecValue('5 ATM water resistant (bơi hồ và bơi biển)', 'vi'), + '5 ATM kháng nước (bơi hồ và bơi biển)' + ); + // Vietnamese words are whole words, not "th" + "ng". + assert.deepEqual(untranslatedViWords('thông dụng'), []); + // English pages are untouched. + assert.equal( + localizeSpecValue('Wireless fast charging (0-80% in 30 min)', 'en'), + 'Wireless fast charging (0-80% in 30 min)' + ); + console.warn('✅ Product detail copy suite passed'); } diff --git a/apps/web/src/lib/spec-localizer.ts b/apps/web/src/lib/spec-localizer.ts index 78d8f4aec..90ac0ce86 100644 --- a/apps/web/src/lib/spec-localizer.ts +++ b/apps/web/src/lib/spec-localizer.ts @@ -436,11 +436,73 @@ const VI_SPEC_REPLACEMENTS: Array<[RegExp, string]> = [ [/\(peak\)/gi, '(đỉnh)'], [/\(HBM\)/gi, '(HBM)'], [/\bratio\b/gi, 'tỷ lệ'], + [/\bpixels\b/gi, 'pixel'], + [/\bppi density\b/gi, 'ppi'], + [/\bdual-band\b/gi, 'băng tần kép'], + [/\bperformance\b/gi, 'hiệu năng'], + [/\befficiency\b/gi, 'tiết kiệm điện'], + [/\(photo\/panorama\)/gi, '(ảnh/toàn cảnh)'], [/\bOcta-core\b/gi, '8 nhân (Octa-core)'], [/\bHexa-core\b/gi, '6 nhân (Hexa-core)'], [/\bDeca-core\b/gi, '10 nhân (Deca-core)'], ]; +// A letter only Vietnamese uses among the spec languages. +const VI_LETTER_RE = + /[ăâđêôơưạảấầẩẫậắằẳẵặẹẻẽếềểễệĩỉịọỏốồổỗộớờởỡợũụủứừửữựỳỵỷỹ]/i; + +// Lowercase tokens a translated value may keep: units and Wi-Fi standards. +const UNTRANSLATED_TOKENS = new Set([ + 'mm', + 'cm', + 'nm', + 'kg', + 'mah', + 'wh', + 'hz', + 'khz', + 'mhz', + 'ghz', + 'nits', + 'ppi', + 'dpi', + 'min', + 'ms', + 'gb', + 'tb', + 'mb', + 'kb', + 'mp', + 'fps', + 'db', + 'dba', + 'mw', + 'kw', + 'cd', + 'in', + 'oz', + 'ac', + 'ax', + 'be', +]); + +/** + * The lowercase English words the Vietnamese replacements leave behind, such + * as "fast charging" in "Wireless fast charging". Capitalised names (Retina, + * Digital Crown) and units do not count. + */ +export function untranslatedViWords(text: string): string[] { + let rest = text; + for (const [pattern] of VI_SPEC_REPLACEMENTS) { + rest = rest.replace(pattern, ' '); + } + // Whole words, Vietnamese letters included, so "thông" is not read as + // "th" and "ng". + return (rest.match(/\p{L}+/gu) ?? []).filter( + (word) => /^[a-z]{2,}$/.test(word) && !UNTRANSLATED_TOKENS.has(word) + ); +} + const EN_SPEC_REPLACEMENTS: Array<[RegExp, string]> = [ [/Không hỗ trợ thẻ nhớ/gi, 'No card slot'], [/Không hỗ trợ/gi, 'Not supported'], @@ -520,6 +582,14 @@ export function localizeSpecValue( } if (lang === 'vi') { + // A value the phrase map cannot cover stays whole in its source + // language rather than half translated, as in "không dây fast + // charging" (DOS/Rate#1187); checked before any rewrite, months too. + // A value already stored partly in Vietnamese is mixed at the source; + // translating its known phrases still leaves less English. + if (!VI_LETTER_RE.test(text) && untranslatedViWords(text).length > 0) { + return text; + } text = text.replace( /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{4})\b/gi, (_, m: string, y: string) => { From 166ef4bb93f47ff8f7021146192907dceb630a1e Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:49:01 +0700 Subject: [PATCH 09/69] fix(importer): tier 2 never links a storage-less title to a sized master (#1202) * fix(importer): tier 2 never links a storage-less title to a sized master With RATE_ITEM_MODEL off, match_master_product drops its storage filter for a NULL size, so "Laptop ASUS Zenbook S 14 UX5406AA-SU348W" scored above the accept threshold against the 1TB master. A Tier 2 hit whose model_key ends in a storage size is now skipped for a title with no storage, and the seed becomes a draft (#1105 option (a)). A ':unknown' master still takes the title; earbuds keep their legacy ':64gb' seeds. Flag-on matching is unchanged. Refs #1200 * fix(importer): watches keep their legacy sized seeds in tier 2 Review follow-up: "Apple Watch Series 10 42mm" has no only_storage_gb benchmark, so its title states no storage while the seed is keyed ':64gb'. Watches, like earbuds, are sold in one storage per model. Refs #1200 Co-Authored-By: Claude Opus 5.5 --- .../shopee/__tests__/entity-matcher.test.ts | 89 ++++++++++++++++++- apps/importer/src/shopee/entity-matcher.ts | 28 +++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts index f2c79b54f..8431ac98e 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -63,7 +63,12 @@ function createShopeeListing( interface MockDbOptions { exactMatch?: { id: string; name: string; model_key: string } | null; exactErr?: { message: string } | null; - fuzzyMatches?: Array<{ master_id: string; name: string; similarity: number }>; + fuzzyMatches?: Array<{ + master_id: string; + name: string; + similarity: number; + model_key?: string; + }>; fuzzyErr?: { message: string } | null; upsertRows?: Array<{ id: string }>; winnerRow?: { id: string } | null; @@ -379,6 +384,88 @@ async function runEntityMatcherSuite() { 'draft', 'Ambiguous Tier 2 similarity (0.65..0.69) must seed new Master Product as draft' ); + // DOS/Rate#1200 (#1105 option (a)): a title with no storage never links to a sized + // master; it seeds a draft. A ':unknown' master still takes it, and earbuds keep + // their legacy ':64gb' seed keys. + const zenbookBare = normalizer.normalize( + createShopeeListing('Laptop ASUS Zenbook S 14 UX5406AA-SU348W') + ); + assert.equal(zenbookBare.storage_gb, null); + const zenbook1tb = { + master_id: 'master-zenbook-1tb', + name: 'Asus Zenbook S 14 1TB', + similarity: 0.86, + model_key: 'asus:zenbook-s-14:1024gb', + }; + const storagelessDb = createMockSupabase({ fuzzyMatches: [zenbook1tb] }); + const storagelessRes = await new EntityMatcher( + storagelessDb.client, + offlineEnricher + ).resolveOrCreateMasterProduct(zenbookBare); + assert.equal(storagelessRes.isNew, true, 'a storage-less title must not link to the 1TB master'); + assert.notEqual(storagelessRes.masterId, 'master-zenbook-1tb'); + assert.equal(storagelessDb.capturedUpserts[0]?.payload.model_key, 'asus:zenbook-s-14:unknown'); + assert.equal(storagelessDb.capturedUpserts[0]?.payload.status, 'draft'); + + const unknownMasterRes = await new EntityMatcher( + createMockSupabase({ + fuzzyMatches: [ + zenbook1tb, + { + master_id: 'master-zenbook-unknown', + name: 'Asus Zenbook S 14', + similarity: 0.8, + model_key: 'asus:zenbook-s-14:unknown', + }, + ], + }).client, + offlineEnricher + ).resolveOrCreateMasterProduct(zenbookBare); + assert.deepEqual(unknownMasterRes, { masterId: 'master-zenbook-unknown', isNew: false }); + + // A title that states its storage matches a sized master as before. + const zenbookSized = normalizer.normalize( + createShopeeListing('Laptop ASUS Zenbook S 14 UX5406AA 32GB 1TB') + ); + const sizedRes = await new EntityMatcher( + createMockSupabase({ fuzzyMatches: [zenbook1tb] }).client, + offlineEnricher + ).resolveOrCreateMasterProduct(zenbookSized); + assert.deepEqual(sizedRes, { masterId: 'master-zenbook-1tb', isNew: false }); + + const earbuds = normalizer.normalize(createShopeeListing('Tai nghe Sony WF-1000XM5 chính hãng')); + assert.equal(earbuds.storage_gb, null); + const earbudsRes = await new EntityMatcher( + createMockSupabase({ + fuzzyMatches: [ + { + master_id: 'master-wf1000xm5', + name: 'Sony WF-1000XM5', + similarity: 0.9, + model_key: 'sony:wf-1000xm5:64gb', + }, + ], + }).client, + offlineEnricher + ).resolveOrCreateMasterProduct(earbuds); + assert.deepEqual(earbudsRes, { masterId: 'master-wf1000xm5', isNew: false }); + // Watches too: Series 10 has one storage and no only_storage_gb benchmark. + const watch = normalizer.normalize(createShopeeListing('Apple Watch Series 10 42mm GPS')); + assert.equal(watch.storage_gb, null); + const watchRes = await new EntityMatcher( + createMockSupabase({ + fuzzyMatches: [ + { + master_id: 'master-series-10-42', + name: 'Apple Watch Series 10 42mm', + similarity: 0.9, + model_key: 'apple:watch-series-10-42mm:64gb', + }, + ], + }).client, + offlineEnricher + ).resolveOrCreateMasterProduct(watch); + assert.deepEqual(watchRes, { masterId: 'master-series-10-42', isNew: false }); console.log(' ✅ [3/7] Tier 2 fuzzy match threshold & ambiguous draft fallback passed'); // 4. Tier 3 Creation Status: benchmark_registry (new) -> published; non-new or crawled_title -> draft diff --git a/apps/importer/src/shopee/entity-matcher.ts b/apps/importer/src/shopee/entity-matcher.ts index 6c8628b6c..476d4d759 100644 --- a/apps/importer/src/shopee/entity-matcher.ts +++ b/apps/importer/src/shopee/entity-matcher.ts @@ -48,6 +48,11 @@ function hasControlOrSpaceChar(val: string): boolean { return /[\p{Cc}\s]/u.test(val); } +/** True when a model_key's last segment is a storage size ("asus:zenbook-s-14:1024gb"). */ +function isStorageKeyed(modelKey: unknown): boolean { + return typeof modelKey === 'string' && /:\d+(?:gb|tb)$/i.test(modelKey); +} + export function sanitizeMatcherEntityId(raw: unknown, maxLen = 128): string { const str = typeof raw === 'number' && Number.isSafeInteger(raw) && raw > 0 @@ -513,7 +518,7 @@ export class EntityMatcher { throw new Error(`[EntityMatcher] Tier 2 fuzzy match failed: ${fuzzyErr.message}`); } let ambiguousFuzzy = false; - const validFuzzyMatches = Array.isArray(fuzzyMatches) + const scoredFuzzyMatches = Array.isArray(fuzzyMatches) ? fuzzyMatches.filter(m => { if (!m || typeof m !== 'object' || Array.isArray(m)) return false; const masterId = sanitizeMatcherEntityId(m.master_id, 128); @@ -522,6 +527,27 @@ export class EntityMatcher { return Number.isFinite(sim) && sim >= ENTITY_MATCH_CANDIDATE_FLOOR && sim <= 1; }) : []; + // A title that states no storage never links to a master keyed by a storage: the + // RPC drops its storage filter for a NULL size, so "Asus Zenbook S 14" would score + // high against "Asus Zenbook S 14 1TB". Such a hit only makes the seed a draft + // (DOS/Rate#1105 option (a), #1200). The item-first matcher (#1151) handles it with + // RATE_ITEM_MODEL on. Earbuds and watches are sold in one storage per model, so + // their legacy `:64gb` seed keys ("apple:watch-series-10-42mm:64gb") still take a + // size-less title. + const validFuzzyMatches = + candidate.storage_gb == null && + candidate.category_slug !== 'earbuds' && + candidate.category_slug !== 'smartwatches' + ? scoredFuzzyMatches.filter(m => !isStorageKeyed(m.model_key)) + : scoredFuzzyMatches; + if (validFuzzyMatches.length < scoredFuzzyMatches.length) { + ambiguousFuzzy = true; + logger.warn( + 'entity_match.tier2_storage_less', + `Tier 2 skipped ${scoredFuzzyMatches.length - validFuzzyMatches.length} sized master(s) for a title with no storage: seeding as draft if new.`, + { modelKey: candidate.model_key } + ); + } if (validFuzzyMatches.length > 0) { const topMatch = validFuzzyMatches[0]!; const topMasterId = sanitizeMatcherEntityId(topMatch.master_id, 128); From 57d88922e4778a61168f5f13e14ac6d12ebf3a07 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:52:28 +0700 Subject: [PATCH 10/69] fix(normalizer): read apple generation words, chips and part numbers (#1203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(normalizer): read apple generation words, chips and part numbers "thế hệ 3", "Gen 3", "3rd Gen" and Roman numerals (II-IV) after a line read as the generation number. A chip (Apple TV 4K A15/A12/A10X, AirPods H2, HomePod S7) or a part number (MN873, MTJV3, MU8F2, ...) fills the generation only when the title states none. Apple Pencil is its own line instead of the iPhone fallback, also before "cho iPad". Existing fixtures keep their keys; across 769 Apple strings in the repo only two Pencil description lines change key. Refs #1194 * fix(normalizer): apple generation review fixes - "(thế hệ thứ N)" and "(Gen N)" in parentheses read as the generation. - Only "Apple Pencil" is the Pencil line; a stylus saying "Pencil" is not. - H2 no longer names AirPods Pro 2: the Pro 3 has it too. - Chips and part numbers are read only from the product's own part of the title, before a "+" bundle or a "cho/tương thích" phrase. - A part number without "/A" ("MN873SA") is read too. Refs #1194 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 52 +++++++++ .../importer/src/shopee/product-normalizer.ts | 101 ++++++++++++++++-- 2 files changed, 147 insertions(+), 6 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index cf1d3d67d..8b52e6770 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -317,6 +317,58 @@ async function runSuite() { ['Apple HomePod (2nd generation)', 'apple:homepod-2:unknown'], ['Apple TV 4K 2022 128GB', 'apple:tv-4k-3:128gb'], ['Apple TV 4K (2022) 128GB', 'apple:tv-4k-3:128gb'], + // Generation words, Roman numerals, chips and part numbers name the generation too + // (DOS/Rate#1194). + ['Apple TV 4K thế hệ 3 Wi-Fi 64GB', 'apple:tv-4k-3:64gb'], + ['Apple TV 4K Gen 3 Wi-Fi + Ethernet 128GB', 'apple:tv-4k-3:128gb'], + ['Apple TV 4K 3rd Gen 128GB', 'apple:tv-4k-3:128gb'], + ['Apple TV 4K (A15) 64GB', 'apple:tv-4k-3:64gb'], + ['Apple TV 4K chip A15 Bionic 128GB', 'apple:tv-4k-3:128gb'], + ['Apple TV 4K MN873 64GB', 'apple:tv-4k-3:64gb'], + ['Apple TV 4K Wi-Fi 64GB MN873SA/A', 'apple:tv-4k-3:64gb'], + ['Apple MN873SA/A', 'apple:tv-4k-3:unknown'], + ['Apple TV 4K II 32GB', 'apple:tv-4k-2:32gb'], + ['Apple TV 4K (A12) 32GB', 'apple:tv-4k-2:32gb'], + ['Apple TV 4K 64GB', 'apple:tv-4k:64gb'], + // A stated generation wins over a chip or part number. + ['Apple TV 4K 2022 Wi-Fi 64GB MXH02', 'apple:tv-4k-3:64gb'], + ['AirPods Pro II', 'apple:airpods-pro-2:unknown'], + // H2 is in the AirPods Pro 2 and 3 alike: it names no Pro generation. + ['AirPods Pro (H2)', 'apple:airpods-pro:unknown'], + ['AirPods Pro MTJV3', 'apple:airpods-pro-2:unknown'], + ['Apple TV 4K MN873SA', 'apple:tv-4k-3:unknown'], + // A parenthesised generation word reads like a bare one. + ['AirPods (thế hệ thứ 3)', 'apple:airpods-3:unknown'], + ['Apple Watch Ultra (thế hệ thứ 2)', 'apple:watch-ultra-2:64gb'], + ['Apple Watch SE (thế hệ thứ 2) 40mm', 'apple:watch-se-2-40mm:unknown'], + ['Apple TV 4K (Gen 3) 64GB', 'apple:tv-4k-3:64gb'], + // A chip or part number of a bundled or fitted accessory names nothing. + [ + 'Tai nghe Apple AirPods Pro chip H1 + ốp silicon tương thích MTJV3', + 'apple:airpods-pro:unknown', + ], + ['Combo iPad Gen 10 + Apple Pencil 1 MQLY3', 'apple:ipad-10:unknown'], + // A stylus that does not say "Apple Pencil" is not one. + ['Bút cảm ứng Stylus Pencil Gen 2 cho iPad Gen 9', 'apple:ipad-9:unknown'], + ['AirPods (H2)', 'apple:airpods-4:unknown'], + ['AirPods thế hệ 3', 'apple:airpods-3:unknown'], + ['HomePod thế hệ 2', 'apple:homepod-2:unknown'], + ['HomePod 2nd Gen', 'apple:homepod-2:unknown'], + ['iPad thế hệ 10 64GB', 'apple:ipad-10:64gb'], + ['iPad Air thế hệ 5 64GB', 'apple:ipad-air-5:64gb'], + ['iPad mini thế hệ 6 64GB', 'apple:ipad-mini-6:64gb'], + ['iPhone SE thế hệ 3 64GB', 'apple:iphone-se-3:64gb'], + ['iPhone SE 3rd Gen 64GB', 'apple:iphone-se-3:64gb'], + ['Apple Watch SE thế hệ 2 40mm', 'apple:watch-se-2-40mm:unknown'], + ['Apple Watch Ultra II 49mm', 'apple:watch-ultra-2:64gb'], + // An Apple Pencil is its own line, never the iPhone fallback, nor the iPad it fits. + ['Apple Pencil 2', 'apple:pencil-2:unknown'], + ['Apple Pencil thế hệ 2', 'apple:pencil-2:unknown'], + ['Apple Pencil (2nd generation)', 'apple:pencil-2:unknown'], + ['Apple Pencil MU8F2ZA/A', 'apple:pencil-2:unknown'], + ['Apple Pencil 2 cho iPad Air M2', 'apple:pencil-2:unknown'], + ['Apple Pencil Pro', 'apple:pencil-pro:unknown'], + ['Apple Pencil (USB-C)', 'apple:pencil-usb-c:unknown'], ['Vision Pro 256GB', 'apple:vision-pro:256gb'], // A chip named for a bundled accessory is not the Air's. ['iPad Air 5 64GB + ốp lưng cho iPad Pro M4', 'apple:ipad-air-5:64gb'], diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index 531136a4d..11823b37f 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -223,6 +223,9 @@ const APPLE_FAMILIES: ReadonlyArray = [ [/\bimac\b/i, 'iMac'], [/homepod\s*mini/i, 'HomePod mini'], [/homepod/i, 'HomePod'], + // An Apple Pencil is never the iPhone a title with no line falls back to; a stylus that + // only says "Pencil" is not Apple's (DOS/Rate#1194). + [/apple\s*pencil/i, 'Pencil'], ]; const APPLE_GENERATION_BY_YEAR: Readonly> = { @@ -233,6 +236,51 @@ const APPLE_GENERATION_BY_YEAR: Readonly> = { 'HomePod 2023': '2', }; +/** "Apple TV 4K II" is the 2nd generation (DOS/Rate#1194). */ +const ROMAN_GENERATIONS: Readonly> = { ii: '2', iii: '3', iv: '4' }; + +/** + * A chip that names the generation of a line a title states without one: the Apple TV 4K + * (A15) is the 3rd (2022). Read only when the title names no generation (DOS/Rate#1194). + */ +const APPLE_CHIP_GENERATIONS: ReadonlyArray = [ + ['TV 4K', /\ba15\b/i, 'TV 4K 3'], + ['TV 4K', /\ba12\b/i, 'TV 4K 2'], + ['TV 4K', /\ba10x\b/i, 'TV 4K 1'], + ['AirPods', /\bh2\b/i, 'AirPods 4'], + ['HomePod', /\bs7\b/i, 'HomePod 2'], +]; + +/** + * Apple part numbers (the 5 characters before the region suffix, "MN873SA/A") that name + * a line's generation: [line without a generation, line with it] (DOS/Rate#1194). + */ +const APPLE_PART_NUMBERS: Readonly> = { + MQD22: ['TV 4K', 'TV 4K 1'], + MP7P2: ['TV 4K', 'TV 4K 1'], + MXGY2: ['TV 4K', 'TV 4K 2'], + MXH02: ['TV 4K', 'TV 4K 2'], + MN873: ['TV 4K', 'TV 4K 3'], + MN893: ['TV 4K', 'TV 4K 3'], + MV7N2: ['AirPods', 'AirPods 2'], + MRXJ2: ['AirPods', 'AirPods 2'], + MME73: ['AirPods', 'AirPods 3'], + MPNY3: ['AirPods', 'AirPods 3'], + MXP63: ['AirPods', 'AirPods 4'], + MXP93: ['AirPods', 'AirPods 4'], + MQD83: ['AirPods Pro', 'AirPods Pro 2'], + MTJV3: ['AirPods Pro', 'AirPods Pro 2'], + MQHV2: ['HomePod', 'HomePod 1'], + MQHW2: ['HomePod', 'HomePod 1'], + MQJ73: ['HomePod', 'HomePod 2'], + MQJ83: ['HomePod', 'HomePod 2'], + MK0C2: ['Pencil', 'Pencil 1'], + MQLY3: ['Pencil', 'Pencil 1'], + MU8F2: ['Pencil', 'Pencil 2'], + MUWA3: ['Pencil', 'Pencil USB-C'], + MX2D3: ['Pencil', 'Pencil Pro'], +}; + /** * Detect canonical category slug from product title across all 5 tech categories. */ @@ -1372,15 +1420,28 @@ export class ProductNormalizer { // "Watch Ultra 2", "AirPods Pro (2nd generation)" is "AirPods Pro 2" (DOS/Rate#817). // One optional space before the "(" keeps a single space in the name; a leading // `\s*` would be quadratic on long whitespace (CodeQL js/polynomial-redos). - let appleTitle = modelTitle.replace( - / ?\(\s*(\d{1,2})(?:st|nd|rd|th)\s*gen(?:eration)?\s*\)/gi, - ' $1' - ); + let appleTitle = modelTitle + .replace(/ ?\(\s*(\d{1,2})(?:st|nd|rd|th)\s*gen(?:eration)?\s*\)/gi, ' $1') + // Generation words read as the number: "Apple TV 4K thế hệ 3", "Gen 3", "3rd Gen" + // and "II" are the 3rd and 2nd generation, in parentheses too: "AirPods (thế hệ + // thứ 3)" is the "AirPods 3" (DOS/Rate#1194). + .replace(/ ?\(\s*(?:thế\s*hệ\s*(?:thứ\s*)?|gen(?:eration)?\s*)(\d{1,2})\s*\)/gi, ' $1') + .replace(/\bthế\s*hệ\s*(?:thứ\s*)?(\d{1,2})\b/gi, '$1') + .replace(/\b(\d{1,2})(?:st|nd|rd|th)\s*gen(?:eration)?\b/gi, '$1') + .replace(/\bgen(?:eration)?\s*(\d{1,2})\b/gi, '$1') + .replace( + /\b(pro|ultra|se|4k|homepod|airpods|pencil|max|mini|air) (ii|iii|iv)\b/gi, + (_: string, line: string, roman: string) => + `${line} ${ROMAN_GENERATIONS[roman.toLowerCase()]}` + ); // An iPhone or iPad named after another Apple product as what it fits is not the // product: "AirPods Pro 2 cho iPhone Air" is the AirPods (DOS/Rate#819). const fitsAt = appleTitle.search(/ (?:cho|for) (?:iphone|ipad)\b/i); // Only another family counts: "Ốp lưng iPhone cho iPhone 15" is still the iPhone 15. - if (fitsAt > 0 && /airpods|\bwatch\b|macbook/i.test(appleTitle.slice(0, fitsAt))) + if ( + fitsAt > 0 && + /airpods|\bwatch\b|macbook|apple\s*pencil/i.test(appleTitle.slice(0, fitsAt)) + ) appleTitle = appleTitle.slice(0, fitsAt); const iphoneMatch = appleTitle.match( /iphone\s*(\d+e\b|\d+(?:\s*(?:pro\s*max|pro|plus|mini|air)|\+(?= ?(?:$|[-,]|\d+(?:[.,]\d+)? ?(?:gb|tb)\b|[45]g\b|lte\b|chính\b)))?|air\b|duo\b|se(?:\s*(?:\(\s*(?:2020|2022|2025)\s*\)|(?:2020|2022|2025)\b|(?:gen\s*)?[234]\b))?)/i @@ -1404,6 +1465,8 @@ export class ProductNormalizer { /airpods\s*(?:(pro)(?:\s*(?:(?:gen|thế\s*hệ)\s*|\(\s*)?(\d+))?|(max)|(?:(?:gen|thế\s*hệ)\s*)?(\d+))/i ); + // True when no Apple line is named and the model fell back to the iPhone. + let noLine = false; // Retail titles often put the chip after the screen size ("iPad Pro 11 inch M4", // "MacBook Air 13 inch M3"); pick it up wherever it sits. const chipMatch = modelTitle.match(/\b(M\d)(?:\s+(Pro|Max))?\b/i); @@ -1521,11 +1584,37 @@ export class ProductNormalizer { if (/^(?:Mac mini|Mac Studio|iMac)$/.test(model) && chip) model = `${model} ${chip}${chipMatch?.[2] ? ` ${chipMatch[2]}` : ''}`; else if ( - /^(?:TV|TV 4K|HomePod|HomePod mini|Mac mini|Mac Studio|iMac)$/.test(model) && + /^(?:TV|TV 4K|HomePod|HomePod mini|Mac mini|Mac Studio|iMac|Pencil)$/.test(model) && generation ) // Retailers name an Apple TV 4K or HomePod by its year as often as by its generation. model = `${model} ${APPLE_GENERATION_BY_YEAR[`${model} ${generation[1]}`] ?? generation[1]}`; + else if (model === 'Pencil') { + const pencil = appleTitle.slice(familyEnd).match(/^ ?\(? ?(pro|usb-?c)\b/i); + if (pencil) model = /^pro$/i.test(pencil[1]!) ? 'Pencil Pro' : 'Pencil USB-C'; + } + noLine = first === Infinity; + } + // A part number or chip names the generation a title leaves out: "Apple TV 4K + // MN873SA/A" and "Apple TV 4K (A15)" are the 3rd (2022). A part number also names a + // title with no line. A stated generation always wins, and only the product's own + // part of the title counts, before a "+" bundle or what it fits (DOS/Rate#1194). + const ownTitle = appleTitle.split(/ ?\+ | (?:cho|for|tương thích|dùng cho|kèm|tặng) /i)[0]!; + const part = Array.from( + ownTitle.matchAll(/\b(m[a-z0-9]{4})(?:[a-z]{1,2}(?:\/a)?)?\b/gi), + m => { + const partNumber = m[1]!.toUpperCase(); + return Object.hasOwn(APPLE_PART_NUMBERS, partNumber) + ? APPLE_PART_NUMBERS[partNumber] + : undefined; + } + ).find(Boolean); + if (part && (model === part[0] || noLine)) model = part[1]; + else { + const chipGeneration = APPLE_CHIP_GENERATIONS.find( + ([line, chipPattern]) => model === line && chipPattern.test(ownTitle) + ); + if (chipGeneration) model = chipGeneration[2]; } // A line Apple sells in more than one size keys each size apart: the 11 and 13 inch // iPad Pro, or the 42 and 46mm Watch Series, are two products at two prices. A title From f3718610a450506e4a1f3ff6c13f583494d5e486 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:59:03 +0700 Subject: [PATCH 11/69] feat(web): sale state for published models with no in-stock listing (#1204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): sale state for published models with no in-stock listing A published model with no in-stock listing showed "0 ₫" and "0 sources" on its card and no reason on its page. masterSaleState derives on_sale, sold_out, discontinued, coming_soon, not_listed or no_stock from data the site already has: the in-stock price, the linked listings (product page), the launch status in the specs and an optional curated metadata.vn_sale_state. Cards and the product page show the state, and API v1 list, detail and compare bodies carry sale_state. No data write. Refs #1188 * fix(web): sale state never says sold out beside an in-stock offer Review of the previous commit: the stored price counts crawled, priced in-stock listings only, so a model whose only in-stock listing is extension-made or unpriced read "sold out" above that offer. The listing count is now unknown while any listing is in stock. The detail API uses the same count as the page, OpenAPI documents sale_state on detail and compare, and free-text "Announced ..." no longer reads as coming soon. Refs #1188 Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 12 ++ apps/web/locales/en.json | 12 ++ apps/web/locales/vi.json | 12 ++ apps/web/package.json | 2 +- apps/web/src/app/[locale]/page.tsx | 2 + .../app/[locale]/smartphones/[slug]/page.tsx | 33 ++++- .../web/src/app/[locale]/smartphones/page.tsx | 2 + .../api/v1/rate/__tests__/spec-scope.test.ts | 2 + apps/web/src/app/api/v1/rate/compare/route.ts | 2 + .../app/api/v1/rate/products/[slug]/route.ts | 13 +- .../web/src/app/api/v1/rate/products/route.ts | 2 + .../src/components/product/ProductCard.tsx | 35 ++++- apps/web/src/lib/__tests__/sale-state.test.ts | 129 ++++++++++++++++++ apps/web/src/lib/sale-state.ts | 127 +++++++++++++++++ .../__tests__/core-rate-architecture.test.ts | 2 +- apps/web/src/server/rate/openapi.ts | 41 +++++- 16 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/lib/__tests__/sale-state.test.ts create mode 100644 apps/web/src/lib/sale-state.ts diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 346446ed6..57c35b581 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1879,5 +1879,17 @@ "summaryNoAuthentic": "Po odfiltrování {removed} spamových nebo odměnových komentářů zatím žádné skutečné recenze.", "proPositiveFeedback": "Pozitivní zpětná vazba od skutečných uživatelů", "originalVietnamese": "vietnamsky" + }, + "saleState": { + "soldOut": "Všude vyprodáno", + "soldOutHint": "Všechny sledované obchody jsou vyprodané.", + "discontinued": "Ve Vietnamu se již neprodává", + "discontinuedHint": "Obchody ve Vietnamu tento model už nově neprodávají.", + "comingSoon": "Brzy ve Vietnamu", + "comingSoonHint": "Tento model se ve Vietnamu oficiálně zatím neprodává.", + "notListed": "Ve Vietnamu zatím bez prodejce", + "notListedHint": "Rate zatím nenašel obchod ve Vietnamu, který tento model prodává.", + "noStock": "Nikde skladem", + "noStockHint": "Žádný obchod ho nemá skladem." } } diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 500281008..c7785fc85 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1879,5 +1879,17 @@ "summaryNoAuthentic": "No genuine reviews yet after filtering {removed} spam or coin-farming comments.", "proPositiveFeedback": "Positive feedback from real users", "originalVietnamese": "in Vietnamese" + }, + "saleState": { + "soldOut": "Out of stock everywhere", + "soldOutHint": "Every store we track is out of stock.", + "discontinued": "Discontinued in Vietnam", + "discontinuedHint": "Stores in Vietnam no longer sell this model new.", + "comingSoon": "Coming to Vietnam", + "comingSoonHint": "This model is not officially sold in Vietnam yet.", + "notListed": "No store in Vietnam yet", + "notListedHint": "Rate has not found a store in Vietnam selling this model.", + "noStock": "Not in stock anywhere", + "noStockHint": "No store has it in stock." } } diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 178141b08..126165ce6 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -1879,5 +1879,17 @@ "summaryNoAuthentic": "Chưa có đánh giá thực chất sau khi lọc {removed} bình luận rác/nhận xu.", "proPositiveFeedback": "Nhận phản hồi tích cực từ người dùng thực tế", "originalVietnamese": "tiếng Việt" + }, + "saleState": { + "soldOut": "Tạm hết hàng ở mọi nơi bán", + "soldOutHint": "Các nơi bán đã ghi nhận hiện đều hết hàng.", + "discontinued": "Ngừng kinh doanh tại Việt Nam", + "discontinuedHint": "Mẫu này không còn được bán mới tại các nơi bán ở Việt Nam.", + "comingSoon": "Sắp về Việt Nam", + "comingSoonHint": "Mẫu này chưa được bán chính hãng tại Việt Nam.", + "notListed": "Chưa có nơi bán tại Việt Nam", + "notListedHint": "Rate chưa ghi nhận nơi bán nào tại Việt Nam cho mẫu này.", + "noStock": "Chưa có nơi bán còn hàng", + "noStockHint": "Không có nơi bán nào đang còn hàng." } } diff --git a/apps/web/package.json b/apps/web/package.json index 68ae5ccc9..86ddd55d6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 26f429d7b..8f33ccdc9 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -42,6 +42,7 @@ import CompareTrayBar from '@/components/compare/CompareTrayBar'; import ProductCard, { type ProductCardSpec, } from '@/components/product/ProductCard'; +import { saleStateOfMaster } from '@/lib/sale-state'; import { buildRadarIndex, getRadarSignal } from '@/lib/deal-radar'; import { isPendingReview } from '@/lib/review-display'; import { @@ -536,6 +537,7 @@ export default async function Home({ minPrice={Number(item.min_price || 0)} maxPrice={Number(item.max_price || 0)} merchantCount={item.merchant_count || 0} + saleState={saleStateOfMaster(item)} />
); diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index e9b67c671..0404887dc 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -63,6 +63,11 @@ import { import { contentLang } from '@/lib/content-lang'; import ContentText from '@/components/elementary/ContentText'; import { buildProductSpecGroups } from '@/lib/product-spec-rows'; +import { + listingCountForSaleState, + masterSaleState, + saleStateLabelKey, +} from '@/lib/sale-state'; import { computeProductStandingInCategory, scoreMasterProductForLeaderboard, @@ -171,9 +176,10 @@ export async function ProductDetailView({ }) { const { locale, slug } = await params; setRequestLocale(locale); - const [t, tNav, storedProduct] = await Promise.all([ + const [t, tNav, tSale, storedProduct] = await Promise.all([ getTranslations({ locale, namespace: 'productDetail' }), getTranslations({ locale, namespace: 'rate.nav' }), + getTranslations({ locale, namespace: 'saleState' }), getCachedMasterProductBySlug(slug), ]); @@ -240,6 +246,15 @@ export async function ProductDetailView({ const keySpecs = product.key_specs as KeySpecs; const minPriceFormatted = currentMinPrice.toLocaleString('vi-VN'); const sellerCount = marketStats.sellerCount; + // Why there is no price, when there is none (DOS/Rate#1188). + const saleState = masterSaleState({ + minPrice: currentMinPrice, + listingCount: listingCountForSaleState(offers.length, inStockOffers.length), + metadata: product.metadata, + availabilityStatus: specs?.availability?.status, + }); + const saleStateKey = + saleState === 'on_sale' ? null : saleStateLabelKey(saleState); const maxPriceFormatted = marketStats.maxPrice.toLocaleString('vi-VN'); const authenticCount = @@ -873,9 +888,15 @@ export async function ProductDetailView({ {t('priceReferenceLabel', { count: sellerCount })}
- {currentMinPrice > 0 - ? `${minPriceFormatted} ₫` - : t('priceNoneInStock')} + {currentMinPrice > 0 ? ( + `${minPriceFormatted} ₫` + ) : saleStateKey ? ( + + {tSale(saleStateKey)} + + ) : ( + t('priceNoneInStock') + )} {currentMinPrice > 0 && currentMinPrice !== marketStats.maxPrice && ( @@ -1070,7 +1091,9 @@ export async function ProductDetailView({
{offers.length === 0 ? ( -

{t('noOffersYet')}

+

+ {saleStateKey ? tSale(`${saleStateKey}Hint`) : t('noOffersYet')} +

) : ( diff --git a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts index cc51cddce..541b5331f 100644 --- a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts +++ b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts @@ -147,6 +147,8 @@ async function testProductFilters() { assert.equal(body.data[0].ram_gb, 1); assert.equal(body.data[0].has_5g, false); assert.equal(body.data[0].specifications.cellular.technology, 'LTE'); + // In stock, so on sale (DOS/Rate#1188). + assert.equal(body.data[0].sale_state, 'on_sale'); catalog = [EARBUD]; ({ status, body } = await listProducts('category=earbuds&battery=400')); diff --git a/apps/web/src/app/api/v1/rate/compare/route.ts b/apps/web/src/app/api/v1/rate/compare/route.ts index 7472de454..3ec19a0ef 100644 --- a/apps/web/src/app/api/v1/rate/compare/route.ts +++ b/apps/web/src/app/api/v1/rate/compare/route.ts @@ -8,6 +8,7 @@ import { import { computeProductRadarSignal } from '@/server/rate/deals'; import { radarExportFields } from '@/lib/deal-radar'; import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; +import { saleStateOfMaster } from '@/lib/sale-state'; import { PUBLIC_API_CORS_HEADERS, jsonApiError, @@ -66,6 +67,7 @@ export async function GET(request: NextRequest) { ...applyCategorySpecTemplate(lockedCategory, p), canonical_href: getMasterProductHref(p), deal_radar: radarExportFields(computeProductRadarSignal(p, offers)), + sale_state: saleStateOfMaster(p), }; }), }, diff --git a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts index c51fbed5a..5c2fb13c4 100644 --- a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts +++ b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts @@ -10,6 +10,7 @@ import { computeProductRadarSignal } from '@/server/rate/deals'; import { isOfferUnavailable } from '@/lib/product-market-stats'; import { getMasterProductCategorySlug } from '@/lib/rate-domain'; import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; +import { listingCountForSaleState, saleStateOfMaster } from '@/lib/sale-state'; import { PUBLIC_API_CORS_HEADERS, jsonApiError, @@ -84,7 +85,17 @@ export async function GET(_request: NextRequest, context: RouteContext) { { ok: true, data: { - product, + // Why there is no price, when there is none (DOS/Rate#1188). + product: { + ...product, + sale_state: saleStateOfMaster( + product, + listingCountForSaleState( + offers.length, + offers.filter((o) => !isOfferUnavailable(o)).length + ) + ), + }, offers, priceHistory, reviewSummary, diff --git a/apps/web/src/app/api/v1/rate/products/route.ts b/apps/web/src/app/api/v1/rate/products/route.ts index 4cac0a62e..c231e557e 100644 --- a/apps/web/src/app/api/v1/rate/products/route.ts +++ b/apps/web/src/app/api/v1/rate/products/route.ts @@ -9,6 +9,7 @@ import { computeProductRadarSignal } from '@/server/rate/deals'; import { radarExportFields } from '@/lib/deal-radar'; import { getMasterProductCategorySlug } from '@/lib/rate-domain'; import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; +import { saleStateOfMaster } from '@/lib/sale-state'; import { PUBLIC_API_CORS_HEADERS, jsonApiError, @@ -206,6 +207,7 @@ export async function GET(request: NextRequest) { return { ...applyCategorySpecTemplate(getMasterProductCategorySlug(p), p), deal_radar: radarExportFields(computeProductRadarSignal(p, offers)), + sale_state: saleStateOfMaster(p), }; }), ...(facets ? { facets } : {}), diff --git a/apps/web/src/components/product/ProductCard.tsx b/apps/web/src/components/product/ProductCard.tsx index b78fa2068..8edd80175 100644 --- a/apps/web/src/components/product/ProductCard.tsx +++ b/apps/web/src/components/product/ProductCard.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useLocale, useTranslations } from 'next-intl'; import { BsGraphUpArrow, BsPatchCheckFill } from 'react-icons/bs'; +import { saleStateLabelKey, type SaleState } from '@/lib/sale-state'; import { Link } from '@/lib/navigation'; import CompareQuickToggle from '@/components/compare/CompareQuickToggle'; import RadarBadge from '@/components/radar/RadarBadge'; @@ -33,6 +34,11 @@ export interface ProductCardProps { readonly minPrice: number; readonly maxPrice: number; readonly merchantCount: number; + /** + * Shown instead of the price when no store has it in stock + * (DOS/Rate#1188); defaults to on sale. + */ + readonly saleState?: SaleState; readonly verified?: boolean; } @@ -92,9 +98,11 @@ export default function ProductCard({ minPrice, maxPrice, merchantCount, + saleState = 'on_sale', verified = false, }: ProductCardProps) { const t = useTranslations('productCard'); + const tSale = useTranslations('saleState'); const locale = useLocale(); const safeMinPrice = sanitizeProductCardPriceVnd(minPrice); const safeMaxPrice = sanitizeProductCardPriceVnd(maxPrice); @@ -197,10 +205,25 @@ export default function ProductCard({
-
{t('refPrice')}
-
- {formatProductCardVnd(safeMinPrice)} -
+ {saleState === 'on_sale' && safeMinPrice > 0 ? ( + <> +
{t('refPrice')}
+
+ {formatProductCardVnd(safeMinPrice)} +
+ + ) : ( +
+ {tSale( + saleStateLabelKey( + saleState === 'on_sale' ? 'no_stock' : saleState + ) + )} +
+ )}
{hasRange && ( @@ -210,7 +233,9 @@ export default function ProductCard({ })}
)} -
{t('sources', { count: safeMerchantCount })}
+ {safeMerchantCount > 0 && ( +
{t('sources', { count: safeMerchantCount })}
+ )}
diff --git a/apps/web/src/lib/__tests__/sale-state.test.ts b/apps/web/src/lib/__tests__/sale-state.test.ts new file mode 100644 index 000000000..f68438fe0 --- /dev/null +++ b/apps/web/src/lib/__tests__/sale-state.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + SALE_STATES, + listingCountForSaleState, + masterSaleState, + saleStateLabelKey, + saleStateOfMaster, +} from '../sale-state'; + +function main() { + console.warn('🧪 Sale state suite (DOS/Rate#1188)'); + + // An in-stock price wins over everything else. + assert.equal( + masterSaleState({ + minPrice: 20_990_000, + listingCount: 3, + metadata: { vn_sale_state: 'discontinued' }, + availabilityStatus: 'Discontinued', + }), + 'on_sale' + ); + + // No in-stock price: the curated state, then sold out, then the launch + // status, then not listed (known) or no stock (unknown). + assert.equal( + masterSaleState({ + minPrice: 0, + listingCount: 2, + metadata: { vn_sale_state: 'discontinued' }, + }), + 'discontinued' + ); + assert.equal( + masterSaleState({ + minPrice: null, + listingCount: 0, + metadata: { vn_sale_state: 'coming_soon' }, + availabilityStatus: 'Available. Released 2026, September 23', + }), + 'coming_soon' + ); + // An unknown curated value is ignored. + assert.equal( + masterSaleState({ + minPrice: 0, + listingCount: 2, + metadata: { vn_sale_state: 'on_sale' }, + }), + 'sold_out' + ); + assert.equal( + masterSaleState({ + minPrice: 0, + listingCount: 0, + availabilityStatus: 'Discontinued', + }), + 'discontinued' + ); + assert.equal( + masterSaleState({ + minPrice: 0, + listingCount: 0, + availabilityStatus: 'Coming soon. Exp. release 2026, Q4', + }), + 'coming_soon' + ); + assert.equal( + masterSaleState({ + minPrice: 0, + listingCount: 0, + availabilityStatus: 'Available. Released 2025, October', + }), + 'not_listed' + ); + assert.equal(masterSaleState({ minPrice: 0 }), 'no_stock'); + + // Free-text "Announced" is not a launch to come. + assert.equal( + masterSaleState({ + minPrice: 0, + listingCount: 0, + availabilityStatus: 'Announced 2024, available', + }), + 'not_listed' + ); + // An in-stock listing outside the stored price leaves the count unknown, + // so the page never says "sold out" above an in-stock offer. + assert.equal(listingCountForSaleState(2, 1), undefined); + assert.equal(listingCountForSaleState(2, 0), 2); + assert.equal( + saleStateOfMaster({ min_price: null }, listingCountForSaleState(2, 1)), + 'no_stock' + ); + assert.equal( + saleStateOfMaster({ min_price: null }, listingCountForSaleState(2, 0)), + 'sold_out' + ); + // A master row has no count of sold-out listings. + assert.equal(saleStateOfMaster({ min_price: 0 }), 'no_stock'); + assert.equal(saleStateOfMaster({ min_price: 9_990_000 }), 'on_sale'); + assert.equal( + saleStateOfMaster({ + min_price: null, + specifications: { availability: { status: 'Discontinued' } }, + }), + 'discontinued' + ); + + // Every state but on_sale has a label and a hint in every locale. + for (const locale of ['vi', 'en', 'cs']) { + const messages = JSON.parse( + readFileSync( + new URL(`../../../locales/${locale}.json`, import.meta.url), + 'utf8' + ) + ) as { saleState: Record }; + for (const state of SALE_STATES) { + if (state === 'on_sale') continue; + const key = saleStateLabelKey(state); + assert.ok(messages.saleState[key], `${locale} ${key}`); + assert.ok(messages.saleState[`${key}Hint`], `${locale} ${key}Hint`); + } + } + console.warn('✅ Sale state suite passed'); +} + +main(); diff --git a/apps/web/src/lib/sale-state.ts b/apps/web/src/lib/sale-state.ts new file mode 100644 index 000000000..99ca09e5d --- /dev/null +++ b/apps/web/src/lib/sale-state.ts @@ -0,0 +1,127 @@ +/** + * Whether a published model can be bought in Vietnam now, from data the site + * already has (DOS/Rate#1188). A model with no in-stock listing used to show + * "0 ₫" on its card and no reason on its page. + */ +export type SaleState = + | 'on_sale' + | 'sold_out' + | 'discontinued' + | 'coming_soon' + | 'not_listed' + | 'no_stock'; + +export const SALE_STATES: readonly SaleState[] = [ + 'on_sale', + 'sold_out', + 'discontinued', + 'coming_soon', + 'not_listed', + 'no_stock', +]; + +/** Values JOY can set in `master_products.metadata.vn_sale_state`. */ +const CURATED_STATES = new Set(['discontinued', 'coming_soon']); + +// GSMArena-style launch status, e.g. "Discontinued" or "Coming soon. Exp. +// release 2026, Q4". +const DISCONTINUED_STATUS_RE = /\b(?:discontinued|cancell?ed)\b/i; +// "Announced" is left out: free-text statuses say "Announced 2024, +// available" for models long on sale. +const COMING_STATUS_RE = /\b(?:coming soon|rumou?red)\b/i; + +export interface SaleStateInput { + /** The lowest plausible in-stock price; 0 or less when none. */ + minPrice: number | null | undefined; + /** + * Linked listings, in stock or not; undefined when unknown, as for a + * master row, whose `merchant_count` counts in-stock stores only. + */ + listingCount?: number; + metadata?: unknown; + /** `specifications.availability.status`. */ + availabilityStatus?: unknown; +} + +const curatedState = (metadata: unknown): SaleState | null => { + if (!metadata || typeof metadata !== 'object') return null; + const value = (metadata as Record).vn_sale_state; + return typeof value === 'string' && CURATED_STATES.has(value as SaleState) + ? (value as SaleState) + : null; +}; + +/** + * In order: + * - `on_sale`: an in-stock price exists. + * - the curated `metadata.vn_sale_state` (`discontinued`, `coming_soon`). + * - `sold_out`: stores list it, none has stock. + * - the launch status: `discontinued`, `coming_soon`. + * - `not_listed` when no store lists it, `no_stock` when that is unknown. + */ +export function masterSaleState(input: SaleStateInput): SaleState { + const minPrice = Number(input.minPrice ?? 0); + if (Number.isFinite(minPrice) && minPrice > 0) return 'on_sale'; + const curated = curatedState(input.metadata); + if (curated) return curated; + if ((input.listingCount ?? 0) > 0) return 'sold_out'; + const status = + typeof input.availabilityStatus === 'string' + ? input.availabilityStatus + : ''; + if (DISCONTINUED_STATUS_RE.test(status)) return 'discontinued'; + if (COMING_STATUS_RE.test(status)) return 'coming_soon'; + return input.listingCount === 0 ? 'not_listed' : 'no_stock'; +} + +/** The `saleState` locale key for a state other than `on_sale`. */ +export function saleStateLabelKey( + state: Exclude +): 'soldOut' | 'discontinued' | 'comingSoon' | 'notListed' | 'noStock' { + switch (state) { + case 'sold_out': + return 'soldOut'; + case 'discontinued': + return 'discontinued'; + case 'coming_soon': + return 'comingSoon'; + case 'not_listed': + return 'notListed'; + default: + return 'noStock'; + } +} + +/** + * The listing count `masterSaleState` may rely on: unknown while some listing + * is in stock, since the stored price counts crawled, priced listings only and + * "sold out" would then contradict the offer table. + */ +export function listingCountForSaleState( + listings: number, + inStockListings: number +): number | undefined { + return inStockListings > 0 ? undefined : listings; +} + +/** + * `masterSaleState` read from a master product row, which carries no count of + * sold-out listings; pass `listingCount` when the listings are at hand. + */ +export function saleStateOfMaster( + master: { + min_price?: number | null; + metadata?: unknown; + specifications?: unknown; + }, + listingCount?: number +): SaleState { + const specs = master.specifications as + { availability?: { status?: unknown } } | null | undefined; + return masterSaleState({ + minPrice: master.min_price, + listingCount, + metadata: master.metadata, + availabilityStatus: specs?.availability?.status, + }); +} diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 1d600c03d..a4a48eb78 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -7564,7 +7564,7 @@ async function runCoreArchitectureSuite() { // keep their capitals; two or more other capitalised words in a row mean // Title Case. const names = - /Rate\.vn|Rate Lab|Rate Cloud|DOS ID|DOSafe|JReviews|Markdown|OpenAPI|Next\.js|PostgreSQL|Supabase|Authorization: Bearer|TikTok Shop|Official Mall|Apple Watch|Galaxy Watch|Galaxy Buds|Galaxy Tab|Xiaomi Watch|Xiaomi Pad|Huawei Watch|AirPods Pro|Hoang Ha Mobile|Hoang Ha|Di Dong Viet|The Gioi Di Dong|Phong Vu|Natural Titanium|Alex Nguyen|Shopee|Lazada|Tiki|Apple|iPhone|Pro|Max|Samsung|Galaxy|Xiaomi|Google|Pixel|Huawei|Garmin|Sony|Bose|Sennheiser|MacBook|iPad|Rate\b|Bayesian \w+|Helpful|Report|[A-Z]{2,}\w*|\w+-[A-Z]\w*/g; + /Rate\.vn|Rate Lab|Rate Cloud|DOS ID|DOSafe|JReviews|Markdown|OpenAPI|Next\.js|PostgreSQL|Supabase|Authorization: Bearer|TikTok Shop|Official Mall|Apple Watch|Galaxy Watch|Galaxy Buds|Galaxy Tab|Xiaomi Watch|Xiaomi Pad|Huawei Watch|AirPods Pro|Hoang Ha Mobile|Hoang Ha|Di Dong Viet|The Gioi Di Dong|Phong Vu|Natural Titanium|Alex Nguyen|Vietnam|Shopee|Lazada|Tiki|Apple|iPhone|Pro|Max|Samsung|Galaxy|Xiaomi|Google|Pixel|Huawei|Garmin|Sony|Bose|Sennheiser|MacBook|iPad|Rate\b|Bayesian \w+|Helpful|Report|[A-Z]{2,}\w*|\w+-[A-Z]\w*/g; const strip = (s: string) => s.replace(/\{[^}]+\}/g, '').replace(names, ''); const titleCase = /\b[A-Z][a-z]+\b(\s+(&|and|of|to|for|in|the|a|on|with|by|or|from|vs|at)?\s*\b[A-Z][a-z]+\b)+/; diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index c97ae5179..bdfa3d006 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -356,6 +356,9 @@ export function buildRateOpenApiSpec( radar_signal: { $ref: '#/components/schemas/ProductDealRadarSignal', }, + sale_state: { + $ref: '#/components/schemas/ProductSaleState', + }, }, }, }, @@ -402,6 +405,19 @@ export function buildRateOpenApiSpec( radar_signal: { $ref: '#/components/schemas/ProductDealRadarSignal', }, + data: { + type: 'object', + properties: { + product: { + type: 'object', + properties: { + sale_state: { + $ref: '#/components/schemas/ProductSaleState', + }, + }, + }, + }, + }, }, }, }, @@ -441,7 +457,17 @@ export function buildRateOpenApiSpec( type: 'object', properties: { ok: { type: 'boolean', example: true }, - data: { type: 'array', items: { type: 'object' } }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + sale_state: { + $ref: '#/components/schemas/ProductSaleState', + }, + }, + }, + }, deal_radar: { type: 'object', additionalProperties: { @@ -1641,6 +1667,19 @@ export function buildRateOpenApiSpec( }, }, }, + ProductSaleState: { + type: 'string', + enum: [ + 'on_sale', + 'sold_out', + 'discontinued', + 'coming_soon', + 'not_listed', + 'no_stock', + ], + description: + 'Whether the model can be bought in Vietnam now: `on_sale` (an in-stock price), `discontinued` / `coming_soon` (curated, or from the launch status when no store has stock), `sold_out` (stores list it, none in stock), `not_listed` (no store lists it), `no_stock` (no in-stock store; whether any store lists it is not known, as in list bodies).', + }, ProductDealRadarSignal: { type: 'object', required: ['verdict', 'savingsPct', 'fakeMarkupPct', 'spreadPct'], From a215d0fabfeed030c1b33077ed17e557fca66f73 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:00:09 +0700 Subject: [PATCH 12/69] fix(normalizer): a supported app or service sets no brand or model (#1205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(normalizer): a supported app or service sets no brand or model "Smart Tivi Samsung ... hỗ trợ Apple TV" keyed apple:tv because the brand chain read "apple" first. Mentions of an Apple app or service after "hỗ trợ / tích hợp / ứng dụng / kết nối / tương thích / supports / works with" are dropped before brand and model detection, and in a television title ("tivi", "smart tv") any Apple TV / AirPlay / HomeKit mention is. An iMac with a bundled Magic Keyboard already keys as the iMac; fixtures pin it. Refs #1195 * fix(normalizer): service mention review fixes - "Apple TV+" drops with its "+", so "iPhone 16 hỗ trợ Apple TV+" stays the iPhone 16, not a Plus. - A television title drops every Apple TV mention only when it names another TV brand: "Apple TV 4K - Smart TV Box" stays the Apple TV. - A colon after the lead word ("hỗ trợ: AirPlay 2") is read too. Refs #1195 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 31 +++++++ .../importer/src/shopee/product-normalizer.ts | 85 +++++++++++++------ 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 8b52e6770..7497b0d82 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -312,6 +312,37 @@ async function runSuite() { ['Apple iMac M4 24 inch 256GB', 'apple:imac-m4:256gb'], ['Mac Studio M4 Max 512GB', 'apple:mac-studio-m4-max:512gb'], ['Loa Apple HomePod mini', 'apple:homepod-mini:unknown'], + // An app the product supports sets no brand: a TV naming "Apple TV" is the TV's + // brand, never `apple:tv`; an Apple product naming Siri stays itself (DOS/Rate#1195). + [ + 'Smart Tivi Samsung 4K 55 inch UA55DU7000 hỗ trợ Apple TV', + 'samsung:smart-tivi-samsung-4k:unknown', + ], + [ + 'Smart Tivi Samsung QLED 4K 65 inch QA65Q60D tích hợp Apple TV, AirPlay 2', + 'samsung:smart-tivi-samsung-qled:unknown', + ], + [ + 'Google Tivi Sony 4K 55 inch K-55S30 có ứng dụng Apple TV', + 'sony:google-tivi-sony-4k:unknown', + ], + [ + 'Smart Tivi Samsung 55 inch Apple TV Netflix YouTube', + 'samsung:smart-tivi-samsung-55:unknown', + ], + ['Tivi LG OLED 55 inch hỗ trợ Apple AirPlay', 'khac:tivi-lg:unknown'], + ['Apple TV 4K hỗ trợ Siri 64GB', 'apple:tv-4k:64gb'], + ['Loa Apple HomePod mini hỗ trợ Apple Music', 'apple:homepod-mini:unknown'], + ['iPad Air M2 tặng Apple TV+', 'apple:ipad-air-m2:unknown'], + // The "+" of "Apple TV+" goes with it, never a Plus model. + ['iPhone 16 hỗ trợ Apple TV+ 128GB', 'apple:iphone-16:128gb'], + ['Loa Samsung HW-Q990D hỗ trợ: Apple AirPlay 2', 'samsung:loa-samsung-hw-q990d:unknown'], + // An Apple TV box sold as a "Smart TV" box names no other TV brand and stays the box. + ['Apple TV 4K 64GB - Smart TV Box with Siri Remote', 'apple:tv-4k:64gb'], + ['Smart TV Apple TV 4K 128GB bản Wifi', 'apple:tv-4k:128gb'], + // A bundled Magic Keyboard after the name keeps the iMac. + ['Apple iMac M4 24 inch 256GB kèm Magic Keyboard', 'apple:imac-m4:256gb'], + ['iMac M4 24 inch 16GB 256GB Magic Keyboard Touch ID', 'apple:imac-m4:256gb'], // A generation or year after the line keeps generations apart. ['Apple HomePod 2', 'apple:homepod-2:unknown'], ['Apple HomePod (2nd generation)', 'apple:homepod-2:unknown'], diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index 11823b37f..77d3ea419 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -207,6 +207,37 @@ export function detectProductOrigin(title: string, description?: string): Produc return 'unknown'; } +/** Apple apps and services a device can support or run. */ +const APPLE_SERVICE = String.raw`apple\s?(?:tv\+?|music|airplay\s?\d?|home(?:kit)?|arcade)|airplay\s?\d?|homekit|siri`; +/** Ends a service name: "TV+" keeps its "+", which would otherwise read as a Plus model. */ +const SERVICE_END = String.raw`(?![a-z0-9+])`; +/** "hỗ trợ Apple TV", "tích hợp: AirPlay 2", "có ứng dụng Apple TV", "works with Siri". */ +const SERVICE_MENTION_RE = new RegExp( + String.raw`\b(?:hỗ trợ|tích hợp|có sẵn|cài sẵn|ứng dụng|app|kết nối|tương thích(?: với)?|supports?|works with|compatible with):? (?:(?:ứng dụng|app) )?(?:${APPLE_SERVICE})${SERVICE_END}`, + 'gi' +); +/** + * A television of another brand: an "Apple TV" in it is the app, never the Apple TV box + * ("Apple TV 4K - Smart TV Box" names no other brand and stays the box). + */ +const TELEVISION_TITLE_RE = /\btivi\b|\b(?:smart|google|android|qled|oled) ?tv\b/i; +const TELEVISION_BRAND_RE = + /\b(?:samsung|lg|sony|tcl|xiaomi|casper|panasonic|sharp|toshiba|philips|hisense|coocaa|aqua|skyworth|ffalcon|asanzo|nanoco)\b/i; +const APPLE_SERVICE_RE = new RegExp(String.raw`\b(?:${APPLE_SERVICE})${SERVICE_END}`, 'gi'); + +/** + * Drops the apps and services a title says the product supports, so they set no brand or + * model: "Smart Tivi Samsung 55 inch hỗ trợ Apple TV" is a Samsung, not an Apple TV + * (DOS/Rate#1195). `title` has single spaces. + */ +function stripServiceMentions(title: string): string { + const stripped = title.replace(SERVICE_MENTION_RE, ' '); + const otherBrandTv = TELEVISION_TITLE_RE.test(stripped) && TELEVISION_BRAND_RE.test(stripped); + return (otherBrandTv ? stripped.replace(APPLE_SERVICE_RE, ' ') : stripped) + .replace(/ {2,}/g, ' ') + .trim(); +} + /** Apple product families, for a title whose line no Apple pattern matched (DOS/Rate#817). */ const APPLE_FAMILIES: ReadonlyArray = [ [/iphone/i, 'iPhone'], @@ -1344,39 +1375,45 @@ export class ProductNormalizer { const categorySlug = detectCategorySlug(title); // 1. Extract Brand + // An app or service the product supports names no brand or model: "Smart Tivi Samsung + // ... hỗ trợ Apple TV" is a Samsung TV (DOS/Rate#1195). + const identityTitle = stripServiceMentions(title); let brand = 'Khác'; // Not the "ipad" in Xiaomi's "Mipad" (DOS/Rate#826). - if (/apple|iphone|(? Date: Wed, 28 Oct 2026 21:10:45 +0700 Subject: [PATCH 13/69] fix(importer): an unknown 5g is stored as null, not false (#1207) * fix(importer): an unknown 5g is stored as null, not false The enricher wrote `has_5g ?? false` in its three return paths, and the chain before and after it guessed too: the normalizer set every non-registry phone to 5G and every tablet without "5G" in the title to no 5G, and the master seed and enrichMasterProductById coerced an unknown value to false. A phone or tablet title that names no 5G now gives has_5g null (and no invented cellular block); the enricher, the seed and the master enrichment keep null. Known values, registry entries and the no-cellular categories (laptops, watches, earbuds) are unchanged. Existing rows are not rewritten. Refs #1196 * fix(importer): read "5 G" titles and let the master schema hold an unknown 5g Review follow-up: "Galaxy A26 128GB 5 G" read as unknown (it was true before), and MasterProductSchema rejected has_5g null. An omitted value still defaults to false, like the column. Refs #1196 Co-Authored-By: Claude Opus 5.5 --- .../shopee/__tests__/entity-matcher.test.ts | 57 +++++++++++++++++++ apps/importer/src/shopee/ai-spec-enricher.ts | 6 +- apps/importer/src/shopee/entity-matcher.ts | 17 +++++- .../importer/src/shopee/product-normalizer.ts | 57 +++++++++++-------- .../shared-data/src/schemas/specs.schema.ts | 3 +- 5 files changed, 111 insertions(+), 29 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts index 8431ac98e..81e78ba6e 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -466,6 +466,50 @@ async function runEntityMatcherSuite() { offlineEnricher ).resolveOrCreateMasterProduct(watch); assert.deepEqual(watchRes, { masterId: 'master-series-10-42', isNew: false }); + // DOS/Rate#1196: an unknown 5G stays null through the normalizer, the enricher and the + // seed; known values are kept. + const no5gInfo = normalizer.normalize(createShopeeListing('Samsung Galaxy A26 128GB')); + assert.equal(no5gInfo.has_5g, null, 'a phone title with no 5G says nothing about it'); + assert.deepEqual(no5gInfo.specifications.cellular ?? {}, {}); + assert.equal( + normalizer.normalize(createShopeeListing('Samsung Galaxy A26 128GB 5 G')).has_5g, + true + ); + assert.equal( + normalizer.normalize(createShopeeListing('Samsung Galaxy A26 5 GB RAM')).has_5g, + null + ); + assert.equal( + normalizer.normalize(createShopeeListing('Samsung Galaxy A26 5G 128GB')).has_5g, + true + ); + assert.equal(normalizer.normalize(createShopeeListing('Xiaomi Pad 7 128GB')).has_5g, null); + assert.equal( + normalizer.normalize(createShopeeListing('Laptop ASUS Vivobook 15 X1504VA 512GB')).has_5g, + false + ); + assert.equal( + normalizer.normalize(createShopeeListing('Samsung Galaxy S24 Ultra 256GB')).has_5g, + true, + 'a registry model keeps its known 5G' + ); + const enrichedNo5g = await offlineEnricher.enrichCandidate(no5gInfo, 'Samsung Galaxy A26 128GB'); + assert.equal(enrichedNo5g.has_5g, null, 'the enricher must not turn an unknown 5G into false'); + assert.equal( + (await offlineEnricher.enrichCandidate({ ...no5gInfo, has_5g: false })).has_5g, + false + ); + assert.equal((await offlineEnricher.enrichCandidate({ ...no5gInfo, has_5g: true })).has_5g, true); + const no5gSeedDb = createMockSupabase(); + await new EntityMatcher(no5gSeedDb.client, offlineEnricher).resolveOrCreateMasterProduct( + no5gInfo + ); + assert.equal(no5gSeedDb.capturedUpserts[0]?.payload.has_5g, null); + const known5gSeedDb = createMockSupabase(); + await new EntityMatcher(known5gSeedDb.client, offlineEnricher).resolveOrCreateMasterProduct( + normalizer.normalize(createShopeeListing('Samsung Galaxy A26 5G 128GB')) + ); + assert.equal(known5gSeedDb.capturedUpserts[0]?.payload.has_5g, true); console.log(' ✅ [3/7] Tier 2 fuzzy match threshold & ambiguous draft fallback passed'); // 4. Tier 3 Creation Status: benchmark_registry (new) -> published; non-new or crawled_title -> draft @@ -562,6 +606,19 @@ async function runEntityMatcherSuite() { }); assert.equal(parsedMaster.status, 'draft'); assert.equal(parsedMaster.average_rating, null); + // A master whose 5G is unknown parses (DOS/Rate#1196). + assert.equal( + MasterProductSchema.parse({ + id: '018f1a50-0000-7000-8000-000000000002', + brand: 'Samsung', + model: 'Galaxy A26', + model_key: 'samsung:galaxy-a26:128gb', + name: 'Samsung Galaxy A26 128GB', + slug: 'samsung-galaxy-a26-128gb', + has_5g: null, + }).has_5g, + null + ); const reviewJsonSchema = getAiReviewBatchJsonSchema(); assert.equal(reviewJsonSchema.type, 'object'); diff --git a/apps/importer/src/shopee/ai-spec-enricher.ts b/apps/importer/src/shopee/ai-spec-enricher.ts index a9a7efd59..c69aa256d 100644 --- a/apps/importer/src/shopee/ai-spec-enricher.ts +++ b/apps/importer/src/shopee/ai-spec-enricher.ts @@ -1033,7 +1033,7 @@ REQUIRED JSON SCHEMA: if (candidate.is_benchmark_enriched === true) { return { ...candidate, - has_5g: toBooleanOrUndefined(candidate.has_5g) ?? false, + has_5g: toBooleanOrUndefined(candidate.has_5g) ?? null, colors: sanitizeColorList(candidate.colors) ?? [], spec_source: candidate.spec_source ?? 'benchmark_registry', }; @@ -1058,7 +1058,7 @@ REQUIRED JSON SCHEMA: if (!aiResult) { return { ...candidate, - has_5g: toBooleanOrUndefined(candidate.has_5g) ?? false, + has_5g: toBooleanOrUndefined(candidate.has_5g) ?? null, colors: sanitizeColorList(candidate.colors) ?? [], }; } @@ -1099,7 +1099,7 @@ REQUIRED JSON SCHEMA: screen_size_inch: toBoundedNumberOrUndefined(aiResult.screen_size_inch, 120) ?? validCandidateScreen, battery_mah: toBoundedIntOrUndefined(aiResult.battery_mah, 500000) ?? validCandidateBattery, - has_5g: aiResult.has_5g ?? toBooleanOrUndefined(candidate.has_5g) ?? false, + has_5g: aiResult.has_5g ?? toBooleanOrUndefined(candidate.has_5g) ?? null, specifications: mergedSpecifications, key_specs: { ...baseKeySpecs, diff --git a/apps/importer/src/shopee/entity-matcher.ts b/apps/importer/src/shopee/entity-matcher.ts index 476d4d759..724fecd10 100644 --- a/apps/importer/src/shopee/entity-matcher.ts +++ b/apps/importer/src/shopee/entity-matcher.ts @@ -84,6 +84,18 @@ function isStrictBoolean(val: unknown): boolean { return false; } +/** A known boolean, or null when the value says nothing (DOS/Rate#1196). */ +function toBooleanOrNull(val: unknown): boolean | null { + if (typeof val === 'boolean') return val; + if (typeof val === 'number') return val === 1 ? true : val === 0 ? false : null; + if (typeof val === 'string') { + const lower = val.trim().toLowerCase(); + if (lower === 'true' || lower === '1') return true; + if (lower === 'false' || lower === '0') return false; + } + return null; +} + export function sanitizeHttpUrl(raw: unknown): string | null { if (typeof raw !== 'string') return null; const trimmed = raw.trim(); @@ -727,7 +739,7 @@ export class EntityMatcher { storage_gb: toBoundedPositiveInt(candidate.storage_gb, MAX_STORAGE_GB), screen_size_inch: toBoundedPositiveNumber(candidate.screen_size_inch, MAX_SCREEN_SIZE_INCH), battery_mah: toBoundedPositiveInt(candidate.battery_mah, MAX_BATTERY_MAH), - has_5g: isStrictBoolean(candidate.has_5g), + has_5g: toBooleanOrNull(candidate.has_5g), specifications: candidate.specifications, key_specs: candidate.key_specs, images: safeImages, @@ -930,9 +942,10 @@ export class EntityMatcher { toBoundedPositiveNumber(aiResult.screen_size_inch, MAX_SCREEN_SIZE_INCH), battery_mah: existingBatteryMah ?? toBoundedPositiveInt(aiResult.battery_mah, MAX_BATTERY_MAH), + // Unknown stays null rather than false (DOS/Rate#1196). has_5g: isStrictBoolean(row.has_5g) ? true - : isStrictBoolean(aiResult.has_5g ?? row.has_5g), + : (toBooleanOrNull(aiResult.has_5g) ?? toBooleanOrNull(row.has_5g)), specifications: mergedSpecifications, key_specs: mergedKeySpecs, updated_at: new Date().toISOString(), diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index 77d3ea419..b8d037b67 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -99,7 +99,8 @@ export interface NormalizedProductCandidate { ram_gb: number | null; screen_size_inch: number | null; battery_mah: number | null; - has_5g: boolean; + /** null = not known: no registry entry and no 5G in a phone or tablet title (DOS/Rate#1196). */ + has_5g: boolean | null; colors: string[]; images: string[]; specifications: ProductSpecifications; @@ -1985,9 +1986,16 @@ export class ProductNormalizer { ? null : (benchmarkSized?.screen_size_inch ?? titleScreenSize ?? appleInches); const batteryMah = benchmarkSized ? benchmarkSized.battery_mah : null; - const has5g = benchmark + // A phone or tablet title that names no 5G does not say it lacks it: unknown, not + // false (and not true for every phone). Laptops, watches and earbuds have no 5G + // (DOS/Rate#1196). + const has5g: boolean | null = benchmark ? benchmark.has_5g - : categorySlug === 'smartphones' || /\b5g\b/i.test(title); + : /\b5 ?g\b/i.test(title) + ? true + : categorySlug === 'smartphones' || categorySlug === 'tablets' + ? null + : false; const fallbackOs = categorySlug === 'laptops' @@ -2244,27 +2252,30 @@ export class ProductNormalizer { nfc: true, other: isApple ? 'Ultra Wideband (UWB), SOS vệ tinh' : 'Hỗ trợ OTG, Chia sẻ nhanh', }, + // Nothing is known about the network of a phone or tablet whose 5G is unknown. cellular: - !has5g && categorySlug !== 'smartphones' - ? { - has_5g: false, - technology: - categorySlug === 'earbuds' || categorySlug === 'laptops' - ? 'Wi-Fi / Bluetooth (No Cellular)' - : 'Wi-Fi / Bluetooth', - sim_type: 'No SIM', - volte: false, - vowifi: false, - } - : { - has_5g: has5g, - bands_5g: has5g ? 'SA/NSA/Sub6' : undefined, - bands_4g: 'LTE Cat 20', - technology: has5g ? 'GSM / HSPA / LTE / 5G' : 'GSM / HSPA / LTE', - sim_type: '2 Nano-SIM + eSIM', - volte: true, - vowifi: true, - }, + has5g === null + ? {} + : !has5g && categorySlug !== 'smartphones' + ? { + has_5g: false, + technology: + categorySlug === 'earbuds' || categorySlug === 'laptops' + ? 'Wi-Fi / Bluetooth (No Cellular)' + : 'Wi-Fi / Bluetooth', + sim_type: 'No SIM', + volte: false, + vowifi: false, + } + : { + has_5g: has5g, + bands_5g: has5g ? 'SA/NSA/Sub6' : undefined, + bands_4g: 'LTE Cat 20', + technology: has5g ? 'GSM / HSPA / LTE / 5G' : 'GSM / HSPA / LTE', + sim_type: '2 Nano-SIM + eSIM', + volte: true, + vowifi: true, + }, multimedia: { loudspeaker: 'Loa kép Stereo đa chiều', headphone_jack_35mm: false, diff --git a/packages/shared-data/src/schemas/specs.schema.ts b/packages/shared-data/src/schemas/specs.schema.ts index 310b11f67..fdc1ec883 100644 --- a/packages/shared-data/src/schemas/specs.schema.ts +++ b/packages/shared-data/src/schemas/specs.schema.ts @@ -140,7 +140,8 @@ export const MasterProductSchema = z.object({ storage_gb: z.number().positive().max(65536).nullable().optional(), screen_size_inch: z.number().positive().max(120).nullable().optional(), battery_mah: z.number().positive().max(500000).nullable().optional(), - has_5g: z.boolean().default(false), + // null = not known (DOS/Rate#1196); an omitted value defaults like the column. + has_5g: z.boolean().nullable().default(false), specifications: ProductSpecificationsSchema.default({}), key_specs: KeySpecsSchema.default({}), images: z.array(HttpUrlSchema).max(100).default([]), From 0366a88ff1ba8a7e1931249bd83bd36166d2df95 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:34:14 +0700 Subject: [PATCH 14/69] fix(importer): ignore masterIdHint when the page states another storage (#1206) * fix(importer): ignore masterIdHint when the page states another storage ingestProduct trusted masterIdHint even when the page stated a different storage than the hinted master, so a 1TB page hinted to the 512GB master joined it. When the title states a storage and the hinted master has another, the hint is not applied: the listing is matched normally and ingest.master_hint_storage_conflict is logged. A page with no stated storage, or storage derived from the catalogue, keeps the hint. Refs #1197 * fix(importer): narrow the storage conflict and keep the hint's item Review of the previous commit (subagent and Codex): - a title listing several sizes ("256/512GB") named the hinted size but was read as a conflict; titleNamesStorage now keeps the hint then; - a title too bare to key would have failed the whole ingest, so it keeps the hint as before; - in item mode the conflict matched from the title alone; the hint's Item is now passed as the item hint; - in off mode the master the listing left kept stale market stats; it is refreshed with the new one. Refs #1197 * fix(importer): read whole size lists in titles, not ram or model digits Codex re-review: titleNamesStorage missed "128/256/512GB", "1/2TB" and "1.5/2TB" and took "64GB RAM" or "Spark64/128GB" for storage. titleStorageSizesGb now reads each slash list with its trailing unit, converts TB, and skips RAM and digits glued to a model name. The item-mode test uses a title keyed apart from the fixture, so it fails without the item hint. Refs #1197 * fix(importer): scan title sizes token by token Codex re-review of the regex: nested optional spacing in the repeated list group backtracked exponentially on long slash lists; "SSD512/1024GB" and "ROM128/256GB" lost their first size; and "SSD 512GB RAM 16GB" lost 512GB to a RAM lookahead. titleStorageSizesGb now folds spacing, splits the title into tokens and reads each size token with a separator-led, linear pattern. SSD/ROM/HDD prefixes may be glued, RAM labels bind to the size after them when the title writes labels first and to the size before them otherwise, and the scan reads at most 500 characters. Refs #1197 * fix(importer): bind ram labels per size and split unit suffixes Codex re-review: one title-wide label direction read "SSD 1TB 64GB RAM" as storage 64GB, and "256/512GB-VN/A" lost its list to the dash suffix. A RAM label now binds to a neighbouring size unless another size sits on its far side, where the title's label habit decides; a unit followed by - . : or ! ends its token; whitespace is collapsed first so every pass is linear. Refs #1197 Co-Authored-By: Claude Opus 5.5 --- .../src/shopee/__tests__/item-matcher.test.ts | 24 +++- .../__tests__/shopee-to-supabase.test.ts | 113 ++++++++++++++++- .../importer/src/shopee/shopee-to-supabase.ts | 114 +++++++++++++++++- 3 files changed, 245 insertions(+), 6 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/item-matcher.test.ts b/apps/importer/src/shopee/__tests__/item-matcher.test.ts index e8b9d9dae..c9d9ad383 100644 --- a/apps/importer/src/shopee/__tests__/item-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/item-matcher.test.ts @@ -813,6 +813,28 @@ async function run() { ); assert.equal(live.listing?.cluster_id, ZENBOOK_1TB.id); assert.equal(live.listing?.variant_source, 'title'); + + // A 1TB page hinted to the Item's 512GB variant: the hint is not applied, + // the listing is matched under the hint's Item (DOS/Rate#1197). + const conflict = await ingest( + 'item', + 'Laptop ASUS UX5406AA 1TB chính hãng', + zenbook, + { + hint: { + id: 'master-zenbook-512', + specifications: { display: { size: 'x' } }, + status: 'published', + item_id: 'item-zenbook', + variant_key: '512gb', + storage_gb: 512, + }, + }, + { masterIdHint: 'master-zenbook-512' } + ); + assert.equal(conflict.listing?.cluster_id, ZENBOOK_1TB.id); + assert.equal(conflict.listing?.item_id, 'item-zenbook'); + assert.equal(conflict.listing?.variant_source, 'title'); assert.ok(!live.calls.some(c => c.table === 'items'), 'a live hint skips matching'); const offHint = await ingest( @@ -825,7 +847,7 @@ async function run() { assert.equal(offHint.listing?.cluster_id, 'master-zenbook-unknown'); assert.equal( offHint.calls.find(c => c.table === 'master_products' && filter(c, 'id'))?.cols, - 'id, specifications' + 'id, specifications, storage_gb' ); } { diff --git a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts index 0fc318860..cbd2b939d 100644 --- a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts +++ b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts @@ -11,6 +11,8 @@ import { trimTrailingSlashes } from '../ai-spec-enricher.js'; import { ShopeeIngestionPipeline, hasPopulatedSpecificationGroups, + titleNamesStorage, + titleStorageSizesGb, normalizeScrapedProductPrices, platformReviewIdFor, } from '../shopee-to-supabase.js'; @@ -50,7 +52,7 @@ function createBaseProduct(overrides: Partial = {}): ShopeeProduc interface StubPipelineOptions { categoriesErr?: { message: string } | null; - hintRow?: { id: string } | null; + hintRow?: { id: string; storage_gb?: number | null } | null; hintErr?: { message: string } | null; exactMasterRow?: { id: string; name: string; model_key: string } | null; recordPriceRpcResult?: { data: string | null; error: { code?: string; message: string } | null }; @@ -141,7 +143,7 @@ function createPipelineStubSupabase(opts: StubPipelineOptions = {}) { eq(col: string, val: string) { return { maybeSingle() { - if (col === 'id' && (cols === 'id' || cols === 'id, specifications')) { + if (col === 'id' && (cols === 'id' || cols.startsWith('id, specifications'))) { return Promise.resolve({ data: opts.hintRow ?? null, error: opts.hintErr ?? null, @@ -518,6 +520,113 @@ async function runTests() { null, 'A malformed label is dropped instead of failing the ingest' ); + + // A hint whose master has another storage than the page states is not + // applied: the listing is matched normally (DOS/Rate#1197). + const { stub: conflictStub, events: conflictEvents } = createPipelineStubSupabase({ + hintRow: { id: 'master-hint-512', storage_gb: 512 }, + }); + const conflictRes = await new ShopeeIngestionPipeline({ + supabaseClient: conflictStub, + apiKey: '', + }).ingestProduct( + createBaseProduct({ + title: 'Điện thoại Apple iPhone 16 Pro Max 1TB Chính hãng VN/A', + models: [{ modelId: 'm-1', name: 'Titan Tự Nhiên - 1TB', price: 28990000, stock: 15 }], + }), + undefined, + { masterIdHint: 'master-hint-512' } + ); + assert.notEqual( + conflictRes.masterId, + 'master-hint-512', + 'a 1TB page must not join the 512GB master' + ); + assert.equal(conflictRes.masterId, 'master-exact-1', 'it falls back to normal matching'); + assert.equal(conflictEvents.productUpserts[0]?.cluster_id, 'master-exact-1'); + // The master it left gets its stats refreshed too. + assert.ok(conflictEvents.marketStatsRefreshed.includes('master-hint-512')); + assert.ok(conflictEvents.marketStatsRefreshed.includes('master-exact-1')); + + // A title that also names the hinted size is no conflict. + assert.equal(titleNamesStorage('iPhone 16 Pro Max 256/512GB', 256), true); + assert.equal(titleNamesStorage('iPhone 16 Pro Max 256/512GB', 512), true); + assert.equal(titleNamesStorage('ThinkPad X1 Carbon Gen 12 1TB SSD', 1024), true); + assert.equal(titleNamesStorage('ThinkPad X1 Carbon Gen 12 1TB SSD', 512), false); + assert.equal(titleNamesStorage('Model 2512GB', 512), false); + assert.equal(titleNamesStorage('Model 1.5TB', 512), false); + assert.equal(titleNamesStorage('Galaxy S25 128/256/512GB', 128), true); + assert.equal(titleNamesStorage('Ổ cứng 1/2TB', 1024), true); + assert.equal(titleNamesStorage('SSD 1.5/2TB', 1536), true); + assert.equal(titleNamesStorage('iPhone 512GB/1TB', 1024), true); + // RAM and digits glued to a model name are not storage. + assert.equal(titleNamesStorage('Laptop Lenovo ThinkPad P16 64GB RAM 1TB SSD', 64), false); + assert.equal(titleNamesStorage('Laptop RAM 16GB SSD 512GB', 16), false); + assert.equal(titleNamesStorage('Tecno Spark64/128GB', 64), false); + assert.deepEqual(titleStorageSizesGb('Laptop Lenovo ThinkPad P16 64GB RAM 1TB SSD'), [1024]); + for (const [title, sizes] of [ + ['Laptop SSD512/1024GB', [512, 1024]], + ['Samsung Galaxy A55 ROM128/256GB', [128, 256]], + ['Laptop ASUS Zenbook S 14 UX5406AA SSD 512GB RAM 16GB', [512]], + ['Laptop RAM: 64GB SSD 1TB', [1024]], + ['Laptop RAM 16GB SSD 512GB', [512]], + ['Redmi 12+256GB', [12, 256]], + ['iPhone 256GB | 512GB', [256, 512]], + ['Ổ cứng 1 TB', [1024]], + ['Galaxy S25 128 / 256 / 512GB', [128, 256, 512]], + ['Laptop Lenovo ThinkPad P16 SSD 1TB 64GB RAM', [1024]], + ['Apple iPhone 16 Pro Max 256/512GB-VN/A', [256, 512]], + ['Laptop 64GB RAM 1TB SSD', [1024]], + ['iPhone 16 Pro 1TB.', [1024]], + ] as const) { + assert.deepEqual(titleStorageSizesGb(title), sizes, title); + } + // Linear in the title: no backtracking blow-up on a long slash list. + const longTitle = `Laptop ASUS Zenbook S 14 UX5406AA SSD 1TB ${'128 / '.repeat(400)}X`; + const started = performance.now(); + titleStorageSizesGb(longTitle); + assert.ok(performance.now() - started < 50, 'size scan stays fast'); + const { stub: listStub } = createPipelineStubSupabase({ + hintRow: { id: 'master-hint-256', storage_gb: 256 }, + }); + const listRes = await new ShopeeIngestionPipeline({ + supabaseClient: listStub, + apiKey: '', + }).ingestProduct( + createBaseProduct({ title: 'Điện thoại Apple iPhone 16 Pro Max 256/512GB Chính hãng VN/A' }), + undefined, + { masterIdHint: 'master-hint-256' } + ); + assert.equal(listRes.masterId, 'master-hint-256'); + + // A page that states no storage keeps the hint. + const { stub: noStorageStub } = createPipelineStubSupabase({ + hintRow: { id: 'master-hint-512', storage_gb: 512 }, + }); + const noStorageRes = await new ShopeeIngestionPipeline({ + supabaseClient: noStorageStub, + apiKey: '', + }).ingestProduct( + createBaseProduct({ + title: 'Điện thoại Apple iPhone 16 Pro Max Chính hãng VN/A', + models: [{ modelId: 'm-1', name: 'Titan Tự Nhiên', price: 28990000, stock: 15 }], + }), + undefined, + { masterIdHint: 'master-hint-512' } + ); + assert.equal(noStorageRes.masterId, 'master-hint-512'); + + // The same storage, or a master with no storage, keeps the hint too. + for (const storage_gb of [256, null]) { + const { stub: sameStub } = createPipelineStubSupabase({ + hintRow: { id: 'master-hint-256', storage_gb }, + }); + const sameRes = await new ShopeeIngestionPipeline({ + supabaseClient: sameStub, + apiKey: '', + }).ingestProduct(createBaseProduct(), undefined, { masterIdHint: 'master-hint-256' }); + assert.equal(sameRes.masterId, 'master-hint-256', `hint kept for storage_gb=${storage_gb}`); + } console.log(' ✅ [3/6] masterIdHint preservation & DB error abort passed'); // 4. Smart Delta Price Compression: client-side 7-day fallback & 23505 race recovery diff --git a/apps/importer/src/shopee/shopee-to-supabase.ts b/apps/importer/src/shopee/shopee-to-supabase.ts index 44381c517..71a96b3d6 100644 --- a/apps/importer/src/shopee/shopee-to-supabase.ts +++ b/apps/importer/src/shopee/shopee-to-supabase.ts @@ -97,6 +97,80 @@ const SPEC_FIELD_GROUP_KEYS = [ 'regulatory', ] as const; +// One size token after spacing is folded: "1TB", "SSD512GB", "ROM128/256GB", +// "128/256/512GB", "1.5/2TB", "512GB/1TB", "12+256GB". A size glued to any +// other word ("Spark64/128GB") is not one. +const SIZE_TOKEN_RE = + /^(?:SSD|ROM|HDD|EMMC|UFS)?:?(\d[\d.,]*(?:GB|TB)?(?:[/+]\d[\d.,]*(?:GB|TB)?)*(?<=GB|TB))$/i; +const SIZE_PART_RE = /^(\d+(?:[.,]\d+)?)(GB|TB)?$/i; +const RAM_LABEL_RE = /^RAM:?$/i; +const STORAGE_LABEL_RE = /^(?:SSD|ROM|HDD|EMMC|UFS):?$/i; +const MAX_TITLE_SCAN = 500; + +const sizesOfToken = (list: string): number[] => { + const parts = list.split(/[/+]/); + const listUnit = SIZE_PART_RE.exec(parts[parts.length - 1])?.[2]; + if (!listUnit) return []; + const sizes: number[] = []; + for (const part of parts) { + const sized = SIZE_PART_RE.exec(part); + if (!sized) return []; + const value = parseFloat(sized[1].replace(',', '.')); + const unit = (sized[2] ?? listUnit).toUpperCase(); + if (Number.isFinite(value) && value > 0) { + sizes.push(Math.round(unit === 'TB' ? value * 1024 : value)); + } + } + return sizes; +}; + +/** + * The storage sizes, in GB, a title names (DOS/Rate#1197), RAM left out. + * A RAM label next to a size binds to it when no other size sits on the + * label's far side ("RAM 16GB", "64GB RAM"). Between two sizes it binds by + * the title's habit: after it when the title writes labels first ("SSD 512GB + * RAM 16GB"), else before it ("64GB RAM 1TB SSD"). Token by token, so the cost + * stays linear in the title. + */ +export function titleStorageSizesGb(title: string): number[] { + const tokens = title + .slice(0, MAX_TITLE_SCAN) + .replace(/\s+/g, ' ') + .replace(/(\d) (GB|TB)\b/gi, '$1$2') + .replace(/ ?([/+]) ?/g, '$1') + // A unit followed by a suffix: "512GB-VN/A", "1TB.". + .replace(/(\d(?:GB|TB))[-.:!]/gi, '$1 ') + .split(/[\s,;|()[\]]+/) + .filter(Boolean); + const isSize = (i: number) => i >= 0 && i < tokens.length && SIZE_TOKEN_RE.test(tokens[i]); + const isRamLabel = (i: number) => i >= 0 && i < tokens.length && RAM_LABEL_RE.test(tokens[i]); + const isLabel = (t: string) => RAM_LABEL_RE.test(t) || STORAGE_LABEL_RE.test(t); + const firstLabel = tokens.findIndex(isLabel); + const firstSize = tokens.findIndex((_, i) => isSize(i)); + const labelsFirst = firstLabel !== -1 && firstLabel < firstSize; + // Whether the RAM label at `label` names the size at `size` (a neighbour). + const ramNames = (label: number, size: number) => { + if (!isRamLabel(label)) return false; + const far = label + (label - size); + if (!isSize(far)) return true; + return labelsFirst ? label < size : label > size; + }; + const sizes: number[] = []; + tokens.forEach((token, i) => { + const size = SIZE_TOKEN_RE.exec(token); + if (!size) return; + const glued = token.length !== size[1].length; + const isRam = !glued && (ramNames(i - 1, i) || ramNames(i + 1, i)); + if (!isRam) sizes.push(...sizesOfToken(size[1])); + }); + return sizes; +} + +/** Whether a title names a storage size, also as one of a list. */ +export function titleNamesStorage(title: string, storageGb: number): boolean { + return titleStorageSizesGb(title).includes(storageGb); +} + /** * Returns true if at least one of the 10 specification field groups contains a * non-empty value (ignoring top-level metadata keys such as `category_slug`). @@ -518,6 +592,8 @@ export class ShopeeIngestionPipeline { let hinted: ItemMatchResult | null = null; // undefined = leave the stored variant_source as it is. let hintedVariantSource: ItemMatchResult['variantSource'] | undefined; + // A hinted master left on a storage conflict: its stats follow the move. + let ignoredHintMasterId: string | null = null; let safeItemIdHint = typeof options.itemIdHint === 'string' ? (sanitizeIngestionEntityId(options.itemIdHint, 128) ?? '') @@ -537,8 +613,8 @@ export class ShopeeIngestionPipeline { .from('master_products') .select( itemModel === 'off' - ? 'id, specifications' - : 'id, specifications, status, item_id, variant_key' + ? 'id, specifications, storage_gb' + : 'id, specifications, storage_gb, status, item_id, variant_key' ) .eq('id', safeMasterIdHint) .maybeSingle(); @@ -553,10 +629,39 @@ export class ShopeeIngestionPipeline { status?: unknown; item_id?: unknown; variant_key?: unknown; + storage_gb?: unknown; } | null; + // The page states a storage the hinted master does not have (a 1TB page + // hinted to the 512GB master): the hint is not applied and the listing is + // matched like any other (DOS/Rate#1197). A page with no stated storage, + // a title that also names the hinted size ("256/512GB") and a title too + // bare to match keep the hint. + const hintStorageGb = Number(hintMeta?.storage_gb); + const storageConflict = + candidate.keyable !== false && + candidate.variant_source === 'title' && + typeof candidate.storage_gb === 'number' && + candidate.storage_gb > 0 && + Number.isFinite(hintStorageGb) && + hintStorageGb > 0 && + candidate.storage_gb !== hintStorageGb && + !titleNamesStorage(product.title, hintStorageGb); const hintItemId = itemModel === 'off' ? null : sanitizeIngestionEntityId(hintMeta?.item_id, 128); - if (hintRow?.id && itemModel !== 'off' && hintMeta?.status === 'archived') { + if (hintRow?.id && storageConflict) { + platLogger.warn( + 'ingest.master_hint_storage_conflict', + `masterIdHint ${hintRow.id} ignored: the page states ${candidate.storage_gb}GB, the master is ${hintStorageGb}GB`, + { + masterId: hintRow.id, + pageStorageGb: candidate.storage_gb, + hintStorageGb, + } + ); + ignoredHintMasterId = hintRow.id; + // The hinted variant's Item still holds the listing's model. + if (hintItemId && !safeItemIdHint) safeItemIdHint = hintItemId; + } else if (hintRow?.id && itemModel !== 'off' && hintMeta?.status === 'archived') { // A retired master (an Item's `unknown` variant after M3) must not take the // listing back: the hint maps to its Item, and the variant comes from the title. platLogger.info( @@ -605,6 +710,9 @@ export class ShopeeIngestionPipeline { let previousItemId: string | null = null; // True when itemId comes from a kept variant: then it is written even when null. let itemIdFromKeptVariant = false; + // Off mode reads no stored listing; the master a conflicting hint named is + // where the listing sat before. + if (itemModel === 'off' && ignoredHintMasterId) previousClusterId = ignoredHintMasterId; if (itemModel !== 'off') { // A variant set by hand is evidence, never undone by a later page that just // does not state the size (design doc 4.4): only listing evidence From f85dc6d97cb51b2a6d110dcc372aae4ff498b23b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:47:05 +0700 Subject: [PATCH 15/69] feat(importer): item seeds, per-item specs and rating, inherited variants (#1208) * feat(importer): a new variant of a known item inherits its specs and publish state Flag-on only (RATE_ITEM_MODEL item / nullable_variant). When the matcher creates a missing variant under an Item that already has specs, the variant takes the Item's specifications, key specs, description and images, keeps only its own storage and RAM, skips the AI enrichment, and is published when the Item is published, the size is stated and the listing is a new device (design doc 4.1, 4.5 step 2). An Item with no specs yet seeds the variant as before. Codex P1 left open on #1151. Refs #1152 * feat(importer): seeds grouped by item, item specs from the seeds, series 10 storage - flagshipSeedsByItem() groups the flat seed list into Items with their variants; Item specs are the first variant's without its storage. - syncItemSeedSpecs() writes those specs onto existing rate.items rows, dry-run unless apply; runMasterSpecsBackfill calls it only with RATE_ITEM_MODEL on, and never creates Items (M1/M2 own that). - Registry: apple:watch-series-10 with only_storage_gb 64 (M0 inventory), so a Series 10 title without storage keys like its ':64gb' seed. 47 Series 10 fixtures move from ':unknown' to ':64gb'. Refs #1152 * feat(importer): spec enrichment per item with the flag on With RATE_ITEM_MODEL on, the matcher asks the AI enricher per Item: the prompt and cache key carry no storage or RAM, so every size of an Item shares one call, and the variant's own storage, RAM and storage key spec are laid over the answer (design doc 5.3). Mode off asks per size as before. Refs #1152 * test(importer): use the chip key spec in the per-item enrichment test Refs #1152 * feat(importer): a listing with no variant refreshes its item rating persistAnalysisToSupabase takes the listing's itemId. With no variant (cluster_id NULL, RATE_ITEM_MODEL nullable_variant) it calls the M2 refresh_item_stats RPC, which pools the rating over every listing of the Item (design doc 4.1, Q4). With a variant, refresh_master_product_rating runs as before (its M2 body resolves the Item). No itemId, no change. Refs #1152 * fix(importer): item review fixes for status, sizes, flag guards and seeds - A new variant under a draft or archived Item is a draft, with or without Item specs; an inherited variant also honours needs_review. - Inherited variants take the flat screen, battery and 5G columns from the Item specs, and their own RAM and RAM key spec. - Per-Item enrichment never takes storage or RAM from the AI answer, even when the variant's own is unknown. - syncItemSeedSpecs and the analyzer's Item rating refresh are no-ops with RATE_ITEM_MODEL off; Item specs never keep storage or RAM, also when an existing Item row has them; seed Item names drop only a trailing storage and seed Items carry no RAM. - Registry `case_mm`: the Series 10 entry describes the 42mm case, so a 46mm title gets none of its size specs and is not benchmark-enriched. - The pipeline passes itemId to persistAnalysisToSupabase. Refs #1152 * fix(importer): series 10 46mm resolution and the pipeline's item mode for ratings Codex re-check follow-up: a 46mm title no longer takes the 42mm resolution, and persistAnalysisToSupabase takes the pipeline's own itemModel (the env is only the default), so a pipeline configured with the Item model on refreshes Item ratings. Refs #1152 Co-Authored-By: Claude Opus 5.5 --- .../shopee/__tests__/entity-matcher.test.ts | 13 +- .../src/shopee/__tests__/item-matcher.test.ts | 325 +++++++++++++++++- .../__tests__/open-core-intelligence.test.ts | 103 +++--- .../importer/src/shopee/ai-review-analyzer.ts | 28 ++ apps/importer/src/shopee/ai-spec-enricher.ts | 52 ++- apps/importer/src/shopee/entity-matcher.ts | 137 +++++++- .../importer/src/shopee/flagship-seed-data.ts | 58 +++- .../src/shopee/jobs/backfill-specs.ts | 94 ++++- .../importer/src/shopee/product-normalizer.ts | 45 ++- .../importer/src/shopee/shopee-to-supabase.ts | 2 + 10 files changed, 765 insertions(+), 92 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts index 81e78ba6e..2c1442d79 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -449,23 +449,24 @@ async function runEntityMatcherSuite() { offlineEnricher ).resolveOrCreateMasterProduct(earbuds); assert.deepEqual(earbudsRes, { masterId: 'master-wf1000xm5', isNew: false }); - // Watches too: Series 10 has one storage and no only_storage_gb benchmark. - const watch = normalizer.normalize(createShopeeListing('Apple Watch Series 10 42mm GPS')); + // Watches too: a line with one storage and no only_storage_gb benchmark (Series 9) + // still takes its legacy sized seed. + const watch = normalizer.normalize(createShopeeListing('Apple Watch Series 9 41mm GPS')); assert.equal(watch.storage_gb, null); const watchRes = await new EntityMatcher( createMockSupabase({ fuzzyMatches: [ { - master_id: 'master-series-10-42', - name: 'Apple Watch Series 10 42mm', + master_id: 'master-series-9-41', + name: 'Apple Watch Series 9 41mm', similarity: 0.9, - model_key: 'apple:watch-series-10-42mm:64gb', + model_key: 'apple:watch-series-9-41mm:64gb', }, ], }).client, offlineEnricher ).resolveOrCreateMasterProduct(watch); - assert.deepEqual(watchRes, { masterId: 'master-series-10-42', isNew: false }); + assert.deepEqual(watchRes, { masterId: 'master-series-9-41', isNew: false }); // DOS/Rate#1196: an unknown 5G stays null through the normalizer, the enricher and the // seed; known values are kept. const no5gInfo = normalizer.normalize(createShopeeListing('Samsung Galaxy A26 128GB')); diff --git a/apps/importer/src/shopee/__tests__/item-matcher.test.ts b/apps/importer/src/shopee/__tests__/item-matcher.test.ts index c9d9ad383..b05047ca9 100644 --- a/apps/importer/src/shopee/__tests__/item-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/item-matcher.test.ts @@ -6,14 +6,19 @@ * 4. Modes item / nullable_variant: Item tiers, exact variant, size not stated (#1105), hints * 5. Pipeline: item_id / variant_source / nullable cluster_id writes, archived masterIdHint, * a manual variant is kept, unkeyable titles are rejected before any write + * 6. Seeds as Item plus variants, Item specs from the seeds; a new variant of a known + * Item inherits its specs and publish state (DOS/Rate#1152, group 4) */ import assert from 'node:assert/strict'; import { composeModelKey, splitModelKey } from '@repo/shared-data'; import { EntityMatcher } from '../entity-matcher.js'; +import { FLAGSHIP_DEVICES_TO_SEED, flagshipSeedsByItem } from '../flagship-seed-data.js'; +import { syncItemSeedSpecs } from '../jobs/backfill-specs.js'; import { ITEM_MODEL_ENV, resolveItemModelMode } from '../item-model-flag.js'; import { ProductNormalizer, UnkeyableProductTitleError } from '../product-normalizer.js'; import { ShopeeIngestionPipeline } from '../shopee-to-supabase.js'; -import type { AiSpecEnricher } from '../ai-spec-enricher.js'; +import { AiSpecEnricher } from '../ai-spec-enricher.js'; +import { AiReviewAnalyzer } from '../ai-review-analyzer.js'; import type { RateSupabaseClient, ShopeeProduct } from '../types.js'; type Row = Record; @@ -87,7 +92,15 @@ const normalizer = new ProductNormalizer(); const candidateFor = (title: string) => normalizer.normalize({ title } as ShopeeProduct); interface ItemDb { - items?: Array<{ id: string; item_key: string }>; + items?: Array<{ + id: string; + item_key: string; + status?: string; + description?: string; + specifications?: Row; + key_specs?: Row; + images?: string[]; + }>; variants?: Array<{ id: string; item_id: string; variant_key: string; status?: string }>; matchItem?: Row[]; matchItemErr?: { message: string }; @@ -145,7 +158,7 @@ async function run() { assert.equal(resolveItemModelMode(' Nullable-Variant '), 'nullable_variant'); assert.equal(resolveItemModelMode('nullable'), 'off', 'a typo must never enable writes'); assert.equal(resolveItemModelMode(true), 'off'); - console.log(' ✅ [1/5] RATE_ITEM_MODEL defaults to off'); + console.log(' ✅ [1/6] RATE_ITEM_MODEL defaults to off'); // 2. Normalizer Item fields const cases: Array<[string, Row]> = [ @@ -207,7 +220,7 @@ async function run() { assert.equal(parts.variantKey, c.variant_key); assert.equal(composeModelKey(c.item_key as string, c.variant_key as string), c.model_key); } - console.log(' ✅ [2/5] Normalizer emits item_key/variant_key and never an empty segment'); + console.log(' ✅ [2/6] Normalizer emits item_key/variant_key and never an empty segment'); // 3. Mode off: today's matcher, no rate.items access { @@ -239,7 +252,7 @@ async function run() { UnkeyableProductTitleError ); } - console.log(' ✅ [3/5] Mode off keeps the master matcher and never reads rate.items'); + console.log(' ✅ [3/6] Mode off keeps the master matcher and never reads rate.items'); // 4. Item modes { @@ -276,6 +289,95 @@ async function run() { 'asus:zenbook-s-14:512gb' ); } + { + // DOS/Rate#1152: a new size of a known Item takes the Item's specs and, for a + // published Item, its publish state; no AI call. Storage and RAM stay its own. + let enrichCalls = 0; + const countingEnricher = { + enrichCandidate: async (candidate: T) => { + enrichCalls += 1; + return candidate; + }, + } as unknown as AiSpecEnricher; + const itemSpecs = { + display: { size_inch: 14, type: 'OLED 3K' }, + hardware: { chipset: 'Intel Core Ultra 7 258V', ram_gb: 32, storage_gb: 1024 }, + battery: { capacity_mah: 4700 }, + }; + const publishedItem = { + ...ZENBOOK_ITEM, + status: 'published', + description: 'Zenbook S 14 (item description)', + specifications: itemSpecs, + key_specs: { chipset: 'Core Ultra 7 258V', storage: '1TB' }, + images: ['https://rate.vn/img/zenbook-s-14.jpg'], + }; + const title = 'Laptop ASUS Zenbook S 14 UX5406AA 16GB 512GB SSD'; + const db = createItemDb({ items: [publishedItem], variants: [ZENBOOK_1TB] }); + const result = await new EntityMatcher(db.client, countingEnricher, { + itemModel: 'item', + }).resolveItemAndVariant(candidateFor(title), 'cat-laptops'); + assert.equal(result.masterId, 'seeded-master'); + const seeded = db.calls.find(c => c.op === 'upsert')?.payload as Row; + assert.equal(seeded.model_key, 'asus:zenbook-s-14:512gb'); + assert.equal(seeded.status, 'published', 'a new size of a published Item is published'); + assert.equal(seeded.storage_gb, 512); + assert.equal(seeded.description, 'Zenbook S 14 (item description)'); + assert.deepEqual(seeded.images, ['https://rate.vn/img/zenbook-s-14.jpg']); + const seededSpecs = seeded.specifications as { display?: Row; hardware?: Row }; + assert.equal(seededSpecs.display?.type, 'OLED 3K'); + assert.equal(seededSpecs.hardware?.chipset, 'Intel Core Ultra 7 258V'); + assert.equal(seededSpecs.hardware?.storage_gb, 512, 'storage is the variant own value'); + assert.equal(seededSpecs.hardware?.ram_gb, 16, 'RAM is the variant own value'); + const seededKeySpecs = seeded.key_specs as Row; + assert.equal(seededKeySpecs.chipset, 'Core Ultra 7 258V'); + assert.equal(seeded.battery_mah, 4700, 'flat battery follows the inherited specs'); + assert.equal(seeded.screen_size_inch, 14); + assert.equal(seeded.ram_gb, 16); + assert.notEqual(seededKeySpecs.storage, '1TB'); + assert.equal(enrichCalls, 0, 'an inherited variant needs no AI enrichment'); + + // A draft Item keeps its new variant a draft; a used listing never publishes one. + const draftDb = createItemDb({ + items: [{ ...publishedItem, status: 'draft' }], + variants: [ZENBOOK_1TB], + }); + await new EntityMatcher(draftDb.client, countingEnricher, { + itemModel: 'item', + }).resolveItemAndVariant(candidateFor(title), 'cat-laptops'); + assert.equal(draftDb.calls.find(c => c.op === 'upsert')?.payload?.status, 'draft'); + const usedDb = createItemDb({ items: [publishedItem], variants: [ZENBOOK_1TB] }); + await new EntityMatcher(usedDb.client, countingEnricher, { + itemModel: 'item', + }).resolveItemAndVariant(candidateFor(`${title} cũ đẹp 99%`), 'cat-laptops'); + assert.equal(usedDb.calls.find(c => c.op === 'upsert')?.payload?.status, 'draft'); + + // An Item with no specs yet: the variant is enriched and seeded as before (the + // Zenbook is a registry model, so the old rule publishes it). + const bareDb = createItemDb({ + items: [{ ...ZENBOOK_ITEM, status: 'published' }], + variants: [ZENBOOK_1TB], + }); + await new EntityMatcher(bareDb.client, countingEnricher, { + itemModel: 'item', + }).resolveItemAndVariant(candidateFor(title), 'cat-laptops'); + assert.equal(enrichCalls, 1); + assert.equal(bareDb.calls.find(c => c.op === 'upsert')?.payload?.status, 'published'); + for (const status of ['draft', 'archived']) { + const unpublishedDb = createItemDb({ + items: [{ ...ZENBOOK_ITEM, status }], + variants: [ZENBOOK_1TB], + }); + await new EntityMatcher(unpublishedDb.client, countingEnricher, { + itemModel: 'item', + }).resolveItemAndVariant(candidateFor(title), 'cat-laptops'); + assert.equal( + unpublishedDb.calls.find(c => c.op === 'upsert')?.payload?.status, + 'draft', + `a new variant of a ${status} Item stays a draft` + ); + } + } { // #1105: a storage-less title on a multi-size Item never lands on the sized variant. const title = 'Laptop ASUS Zenbook S 14 UX5406AA-SU348W'; @@ -513,7 +615,7 @@ async function run() { assert.equal(result.masterId, 'master-app2-std'); assert.ok(!db.calls.some(c => c.op === 'upsert')); } - console.log(' ✅ [4/5] Item tiers, exact variants, size not stated (#1105) and hints'); + console.log(' ✅ [4/6] Item tiers, exact variants, size not stated (#1105) and hints'); // 5. Pipeline writes const product = (title: string): ShopeeProduct => ({ @@ -887,7 +989,216 @@ async function run() { assert.equal(hintedRefresh.listing?.cluster_id, 'master-legacy'); } console.log( - ' ✅ [5/5] Pipeline writes item_id, nullable cluster_id and keeps hand-set variants' + ' ✅ [5/6] Pipeline writes item_id, nullable cluster_id and keeps hand-set variants' + ); + // 6. Seeds as Item plus variants, Item specs from the seeds (DOS/Rate#1152) + { + const itemSeeds = flagshipSeedsByItem(); + const seen = new Set(); + for (const item of itemSeeds) { + assert.ok(!seen.has(item.itemKey), `one Item per key: ${item.itemKey}`); + seen.add(item.itemKey); + assert.ok(item.variants.length > 0); + for (const variant of item.variants) { + assert.equal(splitModelKey(variant.model_key)?.itemKey, item.itemKey); + } + assert.equal(item.specifications.hardware?.storage_gb, undefined, item.itemKey); + assert.equal(item.key_specs.storage, undefined, item.itemKey); + } + assert.equal( + itemSeeds.reduce((n, item) => n + item.variants.length, 0), + FLAGSHIP_DEVICES_TO_SEED.length, + 'every seed is a variant of exactly one Item' + ); + const series10 = itemSeeds.find(i => i.itemKey === 'apple:watch-series-10-42mm'); + assert.equal(series10?.name, 'Apple Watch Series 10 42mm GPS'); + assert.equal( + itemSeeds.find(i => i.itemKey === 'apple:watch-series-11-46mm')?.name, + 'Apple Watch Series 11 46mm GPS' + ); + assert.equal(itemSeeds.find(i => i.itemKey === 'xiaomi:18-pro-max')?.name, 'Xiaomi 18 Pro Max'); + for (const item of itemSeeds) { + assert.equal(item.specifications.hardware?.ram_gb, undefined, `no RAM on ${item.itemKey}`); + assert.equal(item.key_specs.ram, undefined, `no RAM key spec on ${item.itemKey}`); + } + // The variant keeps its storage; the Item does not. + assert.equal(series10?.variants[0]?.storage_gb, 64); + + // The registry knows the Series 10's one storage, so a bare title keys like its seed. + assert.equal( + normalizer.normalize({ title: 'Apple Watch Series 10 42mm GPS' } as ShopeeProduct).model_key, + 'apple:watch-series-10-42mm:64gb' + ); + + // The registry describes the 42mm case: a 46mm title gets none of its size specs and + // is not benchmark-enriched (so it is never published on 42mm data). + const series10Big = normalizer.normalize({ + title: 'Apple Watch Series 10 46mm GPS', + } as ShopeeProduct); + assert.equal(series10Big.model_key, 'apple:watch-series-10-46mm:64gb'); + assert.equal(series10Big.is_benchmark_enriched, false); + assert.equal(series10Big.battery_mah, null); + assert.notEqual(series10Big.screen_size_inch, 1.77); + assert.equal(series10Big.specifications.display?.resolution, undefined); + assert.equal(series10Big.specifications.design?.weight_g, undefined); + + // Item specs: dry-run writes nothing, apply updates the Items that exist and lists + // the missing ones (Items are created by the M1 trigger, never here). + const seeds = itemSeeds.filter(i => + ['apple:watch-series-10-42mm', 'asus:zenbook-s-14'].includes(i.itemKey) + ); + const seedDb = (present: string[]) => + createDb(call => + call.table === 'items' && call.op === 'select' + ? { + data: present.includes(String(filter(call, 'item_key'))) + ? { + id: `item-${String(filter(call, 'item_key'))}`, + specifications: { hardware: { storage_gb: 1024, ram_gb: 32 } }, + key_specs: { storage: '1TB', ram: '32GB' }, + } + : null, + error: null, + } + : undefined + ); + const dry = seedDb(['asus:zenbook-s-14']); + const offRun = await syncItemSeedSpecs(dry.client, { seeds, apply: true, itemModel: 'off' }); + assert.deepEqual(offRun, { updated: 0, missing: [] }); + assert.equal(dry.calls.length, 0, 'flag off: no Item read or write'); + const dryRun = await syncItemSeedSpecs(dry.client, { seeds, itemModel: 'item' }); + assert.deepEqual(dryRun, { updated: 1, missing: ['apple:watch-series-10-42mm'] }); + assert.ok(!dry.calls.some(c => c.op === 'update')); + const applied = seedDb(['asus:zenbook-s-14', 'apple:watch-series-10-42mm']); + const applyRun = await syncItemSeedSpecs(applied.client, { + seeds, + apply: true, + itemModel: 'item', + }); + assert.equal(applyRun.updated, 2); + const update = applied.calls.find(c => c.op === 'update' && c.table === 'items'); + assert.ok(update?.payload?.specifications); + assert.equal( + (update?.payload?.specifications as { hardware?: Row }).hardware?.storage_gb, + undefined, + 'storage is never written on an Item, even one already there' + ); + assert.equal( + (update?.payload?.specifications as { hardware?: Row }).hardware?.ram_gb, + undefined + ); + assert.equal((update?.payload?.key_specs as Row).storage, undefined); + assert.equal((update?.payload?.key_specs as Row).ram, undefined); + assert.ok(!applied.calls.some(c => c.op === 'insert' || c.op === 'upsert')); + } + // Spec enrichment per Item: one AI ask per Item (no size in the prompt or cache key); + // the variant keeps its own storage and RAM. Mode off still asks per size. + { + const asks: Array<{ storageGb: number | null; ramGb: number | null }> = []; + const enricher = new AiSpecEnricher({ apiKey: '' }); + (enricher as unknown as { fetchSpecsFromAi: unknown }).fetchSpecsFromAi = async (params: { + storageGb: number | null; + ramGb: number | null; + }) => { + asks.push({ storageGb: params.storageGb, ramGb: params.ramGb }); + return { + storage_gb: 1024, + ram_gb: 32, + specifications: { + hardware: { chipset: 'Intel Core i5-1335U', storage_gb: 1024, ram_gb: 32 }, + }, + key_specs: { chip: 'Core i5-1335U', storage: '1TB' }, + }; + }; + const vivobook = candidateFor('Laptop ASUS Vivobook 15 X1504VA 16GB 512GB'); + assert.equal(vivobook.is_benchmark_enriched, false); + const perItem = await enricher.enrichCandidate(vivobook, undefined, { perItem: true }); + assert.deepEqual(asks.at(-1), { storageGb: null, ramGb: null }); + assert.equal(perItem.storage_gb, 512); + assert.equal(perItem.ram_gb, 16); + const bare = candidateFor('Laptop ASUS Vivobook 15 X1504VA'); + const bareItem = await enricher.enrichCandidate(bare, undefined, { perItem: true }); + assert.equal(bareItem.storage_gb, bare.storage_gb, 'no AI storage on a variant'); + assert.notEqual(bareItem.storage_gb, 1024); + assert.notEqual(bareItem.key_specs.storage, '1TB'); + assert.equal(perItem.specifications.hardware?.storage_gb, 512); + assert.equal(perItem.specifications.hardware?.ram_gb, 16); + assert.equal(perItem.specifications.hardware?.chipset, 'Intel Core i5-1335U'); + assert.equal(perItem.key_specs.storage, vivobook.key_specs.storage); + assert.equal(perItem.key_specs.chip, 'Core i5-1335U'); + await enricher.enrichCandidate(vivobook); + assert.deepEqual(asks.at(-1), { storageGb: 512, ramGb: 16 }); + + // The matcher asks per Item only with the flag on. + const seen: unknown[] = []; + const recording = { + enrichCandidate: async (candidate: T, _title?: string, options?: unknown) => { + seen.push(options); + return candidate; + }, + } as unknown as AiSpecEnricher; + await new EntityMatcher(createItemDb({ seededItemId: 'item-vivobook' }).client, recording, { + itemModel: 'item', + }).resolveItemAndVariant(vivobook, 'cat-laptops'); + await new EntityMatcher(createItemDb().client, recording, { + itemModel: 'off', + }).resolveItemAndVariant(vivobook, 'cat-laptops'); + assert.deepEqual(seen, [{ perItem: true }, { perItem: false }]); + } + // Item-level rating (Q4): a listing with no variant refreshes its Item; with a + // variant the master RPC (Item-aware in M2) runs as before; no Item, no Item RPC. + { + const persist = async ( + ids: { + masterId?: string | null; + itemId?: string | null; + itemModel?: 'off' | 'item' | 'nullable_variant'; + }, + mode = 'nullable_variant' + ) => { + const saved = process.env[ITEM_MODEL_ENV]; + process.env[ITEM_MODEL_ENV] = mode; + try { + return await persistOnce(ids); + } finally { + if (saved === undefined) delete process.env[ITEM_MODEL_ENV]; + else process.env[ITEM_MODEL_ENV] = saved; + } + }; + const persistOnce = async (ids: { + masterId?: string | null; + itemId?: string | null; + itemModel?: 'off' | 'item' | 'nullable_variant'; + }) => { + const db = createDb(call => + call.table === 'reviews' && call.op === 'select' ? { data: [], error: null } : undefined + ); + await new AiReviewAnalyzer({ apiKey: '' }).persistAnalysisToSupabase(db.client, { + productId: 'product-1152', + reviews: [], + ...ids, + }); + return db.calls.filter(c => c.op === 'rpc').map(c => [c.table, c.payload] as const); + }; + assert.deepEqual(await persist({ masterId: null, itemId: 'item-zenbook' }), [ + ['refresh_item_stats', { p_item_id: 'item-zenbook' }], + ]); + assert.deepEqual(await persist({ masterId: 'master-zenbook-1tb', itemId: 'item-zenbook' }), [ + ['refresh_master_product_rating', { p_master_id: 'master-zenbook-1tb' }], + ]); + assert.deepEqual(await persist({ masterId: null }), []); + assert.deepEqual(await persist({ masterId: null, itemId: 'item-zenbook' }, 'off'), []); + // The pipeline's own mode wins over the env (a pipeline configured with the model on). + assert.deepEqual( + await persist( + { masterId: null, itemId: 'item-zenbook', itemModel: 'nullable_variant' }, + 'off' + ), + [['refresh_item_stats', { p_item_id: 'item-zenbook' }]] + ); + } + console.log( + ' ✅ [6/6] Seeds grouped by Item; Item specs from the seeds; enrichment and rating per Item (#1152)' ); console.log('🎉 All item-first matcher tests passed!'); } diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 7497b0d82..143476744 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -495,35 +495,35 @@ async function runSuite() { 'apple:watch-series-11-46mm:64gb', ], ['Apple Watch Series 11 GPS 46mm viền nhôm dây thể thao', 'apple:watch-series-11-46mm:64gb'], - ['Apple Watch Series 10 42mm (GPS) Viền Nhôm - Dây Vải', 'apple:watch-series-10-42mm:unknown'], + ['Apple Watch Series 10 42mm (GPS) Viền Nhôm - Dây Vải', 'apple:watch-series-10-42mm:64gb'], ['Apple Watch Series 11 42mm viền thép dây Link Bracelet', 'apple:watch-series-11-42mm:64gb'], // Two sizes, one of them the band's, leave the size-less key (Tier 2). - ['Apple Watch Series 10 dây Sport Band 45mm cho mặt 42mm', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 Sport Loop 45mm cho mặt 46mm', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 Sport Band 45mm cho mặt 42mm', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 dây Sport Band 45mm cho mặt 42mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 Sport Loop 45mm cho mặt 46mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 Sport Band 45mm cho mặt 42mm', 'apple:watch-series-10:64gb'], ['Apple Watch SE 3 GPS Strap 40mm for 44mm case', 'apple:watch-se-3:unknown'], - ['Apple Watch Series 10 Bracelet 42mm | 46mm', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 Bracelet 42mm | 46mm', 'apple:watch-series-10:64gb'], // A band is sold by the case it fits: a band's size alone is the case's. [ 'Apple Watch Series 10 GPS Dây Cao Su 46mm Chính Hãng VN/A', - 'apple:watch-series-10-46mm:unknown', + 'apple:watch-series-10-46mm:64gb', ], ['Apple Watch Series 9 GPS Sport Loop 45mm', 'apple:watch-series-9-45mm:unknown'], ['Apple Watch Series 11 GPS Viền Nhôm Dây Silicone 42mm', 'apple:watch-series-11-42mm:64gb'], // The band phrase ends at ")", ",", "|", ";", an en dash or " - ": a case size after it // wins over the band's. ['Apple Watch Series 11 GPS (Viền nhôm, dây cao su) 46mm', 'apple:watch-series-11-46mm:64gb'], - ['Apple Watch Series 10 dây Sport Band 45mm, mặt 42mm', 'apple:watch-series-10-42mm:unknown'], - ['Apple Watch Series 10 Dây Vải 45mm - 46mm GPS', 'apple:watch-series-10-46mm:unknown'], - ['Apple Watch Series 10 Dây Vải 45mm; 46mm GPS', 'apple:watch-series-10-46mm:unknown'], - ['Apple Watch Series 10 Dây Vải 45mm \u2013 46mm GPS', 'apple:watch-series-10-46mm:unknown'], - ['Apple Watch Series 10 (dây vải 45mm) 46mm', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 dây Sport Band 45mm, mặt 42mm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 Dây Vải 45mm - 46mm GPS', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 Dây Vải 45mm; 46mm GPS', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 Dây Vải 45mm \u2013 46mm GPS', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 (dây vải 45mm) 46mm', 'apple:watch-series-10-46mm:64gb'], // A glued "Sportband" is a band; "không dây" is wireless, no band. - ['Apple Watch Series 10 Sportband 45mm cho mặt 42mm', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 sạc không dây 46mm cho dây 42mm', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 Sportband 45mm cho mặt 42mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 sạc không dây 46mm cho dây 42mm', 'apple:watch-series-10-46mm:64gb'], [ 'Apple Watch Series 10 sạc kho\u0302ng dây 46mm cho dây 42mm', - 'apple:watch-series-10-46mm:unknown', + 'apple:watch-series-10-46mm:64gb', ], // A size in brackets after the band is the case's when it is the only one. [ @@ -532,9 +532,9 @@ async function runSuite() { ], // A decomposed or upper-case "dây" is the same word, so "42mm" after its comma is a // second band size (DOS/Rate#1137); "Bandit" is no band. - ['Apple Watch Series 10 da\u0302y vải 45mm, 42mm', 'apple:watch-series-10:unknown'], - ['APPLE WATCH SERIES 10 DÂY VẢI 45MM, 42MM', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 Bandit 46mm cho mặt 42mm', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 da\u0302y vải 45mm, 42mm', 'apple:watch-series-10:64gb'], + ['APPLE WATCH SERIES 10 DÂY VẢI 45MM, 42MM', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 Bandit 46mm cho mặt 42mm', 'apple:watch-series-10-46mm:64gb'], ]; for (const [title, expectedKey] of watchBandSizeKeys1124) { assert.equal(keyOf(title), expectedKey, title); @@ -550,38 +550,35 @@ async function runSuite() { ['Apple Watch Series 11 GPS Nhôm Dây Sport Loop 42mm | 46mm', 'apple:watch-series-11:64gb'], ['Apple Watch Series 11 Dây Vải 42mm 46mm', 'apple:watch-series-11:64gb'], // The list runs on over every listed size, and a pipe list keys like a comma list. - ['Apple Watch Series 10 Sport Band 42mm, 46mm, 42mm', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 Dây Vải 42mm | 46mm | Chính Hãng', 'apple:watch-series-10:unknown'], - [ - 'Apple Watch Series 10 Sport Band 42mm, 46mm | mặt 46mm', - 'apple:watch-series-10-46mm:unknown', - ], + ['Apple Watch Series 10 Sport Band 42mm, 46mm, 42mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 Dây Vải 42mm | 46mm | Chính Hãng', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 Sport Band 42mm, 46mm | mặt 46mm', 'apple:watch-series-10-46mm:64gb'], // A band phrase without a size starts no list: the size after its comma is the case's. - ['Apple Watch Series 10 Dây Cao Su, 46mm', 'apple:watch-series-10-46mm:unknown'], - ['Apple Watch Series 10 Dây Cao Su | 42mm GPS', 'apple:watch-series-10-42mm:unknown'], - ['Apple Watch Series 10 Dây Cao Su S/M, 46mm', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 Dây Cao Su, 46mm', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 Dây Cao Su | 42mm GPS', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 Dây Cao Su S/M, 46mm', 'apple:watch-series-10-46mm:64gb'], // A listed size keeps its band's fit, a bracket or text after it. - ['Apple Watch Series 10 Sport Band 45mm, 46mm GPS', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 Sport Band 45mm, 46mm GPS', 'apple:watch-series-10:64gb'], [ 'Apple Watch Series 10 GPS Dây Cao Su 42mm, 46mm Chính Hãng VN/A', - 'apple:watch-series-10:unknown', + 'apple:watch-series-10:64gb', ], - ['Apple Watch Series 10 Sport Band 42mm S/M, 46mm M/L', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 Sport Band 42mm | 46mm (S/M)', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 [Sport Band 42mm, 46mm] Mới 100%', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 Dây Sport Band 46mm, 42mm.', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 Sport Band 42mm S/M, 46mm M/L', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 Sport Band 42mm | 46mm (S/M)', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 [Sport Band 42mm, 46mm] Mới 100%', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 Dây Sport Band 46mm, 42mm.', 'apple:watch-series-10:64gb'], // A case size before the band phrase still wins. ['Apple Watch Series 11 GPS 46mm, Dây Cao Su 46mm S/M', 'apple:watch-series-11-46mm:64gb'], // ")", ";", an en dash or " - " still end the band phrase and its list. - ['Apple Watch Series 10 (Sport Band 45mm) 46mm, 42mm', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 (Sport Band 45mm) 46mm, 42mm', 'apple:watch-series-10-46mm:64gb'], // A listed part that names the case ("case", "mặt", "vỏ") is the case's. [ 'Apple Watch Series 11 GPS Sport Band 46mm, 42mm Aluminum Case', 'apple:watch-series-11-42mm:64gb', ], ['Apple Watch Series 11 GPS Sport Band 46mm | 42mm Case', 'apple:watch-series-11-42mm:64gb'], - ['Apple Watch Series 10 Dây Vải 45mm, 42mm mặt nhôm', 'apple:watch-series-10-42mm:unknown'], - ['Apple Watch Series 10 Dây Vải 45mm, 46mm vỏ nhôm', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 Dây Vải 45mm, 42mm mặt nhôm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 Dây Vải 45mm, 46mm vỏ nhôm', 'apple:watch-series-10-46mm:64gb'], ]; for (const [title, expectedKey] of watchBandListKeys1137) { assert.equal(keyOf(title), expectedKey, title); @@ -590,41 +587,35 @@ async function runSuite() { // band word, names no case. const watchBandRangeKeys1162: Array<[string, string]> = [ // The titles of the issue. - ['Apple Watch Series 10 Sport Loop 42mm - 46mm', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 Sport Loop 42mm - 46mm', 'apple:watch-series-10:64gb'], ['Apple Watch SE 3 GPS 40mm, 44mm Dây Sport Band', 'apple:watch-se-3:unknown'], // An en dash range keys like " - "; a pipe or longer list keys like a comma list. - ['Apple Watch Series 10 Sport Loop 42mm \u2013 46mm', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 GPS, 42mm, 46mm Dây Sport Loop', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 GPS 42mm | 46mm Dây Cao Su', 'apple:watch-series-10:unknown'], - ['Apple Watch Series 10 GPS 46mm - 42mm Dây Sport Band', 'apple:watch-series-10:unknown'], + ['Apple Watch Series 10 Sport Loop 42mm \u2013 46mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 GPS, 42mm, 46mm Dây Sport Loop', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 GPS 42mm | 46mm Dây Cao Su', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 GPS 46mm - 42mm Dây Sport Band', 'apple:watch-series-10:64gb'], // A size after the dash with more text, a single size before the band, a size the // case names or a size cut off by ")" is still the case's. - ['Apple Watch Series 10 Sport Loop 42mm - 46mm GPS', 'apple:watch-series-10-46mm:unknown'], - ['Apple Watch Series 10 GPS, 44mm Dây Sport Band', 'apple:watch-series-10-44mm:unknown'], - [ - 'Apple Watch Series 10 Vỏ nhôm 46mm, 42mm Dây Sport Band', - 'apple:watch-series-10-46mm:unknown', - ], - ['Apple Watch Series 10 (GPS 46mm) 42mm Dây Sport', 'apple:watch-series-10-46mm:unknown'], - ['Apple Watch Series 10 GPS 46mm, 46mm Dây Cao Su', 'apple:watch-series-10-46mm:unknown'], + ['Apple Watch Series 10 Sport Loop 42mm - 46mm GPS', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 GPS, 44mm Dây Sport Band', 'apple:watch-series-10-44mm:64gb'], + ['Apple Watch Series 10 Vỏ nhôm 46mm, 42mm Dây Sport Band', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 (GPS 46mm) 42mm Dây Sport', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 GPS 46mm, 46mm Dây Cao Su', 'apple:watch-series-10-46mm:64gb'], [ 'Apple Watch Series 10 (Vỏ nhôm) 46mm, 42mm Dây Sport Band', - 'apple:watch-series-10-46mm:unknown', + 'apple:watch-series-10-46mm:64gb', ], [ 'Apple Watch Series 10 GPS 46mm, 42mm Dây Sport Band mặt 46mm', - 'apple:watch-series-10-46mm:unknown', + 'apple:watch-series-10-46mm:64gb', ], // A range runs on from a listed bare size, not from one with a fit after it. - ['Apple Watch Series 10 Sport Band 42mm S/M, 44mm - 46mm', 'apple:watch-series-10:unknown'], - [ - 'Apple Watch Series 10 Sport Band 42mm, 44mm M/L - 46mm', - 'apple:watch-series-10-46mm:unknown', - ], + ['Apple Watch Series 10 Sport Band 42mm S/M, 44mm - 46mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 Sport Band 42mm, 44mm M/L - 46mm', 'apple:watch-series-10-46mm:64gb'], // A size with a band of its own is no list item: the size before it is the case's. [ 'Apple Watch Series 10 GPS 46mm Dây Sport Band, 42mm Dây Cao Su', - 'apple:watch-series-10-46mm:unknown', + 'apple:watch-series-10-46mm:64gb', ], ]; for (const [title, expectedKey] of watchBandRangeKeys1162) { diff --git a/apps/importer/src/shopee/ai-review-analyzer.ts b/apps/importer/src/shopee/ai-review-analyzer.ts index 1656bdc18..15660b71d 100644 --- a/apps/importer/src/shopee/ai-review-analyzer.ts +++ b/apps/importer/src/shopee/ai-review-analyzer.ts @@ -8,6 +8,7 @@ import { } from './ai-spec-enricher.js'; import type { RateSupabaseClient, ScrapedReviewer } from './types.js'; import { computeRetryDelayMs, sanitizeAiGeneratedText } from './logger.js'; +import { resolveItemModelMode, type ItemModelMode } from './item-model-flag.js'; export { computeRetryDelayMs, sanitizeAiGeneratedText } from './logger.js'; /** @@ -2243,6 +2244,13 @@ Return ONLY valid JSON matching: params: { productId: string; masterId?: string | null; + /** + * The listing's Item (RATE_ITEM_MODEL on). A listing with no variant refreshes its + * Item's rating, which pools every size (design doc 4.1, Q4, DOS/Rate#1152). + */ + itemId?: string | null; + /** The caller's Item model mode; defaults to RATE_ITEM_MODEL. */ + itemModel?: ItemModelMode; merchantId?: string | null; reviews: ReviewInputItem[]; entityName?: string; @@ -2262,6 +2270,7 @@ Return ONLY valid JSON matching: throw new Error('[AiReviewAnalyzer] productId must be a valid non-empty string'); } const safeMasterId = normalizeSafeDbId(params?.masterId); + const safeItemId = normalizeSafeDbId(params?.itemId); const safeMerchantId = normalizeSafeDbId(params?.merchantId); const safePlatform = typeof params?.platform === 'string' @@ -2427,6 +2436,25 @@ Return ONLY valid JSON matching: } } + // 3b. A listing with no variant still rates its Item: the M2 `refresh_item_stats` + // recomputes the Item's pooled rating over all its listings. With a variant, + // `refresh_master_product_rating` above resolves the Item itself (M2 body). + if ( + !safeMasterId && + safeItemId && + (params?.itemModel ?? resolveItemModelMode()) !== 'off' && + typeof supabase.rpc === 'function' + ) { + const { error: itemRpcErr } = await supabase.rpc('refresh_item_stats', { + p_item_id: safeItemId, + }); + if (itemRpcErr) { + throw new Error( + `[AiReviewAnalyzer] Failed to refresh the Item rating via RPC: ${itemRpcErr.message}` + ); + } + } + // 4. Evaluate and update merchant review_risk_score (leaving risk_score for DOSafe threat_intel) let merchantRisk: MerchantRiskEvaluation | undefined; if (safeMerchantId) { diff --git a/apps/importer/src/shopee/ai-spec-enricher.ts b/apps/importer/src/shopee/ai-spec-enricher.ts index c69aa256d..392c1bcdc 100644 --- a/apps/importer/src/shopee/ai-spec-enricher.ts +++ b/apps/importer/src/shopee/ai-spec-enricher.ts @@ -383,6 +383,14 @@ function mergeSpecGroupRecord( * Deep-merges two 10-Field-Group `ProductSpecifications` objects without allowing * `undefined`, `null`, or empty incoming fields to overwrite defined base fields. */ +/** Key specs without the per-size ones (storage, RAM), for an Item-level answer. */ +function withoutSizeKeySpecs(keySpecs: Record): Record { + const rest = { ...keySpecs }; + delete rest.storage; + delete rest.ram; + return rest; +} + export function mergeProductSpecifications( base: unknown, incoming: unknown, @@ -1025,8 +1033,13 @@ REQUIRED JSON SCHEMA: */ public async enrichCandidate( candidate: NormalizedProductCandidate, - rawTitle?: string + rawTitle?: string, + options: { perItem?: boolean } = {} ): Promise { + // Per Item (RATE_ITEM_MODEL on, design doc 5.3, DOS/Rate#1152): the AI is asked and + // cached for the Item, not the size, so every size shares one call; storage and RAM + // stay the variant's own. + const perItem = options?.perItem === true; if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { throw new Error('[AiSpecEnricher] candidate must be a non-null object'); } @@ -1048,8 +1061,8 @@ REQUIRED JSON SCHEMA: const aiResult = await this.fetchSpecsFromAi({ brand: candidate.brand, model: candidate.model, - storageGb: validCandidateStorage, - ramGb: validCandidateRam, + storageGb: perItem ? null : validCandidateStorage, + ramGb: perItem ? null : validCandidateRam, rawTitle, rawDescription: candidate.description, categorySlug: candidate.category_slug, @@ -1071,6 +1084,15 @@ REQUIRED JSON SCHEMA: aiResult.specifications, safeColors ); + if (perItem) { + // The Item's answer names no size: the variant's storage and RAM win. + const ownHardware = sanitizeSpecifications(candidate.specifications).hardware; + mergedSpecifications.hardware = { + ...mergedSpecifications.hardware, + storage_gb: ownHardware?.storage_gb, + ram_gb: ownHardware?.ram_gb, + }; + } const baseKeySpecs = candidate.key_specs && @@ -1093,18 +1115,28 @@ REQUIRED JSON SCHEMA: return { ...candidate, colors: safeColors, - ram_gb: validCandidateRam ?? toBoundedIntOrUndefined(aiResult.ram_gb, 2048) ?? null, - storage_gb: - validCandidateStorage ?? toBoundedIntOrUndefined(aiResult.storage_gb, 65536) ?? null, + // Per Item, the answer names no size: the variant's own (possibly unknown) storage + // and RAM are kept, never the AI's. + ram_gb: perItem + ? validCandidateRam + : (validCandidateRam ?? toBoundedIntOrUndefined(aiResult.ram_gb, 2048) ?? null), + storage_gb: perItem + ? validCandidateStorage + : (validCandidateStorage ?? toBoundedIntOrUndefined(aiResult.storage_gb, 65536) ?? null), screen_size_inch: toBoundedNumberOrUndefined(aiResult.screen_size_inch, 120) ?? validCandidateScreen, battery_mah: toBoundedIntOrUndefined(aiResult.battery_mah, 500000) ?? validCandidateBattery, has_5g: aiResult.has_5g ?? toBooleanOrUndefined(candidate.has_5g) ?? null, specifications: mergedSpecifications, - key_specs: { - ...baseKeySpecs, - ...incomingKeySpecs, - }, + key_specs: perItem + ? { + ...baseKeySpecs, + ...withoutSizeKeySpecs(incomingKeySpecs), + } + : { + ...baseKeySpecs, + ...incomingKeySpecs, + }, spec_source: 'ai_unverified', spec_enrichment_meta: { provider: this.provider, diff --git a/apps/importer/src/shopee/entity-matcher.ts b/apps/importer/src/shopee/entity-matcher.ts index 724fecd10..2641b5bf1 100644 --- a/apps/importer/src/shopee/entity-matcher.ts +++ b/apps/importer/src/shopee/entity-matcher.ts @@ -177,6 +177,64 @@ export interface ItemMatchResult { type MatchedItem = { id: string; itemKey: string }; +/** + * A variant candidate with its Item's specs, description, key specs and images; the + * variant keeps its own storage and RAM (design doc 4.1 "overrides only"). The flat + * columns follow the inherited specs so they never disagree with them. + */ +function inheritItemProfile( + candidate: NormalizedProductCandidate, + profile: ItemSpecProfile +): NormalizedProductCandidate { + const specifications = mergeProductSpecifications( + candidate.specifications, + profile.specifications + ) as Record; + const hardware = (specifications.hardware ?? {}) as Record; + const ownHardware = (candidate.specifications?.hardware ?? {}) as Record; + specifications.hardware = { + ...hardware, + storage_gb: ownHardware.storage_gb, + ram_gb: ownHardware.ram_gb, + }; + const ownKeySpecs = (candidate.key_specs ?? {}) as Record; + const itemKeySpecs = { ...profile.keySpecs }; + delete itemKeySpecs.storage; + delete itemKeySpecs.ram; + const display = profile.specifications.display; + const battery = profile.specifications.battery; + const cellular = profile.specifications.cellular; + return { + ...candidate, + description: profile.description ?? candidate.description, + screen_size_inch: display?.size_inch ?? candidate.screen_size_inch, + battery_mah: battery?.capacity_mah ?? candidate.battery_mah, + has_5g: typeof cellular?.has_5g === 'boolean' ? cellular.has_5g : candidate.has_5g, + specifications: specifications as NormalizedProductCandidate['specifications'], + key_specs: { + ...itemKeySpecs, + ...(ownKeySpecs.storage !== undefined ? { storage: ownKeySpecs.storage } : {}), + ...(ownKeySpecs.ram !== undefined ? { ram: ownKeySpecs.ram } : {}), + } as NormalizedProductCandidate['key_specs'], + images: profile.images.length > 0 ? profile.images : candidate.images, + }; +} + +/** Item-level specs a new variant inherits (design doc 4.1, DOS/Rate#1152). */ +type ItemSpecProfile = { + description: string | null; + specifications: ReturnType; + keySpecs: Record; + images: string[]; +}; + +/** The Item a new variant is created under: its status, and its specs when it has any. */ +type ParentItem = { + status: string | null; + profile: ItemSpecProfile | null; + variantKnown: boolean; +}; + export class EntityMatcher { private enricher: AiSpecEnricher; public readonly itemModel: ItemModelMode; @@ -358,14 +416,19 @@ export class EntityMatcher { // ("iPhone 15 Pro Max" ~ "iPhone 15 Pro"), so a new variant gets its own key. if (fuzzyItem) return seedOwnKey(); - // Missing variant of a known Item: create it under the Item's own key. + // Missing variant of a known Item: create it under the Item's own key. It takes the + // Item's specs (only storage and RAM are its own) and, for a stated size, the + // Item's publish state: a new size of a published Item is published (design doc + // 4.5 step 2, DOS/Rate#1152). const variantModelKey = composeModelKey(item.itemKey, targetVariantKey); + const parent = await this.parentItem(item.id, variantKnown); const seeded = await this.seedMasterProduct( { ...candidate, model_key: variantModelKey }, variantModelKey, safeCategoryId, safeRawTitle, - ambiguousFuzzy + ambiguousFuzzy, + parent ); logger.info( 'entity_match.variant_created', @@ -438,6 +501,45 @@ export class EntityMatcher { return { item, ambiguous: false }; } + /** + * The Item a new variant goes under: its status, and the Item-level fields the variant + * inherits (design doc 4.1). `profile` is null when the Item has no specs yet (a draft + * Item the M1 trigger just created): the variant is then enriched as before. + */ + private async parentItem(itemId: string, variantKnown: boolean): Promise { + const { data, error } = await this.supabase + .from('items') + .select('id, status, description, specifications, key_specs, images') + .eq('id', itemId) + .maybeSingle(); + if (error) { + throw new Error(`[EntityMatcher] Item profile lookup for ${itemId} failed: ${error.message}`); + } + const row = data as Record | null; + const status = typeof row?.status === 'string' ? row.status : null; + const specifications = mergeProductSpecifications(undefined, row?.specifications); + const hasSpecs = Object.entries(specifications).some( + ([group, fields]) => + group !== 'category_slug' && + Boolean(fields) && + typeof fields === 'object' && + Object.values(fields as Record).some(v => v !== undefined && v !== null) + ); + const profile: ItemSpecProfile | null = + row && hasSpecs + ? { + description: typeof row.description === 'string' ? row.description : null, + specifications, + keySpecs: + row.key_specs && typeof row.key_specs === 'object' && !Array.isArray(row.key_specs) + ? (row.key_specs as Record) + : {}, + images: sanitizeHttpUrlList(row.images), + } + : null; + return { status, profile, variantKnown }; + } + private async listItemVariants( itemId: string ): Promise> { @@ -641,11 +743,18 @@ export class EntityMatcher { safeModelKey: string, safeCategoryId: string | undefined, safeRawTitle: string | undefined, - ambiguousFuzzy: boolean + ambiguousFuzzy: boolean, + parent?: ParentItem ): Promise<{ masterId: string; isNew: boolean }> { // Tier 3: No existing master product matches -> Enrich with AI Spec Enricher (if not in local benchmark registry) - // and seed a new Master Product. - candidate = await this.enricher.enrichCandidate(candidate, safeRawTitle); + // and seed a new Master Product. A variant of a known Item takes the Item's specs + // instead: no AI call, and only storage and RAM are its own (DOS/Rate#1152). + const inherited = parent?.profile ?? null; + candidate = inherited + ? inheritItemProfile(candidate, inherited) + : await this.enricher.enrichCandidate(candidate, safeRawTitle, { + perItem: this.itemModel !== 'off', + }); // - Upsert on model_key (unique) so a concurrent crawler inserting the same // canonical entity resolves instead of crashing (plan v7 §5.1 / Gemini B3). @@ -657,8 +766,24 @@ export class EntityMatcher { candidate.spec_source === 'ai_unverified' || candidate.spec_source === 'crawled_title' || !candidate.is_benchmark_enriched; - const seedStatus = + // A stated size of a published Item is published like its Item, unless the listing + // itself is not a new device. + // Under an Item that is not published (draft or archived) a new variant is a draft. + // Under a published Item, an inherited variant is published for a stated size of a + // new device; one with no Item specs yet follows the Tier 3 rule. + const tier3Status = candidate.needs_review || ambiguousFuzzy || isUnverifiedSpec ? 'draft' : 'published'; + const seedStatus = !parent + ? tier3Status + : parent.status !== 'published' + ? 'draft' + : !inherited + ? tier3Status + : parent.variantKnown && + !candidate.needs_review && + (candidate.condition ?? 'new') === 'new' + ? 'published' + : 'draft'; logger.info( 'entity_match.tier3_create_start', `Tier 3: Creating new canonical Master Product: ${candidate.name} (status: ${seedStatus}, spec_source: ${candidate.spec_source ?? 'unknown'})...`, diff --git a/apps/importer/src/shopee/flagship-seed-data.ts b/apps/importer/src/shopee/flagship-seed-data.ts index cdaeb6832..e23721a35 100644 --- a/apps/importer/src/shopee/flagship-seed-data.ts +++ b/apps/importer/src/shopee/flagship-seed-data.ts @@ -1,4 +1,4 @@ -import type { ProductSpecifications, KeySpecs } from '@repo/shared-data'; +import { splitModelKey, type ProductSpecifications, type KeySpecs } from '@repo/shared-data'; /** * Spec-only device catalog entry. It deliberately carries no listings, prices, merchants, @@ -3881,3 +3881,59 @@ export const FLAGSHIP_DEVICES_TO_SEED: FlagshipDeviceSeed[] = [ }, }, ]; + +/** + * A seed Item with its variants (design doc 4.1, 5.3, DOS/Rate#1152): Item-level specs + * come from the first variant without its storage, which stays a variant override. + */ +export interface FlagshipItemSeed { + itemKey: string; + categorySlug: FlagshipDeviceSeed['categorySlug']; + brand: string; + model: string; + /** The first variant's name without its trailing storage. */ + name: string; + description: string; + images: string[]; + specifications: ProductSpecifications; + key_specs: KeySpecs; + variants: FlagshipDeviceSeed[]; +} + +/** Groups the flat seed list by Item (`brand:model`, the key's first two segments). */ +export function flagshipSeedsByItem( + seeds: readonly FlagshipDeviceSeed[] = FLAGSHIP_DEVICES_TO_SEED +): FlagshipItemSeed[] { + const items = new Map(); + for (const seed of seeds) { + const parts = splitModelKey(seed.model_key); + if (!parts) continue; + const existing = items.get(parts.itemKey); + if (existing) { + existing.variants.push(seed); + continue; + } + // Storage and RAM are per variant (design doc 4.1): never on the Item. + const hardware = { ...seed.specifications.hardware }; + delete hardware.storage_gb; + delete hardware.ram_gb; + const keySpecs = { ...seed.key_specs }; + delete keySpecs.storage; + delete keySpecs.ram; + items.set(parts.itemKey, { + itemKey: parts.itemKey, + categorySlug: seed.categorySlug, + brand: seed.brand, + model: seed.model, + // Only a trailing storage leaves the name: "Watch Series 11 46mm GPS 64GB" keeps + // its case size, "Xiaomi 18 Pro Max 512GB" (variant "16GB/512GB") loses the 512GB. + name: seed.name.replace(/ \d+(?:[.,]\d+)? ?(?:GB|TB)$/i, ''), + description: seed.description, + images: seed.images, + specifications: { ...seed.specifications, hardware }, + key_specs: keySpecs as KeySpecs, + variants: [seed], + }); + } + return [...items.values()]; +} diff --git a/apps/importer/src/shopee/jobs/backfill-specs.ts b/apps/importer/src/shopee/jobs/backfill-specs.ts index e9e28d3a4..bf6369120 100644 --- a/apps/importer/src/shopee/jobs/backfill-specs.ts +++ b/apps/importer/src/shopee/jobs/backfill-specs.ts @@ -19,7 +19,10 @@ import { fetchAllPages, resolveBackfillSupabaseTarget } from '../paging.js'; import { CANONICAL_BACKFILL_SPECS_BY_MODEL_KEY, FLAGSHIP_DEVICES_TO_SEED, + flagshipSeedsByItem, + type FlagshipItemSeed, } from '../flagship-seed-data.js'; +import { resolveItemModelMode, type ItemModelMode } from '../item-model-flag.js'; import { createImporterLogger } from '../logger.js'; import { scopeSupabaseToRate, type RateSupabaseClient } from '../types.js'; @@ -46,10 +49,95 @@ export interface BackfillSpecsResult { readonly apply: boolean; readonly catalogUpdated: number; readonly catalogInserted: number; + /** Items whose specs the seeds set (RATE_ITEM_MODEL on only; 0 otherwise). */ + readonly itemsUpdated: number; readonly skipped: number; readonly totalAudited: number; } +/** Item specs carry no storage or RAM: those are per variant (design doc 4.1). */ +function withoutVariantSpecs(specs: ProductSpecifications): ProductSpecifications { + if (!specs.hardware) return specs; + const hardware = { ...specs.hardware }; + delete hardware.storage_gb; + delete hardware.ram_gb; + return { ...specs, hardware }; +} + +function withoutVariantKeySpecs(keySpecs: Record): Record { + const rest = { ...keySpecs }; + delete rest.storage; + delete rest.ram; + return rest; +} + +/** + * Writes each seed Item's specs, key specs, description and images onto its `rate.items` + * row (design doc 4.1, 5.3, DOS/Rate#1152). Variant-only fields (storage) stay on the + * variants. Items are never created here: the M1 sync trigger and M2 backfill own that, + * so an Item not found is skipped. Dry-run unless `apply` is true. + */ +export async function syncItemSeedSpecs( + supabase: RateSupabaseClient, + options: { + apply?: boolean; + seeds?: readonly FlagshipItemSeed[]; + itemModel?: ItemModelMode; + } = {} +): Promise<{ updated: number; missing: string[] }> { + // Items exist only with the Item model on: with it off this writes nothing. + if ((options.itemModel ?? resolveItemModelMode()) === 'off') return { updated: 0, missing: [] }; + const apply = options.apply === true; + const missing: string[] = []; + let updated = 0; + for (const seed of options.seeds ?? flagshipSeedsByItem()) { + const { data: item, error: lookupErr } = await supabase + .from('items') + .select('id, specifications, key_specs') + .eq('item_key', seed.itemKey) + .maybeSingle(); + if (lookupErr) { + throw new Error(`Failed to look up Item ${seed.itemKey}: ${lookupErr.message}`); + } + if (!item) { + missing.push(seed.itemKey); + continue; + } + const row = item as { id: string; specifications?: unknown; key_specs?: unknown }; + if (apply) { + const { error: upErr } = await supabase + .from('items') + .update({ + description: seed.description, + specifications: withoutVariantSpecs( + mergeProductSpecifications(row.specifications, seed.specifications) + ), + key_specs: withoutVariantKeySpecs( + mergeKeySpecs(row.key_specs, seed.key_specs as unknown as Record) + ), + ...(seed.images.length > 0 ? { images: seed.images, thumbnail: seed.images[0] } : {}), + updated_at: new Date().toISOString(), + }) + .eq('id', row.id); + if (upErr) { + throw new Error(`Failed to update Item specs of ${seed.itemKey}: ${upErr.message}`); + } + } + updated++; + logger.info( + 'backfill.item_specs', + `[Item Specs] ${seed.name} (${seed.itemKey}): ${apply ? 'UPDATED' : 'DRY-RUN WOULD UPDATE'}`, + { itemKey: seed.itemKey, apply } + ); + } + if (missing.length > 0) { + logger.warn('backfill.item_missing', `${missing.length} seed Item(s) have no rate.items row`, { + missing, + }); + } + return { updated, missing }; +} + /** * Spec-only audit: every master product must carry all 10 Field Groups and the main + second * camera. Deliberately asserts nothing about offers, reviews or ratings. @@ -355,6 +443,9 @@ export async function runMasterSpecsBackfill( } } + // Item-level specs from the seeds, once Items exist (RATE_ITEM_MODEL on, DOS/Rate#1152). + const itemsUpdated = (await syncItemSeedSpecs(supabase, { apply })).updated; + const masters = await fetchAllPages('Failed to fetch master_products', (from, to) => supabase .from('master_products') @@ -442,13 +533,14 @@ export async function runMasterSpecsBackfill( logger.info( 'backfill.completed', `SPEC CATALOG SYNC COMPLETE [mode=${apply ? 'APPLY' : 'DRY-RUN'}]: ${catalogUpdated} updated, ${catalogInserted} inserted as draft, ${skipped} skipped; ${totalAudited}/${totalAudited} master products verified with 10/10 Field Groups.`, - { apply, catalogUpdated, catalogInserted, skipped, totalAudited } + { apply, catalogUpdated, catalogInserted, itemsUpdated, skipped, totalAudited } ); return { apply, catalogUpdated, catalogInserted, + itemsUpdated, skipped, totalAudited, }; diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index b8d037b67..cb898d1b5 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -381,6 +381,10 @@ const CANONICAL_PHONE_REGISTRY: Record< // title that names no storage still knows it. A model sold in several sizes has none: // its storage-less title keys as ':unknown' (DOS/Rate#1094). only_storage_gb?: number; + // The watch case (mm) the size-dependent specs (screen, battery, weight, dimensions) + // describe. A title naming another case gets none of them and is not benchmark + // enriched (DOS/Rate#1152). + case_mm?: number; } > = { 'apple:iphone-16-pro-max': { @@ -1118,6 +1122,31 @@ const CANONICAL_PHONE_REGISTRY: Record< release_date: 'November 2024', ip_rating: 'Precision-machined aluminum enclosure (Space Black anodized seal)', }, + // One storage (64GB), so a title without it keys like the `:64gb` seed (M0 inventory, + // DOS/Rate#1148, #1152). Size-dependent specs are the 42mm's (`case_mm`). + 'apple:watch-series-10': { + case_mm: 42, + screen_size_inch: 1.77, + battery_mah: 327, + ram_gb: 2, + chipset: 'Apple S10 SiP (64-bit dual-core processor)', + cpu: 'Dual-core S10 with 4-core Neural Engine', + gpu: 'Apple Watch integrated GPU', + os: 'watchOS 11', + weight_g: 30, + dimensions: '42.0 x 36.0 x 9.7 mm', + resolution: '374 x 446 pixels (~326 ppi)', + screen_type: 'Wide-angle LTPO3 OLED Always-On Retina display, 1-60Hz, 2000 nits (peak)', + refresh_rate_hz: 60, + rear_camera: 'Optical Heart Sensor Gen 3 + Electrical Heart Sensor (ECG)', + second_camera: 'Depth gauge (6m) + Water temperature sensor', + front_camera: 'Ambient light & Double Tap gesture sensor', + charging_w: 15, + has_5g: false, + release_date: 'September 2024', + ip_rating: 'WR50 water resistant (50m) + IP6X dust resistant', + only_storage_gb: 64, + }, 'apple:watch-series-11': { screen_size_inch: 1.96, battery_mah: 345, @@ -1975,8 +2004,14 @@ export class ProductNormalizer { // The registry describes one size of a line: its screen and battery do not describe // the other size ("iPad Pro M4 13" is not the 11 inch entry) (DOS/Rate#1113). const appleInches = appleSize && !appleSize.endsWith('mm') ? parseFloat(appleSize) : null; + // A watch registry entry pinned to one case does not describe another one. + const caseMismatch = + benchmark?.case_mm !== undefined && + appleSize.endsWith('mm') && + parseInt(appleSize, 10) !== benchmark.case_mm; const benchmarkSized = benchmark && + !caseMismatch && (appleInches === null || Math.floor(benchmark.screen_size_inch ?? NaN) === Math.floor(appleInches)) ? benchmark @@ -2170,8 +2205,8 @@ export class ProductNormalizer { prices_msrp: `${storageDisplay}: ${safePriceMin.toLocaleString('vi-VN')}₫`, }, design: { - dimensions: benchmark?.dimensions || undefined, - weight_g: benchmark?.weight_g || undefined, + dimensions: (caseMismatch ? undefined : benchmark?.dimensions) || undefined, + weight_g: (caseMismatch ? undefined : benchmark?.weight_g) || undefined, materials: isSmartphone ? isApple ? 'Ceramic Shield front, glass back, metal frame' @@ -2196,7 +2231,7 @@ export class ProductNormalizer { display: { size_inch: categorySlug === 'earbuds' ? undefined : screenSize || undefined, type: benchmark?.screen_type || fallbackScreenType, - resolution: benchmark?.resolution || undefined, + resolution: (caseMismatch ? undefined : benchmark?.resolution) || undefined, refresh_rate_hz: categorySlug === 'earbuds' ? undefined @@ -2336,8 +2371,8 @@ export class ProductNormalizer { key_specs: keySpecs, description: sanitizedDescription && sanitizedDescription.length > 0 ? sanitizedDescription : undefined, - is_benchmark_enriched: Boolean(benchmark), - spec_source: benchmark ? 'benchmark_registry' : 'crawled_title', + is_benchmark_enriched: Boolean(benchmark) && !caseMismatch, + spec_source: benchmark && !caseMismatch ? 'benchmark_registry' : 'crawled_title', }; } } diff --git a/apps/importer/src/shopee/shopee-to-supabase.ts b/apps/importer/src/shopee/shopee-to-supabase.ts index 71a96b3d6..68a793378 100644 --- a/apps/importer/src/shopee/shopee-to-supabase.ts +++ b/apps/importer/src/shopee/shopee-to-supabase.ts @@ -1196,6 +1196,8 @@ export class ShopeeIngestionPipeline { { productId, masterId, + itemId, + itemModel, merchantId, reviews: analysisInput, entityName: candidate.name, From a238a2c487d41790d40a401bfc922934fef73ebe Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:54:53 +0700 Subject: [PATCH 16/69] fix(normalizer): pin the series 11 registry entry to its 46mm case (#1221) * fix(normalizer): pin the series 11 registry entry to its 46mm case The apple:watch-series-11 entry describes the 46mm case, but every Series 11 title used its screen, battery, weight, dimensions and resolution and was benchmark-enriched. With case_mm 46 (added for Series 10 in #1152), a 42mm title gets none of those and is not benchmark-enriched, so its Tier 3 master is a draft. Keys do not change. Closes #1211 * test(normalizer): series 11 size-less title and 42mm dimensions Refs #1211 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 30 +++++++++++++++---- .../importer/src/shopee/product-normalizer.ts | 2 ++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 143476744..ceb216c16 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -622,12 +622,30 @@ async function runSuite() { assert.equal(keyOf(title), expectedKey, title); } // The registry has one entry per line: a sized title keeps the line's benchmark, but - // the entry's screen and battery describe only the size it was written for. - assert.equal( - normalizer.normalize(createDummyShopeeProduct('Apple Watch Series 11 GPS 42mm')) - .is_benchmark_enriched, - true - ); + // the entry's screen and battery describe only the size it was written for. A watch + // entry pinned to one case (`case_mm`) does not describe another case at all: the + // Series 11 entry is the 46mm, so a 42mm title is not benchmark-enriched and gets none + // of its size specs (DOS/Rate#1211). + const series11Small = normalizer.normalize( + createDummyShopeeProduct('Apple Watch Series 11 GPS 42mm') + ); + assert.equal(series11Small.model_key, 'apple:watch-series-11-42mm:64gb'); + assert.equal(series11Small.is_benchmark_enriched, false); + assert.equal(series11Small.battery_mah, null); + assert.notEqual(series11Small.screen_size_inch, 1.96); + assert.equal(series11Small.specifications.design?.weight_g, undefined); + assert.equal(series11Small.specifications.display?.resolution, undefined); + assert.equal(series11Small.specifications.design?.dimensions, undefined); + // A title with no case size keeps the entry as before. + const series11Bare = normalizer.normalize(createDummyShopeeProduct('Apple Watch Series 11 GPS')); + assert.equal(series11Bare.model_key, 'apple:watch-series-11:64gb'); + assert.equal(series11Bare.is_benchmark_enriched, true); + const series11Big = normalizer.normalize( + createDummyShopeeProduct('Apple Watch Series 11 GPS 46mm') + ); + assert.equal(series11Big.is_benchmark_enriched, true); + assert.equal(series11Big.screen_size_inch, 1.96); + assert.equal(series11Big.battery_mah, 345); const ipadPro13 = normalizer.normalize( createDummyShopeeProduct('iPad Pro M4 13 inch WiFi 256GB') ); diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index cb898d1b5..3bd5bac84 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1147,7 +1147,9 @@ const CANONICAL_PHONE_REGISTRY: Record< ip_rating: 'WR50 water resistant (50m) + IP6X dust resistant', only_storage_gb: 64, }, + // Size-dependent specs are the 46mm's (`case_mm`, DOS/Rate#1211). 'apple:watch-series-11': { + case_mm: 46, screen_size_inch: 1.96, battery_mah: 345, ram_gb: 2, From ecd488372586a187a50473e56f318fa17fd1b728 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:00:26 +0700 Subject: [PATCH 17/69] fix(web): ai review summary says it covers one store's latest analysis (#1220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): ai review summary says it covers one store's latest analysis The summary sentence comes from the latest review_analysis row, which is one listing's batch, while the page and the percentages beside it count every review. "Đã phân tích 5 đánh giá" therefore read as 5 of the 15 shown. The vi/en/cs copy now says "Lần phân tích gần nhất (một nơi bán) xét 5 đánh giá" and drops the batch's own rating, which is not the product score. Scoring is unchanged. Refs #1216 * fix(web): read the summary as 'đã xét' in vietnamese Refs #1216 Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/analyzer-copy-locale.spec.ts | 9 ++++++--- apps/web/locales/cs.json | 4 ++-- apps/web/locales/en.json | 4 ++-- apps/web/locales/vi.json | 4 ++-- apps/web/src/lib/__tests__/analyzer-copy.test.ts | 12 +++++++++++- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/apps/web/e2e/analyzer-copy-locale.spec.ts b/apps/web/e2e/analyzer-copy-locale.spec.ts index 11b1d816d..c2b22dffb 100644 --- a/apps/web/e2e/analyzer-copy-locale.spec.ts +++ b/apps/web/e2e/analyzer-copy-locale.spec.ts @@ -6,17 +6,20 @@ import { expect, test } from '@playwright/test'; const PRODUCT = '/smartphones/e2e-review-labels-phone'; const COPY = { vi: { - summary: 'Đã phân tích 6 đánh giá: giữ lại 6 đánh giá thực chất', + summary: + 'Lần phân tích gần nhất (một nơi bán) đã xét 6 đánh giá: giữ lại 6 đánh giá thực chất', pro: 'Nhận phản hồi tích cực từ người dùng thực tế', reason: 'Đánh giá chi tiết, hợp lệ từ người dùng thực tế.', }, en: { - summary: 'Analyzed 6 reviews: kept 6 genuine reviews', + summary: + 'The latest analysis (one store) checked 6 reviews: kept 6 genuine reviews', pro: 'Positive feedback from real users', reason: 'Detailed, valid review from a real user.', }, cs: { - summary: 'Analyzováno 6 recenzí: ponecháno 6 skutečných recenzí', + summary: + 'Poslední analýza (jeden obchod) prověřila 6 recenzí: ponecháno 6 skutečných recenzí', pro: 'Pozitivní zpětná vazba od skutečných uživatelů', reason: 'Podrobná, platná recenze od skutečného uživatele.', }, diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 57c35b581..64e0ded43 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1875,8 +1875,8 @@ "reasonCommunityAdLinkExcluded": "Zjištěn reklamní odkaz nebo přesměrování mimo platformu, vynecháno z průměru.", "reasonCommunityCompetitorExcluded": "Podezření na přesměrování ke konkurenci, vynecháno z průměru.", "reasonCommunityTooShortExcluded": "Příliš krátké na ověření zkušenosti, vynecháno z průměru.", - "summaryDefault": "Analyzováno {total} recenzí: ponecháno {kept} skutečných recenzí (vážené skóre JReviews: {rating}/5★) a z celkového skóre vyřazeno {removed} spamových, odměnových, falešných nebo bombardujících recenzí.", - "summaryNoAuthentic": "Po odfiltrování {removed} spamových nebo odměnových komentářů zatím žádné skutečné recenze.", + "summaryDefault": "Poslední analýza (jeden obchod) prověřila {total} recenzí: ponecháno {kept} skutečných recenzí a vyřazeno {removed} spamových, odměnových, falešných nebo bombardujících recenzí.", + "summaryNoAuthentic": "Poslední analýza (jeden obchod) po odfiltrování {removed} spamových nebo odměnových komentářů nenašla žádné skutečné recenze.", "proPositiveFeedback": "Pozitivní zpětná vazba od skutečných uživatelů", "originalVietnamese": "vietnamsky" }, diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index c7785fc85..796b19dd9 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1875,8 +1875,8 @@ "reasonCommunityAdLinkExcluded": "Advertising link or off-platform redirect detected, left out of the average.", "reasonCommunityCompetitorExcluded": "Suspected redirect to a competitor, left out of the average.", "reasonCommunityTooShortExcluded": "Too short to verify the experience, left out of the average.", - "summaryDefault": "Analyzed {total} reviews: kept {kept} genuine reviews (weighted JReviews score: {rating}/5★) and removed {removed} spam, coin-farming, fake or review-bombing reviews from the overall score.", - "summaryNoAuthentic": "No genuine reviews yet after filtering {removed} spam or coin-farming comments.", + "summaryDefault": "The latest analysis (one store) checked {total} reviews: kept {kept} genuine reviews and removed {removed} spam, coin-farming, fake or review-bombing reviews.", + "summaryNoAuthentic": "The latest analysis (one store) found no genuine reviews after filtering {removed} spam or coin-farming comments.", "proPositiveFeedback": "Positive feedback from real users", "originalVietnamese": "in Vietnamese" }, diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 126165ce6..e12871f32 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -1875,8 +1875,8 @@ "reasonCommunityAdLinkExcluded": "Phát hiện đường dẫn quảng cáo hoặc điều hướng ngoài luồng, loại khỏi điểm trung bình.", "reasonCommunityCompetitorExcluded": "Nghi vấn điều hướng sang đối thủ, loại khỏi điểm trung bình.", "reasonCommunityTooShortExcluded": "Nội dung quá ngắn để kiểm chứng trải nghiệm, loại khỏi điểm trung bình.", - "summaryDefault": "Đã phân tích {total} đánh giá: giữ lại {kept} đánh giá thực chất (điểm JReviews có trọng số: {rating}/5★) và loại bỏ {removed} đánh giá rác, nhận xu, ảo hoặc bom hàng khỏi điểm tổng.", - "summaryNoAuthentic": "Chưa có đánh giá thực chất sau khi lọc {removed} bình luận rác/nhận xu.", + "summaryDefault": "Lần phân tích gần nhất (một nơi bán) đã xét {total} đánh giá: giữ lại {kept} đánh giá thực chất và loại bỏ {removed} đánh giá rác, nhận xu, ảo hoặc bom hàng.", + "summaryNoAuthentic": "Lần phân tích gần nhất (một nơi bán) chưa thấy đánh giá thực chất sau khi lọc {removed} bình luận rác/nhận xu.", "proPositiveFeedback": "Nhận phản hồi tích cực từ người dùng thực tế", "originalVietnamese": "tiếng Việt" }, diff --git a/apps/web/src/lib/__tests__/analyzer-copy.test.ts b/apps/web/src/lib/__tests__/analyzer-copy.test.ts index 534053f35..d21eb97f6 100644 --- a/apps/web/src/lib/__tests__/analyzer-copy.test.ts +++ b/apps/web/src/lib/__tests__/analyzer-copy.test.ts @@ -124,6 +124,14 @@ function main() { 'Phát hiện đường dẫn quảng cáo hoặc điều hướng ngoài luồng nghi vấn Seeding.', summaryDefault: 'Đã phân tích {total} đánh giá: giữ lại {kept} đánh giá thực chất (điểm JReviews có trọng số: {rating}/5★) và loại bỏ {removed} đánh giá rác/nhận xu/seeding/bom hàng khỏi điểm tổng.', + // The UI says it covers one store's latest batch (DOS/Rate#1216). + summaryNoAuthentic: + 'Chưa có đánh giá thực chất sau khi lọc {removed} bình luận rác/nhận xu.', + }; + // Values the UI leaves out on purpose: the summary speaks for one store's + // latest batch, whose rating is not the product's score (DOS/Rate#1216). + const dropped: Partial> = { + summaryDefault: ['rating'], }; const covered = new Set(); for (const [text, key] of samples) { @@ -138,7 +146,9 @@ function main() { assert.ok(!/seeding/i.test(vi[copy.key]!), `${key} still says seeding`); assert.deepEqual( placeholders(vi[copy.key]!), - placeholders(template), + placeholders(template).filter( + (p) => !(dropped[copy.key] ?? []).includes(p ?? '') + ), `${key} uses every value the stored string carries` ); } From e80fd3bf29675eb15c2e68ed408c1c50d7dd8840 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:00:42 +0700 Subject: [PATCH 18/69] fix(web): four vietnamese copy defects on product, compare and deals (#1226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): four vietnamese copy defects on product, compare and deals - sensor-shift OIS reads "chống rung dịch chuyển cảm biến (OIS)" once; the bare OIS rule no longer rewrites it again (#1210). - the product quick-spec durability uses localizeSpecValue like the spec section (#1219). - compare skips the ppi suffix when the resolution already names it; a bare "No" storage expansion is spelled "No card slot" so it localizes, and vi reads "Không hỗ trợ thẻ nhớ" like the en/cs maps (#1214). - deals count reads as a display limit, not as devices failing the budget (#1218). * fix(web): skip the compare ppi suffix for unspaced ppi too Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/vi.json | 2 +- .../app/[locale]/smartphones/[slug]/page.tsx | 8 ++--- .../components/compare/CompareWorkspace.tsx | 12 +++++-- .../lib/__tests__/product-detail-copy.test.ts | 33 ++++++++++++++++++- .../lib/__tests__/product-spec-rows.test.ts | 24 ++++++++++++++ apps/web/src/lib/product-spec-rows.ts | 11 +++++-- apps/web/src/lib/spec-localizer.ts | 15 +++++++-- 7 files changed, 92 insertions(+), 13 deletions(-) diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index e12871f32..8f086aa06 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -685,7 +685,7 @@ "dealsMdTableHead": "| Hạng | Thiết bị | Hãng | Điểm theo nhu cầu | Điểm JReviews | Giá thấp nhất | Tiết kiệm giữa các sàn | Tín hiệu giá |", "samePrice": "Đồng giá", "filterBadge": "Tìm deal đã kiểm tra trên 10 sàn", - "showingCount": "Đang hiển thị {visible}/{count} thiết bị thỏa mãn ngân sách", + "showingCount": "Hiển thị {visible} trên {count} thiết bị trong ngân sách", "copiedMdBtn": "✓ Đã sao chép Markdown!", "copyMdBtn": "Sao chép Markdown", "exportCsvBtn": "Xuất CSV ({count})", diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index 0404887dc..c7e2c297d 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -57,6 +57,7 @@ import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; import { normalizeRateLocale } from '@/lib/rate-i18n'; import { getSpecUiDictionary, + localizeSpecValue, specGroupHeading, specSectionBadge, } from '@/lib/spec-localizer'; @@ -868,10 +869,9 @@ export async function ProductDetailView({
diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index bdd17dc5f..da3e52458 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -65,6 +65,7 @@ import { upsertWatchlistItem } from '@/components/watchlist/watchlist-store'; import { getSpecUiDictionary, localizeSpecValue, + normalizeStorageExpansion, specGroupHeading, } from '@/lib/spec-localizer'; import { compareRowIsShown, known5g } from '@/lib/product-spec-rows'; @@ -332,7 +333,12 @@ export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { renderValue: (p) => { const specs = (p.specifications || {}) as ProductSpecifications; const res = specs.display?.resolution || '-'; - const ppi = specs.display?.ppi ? ` (${specs.display.ppi} ppi)` : ''; + // A resolution text that already names its density keeps it + // once (DOS/Rate#1214). + const ppi = + specs.display?.ppi && !/ppi\b/i.test(res) + ? ` (${specs.display.ppi} ppi)` + : ''; return res === '-' && !ppi ? '-' : `${res}${ppi}`; }, numericValue: (p) => { @@ -433,7 +439,9 @@ export function buildSpecGroups(t: CompareTranslator): SpecGroupDef[] { specs.hardware?.os, specs.hardware?.storage_expansion ? t('valCardSlotPrefix', { - value: specs.hardware.storage_expansion, + value: normalizeStorageExpansion( + specs.hardware.storage_expansion + ), }) : null, ].filter(Boolean); diff --git a/apps/web/src/lib/__tests__/product-detail-copy.test.ts b/apps/web/src/lib/__tests__/product-detail-copy.test.ts index a19c3f641..0ecbf7a21 100644 --- a/apps/web/src/lib/__tests__/product-detail-copy.test.ts +++ b/apps/web/src/lib/__tests__/product-detail-copy.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { computeProductRadarSignal } from '../../server/rate/deals'; import { bayesianTierKey, radarExplanation } from '../product-detail-copy'; -import { localizeSpecValue, untranslatedViWords } from '../spec-localizer'; +import { + localizeSpecValue, + normalizeStorageExpansion, + untranslatedViWords, +} from '../spec-localizer'; import { computeBayesianRatingSummary } from '../rate-api'; type Messages = Record>; @@ -223,6 +227,33 @@ function main() { localizeSpecValue('5 ATM water resistant (bơi hồ và bơi biển)', 'vi'), '5 ATM kháng nước (bơi hồ và bơi biển)' ); + // OIS is named once (DOS/Rate#1210). + assert.equal( + localizeSpecValue( + '48 MP, f/1.6, (wide), dual pixel PDAF, sensor-shift OIS', + 'vi' + ), + '48 MP, f/1.6, (góc rộng), lấy nét Dual Pixel PDAF, chống rung dịch chuyển cảm biến (OIS)' + ); + assert.equal( + localizeSpecValue('12 MP, f/2.8, (telephoto), PDAF, OIS', 'vi'), + '12 MP, f/2.8, (tele), PDAF, chống rung quang học OIS' + ); + // The durability quick spec reads like the spec section (DOS/Rate#1219). + assert.equal( + localizeSpecValue('IP68 dust/water resistant (up to 6m for 30 min)', 'vi'), + 'IP68 kháng bụi và nước (ở độ sâu tới 6m trong 30 phút)' + ); + // A bare "No" storage expansion means no card slot (DOS/Rate#1214). + for (const [raw, vi] of [ + ['No', 'Không hỗ trợ thẻ nhớ'], + ['No (NVMe)', 'Không hỗ trợ thẻ nhớ (NVMe)'], + ['No card slot', 'Không hỗ trợ thẻ nhớ'], + ['microSDXC (dedicated slot)', 'microSDXC (dedicated slot)'], + ] as const) { + assert.equal(localizeSpecValue(normalizeStorageExpansion(raw), 'vi'), vi); + } + assert.equal(normalizeStorageExpansion('Nope'), 'Nope'); // Vietnamese words are whole words, not "th" + "ng". assert.deepEqual(untranslatedViWords('thông dụng'), []); // English pages are untouched. diff --git a/apps/web/src/lib/__tests__/product-spec-rows.test.ts b/apps/web/src/lib/__tests__/product-spec-rows.test.ts index 8f38cefb9..97138447b 100644 --- a/apps/web/src/lib/__tests__/product-spec-rows.test.ts +++ b/apps/web/src/lib/__tests__/product-spec-rows.test.ts @@ -225,6 +225,30 @@ async function testCompareUnknownSpecs() { }), 'USB-C 3.2 | GPS' ); + // The resolution text that names its ppi keeps one (DOS/Rate#1214). + const display = (resolution: string) => + render('display_resolution', { + specifications: { display: { resolution, ppi: 460 } }, + }); + assert.equal( + display('1290 x 2796 pixels, 19.5:9 ratio (~460 ppi density)'), + '1290 x 2796 pixels, 19.5:9 ratio (~460 ppi density)' + ); + assert.equal(display('1290 x 2796 pixels'), '1290 x 2796 pixels (460 ppi)'); + // A bare "No" card slot reaches the localizer spelled out. + const tValues = ((key: string, values?: { value?: string }) => + values?.value ? `${key}: ${values.value}` : key) as unknown as Parameters< + typeof buildSpecGroups + >[0]; + const osExp = buildSpecGroups(tValues) + .flatMap((g) => g.rows) + .find((r) => r.id === 'hw_os_exp')!; + assert.equal( + osExp.renderValue({ + specifications: { hardware: { os: 'iOS 20', storage_expansion: 'No' } }, + } as never), + 'iOS 20 | valCardSlotPrefix: No card slot' + ); console.warn(' ✅ Compare shows no invented value for unknown specs'); } diff --git a/apps/web/src/lib/product-spec-rows.ts b/apps/web/src/lib/product-spec-rows.ts index 43591be36..356ad54aa 100644 --- a/apps/web/src/lib/product-spec-rows.ts +++ b/apps/web/src/lib/product-spec-rows.ts @@ -4,7 +4,11 @@ import { type CategorySpecTemplate, type SpecGroupKey, } from '@repo/shared-data/rules'; -import { localizeSpecValue, type SpecUiDictionary } from './spec-localizer'; +import { + localizeSpecValue, + normalizeStorageExpansion, + type SpecUiDictionary, +} from './spec-localizer'; export interface ProductSpecRow { label: string; @@ -134,7 +138,10 @@ export function buildProductSpecGroups({ }, shows('hardware', 'storage_expansion') && { label: f.storageExpansion, - value: loc(s.hardware?.storage_expansion), + value: loc( + s.hardware?.storage_expansion && + normalizeStorageExpansion(s.hardware.storage_expansion) + ), }, shows('hardware', 'os') && { label: f.os, value: loc(s.hardware?.os) }, ], diff --git a/apps/web/src/lib/spec-localizer.ts b/apps/web/src/lib/spec-localizer.ts index 90ac0ce86..fe310b898 100644 --- a/apps/web/src/lib/spec-localizer.ts +++ b/apps/web/src/lib/spec-localizer.ts @@ -387,7 +387,7 @@ const VI_SPEC_REPLACEMENTS: Array<[RegExp, string]> = [ [/\bSingle Camera\b/gi, 'Camera đơn'], [/\bAvailable\.\s*Released\s+/gi, 'Đang kinh doanh - Phát hành '], [/\bAvailable\b/gi, 'Đang kinh doanh'], - [/\bNo card slot\b/gi, 'Không hỗ trợ khe thẻ nhớ'], + [/\bNo card slot\b/gi, 'Không hỗ trợ thẻ nhớ'], [/\bnon-removable\b/gi, 'liền máy'], [/\bdust\/water resistant\b/gi, 'kháng bụi và nước'], [/\bwater resistant\b/gi, 'kháng nước'], @@ -421,8 +421,9 @@ const VI_SPEC_REPLACEMENTS: Array<[RegExp, string]> = [ [/\(variable aperture\)/gi, '(khẩu độ biến thiên)'], [/\bmulti-directional PDAF\b/gi, 'lấy nét đa hướng PDAF'], [/\bdual pixel PDAF\b/gi, 'lấy nét Dual Pixel PDAF'], - [/\bsensor-shift OIS\b/gi, 'chống rung dịch chuyển cảm biến OIS'], - [/\bOIS\b/g, 'chống rung quang học OIS'], + [/\bsensor-shift OIS\b/gi, 'chống rung dịch chuyển cảm biến (OIS)'], + // The rule above already names the OIS it rewrites (DOS/Rate#1210). + [/(? = [ [/\boptical zoom\b/gi, 'optický zoom'], ]; +/** + * Spells out a storage expansion value stored as a bare "No", which means + * no card slot, so `localizeSpecValue` can translate it (DOS/Rate#1214). + */ +export function normalizeStorageExpansion(raw: string): string { + return raw.replace(/^\s*No(?=\s*(?:[(,;]|$))/i, 'No card slot'); +} + const DISALLOWED_SPEC_TEXT_REGEX = /^(?:null|undefined|nan|none|n\/a|infinity|-infinity|__proto__|constructor|prototype)$/i; From 1ffc96ceb961caface52d768fb9bca2407b98173 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:03:02 +0700 Subject: [PATCH 19/69] fix(web): review cards label the rating weight and the trust estimate apart (#1224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): review cards label the rating weight and the trust estimate apart Every review showed "Trọng số: 1.00x" on its badge and "Trọng số 0.50x" in the line under it. The badge is the weight the product score uses (helpfulness_weight, after votes and reports); the line is the 6-factor trust model's estimate. The badge now reads "Trọng số tính điểm", and the model's explanations start with "Độ tin cậy" (en "Rating weight" / "Trust", cs "Váha v hodnocení" / "Důvěryhodnost"). Scoring is unchanged. Refs #1215 * fix(web): call the trust model's figure an estimate, label merchant weights alike Review: "Kiểm tra độ tin cậy (6 yếu tố): Độ tin cậy 0.50x" repeated the label, and the figure (A·B·C·D·E·F) takes in depth and votes, not trust alone. It now reads "Ước tính 0.50x" (en "Estimate", cs "Odhad"). The merchant page's review flag names the same helpfulness weight "trọng số tính điểm" like the review badge. Refs #1215 Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/review-labels-locale.spec.ts | 9 ++++++++- apps/web/locales/cs.json | 20 +++++++++---------- apps/web/locales/en.json | 20 +++++++++---------- apps/web/locales/vi.json | 20 +++++++++---------- .../src/lib/__tests__/review-display.test.ts | 11 +++++++++- 5 files changed, 48 insertions(+), 32 deletions(-) diff --git a/apps/web/e2e/review-labels-locale.spec.ts b/apps/web/e2e/review-labels-locale.spec.ts index 5ce2fb232..4c2ca99db 100644 --- a/apps/web/e2e/review-labels-locale.spec.ts +++ b/apps/web/e2e/review-labels-locale.spec.ts @@ -59,8 +59,15 @@ for (const [locale, labels] of Object.entries(LABELS)) { const ratingWeight = page.getByTestId('review-rating-weight'); await expect(ratingWeight).toHaveCount(6); await expect(ratingWeight.first()).toHaveText( - /^(Trọng số|Weight|Váha): \d\.\d\dx$/ + /^(Trọng số tính điểm|Rating weight|Váha v hodnocení): \d\.\d\dx$/ ); + // The model's estimate is labelled as trust, never as a second weight + // (DOS/Rate#1215). + await expect( + page + .getByTestId('review-6axis-breakdown') + .filter({ hasText: /(Trọng số|Weight|Váha) \d/ }) + ).toHaveCount(0); // The seeded phone has buyer reviews only, so the note is the user-only one. await expect( page.getByText(labels.blendNote, { exact: true }) diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 64e0ded43..46ce780f9 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -534,20 +534,20 @@ "sponsorshipBadgeIncentivized": "Za odměnu / výhodu", "sponsorshipBadgeAffiliate": "Affiliate marketing", "starAnomalyWarning": "Neobvyklé rozložení hvězdiček: {fiveStarPct} % recenzí má plných 5 hvězdiček ({lowEffortPct} % jen s hvězdičkami nebo bez podrobností o zkušenosti)", - "weightExplanationShippingOnly": "Váha 0.00x pro produkt: jen komentář k doručení/balení (přesunuto do skóre prodejce)", - "weightExplanationFiller": "Váha 0.00x: komentář kvůli odměnám / bez popisu zkušenosti s produktem", - "weightExplanationSabotage": "Váha 0.00x: podezření na nepodložené poškozování konkurencí", - "weightExplanationBombing": "Váha 0.00x: neobvyklé hodnocení / review bombing", - "weightExplanationSeeding": "Váha 0.00x: podezření na falešnou recenzi nebo duplicitní šablonový text", - "weightExplanationStarsOnly": "Váha {weight}x: jen hvězdičky, bez textu (C=0.25)", - "weightExplanationPlatformLabel": "Váha {weight}x: výchozí štítek platformy podle počtu hvězdiček (C=0.25)", - "weightExplanationLowEffort": "Váha {weight}x: krátký komentář s málo technickými detaily (C=0.50)", + "weightExplanationShippingOnly": "Odhad 0.00x pro produkt: jen komentář k doručení/balení (přesunuto do skóre prodejce)", + "weightExplanationFiller": "Odhad 0.00x: komentář kvůli odměnám / bez popisu zkušenosti s produktem", + "weightExplanationSabotage": "Odhad 0.00x: podezření na nepodložené poškozování konkurencí", + "weightExplanationBombing": "Odhad 0.00x: neobvyklé hodnocení / review bombing", + "weightExplanationSeeding": "Odhad 0.00x: podezření na falešnou recenzi nebo duplicitní šablonový text", + "weightExplanationStarsOnly": "Odhad {weight}x: jen hvězdičky, bez textu (C=0.25)", + "weightExplanationPlatformLabel": "Odhad {weight}x: výchozí štítek platformy podle počtu hvězdiček (C=0.25)", + "weightExplanationLowEffort": "Odhad {weight}x: krátký komentář s málo technickými detaily (C=0.50)", "blendNoteBlended": "Váhy: 60 % ověření kupující + 40 % experti Rate Lab (spolehlivost ≥ 60 %)", "blendNoteSeparateLowConfidence": "Skóre expertů a kupujících zvlášť (spojí se 60/40, až spolehlivost kupujících dosáhne střední úrovně, spolehlivost ≥ 60 %)", "blendNoteUserOnly": "Váhy: 100 % ověření kupující (falešné recenze a spam za odměny odfiltrovány)", "blendNoteExpertOnly": "Váhy: 100 % expertní hodnocení Rate Lab", "blendNoteInsufficientData": "Zatím nedostatek ověřených dat z recenzí", - "ratingWeightBadge": "Váha: {weight}x", + "ratingWeightBadge": "Váha v hodnocení: {weight}x", "ratingWeightTitle": "Váha použitá v hodnocení produktu (po započtení užitečných hlasů a nahlášení)", "trustModelBadge": "Kontrola důvěryhodnosti (6 faktorů): {explanation}", "variationPrefix": "Varianta: {variation}", @@ -916,7 +916,7 @@ "noReviewsYet": "Pro tento obchod zatím nejsou zaznamenány žádné recenze.", "flagCoinSpam": "Spam za odměnu (váha 0,0)", "flagSeedingFake": "Podezření na falešnou recenzi (váha 0,0)", - "flagAuthenticWeight": "✓ Skutečná recenze (váha {weight})", + "flagAuthenticWeight": "✓ Skutečná recenze (váha v hodnocení {weight})", "flagPendingAnalysis": "Čeká na analýzu", "noCommentBody": "(Bez textového komentáře)", "listingsSectionEyebrow": "Cenové rozpětí a nadsazené původní ceny", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 796b19dd9..0cea5019e 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -534,20 +534,20 @@ "sponsorshipBadgeIncentivized": "Incentivized / Rewarded", "sponsorshipBadgeAffiliate": "Affiliate marketing", "starAnomalyWarning": "Unusual star distribution: {fiveStarPct}% of reviews are a perfect 5 stars ({lowEffortPct}% are star-only or lack details about the experience)", - "weightExplanationShippingOnly": "Weight 0.00x for the product: shipping/packaging comment only (moved to the merchant score)", - "weightExplanationFiller": "Weight 0.00x: coin-farming comment / no product experience described", - "weightExplanationSabotage": "Weight 0.00x: suspected unfounded competitor sabotage", - "weightExplanationBombing": "Weight 0.00x: abnormal review / review bombing", - "weightExplanationSeeding": "Weight 0.00x: suspected fake review or duplicated template text", - "weightExplanationStarsOnly": "Weight {weight}x: stars only, no text (C=0.25)", - "weightExplanationPlatformLabel": "Weight {weight}x: the platform default label for the star rating (C=0.25)", - "weightExplanationLowEffort": "Weight {weight}x: short comment with few technical details (C=0.50)", + "weightExplanationShippingOnly": "Estimate 0.00x for the product: shipping/packaging comment only (moved to the merchant score)", + "weightExplanationFiller": "Estimate 0.00x: coin-farming comment / no product experience described", + "weightExplanationSabotage": "Estimate 0.00x: suspected unfounded competitor sabotage", + "weightExplanationBombing": "Estimate 0.00x: abnormal review / review bombing", + "weightExplanationSeeding": "Estimate 0.00x: suspected fake review or duplicated template text", + "weightExplanationStarsOnly": "Estimate {weight}x: stars only, no text (C=0.25)", + "weightExplanationPlatformLabel": "Estimate {weight}x: the platform default label for the star rating (C=0.25)", + "weightExplanationLowEffort": "Estimate {weight}x: short comment with few technical details (C=0.50)", "blendNoteBlended": "Weighting: 60% verified buyers + 40% Rate Lab experts (confidence ≥ 60%)", "blendNoteSeparateLowConfidence": "Expert and buyer scores shown separately (blended 60/40 once buyer confidence reaches medium, confidence ≥ 60%)", "blendNoteUserOnly": "Weighting: 100% verified buyers (fake reviews and coin spam filtered out)", "blendNoteExpertOnly": "Weighting: 100% Rate Lab expert review", "blendNoteInsufficientData": "Not enough verified review data yet", - "ratingWeightBadge": "Weight: {weight}x", + "ratingWeightBadge": "Rating weight: {weight}x", "ratingWeightTitle": "Weight used in the product rating (after helpful votes and reports)", "trustModelBadge": "Trust check (6 factors): {explanation}", "variationPrefix": "Variant: {variation}", @@ -916,7 +916,7 @@ "noReviewsYet": "No buyer reviews recorded for this merchant yet.", "flagCoinSpam": "Low-value coin spam (weight 0.0)", "flagSeedingFake": "Suspected fake review (weight 0.0)", - "flagAuthenticWeight": "✓ Verified buyer review (weight {weight})", + "flagAuthenticWeight": "✓ Verified buyer review (rating weight {weight})", "flagPendingAnalysis": "Pending analysis", "noCommentBody": "(No comment text)", "listingsSectionEyebrow": "Price spread and inflated list prices", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 8f086aa06..2eadd5f59 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -534,20 +534,20 @@ "sponsorshipBadgeIncentivized": "Đổi quà / Nhận ưu đãi", "sponsorshipBadgeAffiliate": "Tiếp thị liên kết", "starAnomalyWarning": "Cảnh báo phân bố sao bất thường: {fiveStarPct}% đánh giá là 5 sao tuyệt đối ({lowEffortPct}% chỉ chấm sao hoặc thiếu chi tiết trải nghiệm)", - "weightExplanationShippingOnly": "Trọng số 0.00x cho sản phẩm: chỉ nhận xét giao hàng/đóng gói (chuyển sang điểm người bán)", - "weightExplanationFiller": "Trọng số 0.00x: bình luận cày xu / không mô tả trải nghiệm sản phẩm", - "weightExplanationSabotage": "Trọng số 0.00x: nghi vấn đối thủ dìm hàng thiếu căn cứ", - "weightExplanationBombing": "Trọng số 0.00x: đánh giá bất thường / bom đơn", - "weightExplanationSeeding": "Trọng số 0.00x: nghi là đánh giá ảo hoặc văn mẫu trùng lặp", - "weightExplanationStarsOnly": "Trọng số {weight}x: chỉ chấm sao, không có nội dung (C=0.25)", - "weightExplanationPlatformLabel": "Trọng số {weight}x: nhãn mặc định theo số sao của sàn (C=0.25)", - "weightExplanationLowEffort": "Trọng số {weight}x: nhận xét ngắn, ít chi tiết kỹ thuật (C=0.50)", + "weightExplanationShippingOnly": "Ước tính 0.00x cho sản phẩm: chỉ nhận xét giao hàng/đóng gói (chuyển sang điểm người bán)", + "weightExplanationFiller": "Ước tính 0.00x: bình luận cày xu / không mô tả trải nghiệm sản phẩm", + "weightExplanationSabotage": "Ước tính 0.00x: nghi vấn đối thủ dìm hàng thiếu căn cứ", + "weightExplanationBombing": "Ước tính 0.00x: đánh giá bất thường / bom đơn", + "weightExplanationSeeding": "Ước tính 0.00x: nghi là đánh giá ảo hoặc văn mẫu trùng lặp", + "weightExplanationStarsOnly": "Ước tính {weight}x: chỉ chấm sao, không có nội dung (C=0.25)", + "weightExplanationPlatformLabel": "Ước tính {weight}x: nhãn mặc định theo số sao của sàn (C=0.25)", + "weightExplanationLowEffort": "Ước tính {weight}x: nhận xét ngắn, ít chi tiết kỹ thuật (C=0.50)", "blendNoteBlended": "Tỷ trọng: 60% người mua thực chất + 40% chuyên gia Rate Lab (độ tin cậy ≥ 60%)", "blendNoteSeparateLowConfidence": "Hiển thị riêng điểm chuyên gia và điểm người mua (gộp 60/40 khi độ tin cậy người mua đạt mức trung bình, độ tin cậy ≥ 60%)", "blendNoteUserOnly": "Tỷ trọng: 100% người mua thực chất (đã lọc đánh giá ảo và spam nhận xu)", "blendNoteExpertOnly": "Tỷ trọng: 100% đánh giá chuyên gia Rate Lab", "blendNoteInsufficientData": "Chưa đủ dữ liệu đánh giá thực chất", - "ratingWeightBadge": "Trọng số: {weight}x", + "ratingWeightBadge": "Trọng số tính điểm: {weight}x", "ratingWeightTitle": "Trọng số dùng để tính điểm sản phẩm (đã tính phiếu hữu ích và báo cáo)", "trustModelBadge": "Kiểm tra độ tin cậy (6 yếu tố): {explanation}", "variationPrefix": "Phân loại: {variation}", @@ -916,7 +916,7 @@ "noReviewsYet": "Chưa có đánh giá nào được ghi nhận cho gian hàng này.", "flagCoinSpam": "Bình luận rác nhận xu (trọng số 0.0)", "flagSeedingFake": "Nghi là đánh giá ảo (trọng số 0.0)", - "flagAuthenticWeight": "✓ Đánh giá thật (trọng số {weight})", + "flagAuthenticWeight": "✓ Đánh giá thật (trọng số tính điểm {weight})", "flagPendingAnalysis": "Đang chờ phân tích", "noCommentBody": "(Không có nội dung bình luận)", "listingsSectionEyebrow": "Biên độ giá và giá gốc bị nâng khống", diff --git a/apps/web/src/lib/__tests__/review-display.test.ts b/apps/web/src/lib/__tests__/review-display.test.ts index 944ccb40a..6e2a1c9e9 100644 --- a/apps/web/src/lib/__tests__/review-display.test.ts +++ b/apps/web/src/lib/__tests__/review-display.test.ts @@ -193,7 +193,16 @@ function main() { const copy = weightExplanationCopy(breakdown); const text = 'key' in copy ? fill(vi.reviews[copy.key]!, copy.values) : copy.text; - assert.equal(text, breakdown.explanationVi, kind); + // The UI calls this the trust level, apart from the rating weight on + // the badge (DOS/Rate#1215); the rest is the server's text. + assert.equal( + text, + 'key' in copy + ? breakdown.explanationVi.replace(/^Trọng số /, 'Ước tính ') + : breakdown.explanationVi, + kind + ); + assert.ok(!text.startsWith('Trọng số'), `${kind} says Trọng số`); assert.equal('key' in copy, kind !== 'formula', `${kind} is localized`); } // Reviews that cover the product as well as delivery are not shipping-only From 82e911d8506ca97344908948198b148aa81a3cee Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:03:12 +0700 Subject: [PATCH 20/69] fix(normalizer): key garmin forerunner 265s apart from the 265 (#1227) * fix(normalizer): key garmin forerunner 265s apart from the 265 "Garmin Forerunner 265S 42mm" matched /forerunner\s*265/ and keyed garmin:forerunner-265:8gb with the 46mm 265's registry specs. The 265S ("265S", "265 S", "265s") is now its own model, garmin:forerunner-265s, with no registry entry (storage unknown, not benchmark-enriched). The 265 keys as before. Closes #1222 * fix(normalizer): read 265s glued or hyphenated, not a s/m band size Codex follow-up: "265S42mm" and "265-S 42mm" are the 265S; "265 S/M band" and "265 S-M band" are the 265 with a band size. Refs #1222 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 18 ++++++++++++++++++ apps/importer/src/shopee/product-normalizer.ts | 7 +++++++ 2 files changed, 25 insertions(+) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index ceb216c16..72a253961 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -813,6 +813,24 @@ async function runSuite() { 'samsung:galaxy-watch-fe-2024:unknown' ); assert.equal(keyOf('Đồng hồ Garmin Forerunner 265'), 'garmin:forerunner-265:8gb'); + // The 265S (42mm) is its own model and never takes the 265's 46mm specs (DOS/Rate#1222). + for (const title of [ + 'Garmin Forerunner 265S 42mm', + 'Đồng hồ Garmin Forerunner 265S', + 'Garmin Forerunner 265 S Music', + 'Garmin Forerunner 265s Đen', + 'Garmin Forerunner 265S42mm', + 'Garmin Forerunner 265-S 42mm', + ]) { + const fr265s = normalizer.normalize(createDummyShopeeProduct(title)); + assert.equal(fr265s.model_key, 'garmin:forerunner-265s:unknown', title); + assert.equal(fr265s.is_benchmark_enriched, false, title); + assert.equal(fr265s.battery_mah, null, title); + } + assert.equal(keyOf('Garmin Forerunner 265 Sport'), 'garmin:forerunner-265:8gb'); + // A band size "S/M" after the 265 is no 265S. + assert.equal(keyOf('Garmin Forerunner 265 S/M band'), 'garmin:forerunner-265:8gb'); + assert.equal(keyOf('Garmin Forerunner 265 S-M band'), 'garmin:forerunner-265:8gb'); assert.equal( keyOf('Điện thoại Samsung Galaxy S25 Ultra, Điện thoại AI, Tìm kiếm thông minh'), 'samsung:galaxy-s25-ultra:unknown' diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index 3bd5bac84..e8f13efda 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1699,6 +1699,13 @@ export class ProductNormalizer { model = 'Zenbook S 14'; } else if (brand === 'Lenovo' && /thinkpad\s*x1\s*carbon\s*gen\s*12/i.test(modelTitle)) { model = 'ThinkPad X1 Carbon Gen 12'; + } else if ( + brand === 'Garmin' && + /forerunner\s*265[ -]?s(?![a-z])(?! ?[/-] ?[a-z])/i.test(modelTitle) + ) { + // The 265S is the 42mm model, not the 46mm 265 (DOS/Rate#1222): "265S", "265 S", + // "265-S", "265S42mm", but not a "265 S/M" band size or a word ("265 Sport"). + model = 'Forerunner 265S'; } else if (brand === 'Garmin' && /forerunner\s*265/i.test(modelTitle)) { model = 'Forerunner 265'; } else if (brand === 'Samsung' && /galaxy\s*watch\s*ultra/i.test(modelTitle)) { From 25d88210941f5c072331ae4adc2b3ad0f3c3c630 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:06:25 +0700 Subject: [PATCH 21/69] fix(importer,web): ai review summary says it covers one listing's batch (#1229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(importer,web): ai review summary says it covers one listing's batch The analyzer stored "Đã phân tích N đánh giá ... (điểm JReviews có trọng số x/5★)" as if N were the product's reviews and x its score. It now stores the #1216 wording ("Lần phân tích gần nhất (một nơi bán) đã xét ...") without the batch score. The review summary reader rewords old rows the same way through listingReviewSummary, so API v1 /products/{slug}, /reviews and the extension side panel print the honest sentence; the web page localizes both stored forms. Co-Authored-By: Claude Opus 5.5 --- .../importer/src/shopee/ai-review-analyzer.ts | 7 +- .../src/lib/__tests__/analyzer-copy.test.ts | 67 +++++++++++++------ apps/web/src/lib/analyzer-copy.ts | 45 ++++++++++++- apps/web/src/lib/rate-api.ts | 4 +- 4 files changed, 97 insertions(+), 26 deletions(-) diff --git a/apps/importer/src/shopee/ai-review-analyzer.ts b/apps/importer/src/shopee/ai-review-analyzer.ts index 15660b71d..dd225f4d2 100644 --- a/apps/importer/src/shopee/ai-review-analyzer.ts +++ b/apps/importer/src/shopee/ai-review-analyzer.ts @@ -1975,10 +1975,13 @@ Return ONLY valid JSON matching: ? Number(((positiveAuthenticCount / authenticCount) * 100).toFixed(2)) : 100; + // One listing's latest batch, not the product; the batch's weighted + // score is not the product score, so it stays out (DOS/Rate#1225). + // apps/web/src/lib/analyzer-copy.ts reads these sentences. const defaultSummary = authenticCount > 0 - ? `Đã phân tích ${safeReviews.length} đánh giá: giữ lại ${authenticCount} đánh giá thực chất (điểm JReviews có trọng số: ${weightedUserRating}/5★) và loại bỏ ${ignoredCount} đánh giá rác/nhận xu/seeding/bom hàng khỏi điểm tổng.` - : `Chưa có đánh giá thực chất sau khi lọc ${ignoredCount} bình luận rác/nhận xu.`; + ? `Lần phân tích gần nhất (một nơi bán) đã xét ${safeReviews.length} đánh giá: giữ lại ${authenticCount} đánh giá thực chất và loại bỏ ${ignoredCount} đánh giá rác, nhận xu, ảo hoặc bom hàng.` + : `Lần phân tích gần nhất (một nơi bán) chưa thấy đánh giá thực chất sau khi lọc ${ignoredCount} bình luận rác/nhận xu.`; const cleanPros = Array.from( new Set( diff --git a/apps/web/src/lib/__tests__/analyzer-copy.test.ts b/apps/web/src/lib/__tests__/analyzer-copy.test.ts index d21eb97f6..954e61537 100644 --- a/apps/web/src/lib/__tests__/analyzer-copy.test.ts +++ b/apps/web/src/lib/__tests__/analyzer-copy.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { ANALYZER_COPY_KEYS, analyzerCopy } from '../analyzer-copy'; +import { + ANALYZER_COPY_KEYS, + analyzerCopy, + listingReviewSummary, +} from '../analyzer-copy'; type Messages = { analyzerCopy?: Record }; const readLocale = (locale: string) => @@ -59,15 +63,11 @@ function main() { 'reasonCommunityDownvoted', ], [ - 'Đã phân tích 10 đánh giá: giữ lại 10 đánh giá thực chất (điểm JReviews có trọng số: 5/5★) và loại bỏ 0 đánh giá rác/nhận xu/seeding/bom hàng khỏi điểm tổng.', + 'Lần phân tích gần nhất (một nơi bán) đã xét 12 đánh giá: giữ lại 9 đánh giá thực chất và loại bỏ 3 đánh giá rác, nhận xu, ảo hoặc bom hàng.', 'summaryDefault', ], [ - 'Đã phân tích 12 đánh giá: giữ lại 9 đánh giá thực chất (điểm JReviews có trọng số: 4.37/5★) và loại bỏ 3 đánh giá rác/nhận xu/seeding/bom hàng khỏi điểm tổng.', - 'summaryDefault', - ], - [ - 'Chưa có đánh giá thực chất sau khi lọc 4 bình luận rác/nhận xu.', + 'Lần phân tích gần nhất (một nơi bán) chưa thấy đánh giá thực chất sau khi lọc 4 bình luận rác/nhận xu.', 'summaryNoAuthentic', ], ['Nhận phản hồi tích cực từ người dùng thực tế', 'proPositiveFeedback'], @@ -122,16 +122,6 @@ function main() { 'Nội dung sao chép / xào nấu văn mẫu seeding giữa các tài khoản (tương đồng cấu trúc {pct}%).', reasonCommunityAdLink: 'Phát hiện đường dẫn quảng cáo hoặc điều hướng ngoài luồng nghi vấn Seeding.', - summaryDefault: - 'Đã phân tích {total} đánh giá: giữ lại {kept} đánh giá thực chất (điểm JReviews có trọng số: {rating}/5★) và loại bỏ {removed} đánh giá rác/nhận xu/seeding/bom hàng khỏi điểm tổng.', - // The UI says it covers one store's latest batch (DOS/Rate#1216). - summaryNoAuthentic: - 'Chưa có đánh giá thực chất sau khi lọc {removed} bình luận rác/nhận xu.', - }; - // Values the UI leaves out on purpose: the summary speaks for one store's - // latest batch, whose rating is not the product's score (DOS/Rate#1216). - const dropped: Partial> = { - summaryDefault: ['rating'], }; const covered = new Set(); for (const [text, key] of samples) { @@ -146,9 +136,7 @@ function main() { assert.ok(!/seeding/i.test(vi[copy.key]!), `${key} still says seeding`); assert.deepEqual( placeholders(vi[copy.key]!), - placeholders(template).filter( - (p) => !(dropped[copy.key] ?? []).includes(p ?? '') - ), + placeholders(template), `${key} uses every value the stored string carries` ); } @@ -160,6 +148,45 @@ function main() { 'every template has a sample' ); + // Summaries stored before DOS/Rate#1225 still read; the batch's weighted + // score, which is not the product's, is left out. + const legacy: Array<[string, string, string]> = [ + [ + 'Đã phân tích 10 đánh giá: giữ lại 10 đánh giá thực chất (điểm JReviews có trọng số: 5/5★) và loại bỏ 0 đánh giá rác/nhận xu/seeding/bom hàng khỏi điểm tổng.', + 'summaryDefault', + 'Lần phân tích gần nhất (một nơi bán) đã xét 10 đánh giá: giữ lại 10 đánh giá thực chất và loại bỏ 0 đánh giá rác, nhận xu, ảo hoặc bom hàng.', + ], + [ + 'Đã phân tích 12 đánh giá: giữ lại 9 đánh giá thực chất (điểm JReviews có trọng số: 4.37/5★) và loại bỏ 3 đánh giá rác/nhận xu/seeding/bom hàng khỏi điểm tổng.', + 'summaryDefault', + 'Lần phân tích gần nhất (một nơi bán) đã xét 12 đánh giá: giữ lại 9 đánh giá thực chất và loại bỏ 3 đánh giá rác, nhận xu, ảo hoặc bom hàng.', + ], + [ + 'Chưa có đánh giá thực chất sau khi lọc 4 bình luận rác/nhận xu.', + 'summaryNoAuthentic', + 'Lần phân tích gần nhất (một nơi bán) chưa thấy đánh giá thực chất sau khi lọc 4 bình luận rác/nhận xu.', + ], + ]; + for (const [text, key, reworded] of legacy) { + const copy = analyzerCopy(text); + assert.ok('key' in copy && copy.key === key, text); + assert.equal(fill(vi[key]!, copy.values), reworded, key); + assert.equal(listingReviewSummary(text), reworded, key); + } + // API v1 and the extension print the same words as the web page; new rows + // pass through unchanged, free text and empty values too. + for (const [text, key] of samples.filter(([, k]) => + k.startsWith('summary') + )) { + assert.equal(listingReviewSummary(text), text, key); + } + for (const [text] of legacy) { + assert.ok(!/Đã phân tích|trọng số/.test(listingReviewSummary(text)!)); + } + assert.equal(listingReviewSummary('Máy dùng ổn.'), 'Máy dùng ổn.'); + assert.equal(listingReviewSummary(null), null); + assert.equal(listingReviewSummary(' '), null); + // Free text (e.g. from the LLM pass) is passed through as Vietnamese. const free = 'Pin trâu, camera chụp đêm tốt nhưng máy hơi nóng khi chơi game.'; diff --git a/apps/web/src/lib/analyzer-copy.ts b/apps/web/src/lib/analyzer-copy.ts index fca5e9c79..6c7c7e226 100644 --- a/apps/web/src/lib/analyzer-copy.ts +++ b/apps/web/src/lib/analyzer-copy.ts @@ -79,6 +79,17 @@ const TEMPLATES: ReadonlyArray = [ /^Đánh giá bị cộng đồng Vote Down \/ Báo cáo nhiều nên giảm trọng số về 0\.$/u, 'reasonCommunityDownvoted', ], + // Written since DOS/Rate#1225: the summary speaks for one listing's latest + // batch, not the product. + [ + /^Lần phân tích gần nhất \(một nơi bán\) đã xét (?\d+) đánh giá: giữ lại (?\d+) đánh giá thực chất và loại bỏ (?\d+) đánh giá rác, nhận xu, ảo hoặc bom hàng\.$/u, + 'summaryDefault', + ], + [ + /^Lần phân tích gần nhất \(một nơi bán\) chưa thấy đánh giá thực chất sau khi lọc (?\d+) bình luận rác\/nhận xu\.$/u, + 'summaryNoAuthentic', + ], + // Rows stored before DOS/Rate#1225, read the same way. [ /^Đã phân tích (?\d+) đánh giá: giữ lại (?\d+) đánh giá thực chất \(điểm JReviews có trọng số: (?\d+(?:\.\d+)?)\/5★\) và loại bỏ (?\d+) đánh giá rác\/nhận xu\/seeding\/bom hàng khỏi điểm tổng\.$/u, 'summaryDefault', @@ -154,6 +165,34 @@ export function analyzerCopy(text: string): AnalyzerCopy { : { text: safeText }; } -export const ANALYZER_COPY_KEYS: readonly AnalyzerCopyKey[] = TEMPLATES.map( - ([, key]) => key -); +export const ANALYZER_COPY_KEYS: readonly AnalyzerCopyKey[] = [ + ...new Set(TEMPLATES.map(([, key]) => key)), +]; + +// The Vietnamese wording of the review summary, as in vi.json analyzerCopy. +const VI_SUMMARY: Partial> = { + summaryDefault: + 'Lần phân tích gần nhất (một nơi bán) đã xét {total} đánh giá: giữ lại {kept} đánh giá thực chất và loại bỏ {removed} đánh giá rác, nhận xu, ảo hoặc bom hàng.', + summaryNoAuthentic: + 'Lần phân tích gần nhất (một nơi bán) chưa thấy đánh giá thực chất sau khi lọc {removed} bình luận rác/nhận xu.', +}; + +/** + * The stored review summary as surfaces without a locale (API v1, the + * extension) print it. A templated summary, old rows included, is reworded + * to say it covers one listing's latest batch and loses the batch's + * weighted score, which is not the product's (DOS/Rate#1225). Free text is + * returned as stored. + */ +export function listingReviewSummary( + text: string | null | undefined +): string | null { + if (typeof text !== 'string' || !text.trim()) return null; + const copy = analyzerCopy(text); + const template = 'key' in copy ? VI_SUMMARY[copy.key] : undefined; + if (!template || !('key' in copy)) return text; + return template.replace( + /\{(\w+)\}/g, + (_, name: string) => copy.values[name] ?? '' + ); +} diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 308fd2756..142fcf9c6 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -2,6 +2,7 @@ import { createClient } from '@supabase/supabase-js'; import type { Database, MasterProduct } from '@repo/shared-data'; import { createLogger } from '@/lib/logger'; import { isPendingReview } from '@/lib/review-display'; +import { listingReviewSummary } from '@/lib/analyzer-copy'; import { implausiblyLowOfferIds, plausibleOffers, @@ -1730,7 +1731,8 @@ export async function getMasterProductReviewSummary( Number(row.authenticity_score ?? fallbackAuthenticPct) * Math.max(1, count); if (!latestSummary && row.ai_summary) { - latestSummary = String(row.ai_summary); + // Says it covers one listing, old rows too (DOS/Rate#1225). + latestSummary = listingReviewSummary(String(row.ai_summary)); } if (Array.isArray(row.pros)) { for (const p of row.pros) { From ead22856c7405d55da6c1bd2aaadfa5a97527ec3 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:09:37 +0700 Subject: [PATCH 22/69] fix(normalizer): a size that names the case wins over a band size before it (#1236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(normalizer): a size that names the case wins over a band size before it "Apple Watch Series 11 GPS + Cellular 45mm Sport Band, 46mm Titanium Case" keyed -45mm: the band size came first outside any band phrase. A title part that names the case ("Case", "vỏ", "mặt") with exactly one size and no band word now gives the case size in either order. None of the 173 watch strings in the repo change key; the #1124/#1137/#1162 fixtures pass unchanged. Closes #1223 * fix(normalizer): a named case size must sit next to its case word Review follow-up: the size must be next to "case", "vỏ", "viền" or "mặt" (up to two words between), "viền" counts, and a part about a box, a cover or a fit ("kèm vỏ hộp 42mm", "fits 42mm case") names no case. Refs #1223 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 31 +++++++++++++++++++ .../importer/src/shopee/product-normalizer.ts | 27 ++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 72a253961..e55753f90 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -621,6 +621,37 @@ async function runSuite() { for (const [title, expectedKey] of watchBandRangeKeys1162) { assert.equal(keyOf(title), expectedKey, title); } + // DOS/Rate#1223: a part that names the case with its size wins over a band size + // written before it, in either order. + const namedCaseKeys1223: Array<[string, string]> = [ + [ + 'Apple Watch Series 11 GPS + Cellular 45mm Sport Band, 46mm Titanium Case', + 'apple:watch-series-11-46mm:64gb', + ], + [ + 'Apple Watch Series 11 GPS + Cellular 46mm Titanium Case, 45mm Sport Band', + 'apple:watch-series-11-46mm:64gb', + ], + [ + 'Apple Watch Series 10 42mm Sport Loop, 46mm Aluminium Case', + 'apple:watch-series-10-46mm:64gb', + ], + ['Apple Watch SE 3 44mm Sport Band, 40mm Case', 'apple:watch-se-3-40mm:unknown'], + ['Apple Watch Series 11 45mm Sport Band | Case 42mm', 'apple:watch-series-11-42mm:64gb'], + [ + 'Apple Watch Series 11 GPS 45mm Sport Band, 46mm Viền Titanium', + 'apple:watch-series-11-46mm:64gb', + ], + // A box, a cover or a fit names no case of the watch itself. + ['Apple Watch Series 10 GPS 46mm, kèm vỏ hộp 42mm', 'apple:watch-series-10-46mm:64gb'], + [ + 'Apple Watch Series 10 GPS 46mm (Sport Band 45mm, fits 42mm case)', + 'apple:watch-series-10-46mm:64gb', + ], + ]; + for (const [title, expectedKey] of namedCaseKeys1223) { + assert.equal(keyOf(title), expectedKey, title); + } // The registry has one entry per line: a sized title keeps the line's benchmark, but // the entry's screen and battery describe only the size it was written for. A watch // entry pinned to one case (`case_mm`) does not describe another case at all: the diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index e8f13efda..a5bb3e3fa 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1272,6 +1272,13 @@ const LONE_WATCH_SIZE = /^ ?(?:38|4[0-2]|4[4-6]) ?mm ?$/i; const LAST_WATCH_SIZE = /(? = { ipad: sizePatterns(IPAD_SIZE), 'macbook air': sizePatterns(MACBOOK_AIR_SIZE), @@ -1359,6 +1366,26 @@ function readAppleSize(model: string, title: string): string { bandList = WATCH_SIZE.test(band.join(' ')); bandEnd = LAST_WATCH_SIZE.test(band.join(' ')); } + // A part that names the case with one size ("46mm Titanium Case", "Case 42mm") says + // the watch's size, whatever band size comes before it: "45mm Sport Band, 46mm + // Titanium Case" is the 46mm (DOS/Rate#1223). + // The size must sit next to the case word, and the part must not be about a box, a + // cover or what something fits ("kèm vỏ hộp 42mm", "fits 42mm case"). + const namedCase = parts + .filter((_, i) => i % 2 === 0) + .map(part => part.normalize('NFC')) + .filter( + part => + !WATCH_BAND_WORD.test(part) && + !CASE_ACCESSORY_WORD.test(part) && + [...part.matchAll(new RegExp(WATCH_SIZE, 'gi'))].length === 1 + ) + .map(part => { + const m = part.match(NAMED_CASE_SIZE); + return m?.[1] ?? m?.[2]; + }) + .filter(Boolean); + if (namedCase.length === 1 && namedCase[0]) return `${namedCase[0]}mm`; const caseSize = outside.join(' ').match(WATCH_SIZE)?.[1]; if (caseSize) return `${caseSize}mm`; const bandSize = own.match(WATCH_SIZE)?.[1]; From f1a46f41517239061a899fcc19b5464bdaab9c9d Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:09:49 +0700 Subject: [PATCH 23/69] fix(web): a 1v1 tie says tie and score gaps read as points (#1228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): a 1v1 tie says tie and score gaps read as points The versus page named a leader on a 5.00 vs 5.00 tie ("dẫn trước", "Dẫn đầu 1v1 … +0.00"), and the featured pairs wrote the score gap as "Chênh 0.00 đ", which reads as đồng. isVersusScoreTie compares scores at the shown two decimals; on a tie the verdict, the banner, the Markdown copy and the JSON export say the two are level (winner null), and the gap badge reads "Chênh {delta} điểm". Refs #1209 * fix(web): ties in the versus csv, api and compare pairs Review of the previous commit: the CSV export still named a winner on a tie; API v1 /versus had no tie flag (is_tie added, overall_winner kept as the tie-break); the compare featured pairs showed "Chênh 0.00 điểm" where the home page says tie; the Markdown tie line kept the "leading device" label; vi said "Cân bằng" in one place and "Ngang điểm" in another. The tie test now checks the vi copy that had the "đ". Refs #1209 Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 4 ++ apps/web/locales/en.json | 4 ++ apps/web/locales/vi.json | 8 +++- .../app/[locale]/compare/[versus]/page.tsx | 21 ++++++---- apps/web/src/app/api/v1/rate/versus/route.ts | 5 +++ .../components/compare/CompareWorkspace.tsx | 9 ++-- .../compare/VersusInteractiveScoreboard.tsx | 42 +++++++++++++------ apps/web/src/lib/versus-verdict.ts | 8 ++++ .../__tests__/core-rate-architecture.test.ts | 24 +++++++++++ apps/web/src/server/rate/openapi.ts | 2 +- 10 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/lib/versus-verdict.ts diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 46ce780f9..11b934a37 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -125,6 +125,10 @@ "versusHeroBadge": "Souboj 1v1 • stejná kategorie ({category})", "viewCategoryLeaderboard": "Zobrazit žebříček kategorie", "versusAutoVerdict": "Automatický verdikt: {winner} vede s celkovým skóre {score}/5.0{expertSuffix}.", + "mdTieLine": "Remíza ({score}/5.0)", + "tieDeltaNote": "(stejné skóre při aktuálních vahách)", + "tie1v1Badge": "Remíza 1v1", + "versusAutoVerdictTie": "Automatický verdikt: oba modely mají stejné celkové skóre ({score}/5.0); rozhodněte podle ceny a parametrů, které potřebujete.", "compareMetaTitleWithNames": "Porovnání {names} - Hodnocení a 10 skupin parametrů | Rate.vn", "compareMetaTitleWithNamesShort": "{names} | Rate.vn", "compareMetaDescWithNames": "Porovnání skutečných hodnocení, 10 skupin technických parametrů a referenčních cen pro {names} na Rate.vn.", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 0cea5019e..01c170650 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -125,6 +125,10 @@ "versusHeroBadge": "1v1 comparison • same category ({category})", "viewCategoryLeaderboard": "View category leaderboard", "versusAutoVerdict": "Automatic verdict: {winner} leads with an overall score of {score}/5.0{expertSuffix}.", + "mdTieLine": "Tie ({score}/5.0)", + "tieDeltaNote": "(same score with the current weights)", + "tie1v1Badge": "1v1 Tie", + "versusAutoVerdictTie": "Automatic verdict: the two models are level on overall score ({score}/5.0); choose on price and the specs you need.", "compareMetaTitleWithNames": "Compare {names} - Ratings and 10 spec groups | Rate.vn", "compareMetaTitleWithNamesShort": "{names} | Rate.vn", "compareMetaDescWithNames": "Compare real review scores, 10 spec groups and reference prices for {names} on Rate.vn.", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 2eadd5f59..baaf88c74 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -125,6 +125,10 @@ "versusHeroBadge": "So sánh 1v1 • cùng danh mục ({category})", "viewCategoryLeaderboard": "Xem bảng xếp hạng cùng ngành", "versusAutoVerdict": "Kết luận tự động: {winner} dẫn trước với điểm tổng hợp {score}/5.0{expertSuffix}.", + "mdTieLine": "Ngang điểm ({score}/5.0)", + "tieDeltaNote": "(cùng điểm theo trọng số hiện tại)", + "tie1v1Badge": "Ngang điểm 1v1", + "versusAutoVerdictTie": "Kết luận tự động: hai mẫu ngang điểm tổng hợp ({score}/5.0), chọn theo giá và thông số bạn cần.", "compareMetaTitleWithNames": "So sánh {names} - Điểm đánh giá và 10 nhóm thông số | Rate.vn", "compareMetaTitleWithNamesShort": "{names} | Rate.vn", "compareMetaDescWithNames": "So sánh điểm đánh giá thật, 10 nhóm thông số kỹ thuật và giá tham khảo giữa {names} trên Rate.vn.", @@ -165,7 +169,7 @@ "mdColCriterion": "Tiêu chí", "mdColAdvantage": "Ưu thế", "mdRowWeightedScore": "Điểm tổng hợp có trọng số", - "mdRowTie": "Cân bằng", + "mdRowTie": "Ngang điểm", "mdRowMinPrice": "Giá tham khảo thấp nhất", "mdRowDealRadar": "Tín hiệu giá", "csvColCriterion": "Tiêu chí", @@ -273,7 +277,7 @@ "popular1v1Eyebrow": "So sánh 1v1 nổi bật ({category})", "popular1v1Heading": "Các cặp so sánh được tìm nhiều nhất trong nhóm {category}", "viewLeaderboardCategoryBtn": "Xem bảng xếp hạng {category} →", - "scoreDeltaBadge": "Chênh {delta} đ", + "scoreDeltaBadge": "Chênh {delta} điểm", "loadIntoMatrixBtn": "Đưa vào bảng so sánh", "go1v1Btn": "So kè 1v1 →", "verdictBadge": "Chấm điểm so sánh ({category})", diff --git a/apps/web/src/app/[locale]/compare/[versus]/page.tsx b/apps/web/src/app/[locale]/compare/[versus]/page.tsx index d23eb76a6..2d75c9330 100644 --- a/apps/web/src/app/[locale]/compare/[versus]/page.tsx +++ b/apps/web/src/app/[locale]/compare/[versus]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { isVersusScoreTie } from '@/lib/versus-verdict'; import { BsTrophy } from 'react-icons/bs'; import { Link } from '@/lib/navigation'; import { @@ -214,14 +215,18 @@ export default async function VersusComparePage({ params }: VersusPageProps) {

- {t('versusAutoVerdict', { - winner: overallWinner.name, - score: overallWinner.combinedScore5.toFixed(2), - expertSuffix: - overallWinner.expertScore10 != null - ? ` (${overallWinner.expertScore10.toFixed(1)}/10 Lab)` - : '', - })} + {isVersusScoreTie(scoreA.combinedScore5, scoreB.combinedScore5) + ? t('versusAutoVerdictTie', { + score: scoreA.combinedScore5.toFixed(2), + }) + : t('versusAutoVerdict', { + winner: overallWinner.name, + score: overallWinner.combinedScore5.toFixed(2), + expertSuffix: + overallWinner.expertScore10 != null + ? ` (${overallWinner.expertScore10.toFixed(1)}/10 Lab)` + : '', + })}

- {t('scoreDeltaBadge', { - delta: battle.scoreDelta5.toFixed(2), - })} + {/* A 0.00 gap is a tie, as on the home page (DOS/Rate#1209). */} + {battle.scoreDelta5 > 0.005 + ? t('scoreDeltaBadge', { + delta: battle.scoreDelta5.toFixed(2), + }) + : t('mdRowTie')}
diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx index 5e7dd8cbe..4896598f7 100644 --- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx +++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx @@ -17,6 +17,7 @@ import { radarMarkdownCell, } from '@/lib/deal-radar'; import { escapeCsvCell } from '@/lib/csv-safe'; +import { isVersusScoreTie } from '@/lib/versus-verdict'; import RadarBadge from '@/components/radar/RadarBadge'; import { useDealRadarIndex } from '@/components/radar/use-deal-radar'; @@ -162,6 +163,8 @@ export function computeVersusWeightedResult( finalB, delta: Math.abs(finalA - finalB).toFixed(2), winner, + // Level at the shown precision: no lead for either (DOS/Rate#1209). + tie: isVersusScoreTie(finalA, finalB), }; } @@ -260,12 +263,14 @@ export default function VersusInteractiveScoreboard({ : weightedResult.finalB.toFixed(2); const lines = [ `### ${t('md1v1Heading', { nameA: safeNameA, nameB: safeNameB })}`, - `- **${t('mdLeadingDevice')}:** **${safeWinnerName}** (${leadScore}/5.0 - ${t('mdScoreDelta', { delta: weightedResult.delta })})`, + weightedResult.tie + ? `- **${t('tie1v1Badge')}:** ${t('mdTieLine', { score: leadScore })}` + : `- **${t('mdLeadingDevice')}:** **${safeWinnerName}** (${leadScore}/5.0 - ${t('mdScoreDelta', { delta: weightedResult.delta })})`, `- **${t('mdAuditUrl')}:** ${window.location.href}`, '', `| ${t('mdColCriterion')} | ${safeNameA} | ${safeNameB} | ${t('mdColAdvantage')} |`, `| --- | --- | --- | --- |`, - `| **${t('mdRowWeightedScore')}** | **${weightedResult.finalA.toFixed(2)}/5.0** | **${weightedResult.finalB.toFixed(2)}/5.0** | **${safeWinnerName}** |`, + `| **${t('mdRowWeightedScore')}** | **${weightedResult.finalA.toFixed(2)}/5.0** | **${weightedResult.finalB.toFixed(2)}/5.0** | **${weightedResult.tie ? sanitizeVersusMarkdownCell(t('mdRowTie')) : safeWinnerName}** |`, ...criteriaRows.map((r) => { const winName = r.aVal > r.bVal @@ -305,7 +310,7 @@ export default function VersusInteractiveScoreboard({ 100, weightedResult.finalA.toFixed(2), weightedResult.finalB.toFixed(2), - weightedResult.winner.name, + weightedResult.tie ? t('mdRowTie') : weightedResult.winner.name, ], ...criteriaRows.map((r) => [ r.label, @@ -386,11 +391,14 @@ export default function VersusInteractiveScoreboard({ persona, weights, exportedAt: new Date().toISOString(), - winner: { - slug: sanitizeVersusExportSlug(weightedResult.winner.slug), - name: weightedResult.winner.name, - scoreDelta5: Number(weightedResult.delta), - }, + tie: weightedResult.tie, + winner: weightedResult.tie + ? null + : { + slug: sanitizeVersusExportSlug(weightedResult.winner.slug), + name: weightedResult.winner.name, + scoreDelta5: Number(weightedResult.delta), + }, productA: { slug: safeSlugA, name: scoreA.name, @@ -567,12 +575,20 @@ export default function VersusInteractiveScoreboard({ >
- {t('leading1v1Badge')} - - {weightedResult.winner.name} - - {t('leadingDeltaNote', { delta: weightedResult.delta })} + {weightedResult.tie ? t('tie1v1Badge') : t('leading1v1Badge')} + {weightedResult.tie ? ( + {t('tieDeltaNote')} + ) : ( + <> + + {weightedResult.winner.name} + + + {t('leadingDeltaNote', { delta: weightedResult.delta })} + + + )}
{scoreA.brand}: {weightedResult.finalA.toFixed(2)}/5.0 •{' '} diff --git a/apps/web/src/lib/versus-verdict.ts b/apps/web/src/lib/versus-verdict.ts new file mode 100644 index 000000000..b5417c38e --- /dev/null +++ b/apps/web/src/lib/versus-verdict.ts @@ -0,0 +1,8 @@ +/** + * Whether two 1v1 scores are level as the page shows them, at two decimals + * (DOS/Rate#1209): 5.00 vs 5.00 is a tie, not a lead for either model. + */ +export function isVersusScoreTie(scoreA: number, scoreB: number): boolean { + if (!Number.isFinite(scoreA) || !Number.isFinite(scoreB)) return false; + return scoreA.toFixed(2) === scoreB.toFixed(2); +} diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index a4a48eb78..b570f6ad9 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -11007,6 +11007,30 @@ async function runCoreArchitectureSuite() { equalWeightsRes.finalB, 'computeVersusWeightedResult must fall back to equal criteria average for product B when all custom weights are 0' ); + // A model against itself is level: a tie, not a lead (DOS/Rate#1209). + const selfRes = computeVersusWeightedResult( + scoreItemA, + { ...scoreItemA, slug: `${scoreItemA.slug}-copy` }, + { screen: 20, performance: 20, camera: 20, battery: 20, value: 20 }, + 'custom' + ); + assert.equal(selfRes.tie, true); + assert.equal(selfRes.delta, '0.00'); + const { isVersusScoreTie } = await import('../../../lib/versus-verdict'); + assert.equal(isVersusScoreTie(5, 4.996), true, 'equal at two decimals'); + assert.equal(isVersusScoreTie(4.98, 4.97), false); + assert.equal(isVersusScoreTie(Number.NaN, 5), false); + // The score gap reads as points, never as đồng. + const viCompare = ( + (await import('../../../../locales/vi.json')).default as { + compare: Record; + } + ).compare; + assert.ok(!/ đ$/.test(viCompare.scoreDeltaBadge ?? '')); + assert.equal( + viCompare.mdRowTie, + (viCompare.tie1v1Badge ?? '').replace(' 1v1', '') + ); console.log( ' ✅ [118/119] ProductValueCalculator, DealFinderWorkspace & VersusInteractiveScoreboard Bounds (#960) passed' diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index bdfa3d006..f39f24f41 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -488,7 +488,7 @@ export function buildRateOpenApiSpec( summary: 'Get programmatic 1v1 Head-to-Head JReviews verdict, 5-criteria scoreboard & price delta', description: - 'Evaluates two same-category master products (`?pair=-vs-` or `?slugA=...&slugB=...`) with optional persona/custom 5-criteria weights (`persona`, `w_screen`, `w_performance`, `w_camera`, `w_battery`, `w_value`) and returns the overall winner, per-criterion head-to-head breakdown, price delta, Deal Radar signals (`deal_radar`), and 10-group specs.', + 'Evaluates two same-category master products (`?pair=-vs-` or `?slugA=...&slugB=...`) with optional persona/custom 5-criteria weights (`persona`, `w_screen`, `w_performance`, `w_camera`, `w_battery`, `w_value`) and returns the overall winner (with `is_tie` true when both scores are level at two decimals; `overall_winner` is then only the price or side-A tie-break), per-criterion head-to-head breakdown, price delta, Deal Radar signals (`deal_radar`), and 10-group specs.', tags: ['Comparison & Watchlist'], parameters: [ { From 08d96c47f9d85f5f1f3622049d87503c9ee4b00b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:14:45 +0700 Subject: [PATCH 24/69] perf(web): cache merchant profiles without per-master reads (#1237) * perf(web): reuse cached master reads in merchant profiles A cold merchant profile waited on one database round trip after another and re-read every listed master's offers and price history uncached. It now reads each master through the product pages' cached readers, reads the listings while the merchant row loads, and runs the master analysis alongside the master, category and review reads. * fix(web): add merchant discount analysis outside the profile cache A nested unstable_cache skips its own cache read in Next 16, so the cached master readers called inside the profile entry re-read every master. The cached entry now holds the profile without its discount analysis and the listing-to-master map; the analysis is added per request from the product pages' cached offers and price history. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 2 +- .../__tests__/merchant-profile-reads.test.ts | 284 ++++++++++++++++++ apps/web/src/lib/rate-api.ts | 141 +++++++-- apps/web/src/server/rate/index.ts | 27 +- 4 files changed, 415 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/lib/__tests__/merchant-profile-reads.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 86ddd55d6..f20450938 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts b/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts new file mode 100644 index 000000000..d2372409f --- /dev/null +++ b/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts @@ -0,0 +1,284 @@ +import assert from 'node:assert/strict'; + +// A merchant profile on a cold render must not wait on one database round trip +// after another: it reads its listings while the merchant loads, and its +// discount analysis is added outside its cache entry from the readers it is +// given, so the product pages' cached entries are reused (DOS/Rate#1217). + +process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://stub.supabase.co'; +process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'stub-anon-key'; + +const MERCHANT_ID = '00000000-0000-4000-8000-000000001217'; +const MASTER_ID = '00000000-0000-4000-9000-000000001217'; +const LISTING_ID = '00000000-0000-4000-a000-000000001217'; + +const merchantRow = { + id: MERCHANT_ID, + platform: 'shopee', + platform_shop_id: '1217', + name: 'Shop 1217', + slug: 'shop-1217', + is_official: false, + risk_score: 10, + review_risk_score: 5, + metadata: {}, +}; +const listingRow = { + id: LISTING_ID, + merchant_id: MERCHANT_ID, + platform: 'shopee', + platform_item_id: '1', + title: 'Phone 1217', + price_current: 9_000_000, + price_original: 12_000_000, + stock: 3, + historical_sold: 1, + rating_star: 5, + rating_count: 1, + images: [], + url: 'https://shopee.vn/product/1217/1', + cluster_id: MASTER_ID, +}; + +const listingUrls: string[] = []; + +type Route = 'merchant' | 'merchantBySlug' | 'listings' | 'other'; + +interface Recorded { + route: string; + start: number; + end: number; +} + +function routeOf(url: URL): string { + const table = url.pathname.replace('/rest/v1/', ''); + const query = decodeURIComponent(url.search); + if (table === 'merchants' && query.includes('id=eq.')) return 'merchant'; + if (table === 'merchants' && query.includes('or=')) return 'merchantBySlug'; + if (table === 'products' && query.includes('merchant_id=eq.')) + return 'listings'; + return table; +} + +function installFetch(options: { + merchantFound?: (route: Route | string) => boolean; + listingsFail?: boolean; + delayMs?: number; +}) { + const calls: Recorded[] = []; + let clock = 0; + const delay = options.delayMs ?? 20; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + ); + const route = routeOf(url); + if (route === 'listings') listingUrls.push(url.search); + const entry: Recorded = { route, start: clock++, end: -1 }; + calls.push(entry); + await new Promise((resolve) => setTimeout(resolve, delay)); + entry.end = clock++; + const offset = Number(url.searchParams.get('offset') ?? 0); + if (route === 'listings' && options.listingsFail) { + return new Response(JSON.stringify({ message: 'listings down' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }); + } + let body: unknown = []; + if (route === 'merchant' || route === 'merchantBySlug') { + body = options.merchantFound?.(route) === false ? [] : [merchantRow]; + } else if (route === 'listings') { + body = offset > 0 ? [] : [listingRow]; + } else if (route === 'master_products') { + body = [ + { + id: MASTER_ID, + slug: 'phone-1217', + name: 'Phone 1217', + brand: 'X', + category_id: null, + metadata: { category_slug: 'smartphones' }, + min_price: 9_000_000, + max_price: 9_000_000, + merchant_count: 1, + }, + ]; + } + return new Response(JSON.stringify(body), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-range': '0-0/*', + }, + }); + }) as typeof fetch; + return calls; +} + +async function main() { + console.warn('🧪 merchant profile reads suite (DOS/Rate#1217)'); + const { + attachMerchantDiscountAnalysis, + getMerchantProfileBase, + getMerchantProfileById, + } = await import('../rate-api'); + const unhandled: unknown[] = []; + process.on('unhandledRejection', (reason) => unhandled.push(reason)); + + const analysis = { + product_id: LISTING_ID, + merchant_name: 'Shop 1217', + platform: 'shopee', + price_current: 9_000_000, + price_original: 12_000_000, + claimed_discount_pct: 25, + real_discount_vs_median_pct: 0, + historical_min_price: 9_000_000, + historical_max_price: 9_000_000, + historical_median_price: 9_000_000, + observation_count: 1, + verdict: 'fake_discount' as const, + badge_label: 'x', + explanation: 'x', + }; + const emptyHistory = { + points: [], + lowestEverPrice: null, + lowestEverDate: null, + lowestEverMerchant: null, + highestEverPrice: null, + currentMinPrice: null, + fakeDiscountCount: 1, + genuineDealCount: 0, + }; + + // 1. The base profile reads no offers or price history and carries no + // analysis; it records each listing's master instead. + { + const calls = installFetch({}); + const base = await getMerchantProfileBase(MERCHANT_ID); + assert.ok(base); + assert.deepEqual(base.listingMasterIds, { [LISTING_ID]: MASTER_ID }); + assert.equal(base.profile.listings[0]?.discount_analysis, null); + assert.equal(base.profile.listings[0]?.master_product?.slug, 'phone-1217'); + const routes = calls.map((c) => c.route); + for (const own of ['v_products_with_merchant_trust', 'price_history']) { + assert.ok(!routes.includes(own), `the base profile must not read ${own}`); + } + + // 2. The listings read starts before the merchant read has answered. + const merchant = calls.find((c) => c.route === 'merchant'); + const firstListings = calls.find((c) => c.route === 'listings'); + assert.ok(merchant && firstListings); + assert.ok( + firstListings.start < merchant.end, + 'listings must be read while the merchant row loads' + ); + assert.equal( + routes.filter((r) => r === 'merchant').length, + 1, + 'the merchant row is read once' + ); + + // 3. The analysis comes from the readers, once per master. + const readerCalls: string[] = []; + const profile = await attachMerchantDiscountAnalysis(base, { + offers: async (id) => { + readerCalls.push(`offers:${id}`); + return []; + }, + priceHistory: async (id, offers) => { + readerCalls.push(`history:${id}:${offers.length}`); + return { ...emptyHistory, byOffer: { [LISTING_ID]: analysis } }; + }, + }); + assert.deepEqual(readerCalls, [ + `offers:${MASTER_ID}`, + `history:${MASTER_ID}:0`, + ]); + assert.equal( + profile.listings[0]?.discount_analysis?.verdict, + 'fake_discount' + ); + assert.equal( + base.profile.listings[0]?.discount_analysis, + null, + 'the cached base is not mutated' + ); + } + + // 4. A failed reader fails the profile: it is never served without its + // analysis (DOS/Rate#329). + { + installFetch({}); + const base = await getMerchantProfileBase(MERCHANT_ID); + assert.ok(base); + await assert.rejects( + attachMerchantDiscountAnalysis(base, { + offers: async () => { + throw new Error('offers unavailable'); + }, + priceHistory: async () => ({ ...emptyHistory, byOffer: {} }), + }), + /offers unavailable/ + ); + } + + // 5. A UUID that is not a merchant id falls back to the slug lookup, and + // the listings are read again for the merchant actually found. + { + const calls = installFetch({ + merchantFound: (route) => route !== 'merchant', + }); + const base = await getMerchantProfileBase( + '00000000-0000-4000-8000-0000000000aa' + ); + assert.ok(base); + assert.equal(base.profile.id, MERCHANT_ID); + const listingReads = calls.filter((c) => c.route === 'listings'); + // Page and end-of-list read for the guessed id, then for the real one. + assert.equal(listingReads.length, 4); + assert.ok( + decodeURIComponent(listingUrls.at(-1) ?? '').includes( + `merchant_id=eq.${MERCHANT_ID}` + ), + 'the listings used are read for the merchant found' + ); + assert.equal(base.profile.listings.length, 1); + } + + // 6. An unknown merchant is null, even when the early listings read fails. + { + installFetch({ merchantFound: () => false }); + assert.equal( + await getMerchantProfileById('00000000-0000-4000-8000-0000000000bb'), + null + ); + installFetch({ merchantFound: () => false, listingsFail: true }); + assert.equal( + await getMerchantProfileBase('00000000-0000-4000-8000-0000000000cc'), + null + ); + } + + // 7. A failed listings read for the merchant found fails the profile. + { + installFetch({ listingsFail: true }); + await assert.rejects(getMerchantProfileBase(MERCHANT_ID)); + } + + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.deepEqual(unhandled, [], 'no read may fail unhandled'); + + console.warn(' ✅ merchant profile reads suite passed'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 142fcf9c6..6058b5583 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -3070,6 +3070,35 @@ async function readInChunks( return rows; } +/** + * Per-master readers for a merchant profile's discount analysis. The server + * layer passes the cached readers the product pages use (DOS/Rate#1217). + */ +export interface MerchantProfileMasterReaders { + offers: (masterId: string) => Promise; + priceHistory: ( + masterId: string, + offers: MerchantOffer[] + ) => Promise; +} + +const UNCACHED_MASTER_READERS: MerchantProfileMasterReaders = { + offers: (masterId) => getMasterProductOffers(masterId), + // Only byOffer is used here: skip the all-time low/high reads. + priceHistory: (masterId, offers) => + getMasterProductPriceHistory(masterId, offers, { allTime: false }), +}; + +/** + * A merchant profile without its listings' discount analysis, plus the master + * each listing belongs to (published or not), so the analysis can be added + * after the profile leaves the Data Cache. + */ +export interface MerchantProfileBase { + profile: MerchantProfileDetail; + listingMasterIds: Record; +} + /** * Fetch detailed profile for a single Merchant by UUID id, slug, or platform_shop_id, * including all listings with Fake Discount verdicts and buyer reviews with AI flags. @@ -3077,6 +3106,49 @@ async function readInChunks( export async function getMerchantProfileById( idOrSlug: string ): Promise { + const base = await getMerchantProfileBase(idOrSlug); + return base + ? attachMerchantDiscountAnalysis(base, UNCACHED_MASTER_READERS) + : null; +} + +/** + * Adds each listing's discount analysis, read per master through `readers`. + * Runs outside the profile's own cache entry: a nested unstable_cache skips + * the cache read, so the product pages' warm entries are reachable only from + * here (DOS/Rate#1217). A failed read throws, never a profile without its + * analysis (DOS/Rate#329). + */ +export async function attachMerchantDiscountAnalysis( + base: MerchantProfileBase, + readers: MerchantProfileMasterReaders +): Promise { + const masterIds = Array.from(new Set(Object.values(base.listingMasterIds))); + const discountByOfferId = new Map(); + await forEachWithConcurrency( + masterIds, + PRICE_HISTORY_CONCURRENCY, + async (masterId) => { + const offers = await readers.offers(masterId); + const ph = await readers.priceHistory(masterId, offers); + for (const [offerId, analysis] of Object.entries(ph.byOffer)) { + discountByOfferId.set(offerId, analysis); + } + } + ); + return { + ...base.profile, + listings: base.profile.listings.map((listing) => ({ + ...listing, + discount_analysis: discountByOfferId.get(listing.id) || null, + })), + }; +} + +/** The merchant profile without discount analysis; see MerchantProfileBase. */ +export async function getMerchantProfileBase( + idOrSlug: string +): Promise { const cleaned = sanitizeRateEntityId(idOrSlug); if (!cleaned || cleaned.includes('..')) return null; @@ -3089,9 +3161,33 @@ export async function getMerchantProfileById( const selectCols = 'id, platform, platform_shop_id, name, slug, url, is_official, rating_star, follower_count, location, risk_score, review_risk_score, review_risk_breakdown, metadata, created_at, updated_at'; + // Secondary reads throw too: a profile with silently empty listings or + // reviews would otherwise be cached as if it were true (DOS/Rate#329). + // Paged: retailers can list more than PostgREST's 1000-row cap. + const readListings = (merchantId: string) => + fetchAllRows('merchant listings', (from, to) => + supabase + .from('products') + .select( + 'id, merchant_id, platform, platform_item_id, title, price_current, price_original, stock, historical_sold, historical_sold_display, rating_star, rating_count, images, url, cluster_id' + ) + .eq('merchant_id', merchantId) + .order('price_current', { ascending: true }) + .order('id') + .range(from, to) + ); + let merchantRow: RawMerchantSafeRow | null = null; + // A UUID is almost always the merchant's own id: read its listings while + // the merchant row loads instead of after it, one round trip fewer on a + // cold render (DOS/Rate#1217). Used only when the id matches. + let listingsByUuid: Promise | null = null; if (isUuid) { + listingsByUuid = readListings(cleaned); + // Settled here so a failure is never unhandled while the merchant loads; + // it is rethrown below when these listings are used. + listingsByUuid.catch(() => undefined); const { data, error } = await supabase .from('merchants') .select(selectCols) @@ -3120,21 +3216,11 @@ export async function getMerchantProfileById( return null; } - // Secondary reads throw too: a profile with silently empty listings or - // reviews would otherwise be cached as if it were true (DOS/Rate#329). - // Paged: retailers can list more than PostgREST's 1000-row cap. const merchantId = merchantRow.id; - const prodRows = await fetchAllRows('merchant listings', (from, to) => - supabase - .from('products') - .select( - 'id, merchant_id, platform, platform_item_id, title, price_current, price_original, stock, historical_sold, historical_sold_display, rating_star, rating_count, images, url, cluster_id' - ) - .eq('merchant_id', merchantId) - .order('price_current', { ascending: true }) - .order('id') - .range(from, to) - ); + const prodRows = + listingsByUuid && merchantId.toLowerCase() === cleaned.toLowerCase() + ? await listingsByUuid + : await readListings(merchantId); const rawProducts = prodRows as unknown as RawProductRow[]; const clusterIds = Array.from( @@ -3224,23 +3310,6 @@ export async function getMerchantProfileById( }); } - // Compute price history / Fake Discount analysis per master product - const discountByOfferId = new Map(); - // A failure throws: the profile must not be cached without its analysis. - await forEachWithConcurrency( - clusterIds, - PRICE_HISTORY_CONCURRENCY, - async (cId) => { - // Only byOffer is used here: skip the all-time low/high reads. - const ph = await getMasterProductPriceHistory(cId, undefined, { - allTime: false, - }); - for (const [offerId, analysis] of Object.entries(ph.byOffer)) { - discountByOfferId.set(offerId, analysis); - } - } - ); - const listings: MerchantProductListing[] = rawProducts.map((p) => { const masterInfo = p.cluster_id ? masterById.get(p.cluster_id) || null @@ -3271,7 +3340,7 @@ export async function getMerchantProfileById( url: sanitizeOfferUrl(p.url), image: firstImageUrl(p.images), master_product: masterInfo, - discount_analysis: discountByOfferId.get(p.id) || null, + discount_analysis: null, }; }); @@ -3388,7 +3457,7 @@ export async function getMerchantProfileById( .map((l) => l.price_current) .filter((p) => Number.isFinite(p) && p > 0 && p <= MAX_VALID_VND_PRICE); - return { + const profile: MerchantProfileDetail = { id: merchantRow.id, platform: merchantRow.platform, platform_shop_id: merchantRow.platform_shop_id, @@ -3421,6 +3490,12 @@ export async function getMerchantProfileById( listings, recent_reviews: recentReviews, }; + + const listingMasterIds: Record = {}; + for (const p of rawProducts) { + if (p.cluster_id) listingMasterIds[p.id] = p.cluster_id; + } + return { profile, listingMasterIds }; } /** Counters shown when platform stats cannot be read; never cached. */ diff --git a/apps/web/src/server/rate/index.ts b/apps/web/src/server/rate/index.ts index fe2728516..c13e81917 100644 --- a/apps/web/src/server/rate/index.ts +++ b/apps/web/src/server/rate/index.ts @@ -13,6 +13,8 @@ import { getMasterProductReviews as _getMasterProductReviews, getMasterProductReviewSummary as _getMasterProductReviewSummary, getMasterProducts as _getMasterProducts, + attachMerchantDiscountAnalysis as _attachMerchantDiscountAnalysis, + getMerchantProfileBase as _getMerchantProfileBase, getMerchantProfileById as _getMerchantProfileById, getMerchantsList as _getMerchantsList, getRatePlatformStats as _getRatePlatformStats, @@ -477,16 +479,31 @@ export async function getCachedMerchantsList( return all; } -const _getCachedMerchantProfileByIdKey = cache((cleanId: string) => +// The profile entry holds no discount analysis: it is added per request from +// the product pages' cached offers and price history. Read inside the +// profile's unstable_cache those would skip their cache (nested entries do), +// so a cold profile would re-read every master (DOS/Rate#1217). +const _getCachedMerchantProfileBaseByKey = (cleanId: string) => withRateDataCache( - _getMerchantProfileById, - ['rate-merchant-profile', cleanId], + _getMerchantProfileBase, + ['rate-merchant-profile-base', cleanId], { revalidate: 600, tags: [RATE_CACHE_TAGS.MERCHANTS, merchantCacheTag(cleanId)], } - )(cleanId) -); + )(cleanId); + +const CACHED_MASTER_READERS = { + offers: getCachedMasterProductOffers, + priceHistory: getCachedMasterProductPriceHistory, +}; + +const _getCachedMerchantProfileByIdKey = cache(async (cleanId: string) => { + const base = await _getCachedMerchantProfileBaseByKey(cleanId); + return base + ? _attachMerchantDiscountAnalysis(base, CACHED_MASTER_READERS) + : null; +}); export function getCachedMerchantProfileById(merchantId: string) { const cleanId = sanitizeCachedEntityId(merchantId); From ae56f71b0019e44920ec10f724c324af822c514c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:22:29 +0700 Subject: [PATCH 25/69] fix(web): privacy policy covers the extension; ingest limit copy is honest (#1239) * fix(web): privacy policy covers the extension; ingest limit copy is honest - /privacy-policy gains a section on the browser extension in vi, en and cs: what lookup sends without opt-in, what the opt-in adds, what stays in the browser, and that typed searches are sent (#1233). - The ingest route comment, 429 message and OpenAPI text say the 15/min per-IP limit is best effort per server instance and that the RPC cap of 120 crowd jobs/min is the global bound (#1234). A real global per-IP limit needs a shared store or DB function (DOS-Me migration). * fix(web): the ingest 429 comes only from the per-instance limit Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 2 ++ apps/web/locales/en.json | 2 ++ apps/web/locales/vi.json | 2 ++ apps/web/src/app/[locale]/privacy-policy/page.tsx | 5 ++++- .../web/src/app/api/v1/rate/extension/ingest/route.ts | 11 ++++++++--- .../rate/__tests__/core-rate-architecture.test.ts | 11 +++++++++++ apps/web/src/server/rate/openapi.ts | 2 +- 7 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 11b934a37..11f23af74 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1409,6 +1409,8 @@ "privacyS3Desc": "Tlačítka \"Užitečné\" a \"Nahlásit\" vyžadují přihlášení účtem DOS ID (alespoň 7 dní starým, s ověřeným e-mailem nebo telefonem). Rate.vn ukládá hlas s ID účtu, aby každý účet hlasoval u recenze jen jednou, a vaši IP adresu neukládá.", "privacyS4Title": "4. Soukromé údaje prodejců", "privacyS4Desc": "Soukromé údaje prodejce (telefonní číslo, bankovní účet) jsou v polích, která veřejnost nemůže číst. Web i API mohou číst jen veřejná pole.", + "privacyS5Title": "5. Rozšíření prohlížeče Rate.vn", + "privacyS5Desc": "Na stránkách produktů 10 podporovaných obchodů rozšíření posílá Rate.vn obchod, URL produktu bez sledovacích parametrů, název produktu, ID položky a obchodu, zvolenou variantu, cenu a původní cenu, aby vyhledalo historii cen; toto vyhledávání je zapnuté ve výchozím stavu, jen čte a nepotřebuje účet. Jen když zapnete \"Đóng góp phát hiện sản phẩm mới (Opt-In)\" (ve výchozím stavu vypnuto), posílá také danou nabídku s hodnocením a počtem hodnocení do fronty ke kontrole; tato data sama veřejné ceny nemění. Rozšíření neposílá účet ani osobní údaje, neotevírá stránky, které jste neotevřeli, a ve vašem prohlížeči ukládá jen adresu serveru, volbu opt-in a mezipaměť platnou po dobu relace s produkty, které jste si prohlédli, vaším posledním hledáním a výsledky vyhledávání. Text, který napíšete do vyhledávacího pole rozšíření, se také odešle k vyhledání.", "privacySide1Title": "Ověřte si otevřený kód", "privacySide1Desc": "Pravidla přístupu k datům a způsob ukládání hlasů jsou veřejné v repozitáři na GitHubu.", "privacySide1Btn": "Technická a API dokumentace", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 01c170650..ef6f1c118 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1409,6 +1409,8 @@ "privacyS3Desc": "\"Helpful\" and \"Report\" need a signed-in DOS ID account (at least 7 days old, with a verified email or phone). Rate.vn stores the vote with your account ID so each account votes once per review, and it does not store your IP address.", "privacyS4Title": "4. Sellers' private details", "privacyS4Desc": "A seller's private details (phone number, bank account) are kept in fields the public cannot read. The website and the API can only read the public fields.", + "privacyS5Title": "5. The Rate.vn browser extension", + "privacyS5Desc": "On product pages of the 10 supported stores, the extension sends Rate.vn the store, the product URL with tracking parameters removed, the product title, the item and shop ids, the selected variant, the price and the original price to look up price history; this lookup is on by default, read-only and needs no account. Only if you turn on \"Đóng góp phát hiện sản phẩm mới (Opt-In)\" (off by default) does it also send that listing, with its rating and rating count, to a moderation queue; this data does not change public prices by itself. The extension sends no account or personal data, opens no page you did not open, and keeps only the server address, the opt-in choice and a session-only cache of the products you viewed, your latest search and their lookup results in your browser. Text you type into the extension's search box is also sent to look it up.", "privacySide1Title": "Check the open code", "privacySide1Desc": "The data access rules and how votes are stored are public in the GitHub repository.", "privacySide1Btn": "Technical and API docs", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index baaf88c74..4a014517d 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -1409,6 +1409,8 @@ "privacyS3Desc": "Nút \"Hữu ích\" và \"Báo cáo\" cần đăng nhập bằng tài khoản DOS ID (tạo ít nhất 7 ngày, đã xác minh email hoặc số điện thoại). Rate.vn lưu bình chọn kèm mã tài khoản để mỗi tài khoản chỉ bình chọn một lần cho mỗi đánh giá, và không lưu địa chỉ IP của bạn.", "privacyS4Title": "4. Thông tin riêng của gian hàng", "privacyS4Desc": "Thông tin riêng của gian hàng (số điện thoại, tài khoản ngân hàng) nằm ở các trường mà công chúng không đọc được. Trang web và API chỉ đọc được các trường công khai.", + "privacyS5Title": "5. Tiện ích trình duyệt Rate.vn", + "privacyS5Desc": "Trên trang sản phẩm của 10 sàn được hỗ trợ, tiện ích gửi cho Rate.vn sàn, URL sản phẩm đã cắt tham số theo dõi, tên sản phẩm, mã sản phẩm và mã gian hàng, phiên bản đang chọn, giá bán và giá gốc để tra lịch sử giá; việc tra cứu này bật sẵn, chỉ đọc và không cần tài khoản. Chỉ khi bạn bật \"Đóng góp phát hiện sản phẩm mới (Opt-In)\" (mặc định tắt), tiện ích còn gửi tin bán đó kèm số sao và số lượt đánh giá vào hàng đợi kiểm duyệt; dữ liệu này không tự đổi giá công khai. Tiện ích không gửi tài khoản hay thông tin cá nhân, không mở trang bạn không mở, và chỉ lưu trên trình duyệt của bạn địa chỉ máy chủ, lựa chọn có đóng góp hay không, cùng bộ nhớ tạm theo phiên gồm các sản phẩm bạn vừa xem, lần tìm kiếm gần nhất và kết quả tra cứu. Nội dung bạn gõ vào ô tìm kiếm của tiện ích cũng được gửi để tra cứu.", "privacySide1Title": "Kiểm tra mã nguồn công khai", "privacySide1Desc": "Quy tắc phân quyền dữ liệu và cách lưu bình chọn đều công khai trong repository trên GitHub.", "privacySide1Btn": "Xem tài liệu kỹ thuật và API", diff --git a/apps/web/src/app/[locale]/privacy-policy/page.tsx b/apps/web/src/app/[locale]/privacy-policy/page.tsx index 383420441..700c8a8c7 100644 --- a/apps/web/src/app/[locale]/privacy-policy/page.tsx +++ b/apps/web/src/app/[locale]/privacy-policy/page.tsx @@ -60,7 +60,10 @@ export default async function PrivacyPolicyPage({

{t('privacyS3Desc')}

{t('privacyS4Title')}

-

{t('privacyS4Desc')}

+

{t('privacyS4Desc')}

+ +

{t('privacyS5Title')}

+

{t('privacyS5Desc')}

diff --git a/apps/web/src/app/api/v1/rate/extension/ingest/route.ts b/apps/web/src/app/api/v1/rate/extension/ingest/route.ts index ac4b6873b..eb8cb62a8 100644 --- a/apps/web/src/app/api/v1/rate/extension/ingest/route.ts +++ b/apps/web/src/app/api/v1/rate/extension/ingest/route.ts @@ -56,7 +56,10 @@ const CrowdIngestSchema = z.object({ userConsentOptIn: z.boolean(), }); -// Bounded per-IP sliding window rate limiter for crowd ingest quarantine +// Per-IP sliding window, kept in this serverless instance's memory: each +// warm instance has its own buckets, so it is a best-effort brake, not a +// global per-IP limit. The global bound is the enqueue_crowd_verify_signal +// RPC cap of 120 crowd jobs per minute in total (DOS/Rate#1234). const ingestRateLimiter = new BoundedSlidingWindowRateLimiter({ windowMs: 60_000, maxRequests: 15, @@ -75,7 +78,8 @@ export async function OPTIONS() { * Zero-Trust Crowdsourced Signal Quarantine Endpoint (Two-Speed Hybrid Architecture v2). * Enforces: * - Explicit Opt-In Consent check (`userConsentOptIn === true`) - * - Zod schema validation & sliding-window IP rate limit + DB-side global rate limit + * - Zod schema validation, a best-effort per-instance IP rate limit and the + * DB-side global cap (120 crowd jobs per minute in total) * - Accessory / Junk Gatekeeper * - Platform Capability Matrix check (only queues platforms with active verification workers) * - 100% Quarantine: Enqueues into `rate.crawl_jobs` via narrow `enqueue_crowd_verify_signal` RPC without `service_role`; NEVER mutates canonical prices directly. @@ -88,7 +92,8 @@ export async function POST(request: NextRequest) { return NextResponse.json( { ok: false, - error: 'Rate limit exceeded. Maximum 15 crowd signals per minute.', + error: + 'Rate limit exceeded. Too many crowd signals from this address; try again in a minute.', }, { status: 429, headers: CORS_HEADERS } ); diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index b570f6ad9..92289ebae 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -3016,9 +3016,20 @@ async function runCoreArchitectureSuite() { ); if (rlRes.status === 429) { hit429 = true; + // The limit is per instance, so the reply promises no global cap + // (DOS/Rate#1234). + const rlBody = (await rlRes.json()) as { error: string }; + assert.ok(!/Maximum 15/.test(rlBody.error), rlBody.error); break; } } + assert.match( + String( + openApi.paths['/api/v1/rate/extension/ingest']?.post?.responses?.['429'] + ?.description + ), + /each server instance/ + ); assert.equal( hit429, true, diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index f39f24f41..0596a184b 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -935,7 +935,7 @@ export function buildRateOpenApiSpec( }, '429': { description: - 'Per-IP sliding-window rate limit exceeded (max 15 signals/min).', + 'Rate limit exceeded: a best-effort per-IP limit of 15 signals/min, counted separately on each server instance. The global cap of 120 crowd jobs/min is reported as a 200 with `jobState: "queue_error"`.', }, }, }, From 1fefafa8da093a0977ab8e175eacf3a098ae1324 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:24:18 +0700 Subject: [PATCH 26/69] fix(extension): truthful opt-in label, store doc and narrower tiktok hosts (#1241) * fix(extension): truthful opt-in label, store doc and narrower tiktok hosts - The opt-in label lists the ten fields the crowd signal sends; the payload is built by buildCrowdIngestPayload next to the labels, and a test ties the label to the payload keys (#1230). - CHROMEWEBSTORE.md justifies exactly the manifest's permissions and hosts (no tabs or localhost; adds TikTok, Hoang Ha, Di Dong Viet, Phong Vu, www.rate.vn) and describes what lookup and the opt-in send; a test compares the doc with the manifest (#1231). - TikTok is limited to www.tiktok.com and shop.tiktok.com, the hosts its product pages use (#1232). What the extension collects is unchanged. * docs(extension): store doc lists the session cache and side panel searches * docs(extension): the session cache also holds the latest search Co-Authored-By: Claude Opus 5.5 --- apps/extension/CHROMEWEBSTORE.md | 24 ++--- apps/extension/manifest.json | 11 ++- .../src/background/service-worker.ts | 15 +--- apps/extension/src/security-guards.ts | 38 ++++++++ apps/extension/src/sidepanel/sidepanel.html | 2 +- apps/extension/src/test-extension-pipeline.ts | 87 ++++++++++++++++++- 6 files changed, 146 insertions(+), 31 deletions(-) diff --git a/apps/extension/CHROMEWEBSTORE.md b/apps/extension/CHROMEWEBSTORE.md index 93eb22a3e..8b5bed549 100644 --- a/apps/extension/CHROMEWEBSTORE.md +++ b/apps/extension/CHROMEWEBSTORE.md @@ -30,21 +30,25 @@ Rate.vn giúp người tiêu dùng mua sắm đồ công nghệ minh bạch và ## 2. Permissions Justification (For Chrome Web Store Review Team) -| Permission / Host Permission | Plain-English Justification | -| :-------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `storage` | Saves the user's preferred Rate.vn server endpoint, temporary product lookup cache for the active session, and the user's explicit opt-in preference for contributing public product price signals. | -| `sidePanel` | Displays the detailed price history chart, cross-merchant price comparison table, and AI review authenticity breakdown alongside the shopping page when the user clicks the extension badge or floating pill. | -| `tabs` | Reads the active shopping tab's product URL and title so the Side Panel can display the matching price history and cross-store comparison for the product currently being viewed. | -| `https://shopee.vn/*`, `https://*.lazada.vn/*`, `https://tiki.vn/*`, `https://cellphones.com.vn/*`, `https://*.thegioididong.com/*`, `https://fptshop.com.vn/*` | Reads the public product title, selected storage variant, and listed price on supported Vietnamese e-commerce product pages to display the floating price-check badge. | -| `https://rate.vn/*`, `http://localhost:3000/*` | Fetches verified price history, cross-merchant offers, and review authenticity scores from the Rate.vn API. | +This table lists exactly what `manifest.json` requests; `test-extension-pipeline.ts` fails when the two disagree (DOS/Rate#1231). + +| Permission / Host Permission | Plain-English Justification | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `storage` | Saves the user's preferred Rate.vn server endpoint, a temporary product lookup cache for the active session, and the user's explicit opt-in preference for contributing product price signals. | +| `sidePanel` | Displays the price history chart, cross-merchant price comparison table and AI review authenticity breakdown beside the shopping page when the user clicks the extension badge or floating pill. | +| `https://shopee.vn/*`, `https://*.lazada.vn/*`, `https://tiki.vn/*`, `https://www.tiktok.com/*`, `https://shop.tiktok.com/*`, `https://cellphones.com.vn/*`, `https://*.thegioididong.com/*`, `https://fptshop.com.vn/*`, `https://hoanghamobile.com/*`, `https://didongviet.vn/*`, `https://phongvu.vn/*` | Runs the content script on the 10 supported Vietnamese stores. It reads the public product title, selected variant, listed price, original price and rating of a product page to show the floating price-check badge. The script loads on every page of these sites because they navigate in-page, but it reads and sends nothing on pages it does not recognise as a product page. TikTok is limited to the two hosts its product pages use (DOS/Rate#1232). | +| `https://rate.vn/*`, `https://www.rate.vn/*` | Fetches verified price history, cross-merchant offers and review authenticity scores from the Rate.vn API, and sends the opt-in crowd signal. | --- ## 3. Privacy & User Data Disclosure -1. **No Personal or Browsing History Profiling:** Rate.vn only activates on supported product detail pages (`shopee.vn`, `lazada.vn`, `tiki.vn`, `cellphones.com.vn`, `thegioididong.com`, `fptshop.com.vn`). -2. **Automatic URL Canonicalization (Tracking Stripping):** Before performing any product lookup, all query parameters and tracking tokens (`utm_*`, `sp_atk`, `xptdk`, `fbclid`, `gclid`) are stripped locally in the browser. Only the clean product pathname, product title, selected storage variant, and displayed price are used for lookup. -3. **Opt-In Crowdsourced Verification Queue:** By default, the extension operates in **100% Read-Only Lookup Mode** (`GET /api/v1/rate/extension/lookup`). Enqueuing newly discovered public product listings for server-side verification (`POST /api/v1/rate/extension/ingest`) is **disabled by default** and requires the user to explicitly enable the "Đóng góp phát hiện sản phẩm mới (Opt-In)" toggle in the Side Panel. +1. **Where it runs:** the content script loads on the 10 supported stores listed above (`shopee.vn`, `lazada.vn`, `tiki.vn`, `www.tiktok.com`, `shop.tiktok.com`, `cellphones.com.vn`, `thegioididong.com`, `fptshop.com.vn`, `hoanghamobile.com`, `didongviet.vn`, `phongvu.vn`). It acts only on pages it recognises as a product page; it never opens or fetches pages the user did not open. +2. **Lookup (on by default, no opt-in):** on a recognised product page the extension sends `GET /api/v1/rate/extension/lookup` with the store, the product URL with query parameters and tracking tokens (`utm_*`, `sp_atk`, `xptdk`, `fbclid`, `gclid`) stripped locally, the product title, the store's item id and shop id, the selected variant, the listed price and the original price. The request is read-only and carries no account or personal data. +3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (Opt-In)" in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue and never change public prices directly. +4. **Stored in the browser only:** the server address and the opt-in choice (`chrome.storage.local`), and a session-only cache of the products viewed in each tab, the latest side panel search and their lookup results (`chrome.storage.session`). +5. **Side panel requests:** text the user types into the side panel search is sent to the same lookup endpoint; the side panel also reads public deals and merchant lists from the Rate.vn API. +6. **Privacy policy:** https://rate.vn/privacy-policy has a section on the extension. --- diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json index 603f56165..affc91f48 100644 --- a/apps/extension/manifest.json +++ b/apps/extension/manifest.json @@ -24,15 +24,13 @@ "service_worker": "background/service-worker.js", "type": "module" }, - "permissions": [ - "storage", - "sidePanel" - ], + "permissions": ["storage", "sidePanel"], "host_permissions": [ "https://shopee.vn/*", "https://*.lazada.vn/*", "https://tiki.vn/*", - "https://*.tiktok.com/*", + "https://www.tiktok.com/*", + "https://shop.tiktok.com/*", "https://cellphones.com.vn/*", "https://*.thegioididong.com/*", "https://fptshop.com.vn/*", @@ -48,7 +46,8 @@ "https://shopee.vn/*", "https://*.lazada.vn/*", "https://tiki.vn/*", - "https://*.tiktok.com/*", + "https://www.tiktok.com/*", + "https://shop.tiktok.com/*", "https://cellphones.com.vn/*", "https://*.thegioididong.com/*", "https://fptshop.com.vn/*", diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index d673e1ef9..e2ca34e9a 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -10,6 +10,7 @@ import { sanitizeProductUrl, } from '../extractors/ecommerce-detector.js'; import { + buildCrowdIngestPayload, DEFAULT_API_BASE_URL, isPrivilegedExtensionSender, normalizeAllowedApiBaseUrl, @@ -114,19 +115,7 @@ async function maybeSendOptInCrowdSignal( 'X-Rate-Extension-Version': '2.0.0', }, signal: AbortSignal.timeout(8_000), - body: JSON.stringify({ - platform: signal.platform, - externalItemId: signal.externalItemId, - externalShopId: signal.externalShopId, - url: signal.canonicalUrl, - title: signal.title, - selectedVariant: signal.selectedVariant, - price: signal.price, - originalPrice: signal.originalPrice, - ratingStar: signal.ratingStar, - ratingCount: signal.ratingCount, - userConsentOptIn: true, - }), + body: JSON.stringify(buildCrowdIngestPayload(signal)), } ); } catch { diff --git a/apps/extension/src/security-guards.ts b/apps/extension/src/security-guards.ts index 8e831200a..ba64a6dcc 100644 --- a/apps/extension/src/security-guards.ts +++ b/apps/extension/src/security-guards.ts @@ -498,3 +498,41 @@ export function shouldRenderTrustPill( lookup.matchStatus === 'ambiguous_variant' ); } + +/** + * What the opt-in crowd signal sends, with the words the side panel uses + * for each field. The opt-in label lists every one, so a field added here + * is disclosed too (DOS/Rate#1230). + */ +export const CROWD_INGEST_FIELD_LABELS = { + platform: 'sàn', + externalItemId: 'mã sản phẩm', + externalShopId: 'mã gian hàng', + url: 'URL đã cắt tham số theo dõi', + title: 'tên sản phẩm', + selectedVariant: 'phiên bản đang chọn', + price: 'giá bán', + originalPrice: 'giá gốc', + ratingStar: 'số sao', + ratingCount: 'số lượt đánh giá', +} as const; + +export function buildCrowdIngestPayload( + signal: ExtractedPageProductSignal +): Record & { + userConsentOptIn: true; +} { + return { + platform: signal.platform, + externalItemId: signal.externalItemId, + externalShopId: signal.externalShopId, + url: signal.canonicalUrl, + title: signal.title, + selectedVariant: signal.selectedVariant, + price: signal.price, + originalPrice: signal.originalPrice, + ratingStar: signal.ratingStar, + ratingCount: signal.ratingCount, + userConsentOptIn: true, + }; +} diff --git a/apps/extension/src/sidepanel/sidepanel.html b/apps/extension/src/sidepanel/sidepanel.html index 7f53fffa1..2fc056ca1 100644 --- a/apps/extension/src/sidepanel/sidepanel.html +++ b/apps/extension/src/sidepanel/sidepanel.html @@ -274,7 +274,7 @@ Đóng góp phát hiện sản phẩm mới (Opt-In): - Chỉ gửi URL sản phẩm công khai đã cắt sạch tham số theo dõi vào hàng đợi kiểm duyệt độc lập của Rate.vn. + Khi bật, mỗi tin bán bạn mở còn được gửi vào hàng đợi kiểm duyệt độc lập của Rate.vn với: sàn, mã sản phẩm, mã gian hàng, URL đã cắt tham số theo dõi, tên sản phẩm, phiên bản đang chọn, giá bán, giá gốc, số sao và số lượt đánh giá. Không kèm tài khoản hay thông tin cá nhân.
diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index 107109fca..b7c741066 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -477,12 +477,97 @@ async function runTests() { ); const manifestJson = JSON.parse( fs.readFileSync(path.resolve(__dirname, '../manifest.json'), 'utf8') - ) as { permissions?: string[] }; + ) as { + permissions?: string[]; + host_permissions?: string[]; + content_scripts?: Array<{ matches?: string[] }>; + }; assert.deepEqual( manifestJson.permissions, ['storage', 'sidePanel'], 'manifest.json must request only least-privilege storage and sidePanel permissions without tabs (Audit C7, #174)' ); + { + // TikTok is limited to the hosts its product pages use (DOS/Rate#1232). + const hosts = manifestJson.host_permissions ?? []; + const matches = manifestJson.content_scripts?.[0]?.matches ?? []; + for (const pattern of [...hosts, ...matches]) { + assert.ok(!pattern.includes('*.tiktok.com'), pattern); + } + const matchesPattern = (pattern: string, url: string) => + new RegExp( + `^${pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace('://*\\.', '://(?:[^/]+\\.)?') + .replace(/\*/g, '.*')}$` + ).test(url); + for (const url of [ + 'https://www.tiktok.com/@appleflagship/product/1729384756', + 'https://www.tiktok.com/view/product/1729384756', + 'https://shop.tiktok.com/view/product/1729401928401', + ]) { + assert.ok( + matches.some((p) => matchesPattern(p, url)), + `content script must run on ${url}` + ); + assert.ok( + hosts.some((p) => matchesPattern(p, url)), + url + ); + } + + // CHROMEWEBSTORE.md justifies exactly what the manifest requests + // (DOS/Rate#1231). + const storeDoc = fs.readFileSync( + path.resolve(__dirname, '../CHROMEWEBSTORE.md'), + 'utf8' + ); + const permissionSection = storeDoc.slice( + storeDoc.indexOf('## 2. Permissions'), + storeDoc.indexOf('## 3.') + ); + const justified = [ + ...permissionSection.matchAll(/^\|\s*(`[^|]+)\|/gm), + ].flatMap((row) => [...row[1]!.matchAll(/`([^`]+)`/g)].map((m) => m[1])); + assert.deepEqual( + [...justified].sort(), + [...(manifestJson.permissions ?? []), ...hosts].sort(), + 'CHROMEWEBSTORE.md permission table must match manifest.json' + ); + assert.deepEqual( + [...matches].sort(), + hosts.filter((h) => !/rate\.vn/.test(h)).sort() + ); + + // The opt-in label names every field the crowd signal sends + // (DOS/Rate#1230). + const { buildCrowdIngestPayload, CROWD_INGEST_FIELD_LABELS } = + await import('./security-guards.js'); + const payload = buildCrowdIngestPayload({ + platform: 'shopee', + canonicalUrl: 'https://shopee.vn/x-i.1.2', + title: 'iPhone 16', + } as never); + assert.deepEqual( + Object.keys(payload).sort(), + [...Object.keys(CROWD_INGEST_FIELD_LABELS), 'userConsentOptIn'].sort() + ); + const panelHtml = fs.readFileSync( + path.resolve(__dirname, 'sidepanel/sidepanel.html'), + 'utf8' + ); + const optInLabel = panelHtml.slice( + panelHtml.indexOf('id="chk-crowd-optin"'), + panelHtml.indexOf('', panelHtml.indexOf('id="chk-crowd-optin"')) + ); + for (const label of Object.values(CROWD_INGEST_FIELD_LABELS)) { + assert.ok(optInLabel.includes(label), `opt-in label lacks "${label}"`); + } + assert.ok(!/Chỉ gửi URL/.test(optInLabel)); + console.log( + ' ✅ [Manifest, store doc and opt-in disclosure agree (#1230, #1231, #1232)] Verified' + ); + } if (fs.existsSync(path.resolve(__dirname, '../dist/manifest.json'))) { // esbuild bundles each entry (#238): no output may import another file, // and the content script (no module support in MV3) must not use import/export. From b3b043b141b945ad7ce5e2f3324ac9ff73ef2fcc Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:30:28 +0700 Subject: [PATCH 27/69] feat(importer): make arena spec sync, review risk and peer floors item-aware (#1243) * feat(importer): make arena spec sync, review risk and peer floors item-aware Behind RATE_ITEM_MODEL (DOS/Rate#1213, design doc 4.1, 4.4, 5.3): - syncArenaSpecsToItem writes Arena specs to rate.items without storage or RAM, and fills only empty storage/RAM overrides on sized variants; dry-run by default, never creates Items, no-op with the flag off. - the Arena audit counts a flagship present when its Item is (another size or a rate.items row) and lists Items with incomplete specs. - resolveClusterPeerFloorPrice takes an optional variant id: a listing with no variant gets no floor and NULL-variant rows never enter one; seller review risk passes it. Flag-off change: the Arena parser stores an unknown 5G as null and invents no cellular defaults (DOS/Rate#1196). * fix(importer): arena item sync keeps one ram and writes only unchanged rows Review findings on DOS/Rate#1213: - writes are a compare-and-swap on updated_at (variants also not archived); a row changed after the read is counted as a conflict; - a stored RAM number or label wins over Arena, so the two never disagree; - slash RAM options (8GB/12GB RAM) are RAM, never storage, and name no single RAM; memory variants come from the GSMArena line only; - the audit keeps the raw item_key of an Item it cannot sanitize. * fix(importer): a suffixed ram label wins over the arena ram Codex round 2 on DOS/Rate#1213: a stored key_specs.ram such as '8GB LPDDR5X' was ignored, so the sync wrote Arena's ram_gb next to a different label. The label's leading size is now read, and a label that names no size blocks Arena's RAM. * test(importer): build the upper-case arena key at run time The shared-data key scan reads every three-part literal in the importer test files and requires it to parse, so the upper-case fixture failed the monorepo build (DOS/Rate#1213). Co-Authored-By: Claude Opus 5.5 --- apps/importer/package.json | 2 +- .../shopee/__tests__/item-aware-jobs.test.ts | 610 ++++++++++++++++++ .../__tests__/open-core-intelligence.test.ts | 12 + apps/importer/src/shopee/arena-spec-syncer.ts | 391 ++++++++++- .../importer/src/shopee/cluster-peer-floor.ts | 22 +- .../src/shopee/jobs/backfill-specs.ts | 4 +- .../src/shopee/merchant-review-risk.ts | 3 +- 7 files changed, 1020 insertions(+), 24 deletions(-) create mode 100644 apps/importer/src/shopee/__tests__/item-aware-jobs.test.ts diff --git a/apps/importer/package.json b/apps/importer/package.json index e5ff54214..96882126f 100644 --- a/apps/importer/package.json +++ b/apps/importer/package.json @@ -37,7 +37,7 @@ "lint": "eslint . --max-warnings 0", "format": "prettier --write \"src/**/*.ts\"", "backfill:specs": "tsx src/shopee/jobs/backfill-specs.ts", - "test": "tsx src/shopee/__tests__/open-core-intelligence.test.ts && tsx src/shopee/__tests__/entity-matcher.test.ts && tsx src/shopee/__tests__/item-matcher.test.ts && tsx src/shopee/__tests__/shopee-to-supabase.test.ts && tsx src/shopee/__tests__/paging.test.ts && tsx src/shopee/__tests__/backfill-review-risk.test.ts && tsx src/shopee/__tests__/item-inventory-report.test.ts && tsx src/shopee/__tests__/arena-audit.test.ts && tsx src/shopee/test-ai-enricher.ts && tsx src/shopee/test-review-analyzer.ts", + "test": "tsx src/shopee/__tests__/open-core-intelligence.test.ts && tsx src/shopee/__tests__/entity-matcher.test.ts && tsx src/shopee/__tests__/item-matcher.test.ts && tsx src/shopee/__tests__/shopee-to-supabase.test.ts && tsx src/shopee/__tests__/paging.test.ts && tsx src/shopee/__tests__/backfill-review-risk.test.ts && tsx src/shopee/__tests__/item-inventory-report.test.ts && tsx src/shopee/__tests__/arena-audit.test.ts && tsx src/shopee/__tests__/item-aware-jobs.test.ts && tsx src/shopee/test-ai-enricher.ts && tsx src/shopee/test-review-analyzer.ts", "test:dist": "node src/shopee/__tests__/dist-exports.smoke.mjs", "backfill:review-risk": "tsx src/shopee/jobs/backfill-review-risk.ts" }, diff --git a/apps/importer/src/shopee/__tests__/item-aware-jobs.test.ts b/apps/importer/src/shopee/__tests__/item-aware-jobs.test.ts new file mode 100644 index 000000000..a2482a965 --- /dev/null +++ b/apps/importer/src/shopee/__tests__/item-aware-jobs.test.ts @@ -0,0 +1,610 @@ +/** + * Item-aware importer jobs (DOS/Rate#1213, design doc 4.1, 4.4 and 5.3), each with + * RATE_ITEM_MODEL off and on: + * 1. Arena parser: an unknown 5G is null (DOS/Rate#1196); storage sizes with their RAM + * 2. syncArenaSpecsToItem: Item specs without storage/RAM, variant overrides only where empty + * 3. Arena audit: another size of a present Item is not a missing release; Item spec gaps + * 4. Peer floor: a NULL-variant listing never enters, and never gets, a peer floor + * 5. Seller review risk: reviews of NULL-variant listings count, they get no price term + */ +import assert from 'node:assert/strict'; +import { + ArenaSpecSyncer, + UPSTREAM_2026_ARENA_REFERENCE_URLS, + parseArenaMemoryVariants, + parseGsmArenaHtmlToSpecs, + syncArenaSpecsToItem, + type ArenaSyncedDeviceSpec, +} from '../arena-spec-syncer.js'; +import { resolveClusterPeerFloorPrice } from '../cluster-peer-floor.js'; +import { + AiReviewAnalyzer, + type MerchantRiskEvaluation, + type MerchantRiskInput, +} from '../ai-review-analyzer.js'; +import { + loadAndScoreMerchant, + scoreMerchants, + strongestPriceAnomaly, + type MerchantRow, + type ProductRow, + type ReviewRow, +} from '../merchant-review-risk.js'; +import { ITEM_MODEL_ENV } from '../item-model-flag.js'; +import type { RateSupabaseClient } from '../types.js'; + +type Row = Record; +type Result = { data: unknown; error: { message: string } | null }; + +interface Call { + table: string; + op: 'select' | 'update' | 'insert' | 'upsert'; + cols?: string; + payload?: Row; + filters: Array<[string, string, unknown]>; +} + +/** Chainable PostgREST stand-in; every query resolves through `handler`. */ +function createDb(handler: (call: Call) => Result | undefined) { + const calls: Call[] = []; + const run = (call: Call): Result => handler(call) ?? { data: null, error: null }; + const builder = (call: Call) => { + const b: Record = {}; + for (const name of ['eq', 'neq', 'in', 'is', 'gt', 'lte']) { + b[name] = (col: string, val: unknown) => { + call.filters.push([name, col, val]); + return b; + }; + } + for (const name of ['order', 'limit', 'select']) b[name] = () => b; + // One page of rows: later pages are empty. + b.range = (from: number) => Promise.resolve(from > 0 ? { data: [], error: null } : run(call)); + b.maybeSingle = () => Promise.resolve(run(call)); + b.then = (resolve: (r: Result) => unknown, reject?: (e: unknown) => unknown) => + Promise.resolve(run(call)).then(resolve, reject); + return b; + }; + const client = { + from(table: string) { + const start = (op: Call['op'], extra: Partial = {}) => { + const call: Call = { table, op, filters: [], ...extra }; + calls.push(call); + return builder(call); + }; + return { + select: (cols: string) => start('select', { cols }), + update: (payload: Row) => start('update', { payload }), + insert: (payload: Row) => start('insert', { payload }), + upsert: (payload: Row) => start('upsert', { payload }), + }; + }, + } as unknown as RateSupabaseClient; + return { client, calls }; +} + +const filterValue = (call: Call | undefined, col: string) => + call?.filters.find(([, c]) => c === col)?.[2]; + +const FULL_SPECS = Object.fromEntries( + [ + 'display', + 'hardware', + 'camera', + 'battery', + 'design', + 'cellular', + 'connectivity', + 'multimedia', + 'availability', + 'regulatory', + ].map(group => [group, { value: 'x' }]) +); + +const ARENA_HTML = `

Xiaomi 18 Pro Max

+ GSM / HSPA / LTE / 5G + Snapdragon 8 Elite Gen 6 + 256GB 12GB RAM, 512GB 16GB RAM, 1TB 16GB RAM + Si/C 7500 mAh`; + +function withFlag(value: string | undefined, fn: () => T): T { + const previous = process.env[ITEM_MODEL_ENV]; + if (value === undefined) delete process.env[ITEM_MODEL_ENV]; + else process.env[ITEM_MODEL_ENV] = value; + try { + return fn(); + } finally { + if (previous === undefined) delete process.env[ITEM_MODEL_ENV]; + else process.env[ITEM_MODEL_ENV] = previous; + } +} + +async function run() { + console.log('🧪 Item-aware importer jobs suite (#1213)'); + + // 1. Arena parser + { + const unknown = parseGsmArenaHtmlToSpecs( + `

Xiaomi Pad 8

Snapdragon` + ); + assert.equal(unknown.has5g, null, 'a page with no network fields says nothing about 5G'); + assert.equal(unknown.specifications.cellular?.has_5g, undefined); + assert.equal(unknown.specifications.cellular?.sim_type, undefined, 'no invented SIM'); + assert.equal(unknown.specifications.cellular?.technology, undefined); + assert.equal(unknown.specifications.cellular?.data_speed, undefined); + + const no5g = parseGsmArenaHtmlToSpecs( + `

Samsung Galaxy A06

GSM / HSPA / LTE` + ); + assert.equal(no5g.has5g, false, 'a stated network without 5G is a known false'); + assert.equal(no5g.specifications.cellular?.has_5g, false); + assert.equal(no5g.specifications.cellular?.technology, 'GSM / HSPA / LTE'); + + const with5g = parseGsmArenaHtmlToSpecs(ARENA_HTML); + assert.equal(with5g.has5g, true); + assert.equal(with5g.specifications.cellular?.has_5g, true); + assert.equal(with5g.storageGb, 1024, 'the top-level storage stays the largest size'); + assert.equal(with5g.ramGb, 16); + assert.deepEqual(with5g.memoryVariants, [ + { storageGb: 256, ramGb: 12 }, + { storageGb: 512, ramGb: 16 }, + { storageGb: 1024, ramGb: 16 }, + ]); + + assert.deepEqual( + parseArenaMemoryVariants('128GB 8GB RAM, 256GB 8GB RAM, 256GB 12GB RAM, 512GB'), + [ + { storageGb: 128, ramGb: 8 }, + { storageGb: 256, ramGb: null }, + { storageGb: 512, ramGb: null }, + ], + 'a size with two RAM options, or none, has no known RAM' + ); + assert.deepEqual( + parseArenaMemoryVariants('256GB 8GB/12GB RAM, 512GB 8/12GB RAM'), + [ + { storageGb: 256, ramGb: null }, + { storageGb: 512, ramGb: null }, + ], + 'slash RAM options are RAM, not storage, and name no single RAM' + ); + assert.deepEqual(parseArenaMemoryVariants(''), []); + assert.deepEqual(parseArenaMemoryVariants(undefined as unknown as string), []); + } + console.log(' ✅ [1/5] Arena parser: unknown 5G is null; storage sizes with their RAM'); + + // 2. syncArenaSpecsToItem + { + const spec: ArenaSyncedDeviceSpec = parseGsmArenaHtmlToSpecs(ARENA_HTML); + const itemRow = { + id: 'item-x18', + specifications: { battery: { capacity_mah: 7000 }, design: { weight_g: 220 } }, + key_specs: { screen: '6.9" OLED', storage: '256GB', ram: '12GB' }, + }; + const variantRows = [ + { id: 'v-256', variant_key: '256gb', status: 'published', storage_gb: null, ram_gb: null }, + { + id: 'v-512', + variant_key: '512gb', + status: 'published', + storage_gb: 512, + ram_gb: 12, + key_specs: { storage: '512GB', ram: '12GB' }, + }, + { + id: 'v-1024', + variant_key: '1024gb', + status: 'draft', + storage_gb: 1024, + ram_gb: null, + key_specs: { storage: '1TB' }, + }, + { id: 'v-unknown', variant_key: 'unknown', status: 'published', storage_gb: null }, + { id: 'v-old', variant_key: '128gb', status: 'archived', storage_gb: null }, + // A stored RAM number: the label follows it, not Arena. + { id: 'v-2048', variant_key: '2048gb', status: 'draft', ram_gb: 8, key_specs: {} }, + // A stored RAM label with a suffix: the number follows it, not Arena. + { + id: 'v-8192', + variant_key: '8192gb', + status: 'draft', + storage_gb: 8192, + key_specs: { storage: '8TB', ram: '24GB LPDDR5X' }, + }, + // A stored label that names no size: Arena's RAM is not used either. + { + id: 'v-16384', + variant_key: '16384gb', + status: 'draft', + storage_gb: 16384, + key_specs: { storage: '16TB', ram: 'LPDDR5X' }, + }, + // A stored RAM label: the number follows it, not Arena. + { + id: 'v-4096', + variant_key: '4096gb', + status: 'draft', + storage_gb: 4096, + key_specs: { storage: '4TB', ram: '8GB' }, + updated_at: '2026-10-28T00:00:00.000Z', + }, + ]; + const catalogDb = (opts: { item?: Row | null; itemErr?: string; changedIds?: string[] } = {}) => + createDb(call => { + if (call.op === 'update') { + // Compare-and-swap: a row another writer changed matches nothing. + const id = filterValue(call, 'id') as string; + return { data: opts.changedIds?.includes(id) ? [] : [{ id }], error: null }; + } + if (call.table === 'items' && call.op === 'select') { + if (opts.itemErr) return { data: null, error: { message: opts.itemErr } }; + return { data: opts.item === undefined ? itemRow : opts.item, error: null }; + } + if (call.table === 'master_products' && call.op === 'select') { + return { data: variantRows, error: null }; + } + return undefined; + }); + + // Flag off (explicit and from the env): nothing is read or written. + const off = catalogDb(); + const offRun = await syncArenaSpecsToItem(off.client, spec, { + key: 'xiaomi:18-pro-max:512gb', + apply: true, + itemModel: 'off', + }); + assert.equal(offRun.status, 'skipped'); + const envOff = await withFlag(undefined, () => + syncArenaSpecsToItem(off.client, spec, { key: 'xiaomi:18-pro-max', apply: true }) + ); + assert.equal(envOff.status, 'skipped'); + assert.equal(off.calls.length, 0, 'flag off: no Item or variant read or write'); + + // A key that is neither an item_key nor a model_key reads nothing. + const badKey = catalogDb(); + for (const key of ['', 'Xiaomi 18', 'xiaomi::unknown', 'xiaomi']) { + const r = await syncArenaSpecsToItem(badKey.client, spec, { key, itemModel: 'item' }); + assert.equal(r.status, 'skipped', key); + } + assert.equal(badKey.calls.length, 0); + + // No rate.items row: never created. + const missing = catalogDb({ item: null }); + const missingRun = await syncArenaSpecsToItem(missing.client, spec, { + key: 'xiaomi:18-pro-max', + apply: true, + itemModel: 'item', + }); + assert.equal(missingRun.status, 'item_missing'); + assert.ok(!missing.calls.some(c => c.op !== 'select')); + assert.ok(!missing.calls.some(c => c.table === 'master_products')); + + // A read error throws instead of reporting the Item missing. + await assert.rejects( + syncArenaSpecsToItem(catalogDb({ itemErr: 'boom' }).client, spec, { + key: 'xiaomi:18-pro-max', + itemModel: 'item', + }), + /Failed to look up Item xiaomi:18-pro-max: boom/ + ); + + // Dry run: same counts, no writes. + const dry = catalogDb(); + const dryRun = await syncArenaSpecsToItem(dry.client, spec, { + // Upper case at run time: the shared-data key scan reads every fixture literal. + key: 'xiaomi:18-pro-max:512gb'.toUpperCase(), + itemModel: 'item', + }); + assert.deepEqual(dryRun, { + status: 'synced', + apply: false, + itemKey: 'xiaomi:18-pro-max', + itemUpdated: true, + variantsUpdated: 5, + conflicts: 0, + }); + assert.equal(filterValue(dry.calls[0], 'item_key'), 'xiaomi:18-pro-max'); + assert.ok(!dry.calls.some(c => c.op !== 'select'), 'dry run writes nothing'); + + // Apply (env flag on, nullable_variant mode). + const applied = catalogDb(); + const appliedRun = await withFlag('nullable_variant', () => + syncArenaSpecsToItem(applied.client, spec, { key: 'xiaomi:18-pro-max', apply: true }) + ); + assert.equal(appliedRun.variantsUpdated, 5); + assert.equal(appliedRun.conflicts, 0); + assert.ok(!applied.calls.some(c => c.op === 'insert' || c.op === 'upsert'), 'no inserts'); + const itemUpdate = applied.calls.find(c => c.op === 'update' && c.table === 'items'); + assert.equal(filterValue(itemUpdate, 'id'), 'item-x18'); + assert.deepEqual( + itemUpdate?.filters.find(([op, col]) => op === 'is' && col === 'updated_at'), + ['is', 'updated_at', null], + 'the Item write only applies to the row as it was read' + ); + const itemSpecs = itemUpdate?.payload?.specifications as Record; + assert.equal(itemSpecs.hardware?.storage_gb, undefined, 'no storage on an Item'); + assert.equal(itemSpecs.hardware?.ram_gb, undefined, 'no RAM on an Item'); + assert.equal(itemSpecs.hardware?.chipset, 'Snapdragon 8 Elite Gen 6'); + assert.equal(itemSpecs.cellular?.has_5g, true); + assert.equal(itemSpecs.battery?.capacity_mah, 7500, 'the Arena value wins over the stored one'); + assert.equal(itemSpecs.design?.weight_g, 220, 'a field Arena does not state is kept'); + const itemKeySpecs = itemUpdate?.payload?.key_specs as Row; + assert.equal(itemKeySpecs.storage, undefined, 'no stored storage left on the Item'); + assert.equal(itemKeySpecs.ram, undefined); + assert.equal(itemKeySpecs.screen, '6.9" OLED'); + const variantsRead = applied.calls.find( + c => c.table === 'master_products' && c.op === 'select' + ); + assert.equal(filterValue(variantsRead, 'item_id'), 'item-x18'); + + const variantUpdates = applied.calls.filter( + c => c.op === 'update' && c.table === 'master_products' + ); + assert.deepEqual( + variantUpdates.map(c => filterValue(c, 'id')), + ['v-256', 'v-1024', 'v-2048', 'v-8192', 'v-4096'], + 'unknown, archived and filled variants are left alone' + ); + const withoutStamp = (payload: Row | undefined) => { + const rest = { ...payload }; + assert.equal(typeof rest.updated_at, 'string'); + delete rest.updated_at; + return rest; + }; + const v256 = withoutStamp(variantUpdates[0]?.payload); + assert.deepEqual(v256, { + storage_gb: 256, + ram_gb: 12, + key_specs: { storage: '256GB', ram: '12GB' }, + }); + const v1024 = withoutStamp(variantUpdates[1]?.payload); + assert.deepEqual( + v1024, + { ram_gb: 16, key_specs: { storage: '1TB', ram: '16GB' } }, + 'only the empty RAM is filled; the stored storage stays' + ); + assert.deepEqual(withoutStamp(variantUpdates[2]?.payload), { + storage_gb: 2048, + key_specs: { storage: '2TB', ram: '8GB' }, + }); + assert.deepEqual(withoutStamp(variantUpdates[3]?.payload), { ram_gb: 24 }); + assert.deepEqual(withoutStamp(variantUpdates[4]?.payload), { ram_gb: 8 }); + for (const update of variantUpdates) { + assert.ok( + update.filters.some( + ([op, col, val]) => op === 'neq' && col === 'status' && val === 'archived' + ), + 'a variant archived after the read is never written' + ); + } + assert.equal( + filterValue(variantUpdates[4], 'updated_at'), + '2026-10-28T00:00:00.000Z', + 'compare-and-swap on the updated_at that was read' + ); + + // Another writer changed rows between the read and the write: left alone. + const raced = catalogDb({ changedIds: ['item-x18', 'v-256'] }); + const racedRun = await syncArenaSpecsToItem(raced.client, spec, { + key: 'xiaomi:18-pro-max', + apply: true, + itemModel: 'item', + }); + assert.equal(racedRun.itemUpdated, false); + assert.equal(racedRun.variantsUpdated, 4); + assert.equal(racedRun.conflicts, 2); + } + console.log(' ✅ [2/5] Arena specs go to the Item, variants get only their own overrides'); + + // 3. Arena audit + { + const [first, second, third] = UPSTREAM_2026_ARENA_REFERENCE_URLS; + assert.ok(first && second && third); + const firstItemKey = first.model_key.split(':').slice(0, 2).join(':'); + const secondItemKey = second.model_key.split(':').slice(0, 2).join(':'); + const masters = [ + // Another size of the first flagship's Item. + { id: 'm-1', name: 'x', model_key: `${firstItemKey}:1024gb`, specifications: FULL_SPECS }, + // The third flagship itself, with complete specs. + { id: 'm-3', name: third.name, model_key: third.model_key, specifications: FULL_SPECS }, + ]; + const items = [ + // The second flagship's Item, with no master of any size yet. + { id: 'i-2', name: 'Second', item_key: secondItemKey, specifications: FULL_SPECS }, + { id: 'i-9', name: 'Thin', item_key: 'acme:thin', specifications: { display: { a: 1 } } }, + ]; + const tables: string[] = []; + const auditClient = { + from: (table: string) => { + tables.push(table); + return { + select: () => ({ + order: () => ({ + range: (from: number) => + Promise.resolve({ + data: from > 0 ? [] : table === 'items' ? items : masters, + error: null, + }), + }), + }), + }; + }, + }; + const syncer = new ArenaSpecSyncer(); + + const offReport = await withFlag(undefined, () => + syncer.auditCatalogAgainstArenaRegistry(auditClient as never) + ); + assert.ok(!tables.includes('items'), 'flag off: rate.items is never read'); + assert.equal(offReport.presentInDatabase, 1, 'flag off: only the exact model_key counts'); + assert.equal(offReport.incompleteSpecItems, undefined, 'flag off: report shape unchanged'); + + tables.length = 0; + const onReport = await syncer.auditCatalogAgainstArenaRegistry(auditClient as never, 1000, { + itemModel: 'item', + }); + assert.ok(tables.includes('items')); + assert.equal(onReport.presentInDatabase, 3, 'another size or an Item row means present'); + assert.ok(!onReport.missingFromDatabase.some(m => m.slug === first.slug)); + assert.ok(!onReport.missingFromDatabase.some(m => m.slug === second.slug)); + assert.deepEqual(onReport.incompleteSpecItems, [ + { id: 'i-9', name: 'Thin', item_key: 'acme:thin', populatedGroups: 1 }, + ]); + + // An Item read error rejects; no partial report. + const failing = { + from: (table: string) => ({ + select: () => ({ + order: () => ({ + range: () => + Promise.resolve( + table === 'items' + ? { data: null, error: { message: 'no items table' } } + : { data: [], error: null } + ), + }), + }), + }), + }; + await assert.rejects( + syncer.auditCatalogAgainstArenaRegistry(failing as never, 1000, { itemModel: 'item' }), + /Arena catalog audit: failed to read items: no items table/ + ); + } + console.log(' ✅ [3/5] Arena audit counts Items with the flag on, unchanged with it off'); + + // 4. Peer floor + { + const peers = [ + { id: 'p-1', cluster_id: 'v-256', price_current: 20_000_000, merchants: null }, + // A NULL-variant listing of the same Item, cheaper: never a peer. + { id: 'p-2', cluster_id: null, price_current: 9_000_000, merchants: { is_official: true } }, + // Another variant's listing: never a peer. + { id: 'p-3', cluster_id: 'v-128', price_current: 15_000_000, merchants: null }, + // A row with no cluster_id field: taken as already filtered (old callers). + { id: 'p-4', price_current: 21_000_000, merchants: null }, + ]; + assert.equal(resolveClusterPeerFloorPrice(peers, 'self', 18_000_000, 'v-256'), 20_000_000); + assert.equal( + resolveClusterPeerFloorPrice(peers, 'self', 18_000_000), + 9_000_000, + 'without a variant id, rows are taken as given (unchanged behaviour)' + ); + assert.equal( + resolveClusterPeerFloorPrice(peers, 'self', 18_000_000, null), + undefined, + 'a listing with no variant gets no floor, not even the master fallback' + ); + assert.equal(resolveClusterPeerFloorPrice(peers, 'self', 18_000_000, ' '), undefined); + assert.equal( + resolveClusterPeerFloorPrice([peers[1]!], 'self', 18_000_000, 'v-256'), + 18_000_000, + 'only NULL-variant peers: the variant falls back to its own min price' + ); + } + console.log(' ✅ [4/5] NULL-variant listings never enter or get a peer floor'); + + // 5. Seller review risk + { + const inputs: MerchantRiskInput[] = []; + class SpyAnalyzer extends AiReviewAnalyzer { + override evaluateMerchantRisk(input: MerchantRiskInput): MerchantRiskEvaluation { + inputs.push(input); + return super.evaluateMerchantRisk(input); + } + } + const analyzer = new SpyAnalyzer(); + const merchants: MerchantRow[] = [ + { id: 'seller', platform: 'shopee', is_official: false, follower_count: 10 }, + { id: 'mall', platform: 'shopee', is_official: true, follower_count: 1000 }, + ]; + // Item-model rows: the seller's cheap listing has no variant (cluster_id NULL). + const products: Array = [ + { + id: 'l-null', + merchant_id: 'seller', + cluster_id: null, + item_id: 'item-x18', + price_current: 5_000_000, + }, + { + id: 'l-256', + merchant_id: 'seller', + cluster_id: 'v-256', + item_id: 'item-x18', + price_current: 19_000_000, + }, + { + id: 'mall-256', + merchant_id: 'mall', + cluster_id: 'v-256', + item_id: 'item-x18', + price_current: 20_000_000, + }, + ]; + const reviews: ReviewRow[] = [ + { id: 'r-1', product_id: 'l-null', rating_star: 5, comment: 'ok', review_flags: null }, + { id: 'r-2', product_id: 'l-null', rating_star: 4, comment: 'good', review_flags: null }, + { id: 'r-3', product_id: 'l-256', rating_star: 5, comment: 'fine', review_flags: null }, + ]; + const masterMin = new Map([['v-256', 19_000_000]]); + + const scoreWith = (flag: string | undefined) => + withFlag(flag, () => { + inputs.length = 0; + const scores = scoreMerchants(analyzer, merchants, products, reviews, masterMin); + return { scores, input: inputs.find(i => i.merchantId === 'seller') }; + }); + const on = scoreWith('item'); + assert.equal(on.input?.reviewResults.length, 3, 'reviews of the NULL-variant listing count'); + assert.equal(on.input?.listingPrice, undefined, 'no price term from a listing with no variant'); + assert.equal(on.input?.canonicalMinPrice, undefined); + const off = scoreWith(undefined); + assert.deepEqual(off.scores, on.scores, 'seller risk does not depend on the flag'); + + // A NULL-variant row slipped into a variant bucket never sets the floor. + const anomaly = strongestPriceAnomaly( + merchants[0]!, + [{ id: 'l-256', merchant_id: 'seller', cluster_id: 'v-256', price_current: 9_000_000 }], + new Map([ + [ + 'v-256', + [ + { id: 'stray', merchant_id: 'x', cluster_id: null, price_current: 3_000_000 }, + { id: 'mall-256', merchant_id: 'mall', cluster_id: 'v-256', price_current: 20_000_000 }, + ], + ], + ]), + new Map([['mall', true]]), + new Map() + ); + assert.deepEqual(anomaly, { listingPrice: 9_000_000, canonicalMinPrice: 20_000_000 }); + + // The per-seller load never asks for peers or a master of a NULL variant. + const db = createDb(call => { + if (call.table === 'merchants') return { data: merchants[0], error: null }; + if (call.table === 'products' && filterValue(call, 'merchant_id')) { + return { data: products.filter(p => p.merchant_id === 'seller'), error: null }; + } + if (call.table === 'products') return { data: products.slice(1), error: null }; + if (call.table === 'reviews') return { data: reviews, error: null }; + if (call.table === 'master_products') { + return { data: [{ id: 'v-256', min_price: 19_000_000 }], error: null }; + } + return undefined; + }); + const loaded = await loadAndScoreMerchant(db.client, analyzer, 'seller'); + assert.equal(loaded?.risk.total_reviews, 3); + const peerRead = db.calls.find(c => c.table === 'products' && filterValue(c, 'cluster_id')); + assert.deepEqual(filterValue(peerRead, 'cluster_id'), ['v-256']); + const masterRead = db.calls.find(c => c.table === 'master_products'); + assert.deepEqual(filterValue(masterRead, 'id'), ['v-256']); + assert.ok(!db.calls.some(c => c.op !== 'select'), 'loading writes nothing'); + } + console.log(' ✅ [5/5] Seller risk pools NULL-variant reviews and gives them no price term'); + console.log('🎉 All item-aware importer job tests passed!'); +} + +run().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index e55753f90..20c3b4f62 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -1146,8 +1146,20 @@ async function runSuite() { assert.ok(synced.specifications.camera?.third_camera?.startsWith('200 MP')); assert.equal(synced.specifications.battery?.fast_charging_w, 100); assert.equal(synced.specifications.battery?.reverse_charging, true); + // The page states no network: 5G is unknown and no cellular group is invented (#1196). + assert.equal(synced.has5g, null); assert.equal( countPopulatedFieldGroups(synced.specifications as unknown as Record), + 9 + ); + const syncedWithNetwork = parseGsmArenaHtmlToSpecs( + `${sampleGsmHtml}GSM / CDMA / HSPA / LTE / 5G` + ); + assert.equal(syncedWithNetwork.has5g, true); + assert.equal( + countPopulatedFieldGroups( + syncedWithNetwork.specifications as unknown as Record + ), 10 ); diff --git a/apps/importer/src/shopee/arena-spec-syncer.ts b/apps/importer/src/shopee/arena-spec-syncer.ts index f2c288dee..a5079ec8d 100644 --- a/apps/importer/src/shopee/arena-spec-syncer.ts +++ b/apps/importer/src/shopee/arena-spec-syncer.ts @@ -13,7 +13,16 @@ import { type ProductSpecifications, type KeySpecs, } from '@repo/shared-data'; +import { isItemKey, splitModelKey } from '@repo/shared-data'; +import { mergeProductSpecifications } from './ai-spec-enricher.js'; +import { resolveItemModelMode, type ItemModelMode } from './item-model-flag.js'; +import { + mergeKeySpecs, + withoutVariantKeySpecs, + withoutVariantSpecs, +} from './jobs/backfill-specs.js'; import { fetchAllPages } from './paging.js'; +import type { RateSupabaseClient } from './types.js'; export interface ArenaSyncedDeviceSpec { source: 'gsmarena' | 'phonearena' | 'hybrid'; @@ -28,11 +37,65 @@ export interface ArenaSyncedDeviceSpec { storageGb: number | null; screenSizeInch: number | null; batteryMah: number | null; - has5g: boolean; + /** null when the page states no network technology (DOS/Rate#1196). */ + has5g: boolean | null; + /** + * The storage sizes the page lists, each with its RAM when the page pairs exactly one + * RAM with it (`256GB 12GB RAM, 512GB 16GB RAM`). Variant overrides (DOS/Rate#1213). + */ + memoryVariants?: ArenaMemoryVariant[]; specifications: ProductSpecifications; keySpecs: KeySpecs; } +export interface ArenaMemoryVariant { + storageGb: number; + ramGb: number | null; +} + +const ARENA_STORAGE_REGEX = /(\d+(?:\.\d+)?)\s*(GB|TB)(?!\s*RAM)/gi; +// A RAM group, including the slash form of several options (`8GB/12GB RAM`, `8/12GB RAM`). +const ARENA_RAM_GROUP_REGEX = + /\d+(?:\.\d+)?(?:\s*GB)?(?:\s*\/\s*\d+(?:\.\d+)?(?:\s*GB)?)*\s*GB\s*RAM/gi; + +/** + * Splits a memory line (`128GB 8GB RAM, 256GB 8GB RAM, 256GB 12GB RAM`) into storage + * sizes in ascending order. A size whose entries name more than one RAM, or none, gets + * `ramGb: null`: the RAM of that variant is not known from the page. + */ +export function parseArenaMemoryVariants(memRaw: string): ArenaMemoryVariant[] { + if (typeof memRaw !== 'string' || !memRaw.trim()) return []; + const ramsByStorage = new Map>(); + for (const segment of memRaw.slice(0, 2000).split(/[,;\n]/)) { + // Every RAM figure of the segment; its numbers are never read as storage. + const rams = new Set(); + for (const group of segment.match(ARENA_RAM_GROUP_REGEX) ?? []) { + for (const n of group.match(/\d+(?:\.\d+)?/g) ?? []) rams.add(Number(n)); + } + const ramValues = [...rams]; + const ram = + ramValues.length === 1 && Number.isInteger(ramValues[0]) && ramValues[0]! >= 1 + ? ramValues[0]! <= 2048 + ? ramValues[0]! + : null + : null; + const storageText = segment.replace(ARENA_RAM_GROUP_REGEX, ' '); + for (const m of storageText.matchAll(ARENA_STORAGE_REGEX)) { + const val = m[2]?.toUpperCase() === 'TB' ? Number(m[1]) * 1024 : Number(m[1]); + if (!Number.isInteger(val) || val < 1 || val > 65536) continue; + const set = ramsByStorage.get(val) ?? new Set(); + set.add(ram); + ramsByStorage.set(val, set); + } + } + return [...ramsByStorage.entries()] + .sort(([a], [b]) => a - b) + .map(([storageGb, rams]) => { + const only = rams.size === 1 ? [...rams][0] : null; + return { storageGb, ramGb: only ?? null }; + }); +} + const NAMED_ARENA_ENTITIES: Readonly> = { ' ': ' ', '&': '&', @@ -304,9 +367,9 @@ export function parseGsmArenaHtmlToSpecs( /IP\d{2}[A-Z]?[^.;\n]*/i ); - // 5G check + // 5G check: a page with no network fields says nothing about 5G (DOS/Rate#1196). const netTech = `${d['nettech'] || ''} ${d['speed'] || ''} ${d['5g'] || ''} ${d['net5g'] || ''}`; - const has5g = /5G|NR|SA\/NSA/i.test(netTech); + const has5g: boolean | null = netTech.trim() ? /5G|NR|SA\/NSA/i.test(netTech) : null; // 3.5mm headphone jack check const jackRaw = d['jack'] || d['3.5mm jack'] || pa['headphones'] || ''; @@ -386,17 +449,26 @@ export function parseGsmArenaHtmlToSpecs( : 'Face ID / Biometric sensor', colors: parsedColors.length > 0 ? parsedColors : undefined, }, - cellular: { - has_5g: has5g, - bands_5g: capSpec(has5g ? d['net5g'] || '5G SA/NSA/Sub6' : d['net5g']), - sim_type: capSpec( - d['sim'] || (has5g ? 'Dual SIM (Nano-SIM + eSIM)' : 'No cellular connectivity') - ), - data_speed: capSpec(d['speed'] || (has5g ? '5G, LTE-A' : 'Wi-Fi Only')), - technology: capSpec( - d['nettech'] || (has5g ? 'GSM / HSPA / LTE / 5G' : 'No cellular connectivity') - ), - }, + // An unknown 5G invents no cellular defaults: only what the page states. + cellular: + has5g === null + ? { + bands_5g: capSpec(d['net5g']), + sim_type: capSpec(d['sim']), + data_speed: capSpec(d['speed']), + technology: capSpec(d['nettech']), + } + : { + has_5g: has5g, + bands_5g: capSpec(has5g ? d['net5g'] || '5G SA/NSA/Sub6' : d['net5g']), + sim_type: capSpec( + d['sim'] || (has5g ? 'Dual SIM (Nano-SIM + eSIM)' : 'No cellular connectivity') + ), + data_speed: capSpec(d['speed'] || (has5g ? '5G, LTE-A' : 'Wi-Fi Only')), + technology: capSpec( + d['nettech'] || (has5g ? 'GSM / HSPA / LTE / 5G' : 'No cellular connectivity') + ), + }, connectivity: { bluetooth: capSpec(d['bluetooth'] || pa['bluetooth']), wlan: capSpec(d['wlan'] || pa['wi-fi']), @@ -469,6 +541,8 @@ export function parseGsmArenaHtmlToSpecs( screenSizeInch, batteryMah, has5g, + // GSMArena's memory line only: PhoneArena's RAM field carries no "RAM" word. + memoryVariants: parseArenaMemoryVariants(d['internalmemory'] || ''), specifications: validatedSpecs, keySpecs, }; @@ -695,6 +769,24 @@ export interface ArenaCatalogAuditReport { model_key: string; populatedGroups: number; }>; + /** + * RATE_ITEM_MODEL on only: Items with < 10 populated field groups. Variants inherit + * their Item's specs, so these are the rows a spec sync should fill (DOS/Rate#1213). + */ + incompleteSpecItems?: Array<{ + id: string; + name: string; + item_key: string; + populatedGroups: number; + }>; +} + +export interface ArenaAuditItemRow { + id: string; + name: string; + slug?: string | null; + item_key: string; + specifications?: Record | null; } export interface ArenaAuditMasterRow { @@ -731,8 +823,13 @@ export class ArenaSpecSyncer { }; }; }, - pageSize?: number + pageSize?: number, + options: { itemModel?: ItemModelMode } = {} ): Promise { + const itemModel = + options && typeof options === 'object' && options.itemModel + ? options.itemModel + : resolveItemModelMode(); const rows = await fetchAllPages( 'Arena catalog audit: failed to read master_products', (from, to) => @@ -798,18 +895,274 @@ export class ArenaSpecSyncer { } } - const missingFromDatabase = UPSTREAM_2026_ARENA_REFERENCE_URLS.filter( - ref => - !existingKeys.has(ref.model_key.toLowerCase()) && !existingSlugs.has(ref.slug.toLowerCase()) - ); + // With the Item model on, a flagship is present when its Item is: another size of + // the same Item is not a missing release (design doc 4.1, DOS/Rate#1213). + const existingItemKeys = new Set(); + let incompleteSpecItems: ArenaCatalogAuditReport['incompleteSpecItems']; + if (itemModel !== 'off') { + incompleteSpecItems = []; + for (const key of existingKeys) { + const itemKey = splitModelKey(key)?.itemKey; + if (itemKey) existingItemKeys.add(itemKey); + } + // The client is typed for master rows; the items read returns Item rows. + const items = await fetchAllPages( + 'Arena catalog audit: failed to read items', + (from, to) => + supabase + .from('items') + .select('id, name, slug, item_key, specifications') + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike<{ + data: ArenaAuditItemRow[] | null; + error: { message: string } | null; + }>, + pageSize + ); + for (const item of items) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + const safeItemKey = sanitizeAuditToken(item.item_key, 200).toLowerCase(); + const reportedItemKey = + safeItemKey || + (typeof item.item_key === 'string' + ? item.item_key + .replace(/\p{Cc}+/gu, '') + .trim() + .slice(0, 200) + : ''); + if (safeItemKey && isItemKey(safeItemKey)) existingItemKeys.add(safeItemKey); + const populatedGroups = countPopulatedFieldGroups(item.specifications); + if (populatedGroups < 10) { + incompleteSpecItems.push({ + id: sanitizeAuditToken(item.id, 128), + name: + typeof item.name === 'string' + ? item.name + .replace(/\p{Cc}+/gu, '') + .trim() + .slice(0, 300) + : '', + item_key: reportedItemKey, + populatedGroups, + }); + } + } + } + + const missingFromDatabase = UPSTREAM_2026_ARENA_REFERENCE_URLS.filter(ref => { + if (existingKeys.has(ref.model_key.toLowerCase())) return false; + if (existingSlugs.has(ref.slug.toLowerCase())) return false; + const itemKey = splitModelKey(ref.model_key.toLowerCase())?.itemKey; + return !(itemKey && existingItemKeys.has(itemKey)); + }); return { totalArena2026Flagships: UPSTREAM_2026_ARENA_REFERENCE_URLS.length, presentInDatabase: UPSTREAM_2026_ARENA_REFERENCE_URLS.length - missingFromDatabase.length, missingFromDatabase, incompleteSpecMasters, + ...(incompleteSpecItems ? { incompleteSpecItems } : {}), + }; + } +} + +export interface ArenaItemSyncResult { + /** `skipped`: flag off or no usable key; `item_missing`: no `rate.items` row. */ + status: 'skipped' | 'item_missing' | 'synced'; + apply: boolean; + itemKey: string | null; + /** The Item was (apply) or would be (dry-run) updated. */ + itemUpdated: boolean; + /** Variants that were (apply) or would be (dry-run) updated. */ + variantsUpdated: number; + /** Rows another writer changed between the read and the write: left as they are. */ + conflicts: number; +} + +/** + * Compare-and-swap on `updated_at`: the update applies only to the row as it was read + * (and, for a variant, still not archived). Returns false when nothing matched. + */ +async function updateIfUnchanged( + supabase: RateSupabaseClient, + table: 'items' | 'master_products', + id: string, + readUpdatedAt: unknown, + payload: Record +): Promise { + let query = supabase + .from(table) + .update({ ...payload, updated_at: new Date().toISOString() }) + .eq('id', id); + query = + typeof readUpdatedAt === 'string' && readUpdatedAt + ? query.eq('updated_at', readUpdatedAt) + : query.is('updated_at', null); + if (table === 'master_products') query = query.neq('status', 'archived'); + const { data, error } = await query.select('id'); + if (error) throw new Error(`Failed to update ${table} ${id}: ${error.message}`); + return Array.isArray(data) && data.length > 0; +} + +/** `12GB` or `8GB LPDDR5X` → its leading size; anything else → null. */ +const gbOfLabel = (raw: unknown): number | null => { + const m = typeof raw === 'string' ? /^\s*(\d+)\s*GB\b/i.exec(raw) : null; + return m ? toPositiveInt(Number(m[1]), 2048) : null; +}; + +const toPositiveInt = (raw: unknown, max: number): number | null => { + const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw.trim()) : NaN; + return Number.isInteger(n) && n > 0 && n <= max ? n : null; +}; + +/** `256gb` → 256, `1tb` → 1024; null for `unknown`, `standard` and suffixed keys. */ +function storageGbOfVariantKey(variantKey: unknown): number | null { + if (typeof variantKey !== 'string') return null; + const m = /^(\d+)(gb|tb)$/.exec(variantKey.trim().toLowerCase()); + if (!m) return null; + return toPositiveInt(m[2] === 'tb' ? Number(m[1]) * 1024 : Number(m[1]), 65536); +} + +const formatStorage = (gb: number): string => + gb >= 1024 && gb % 512 === 0 ? `${gb / 1024}TB` : `${gb}GB`; + +/** + * Writes an Arena spec to the Item model (design doc 4.1 and 5.3, DOS/Rate#1213): + * - the Item (`rate.items` by `item_key`) gets the specs and key specs without storage + * or RAM, merged over what it has; + * - each sized variant of the Item gets only its own overrides, and only where they are + * still empty: `storage_gb` from its `variant_key`, `ram_gb` when the page pairs one + * RAM with that size, and the matching `key_specs.storage`/`key_specs.ram`. + * A stored RAM (number or label) wins over Arena's. Each write is a compare-and-swap on + * `updated_at`, so a row another writer changed (or archived) after the read is left + * alone and counted in `conflicts`. + * `key` is an `item_key` or a `model_key`. Items are never created (the M1 sync trigger + * and the M2 backfill own that). Dry-run unless `apply`; with RATE_ITEM_MODEL off it + * reads and writes nothing. + */ +export async function syncArenaSpecsToItem( + supabase: RateSupabaseClient, + spec: ArenaSyncedDeviceSpec, + options: { key: string; apply?: boolean; itemModel?: ItemModelMode } +): Promise { + const safeOptions = + options && typeof options === 'object' && !Array.isArray(options) + ? options + : ({} as { key?: string; apply?: boolean; itemModel?: ItemModelMode }); + const apply = safeOptions.apply === true; + const result: ArenaItemSyncResult = { + status: 'skipped', + apply, + itemKey: null, + itemUpdated: false, + variantsUpdated: 0, + conflicts: 0, + }; + if ((safeOptions.itemModel ?? resolveItemModelMode()) === 'off') return result; + if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return result; + const rawKey = typeof safeOptions.key === 'string' ? safeOptions.key.trim().toLowerCase() : ''; + const itemKey = isItemKey(rawKey) ? rawKey : (splitModelKey(rawKey)?.itemKey ?? null); + if (!itemKey) return result; + result.itemKey = itemKey; + + const { data: item, error: itemErr } = await supabase + .from('items') + .select('id, specifications, key_specs, updated_at') + .eq('item_key', itemKey) + .maybeSingle(); + if (itemErr) throw new Error(`Failed to look up Item ${itemKey}: ${itemErr.message}`); + const itemRow = item as { + id?: unknown; + specifications?: unknown; + key_specs?: unknown; + updated_at?: unknown; + } | null; + const itemId = typeof itemRow?.id === 'string' && itemRow.id.trim() ? itemRow.id.trim() : null; + if (!itemRow || !itemId) { + result.status = 'item_missing'; + return result; + } + result.status = 'synced'; + + const itemPayload = { + specifications: withoutVariantSpecs( + mergeProductSpecifications(itemRow.specifications, spec.specifications) + ), + key_specs: withoutVariantKeySpecs( + mergeKeySpecs(itemRow.key_specs, spec.keySpecs as unknown as Record) + ), + }; + if ( + !apply || + (await updateIfUnchanged(supabase, 'items', itemId, itemRow.updated_at, itemPayload)) + ) { + result.itemUpdated = true; + } else { + result.conflicts++; + } + + const { data: variants, error: varErr } = await supabase + .from('master_products') + .select('id, variant_key, status, storage_gb, ram_gb, key_specs, updated_at') + .eq('item_id', itemId); + if (varErr) throw new Error(`Failed to read the variants of ${itemKey}: ${varErr.message}`); + const ramByStorage = new Map( + (Array.isArray(spec.memoryVariants) ? spec.memoryVariants : []).map(v => [ + v?.storageGb, + v?.ramGb ?? null, + ]) + ); + for (const raw of Array.isArray(variants) ? variants : []) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue; + const row = raw as { + id?: unknown; + variant_key?: unknown; + status?: unknown; + storage_gb?: unknown; + ram_gb?: unknown; + key_specs?: unknown; + updated_at?: unknown; }; + const variantId = typeof row.id === 'string' && row.id.trim() ? row.id.trim() : null; + const storageGb = storageGbOfVariantKey(row.variant_key); + if (!variantId || !storageGb || row.status === 'archived') continue; + const keySpecs = + row.key_specs && typeof row.key_specs === 'object' && !Array.isArray(row.key_specs) + ? (row.key_specs as Record) + : {}; + // One RAM per variant: a stored value (number, else label) wins over Arena's, so + // the number and the label never disagree. + // A stored label that names no single size also blocks Arena's RAM. + const storedRamGb = toPositiveInt(row.ram_gb, 2048); + const hasRamLabel = typeof keySpecs.ram === 'string' && keySpecs.ram.trim() !== ''; + const ramGb = + storedRamGb ?? + (hasRamLabel ? gbOfLabel(keySpecs.ram) : toPositiveInt(ramByStorage.get(storageGb), 2048)); + const storedStorageGb = toPositiveInt(row.storage_gb, 65536); + const update: Record = {}; + const keySpecUpdate: Record = {}; + if (storedStorageGb === null) update.storage_gb = storageGb; + if (ramGb !== null && storedRamGb === null) update.ram_gb = ramGb; + if (typeof keySpecs.storage !== 'string' || !keySpecs.storage.trim()) { + keySpecUpdate.storage = formatStorage(storedStorageGb ?? storageGb); + } + if (ramGb !== null && (typeof keySpecs.ram !== 'string' || !keySpecs.ram.trim())) { + keySpecUpdate.ram = `${ramGb}GB`; + } + if (Object.keys(keySpecUpdate).length > 0) { + update.key_specs = mergeKeySpecs(keySpecs, keySpecUpdate); + } + if (Object.keys(update).length === 0) continue; + if ( + apply && + !(await updateIfUnchanged(supabase, 'master_products', variantId, row.updated_at, update)) + ) { + result.conflicts++; + continue; + } + result.variantsUpdated++; } + return result; } export const UPSTREAM_2026_ARENA_REFERENCE_URLS: ReadonlyArray<{ diff --git a/apps/importer/src/shopee/cluster-peer-floor.ts b/apps/importer/src/shopee/cluster-peer-floor.ts index 3b309429d..ca17c1e04 100644 --- a/apps/importer/src/shopee/cluster-peer-floor.ts +++ b/apps/importer/src/shopee/cluster-peer-floor.ts @@ -42,6 +42,8 @@ function parseFiniteVndPrice(value: unknown): number { export interface ClusterPeerPriceCandidate { id?: unknown; + /** The peer's variant. When given, only peers of `variantId` count (DOS/Rate#1213). */ + cluster_id?: unknown; price_current?: unknown; merchants?: { is_official?: boolean | null } | Array<{ is_official?: boolean | null }> | null; } @@ -51,15 +53,26 @@ export interface ClusterPeerPriceCandidate { * preferring Official Mall sibling prices when available, and falling back to * `fallbackMasterMinPrice` only when no positive-priced peer listings exist. * Ignores unscaled prices `> MAX_VALID_VND_PRICE` (1B VND). + * + * Peer floors are per variant (design doc 4.4). With `variantId` given: + * - `null` (a listing with no variant): no floor at all, not even the fallback; + * - an id: a row whose `cluster_id` is set and is not that id, including a + * NULL-variant row (`cluster_id: null`), never enters the floor. + * Omitted, rows are taken as already filtered to one variant (unchanged behaviour). */ export function resolveClusterPeerFloorPrice( peerRows: readonly ClusterPeerPriceCandidate[], currentProductId: string, - fallbackMasterMinPrice?: number | null + fallbackMasterMinPrice?: number | null, + variantId?: string | null ): number | undefined { + if (variantId === null) return undefined; let officialMin = Number.POSITIVE_INFINITY; let peerMin = Number.POSITIVE_INFINITY; const safeCurrentId = sanitizeClusterPeerId(currentProductId); + const safeVariantId = variantId === undefined ? undefined : sanitizeClusterPeerId(variantId); + // A variant id that does not sanitize names no variant: no floor. + if (safeVariantId === '') return undefined; if (Array.isArray(peerRows)) { const limit = Math.min(peerRows.length, MAX_CLUSTER_PEER_ROWS); @@ -68,6 +81,13 @@ export function resolveClusterPeerFloorPrice( if (!row || typeof row !== 'object' || Array.isArray(row)) continue; const rowId = sanitizeClusterPeerId(row.id); if (safeCurrentId && rowId === safeCurrentId) continue; + if ( + safeVariantId !== undefined && + row.cluster_id !== undefined && + sanitizeClusterPeerId(row.cluster_id) !== safeVariantId + ) { + continue; + } const rawPrice = parseFiniteVndPrice(row.price_current); if (!Number.isFinite(rawPrice) || rawPrice <= 0 || rawPrice > MAX_VALID_VND_PRICE) continue; const price = Math.round(rawPrice); diff --git a/apps/importer/src/shopee/jobs/backfill-specs.ts b/apps/importer/src/shopee/jobs/backfill-specs.ts index bf6369120..d4ab0cdeb 100644 --- a/apps/importer/src/shopee/jobs/backfill-specs.ts +++ b/apps/importer/src/shopee/jobs/backfill-specs.ts @@ -56,7 +56,7 @@ export interface BackfillSpecsResult { } /** Item specs carry no storage or RAM: those are per variant (design doc 4.1). */ -function withoutVariantSpecs(specs: ProductSpecifications): ProductSpecifications { +export function withoutVariantSpecs(specs: ProductSpecifications): ProductSpecifications { if (!specs.hardware) return specs; const hardware = { ...specs.hardware }; delete hardware.storage_gb; @@ -64,7 +64,7 @@ function withoutVariantSpecs(specs: ProductSpecifications): ProductSpecification return { ...specs, hardware }; } -function withoutVariantKeySpecs(keySpecs: Record): Record { +export function withoutVariantKeySpecs(keySpecs: Record): Record { const rest = { ...keySpecs }; delete rest.storage; delete rest.ram; diff --git a/apps/importer/src/shopee/merchant-review-risk.ts b/apps/importer/src/shopee/merchant-review-risk.ts index 715540e38..3f0a4293d 100644 --- a/apps/importer/src/shopee/merchant-review-risk.ts +++ b/apps/importer/src/shopee/merchant-review-risk.ts @@ -198,6 +198,7 @@ export function strongestPriceAnomaly( .filter((p): p is ProductRow => Boolean(p && typeof p === 'object' && !Array.isArray(p))) .map(p => ({ id: p.id, + cluster_id: p.cluster_id, price_current: p.price_current, merchants: { is_official: @@ -210,7 +211,7 @@ export function strongestPriceAnomaly( masterMinByCluster && typeof masterMinByCluster.get === 'function' ? masterMinByCluster.get(clusterId) : undefined; - const floor = resolveClusterPeerFloorPrice(peers, listing.id, fallbackMasterMin); + const floor = resolveClusterPeerFloorPrice(peers, listing.id, fallbackMasterMin, clusterId); if (!floor || floor <= 2_000_000 || floor > MAX_VALID_VND_PRICE) continue; const ratio = price / floor; if (ratio < 0.58 && (!best || ratio < best.ratio)) { From 33eb7096aa43a6066e33d510701c0b97ff31d8d3 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:30:56 +0700 Subject: [PATCH 28/69] fix(extension,web): tiktok pdp item ids; brand and category pages are not products (#1255) * fix(extension,web): tiktok pdp item ids; brand and category pages are not products - TikTok Shop /shop/vn/pdp// (and /shop/pdp/...) yields in the extension extractor and the server URL parser (#1245). - On slug-id stores a path ending in a bare brand (mobile/apple.html, dien-thoai/samsung) or "-series" is a listing, and CellphoneS paths below the root are categories, so none yields an item id; product slugs are unchanged (#1246). * fix(web): the server url parser skips the same listing pages as the extension Mirror the brand, "-series" and CellphoneS sub-path rules in parseEcommerceUrlOrQuery so a pasted category link or an older client's ingest no longer resolves to item id "apple". Accessory slugs ending in "-series" (op-lung-...-iphone-16-series) stay products in both (#1246). Co-Authored-By: Claude Opus 5.5 --- .../src/extractors/ecommerce-detector.ts | 62 ++++++++++++++++++- apps/extension/src/test-extension-pipeline.ts | 59 ++++++++++++++++++ apps/web/src/lib/rate-domain-core.ts | 61 +++++++++++++++++- .../__tests__/core-rate-architecture.test.ts | 33 ++++++++++ 4 files changed, 211 insertions(+), 4 deletions(-) diff --git a/apps/extension/src/extractors/ecommerce-detector.ts b/apps/extension/src/extractors/ecommerce-detector.ts index 4cd16b4f9..46c9e6221 100644 --- a/apps/extension/src/extractors/ecommerce-detector.ts +++ b/apps/extension/src/extractors/ecommerce-detector.ts @@ -140,6 +140,57 @@ const RETAIL_CATEGORY_ROOT_SLUGS = new Set([ 'airpods', ]); +// A path ending in a bare brand lists that brand's products, as in +// cellphones.com.vn/mobile/apple.html or fptshop.com.vn/dien-thoai/samsung; +// no product slug is a brand alone (DOS/Rate#1246). Keep these rules in +// sync with parseEcommerceUrlOrQuery in apps/web/src/lib/rate-domain-core.ts. +const RETAIL_BRAND_LISTING_SLUGS = new Set([ + 'apple', + 'samsung', + 'xiaomi', + 'redmi', + 'poco', + 'oppo', + 'vivo', + 'realme', + 'oneplus', + 'honor', + 'huawei', + 'nokia', + 'motorola', + 'sony', + 'google', + 'asus', + 'acer', + 'lenovo', + 'dell', + 'hp', + 'msi', + 'lg', + 'tecno', + 'infinix', + 'itel', + 'nubia', + 'zte', + 'masstel', + 'benco', + 'garmin', + 'amazfit', + 'jbl', + 'marshall', + 'anker', + 'baseus', + 'ugreen', + 'mac', +]); + +// "iphone-16-series" lists a series; a case or screen protector for it +// ("op-lung-iphone-16-series") is a product. +const ACCESSORY_SLUG_PREFIX = + /^(?:op|bao|cuong-luc|kinh|mieng-dan|dan|sac|cu-sac|cap|day|tui|vo|ban-phim|chuot)-/; +const isSeriesListingSlug = (slug: string) => + /-series$/.test(slug) && !ACCESSORY_SLUG_PREFIX.test(slug); + export function extractIdsFromUrl( platform: SupportedEcommercePlatform, canonicalUrl: string @@ -191,7 +242,9 @@ export function extractIdsFromUrl( } const prodMatch = canonicalUrl.match(/\/product\/(\d+)/i) || - canonicalUrl.match(/\/view\/product\/(\d+)/i); + canonicalUrl.match(/\/view\/product\/(\d+)/i) || + // TikTok Shop's newer product pages (DOS/Rate#1245). + canonicalUrl.match(/\/shop\/(?:[a-z]{2}\/)?pdp\/[^/]+\/(\d+)/i); const shopMatch = canonicalUrl.match(/\/@([a-zA-Z0-9_.-]+)/); const itemId = isPositiveDigits(prodMatch?.[1]) ? prodMatch![1]! : null; const rawShop = shopMatch?.[1] || ''; @@ -239,7 +292,12 @@ export function extractIdsFromUrl( !RETAIL_NON_PRODUCT_FIRST_SEGMENTS.has(firstSegLower) && !RETAIL_NON_PRODUCT_FIRST_SEGMENTS.has(lastSegLower) && !(segments.length === 1 && RETAIL_CATEGORY_ROOT_SLUGS.has(firstSegLower)) && - !(platform === 'tgdd' && segments.length < 2); + !RETAIL_BRAND_LISTING_SLUGS.has(lastSegLower) && + !isSeriesListingSlug(lastSegLower) && + !(platform === 'tgdd' && segments.length < 2) && + // CellphoneS product pages sit at the root ("/iphone-16-pro-max.html"); + // deeper paths are category listings (DOS/Rate#1246). + !(platform === 'cellphones' && segments.length > 1); return { externalItemId: isValidItemSlug ? lastSegment : null, externalShopId: null, diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index b7c741066..be40a34db 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -2797,6 +2797,65 @@ async function runTests() { externalShopId: null, } ); + // TikTok Shop's pdp pages carry the id last (DOS/Rate#1245). + for (const url of [ + 'https://www.tiktok.com/shop/vn/pdp/apple-iphone-16-128gb/1729384756', + 'https://www.tiktok.com/shop/pdp/apple-iphone-16-128gb/1729384756', + ]) { + assert.equal( + extractIdsFromUrl('tiktok', url).externalItemId, + '1729384756', + url + ); + } + // Brand and category listings are not products; product slugs still + // are (DOS/Rate#1246). + for (const [platform, url] of [ + ['cellphones', 'https://cellphones.com.vn/mobile/apple.html'], + ['cellphones', 'https://cellphones.com.vn/mobile/apple/iphone-16.html'], + ['cellphones', 'https://cellphones.com.vn/laptop/mac.html'], + ['fptshop', 'https://fptshop.com.vn/dien-thoai/samsung'], + ['hoangha', 'https://hoanghamobile.com/dien-thoai-di-dong/xiaomi'], + ['didongviet', 'https://didongviet.vn/dien-thoai/oppo.html'], + ['tgdd', 'https://www.thegioididong.com/dtdd/apple'], + ['fptshop', 'https://fptshop.com.vn/dien-thoai/iphone-16-series'], + ] as const) { + assert.equal(extractIdsFromUrl(platform, url).externalItemId, null, url); + } + for (const [platform, url, id] of [ + [ + 'cellphones', + 'https://cellphones.com.vn/iphone-16-pro-max.html', + 'iphone-16-pro-max', + ], + [ + 'fptshop', + 'https://fptshop.com.vn/dien-thoai/iphone-16-pro-max', + 'iphone-16-pro-max', + ], + [ + 'didongviet', + 'https://didongviet.vn/dien-thoai/iphone-16.html', + 'iphone-16', + ], + [ + 'phongvu', + 'https://phongvu.vn/apple-iphone-16e-128gb--s2502001', + 'apple-iphone-16e-128gb--s2502001', + ], + [ + 'tgdd', + 'https://www.thegioididong.com/dtdd/samsung-galaxy-s25', + 'samsung-galaxy-s25', + ], + [ + 'cellphones', + 'https://cellphones.com.vn/op-lung-magsafe-iphone-16-series.html', + 'op-lung-magsafe-iphone-16-series', + ], + ] as const) { + assert.equal(extractIdsFromUrl(platform, url).externalItemId, id, url); + } assert.deepEqual( extractIdsFromUrl( 'cellphones', diff --git a/apps/web/src/lib/rate-domain-core.ts b/apps/web/src/lib/rate-domain-core.ts index 2d32f5f06..e87462fb6 100644 --- a/apps/web/src/lib/rate-domain-core.ts +++ b/apps/web/src/lib/rate-domain-core.ts @@ -1429,6 +1429,57 @@ const RETAIL_NON_PRODUCT_FIRST_SEGMENTS = new Set([ 'c', ]); +// A path ending in a bare brand lists that brand's products, as in +// cellphones.com.vn/mobile/apple.html or fptshop.com.vn/dien-thoai/samsung; +// no product slug is a brand alone (DOS/Rate#1246). Keep these rules in +// sync with extractIdsFromUrl in apps/extension/src/extractors/ecommerce-detector.ts. +const RETAIL_BRAND_LISTING_SLUGS = new Set([ + 'apple', + 'samsung', + 'xiaomi', + 'redmi', + 'poco', + 'oppo', + 'vivo', + 'realme', + 'oneplus', + 'honor', + 'huawei', + 'nokia', + 'motorola', + 'sony', + 'google', + 'asus', + 'acer', + 'lenovo', + 'dell', + 'hp', + 'msi', + 'lg', + 'tecno', + 'infinix', + 'itel', + 'nubia', + 'zte', + 'masstel', + 'benco', + 'garmin', + 'amazfit', + 'jbl', + 'marshall', + 'anker', + 'baseus', + 'ugreen', + 'mac', +]); + +// "iphone-16-series" lists a series; a case or screen protector for it +// ("op-lung-iphone-16-series") is a product. +const ACCESSORY_SLUG_PREFIX = + /^(?:op|bao|cuong-luc|kinh|mieng-dan|dan|sac|cu-sac|cap|day|tui|vo|ban-phim|chuot)-/; +const isSeriesListingSlug = (slug: string) => + /-series$/.test(slug) && !ACCESSORY_SLUG_PREFIX.test(slug); + const RETAIL_CATEGORY_ROOT_SLUGS = new Set([ 'dtdd', 'dien-thoai', @@ -1588,7 +1639,9 @@ export function parseEcommerceUrlOrQuery(raw: string): ParsedEcommerceUrl { } const prodMatch = parsedPathname.match(/\/product\/(\d+)/i) || - parsedPathname.match(/\/view\/product\/(\d+)/i); + parsedPathname.match(/\/view\/product\/(\d+)/i) || + // TikTok Shop's newer product pages (DOS/Rate#1245). + parsedPathname.match(/\/shop\/(?:[a-z]{2}\/)?pdp\/[^/]+\/(\d+)/i); const shopMatch = parsedPathname.match(/\/@([a-zA-Z0-9_.-]{1,64})/); const rawShopHandle = shopMatch?.[1] || null; const safeShopHandle = @@ -1652,7 +1705,11 @@ export function parseEcommerceUrlOrQuery(raw: string): ParsedEcommerceUrl { !( segments.length === 1 && RETAIL_CATEGORY_ROOT_SLUGS.has(firstSegLower) ) && - !(retailPlatform === 'tgdd' && segments.length < 2); + !RETAIL_BRAND_LISTING_SLUGS.has(lastSegLower) && + !isSeriesListingSlug(lastSegLower) && + !(retailPlatform === 'tgdd' && segments.length < 2) && + // CellphoneS product pages sit at the root (DOS/Rate#1246). + !(retailPlatform === 'cellphones' && segments.length > 1); return { isUrl: true, platform: retailPlatform, diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 92289ebae..2217271a8 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -11737,6 +11737,39 @@ async function runCoreArchitectureSuite() { ).platformShopId, null ); + // Brand, series and CellphoneS sub-path listings are not products, as + // in the extension (DOS/Rate#1246). + for (const url of [ + 'https://cellphones.com.vn/mobile/apple.html', + 'https://cellphones.com.vn/mobile/apple/iphone-16.html', + 'https://fptshop.com.vn/dien-thoai/samsung', + 'https://fptshop.com.vn/dien-thoai/iphone-16-series', + ]) { + assert.equal(parseEcommerceUrlOrQuery986(url).platformItemId, null, url); + } + assert.equal( + parseEcommerceUrlOrQuery986( + 'https://cellphones.com.vn/op-lung-magsafe-iphone-16-series.html' + ).platformItemId, + 'op-lung-magsafe-iphone-16-series' + ); + assert.equal( + parseEcommerceUrlOrQuery986( + 'https://cellphones.com.vn/iphone-16-pro-max.html' + ).platformItemId, + 'iphone-16-pro-max' + ); + // TikTok Shop's pdp pages carry the id last (DOS/Rate#1245). + for (const url of [ + 'https://www.tiktok.com/shop/vn/pdp/apple-iphone-16-128gb/1729384756', + 'https://www.tiktok.com/shop/pdp/apple-iphone-16-128gb/1729384756', + ]) { + assert.equal( + parseEcommerceUrlOrQuery986(url).platformItemId, + '1729384756', + url + ); + } assert.equal( parseEcommerceUrlOrQuery986('https://cellphones.com.vn/..') .platformItemId, From 02d4453fa0b980e93b4f5ab99871df4f8a2c17e0 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:31:57 +0700 Subject: [PATCH 29/69] fix(web): merchant profile follow-ups from the cold-render review (#1254) * fix(web): keep merchant listing prices with their discount verdicts The listing price now comes from the same cached offers the verdict was computed on, with the store's min and max price recomputed from them. A discarded speculative listings read no longer logs an error, and the merchant profile tests cover offer pass-through, one read per master, unclustered listings and the analysis staying outside the cache entry. * fix(web): show the price a merchant listing's verdict was computed on Taking the price from the cached offers could still disagree with a verdict from an older price-history entry, and an offer price of 0 could replace a valid one. A listing with a verdict now shows the verdict's own price, always valid, and the listings stay sorted by the price shown. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/merchant-profile-reads.test.ts | 175 +++++++++++++++++- apps/web/src/lib/rate-api.ts | 85 ++++++--- 2 files changed, 230 insertions(+), 30 deletions(-) diff --git a/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts b/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts index d2372409f..ca1897a88 100644 --- a/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts +++ b/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; // A merchant profile on a cold render must not wait on one database round trip // after another: it reads its listings while the merchant loads, and its @@ -11,6 +13,9 @@ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'stub-anon-key'; const MERCHANT_ID = '00000000-0000-4000-8000-000000001217'; const MASTER_ID = '00000000-0000-4000-9000-000000001217'; const LISTING_ID = '00000000-0000-4000-a000-000000001217'; +const SECOND_LISTING_ID = '00000000-0000-4000-a000-000000001240'; +const UNCLUSTERED_LISTING_ID = '00000000-0000-4000-a000-000000001241'; +const OTHER_LISTING_ID = '00000000-0000-4000-a000-0000000000ff'; const merchantRow = { id: MERCHANT_ID, @@ -63,6 +68,8 @@ function routeOf(url: URL): string { function installFetch(options: { merchantFound?: (route: Route | string) => boolean; listingsFail?: boolean; + /** Listings of MERCHANT_ID; any other merchant id lists OTHER_LISTING_ID. */ + listings?: unknown[]; delayMs?: number; }) { const calls: Recorded[] = []; @@ -93,7 +100,15 @@ function installFetch(options: { if (route === 'merchant' || route === 'merchantBySlug') { body = options.merchantFound?.(route) === false ? [] : [merchantRow]; } else if (route === 'listings') { - body = offset > 0 ? [] : [listingRow]; + const ownListings = decodeURIComponent(url.search).includes( + `merchant_id=eq.${MERCHANT_ID}` + ); + body = + offset > 0 + ? [] + : ownListings + ? (options.listings ?? [listingRow]) + : [{ ...listingRow, id: OTHER_LISTING_ID }]; } else if (route === 'master_products') { body = [ { @@ -120,6 +135,21 @@ function installFetch(options: { return calls; } +/** Error-level log lines written while `run` runs. */ +async function captureErrorLogs(run: () => Promise): Promise { + const lines: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + try { + await run(); + } finally { + console.error = original; + } + return lines; +} + async function main() { console.warn('🧪 merchant profile reads suite (DOS/Rate#1217)'); const { @@ -249,7 +279,124 @@ async function main() { ), 'the listings used are read for the merchant found' ); - assert.equal(base.profile.listings.length, 1); + assert.deepEqual( + base.profile.listings.map((l) => l.id), + [LISTING_ID], + 'the guessed id listings are discarded' + ); + } + + // 5b. Offers reach the price-history reader; two listings of one master + // read it once; an unclustered listing has no master and no analysis; + // a listing takes its price from the offers its verdict used (#1240). + { + installFetch({ + listings: [ + listingRow, + { ...listingRow, id: SECOND_LISTING_ID, price_current: 9_500_000 }, + { + ...listingRow, + id: UNCLUSTERED_LISTING_ID, + price_current: 20_000_000, + cluster_id: null, + }, + ], + }); + const base = await getMerchantProfileBase(MERCHANT_ID); + assert.ok(base); + assert.deepEqual(base.listingMasterIds, { + [LISTING_ID]: MASTER_ID, + [SECOND_LISTING_ID]: MASTER_ID, + }); + const offer = { + id: LISTING_ID, + platform: 'shopee', + title: 'Phone 1217', + price_current: 8_500_000, + price_original: 12_000_000, + stock: 3, + rating_star: 5, + rating_count: 1, + url: 'https://shopee.vn/product/1217/1', + merchant: { + id: MERCHANT_ID, + name: 'Shop 1217', + platform: 'shopee', + is_official: false, + rating_star: null, + follower_count: 0, + location: null, + risk_score: 10, + dosafe_risk_score: 10, + review_risk_score: 5, + review_risk_breakdown: null, + }, + } as unknown as Parameters< + Parameters[1]['priceHistory'] + >[1][number]; + let offersCalls = 0; + const seenOffers: string[][] = []; + const profile = await attachMerchantDiscountAnalysis(base, { + offers: async () => { + offersCalls += 1; + return [offer]; + }, + priceHistory: async (_id, offers) => { + seenOffers.push(offers.map((o) => o.id)); + // The price rose to 10.0M since the profile read 9.0M. + return { + ...emptyHistory, + byOffer: { + [LISTING_ID]: { + ...analysis, + price_current: 10_000_000, + price_original: 13_000_000, + }, + }, + }; + }, + }); + assert.equal(offersCalls, 1, 'one read per master, not per listing'); + assert.deepEqual(seenOffers, [[LISTING_ID]]); + const byId = new Map(profile.listings.map((l) => [l.id, l])); + assert.equal(byId.get(LISTING_ID)?.price_current, 10_000_000); + assert.equal(byId.get(LISTING_ID)?.price_original, 13_000_000); + // No verdict: the profile's own price stays. + assert.equal(byId.get(SECOND_LISTING_ID)?.price_current, 9_500_000); + assert.equal(byId.get(SECOND_LISTING_ID)?.discount_analysis, null); + assert.equal(byId.get(UNCLUSTERED_LISTING_ID)?.discount_analysis, null); + assert.deepEqual( + profile.listings.map((l) => l.id), + [SECOND_LISTING_ID, LISTING_ID, UNCLUSTERED_LISTING_ID], + 'listings stay sorted by the price shown' + ); + assert.equal(profile.min_price, 9_500_000); + assert.equal(profile.max_price, 20_000_000); + } + + // 5c. The server layer attaches the analysis outside the profile's cache + // entry: a nested unstable_cache skips its cache read, so the product + // pages' entries would never be reused (DOS/Rate#1217). A source check, + // a tripwire against moving the call back inside: a reflow or rename + // breaks it, and it does not prove the call is outside every cache. + { + const source = readFileSync( + join(__dirname, '../../server/rate/index.ts'), + 'utf8' + ); + const baseEntry = source.match( + /withRateDataCache\(\s*_getMerchantProfileBase,[\s\S]*?\)\(cleanId\)/ + ); + assert.ok(baseEntry, 'the base profile is a cache entry of its own'); + assert.ok( + !baseEntry[0].includes('_attachMerchantDiscountAnalysis'), + 'the analysis must not run inside the profile cache entry' + ); + assert.match( + source, + /await _getCachedMerchantProfileBaseByKey\(cleanId\);\s*return base\s*\?\s*_attachMerchantDiscountAnalysis\(base, CACHED_MASTER_READERS\)/, + 'getCachedMerchantProfileById attaches the analysis after the cache read' + ); } // 6. An unknown merchant is null, even when the early listings read fails. @@ -260,16 +407,30 @@ async function main() { null ); installFetch({ merchantFound: () => false, listingsFail: true }); - assert.equal( - await getMerchantProfileBase('00000000-0000-4000-8000-0000000000cc'), - null + const errors = await captureErrorLogs(async () => { + assert.equal( + await getMerchantProfileBase('00000000-0000-4000-8000-0000000000cc'), + null + ); + }); + assert.deepEqual( + errors, + [], + 'a discarded speculative read logs nothing (#1240)' ); } - // 7. A failed listings read for the merchant found fails the profile. + // 7. A failed listings read for the merchant found fails the profile, and + // is logged once. { installFetch({ listingsFail: true }); - await assert.rejects(getMerchantProfileBase(MERCHANT_ID)); + const errors = await captureErrorLogs(async () => { + await assert.rejects(getMerchantProfileBase(MERCHANT_ID)); + }); + assert.equal( + errors.filter((line) => line.includes('merchant listings')).length, + 1 + ); } await new Promise((resolve) => setTimeout(resolve, 50)); diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 6058b5583..3be8f2b18 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -133,17 +133,21 @@ async function fetchAllRows( from: number, to: number ) => PromiseLike<{ data: T[] | null; error: { message: string } | null }>, - pageSize = 1000 + pageSize = 1000, + /** False for a speculative read whose caller logs a failure if it uses it. */ + logFailure = true ): Promise { const rows: T[] = []; for (let from = 0, pages = 0; ;) { const { data, error } = await buildPage(from, from + pageSize - 1); if (error) { - log.error('paged read failed', { - table: label, - row: from, - message: error.message, - }); + if (logFailure) { + log.error('paged read failed', { + table: label, + row: from, + message: error.message, + }); + } throw new RateDataUnavailableError(label, error); } const page = data ?? []; @@ -3136,12 +3140,35 @@ export async function attachMerchantDiscountAnalysis( } } ); + // A listing with a verdict shows the price the verdict was computed on: the + // profile entry and the price-history entry expire apart, and an old price + // next to a verdict quoting the new one would contradict itself + // (DOS/Rate#1240). A verdict's price is always a valid one. Stock, rating + // and title stay as the profile read them. + const listings = base.profile.listings + .map((listing) => { + const analysis = discountByOfferId.get(listing.id) || null; + return { + ...listing, + ...(analysis + ? { + price_current: analysis.price_current, + price_original: analysis.price_original, + } + : {}), + discount_analysis: analysis, + }; + }) + // Stable: equal prices keep the profile's id order. + .sort((a, b) => a.price_current - b.price_current); + const prices = listings + .map((l) => l.price_current) + .filter((p) => Number.isFinite(p) && p > 0 && p <= MAX_VALID_VND_PRICE); return { ...base.profile, - listings: base.profile.listings.map((listing) => ({ - ...listing, - discount_analysis: discountByOfferId.get(listing.id) || null, - })), + min_price: prices.length > 0 ? Math.min(...prices) : null, + max_price: prices.length > 0 ? Math.max(...prices) : null, + listings, }; } @@ -3164,17 +3191,21 @@ export async function getMerchantProfileBase( // Secondary reads throw too: a profile with silently empty listings or // reviews would otherwise be cached as if it were true (DOS/Rate#329). // Paged: retailers can list more than PostgREST's 1000-row cap. - const readListings = (merchantId: string) => - fetchAllRows('merchant listings', (from, to) => - supabase - .from('products') - .select( - 'id, merchant_id, platform, platform_item_id, title, price_current, price_original, stock, historical_sold, historical_sold_display, rating_star, rating_count, images, url, cluster_id' - ) - .eq('merchant_id', merchantId) - .order('price_current', { ascending: true }) - .order('id') - .range(from, to) + const readListings = (merchantId: string, logFailure = true) => + fetchAllRows( + 'merchant listings', + (from, to) => + supabase + .from('products') + .select( + 'id, merchant_id, platform, platform_item_id, title, price_current, price_original, stock, historical_sold, historical_sold_display, rating_star, rating_count, images, url, cluster_id' + ) + .eq('merchant_id', merchantId) + .order('price_current', { ascending: true }) + .order('id') + .range(from, to), + undefined, + logFailure ); let merchantRow: RawMerchantSafeRow | null = null; @@ -3184,7 +3215,9 @@ export async function getMerchantProfileBase( let listingsByUuid: Promise | null = null; if (isUuid) { - listingsByUuid = readListings(cleaned); + // Unlogged: a UUID that is no merchant id discards this read, failed or + // not (DOS/Rate#1240). A failure is logged below if the read is used. + listingsByUuid = readListings(cleaned, false); // Settled here so a failure is never unhandled while the merchant loads; // it is rethrown below when these listings are used. listingsByUuid.catch(() => undefined); @@ -3219,7 +3252,13 @@ export async function getMerchantProfileBase( const merchantId = merchantRow.id; const prodRows = listingsByUuid && merchantId.toLowerCase() === cleaned.toLowerCase() - ? await listingsByUuid + ? await listingsByUuid.catch((err: unknown) => { + log.error('paged read failed', { + table: 'merchant listings', + message: err instanceof Error ? err.message : String(err), + }); + throw err; + }) : await readListings(merchantId); const rawProducts = prodRows as unknown as RawProductRow[]; From ba87a0079a46f1437d77529613db8a471a7f149c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:46:10 +0700 Subject: [PATCH 30/69] fix(web): versus verdict follows the scoreboard's weights (#1238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): versus verdict follows the scoreboard's weights The verdict under the versus title was rendered on the server from the default weights, while the scoreboard below re-scores with the persona the reader picks, so the two could name different leaders. The verdict now lives in the scoreboard and reads its result through versusVerdictCopy: it names the same leader or tie as the banner and says which weights it uses when they are not the default ones ("(Theo trọng số: Chơi game nặng.)"). The default view reads as before. Refs #1235 * fix(web): announce the versus verdict and test the component's mapping Review: a persona click changed the verdict silently for screen readers, and the test rebuilt the component's score and label mapping instead of testing it. versusVerdictFor now holds that mapping (leader's score, tie score, persona label with the custom fallback) and the component calls it; the verdict is aria-live polite. The test also checks the flipped score and the fallback label. Refs #1235 Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 1 + apps/web/locales/en.json | 1 + apps/web/locales/vi.json | 1 + .../app/[locale]/compare/[versus]/page.tsx | 34 +- .../compare/VersusInteractiveScoreboard.tsx | 574 ++++++++++-------- apps/web/src/lib/versus-verdict.ts | 81 +++ .../__tests__/core-rate-architecture.test.ts | 69 +++ 7 files changed, 459 insertions(+), 302 deletions(-) diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 11f23af74..e01271e76 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -129,6 +129,7 @@ "tieDeltaNote": "(stejné skóre při aktuálních vahách)", "tie1v1Badge": "Remíza 1v1", "versusAutoVerdictTie": "Automatický verdikt: oba modely mají stejné celkové skóre ({score}/5.0); rozhodněte podle ceny a parametrů, které potřebujete.", + "versusVerdictWeights": "(Váhy: {weights}.)", "compareMetaTitleWithNames": "Porovnání {names} - Hodnocení a 10 skupin parametrů | Rate.vn", "compareMetaTitleWithNamesShort": "{names} | Rate.vn", "compareMetaDescWithNames": "Porovnání skutečných hodnocení, 10 skupin technických parametrů a referenčních cen pro {names} na Rate.vn.", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index ef6f1c118..7c5c29dc0 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -129,6 +129,7 @@ "tieDeltaNote": "(same score with the current weights)", "tie1v1Badge": "1v1 Tie", "versusAutoVerdictTie": "Automatic verdict: the two models are level on overall score ({score}/5.0); choose on price and the specs you need.", + "versusVerdictWeights": "(Weights: {weights}.)", "compareMetaTitleWithNames": "Compare {names} - Ratings and 10 spec groups | Rate.vn", "compareMetaTitleWithNamesShort": "{names} | Rate.vn", "compareMetaDescWithNames": "Compare real review scores, 10 spec groups and reference prices for {names} on Rate.vn.", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 4a014517d..897372ad0 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -129,6 +129,7 @@ "tieDeltaNote": "(cùng điểm theo trọng số hiện tại)", "tie1v1Badge": "Ngang điểm 1v1", "versusAutoVerdictTie": "Kết luận tự động: hai mẫu ngang điểm tổng hợp ({score}/5.0), chọn theo giá và thông số bạn cần.", + "versusVerdictWeights": "(Theo trọng số: {weights}.)", "compareMetaTitleWithNames": "So sánh {names} - Điểm đánh giá và 10 nhóm thông số | Rate.vn", "compareMetaTitleWithNamesShort": "{names} | Rate.vn", "compareMetaDescWithNames": "So sánh điểm đánh giá thật, 10 nhóm thông số kỹ thuật và giá tham khảo giữa {names} trên Rate.vn.", diff --git a/apps/web/src/app/[locale]/compare/[versus]/page.tsx b/apps/web/src/app/[locale]/compare/[versus]/page.tsx index 2d75c9330..d03516f65 100644 --- a/apps/web/src/app/[locale]/compare/[versus]/page.tsx +++ b/apps/web/src/app/[locale]/compare/[versus]/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getTranslations, setRequestLocale } from 'next-intl/server'; -import { isVersusScoreTie } from '@/lib/versus-verdict'; import { BsTrophy } from 'react-icons/bs'; import { Link } from '@/lib/navigation'; import { @@ -12,10 +11,7 @@ import { } from '@/server/rate'; import { normalizeRateLocale } from '@/lib/rate-i18n'; import { defaultOpenGraph, fitTitle, fitDescription } from '@/lib/seo'; -import { - comparePositiveMinPrices, - scoreMasterProductForLeaderboard, -} from '@/lib/leaderboard-engine'; +import { scoreMasterProductForLeaderboard } from '@/lib/leaderboard-engine'; import PageShell from '../../components/page-shell'; import CompareWorkspace from '@/components/compare/CompareWorkspace'; import CompareTrayBar from '@/components/compare/CompareTrayBar'; @@ -133,19 +129,6 @@ export default async function VersusComparePage({ params }: VersusPageProps) { const scoreA = scoreMasterProductForLeaderboard(prodA, 'combined'); const scoreB = scoreMasterProductForLeaderboard(prodB, 'combined'); - const tiePriceSide = comparePositiveMinPrices( - scoreA.minPrice, - scoreB.minPrice - ); - const overallWinner = - scoreA.combinedScore5 > scoreB.combinedScore5 - ? scoreA - : scoreB.combinedScore5 > scoreA.combinedScore5 - ? scoreB - : tiePriceSide === 'B' - ? scoreB - : scoreA; - // Summary ItemList: each item links to its product page, which carries the // Product markup (DOS/Rate#1059). const itemListSchema = { @@ -214,21 +197,6 @@ export default async function VersusComparePage({ params }: VersusPageProps) { {scoreB.name} -

- {isVersusScoreTie(scoreA.combinedScore5, scoreB.combinedScore5) - ? t('versusAutoVerdictTie', { - score: scoreA.combinedScore5.toFixed(2), - }) - : t('versusAutoVerdict', { - winner: overallWinner.name, - score: overallWinner.combinedScore5.toFixed(2), - expertSuffix: - overallWinner.expertScore10 != null - ? ` (${overallWinner.expertScore10.toFixed(1)}/10 Lab)` - : '', - })} -

- > & { + custom: string; + } = { + default: t('personaDefault'), + photography: t('personaPhotography'), + gaming: t('personaGaming'), + endurance: t('personaEndurance'), + value_hunter: t('personaValueHunter'), + custom: t('customWeightsActive'), + }; + const verdict = versusVerdictFor( + weightedResult, + scoreA.slug, + persona, + personaLabels + ); + return ( -
- {/* Top Action & Persona Filter Strip */} -
- {/* Presets expose the pressed one, not only its colour (DOS/Rate#1069). */} -
- + {/* The verdict reads the scoreboard's own result, so it follows the + weights the reader picks; other weights than the default are named + (DOS/Rate#1235). */} +

+ {/* Narrows the values to each key's own placeholders. */} + {verdict.key === 'versusAutoVerdictTie' + ? t(verdict.key, verdict.values) + : t(verdict.key, verdict.values)} + {verdict.weightsLabel && + ` ${t('versusVerdictWeights', { weights: verdict.weightsLabel })}`} +

+
+ {/* Top Action & Persona Filter Strip */} +
+ {/* Presets expose the pressed one, not only its colour (DOS/Rate#1069). */} +
- {t('useCaseLabel')} - - {( - [ - { key: 'default', label: t('personaDefault') }, - { key: 'photography', label: t('personaPhotography') }, - { key: 'gaming', label: t('personaGaming') }, - { key: 'endurance', label: t('personaEndurance') }, - { key: 'value_hunter', label: t('personaValueHunter') }, - ] as const - ).map((item) => ( + + {t('useCaseLabel')} + + {( + [ + { key: 'default', label: t('personaDefault') }, + { key: 'photography', label: t('personaPhotography') }, + { key: 'gaming', label: t('personaGaming') }, + { key: 'endurance', label: t('personaEndurance') }, + { key: 'value_hunter', label: t('personaValueHunter') }, + ] as const + ).map((item) => ( + + ))} - ))} - + {persona === 'custom' && ( + {`: ${t('customWeightsActive')}`} + )} + +
+ +
+ + + + + ⇄ {t('swapAbBtn')} + +
-
- - - - - ⇄ {t('swapAbBtn')} - -
-
+
+ {criteriaRows.map((c) => ( +
+
+ {c.label} + + {weights[c.key]}% + +
+ + updateWeight(c.key, Number(e.target.value)) + } + className="form-range" + aria-label={c.label} + /> +
+ ))} +
+
+ )} - {/* Interactive 5-Criteria Weight Sliders */} - {showSliders && ( + {/* Dynamic 1v1 Winner Banner */}
-
- {criteriaRows.map((c) => ( -
-
- {c.label} - - {weights[c.key]}% - -
- updateWeight(c.key, Number(e.target.value))} - className="form-range" - aria-label={c.label} - /> -
- ))} +
+ + {weightedResult.tie ? t('tie1v1Badge') : t('leading1v1Badge')} + + {weightedResult.tie ? ( + {t('tieDeltaNote')} + ) : ( + <> + + {weightedResult.winner.name} + + + {t('leadingDeltaNote', { delta: weightedResult.delta })} + + + )} +
+
+ {scoreA.brand}: {weightedResult.finalA.toFixed(2)}/5.0 •{' '} + {scoreB.brand}: {weightedResult.finalB.toFixed(2)}/5.0
- )} - {/* Dynamic 1v1 Winner Banner */} -
-
- - {weightedResult.tie ? t('tie1v1Badge') : t('leading1v1Badge')} - - {weightedResult.tie ? ( - {t('tieDeltaNote')} - ) : ( - <> - - {weightedResult.winner.name} - - - {t('leadingDeltaNote', { delta: weightedResult.delta })} + {/* Product Headers */} +
+
+ + {scoreA.name} + +
+ + ★ {weightedResult.finalA.toFixed(2)}/5.0 - - )} -
-
- {scoreA.brand}: {weightedResult.finalA.toFixed(2)}/5.0 •{' '} - {scoreB.brand}: {weightedResult.finalB.toFixed(2)}/5.0 -
-
- - {/* Product Headers */} -
-
- - {scoreA.name} - -
- - ★ {weightedResult.finalA.toFixed(2)}/5.0 - - - {scoreA.expertScore10 != null - ? `Lab ${scoreA.expertScore10.toFixed(1)}/10` - : t('labScorePending')} - + + {scoreA.expertScore10 != null + ? `Lab ${scoreA.expertScore10.toFixed(1)}/10` + : t('labScorePending')} + +
-
-
- - VS - -
-
- - {scoreB.name} - -
- - ★ {weightedResult.finalB.toFixed(2)}/5.0 - - - {scoreB.expertScore10 != null - ? `Lab ${scoreB.expertScore10.toFixed(1)}/10` - : t('labScorePending')} +
+ + VS
+
+ + {scoreB.name} + +
+ + ★ {weightedResult.finalB.toFixed(2)}/5.0 + + + {scoreB.expertScore10 != null + ? `Lab ${scoreB.expertScore10.toFixed(1)}/10` + : t('labScorePending')} + +
+
-
- {/* 5-Criteria Dual Progress Bar Comparison Rows */} - {criteriaRows.map((row) => { - const aWins = row.aVal > row.bVal; - const bWins = row.bVal > row.aVal; - const aPct = Math.min(100, Math.round((row.aVal / 5) * 100)); - const bPct = Math.min(100, Math.round((row.bVal / 5) * 100)); - return ( -
-
-
- - {row.aVal.toFixed(2)}/5.0 {aWins ? '✓' : ''} - -
-
{ + const aWins = row.aVal > row.bVal; + const bWins = row.bVal > row.aVal; + const aPct = Math.min(100, Math.round((row.aVal / 5) * 100)); + const bPct = Math.min(100, Math.round((row.bVal / 5) * 100)); + return ( +
+
+
+ + > + {row.aVal.toFixed(2)}/5.0 {aWins ? '✓' : ''} + +
+
+
-
-
- {row.label}{' '} - - ({weights[row.key]}%) - -
-
-
-
+
+ {row.label}{' '} + + ({weights[row.key]}%) + +
+
+
+
+
+ + > + {bWins ? '✓ ' : ''} + {row.bVal.toFixed(2)}/5.0 +
- - {bWins ? '✓ ' : ''} - {row.bVal.toFixed(2)}/5.0 -
-
- ); - })} + ); + })} - {/* 1v1 Live Deal Radar & Fake-Sale Trap Comparison Row */} -
-
- - {t('price60dStable')} - - } - /> -
-
- {t('radarRowTitle')} -
-
- - {t('price60dStable')} - - } - /> + {/* 1v1 Live Deal Radar & Fake-Sale Trap Comparison Row */} +
+
+ + {t('price60dStable')} + + } + /> +
+
+ {t('radarRowTitle')} +
+
+ + {t('price60dStable')} + + } + /> +
-
+ ); } diff --git a/apps/web/src/lib/versus-verdict.ts b/apps/web/src/lib/versus-verdict.ts index b5417c38e..3a21e045c 100644 --- a/apps/web/src/lib/versus-verdict.ts +++ b/apps/web/src/lib/versus-verdict.ts @@ -6,3 +6,84 @@ export function isVersusScoreTie(scoreA: number, scoreB: number): boolean { if (!Number.isFinite(scoreA) || !Number.isFinite(scoreB)) return false; return scoreA.toFixed(2) === scoreB.toFixed(2); } + +export interface VersusVerdictInput { + tie: boolean; + /** The leader's score under the weights in use (either side on a tie). */ + score: number; + winnerName: string; + winnerExpertScore10?: number | null; + /** The weights' name, or null for the default weights. */ + weightsLabel: string | null; +} + +export type VersusVerdictCopy = + | { + key: 'versusAutoVerdictTie'; + values: { score: string }; + weightsLabel: string | null; + } + | { + key: 'versusAutoVerdict'; + values: { winner: string; score: string; expertSuffix: string }; + weightsLabel: string | null; + }; + +/** + * The automatic 1v1 verdict for the scoreboard's current result, so it + * names the same leader, or the same tie, as the banner, and says which + * weights it uses when they are not the default ones (DOS/Rate#1235). + */ +export function versusVerdictCopy( + input: VersusVerdictInput +): VersusVerdictCopy { + const score = input.score.toFixed(2); + if (input.tie) { + return { + key: 'versusAutoVerdictTie', + values: { score }, + weightsLabel: input.weightsLabel, + }; + } + return { + key: 'versusAutoVerdict', + values: { + winner: input.winnerName, + score, + expertSuffix: + input.winnerExpertScore10 != null + ? ` (${input.winnerExpertScore10.toFixed(1)}/10 Lab)` + : '', + }, + weightsLabel: input.weightsLabel, + }; +} + +/** + * `versusVerdictCopy` for a scoreboard result: the leader's score under the + * weights in use, and the weights' label unless they are the default ones + * (a persona without its own label reads as custom weights). + */ +export function versusVerdictFor( + result: { + tie: boolean; + finalA: number; + finalB: number; + winner: { slug: string; name: string; expertScore10?: number | null }; + }, + slugA: string, + persona: string, + labels: Partial> & { custom: string } +): VersusVerdictCopy { + return versusVerdictCopy({ + tie: result.tie, + score: + result.tie || result.winner.slug === slugA + ? result.finalA + : result.finalB, + winnerName: result.winner.name, + winnerExpertScore10: result.winner.expertScore10, + weightsLabel: + persona === 'default' ? null : (labels[persona] ?? labels.custom), + }); +} diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 2217271a8..19e8aa49d 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -11031,6 +11031,75 @@ async function runCoreArchitectureSuite() { assert.equal(isVersusScoreTie(5, 4.996), true, 'equal at two decimals'); assert.equal(isVersusScoreTie(4.98, 4.97), false); assert.equal(isVersusScoreTie(Number.NaN, 5), false); + // The verdict follows the persona: A leads on the default weights, B on + // the camera persona, and the verdict names each in turn with its + // weights (DOS/Rate#1235). + const { versusVerdictCopy, versusVerdictFor } = + await import('../../../lib/versus-verdict'); + const camA = { + ...scoreItemA, + slug: 'cam-a', + name: 'Model A', + combinedScore5: 4.9, + expertScore10: null, + criteria: { screen: 5, performance: 5, camera: 3, battery: 5, value: 5 }, + }; + const camB = { + ...scoreItemB, + slug: 'cam-b', + name: 'Model B', + combinedScore5: 4.6, + expertScore10: null, + criteria: { screen: 4, performance: 4, camera: 5, battery: 4, value: 4 }, + }; + const { PERSONA_WEIGHTS } = + await import('../../../components/compare/VersusInteractiveScoreboard'); + const labels = { + photography: 'Chụp ảnh / Âm thanh', + custom: 'Trọng số tùy chỉnh', + }; + const verdictFor = (persona: 'default' | 'photography' | 'business') => + versusVerdictFor( + computeVersusWeightedResult( + camA, + camB, + PERSONA_WEIGHTS[persona], + persona + ), + camA.slug, + persona, + labels + ); + const byDefault = verdictFor('default'); + const byCamera = verdictFor('photography'); + assert.equal(byDefault.key, 'versusAutoVerdict'); + assert.equal(byCamera.key, 'versusAutoVerdict'); + if ( + byDefault.key === 'versusAutoVerdict' && + byCamera.key === 'versusAutoVerdict' + ) { + assert.equal(byDefault.values.winner, 'Model A'); + assert.equal(byDefault.values.score, '4.90'); + assert.equal(byCamera.values.winner, 'Model B'); + assert.equal(byCamera.values.score, '4.50'); + } + assert.equal(byDefault.weightsLabel, null); + assert.equal(byCamera.weightsLabel, 'Chụp ảnh / Âm thanh'); + // A persona without its own label reads as custom weights. + assert.equal(verdictFor('business').weightsLabel, 'Trọng số tùy chỉnh'); + assert.deepEqual( + versusVerdictCopy({ + tie: true, + score: 5, + winnerName: 'x', + weightsLabel: null, + }), + { + key: 'versusAutoVerdictTie', + values: { score: '5.00' }, + weightsLabel: null, + } + ); // The score gap reads as points, never as đồng. const viCompare = ( (await import('../../../../locales/vi.json')).default as { From 6cb910e6182d5dd3f2a8b7f7c37059a6e3140177 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:46:17 +0700 Subject: [PATCH 31/69] fix(importer,web): scope llm review summaries and drop quoted scores (#1260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(importer,web): scope llm review summaries and drop quoted scores The LLM pass's free-text review summary covers one listing's latest batch but was stored and shown as is, so it could read as the product's verdict or quote a batch score (follow-up of DOS/Rate#1225). - shared rule (@repo/shared-data/rules): scopeLlmReviewSummary adds a fixed one-listing scope prefix and returns null for text that quotes a score, star rating, share or count; - importer: the prompt asks for qualitative, scoped text; the stored summary is scoped, or the templated sentence when the text quotes a number; - web reader (listingReviewSummary, used by the page, API v1 and the extension): old free-text rows are scoped the same way, or hidden when they quote a number. Old rows are not rewritten. Refs #1244 * fix(importer,web): catch more score phrasings and keep spec text Review round 1 on DOS/Rate#1244: - claims also caught: score word before the number (rating 4.8, điểm 9), shares (phần trăm, percent, 90% khen), counts in words (hơn trăm người), stars in words (năm sao), full-width digits and invisible characters (NFKC); - kept: battery percentages, durations like 2/3 ngày, dates, 24/7, điểm cộng/trừ/ảnh, sao lưu; slash scores only out of 5, 10 or 100; - repeated prefixes collapse; truncation cuts on code points; - rate-api: a hidden latest summary no longer falls back to an older batch; - e2e and core-rate tests expect the scoped summary. Refs #1244 * fix(shared-data): read score claims in context Codex round 2 on DOS/Rate#1244: - caught: counts of people with a verb (30 người khen), 'các/the' before buyers in shares, 'đánh giá 4.8', fractions with a score word or unit (chấm 4/5, 85/100 điểm); - kept: battery percentages in buyer sentences (shares need a direct buyer/praise noun), screen spots (điểm chết/sáng), dates and durations as fractions (5/10, 3/5 ngày); - input is cut on code points and lone surrogates are dropped, so a pair cut upstream never reaches the output. Refs #1244 Co-Authored-By: Claude Opus 5.5 --- .../importer/src/shopee/ai-review-analyzer.ts | 9 +- .../src/shopee/test-review-analyzer.ts | 31 ++++- apps/web/e2e/catalog.spec.ts | 3 +- .../src/lib/__tests__/analyzer-copy.test.ts | 20 ++- apps/web/src/lib/analyzer-copy.ts | 9 +- apps/web/src/lib/rate-api.ts | 8 +- .../__tests__/core-rate-architecture.test.ts | 7 +- .../src/__tests__/shared-data.test.ts | 128 +++++++++++++++++ packages/shared-data/src/rules/index.ts | 1 + .../shared-data/src/rules/review-summary.ts | 131 ++++++++++++++++++ 10 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 packages/shared-data/src/rules/review-summary.ts diff --git a/apps/importer/src/shopee/ai-review-analyzer.ts b/apps/importer/src/shopee/ai-review-analyzer.ts index dd225f4d2..8d31ff4f2 100644 --- a/apps/importer/src/shopee/ai-review-analyzer.ts +++ b/apps/importer/src/shopee/ai-review-analyzer.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import { z, MAX_VALID_VND_PRICE, seedingRiskPenalty } from '@repo/shared-data'; +import { scopeLlmReviewSummary } from '@repo/shared-data/rules'; import { resolveValidHttpBaseUrl, extractJsonObjectString, @@ -1534,7 +1535,9 @@ Return ONLY valid JSON matching: "pros": ["string (Vietnamese)"], "cons": ["string (Vietnamese)"], "ai_summary": "string (2-3 sentences in Vietnamese summarizing authentic buyer feedback)" -}`; +} + +Rules for "ai_summary": these reviews come from ONE listing's latest batch, not from every buyer of the product. Describe only what these buyers say about their experience (quality, battery, defects, accuracy of the description). Never state star ratings, scores, percentages, review or buyer counts, and never give an overall verdict on the product as a whole.`; interface RawAiReviewItem { review_id?: string; @@ -1997,7 +2000,9 @@ Return ONLY valid JSON matching: .filter(Boolean) ) ).slice(0, 6); - const cleanSummaryText = sanitizeAiGeneratedText(aiSummaryText, 600); + // LLM text covers the same one-listing batch: stored after a scope prefix, and + // dropped for the templated sentence when it quotes a score or count (DOS/Rate#1244). + const cleanSummaryText = scopeLlmReviewSummary(sanitizeAiGeneratedText(aiSummaryText, 600)); return { total_reviews_analyzed: safeReviews.length, diff --git a/apps/importer/src/shopee/test-review-analyzer.ts b/apps/importer/src/shopee/test-review-analyzer.ts index 23b572639..d2e28a65e 100644 --- a/apps/importer/src/shopee/test-review-analyzer.ts +++ b/apps/importer/src/shopee/test-review-analyzer.ts @@ -4,6 +4,7 @@ */ import http from 'node:http'; import assert from 'node:assert/strict'; +import { LLM_REVIEW_SUMMARY_PREFIX } from '@repo/shared-data/rules'; import { readFileSync } from 'node:fs'; import { AiReviewAnalyzer, @@ -336,6 +337,34 @@ async function run() { assert.equal(ignoredSummary.overall_rating, 0); console.log('✅ [JReviews] every review excluded -> weighted rating 0 (matches the SQL refresh)'); + // LLM summary text (DOS/Rate#1244): stored once with the one-listing scope; a + // summary quoting a score or count falls back to the templated sentence. + const llmScoped = heuristicOnly().computeJReviewsSummary( + jr, + jrRes, + null, + [], + [], + 'Pin trâu, camera chụp đêm tốt nhưng máy hơi nóng.' + ); + assert.equal( + llmScoped.ai_summary, + `${LLM_REVIEW_SUMMARY_PREFIX}Pin trâu, camera chụp đêm tốt nhưng máy hơi nóng.` + ); + const llmScore = heuristicOnly().computeJReviewsSummary( + jr, + jrRes, + null, + [], + [], + 'Sản phẩm đạt 4.9/5 sao, được người mua đánh giá rất cao.' + ); + assert.ok( + llmScore.ai_summary.startsWith('Lần phân tích gần nhất (một nơi bán) đã xét 4 đánh giá'), + 'a score quoted by the LLM is never stored' + ); + console.log('✅ [JReviews] LLM summary scoped to one listing; quoted scores fall back'); + console.log('\n================================================================'); console.log('4. Merchant risk: fair to sellers, harsh on seeding & dump pricing'); console.log('================================================================'); @@ -2288,7 +2317,7 @@ async function run() { 'computeJReviewsSummary must strip HTML tags and deduplicate pros' ); assert.deepEqual(summary834.cons, ['Nóng máy']); - assert.equal(summary834.ai_summary, 'Tổng kết đánh giá'); + assert.equal(summary834.ai_summary, `${LLM_REVIEW_SUMMARY_PREFIX}Tổng kết đánh giá`); await assert.rejects( () => diff --git a/apps/web/e2e/catalog.spec.ts b/apps/web/e2e/catalog.spec.ts index f5db659e0..7458befc8 100644 --- a/apps/web/e2e/catalog.spec.ts +++ b/apps/web/e2e/catalog.spec.ts @@ -163,8 +163,9 @@ test('offer and listing reads page past the 1000-row cap', async ({ 'Review past the 1000-row listing cap.' ); expect(product.data.reviewSummary.total_analyzed).toBe(7); + // LLM free text is shown with the one-listing scope (DOS/Rate#1244). expect(product.data.reviewSummary.ai_summary).toBe( - 'E2E analysis past the 1000-row listing cap.' + 'Theo các đánh giá của một nơi bán trong lần phân tích gần nhất: E2E analysis past the 1000-row listing cap.' ); const merchant = await ( diff --git a/apps/web/src/lib/__tests__/analyzer-copy.test.ts b/apps/web/src/lib/__tests__/analyzer-copy.test.ts index 954e61537..81b21d637 100644 --- a/apps/web/src/lib/__tests__/analyzer-copy.test.ts +++ b/apps/web/src/lib/__tests__/analyzer-copy.test.ts @@ -5,6 +5,7 @@ import { analyzerCopy, listingReviewSummary, } from '../analyzer-copy'; +import { LLM_REVIEW_SUMMARY_PREFIX } from '@repo/shared-data/rules'; type Messages = { analyzerCopy?: Record }; const readLocale = (locale: string) => @@ -183,7 +184,24 @@ function main() { for (const [text] of legacy) { assert.ok(!/Đã phân tích|trọng số/.test(listingReviewSummary(text)!)); } - assert.equal(listingReviewSummary('Máy dùng ổn.'), 'Máy dùng ổn.'); + // LLM free text (DOS/Rate#1244): old rows get the one-listing scope; a + // summary quoting a score, a share or a count is not shown at all. + assert.equal( + listingReviewSummary('Máy dùng ổn.'), + `${LLM_REVIEW_SUMMARY_PREFIX}Máy dùng ổn.` + ); + const scoped = `${LLM_REVIEW_SUMMARY_PREFIX}Pin tốt, giao nhanh.`; + assert.equal(listingReviewSummary(scoped), scoped, 'scoped once'); + for (const claim of [ + 'Sản phẩm đạt 4.8/5 sao, người mua rất hài lòng.', + 'Điểm JReviews có trọng số 4,6 cho thấy chất lượng tốt.', + '95% người mua khen pin.', + 'Có 120 đánh giá khen camera.', + 'Rated 4.7 out of 5 by buyers.', + `${LLM_REVIEW_SUMMARY_PREFIX}Điểm trung bình rất cao.`, + ]) { + assert.equal(listingReviewSummary(claim), null, claim); + } assert.equal(listingReviewSummary(null), null); assert.equal(listingReviewSummary(' '), null); diff --git a/apps/web/src/lib/analyzer-copy.ts b/apps/web/src/lib/analyzer-copy.ts index 6c7c7e226..6681d23f1 100644 --- a/apps/web/src/lib/analyzer-copy.ts +++ b/apps/web/src/lib/analyzer-copy.ts @@ -8,6 +8,7 @@ * The Vietnamese messages may word a template more plainly than the stored * text (no "seeding", DOS/Rate#656); the patterns below match the stored text. */ +import { scopeLlmReviewSummary } from '@repo/shared-data/rules'; import { isVietnamese } from './content-lang'; export type AnalyzerCopyKey = @@ -181,8 +182,10 @@ const VI_SUMMARY: Partial> = { * The stored review summary as surfaces without a locale (API v1, the * extension) print it. A templated summary, old rows included, is reworded * to say it covers one listing's latest batch and loses the batch's - * weighted score, which is not the product's (DOS/Rate#1225). Free text is - * returned as stored. + * weighted score, which is not the product's (DOS/Rate#1225). Free text + * from the LLM pass, old rows included, gets the same one-listing scope + * prefix, and is not shown (null) when it quotes a score or count + * (DOS/Rate#1244). */ export function listingReviewSummary( text: string | null | undefined @@ -190,7 +193,7 @@ export function listingReviewSummary( if (typeof text !== 'string' || !text.trim()) return null; const copy = analyzerCopy(text); const template = 'key' in copy ? VI_SUMMARY[copy.key] : undefined; - if (!template || !('key' in copy)) return text; + if (!template || !('key' in copy)) return scopeLlmReviewSummary(text); return template.replace( /\{(\w+)\}/g, (_, name: string) => copy.values[name] ?? '' diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 3be8f2b18..3dc0b8d00 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -1727,6 +1727,7 @@ export async function getMasterProductReviewSummary( let totalAnalyzed = 0; let weightedAuthenticity = 0; let latestSummary: string | null = null; + let summaryTaken = false; for (const row of typedAnalyses) { const count = Number(row.total_reviews_analyzed || 0); @@ -1734,8 +1735,11 @@ export async function getMasterProductReviewSummary( weightedAuthenticity += Number(row.authenticity_score ?? fallbackAuthenticPct) * Math.max(1, count); - if (!latestSummary && row.ai_summary) { - // Says it covers one listing, old rows too (DOS/Rate#1225). + if (!summaryTaken && row.ai_summary) { + // Says it covers one listing, old rows too (DOS/Rate#1225). Only the + // latest row: a hidden one does not fall back to an older batch + // (DOS/Rate#1244). + summaryTaken = true; latestSummary = listingReviewSummary(String(row.ai_summary)); } if (Array.isArray(row.pros)) { diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 19e8aa49d..9d0430da9 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { LLM_REVIEW_SUMMARY_PREFIX } from '@repo/shared-data/rules'; import { KNOWN_LOCAL_DEVICE_IMAGES, computeJReviewsWeight, @@ -4174,7 +4175,11 @@ async function runCoreArchitectureSuite() { 2, 'getMasterProductReviewSummary must chunk 160 productIds into 2 requests of <=150 IDs (#344)' ); - assert.equal(chunkedSummary344.ai_summary, 'Summary from chunk 2'); + // Free text is shown with the one-listing scope (DOS/Rate#1244). + assert.equal( + chunkedSummary344.ai_summary, + `${LLM_REVIEW_SUMMARY_PREFIX}Summary from chunk 2` + ); assert.deepEqual(chunkedSummary344.pros, ['Pro 2', 'Pro 1']); const mockOffers344 = manyListingRows344.map((r, idx) => ({ diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 9d854b6d5..79cfd9015 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -18,6 +18,12 @@ import { z, } from '../index.js'; import { getAdminClient, getRateClient, getDosafeClient } from '../server.js'; +import { + LLM_REVIEW_SUMMARY_PREFIX, + hasReviewScoreClaim, + isScopedLlmReviewSummary, + scopeLlmReviewSummary, +} from '../rules/index.js'; import * as rules from '../rules/index.js'; import { readdirSync, readFileSync } from 'node:fs'; @@ -1255,6 +1261,126 @@ function testItemVariantKeys(): void { ]); } +function testLlmReviewSummaryScope(): void { + // Qualitative text is kept, after the one-listing scope prefix, once. + const plain = 'Pin trâu, camera đêm tốt, máy hơi nóng khi chơi game 2 tiếng.'; + const scoped = scopeLlmReviewSummary(` ${plain} +`); + assert.equal(scoped, `${LLM_REVIEW_SUMMARY_PREFIX}${plain}`); + assert.ok(isScopedLlmReviewSummary(scoped)); + assert.equal(scopeLlmReviewSummary(scoped), scoped, 'never prefixed twice'); + // Spec numbers are not claims. + for (const ok of [ + 'Pin 5000mAh, sạc 65W, RAM 8GB, màn 120Hz, dùng 6 tháng vẫn ổn.', + 'Pin còn 90% sau 1 năm.', + 'Sạc từ 0 lên 100% trong 1 tiếng.', + 'Có 2 điểm trừ: nóng và nặng.', + '1 lượt sạc dùng 2 ngày.', + 'Shop hỗ trợ 24/7.', + 'Giao ngày 28/10 đúng hẹn.', + 'Máy dùng được cho 2 người.', + 'Pin trung bình 2 ngày.', + 'Camera 50MP chụp đẹp, dùng 2 năm chưa lỗi.', + 'Pin dùng được 2/3 ngày.', + 'Pin tụt 10% sau một giờ.', + 'Màn hình có 1000 điểm ảnh bị lỗi.', + 'Máy hỗ trợ 2 sao lưu tự động.', + 'Người mua cho biết pin tụt 10% sau một giờ.', + 'Khách khen pin còn 90% sau một năm.', + 'Màn hình có 2 điểm chết.', + 'Có 2 điểm sáng: pin và camera.', + 'Giao hàng ngày 5/10 đúng hẹn.', + 'Pin dùng được 3/5 ngày.', + ]) { + assert.equal(hasReviewScoreClaim(ok), false, ok); + } + // Scores, shares and counts (vi and en) are claims: never stored or shown. + for (const claim of [ + 'Đạt 4.8/5.', + '4,8 trên 5 điểm.', + 'Được chấm 5 sao.', + '5★ từ người mua.', + '90% khen pin.', + 'Có 120 đánh giá tích cực.', + '30 người mua phàn nàn.', + 'Điểm trung bình cao.', + 'Điểm JReviews có trọng số tốt.', + 'Rated 4.7 out of 5.', + '120 reviews praise the battery.', + 'Weighted score is high.', + 'Đánh giá trung bình 4.8.', + 'Điểm 4,8.', + 'Rated 4.7.', + 'Score of 4.7.', + 'Average of 4.7.', + '80 phần trăm người mua khen pin.', + '30 khách hàng phàn nàn.', + 'Trong 120 bài đánh giá, nhiều người khen.', + '50 phản hồi tích cực.', + 'Hầu hết đều cho năm sao.', + 'Most buyers gave five stars.', + '4.8 🌟.', + 'Đạt 9/10.', + 'Có 30 người khen pin.', + '95% các khách hàng hài lòng.', + 'Được đánh giá 4.8.', + 'Chấm 4/5.', + '85/100 điểm.', + 'rating 4.8', + 'điểm 9', + 'hơn trăm người khen pin.', + 'Hàng nghìn đánh giá tốt.', + 'Hundreds of buyers like it.', + '95 percent of buyers are satisfied.', + '95 phần trăm người mua hài lòng.', + '4.8⭐', + '4​.8/5', + ]) { + assert.equal(hasReviewScoreClaim(claim), true, claim); + assert.equal(scopeLlmReviewSummary(claim), null, claim); + } + assert.equal( + scopeLlmReviewSummary(`${LLM_REVIEW_SUMMARY_PREFIX}Đạt 4.8/5.`), + null, + 'a scoped row is checked too' + ); + for (const empty of [ + null, + undefined, + '', + ' ', + 42, + LLM_REVIEW_SUMMARY_PREFIX, + ]) { + assert.equal(scopeLlmReviewSummary(empty), null); + } + const long = scopeLlmReviewSummary('Máy dùng tốt. '.repeat(100))!; + assert.ok(long.length <= 600 && long.endsWith('…')); + const emoji = scopeLlmReviewSummary('😀'.repeat(400))!; + // A pair cut in half upstream: the lone half is dropped. + const loneHigh = scopeLlmReviewSummary( + `${LLM_REVIEW_SUMMARY_PREFIX}a${'😀'.repeat(10)}${String.fromCharCode(0xd83d)}` + )!; + const lastUnit = loneHigh.charCodeAt(loneHigh.length - 1); + assert.ok( + !(lastUnit >= 0xd800 && lastUnit <= 0xdbff), + 'a cut-off pair is dropped' + ); + const beforeEllipsis = emoji.charCodeAt(emoji.length - 2); + assert.ok(emoji.length <= 600 && emoji.endsWith('…')); + assert.ok( + !(beforeEllipsis >= 0xd800 && beforeEllipsis <= 0xdbff), + 'no lone surrogate' + ); + assert.equal( + scopeLlmReviewSummary( + `${LLM_REVIEW_SUMMARY_PREFIX}${LLM_REVIEW_SUMMARY_PREFIX}Pin tốt.` + ), + `${LLM_REVIEW_SUMMARY_PREFIX}Pin tốt.`, + 'repeated prefixes collapse to one' + ); +} + function main(): void { console.log('🧪 Running @repo/shared-data unit tests...'); testVersionAndZeroStrapiExports(); @@ -1285,6 +1411,8 @@ function main(): void { ); testCategorySpecTemplates(); console.log(' ✅ [9/9] Category spec templates (DOS/Rate#1142) verified'); + testLlmReviewSummaryScope(); + console.log(' ✅ [10/10] LLM review summary scope (DOS/Rate#1244) verified'); console.log('🎉 All @repo/shared-data tests passed!'); } diff --git a/packages/shared-data/src/rules/index.ts b/packages/shared-data/src/rules/index.ts index 5c111d6f3..ed684a623 100644 --- a/packages/shared-data/src/rules/index.ts +++ b/packages/shared-data/src/rules/index.ts @@ -10,3 +10,4 @@ export * from './offer-plausibility.js'; export * from './listing-price-floor.js'; export * from './spec-template.js'; export * from './model-key.js'; +export * from './review-summary.js'; diff --git a/packages/shared-data/src/rules/review-summary.ts b/packages/shared-data/src/rules/review-summary.ts new file mode 100644 index 000000000..018a0b069 --- /dev/null +++ b/packages/shared-data/src/rules/review-summary.ts @@ -0,0 +1,131 @@ +/** + * Free-text review summaries written by the LLM pass of the review analyzer + * (DOS/Rate#1244). Such a summary covers one listing's latest batch, not the + * product, so: + * - it is stored and shown after a fixed scope prefix, and + * - a summary that quotes a score, a star rating, a percentage or a count is + * never stored or shown: those are batch numbers, not the product's. + * Shared by the importer (before storing) and the web reader (for rows stored + * before this rule, which are not rewritten). + */ + +/** The scope every free-text summary is stored and shown with. */ +const LLM_REVIEW_SUMMARY_PREFIX = + 'Theo các đánh giá của một nơi bán trong lần phân tích gần nhất: '; + +const MAX_LLM_REVIEW_SUMMARY_LENGTH = 600; + +// A number next to a score, rating or count unit (vi and en). Spec numbers +// (5000mAh, 65W, 120Hz), durations and "điểm cộng/trừ" (pros/cons) are not. +const NUMBER_THEN_UNIT = + /\d+(?:[.,]\d+)?\s*(?:★|⭐|🌟|☆|sao\b(?!\s*(?:lưu|chép))|điểm(?!\s*(?:cộng|trừ|ảnh|chết|sáng|yếu|mạnh|nhấn|đáng|nổi|nóng|đen|lỗi))|stars?\b|points?\b|pts?\b|(?:bài\s*)?đánh\s*giá|bình\s*luận|nhận\s*xét|phản\s*hồi|lượt\s*(?:đánh\s*giá|mua|bán)|người\s*(?:mua|dùng|đánh\s*giá|khen|chê|phàn\s*nàn|hài\s*lòng|cho|nói|thấy|đều)|khách|reviews?\b|ratings?\b|comments?\b|feedbacks?\b|buyers?\b|customers?\b|users?\b|reviewers?\b)/iu; +// A score word followed by a number: "điểm 4,8", "đánh giá trung bình 4.8", "rated 4.7". +const UNIT_THEN_NUMBER = + /(?:điểm(?!\s*(?:cộng|trừ|ảnh|chết|sáng|yếu|mạnh|nhấn|đáng|nổi|nóng|đen|lỗi))|đánh\s*giá(?:\s*trung\s*bình)?|rated|rating|score|average)\s*(?:là|đạt|of|is|:)?\s*\d+(?:[.,]\d+)?/iu; +// A share of buyers: "90% người mua", "80 phần trăm các khách hàng", "90% khen". +// A percentage elsewhere in a sentence about buyers (battery 90%) is not one. +const SHARE = + /\d+(?:[.,]\d+)?\s*(?:%|phần\s*trăm|percent)\s*(?:of\s*)?(?:the\s*|các\s*|số\s*)?(?:khen|chê|hài\s*lòng|phàn\s*nàn|praise|complain|recommend|satisf|người|khách|buyers?|customers?|users?|reviewers?|reviews?|đánh\s*giá)/iu; +// Star ratings in words: "năm sao", "five stars". +const WORD_STARS = + /(?:một|hai|ba|bốn|năm|one|two|three|four|five)\s*(?:sao\b(?!\s*(?:lưu|chép))|stars?\b)/iu; +// Counts in words: "hơn trăm người", "hàng nghìn đánh giá", "hundreds of buyers". +const WORD_COUNT = + /(?:chục|trăm|nghìn|ngàn|dozens?|hundreds?|thousands?)\s*(?:of\s*)?(?:người|khách|lượt|bài|đánh\s*giá|bình\s*luận|phản\s*hồi|reviews?\b|buyers?\b|customers?\b|users?\b)/iu; +// Score words that quote the analyzer's or the platform's numbers. +const SCORE_WORDS = + /jreviews|bayes|trọng\s*số|weighted|điểm\s*(?:số|trung\s*bình|tổng|đánh\s*giá|chấm)|average\s*rating|rating\s*score|overall\s*score/iu; +// "4.8/5", "đạt 9/10", "85/100 điểm": a fraction is a score only with a decimal +// numerator, a score word before it or a score unit after it, so dates (5/10) +// and durations (3/5 ngày) are not. +const FRACTION = String.raw`\d{1,3}(?:[.,]\d+)?\s*\/\s*(?:5|10|100)(?![\d/])`; +const SLASH_SCORE = new RegExp( + String.raw`(? maxUnits) break; + out += ch; + } + return out; +} + +// A surrogate half without its pair, as left by a UTF-16 cut upstream. +const LONE_SURROGATE = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? room ? `${clipCodePoints(body, room - 1).trimEnd()}…` : body; + return `${LLM_REVIEW_SUMMARY_PREFIX}${clipped}`; +} + +// One export list: the rules test reads quoted text in export statements as import paths. +export { + LLM_REVIEW_SUMMARY_PREFIX, + hasReviewScoreClaim, + isScopedLlmReviewSummary, + scopeLlmReviewSummary, +}; From 71de13f15c70a5635892890595a3447995bfa0f4 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:46:41 +0700 Subject: [PATCH 32/69] fix(web): merchant pages state sold-out listings and missing reviews (#1261) * fix(web): merchant pages state sold-out listings and missing review data A sold-out listing is marked as such, listed last, gets no discount verdict and leaves the store's price range. A store with no analysed reviews shows "not enough data" instead of a review risk number, and the directory neither files it under low review risk nor sorts it as the lowest. Listings from stores that publish no sold count drop "Sold: 0", and the unclustered label reads in plain words. * fix(web): no review risk number on home cards without reviews The home merchant cards and the directory's shared table now show "not enough data" for stores with no analysed reviews, the directory price range leaves sold-out listings out, and the OpenAPI seeding tier text says such stores match neither tier. Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/merchant-profile-links.spec.ts | 4 +- apps/web/locales/cs.json | 17 ++- apps/web/locales/en.json | 17 ++- apps/web/locales/vi.json | 17 ++- apps/web/package.json | 2 +- .../src/app/[locale]/merchants/[id]/page.tsx | 109 +++++++++++++----- apps/web/src/app/[locale]/page.tsx | 37 ++++-- .../merchants/MerchantDirectoryExplorer.tsx | 41 ++++--- .../merchants/MerchantShareAuditButton.tsx | 20 ++-- .../__tests__/merchant-profile-reads.test.ts | 65 +++++++++++ .../__tests__/merchant-review-risk.test.ts | 103 +++++++++++++++++ apps/web/src/lib/__tests__/sold-count.test.ts | 10 +- apps/web/src/lib/merchant-directory.ts | 21 +++- apps/web/src/lib/merchant-risk-tone.ts | 2 + apps/web/src/lib/rate-api.ts | 48 ++++++-- apps/web/src/lib/rate-domain-core.ts | 17 +++ apps/web/src/lib/sold-count.ts | 9 ++ apps/web/src/server/rate/openapi.ts | 2 +- 18 files changed, 452 insertions(+), 89 deletions(-) create mode 100644 apps/web/src/lib/__tests__/merchant-review-risk.test.ts diff --git a/apps/web/e2e/merchant-profile-links.spec.ts b/apps/web/e2e/merchant-profile-links.spec.ts index 589177628..054b0d646 100644 --- a/apps/web/e2e/merchant-profile-links.spec.ts +++ b/apps/web/e2e/merchant-profile-links.spec.ts @@ -19,7 +19,9 @@ test('a merchant profile links only products that have a page', async ({ page.getByRole('table').getByText('E2E Draft Phone chính hãng') ).toBeVisible(); await expect( - page.getByText(/Chưa gom cụm|Unclustered/).first() + page + .getByText(/Chưa ghép với dòng máy nào|Not matched to a model yet/) + .first() ).toBeVisible(); await expect(page.locator('a[href*="e2e-draft-phone"]')).toHaveCount(0); await expect(page.getByText('E2E Draft Phone', { exact: true })).toHaveCount( diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index e01271e76..5dfd13092 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -858,7 +858,6 @@ "dosafeAlert": "Nahlášená upozornění na podvod", "authenticLabel": "Autentické:", "filteredSpamCount": "{count, plural, one {# falešná nebo spamová recenze odfiltrována} few {# falešné nebo spamové recenze odfiltrovány} other {# falešných nebo spamových recenzí odfiltrováno}}", - "noAbnormalReviews": "Žádné podezřelé vzorce recenzí", "priceRangeLabel": "Referenční cenové rozpětí:", "radarGenuineBadge": "{count} nabídek pod mediánem (-{pct}%)", "radarFakeBadge": "{count} varování na falešnou slevu", @@ -936,7 +935,7 @@ "itemMetaLine": "ID položky:", "soldLabel": "Prodáno:", "lowestMarketPriceLabel": "Nejnižší tržní cena:", - "unclusteredLabel": "Nepřiřazeno", + "unclusteredLabel": "Zatím nepřiřazeno k modelu", "historicalLowObs": "Historické minimum: {price} ₫ ({count} měření)", "collectingHistoryBadge": "Sběr historie cen", "viewReviewsAndPricesBtn": "★ Recenze a Ceny", @@ -961,7 +960,15 @@ "riskReviewLabel": "Riziko recenzí", "trustBadge": "Důvěra: {score}/100", "scamRiskBadge": "Riziko podvodu: {score}/100", - "reviewRiskBadge": "Riziko recenzí: {score}/100" + "reviewRiskBadge": "Riziko recenzí: {score}/100", + "reviewRiskNoData": "Riziko recenzí: nedostatek dat", + "reviewRiskNoDataShort": "Nedostatek dat", + "profileMetaDescNoReviews": "Profil prodejce {name}: riziko podvodu DOSafe ({dosafe}/100) a historie cen {count} produktů na Rate.vn. Zatím žádné recenze k analýze.", + "mdSeedingLineNoReviews": "- **Riziko recenzí:** nedostatek dat (zatím žádné recenze k analýze)", + "mdAuthenticLineNoReviews": "- **Podíl skutečných recenzí:** zatím žádné recenze", + "listingUnavailableBadge": "Není skladem", + "listingUnavailableTitle": "Obchod jej hlásí jako vyprodaný nebo ukončený. Cena je naposledy zjištěná, ne aktuální.", + "listingUnavailableAudit": "Není skladem, sleva se neposuzuje" }, "productDetail": { "notFoundTitle": "Produkt nenalezen | Rate.vn", @@ -1228,7 +1235,9 @@ "reviewRiskLabel": "Riziko recenzí", "officialMallBadge": "Official Mall", "authenticBadge": "Autentická recenze", - "starsAria": "{count} z 5 hvězdiček" + "starsAria": "{count} z 5 hvězdiček", + "reviewRiskNoData": "Nedostatek dat", + "reviewRiskNoReviews": "Zatím žádné recenze k analýze" }, "developers": { "seoTitle": "Dokumentace REST API v1 pro vývojáře | Rate.vn", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 7c5c29dc0..e2dde8925 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -858,7 +858,6 @@ "dosafeAlert": "Scam alerts reported", "authenticLabel": "Authentic:", "filteredSpamCount": "{count, plural, one {# fake or spam review filtered} other {# fake or spam reviews filtered}}", - "noAbnormalReviews": "No abnormal review patterns detected", "priceRangeLabel": "Reference price range:", "radarGenuineBadge": "{count} below-median deals (-{pct}%)", "radarFakeBadge": "{count} fake-sale markup alerts", @@ -936,7 +935,7 @@ "itemMetaLine": "Item ID:", "soldLabel": "Sold:", "lowestMarketPriceLabel": "Market lowest price:", - "unclusteredLabel": "Unclustered", + "unclusteredLabel": "Not matched to a model yet", "historicalLowObs": "All-time low: {price} ₫ ({count} points)", "collectingHistoryBadge": "Collecting price history", "viewReviewsAndPricesBtn": "★ Reviews & price", @@ -961,7 +960,15 @@ "riskReviewLabel": "Review risk", "trustBadge": "Trust: {score}/100", "scamRiskBadge": "Scam risk: {score}/100", - "reviewRiskBadge": "Review risk: {score}/100" + "reviewRiskBadge": "Review risk: {score}/100", + "reviewRiskNoData": "Review risk: not enough data", + "reviewRiskNoDataShort": "Not enough data", + "profileMetaDescNoReviews": "Seller profile for {name}: DOSafe scam risk ({dosafe}/100) and price history across {count} products on Rate.vn. No reviews to analyse yet.", + "mdSeedingLineNoReviews": "- **Review risk:** not enough data (no reviews to analyse yet)", + "mdAuthenticLineNoReviews": "- **Genuine review share:** no reviews yet", + "listingUnavailableBadge": "Out of stock", + "listingUnavailableTitle": "The store reports it sold out or discontinued. The price is the last one seen, not a current price.", + "listingUnavailableAudit": "Out of stock, no deal check" }, "productDetail": { "notFoundTitle": "Product not found | Rate.vn", @@ -1228,7 +1235,9 @@ "reviewRiskLabel": "Review risk", "officialMallBadge": "Official Mall", "authenticBadge": "Authentic review", - "starsAria": "{count} out of 5 stars" + "starsAria": "{count} out of 5 stars", + "reviewRiskNoData": "Not enough data", + "reviewRiskNoReviews": "No reviews to analyse yet" }, "developers": { "seoTitle": "REST API v1 docs for developers | Rate.vn", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 897372ad0..c63839f79 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -858,7 +858,6 @@ "dosafeAlert": "Có cảnh báo lừa đảo", "authenticLabel": "Thực chất:", "filteredSpamCount": "{count} đánh giá ảo hoặc spam đã lọc", - "noAbnormalReviews": "Chưa phát hiện đánh giá bất thường", "priceRangeLabel": "Biên độ giá tham chiếu:", "radarGenuineBadge": "{count} tin bán dưới giá trung vị (-{pct}%)", "radarFakeBadge": "{count} Cảnh báo kê khống giá gốc", @@ -936,7 +935,7 @@ "itemMetaLine": "Mã sàn:", "soldLabel": "Đã bán:", "lowestMarketPriceLabel": "Giá thấp nhất thị trường:", - "unclusteredLabel": "Chưa gom cụm", + "unclusteredLabel": "Chưa ghép với dòng máy nào", "historicalLowObs": "Đáy lịch sử: {price} ₫ ({count} mốc đo)", "collectingHistoryBadge": "Đang thu thập lịch sử giá", "viewReviewsAndPricesBtn": "★ Xem đánh giá và giá", @@ -961,7 +960,15 @@ "riskReviewLabel": "Rủi ro đánh giá", "trustBadge": "Tin cậy: {score}/100", "scamRiskBadge": "Rủi ro lừa đảo: {score}/100", - "reviewRiskBadge": "Rủi ro đánh giá: {score}/100" + "reviewRiskBadge": "Rủi ro đánh giá: {score}/100", + "reviewRiskNoData": "Rủi ro đánh giá: chưa đủ dữ liệu", + "reviewRiskNoDataShort": "Chưa đủ dữ liệu", + "profileMetaDescNoReviews": "Hồ sơ gian hàng {name}: rủi ro lừa đảo DOSafe ({dosafe}/100) và lịch sử giá {count} sản phẩm trên Rate.vn. Chưa có đánh giá để phân tích.", + "mdSeedingLineNoReviews": "- **Rủi ro đánh giá:** chưa đủ dữ liệu (chưa có đánh giá để phân tích)", + "mdAuthenticLineNoReviews": "- **Tỷ lệ đánh giá thật:** chưa có đánh giá", + "listingUnavailableBadge": "Hết hàng", + "listingUnavailableTitle": "Gian hàng báo hết hàng hoặc ngừng bán. Giá là giá ghi nhận lần cuối, không phải giá đang bán.", + "listingUnavailableAudit": "Hết hàng, không xét khuyến mãi" }, "productDetail": { "notFoundTitle": "Không tìm thấy sản phẩm | Rate.vn", @@ -1228,7 +1235,9 @@ "reviewRiskLabel": "Rủi ro đánh giá", "officialMallBadge": "Official Mall", "authenticBadge": "Đánh giá thực chất", - "starsAria": "{count} trên 5 sao" + "starsAria": "{count} trên 5 sao", + "reviewRiskNoData": "Chưa đủ dữ liệu", + "reviewRiskNoReviews": "Chưa có đánh giá để phân tích" }, "developers": { "seoTitle": "Tài liệu REST API v1 cho nhà phát triển | Rate.vn", diff --git a/apps/web/package.json b/apps/web/package.json index f20450938..ced0ea330 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/merchants/[id]/page.tsx b/apps/web/src/app/[locale]/merchants/[id]/page.tsx index 0dd4a3692..db57f742f 100644 --- a/apps/web/src/app/[locale]/merchants/[id]/page.tsx +++ b/apps/web/src/app/[locale]/merchants/[id]/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from 'next'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { notFound } from 'next/navigation'; import { discountVerdictCopy } from '@/lib/discount-verdict-copy'; -import { formatSoldCount } from '@/lib/sold-count'; +import { formatSoldCount, hasSoldCount } from '@/lib/sold-count'; import AnalyzerText from '@/components/reviews/AnalyzerText'; import { Link } from '@/lib/navigation'; import { getCategoryRouteHref, resolveProductImageUrl } from '@/lib/rate-api'; @@ -31,7 +31,11 @@ import { BsStarFill, } from 'react-icons/bs'; import { isPendingReview } from '@/lib/review-display'; -import { merchantRiskTone } from '@/lib/merchant-risk-tone'; +import { + hasReviewRiskEvidence, + merchantRiskTone, +} from '@/lib/merchant-risk-tone'; +import { isOfferUnavailable } from '@/lib/product-market-stats'; import ContentText from '@/components/elementary/ContentText'; import { contentLang } from '@/lib/content-lang'; import ClientMessages from '@/components/providers/ClientMessages'; @@ -72,13 +76,20 @@ export async function generateMetadata({ name: merchant.name, score: merchant.trust_score, }); + // No review risk number without analysed reviews (DOS/Rate#1249). const description = fitDescription( - t('profileMetaDesc', { - name: merchant.name, - dosafe: merchant.dosafe_risk_score, - seeding: merchant.review_risk_score, - count: merchant.product_count, - }) + hasReviewRiskEvidence(merchant.review_risk_breakdown) + ? t('profileMetaDesc', { + name: merchant.name, + dosafe: merchant.dosafe_risk_score, + seeding: merchant.review_risk_score, + count: merchant.product_count, + }) + : t('profileMetaDescNoReviews', { + name: merchant.name, + dosafe: merchant.dosafe_risk_score, + count: merchant.product_count, + }) ); return { @@ -470,19 +481,27 @@ export default async function MerchantProfilePage({ {t('layer2Title')}
- - {t('reviewRiskBadge', { - score: merchant.review_risk_score, - })} - + {hasReviewRiskEvidence(brk) ? ( + + {t('reviewRiskBadge', { + score: merchant.review_risk_score, + })} + + ) : ( + // No analysed reviews: a number would be a guess + // (DOS/Rate#1249). + + {t('reviewRiskNoData')} + + )}

{t('itemMetaLine')}{' '} {item.platform_item_id} •{' '} - {t('soldLabel')}{' '} - {formatSoldCount( - item.historical_sold, - item.historical_sold_display, - locale - )}{' '} - • ★ {item.rating_star.toFixed(1)} ( + {/* Many retail sites publish no sold + count: no "Sold: 0" (DOS/Rate#1252). */} + {hasSoldCount(item.historical_sold) && ( + <> + {t('soldLabel')}{' '} + {formatSoldCount( + item.historical_sold, + item.historical_sold_display, + locale + )}{' '} + •{' '} + + )} + ★ {item.rating_star.toFixed(1)} ( {item.rating_count})

@@ -950,17 +979,33 @@ export default async function MerchantProfilePage({ )} -
+
{priceText} ₫
- {origText && ( + {origText && !unavailable && (
{origText} ₫
)} + {unavailable && ( + + {t('listingUnavailableBadge')} + + )} - {da ? ( + {unavailable ? ( + + {t('listingUnavailableAudit')} + + ) : da ? (
{tHome('reviewRiskLabel')}
-
- {merchant.review_risk_score}/100 -
-
- {tHome('flaggedReviewsRatio', { - flagged: flaggedRevCount, - total: totalRevAnalyzed, - })} -
+ {/* No analysed reviews: no number (DOS/Rate#1249). */} + {hasReviewRiskEvidence(breakdown) ? ( + <> +
+ {merchant.review_risk_score}/100 +
+
+ {tHome('flaggedReviewsRatio', { + flagged: flaggedRevCount, + total: totalRevAnalyzed, + })} +
+ + ) : ( + <> +
{tHome('reviewRiskNoData')}
+
+ {tHome('reviewRiskNoReviews')} +
+ + )}
{tHome('ratingsAndProductsLabel')}
diff --git a/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx b/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx index f18f17f64..1f10ff69a 100644 --- a/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx +++ b/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx @@ -24,6 +24,7 @@ import { type MerchantSeedingTier, type MerchantSortOption, } from '@/lib/merchant-directory'; +import { hasReviewRiskEvidence } from '@/lib/merchant-risk-tone'; import { selectMerchantRadar } from '@/lib/deal-radar'; import { useDealRadarPayload } from '@/components/radar/use-deal-radar'; import { useLocale, useTranslations } from 'next-intl'; @@ -265,7 +266,8 @@ export default function MerchantDirectoryExplorer({ dosafeZero: t('dosafeZero'), dosafeAlert: t('dosafeAlert'), authenticLabel: t('authenticLabel'), - noAbnormalReviews: t('noAbnormalReviews'), + noReviewsToAnalyse: t('layer2NoReviews'), + reviewRiskNoData: t('reviewRiskNoDataShort'), riskScamLabel: t('riskScamLabel'), riskReviewLabel: t('riskReviewLabel'), priceRangeLabel: t('priceRangeLabel'), @@ -350,7 +352,9 @@ export default function MerchantDirectoryExplorer({ sanitizeMerchantAuditText(m.platform, 'shopee', 32).toUpperCase(), m.is_official ? 'Yes' : 'No', `${sanitizeMerchantScore100(m.trust_score)}/100`, - `${sanitizeMerchantScore100(m.review_risk_score)}/100`, + hasReviewRiskEvidence(m.review_risk_breakdown) + ? `${sanitizeMerchantScore100(m.review_risk_score)}/100` + : labels.reviewRiskNoData, String(sanitizeMerchantCount(rStats.genuineDealCount)), String(sanitizeMerchantCount(rStats.fakeTrapCount)), String(sanitizeMerchantCount(m.review_risk_breakdown?.authentic_count)), @@ -909,17 +913,26 @@ export default function MerchantDirectoryExplorer({
) : ( - labels.noAbnormalReviews + labels.noReviewsToAnalyse )}
diff --git a/apps/web/src/components/merchants/MerchantShareAuditButton.tsx b/apps/web/src/components/merchants/MerchantShareAuditButton.tsx index 884fb3b10..a72c35669 100644 --- a/apps/web/src/components/merchants/MerchantShareAuditButton.tsx +++ b/apps/web/src/components/merchants/MerchantShareAuditButton.tsx @@ -140,17 +140,23 @@ export default function MerchantShareAuditButton({ label: safeTrustLevelLabel, }), t('mdDosafeLine', { score: safeDosafeRisk }), - t('mdSeedingLine', { score: safeSeedingRisk }), + // No analysed reviews: neither a risk number nor "100% genuine" + // (DOS/Rate#1249). + safeTotalReviews > 0 + ? t('mdSeedingLine', { score: safeSeedingRisk }) + : t('mdSeedingLineNoReviews'), t('mdRadarLine', { genuine: safeGenuineDeals, traps: safeFakeTraps, }), - t('mdAuthenticLine', { - pct: authenticPct, - auth: safeAuthenticReviews, - total: safeTotalReviews, - flagged: safeFlaggedReviews, - }), + safeTotalReviews > 0 + ? t('mdAuthenticLine', { + pct: authenticPct, + auth: safeAuthenticReviews, + total: safeTotalReviews, + flagged: safeFlaggedReviews, + }) + : t('mdAuthenticLineNoReviews'), t('mdProductsLine', { count: safeProductCount }), t('mdUrlLine', { url: window.location.href }), ]; diff --git a/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts b/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts index ca1897a88..5307bdd94 100644 --- a/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts +++ b/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts @@ -399,6 +399,71 @@ async function main() { ); } + // 5d. A sold-out listing carries its availability, is listed after the + // buyable ones, gets no verdict and is out of the store's price range + // (DOS/Rate#1248). + { + installFetch({ + listings: [ + { + ...listingRow, + availability: 'out_of_stock', + price_current: 2_694_000, + }, + { + ...listingRow, + id: SECOND_LISTING_ID, + price_current: 9_500_000, + availability: 'in_stock', + }, + { + ...listingRow, + id: UNCLUSTERED_LISTING_ID, + price_current: 20_000_000, + cluster_id: null, + availability: null, + }, + ], + }); + const base = await getMerchantProfileBase(MERCHANT_ID); + assert.ok(base); + assert.deepEqual( + base.profile.listings.map((l) => [l.id, l.availability]), + [ + [SECOND_LISTING_ID, 'in_stock'], + [UNCLUSTERED_LISTING_ID, null], + [LISTING_ID, 'out_of_stock'], + ] + ); + assert.equal(base.profile.min_price, 9_500_000); + const profile = await attachMerchantDiscountAnalysis(base, { + offers: async () => [], + priceHistory: async () => ({ + ...emptyHistory, + byOffer: { + [LISTING_ID]: { ...analysis, verdict: 'genuine_deal' as const }, + [SECOND_LISTING_ID]: { + ...analysis, + product_id: SECOND_LISTING_ID, + price_current: 9_500_000, + }, + }, + }), + }); + const soldOut = profile.listings.find((l) => l.id === LISTING_ID); + assert.equal(soldOut?.discount_analysis, null, 'no verdict when sold out'); + assert.equal( + profile.listings.find((l) => l.id === SECOND_LISTING_ID) + ?.discount_analysis?.verdict, + 'fake_discount', + 'an in-stock listing keeps its verdict' + ); + assert.equal(soldOut?.price_current, 2_694_000); + assert.equal(profile.listings.at(-1)?.id, LISTING_ID); + assert.equal(profile.min_price, 9_500_000); + assert.equal(profile.max_price, 20_000_000); + } + // 6. An unknown merchant is null, even when the early listings read fails. { installFetch({ merchantFound: () => false }); diff --git a/apps/web/src/lib/__tests__/merchant-review-risk.test.ts b/apps/web/src/lib/__tests__/merchant-review-risk.test.ts new file mode 100644 index 000000000..49a946909 --- /dev/null +++ b/apps/web/src/lib/__tests__/merchant-review-risk.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { filterMerchantDirectory } from '../merchant-directory'; +import { hasReviewRiskEvidence } from '../merchant-risk-tone'; +import { filterAndSortMerchants, type MerchantSummary } from '../rate-domain'; + +// A store with no analysed reviews has no measured review risk: no number, +// no low-risk filter match, and not sorted as the lowest risk (DOS/Rate#1249). + +function merchant( + id: string, + reviewRisk: number, + totalReviews: number, + trust = 80 +): MerchantSummary { + return { + id, + platform: 'shopee', + platform_shop_id: id, + name: `Shop ${id}`, + slug: id, + is_official: false, + trust_score: trust, + risk_score: 100 - trust, + dosafe_risk_score: 0, + review_risk_score: reviewRisk, + product_count: 1, + follower_count: 0, + review_risk_breakdown: { + total_reviews: totalReviews, + authentic_count: totalReviews, + coin_spam_count: 0, + seeding_fake_count: 0, + sabotage_or_bombing_count: 0, + suspicious_ratio_pct: 0, + }, + } as unknown as MerchantSummary; +} + +function main() { + console.warn('🧪 merchant review risk suite (DOS/Rate#1249)'); + + assert.equal(hasReviewRiskEvidence({ total_reviews: 3 }), true); + assert.equal(hasReviewRiskEvidence({ total_reviews: 0 }), false); + assert.equal(hasReviewRiskEvidence({ total_reviews: Number.NaN }), false); + assert.equal(hasReviewRiskEvidence(null), false); + assert.equal(hasReviewRiskEvidence(undefined), false); + + // FPT Shop case: a 4/100 baseline with no reviews against measured stores. + const noReviews = merchant('none', 4, 0, 96); + const measuredLow = merchant('low', 10, 20); + const measuredHigh = merchant('high', 60, 20); + const all = [noReviews, measuredHigh, measuredLow]; + + const sorted = filterMerchantDirectory(all, { + query: '', + platform: 'all', + officialOnly: false, + riskTier: 'all', + seedingTier: 'all', + sortBy: 'seeding_asc', + }); + assert.deepEqual( + sorted.map((m) => m.id), + ['low', 'high', 'none'], + 'stores with no reviews sort after every measured store' + ); + + const low = filterMerchantDirectory(all, { + query: '', + platform: 'all', + officialOnly: false, + riskTier: 'all', + seedingTier: 'low', + sortBy: 'trust_desc', + }); + assert.deepEqual( + low.map((m) => m.id), + ['low'], + 'no reviews is not low review risk' + ); + const elevated = filterMerchantDirectory(all, { + query: '', + platform: 'all', + officialOnly: false, + riskTier: 'all', + seedingTier: 'elevated', + sortBy: 'trust_desc', + }); + assert.deepEqual( + elevated.map((m) => m.id), + ['high'] + ); + + // The API list sort agrees with the directory. + assert.deepEqual( + filterAndSortMerchants(all, { sort: 'seeding_asc' }).map((m) => m.id), + ['low', 'high', 'none'] + ); + + console.warn(' ✅ merchant review risk suite passed'); +} + +main(); diff --git a/apps/web/src/lib/__tests__/sold-count.test.ts b/apps/web/src/lib/__tests__/sold-count.test.ts index dbea94d28..7a2ed121f 100644 --- a/apps/web/src/lib/__tests__/sold-count.test.ts +++ b/apps/web/src/lib/__tests__/sold-count.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { formatSoldCount } from '../sold-count'; +import { formatSoldCount, hasSoldCount } from '../sold-count'; assert.equal( formatSoldCount(23873, null, 'vi'), @@ -54,4 +54,12 @@ assert.equal( '23.873', 'Invalid locale tags must safely fall back to vi-VN (#848)' ); +// A store that publishes no sold count shows no "Sold: 0" (DOS/Rate#1252). +assert.equal(hasSoldCount(0), false); +assert.equal(hasSoldCount(null), false); +assert.equal(hasSoldCount(0.4), false); +assert.equal(hasSoldCount(Number.NaN), false); +assert.equal(hasSoldCount(-3), false); +assert.equal(hasSoldCount(1), true); +assert.equal(hasSoldCount('20000'), true); console.log('✅ sold-count display'); diff --git a/apps/web/src/lib/merchant-directory.ts b/apps/web/src/lib/merchant-directory.ts index c97ed0073..212aa1ab8 100644 --- a/apps/web/src/lib/merchant-directory.ts +++ b/apps/web/src/lib/merchant-directory.ts @@ -1,4 +1,5 @@ import type { MerchantSummary } from './rate-domain'; +import { hasReviewRiskEvidence } from './merchant-risk-tone'; // Filters, sort and paging of the /merchants directory, shared by the page, // its API (/api/v1/rate/merchants/directory) and the explorer. The page and @@ -320,13 +321,23 @@ export function filterMerchantDirectory( if (officialOnly && !isOfficialMerchantFlag(m.is_official)) return false; const trustScore = toSafeScore100(m.trust_score); const reviewRiskScore = toSafeScore100(m.review_risk_score); + // A store with no analysed reviews has no measured review risk: it + // matches neither review tier (DOS/Rate#1249). + const reviewRiskKnown = hasReviewRiskEvidence(m.review_risk_breakdown); if (riskTier === 'safe' && trustScore < 80) return false; if (riskTier === 'moderate' && (trustScore < 60 || trustScore >= 80)) { return false; } if (riskTier === 'warning' && trustScore >= 60) return false; - if (seedingTier === 'low' && reviewRiskScore > 15) return false; - if (seedingTier === 'elevated' && reviewRiskScore <= 15) return false; + if (seedingTier === 'low' && (!reviewRiskKnown || reviewRiskScore > 15)) { + return false; + } + if ( + seedingTier === 'elevated' && + (!reviewRiskKnown || reviewRiskScore <= 15) + ) { + return false; + } if (kw) { const hit = toCleanLower(m.name).includes(kw) || @@ -348,9 +359,13 @@ export function filterMerchantDirectory( case 'products_desc': return bProducts - aProducts || bTrust - aTrust; case 'seeding_asc': + // Measured scores first: no reviews is not the lowest risk (#1249). return ( + Number(!hasReviewRiskEvidence(a.review_risk_breakdown)) - + Number(!hasReviewRiskEvidence(b.review_risk_breakdown)) || toSafeScore100(a.review_risk_score) - - toSafeScore100(b.review_risk_score) || bTrust - aTrust + toSafeScore100(b.review_risk_score) || + bTrust - aTrust ); case 'followers_desc': return ( diff --git a/apps/web/src/lib/merchant-risk-tone.ts b/apps/web/src/lib/merchant-risk-tone.ts index 18885540b..83c8ae6ce 100644 --- a/apps/web/src/lib/merchant-risk-tone.ts +++ b/apps/web/src/lib/merchant-risk-tone.ts @@ -15,3 +15,5 @@ export function merchantRiskTone(score: unknown): MerchantRiskTone { if (n < MERCHANT_RISK_CLEAN_BELOW) return 'good'; return n < MERCHANT_RISK_ALERT_FROM ? 'warn' : 'bad'; } + +export { hasReviewRiskEvidence } from './rate-domain-core'; diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 3dc0b8d00..a65a34ba2 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -2291,6 +2291,8 @@ export interface MerchantProductListing { rating_count: number; url: string; image: string | null; + /** As the retailer last reported it; null when unknown (counts as in stock). */ + availability: OfferAvailability | null; master_product: { id: string; slug: string; @@ -2356,6 +2358,7 @@ interface RawProductRow { images?: string[] | null; url: string; cluster_id?: string | null; + availability?: string | null; } export function normalizeMasterProductRow( @@ -2825,7 +2828,7 @@ export async function getMerchantsList( (chunk, from, to) => supabase .from('products') - .select('id, merchant_id, price_current') + .select('id, merchant_id, price_current, availability') .in('merchant_id', chunk) .order('id') .range(from, to) @@ -2836,10 +2839,14 @@ export async function getMerchantsList( id: string; merchant_id: string; price_current: number | string; + availability?: unknown; }>) || []; const prodToMerchant = new Map(); const prodsByMerchant = new Map(); + // The price range is what the store sells now: sold-out listings keep + // their last price but count only as listings (DOS/Rate#1248). + const livePricesByMerchant = new Map(); const productIdsByMerchant = new Map(); for (const p of products) { @@ -2848,6 +2855,15 @@ export async function getMerchantsList( const price = Number(p.price_current || 0); if (Number.isFinite(price) && price > 0 && price <= MAX_VALID_VND_PRICE) { list.push(price); + if ( + !isOfferUnavailable({ + availability: toOfferAvailability(p.availability), + }) + ) { + const live = livePricesByMerchant.get(p.merchant_id) ?? []; + live.push(price); + livePricesByMerchant.set(p.merchant_id, live); + } } prodsByMerchant.set(p.merchant_id, list); @@ -2944,6 +2960,7 @@ export async function getMerchantsList( const results: MerchantSummary[] = merchants.map((m) => { const prices = prodsByMerchant.get(m.id) ?? []; + const livePrices = livePricesByMerchant.get(m.id) ?? []; const persistedBreakdown = extractPersistedBreakdown(m); const mReviews = mappedReviewsByMerchant.get(m.id) ?? []; const dynamicRisk = @@ -3011,8 +3028,8 @@ export async function getMerchantsList( risk_score: combinedRisk, trust_score: trustScore, product_count: prices.length, - min_price: prices.length > 0 ? Math.min(...prices) : null, - max_price: prices.length > 0 ? Math.max(...prices) : null, + min_price: livePrices.length > 0 ? Math.min(...livePrices) : null, + max_price: livePrices.length > 0 ? Math.max(...livePrices) : null, review_risk_breakdown: breakdown, updated_at: m.updated_at || m.created_at || null, }; @@ -3151,7 +3168,11 @@ export async function attachMerchantDiscountAnalysis( // and title stay as the profile read them. const listings = base.profile.listings .map((listing) => { - const analysis = discountByOfferId.get(listing.id) || null; + // A sold-out listing's last price is no deal and no historical low + // (DOS/Rate#1248): it gets no verdict. + const analysis = isOfferUnavailable(listing) + ? null + : discountByOfferId.get(listing.id) || null; return { ...listing, ...(analysis @@ -3163,9 +3184,14 @@ export async function attachMerchantDiscountAnalysis( discount_analysis: analysis, }; }) - // Stable: equal prices keep the profile's id order. - .sort((a, b) => a.price_current - b.price_current); + // Stable: equal prices keep the profile's id order. Sold-out last. + .sort( + (a, b) => + Number(isOfferUnavailable(a)) - Number(isOfferUnavailable(b)) || + a.price_current - b.price_current + ); const prices = listings + .filter((l) => !isOfferUnavailable(l)) .map((l) => l.price_current) .filter((p) => Number.isFinite(p) && p > 0 && p <= MAX_VALID_VND_PRICE); return { @@ -3202,7 +3228,7 @@ export async function getMerchantProfileBase( supabase .from('products') .select( - 'id, merchant_id, platform, platform_item_id, title, price_current, price_original, stock, historical_sold, historical_sold_display, rating_star, rating_count, images, url, cluster_id' + 'id, merchant_id, platform, platform_item_id, title, price_current, price_original, stock, historical_sold, historical_sold_display, rating_star, rating_count, images, url, cluster_id, availability' ) .eq('merchant_id', merchantId) .order('price_current', { ascending: true }) @@ -3382,10 +3408,16 @@ export async function getMerchantProfileBase( rating_count: toSafeNonNegativeVoteCount(p.rating_count, 100_000_000), url: sanitizeOfferUrl(p.url), image: firstImageUrl(p.images), + availability: toOfferAvailability(p.availability), master_product: masterInfo, discount_analysis: null, }; }); + // Sold-out listings keep their last price: listed after the buyable ones, + // as on the product page (DOS/Rate#1248). Stable, so price order stays. + listings.sort( + (a, b) => Number(isOfferUnavailable(a)) - Number(isOfferUnavailable(b)) + ); const prodById = new Map(listings.map((l) => [l.id, l])); const recentReviews: MerchantProfileDetail['recent_reviews'] = ( @@ -3496,7 +3528,9 @@ export async function getMerchantProfileBase( Math.max(baseRisk, dosafeRisk, effectiveReviewRisk, metaCombined) ); const trustScore = Math.max(0, 100 - combinedRisk); + // The store's price range is what it sells now: sold-out listings are out. const prices = listings + .filter((l) => !isOfferUnavailable(l)) .map((l) => l.price_current) .filter((p) => Number.isFinite(p) && p > 0 && p <= MAX_VALID_VND_PRICE); diff --git a/apps/web/src/lib/rate-domain-core.ts b/apps/web/src/lib/rate-domain-core.ts index e87462fb6..b6d2b7305 100644 --- a/apps/web/src/lib/rate-domain-core.ts +++ b/apps/web/src/lib/rate-domain-core.ts @@ -1344,7 +1344,11 @@ export function filterAndSortMerchants( safeNum(b.trust_score) - safeNum(a.trust_score) ); case 'seeding_asc': + // Measured scores first: no reviews is not the lowest risk + // (DOS/Rate#1249). return ( + Number(!hasReviewRiskEvidence(a.review_risk_breakdown)) - + Number(!hasReviewRiskEvidence(b.review_risk_breakdown)) || safeNum(a.review_risk_score, 100) - safeNum(b.review_risk_score, 100) || safeNum(b.trust_score) - safeNum(a.trust_score) @@ -1932,3 +1936,16 @@ export function extractProductKeywordFromUrl(rawUrl?: string | null): string { return ''; } } + +/** + * Whether a merchant's review risk score was measured on analysed reviews. + * With none, the score is only a platform baseline: it is shown as "not + * enough data", and is neither a low-risk filter match nor sorted as the + * lowest risk (DOS/Rate#1249). + */ +export function hasReviewRiskEvidence( + breakdown: { total_reviews?: unknown } | null | undefined +): boolean { + const total = Number(breakdown?.total_reviews); + return Number.isFinite(total) && total > 0; +} diff --git a/apps/web/src/lib/sold-count.ts b/apps/web/src/lib/sold-count.ts index eed6d0436..9e1290897 100644 --- a/apps/web/src/lib/sold-count.ts +++ b/apps/web/src/lib/sold-count.ts @@ -91,3 +91,12 @@ export function formatSoldCount( } return sold.toLocaleString(isVi ? 'vi-VN' : safeLocale); } + +/** + * Whether the store reported a sold count. Many retail sites publish none, + * and their 0 must not read as "nobody bought this" (DOS/Rate#1252). + */ +export function hasSoldCount(historicalSold: unknown): boolean { + const sold = parseDecimalSoldCount(historicalSold); + return Number.isFinite(sold) && sold >= 1 && sold <= MAX_DISPLAY_SOLD_COUNT; +} diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index 0596a184b..24b5e098c 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -1054,7 +1054,7 @@ export function buildRateOpenApiSpec( name: 'seeding', in: 'query', description: - 'AI seeding risk tier (`all`, `low` <= 15, `elevated` > 15)', + 'AI seeding risk tier (`all`, `low` <= 15, `elevated` > 15). Stores with no analysed reviews match neither tier.', schema: { type: 'string', enum: ['all', 'low', 'elevated'], From 08da8dce2cce493c029583f1e5d7e00c3e9d8b65 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:48:43 +0700 Subject: [PATCH 33/69] fix(web): leaderboard reads in vietnamese and drops the stray 10.0 (#1257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): leaderboard reads in vietnamese and drops the stray 10.0 The vi leaderboard showed "User 5.00/5.0", "Expert: chưa có", "Display", "Perf", "Cam/ANC" (on phones too), "Battery" and "Value", and its H1 read "Bảng xếp hạng thiết bị độc lập 10.0" (cs also in Title Case). The short labels are now Vietnamese, the camera label follows the category ("Camera", or "Chống ồn" for earbuds), the user and expert scores use locale strings, the "10.0" is gone in every locale, and the vi catalogue filter descriptions lose their English "Display:" / "Battery:" prefixes. Refs #1250 Refs #1251 * fix(web): earbuds leaderboard names the anc criterion in its tabs too Review: the earbuds tab showed "Chống ồn" on the cards but "Camera & quay chụp" in the criterion tabs, the criteria rows and the Markdown title. The long criterion label now follows the category as well ("Chống ồn & âm thanh" for earbuds). Refs #1250 Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 12 +++-- apps/web/locales/en.json | 10 ++-- apps/web/locales/vi.json | 34 ++++++++------ .../leaderboard/LeaderboardExplorer.tsx | 27 ++++++++--- .../__tests__/leaderboard-badge-copy.test.ts | 47 +++++++++++++++++++ 5 files changed, 102 insertions(+), 28 deletions(-) diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 5dfd13092..ec88c4a01 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -341,7 +341,7 @@ "metaTitle": "Žebříček technologií | Rate.vn", "metaDescription": "Nezávislý žebříček podle kategorií: hodnocení expertů a ověřených kupujících v 5 kritériích, bez reklam a affiliate odkazů.", "kicker": "Nezávislý žebříček zařízení", - "title": "Nezávislý Žebříček Zařízení 10.0", + "title": "Nezávislý žebříček zařízení", "subtitle": "Objektivní žebříček podle kategorií, který odděluje hodnocení ověřených kupujících, expertů a 5 kritérií od cen obchodů.", "home": "Domů", "breadcrumb": "Žebříček", @@ -408,16 +408,16 @@ "critScreen": "Displej a obraz", "critPerformance": "Výkon a teploty", "critCamera": "Fotoaparát a optika", + "critAnc": "Potlačení hluku a zvuk", "critBattery": "Baterie a nabíjení", "critValue": "Poměr cena / výkon", "shortDisplay": "Displej", "shortPerf": "Výkon", - "shortCamAnc": "Foto/ANC", "shortBattery": "Baterie", "shortValue": "Hodnota", "unratedShort": "nehodnoceno", "expertUnrated": "Expert: nehodnoceno", - "userUnrated": "User: nehodnoceno", + "userUnrated": "Uživatelé: nehodnoceno", "copiedLeaderboardMd": "Zkopírováno!", "copyLeaderboardMdBtn": "Kopírovat žebříček (MD)", "exportCsvBtn": "Exportovat CSV", @@ -429,7 +429,11 @@ "showdownsHeading": "Přímé porovnání nejlepších zařízení ve stejné kategorii", "openCompareMatrixBtn": "Otevřít porovnání →", "view1v1DeepBtn": "Zobrazit 1v1 duel", - "categoryTabsAria": "Vyberte kategorii" + "categoryTabsAria": "Vyberte kategorii", + "shortCamera": "Foto", + "shortAnc": "ANC", + "userScoreShort": "Uživatelé {score}/5.0", + "expertScoreShort": "Expert {score}/10" }, "reviews": { "criteriaScreen": "Displej a zobrazení", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index e2dde8925..1ab66ab87 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -341,7 +341,7 @@ "metaTitle": "Tech device leaderboard | Rate.vn", "metaDescription": "Independent rankings by category: expert and verified buyer scores across 5 criteria, with no ads or affiliate bias.", "kicker": "Independent device rankings", - "title": "Independent 10.0 device leaderboard", + "title": "Independent device leaderboard", "subtitle": "Objective rankings by category that keep verified buyer scores, lab expert scores and 5 sub-criteria separate from store prices.", "home": "Home", "breadcrumb": "Leaderboard", @@ -408,11 +408,11 @@ "critScreen": "Display & visuals", "critPerformance": "Performance & thermals", "critCamera": "Camera & optics", + "critAnc": "Noise cancelling & audio", "critBattery": "Battery & fast charge", "critValue": "Value for money", "shortDisplay": "Display", "shortPerf": "Perf", - "shortCamAnc": "Cam/ANC", "shortBattery": "Battery", "shortValue": "Value", "unratedShort": "unrated", @@ -429,7 +429,11 @@ "showdownsHeading": "Head-to-head matchups between top-ranked devices", "openCompareMatrixBtn": "Compare in detail →", "view1v1DeepBtn": "View 1v1 Head-to-Head", - "categoryTabsAria": "Choose a category" + "categoryTabsAria": "Choose a category", + "shortCamera": "Camera", + "shortAnc": "ANC", + "userScoreShort": "User {score}/5.0", + "expertScoreShort": "Expert {score}/10" }, "reviews": { "criteriaScreen": "Display & visuals", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index c63839f79..34cd310f1 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -341,7 +341,7 @@ "metaTitle": "Bảng xếp hạng thiết bị công nghệ | Rate.vn", "metaDescription": "Bảng xếp hạng độc lập theo danh mục: điểm chuyên gia và điểm người mua thật theo 5 tiêu chí, không bị chi phối bởi quảng cáo hay link affiliate.", "kicker": "Bảng xếp hạng thiết bị độc lập", - "title": "Bảng xếp hạng thiết bị độc lập 10.0", + "title": "Bảng xếp hạng thiết bị độc lập", "subtitle": "Xếp hạng khách quan theo danh mục, tách riêng điểm người mua thật, điểm chuyên gia và 5 tiêu chí kỹ thuật khỏi giá bán của các sàn.", "home": "Trang chủ", "breadcrumb": "Bảng xếp hạng", @@ -408,16 +408,16 @@ "critScreen": "Màn hình & hiển thị", "critPerformance": "Hiệu năng & nhiệt độ", "critCamera": "Camera & quay chụp", + "critAnc": "Chống ồn & âm thanh", "critBattery": "Pin & sạc nhanh", "critValue": "Đáng đồng tiền", - "shortDisplay": "Display", - "shortPerf": "Perf", - "shortCamAnc": "Cam/ANC", - "shortBattery": "Battery", - "shortValue": "Value", + "shortDisplay": "Màn hình", + "shortPerf": "Hiệu năng", + "shortBattery": "Pin", + "shortValue": "Đáng tiền", "unratedShort": "chưa có", - "expertUnrated": "Expert: chưa có", - "userUnrated": "User: chưa có", + "expertUnrated": "Chuyên gia: chưa có", + "userUnrated": "Người mua: chưa có", "copiedLeaderboardMd": "Đã chép BXH Markdown!", "copyLeaderboardMdBtn": "Sao chép BXH Markdown", "exportCsvBtn": "Xuất bảng CSV", @@ -429,7 +429,11 @@ "showdownsHeading": "So kè trực tiếp các thiết bị dẫn đầu cùng danh mục", "openCompareMatrixBtn": "So sánh chi tiết →", "view1v1DeepBtn": "Xem đối đầu 1v1 chuyên sâu", - "categoryTabsAria": "Chọn ngành hàng" + "categoryTabsAria": "Chọn ngành hàng", + "shortCamera": "Camera", + "shortAnc": "Chống ồn", + "userScoreShort": "Người mua {score}/5.0", + "expertScoreShort": "Chuyên gia {score}/10" }, "reviews": { "criteriaScreen": "Màn hình & hiển thị", @@ -1553,27 +1557,27 @@ "features": { "has5g": { "label": "Hỗ trợ 5G", - "desc": "Cellular: Kết nối mạng di động 5G tốc độ cao" + "desc": "Kết nối mạng di động 5G tốc độ cao" }, "highRefresh": { "label": "Màn hình 120Hz+", - "desc": "Display: Tần số quét từ 120Hz trở lên" + "desc": "Tần số quét từ 120Hz trở lên" }, "bigBattery": { "label": "Pin >= 5000mAh", - "desc": "Battery: Dung lượng pin từ 5000 mAh trở lên" + "desc": "Dung lượng pin từ 5000 mAh trở lên" }, "fastCharge": { "label": "Sạc nhanh >= 45W", - "desc": "Battery: Công suất sạc nhanh từ 45W trở lên" + "desc": "Công suất sạc nhanh từ 45W trở lên" }, "hasNfc": { "label": "Có NFC", - "desc": "Connectivity: Thanh toán chạm & đọc CCCD gắn chip" + "desc": "Thanh toán chạm & đọc CCCD gắn chip" }, "compactScreen": { "label": "Nhỏ gọn (<= 6.2\")", - "desc": "Display: Màn hình gọn dễ cầm một tay (<= 6.2 inch)" + "desc": "Màn hình gọn dễ cầm một tay (<= 6.2 inch)" } } }, diff --git a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx index 57be9ac29..72c03c237 100644 --- a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx +++ b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx @@ -393,12 +393,13 @@ export default function LeaderboardExplorer({ expert: t('critExpert'), screen: t('critScreen'), performance: t('critPerformance'), - camera: t('critCamera'), + // Earbuds score noise cancelling here (DOS/Rate#1250). + camera: activeCategory === 'earbuds' ? t('critAnc') : t('critCamera'), battery: t('critBattery'), value: t('critValue'), }, }), - [t] + [t, activeCategory] ); const categoryCounts = useMemo(() => { @@ -1290,7 +1291,9 @@ export default function LeaderboardExplorer({ {entry.expertScore10 != null - ? `Expert ${entry.expertScore10.toFixed(1)}/10` + ? t('expertScoreShort', { + score: entry.expertScore10.toFixed(1), + }) : t('expertUnrated')} {entry.userScore5 > 0 - ? `User ${entry.userScore5.toFixed(2)}/5.0` + ? t('userScoreShort', { + score: entry.userScore5.toFixed(2), + }) : t('userUnrated')} {entry.expertScore10 != null - ? `Expert ${entry.expertScore10.toFixed(1)}/10` + ? t('expertScoreShort', { + score: entry.expertScore10.toFixed(1), + }) : t('expertUnrated')} @@ -1621,7 +1628,15 @@ export default function LeaderboardExplorer({ [ { key: 'screen', short: t('shortDisplay') }, { key: 'performance', short: t('shortPerf') }, - { key: 'camera', short: t('shortCamAnc') }, + { + key: 'camera', + // Earbuds score noise cancelling here, the + // others their cameras (DOS/Rate#1250). + short: + activeCategory === 'earbuds' + ? t('shortAnc') + : t('shortCamera'), + }, { key: 'battery', short: t('shortBattery') }, { key: 'value', short: t('shortValue') }, ] as const diff --git a/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts b/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts index b69c5a679..13c0dfc6f 100644 --- a/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts +++ b/apps/web/src/lib/__tests__/leaderboard-badge-copy.test.ts @@ -30,4 +30,51 @@ for (const locale of ['vi', 'en', 'cs'] as const) { } } +// The Vietnamese leaderboard reads in Vietnamese, and no heading carries a +// stray version number (DOS/Rate#1250, DOS/Rate#1251). +{ + const vi = JSON.parse( + readFileSync( + join(__dirname, '..', '..', '..', 'locales', 'vi.json'), + 'utf8' + ) + ); + const lb = vi.leaderboard as Record; + for (const key of [ + 'shortDisplay', + 'shortPerf', + 'shortCamera', + 'shortAnc', + 'shortBattery', + 'shortValue', + 'userUnrated', + 'expertUnrated', + 'userScoreShort', + 'expertScoreShort', + ]) { + assert.ok(lb[key], `vi: missing leaderboard.${key}`); + assert.doesNotMatch( + lb[key]!, + /\b(Display|Perf|Battery|Value|User|Expert|Cam\/ANC)\b/, + `vi leaderboard.${key} is English` + ); + } + for (const locale of ['vi', 'en', 'cs'] as const) { + const title = JSON.parse( + readFileSync( + join(__dirname, '..', '..', '..', 'locales', `${locale}.json`), + 'utf8' + ) + ).leaderboard.title as string; + assert.doesNotMatch(title, /\d+\.\d/, `${locale} leaderboard title`); + } + const features = vi.catalog.schemas.smartphones.features as Record< + string, + { desc?: string } + >; + for (const [key, f] of Object.entries(features)) { + assert.doesNotMatch(f.desc ?? '', /^[A-Z][a-z]+: /, `vi filter ${key}`); + } +} + console.log('leaderboard-badge-copy: ok'); From 7e1a3e18fd14464e13c2a42f0749fc8d79bd73ee Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:34:38 +0700 Subject: [PATCH 34/69] docs(proposals): dos-me m1+m2 item migration proposal with replay test (#1242) * docs(proposals): dos-me m1+m2 item migration proposal with rollback and replay test PROPOSAL, NOT APPLIED. Idempotent SQL for DOS-Me M1+M2 (rate.items, item_id / variant_key / variant_source columns, products(item_id) index, RLS and grants, the M1 sync triggers, match_item, refresh_item_stats with the pooled Item rating, Item-aware refresh_master_product_rating and refresh_review_vote_counts, the transactional unknown-variant retire RPC, a products trigger keeping a hand-set variant against a racing ingest, an AFTER trigger refreshing the Item a listing leaves, and the design 7.1 backfill), a rollback and read-only verification queries. A CI step replays it on the migration replay stack: proposal twice, verification, triggers and RPCs per role, rollback twice, re-apply. .gitignore lets only these reviewed proposal files through its *.sql rule. Refs #1150 * docs(proposals): item migration review fixes for locks, retire, old writers and rollback Review round 1 (fresh subagent and Codex): - One lock order (listing, variant, Item): the listing trigger refreshes the left variant before the left Item, the rating refresh writes the variants before the Item, and the retire RPC locks listings, then the variant, re-checks, then refreshes. - A retired unknown variant takes no listing (FOR SHARE read in the listing trigger); the retire RPC restores the caller's override and records what it changed in metadata. - refresh_master_market_stats keeps its signature and query and also refreshes the variant's Item, so writers that know no Items keep Item numbers fresh. - Rollback puts retired listings back, restores the market stats body and recomputes every variant's rating and prices. - Slug pre-check also covers existing Items; V8 uses a lateral join; the test reads only SELECT statements, calls an RPC as service_role and covers the new behaviour; .gitignore names the three files. Refs #1150 * docs(proposals): item migration review round 2 fixes - The listing trigger takes no variant lock (INSERT ... ON CONFLICT runs it before locking the listing); the retire RPC sweeps again after archiving, and a call on an already retired unknown sweeps stragglers. - The rating mirror locks sibling variants in id order with SKIP LOCKED. - Retire markers clear on any later variant or Item change, and the rollback restores a listing only onto a variant of its own Item. Refs #1150 * docs(proposals): rate_worker item grants and a re-runnable rollback restore - rate_worker gets UPDATE on rate.items spec columns only (RS#700), with its RLS policy; SELECT and EXECUTE on match_item / refresh_item_stats were already granted. It cannot publish, re-key or reprice an Item. - The rollback's listing restore runs only while the item_id columns exist, so a second rollback no longer fails. Refs #1150 * docs(proposals): re-check a retired unknown variant at commit Codex round 3: a write whose BEFORE trigger saw the unknown variant live while a retire was running could commit onto it after both sweeps. A deferred constraint trigger re-checks the variant at the writer's commit (the writer waits on the variant's FK lock until the retire commits) and moves the listing to no variant. The SKIP LOCKED rating-mirror lag is documented as a known trade-off with its reconciliation. Refs #1150 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + .gitignore | 4 + ...2026-10-28-dos-me-m1-m2-items-rollback.sql | 230 +++++ .../2026-10-28-dos-me-m1-m2-items-verify.sql | 90 ++ .../2026-10-28-dos-me-m1-m2-items.md | 64 ++ .../2026-10-28-dos-me-m1-m2-items.sql | 869 ++++++++++++++++++ scripts/test-item-migration-proposal.mjs | 395 ++++++++ 7 files changed, 1655 insertions(+) create mode 100644 Docs/Proposals/2026-10-28-dos-me-m1-m2-items-rollback.sql create mode 100644 Docs/Proposals/2026-10-28-dos-me-m1-m2-items-verify.sql create mode 100644 Docs/Proposals/2026-10-28-dos-me-m1-m2-items.md create mode 100644 Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql create mode 100644 scripts/test-item-migration-proposal.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58edbe848..d1487a94e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,9 @@ jobs: - name: Replay rate migrations on PostgreSQL 17 (Docker) run: node scripts/test-migration-replay.mjs + - name: Replay the DOS-Me Item migration proposal (Docker) + run: node scripts/test-item-migration-proposal.mjs + - name: Check generated rate schema types match the migrations run: pnpm check:db-types diff --git a/.gitignore b/.gitignore index a81e6db7f..ad673198f 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,10 @@ playwright-report/ !supabase/migrations/*.sql # Reviewed E2E fixture: synthetic catalog rows only, no credentials. !apps/web/e2e/seed.sql +# Reviewed DOS-Me migration proposal (DOS/Rate#1150): DDL only, no data, no credentials. +!Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql +!Docs/Proposals/2026-10-28-dos-me-m1-m2-items-rollback.sql +!Docs/Proposals/2026-10-28-dos-me-m1-m2-items-verify.sql * - Copy* * - Bản sao* *.dump diff --git a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items-rollback.sql b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items-rollback.sql new file mode 100644 index 000000000..9a9f6714b --- /dev/null +++ b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items-rollback.sql @@ -0,0 +1,230 @@ +-- PROPOSAL, NOT APPLIED. Rollback of 2026-10-28-dos-me-m1-m2-items.sql (DOS/Rate#1150, +-- design section 7 "M1"/"M2" reversibility). Run with the writers paused (importer and Rate +-- Services workers stopped), in one transaction. Re-runnable. +-- +-- Never TRUNCATE rate.items: it fails while the FKs exist, and TRUNCATE ... CASCADE would wipe +-- master_products, products and through them price history, reviews and alerts. +-- Data written only by this migration (Items, item_id, variant_key, variant_source) is lost; +-- nothing older depends on it. The two rating functions get their previous bodies back +-- (20261018153000 and 20261018120103), so the old readers keep working. + +BEGIN; + +-- 1. Triggers first, so the column drops below fire nothing. +DROP TRIGGER IF EXISTS trg_products_recheck_retired_variant ON rate.products; +DROP TRIGGER IF EXISTS trg_products_refresh_left ON rate.products; +DROP TRIGGER IF EXISTS trg_products_sync_item ON rate.products; +DROP TRIGGER IF EXISTS trg_master_products_move_listings ON rate.master_products; +DROP TRIGGER IF EXISTS trg_master_products_sync_item ON rate.master_products; +DROP TRIGGER IF EXISTS trg_rate_items_set_updated_at ON rate.items; + +-- 2. Previous rating bodies (verbatim from the reference migrations). +CREATE OR REPLACE FUNCTION rate.refresh_master_product_rating(p_master_id UUID) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = '' +AS $$ + UPDATE rate.master_products m + SET user_rating_weighted = COALESCE(s.weighted, 0), + authentic_review_count = COALESCE(s.n, 0), + updated_at = now() + FROM ( + SELECT + round( + (sum(r.rating_star * r.helpfulness_weight) FILTER (WHERE r.reviewer_type = 'user')) + / NULLIF(sum(r.helpfulness_weight) FILTER (WHERE r.reviewer_type = 'user'), 0), + 2 + ) AS weighted, + (count(*) FILTER (WHERE r.reviewer_type = 'user'))::INTEGER AS n + FROM rate.products p + JOIN rate.reviews r ON r.product_id = p.id + JOIN rate.review_flags f ON f.review_id = r.id + WHERE p.cluster_id = p_master_id + AND p.origin = 'crawl' + AND r.origin IN ('crawl', 'community') + -- Reviews excluded from the rating (unanalyzed community reviews, + -- flagged ones) never count, whatever their weight (20261014033000). + AND COALESCE(f.include_in_rating, true) + AND r.helpfulness_weight > 0 + ) s + WHERE m.id = p_master_id; +$$; + +CREATE OR REPLACE FUNCTION rate.refresh_review_vote_counts(p_review_id UUID) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_up INTEGER; + v_down INTEGER; + v_rep INTEGER; + v_cat TEXT; + v_verdict TEXT; + v_inc BOOLEAN; + v_master UUID; +BEGIN + SELECT + count(*) FILTER (WHERE vote = 'up'), + count(*) FILTER (WHERE vote = 'down'), + count(*) FILTER (WHERE reported) + INTO v_up, v_down, v_rep + FROM rate.review_votes + WHERE review_id = p_review_id; + + SELECT f.flag_category, f.verdict, COALESCE(f.include_in_rating, true) + INTO v_cat, v_verdict, v_inc + FROM rate.review_flags f + WHERE f.review_id = p_review_id; + + -- Votes cannot lift a review that is excluded from the rating + -- (20261014033000, relied on by 20261017120000's unanalyzed reviews). + UPDATE rate.reviews + SET upvotes = v_up, + downvotes = v_down, + report_count = v_rep, + helpfulness_weight = CASE + WHEN NOT COALESCE(v_inc, true) THEN 0.00 + ELSE rate.compute_review_weight( + v_up, v_down, v_rep, + COALESCE(v_cat, 'authentic'), + COALESCE(v_verdict, 'unanalyzed') + ) + END + WHERE id = p_review_id; + + SELECT p.cluster_id INTO v_master + FROM rate.reviews r + JOIN rate.products p ON p.id = r.product_id + WHERE r.id = p_review_id; + + IF v_master IS NOT NULL THEN + PERFORM rate.refresh_master_product_rating(v_master); + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION rate.refresh_master_market_stats(p_master_id UUID) +RETURNS void +LANGUAGE sql +SECURITY DEFINER +SET search_path = '' +AS $$ + WITH priced AS ( + SELECT + p.price_current AS price, + COALESCE(NULLIF(p.price_max, 0), p.price_current) AS price_top, + p.merchant_id, + (pg_catalog.row_number() OVER (ORDER BY p.price_current, p.id) - 1)::INTEGER AS k, + (pg_catalog.count(*) OVER ())::INTEGER AS m + FROM rate.products p + WHERE p.cluster_id = p_master_id + AND p.origin = 'crawl' + AND p.availability = 'in_stock' + AND p.price_current > 0 + ), + judged AS ( + SELECT + c.price, + c.price_top, + c.merchant_id, + (c.m >= 3 AND c.price < 0.4 * r.price) AS implausible + FROM priced c + LEFT JOIN priced r + ON r.k = CASE WHEN (c.m - 2) / 2 < c.k THEN (c.m - 2) / 2 ELSE (c.m - 2) / 2 + 1 END + ), + s AS ( + SELECT + MIN(j.price) FILTER (WHERE NOT j.implausible) AS min_price, + MAX(j.price_top) AS max_price, + COUNT(DISTINCT j.merchant_id)::INTEGER AS merchant_count + FROM judged j + ) + UPDATE rate.master_products m + SET min_price = s.min_price, + max_price = s.max_price, + merchant_count = s.merchant_count, + updated_at = now() + FROM s + WHERE m.id = p_master_id + AND (m.min_price, m.max_price, m.merchant_count) + IS DISTINCT FROM (s.min_price, s.max_price, s.merchant_count); +$$; + +-- 2b. Put back what retire_item_unknown_variant changed (it records it in `metadata`), then +-- recompute every variant's rating and prices with the old bodies, so no pooled Item rating +-- stays mirrored on a variant. Listings that never had a variant (written with +-- RATE_ITEM_MODEL=nullable_variant) keep cluster_id NULL: the old schema cannot say which +-- Item they belong to. Count them first with +-- SELECT count(*) FROM rate.products WHERE cluster_id IS NULL AND item_id IS NOT NULL +-- AND NOT (metadata ? 'retired_unknown_variant'); +UPDATE rate.master_products mp +SET status = mp.metadata ->> 'status_before_retire', + metadata = mp.metadata - 'status_before_retire' - 'retired_by' +WHERE mp.metadata ->> 'retired_by' = 'retire_item_unknown_variant' + AND mp.metadata ? 'status_before_retire'; +-- Only while the item_id columns still exist (a second run finds them dropped). +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'rate' AND table_name = 'products' AND column_name = 'item_id') THEN + EXECUTE $q$ + UPDATE rate.products p + SET cluster_id = mp.id, + metadata = p.metadata - 'retired_unknown_variant' - 'variant_source_before_retire' + FROM rate.master_products mp + WHERE p.cluster_id IS NULL + AND p.metadata ? 'retired_unknown_variant' + AND mp.id = (p.metadata ->> 'retired_unknown_variant')::uuid + AND mp.item_id IS NOT DISTINCT FROM p.item_id + $q$; + END IF; +END $$; +DO $$ +DECLARE + v_id uuid; +BEGIN + FOR v_id IN SELECT m.id FROM rate.master_products m ORDER BY m.id LOOP + PERFORM rate.refresh_master_market_stats(v_id); + PERFORM rate.refresh_master_product_rating(v_id); + END LOOP; +END $$; + +-- 3. New functions. +DROP FUNCTION IF EXISTS rate.retire_item_unknown_variant(uuid); +DROP FUNCTION IF EXISTS rate.match_item(text, text, text, real); +DROP FUNCTION IF EXISTS rate.refresh_item_stats(uuid); +DROP FUNCTION IF EXISTS rate.refresh_item_rating(uuid); +DROP FUNCTION IF EXISTS rate.products_recheck_retired_variant(); +DROP FUNCTION IF EXISTS rate.products_refresh_left(); +DROP FUNCTION IF EXISTS rate.products_sync_item(); +DROP FUNCTION IF EXISTS rate.master_products_move_listings(); +DROP FUNCTION IF EXISTS rate.master_products_sync_item(); +DROP FUNCTION IF EXISTS rate.item_name_from_master(text); +DROP FUNCTION IF EXISTS rate.item_slug_from_master(text, text); + +-- 4. Constraints, indexes and columns on the existing tables. +ALTER TABLE rate.products DROP CONSTRAINT IF EXISTS fk_products_item; +ALTER TABLE rate.products DROP CONSTRAINT IF EXISTS products_variant_source_check; +ALTER TABLE rate.master_products DROP CONSTRAINT IF EXISTS fk_master_products_item; +DO $$ +BEGIN + IF to_regclass('rate.items') IS NOT NULL THEN + ALTER TABLE rate.items DROP CONSTRAINT IF EXISTS fk_items_default_variant; + END IF; +END $$; +DROP INDEX IF EXISTS rate.idx_rate_products_item; +DROP INDEX IF EXISTS rate.idx_master_products_item; +DROP INDEX IF EXISTS rate.uq_master_products_item_variant; +ALTER TABLE rate.products DROP COLUMN IF EXISTS variant_source; +ALTER TABLE rate.products DROP COLUMN IF EXISTS item_id; +ALTER TABLE rate.master_products DROP COLUMN IF EXISTS sort_order; +ALTER TABLE rate.master_products DROP COLUMN IF EXISTS variant_key; +ALTER TABLE rate.master_products DROP COLUMN IF EXISTS item_id; + +-- 5. The Item table (its policies, grants and indexes go with it). +DROP TABLE IF EXISTS rate.items; + +COMMIT; diff --git a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items-verify.sql b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items-verify.sql new file mode 100644 index 000000000..ab1f37966 --- /dev/null +++ b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items-verify.sql @@ -0,0 +1,90 @@ +-- PROPOSAL. Read-only verification for 2026-10-28-dos-me-m1-m2-items.sql (DOS/Rate#1150, +-- design section 7.3). Run before (inventory), after M1+M2, and again as the reconciliation +-- after the last writer ships Item-aware code. Every query is a SELECT. Expected results are +-- in the trailing comments; `scripts/test-item-migration-proposal.mjs` runs them on the +-- replay stack and fails on any non-expected value. + +-- V1 Every variant has an Item. expect 0 +SELECT count(*) AS masters_without_item FROM rate.master_products WHERE item_id IS NULL; + +-- V2 Every listing on a variant has an Item. expect 0 +SELECT count(*) AS listings_without_item +FROM rate.products WHERE item_id IS NULL AND cluster_id IS NOT NULL; + +-- V3 A listing's Item is its variant's Item. expect 0 +SELECT count(*) AS listing_item_mismatch +FROM rate.products p JOIN rate.master_products mp ON mp.id = p.cluster_id +WHERE p.item_id IS DISTINCT FROM mp.item_id; + +-- V4 variant_key is the key's third segment. expect 0 +SELECT count(*) AS variant_key_drift +FROM rate.master_products +WHERE variant_key IS DISTINCT FROM NULLIF(split_part(model_key, ':', 3), ''); + +-- V5 An Item's key is its variants' brand:model. expect 0 +SELECT count(*) AS item_key_drift +FROM rate.master_products mp JOIN rate.items i ON i.id = mp.item_id +WHERE i.item_key <> split_part(mp.model_key, ':', 1) || ':' || split_part(mp.model_key, ':', 2); + +-- V6 A published variant never sits under an unpublished Item. expect 0 +SELECT count(*) AS published_variant_hidden +FROM rate.master_products mp JOIN rate.items i ON i.id = mp.item_id +WHERE mp.status = 'published' AND i.status <> 'published'; + +-- V7 Item "from" price = min over its published, priced variants. expect 0 +SELECT count(*) AS item_min_price_drift +FROM rate.items i +WHERE i.min_price IS DISTINCT FROM ( + SELECT COALESCE(min(mp.min_price) FILTER (WHERE mp.min_price > 0), 0) + FROM rate.master_products mp + WHERE mp.item_id = i.id AND mp.status = 'published'); + +-- V8 Item listing counts. expect 0 +SELECT count(*) AS item_count_drift +FROM rate.items i +CROSS JOIN LATERAL ( + SELECT count(*)::integer AS listings, + (count(*) FILTER (WHERE p.cluster_id IS NULL))::integer AS unknown + FROM rate.products p WHERE p.item_id = i.id AND p.origin = 'crawl' +) c +WHERE (i.listing_count, i.unknown_variant_listing_count) IS DISTINCT FROM (c.listings, c.unknown); + +-- V9 An Item slug may only equal one of its own variants' slugs. expect 0 +SELECT count(*) AS foreign_slug_clash +FROM rate.items i JOIN rate.master_products mp ON mp.slug = i.slug +WHERE mp.item_id IS DISTINCT FROM i.id; + +-- V10 Item names and slugs carry no storage. expect 0 +SELECT count(*) AS sized_item_names +FROM rate.items +WHERE slug ~ '-[0-9]+(gb|tb)$' OR name ~* '[[:space:]][0-9]+[[:space:]]?(gb|tb)$'; + +-- V11 Item specs carry no storage or RAM (variant overrides). expect 0 +SELECT count(*) AS sized_item_specs +FROM rate.items +WHERE specifications #> '{hardware,storage_gb}' IS NOT NULL + OR specifications #> '{hardware,ram_gb}' IS NOT NULL + OR key_specs ? 'storage' OR key_specs ? 'ram'; + +-- V12 The variant rating mirrors its Item's. expect 0 +SELECT count(*) AS rating_mirror_drift +FROM rate.master_products mp JOIN rate.items i ON i.id = mp.item_id +WHERE (COALESCE(mp.user_rating_weighted, 0), COALESCE(mp.authentic_review_count, 0)) + IS DISTINCT FROM (COALESCE(i.user_rating_weighted, 0), i.authentic_review_count); + +-- V13 variant_source values (information): legacy after the backfill, then evidence values. +SELECT COALESCE(variant_source, '(null)') AS variant_source, count(*) +FROM rate.products GROUP BY 1 ORDER BY 1; + +-- V14 Multi-size Items still holding a live unknown variant (retire candidates). expect 0 +-- after the importer's retire call; information before (list them with i.item_key). +SELECT count(*) AS retire_candidates +FROM rate.items i +JOIN rate.master_products u ON u.item_id = i.id AND u.variant_key = 'unknown' AND u.status <> 'archived' +WHERE EXISTS (SELECT 1 FROM rate.master_products s + WHERE s.item_id = i.id AND s.status <> 'archived' AND s.variant_key ~ '^[0-9]+(gb|tb)$'); + +-- V15 Published variant counts per Item, for the page smoke test (information). +SELECT i.slug, count(mp.id) AS variants +FROM rate.items i JOIN rate.master_products mp ON mp.item_id = i.id +WHERE i.status = 'published' GROUP BY 1 ORDER BY 2 DESC, 1; diff --git a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.md b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.md new file mode 100644 index 000000000..f16e4651b --- /dev/null +++ b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.md @@ -0,0 +1,64 @@ +# Proposal: DOS-Me migration M1 + M2 for Items (DOS/Rate#1150) + +**Status:** proposal for JOY, **not applied**. Zero DDL on the shared prod Supabase from DOS/Rate; JOY reviews and applies it through DOS-Me (preview, then beta, then prod). The DOS-Me beta flow is still locked (ROADMAP 6.4). + +Design: [`2026-10-27-item-variant-listing-model.md`](./2026-10-27-item-variant-listing-model.md), sections 4.3, 4.5, 7, 7.1, 7.3 (approved by JOY on 2026-10-27, #1129). + +## Files + +| File | What | +| :------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`2026-10-28-dos-me-m1-m2-items.sql`](./2026-10-28-dos-me-m1-m2-items.sql) | M1 (schema, RLS, grants, sync triggers, race guards) and M2 (RPCs, backfill) in one transaction. Idempotent. | +| [`2026-10-28-dos-me-m1-m2-items-rollback.sql`](./2026-10-28-dos-me-m1-m2-items-rollback.sql) | Full rollback, re-runnable; restores the two rating functions' previous bodies. Writers paused. | +| [`2026-10-28-dos-me-m1-m2-items-verify.sql`](./2026-10-28-dos-me-m1-m2-items-verify.sql) | Read-only checks (V1 to V15) to run before, after, and as the reconciliation. | +| [`scripts/test-item-migration-proposal.mjs`](../../scripts/test-item-migration-proposal.mjs) | CI test on the throwaway PostgreSQL 17 stack of the migration replay: proposal twice, verification, triggers and RPCs as `anon` / `rate_worker` / `service_role`, rollback twice, re-apply. | + +## Order of operations + +1. **DOS/Rate#1123 first** (M0 re-key of the six drifted seed masters, with JOY's approval), so the Items get the normalizer's Item keys (`apple:watch-ultra-3`, `apple:airpods-pro-2`). +2. This migration on preview, run the verification file, then beta, then prod. +3. The importer keeps `RATE_ITEM_MODEL` off until Rate Services handles NULL `cluster_id` (design task 13). Flag `item` can come first; `nullable_variant` after task 13. +4. Mirror the applied file into `supabase/migrations/` and regenerate `database.types.ts` (as #1147 did). + +## Contents and the contracts they satisfy + +| Piece | Contract / source | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------- | +| `rate.items` (design 4.3 columns), RLS: public reads published, `service_role` all, `rate_worker` reads all and updates spec columns only (description, specifications, key_specs, spec_provenance, images, thumbnail, metadata), never status, key, prices or counts | matcher reads draft Items (#1179); seed spec sync writes as service role (#1208); Rate Services needs SELECT, UPDATE and the two RPCs (RS#700) | +| `master_products.item_id`, `variant_key`, `sort_order`; `products.item_id`, `variant_source` (+ CHECK) | pipeline writes (#1179, #1190) | +| index `rate.products(item_id)` | Rate Services filters by Item (RS#688, PR #689) | +| M1 sync triggers: a master inserted or re-keyed gets its Item from its key (draft, published with its variant); its listings follow; a listing's Item follows its variant | design 4.3 compatibility sync; `itemIdOfMaster` in the matcher fails loudly without it | +| `match_item(search_title, p_brand, p_model, p_threshold)` -> `item_id, item_key, name, similarity` | matcher Item Tier 2 (#1179) | +| `refresh_item_stats(p_item_id)`: prices from published variants, listing / unknown-variant / merchant counts, **and the pooled Item rating** mirrored onto the variants | pipeline and review analyzer (#1190, #1208) | +| `refresh_master_product_rating(p_master_id)`: same signature, rates the whole Item when the master has one | design 4.5 | +| `refresh_review_vote_counts`: resolves the Item through `products.item_id`, so votes on a listing with no variant count | design 4.5 | +| `retire_item_unknown_variant(p_item_id)`: one transaction, Item row locked; listings of a multi-size Item's live `unknown` variant move to no variant, the variant is archived, stats refreshed; earbuds never | Codex P1 left open on #1179 | +| `products` BEFORE trigger: a `manual` variant is never moved by a later write (an ingest that read the row before it was hand-set included); admin tools move it with `SET LOCAL rate.variant_override = 'on'` | Codex P1 on #1179 (read-then-upsert race) | +| `products` AFTER trigger: the Item and variant a listing leaves are refreshed by the statement that moves it, under the row lock | Codex finding on #1190 (overlapping ingests leave the middle Item stale) | +| Backfill (design 7.1) with an up-front slug-collision abort; Item specs and key specs without storage and RAM | design 7.1, #1208 rule "no storage on an Item" | + +## Concurrency and compatibility + +- **One lock order everywhere: listing, then variant, then Item.** The listing triggers refresh the left variant before the left Item and take no variant lock themselves (an `INSERT ... ON CONFLICT` runs the BEFORE trigger before it locks the conflicting listing); the rating refresh mirrors onto the variants in id order with `SKIP LOCKED` (a skipped variant belongs to a concurrent refresh of the same Item, which mirrors itself), then writes the Item; the retire RPC locks the listings, then the variant, then refreshes the Item. +- **A retired `unknown` variant takes no listing.** The listing trigger turns a write onto a retired `unknown` into "no variant, same Item". A write that races a running retire is caught by the retire's second sweep (writes committed before it) or by a deferred constraint trigger that re-checks the variant at the writer's commit (the writer waits on the variant's FK lock until the retire commits, so it then sees the variant archived). Calling the RPC again on an Item whose `unknown` is already retired also sweeps any straggler. +- **Known trade-off: the variant rating mirror can lag.** To avoid sibling-variant deadlocks the mirror skips a variant another transaction holds (`SKIP LOCKED`). If that transaction does not itself refresh the Item (for example a specs update), the variant keeps its previous mirrored rating until the Item's next refresh (any ingest, vote or retire on it). The Item's own rating is always current; only old readers of the variant columns see the lag. V12 reports it; the reconciliation `SELECT rate.refresh_item_rating(id) FROM rate.items` clears it. +- **Old writers keep Item numbers fresh.** `refresh_master_market_stats(p_master_id)` keeps its signature and query and also refreshes the variant's Item (removed with the sync triggers in M4), so the importer with the flag off and Rate Services before task 13 do not leave Item prices or counts stale. +- **The retire RPC records what it changed** in `metadata` (listing: `retired_unknown_variant`, `variant_source_before_retire`; variant: `status_before_retire`, `retired_by`), so the rollback can put those listings back on their variant. Any later variant or Item change on the listing clears its markers, and the rollback restores a listing only onto a variant of its own Item. +- **`rate.variant_override` is a cooperative switch, not a permission.** Any role that can update `rate.products` could set it; only the worker and service role can, and both are trusted writers. The retire RPC restores the caller's value. +- **An Item is never demoted automatically.** Publishing a variant publishes its Item; archiving every variant leaves the Item's status to an admin (or M3/M4 tooling). + +## Decisions to confirm (JOY) + +- **`match_item` filters by brand, not by model.** The design names a `p_model` argument; filtering by it would only find the exact model, which Tier 1 already did. The matcher only links a fuzzy Item hit to variants that exist, so a looser match cannot create a variant under a foreign Item. +- **Item rating with no reviews is stored as 0**, like the variant columns today (`refresh_master_product_rating` writes `COALESCE(..., 0)`). The web shows "no rating" when the review count is 0. +- **`rate.v_products_with_merchant_trust` is not changed.** The design suggested appending `item_id`; doing so breaks a re-run of `20261018153000` (see the note in `20261020230000`). Readers take `item_id` from `rate.products`. +- **Price alerts on a retired `unknown` variant stay on it** (archived): an alert needs a size. M3's report lists them (design 7.3). +- **Admin override setting name:** `rate.variant_override`. Any admin tool that re-assigns a hand-set variant must set it locally. + +## Not in this migration + +Web RPCs (`search_items_v1`, facet and autocomplete v2, `resolve_catalog_slug`, `submit_community_review` by Item) belong to the web tasks (design section 10, tasks 7 to 11). M3 (bulk retire with a backup table) and M4 (NOT NULL, guard triggers, dropping the sync triggers) are later phases. + +## Rollback + +Run `2026-10-28-dos-me-m1-m2-items-rollback.sql` with the importer and Rate Services workers paused. It drops the triggers, the new functions, the new columns and `rate.items`, and restores `refresh_master_product_rating` (20261018153000) and `refresh_review_vote_counts` (20261018120103). Never `TRUNCATE rate.items`. diff --git a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql new file mode 100644 index 000000000..8474c0ee6 --- /dev/null +++ b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql @@ -0,0 +1,869 @@ +-- PROPOSAL, NOT APPLIED. DOS-Me migration M1 + M2 for the Item / variant / listing model +-- (DOS/Rate#1150; design Docs/Proposals/2026-10-27-item-variant-listing-model.md, sections +-- 4.3, 4.5, 7, 7.1). JOY reviews and applies it through DOS-Me (preview, then beta, then +-- prod). Zero DDL on prod from DOS/Rate. Rollback: 2026-10-28-dos-me-m1-m2-items-rollback.sql. +-- Verification: 2026-10-28-dos-me-m1-m2-items-verify.sql. +-- +-- Order: DOS/Rate#1123 (re-key of the six drifted seed masters, M0) runs first, so the Items +-- are created with the normalizer's Item keys. +-- +-- Idempotent: every step is IF NOT EXISTS, guarded by pg_constraint / pg_trigger, CREATE OR +-- REPLACE, or a re-runnable UPDATE ... WHERE . One transaction (M1 and M2 ship +-- together so no Item is created by a trigger and then skipped by the backfill). +-- +-- Contracts the importer already calls (DOS/Rate#1179, #1190, #1208, #1202): +-- rate.items(id, item_key, status, description, specifications, key_specs, images, ...) +-- rate.master_products.item_id / variant_key, rate.products.item_id / variant_source +-- rate.match_item(search_title, p_brand, p_model, p_threshold) +-- -> item_id, item_key, name, similarity +-- rate.refresh_item_stats(p_item_id): prices, counts AND the Item's pooled rating +-- rate.refresh_master_product_rating(p_master_id): same signature, Item-aware body +-- M1 sync triggers: a master gets its Item from its key; a listing's item follows its variant +-- Added here for the matcher's open Codex P1s: +-- rate.retire_item_unknown_variant(p_item_id): transactional unknown-variant retire +-- a products trigger that keeps a hand-set (manual) variant against a racing ingest, and +-- an AFTER trigger that refreshes the Item and variant a listing leaves (overlapping ingests) +-- +-- Not in this migration: rate.v_products_with_merchant_trust is left unchanged (appending a +-- column makes a re-run of 20261018153000 fail, see 20261020230000); listings read item_id +-- from rate.products. Web RPCs (search_items_v1, facets/autocomplete v2, resolve_catalog_slug, +-- submit_community_review by Item) belong to the web tasks (design section 10, tasks 7 to 11). + +BEGIN; + +-- M1.1 rate.items -------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS rate.items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + item_key text NOT NULL, + slug text NOT NULL, + name text NOT NULL, + brand text NOT NULL, + model text NOT NULL, + category_id uuid REFERENCES rate.categories(id) ON DELETE SET NULL, + status text NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'published', 'archived')), + entity_type text NOT NULL DEFAULT 'product', + description text, + specifications jsonb NOT NULL DEFAULT '{}'::jsonb, + key_specs jsonb NOT NULL DEFAULT '{}'::jsonb, + spec_provenance jsonb NOT NULL DEFAULT '{}'::jsonb, + images text[] NOT NULL DEFAULT '{}', + thumbnail text, + default_variant_id uuid, + min_price numeric(15, 2) NOT NULL DEFAULT 0, + max_price numeric(15, 2) NOT NULL DEFAULT 0, + price_variant_count integer NOT NULL DEFAULT 0, + merchant_count integer NOT NULL DEFAULT 0, + listing_count integer NOT NULL DEFAULT 0, + unknown_variant_listing_count integer NOT NULL DEFAULT 0, + total_reviews integer NOT NULL DEFAULT 0, + average_rating numeric(3, 2) NOT NULL DEFAULT 0, + user_rating_weighted numeric(3, 2), + authentic_review_count integer NOT NULL DEFAULT 0, + expert_rating numeric(3, 2), + expert_review_summary text, + expert_criteria_scores jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT items_item_key_key UNIQUE (item_key), + CONSTRAINT items_slug_key UNIQUE (slug) +); +-- No shape CHECK on item_key: legacy keys still get an Item (design 7.1). + +-- M1.2 columns on the variant and listing tables ------------------------------------------ + +ALTER TABLE rate.master_products ADD COLUMN IF NOT EXISTS item_id uuid; +ALTER TABLE rate.master_products ADD COLUMN IF NOT EXISTS variant_key text; +ALTER TABLE rate.master_products ADD COLUMN IF NOT EXISTS sort_order smallint; +ALTER TABLE rate.products ADD COLUMN IF NOT EXISTS item_id uuid; +ALTER TABLE rate.products ADD COLUMN IF NOT EXISTS variant_source text; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid = 'rate.products'::regclass AND conname = 'products_variant_source_check') THEN + ALTER TABLE rate.products ADD CONSTRAINT products_variant_source_check CHECK + (variant_source IS NULL OR variant_source IN ('title', 'sku', 'single_size', 'single_variant', 'manual', 'legacy')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid = 'rate.master_products'::regclass AND conname = 'fk_master_products_item') THEN + ALTER TABLE rate.master_products ADD CONSTRAINT fk_master_products_item + FOREIGN KEY (item_id) REFERENCES rate.items(id) ON DELETE RESTRICT; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid = 'rate.products'::regclass AND conname = 'fk_products_item') THEN + ALTER TABLE rate.products ADD CONSTRAINT fk_products_item + FOREIGN KEY (item_id) REFERENCES rate.items(id) ON DELETE RESTRICT; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid = 'rate.items'::regclass AND conname = 'fk_items_default_variant') THEN + ALTER TABLE rate.items ADD CONSTRAINT fk_items_default_variant + FOREIGN KEY (default_variant_id) REFERENCES rate.master_products(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_master_products_item_variant + ON rate.master_products (item_id, variant_key) WHERE item_id IS NOT NULL AND variant_key IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_master_products_item ON rate.master_products (item_id); +-- Rate Services filters listings by Item (RS#688, PR #689). +CREATE INDEX IF NOT EXISTS idx_rate_products_item ON rate.products (item_id); +CREATE INDEX IF NOT EXISTS idx_items_status_cat_price ON rate.items (status, category_id, min_price); +CREATE INDEX IF NOT EXISTS idx_items_status_rating ON rate.items (status, user_rating_weighted DESC); + +-- pg_trgm lives in schema `sign` on prod and elsewhere in other stacks: look it up at run time. +DO $$ +DECLARE + v_trgm text; +BEGIN + SELECT n.nspname INTO v_trgm + FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'pg_trgm'; + IF v_trgm IS NULL THEN + RAISE EXCEPTION 'pg_trgm extension is not installed'; + END IF; + EXECUTE format( + 'CREATE INDEX IF NOT EXISTS idx_items_name_trgm ON rate.items USING gin (name %I.gin_trgm_ops)', + v_trgm + ); +END $$; + +-- M1.3 RLS, grants, updated_at ------------------------------------------------------------- + +ALTER TABLE rate.items ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "Public can view published items" ON rate.items; +CREATE POLICY "Public can view published items" ON rate.items + FOR SELECT USING (status = 'published'); +DROP POLICY IF EXISTS "Service role write items" ON rate.items; +CREATE POLICY "Service role write items" ON rate.items + FOR ALL TO service_role USING (true) WITH CHECK (true); +-- The worker reads every Item (the matcher links listings to draft Items too); it writes +-- Items only through the SECURITY DEFINER triggers and RPCs below. +DROP POLICY IF EXISTS "Rate worker read items" ON rate.items; +CREATE POLICY "Rate worker read items" ON rate.items + FOR SELECT TO rate_worker USING (true); + +GRANT SELECT ON rate.items TO anon, authenticated; +GRANT ALL ON rate.items TO service_role; +GRANT SELECT ON rate.items TO rate_worker; +-- Rate Services updates Item specs before the flag goes on (RS#700): spec and description +-- columns only. Identity, status, prices, counts and ratings stay with the triggers and +-- RPCs, so the worker cannot publish or re-key an Item. +REVOKE UPDATE ON rate.items FROM rate_worker; +GRANT UPDATE (description, specifications, key_specs, spec_provenance, images, thumbnail, + metadata, updated_at) + ON rate.items TO rate_worker; +DROP POLICY IF EXISTS "Rate worker update items" ON rate.items; +CREATE POLICY "Rate worker update items" ON rate.items + FOR UPDATE TO rate_worker USING (true) WITH CHECK (true); + +DROP TRIGGER IF EXISTS trg_rate_items_set_updated_at ON rate.items; +CREATE TRIGGER trg_rate_items_set_updated_at + BEFORE UPDATE ON rate.items + FOR EACH ROW EXECUTE FUNCTION rate.set_updated_at_timestamp(); + +-- M1.4 Item slug and name from a variant (design 7.1) --------------------------------------- + +CREATE OR REPLACE FUNCTION rate.item_slug_from_master(p_slug text, p_model_key text) +RETURNS text +LANGUAGE sql +IMMUTABLE +SET search_path = '' +AS $$ + SELECT COALESCE( + NULLIF(regexp_replace(COALESCE(p_slug, ''), '-([0-9]+(gb|tb)|standard|unknown)$', ''), ''), + trim(BOTH '-' FROM regexp_replace( + lower(split_part(COALESCE(p_model_key, ''), ':', 1) || '-' || split_part(COALESCE(p_model_key, ''), ':', 2)), + '[^a-z0-9-]+', '-', 'g')) + ); +$$; + +CREATE OR REPLACE FUNCTION rate.item_name_from_master(p_name text) +RETURNS text +LANGUAGE sql +IMMUTABLE +SET search_path = '' +AS $$ + SELECT COALESCE( + NULLIF(btrim(regexp_replace(COALESCE(p_name, ''), '[[:space:]]+[0-9]+([.,][0-9]+)?[[:space:]]?(gb|tb)$', '', 'i')), ''), + COALESCE(p_name, '') + ); +$$; + +REVOKE EXECUTE ON FUNCTION rate.item_slug_from_master(text, text) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION rate.item_name_from_master(text) FROM PUBLIC, anon, authenticated; + +-- M1.5 compatibility sync (removed in M4) -------------------------------------------------- + +-- A master inserted or re-keyed without its Item gets one from its key (created as draft); +-- publishing a variant publishes its Item. SECURITY DEFINER: rate_worker holds no write grant +-- on rate.items; the function only derives an Item from the row being written. +CREATE OR REPLACE FUNCTION rate.master_products_sync_item() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_key text; + v_slug text; +BEGIN + IF TG_OP = 'INSERT' OR NEW.model_key IS DISTINCT FROM OLD.model_key OR NEW.item_id IS NULL THEN + v_key := split_part(NEW.model_key, ':', 1) || ':' || split_part(NEW.model_key, ':', 2); + NEW.variant_key := NULLIF(split_part(NEW.model_key, ':', 3), ''); + NEW.item_id := NULL; + SELECT i.id INTO NEW.item_id FROM rate.items i WHERE i.item_key = v_key; + IF NEW.item_id IS NULL THEN + v_slug := rate.item_slug_from_master(NEW.slug, NEW.model_key); + BEGIN + INSERT INTO rate.items (item_key, slug, name, brand, model, category_id, status) + VALUES (v_key, v_slug, rate.item_name_from_master(NEW.name), NEW.brand, NEW.model, + NEW.category_id, CASE WHEN NEW.status = 'published' THEN 'published' ELSE 'draft' END) + ON CONFLICT (item_key) DO NOTHING; + EXCEPTION WHEN unique_violation THEN + -- The slug belongs to another Item: suffix it with a hash of the key. + INSERT INTO rate.items (item_key, slug, name, brand, model, category_id, status) + VALUES (v_key, left(v_slug, 190) || '-' || left(md5(v_key), 8), + rate.item_name_from_master(NEW.name), NEW.brand, NEW.model, + NEW.category_id, CASE WHEN NEW.status = 'published' THEN 'published' ELSE 'draft' END) + ON CONFLICT (item_key) DO NOTHING; + END; + SELECT i.id INTO NEW.item_id FROM rate.items i WHERE i.item_key = v_key; + END IF; + END IF; + IF NEW.status = 'published' AND NEW.item_id IS NOT NULL THEN + UPDATE rate.items SET status = 'published' WHERE id = NEW.item_id AND status <> 'published'; + END IF; + RETURN NEW; +END $$; + +-- A master's listings follow it to a new Item in the same statement. +CREATE OR REPLACE FUNCTION rate.master_products_move_listings() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF NEW.item_id IS DISTINCT FROM OLD.item_id THEN + UPDATE rate.products SET item_id = NEW.item_id + WHERE cluster_id = NEW.id AND item_id IS DISTINCT FROM NEW.item_id; + END IF; + RETURN NULL; +END $$; + +-- A listing's Item follows its variant whenever a variant is set (NULL variant: keep the +-- Item the writer supplied). A hand-set variant (variant_source 'manual') is evidence: a +-- later write, including an ingest that read the listing before it was hand-set, never +-- moves it (the read-then-upsert race of DOS/Rate#1179). Admin tools and the retire RPC move +-- it on purpose with SET LOCAL rate.variant_override = 'on'. +CREATE OR REPLACE FUNCTION rate.products_sync_item() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_status text; + v_variant_key text; +BEGIN + IF TG_OP = 'UPDATE' + AND OLD.variant_source = 'manual' + AND COALESCE(current_setting('rate.variant_override', true), '') <> 'on' + AND (NEW.cluster_id IS DISTINCT FROM OLD.cluster_id + OR NEW.variant_source IS DISTINCT FROM OLD.variant_source + OR NEW.item_id IS DISTINCT FROM OLD.item_id) THEN + NEW.cluster_id := OLD.cluster_id; + NEW.variant_source := OLD.variant_source; + NEW.item_id := OLD.item_id; + END IF; + IF NEW.cluster_id IS NOT NULL THEN + -- No lock here: an INSERT ... ON CONFLICT runs this before it locks the conflicting + -- listing, so locking the variant would invert the listing -> variant lock order. + -- A write that races a running retire is swept up by the retire itself (it re-sweeps + -- after archiving, and a later call sweeps a retired variant again). + SELECT mp.item_id, mp.status, mp.variant_key INTO NEW.item_id, v_status, v_variant_key + FROM rate.master_products mp WHERE mp.id = NEW.cluster_id; + IF v_status = 'archived' AND v_variant_key = 'unknown' + AND (TG_OP = 'INSERT' OR NEW.cluster_id IS DISTINCT FROM OLD.cluster_id) THEN + -- A retired `unknown` variant takes no listing: the listing keeps the Item only. + NEW.cluster_id := NULL; + NEW.variant_source := NULL; + END IF; + END IF; + -- The retire markers describe one move; any later variant or Item change voids them, so + -- the rollback never puts a listing back on a variant of another Item. + IF TG_OP = 'UPDATE' AND NEW.metadata ? 'retired_unknown_variant' + AND (NEW.cluster_id IS NOT NULL OR NEW.item_id IS DISTINCT FROM OLD.item_id) THEN + NEW.metadata := NEW.metadata - 'retired_unknown_variant' - 'variant_source_before_retire'; + END IF; + RETURN NEW; +END $$; + +-- The Item and variant a listing leaves are refreshed by the statement that moves it, under +-- the row lock, so two overlapping ingests of one listing cannot leave the middle Item stale +-- (DOS/Rate#1185 / PR #1190 review). The writer still refreshes the new Item and variant. +CREATE OR REPLACE FUNCTION rate.products_refresh_left() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Lock order everywhere: listing, then variant, then Item. The variant first also gives + -- the Item its post-move variant prices. + IF OLD.cluster_id IS NOT NULL AND OLD.cluster_id IS DISTINCT FROM NEW.cluster_id THEN + PERFORM rate.refresh_master_market_stats(OLD.cluster_id); + END IF; + IF OLD.item_id IS NOT NULL AND OLD.item_id IS DISTINCT FROM NEW.item_id THEN + PERFORM rate.refresh_item_stats(OLD.item_id); + END IF; + RETURN NULL; +END $$; + +REVOKE EXECUTE ON FUNCTION rate.master_products_sync_item() FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION rate.master_products_move_listings() FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION rate.products_sync_item() FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION rate.products_refresh_left() FROM PUBLIC, anon, authenticated; + +-- Re-check at commit: a write that read the variant before a running retire archived it +-- (its BEFORE trigger saw it live) waits on the variant's FK lock until the retire commits, +-- so at its own commit this deferred check sees the variant archived and moves the listing +-- to "no variant, same Item". The retire's sweeps cover writes committed before it. +CREATE OR REPLACE FUNCTION rate.products_recheck_retired_variant() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_override text := COALESCE(current_setting('rate.variant_override', true), ''); +BEGIN + IF NEW.cluster_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM rate.master_products mp + WHERE mp.id = NEW.cluster_id AND mp.status = 'archived' AND mp.variant_key = 'unknown') THEN + PERFORM set_config('rate.variant_override', 'on', true); + UPDATE rate.products p SET cluster_id = NULL, variant_source = NULL + WHERE p.id = NEW.id AND p.cluster_id = NEW.cluster_id; + PERFORM set_config('rate.variant_override', v_override, true); + END IF; + RETURN NULL; +END $$; + +REVOKE EXECUTE ON FUNCTION rate.products_recheck_retired_variant() FROM PUBLIC, anon, authenticated; + +-- M2.1 Item rating and stats --------------------------------------------------------------- + +-- The Item's pooled rating over every crawled listing of the Item, including listings with +-- no variant (design 4.1, Q4), mirrored onto each variant for old readers. Same review filter +-- as refresh_master_product_rating (20261018153000). Variants are written before the Item +-- (lock order: listing, variant, Item). +CREATE OR REPLACE FUNCTION rate.refresh_item_rating(p_item_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_weighted numeric; + v_n integer; +BEGIN + SELECT + COALESCE(round( + (sum(r.rating_star * r.helpfulness_weight) FILTER (WHERE r.reviewer_type = 'user')) + / NULLIF(sum(r.helpfulness_weight) FILTER (WHERE r.reviewer_type = 'user'), 0), + 2 + ), 0), + COALESCE((count(*) FILTER (WHERE r.reviewer_type = 'user'))::integer, 0) + INTO v_weighted, v_n + FROM rate.products p + JOIN rate.reviews r ON r.product_id = p.id + JOIN rate.review_flags f ON f.review_id = r.id + WHERE p.item_id = p_item_id + AND p.origin = 'crawl' + AND r.origin IN ('crawl', 'community') + AND COALESCE(f.include_in_rating, true) + AND r.helpfulness_weight > 0; + + -- Mirror onto the variants in id order. A variant another transaction holds is skipped: + -- that transaction is a refresh of the same Item (every variant write here goes through + -- refresh_master_market_stats or this function) and mirrors when it gets here, so two + -- refreshes of sibling variants never wait on each other. + UPDATE rate.master_products m + SET user_rating_weighted = v_weighted, + authentic_review_count = v_n, + updated_at = now() + WHERE m.id IN ( + SELECT mp.id FROM rate.master_products mp + WHERE mp.item_id = p_item_id + AND (mp.user_rating_weighted, mp.authentic_review_count) IS DISTINCT FROM (v_weighted, v_n) + ORDER BY mp.id + FOR UPDATE SKIP LOCKED + ); + + UPDATE rate.items i + SET user_rating_weighted = v_weighted, + authentic_review_count = v_n + WHERE i.id = p_item_id + AND (i.user_rating_weighted, i.authentic_review_count) IS DISTINCT FROM (v_weighted, v_n); +END $$; + +-- Item prices (from its published variants), counts (over every crawled listing, with and +-- without a variant) and its rating. +CREATE OR REPLACE FUNCTION rate.refresh_item_stats(p_item_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + PERFORM rate.refresh_item_rating(p_item_id); + UPDATE rate.items i + SET min_price = COALESCE(v.min_price, 0), + max_price = COALESCE(v.max_price, 0), + price_variant_count = COALESCE(v.n, 0), + listing_count = l.listing_count, + unknown_variant_listing_count = l.unknown_count, + merchant_count = l.merchant_count + FROM ( + SELECT min(mp.min_price) FILTER (WHERE mp.min_price > 0) AS min_price, + max(mp.max_price) FILTER (WHERE mp.min_price > 0) AS max_price, + (count(*) FILTER (WHERE mp.min_price > 0))::integer AS n + FROM rate.master_products mp + WHERE mp.item_id = p_item_id AND mp.status = 'published' + ) v, + ( + SELECT count(*)::integer AS listing_count, + (count(*) FILTER (WHERE p.cluster_id IS NULL))::integer AS unknown_count, + (count(DISTINCT p.merchant_id))::integer AS merchant_count + FROM rate.products p + WHERE p.item_id = p_item_id AND p.origin = 'crawl' + ) l + WHERE i.id = p_item_id + AND (i.min_price, i.max_price, i.price_variant_count, i.listing_count, + i.unknown_variant_listing_count, i.merchant_count) + IS DISTINCT FROM (COALESCE(v.min_price, 0), COALESCE(v.max_price, 0), COALESCE(v.n, 0), + l.listing_count, l.unknown_count, l.merchant_count); +END $$; + +-- Same signature; a master with an Item rates the whole Item (design 4.5). Without an Item +-- (before the backfill) the 20261018153000 body runs unchanged. +CREATE OR REPLACE FUNCTION rate.refresh_master_product_rating(p_master_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_item uuid; +BEGIN + SELECT m.item_id INTO v_item FROM rate.master_products m WHERE m.id = p_master_id; + IF v_item IS NOT NULL THEN + PERFORM rate.refresh_item_rating(v_item); + RETURN; + END IF; + UPDATE rate.master_products m + SET user_rating_weighted = COALESCE(s.weighted, 0), + authentic_review_count = COALESCE(s.n, 0), + updated_at = now() + FROM ( + SELECT + round( + (sum(r.rating_star * r.helpfulness_weight) FILTER (WHERE r.reviewer_type = 'user')) + / NULLIF(sum(r.helpfulness_weight) FILTER (WHERE r.reviewer_type = 'user'), 0), + 2 + ) AS weighted, + (count(*) FILTER (WHERE r.reviewer_type = 'user'))::integer AS n + FROM rate.products p + JOIN rate.reviews r ON r.product_id = p.id + JOIN rate.review_flags f ON f.review_id = r.id + WHERE p.cluster_id = p_master_id + AND p.origin = 'crawl' + AND r.origin IN ('crawl', 'community') + AND COALESCE(f.include_in_rating, true) + AND r.helpfulness_weight > 0 + ) s + WHERE m.id = p_master_id; +END $$; + +-- Votes on a listing with no variant count too: resolve the Item through products.item_id +-- (design 4.5). Body of 20261018120103 otherwise unchanged. +CREATE OR REPLACE FUNCTION rate.refresh_review_vote_counts(p_review_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_up INTEGER; + v_down INTEGER; + v_rep INTEGER; + v_cat TEXT; + v_verdict TEXT; + v_inc BOOLEAN; + v_master UUID; + v_item UUID; +BEGIN + SELECT + count(*) FILTER (WHERE vote = 'up'), + count(*) FILTER (WHERE vote = 'down'), + count(*) FILTER (WHERE reported) + INTO v_up, v_down, v_rep + FROM rate.review_votes + WHERE review_id = p_review_id; + + SELECT f.flag_category, f.verdict, COALESCE(f.include_in_rating, true) + INTO v_cat, v_verdict, v_inc + FROM rate.review_flags f + WHERE f.review_id = p_review_id; + + UPDATE rate.reviews + SET upvotes = v_up, + downvotes = v_down, + report_count = v_rep, + helpfulness_weight = CASE + WHEN NOT COALESCE(v_inc, true) THEN 0.00 + ELSE rate.compute_review_weight( + v_up, v_down, v_rep, + COALESCE(v_cat, 'authentic'), + COALESCE(v_verdict, 'unanalyzed') + ) + END + WHERE id = p_review_id; + + SELECT p.cluster_id, p.item_id INTO v_master, v_item + FROM rate.reviews r + JOIN rate.products p ON p.id = r.product_id + WHERE r.id = p_review_id; + + IF v_item IS NOT NULL THEN + PERFORM rate.refresh_item_rating(v_item); + ELSIF v_master IS NOT NULL THEN + PERFORM rate.refresh_master_product_rating(v_master); + END IF; +END; +$$; + +-- Compatibility hook (removed in M4 with the sync triggers): writers that do not know Items +-- (the importer with RATE_ITEM_MODEL off, Rate Services before task 13) refresh only the +-- variant; its Item is refreshed in the same call. Variant query of 20261024060000 unchanged. +CREATE OR REPLACE FUNCTION rate.refresh_master_market_stats(p_master_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_item uuid; +BEGIN + WITH priced AS ( + SELECT + p.price_current AS price, + COALESCE(NULLIF(p.price_max, 0), p.price_current) AS price_top, + p.merchant_id, + (pg_catalog.row_number() OVER (ORDER BY p.price_current, p.id) - 1)::INTEGER AS k, + (pg_catalog.count(*) OVER ())::INTEGER AS m + FROM rate.products p + WHERE p.cluster_id = p_master_id + AND p.origin = 'crawl' + AND p.availability = 'in_stock' + AND p.price_current > 0 + ), + judged AS ( + SELECT + c.price, + c.price_top, + c.merchant_id, + (c.m >= 3 AND c.price < 0.4 * r.price) AS implausible + FROM priced c + LEFT JOIN priced r + ON r.k = CASE WHEN (c.m - 2) / 2 < c.k THEN (c.m - 2) / 2 ELSE (c.m - 2) / 2 + 1 END + ), + s AS ( + SELECT + MIN(j.price) FILTER (WHERE NOT j.implausible) AS min_price, + MAX(j.price_top) AS max_price, + COUNT(DISTINCT j.merchant_id)::INTEGER AS merchant_count + FROM judged j + ) + UPDATE rate.master_products m + SET min_price = s.min_price, + max_price = s.max_price, + merchant_count = s.merchant_count, + updated_at = now() + FROM s + WHERE m.id = p_master_id + AND (m.min_price, m.max_price, m.merchant_count) + IS DISTINCT FROM (s.min_price, s.max_price, s.merchant_count); + SELECT m.item_id INTO v_item FROM rate.master_products m WHERE m.id = p_master_id; + IF v_item IS NOT NULL THEN + PERFORM rate.refresh_item_stats(v_item); + END IF; +END $$; + +-- M2.2 Item fuzzy match (design 4.5 step 5) ----------------------------------------------- + +-- Item names carry no storage, so a bare title never scores against a sized name (#1105). +-- p_brand filters; p_model is accepted for the matcher's call shape but does not filter: +-- an Item's model is the key segment and a different spelling must still be found. +CREATE OR REPLACE FUNCTION rate.match_item( + search_title text, + p_brand text DEFAULT NULL, + p_model text DEFAULT NULL, + p_threshold real DEFAULT 0.4 +) +RETURNS TABLE(item_id uuid, item_key text, name text, similarity real) +LANGUAGE plpgsql +STABLE +SET search_path = '' +AS $$ +DECLARE + v_trgm text; +BEGIN + SELECT n.nspname INTO v_trgm + FROM pg_catalog.pg_extension e JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'pg_trgm'; + IF v_trgm IS NULL THEN + RAISE EXCEPTION 'rate.match_item: pg_trgm extension is not installed'; + END IF; + RETURN QUERY EXECUTE format( + 'SELECT i.id, i.item_key, i.name, %I.similarity(i.name, $1) AS similarity + FROM rate.items i + WHERE i.status <> ''archived'' + AND ($2 IS NULL OR lower(i.brand) = lower($2)) + AND %I.similarity(i.name, $1) >= $3 + ORDER BY 4 DESC, i.id + LIMIT 5', + v_trgm, v_trgm + ) USING search_title, p_brand, p_threshold; +END $$; + +-- M2.3 retire an Item's unknown variant (design 4.5 step 4, M3 rule for one Item) --------- + +-- When a multi-size Item (a live sized variant, a category with a storage axis) still has a +-- live `unknown` variant, its listings move to "no variant" and the variant is archived, in +-- one transaction. Lock order as everywhere else: listings, then the variant, then the Item +-- (through the refreshes), so it cannot deadlock with an ingest moving one of those +-- listings. What it changes is recorded in `metadata` (listing: the variant and source it +-- had; variant: its status before), so the rollback can put it back. Returns the number of +-- listings moved. Price alerts stay on the archived variant (an alert needs a size). +CREATE OR REPLACE FUNCTION rate.retire_item_unknown_variant(p_item_id uuid) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_unknown uuid; + v_status text; + v_moved integer := 0; + v_swept integer := 0; + v_override text := COALESCE(current_setting('rate.variant_override', true), ''); +BEGIN + IF EXISTS (SELECT 1 FROM rate.items i JOIN rate.categories c ON c.id = i.category_id + WHERE i.id = p_item_id AND c.slug = 'earbuds') THEN + RETURN 0; + END IF; + SELECT mp.id INTO v_unknown + FROM rate.master_products mp + WHERE mp.item_id = p_item_id AND mp.variant_key = 'unknown' AND mp.status <> 'archived'; + IF v_unknown IS NULL THEN + -- Already retired: move any listing a racing writer put back on it. + SELECT mp.id INTO v_unknown + FROM rate.master_products mp + WHERE mp.item_id = p_item_id AND mp.variant_key = 'unknown' AND mp.status = 'archived' + AND mp.metadata ->> 'retired_by' = 'retire_item_unknown_variant'; + IF v_unknown IS NULL THEN + RETURN 0; + END IF; + PERFORM set_config('rate.variant_override', 'on', true); + UPDATE rate.products p + SET cluster_id = NULL, + variant_source = NULL, + metadata = COALESCE(p.metadata, '{}'::jsonb) + || jsonb_build_object('retired_unknown_variant', v_unknown, + 'variant_source_before_retire', p.variant_source) + WHERE p.cluster_id = v_unknown; + GET DIAGNOSTICS v_moved = ROW_COUNT; + PERFORM set_config('rate.variant_override', v_override, true); + IF v_moved > 0 THEN + PERFORM rate.refresh_master_market_stats(v_unknown); + PERFORM rate.refresh_item_stats(p_item_id); + END IF; + RETURN v_moved; + END IF; + PERFORM 1 FROM rate.products p WHERE p.cluster_id = v_unknown ORDER BY p.id FOR UPDATE; + SELECT mp.status INTO v_status FROM rate.master_products mp WHERE mp.id = v_unknown FOR UPDATE; + -- Re-check under the locks: a concurrent call may have retired it, or the sized variant + -- may have been archived meanwhile. + IF v_status IS NULL OR v_status = 'archived' OR NOT EXISTS ( + SELECT 1 FROM rate.master_products mp + WHERE mp.item_id = p_item_id AND mp.status <> 'archived' + AND mp.variant_key ~ '^[0-9]+(gb|tb)$') THEN + RETURN 0; + END IF; + PERFORM set_config('rate.variant_override', 'on', true); + UPDATE rate.products p + SET cluster_id = NULL, + variant_source = NULL, + metadata = COALESCE(p.metadata, '{}'::jsonb) + || jsonb_build_object('retired_unknown_variant', v_unknown, + 'variant_source_before_retire', p.variant_source) + WHERE p.cluster_id = v_unknown; + GET DIAGNOSTICS v_moved = ROW_COUNT; + PERFORM set_config('rate.variant_override', v_override, true); + UPDATE rate.master_products mp + SET status = 'archived', + metadata = COALESCE(mp.metadata, '{}'::jsonb) + || jsonb_build_object('status_before_retire', v_status, 'retired_by', 'retire_item_unknown_variant') + WHERE mp.id = v_unknown; + -- Sweep again: a listing written onto the variant while this ran (its writer read the + -- variant before the archive) is moved too; later writes see it archived. + PERFORM set_config('rate.variant_override', 'on', true); + UPDATE rate.products p + SET cluster_id = NULL, + variant_source = NULL, + metadata = COALESCE(p.metadata, '{}'::jsonb) + || jsonb_build_object('retired_unknown_variant', v_unknown, + 'variant_source_before_retire', p.variant_source) + WHERE p.cluster_id = v_unknown; + GET DIAGNOSTICS v_swept = ROW_COUNT; + PERFORM set_config('rate.variant_override', v_override, true); + v_moved := v_moved + v_swept; + PERFORM rate.refresh_master_market_stats(v_unknown); + PERFORM rate.refresh_item_stats(p_item_id); + RETURN v_moved; +END $$; + +-- M2.4 function grants --------------------------------------------------------------------- + +REVOKE EXECUTE ON FUNCTION rate.refresh_item_rating(uuid) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION rate.refresh_item_stats(uuid) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION rate.match_item(text, text, text, real) FROM PUBLIC, anon, authenticated; +REVOKE EXECUTE ON FUNCTION rate.retire_item_unknown_variant(uuid) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION rate.refresh_item_rating(uuid) TO service_role, rate_worker; +GRANT EXECUTE ON FUNCTION rate.refresh_item_stats(uuid) TO service_role, rate_worker; +GRANT EXECUTE ON FUNCTION rate.match_item(text, text, text, real) TO service_role, rate_worker; +GRANT EXECUTE ON FUNCTION rate.retire_item_unknown_variant(uuid) TO service_role, rate_worker; + +-- M1.6 triggers (after the functions they call exist) ------------------------------------- + +DROP TRIGGER IF EXISTS trg_master_products_sync_item ON rate.master_products; +CREATE TRIGGER trg_master_products_sync_item + BEFORE INSERT OR UPDATE OF model_key, status, item_id ON rate.master_products + FOR EACH ROW EXECUTE FUNCTION rate.master_products_sync_item(); +DROP TRIGGER IF EXISTS trg_master_products_move_listings ON rate.master_products; +CREATE TRIGGER trg_master_products_move_listings + AFTER UPDATE OF model_key, item_id ON rate.master_products + FOR EACH ROW EXECUTE FUNCTION rate.master_products_move_listings(); +DROP TRIGGER IF EXISTS trg_products_sync_item ON rate.products; +CREATE TRIGGER trg_products_sync_item + BEFORE INSERT OR UPDATE OF cluster_id, item_id, variant_source ON rate.products + FOR EACH ROW EXECUTE FUNCTION rate.products_sync_item(); +DROP TRIGGER IF EXISTS trg_products_refresh_left ON rate.products; +CREATE TRIGGER trg_products_refresh_left + AFTER UPDATE OF cluster_id, item_id ON rate.products + FOR EACH ROW EXECUTE FUNCTION rate.products_refresh_left(); +DROP TRIGGER IF EXISTS trg_products_recheck_retired_variant ON rate.products; +CREATE CONSTRAINT TRIGGER trg_products_recheck_retired_variant + AFTER INSERT OR UPDATE OF cluster_id ON rate.products + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION rate.products_recheck_retired_variant(); + +-- M2.5 backfill (design 7.1) --------------------------------------------------------------- + +-- Abort before writing if two Items would share a slug (the M0 inventory found none). +DO $$ +DECLARE + v_clash text; +BEGIN + SELECT string_agg(slug, ', ') INTO v_clash FROM ( + SELECT rate.item_slug_from_master(mp.slug, mp.model_key) AS slug + FROM rate.master_products mp + WHERE mp.item_id IS NULL + GROUP BY 1 + HAVING count(DISTINCT split_part(mp.model_key, ':', 1) || ':' || split_part(mp.model_key, ':', 2)) > 1 + ) c; + IF v_clash IS NOT NULL THEN + RAISE EXCEPTION 'M2 backfill: Item slug collisions, resolve them first: %', v_clash; + END IF; + -- ...or take the slug of an Item that already exists under another key. + SELECT string_agg(DISTINCT i.slug, ', ') INTO v_clash + FROM rate.master_products mp + JOIN rate.items i ON i.slug = rate.item_slug_from_master(mp.slug, mp.model_key) + WHERE mp.item_id IS NULL + AND i.item_key <> split_part(mp.model_key, ':', 1) || ':' || split_part(mp.model_key, ':', 2); + IF v_clash IS NOT NULL THEN + RAISE EXCEPTION 'M2 backfill: Item slugs already taken by other Items: %', v_clash; + END IF; +END $$; + +UPDATE rate.master_products mp +SET variant_key = NULLIF(split_part(mp.model_key, ':', 3), '') +WHERE mp.variant_key IS DISTINCT FROM NULLIF(split_part(mp.model_key, ':', 3), ''); + +-- One Item per brand:model, including Items whose masters are all archived. Representative +-- variant: published first, then most merchants, then oldest. Item specs carry no storage or +-- RAM (variant overrides, design 4.1). +INSERT INTO rate.items (item_key, slug, name, brand, model, category_id, status, entity_type, + description, specifications, key_specs, spec_provenance, images, thumbnail, + expert_rating, expert_review_summary, expert_criteria_scores, metadata) +SELECT DISTINCT ON (k.item_key) + k.item_key, + rate.item_slug_from_master(mp.slug, mp.model_key), + rate.item_name_from_master(mp.name), + mp.brand, mp.model, mp.category_id, + CASE WHEN bool_or(mp.status = 'published') OVER w THEN 'published' + WHEN bool_or(COALESCE(mp.status, 'draft') <> 'archived') OVER w THEN 'draft' + ELSE 'archived' END, + COALESCE(mp.entity_type, 'product'), mp.description, + COALESCE(mp.specifications, '{}'::jsonb) #- '{hardware,storage_gb}' #- '{hardware,ram_gb}', + COALESCE(mp.key_specs, '{}'::jsonb) - 'storage' - 'ram', + COALESCE(mp.spec_provenance, '{}'::jsonb), COALESCE(mp.images, '{}'), mp.thumbnail, + mp.expert_rating, mp.expert_review_summary, COALESCE(mp.expert_criteria_scores, '{}'::jsonb), + COALESCE(mp.metadata, '{}'::jsonb) || jsonb_build_object('backfilled_from_master', mp.id) +FROM rate.master_products mp +CROSS JOIN LATERAL ( + SELECT split_part(mp.model_key, ':', 1) || ':' || split_part(mp.model_key, ':', 2) AS item_key +) k +WINDOW w AS (PARTITION BY k.item_key) +ORDER BY k.item_key, (mp.status = 'published') DESC NULLS LAST, + mp.merchant_count DESC NULLS LAST, mp.created_at ASC NULLS LAST, mp.id +ON CONFLICT (item_key) DO NOTHING; + +UPDATE rate.master_products mp SET item_id = i.id +FROM rate.items i +WHERE mp.item_id IS DISTINCT FROM i.id + AND i.item_key = split_part(mp.model_key, ':', 1) || ':' || split_part(mp.model_key, ':', 2); + +-- Re-runnable and self-correcting: also fixes listings whose master changed after a run. +UPDATE rate.products p +SET item_id = mp.item_id, variant_source = COALESCE(p.variant_source, 'legacy') +FROM rate.master_products mp +WHERE p.cluster_id = mp.id + AND (p.item_id IS DISTINCT FROM mp.item_id OR p.variant_source IS NULL); + +-- Items that already existed (created by the sync trigger, or by a re-run) take their status +-- from their masters, so a published product is never hidden behind a draft Item. +UPDATE rate.items i SET status = s.status +FROM ( + SELECT item_id, + CASE WHEN bool_or(status = 'published') THEN 'published' + WHEN bool_or(COALESCE(status, 'draft') <> 'archived') THEN 'draft' + ELSE 'archived' END AS status + FROM rate.master_products WHERE item_id IS NOT NULL GROUP BY item_id +) s +WHERE i.id = s.item_id AND i.status IS DISTINCT FROM s.status; + +-- Item aggregates and ratings. +DO $$ +DECLARE + v_id uuid; +BEGIN + FOR v_id IN SELECT i.id FROM rate.items i ORDER BY i.id LOOP + PERFORM rate.refresh_item_stats(v_id); + END LOOP; +END $$; + +COMMIT; diff --git a/scripts/test-item-migration-proposal.mjs b/scripts/test-item-migration-proposal.mjs new file mode 100644 index 000000000..f87f75663 --- /dev/null +++ b/scripts/test-item-migration-proposal.mjs @@ -0,0 +1,395 @@ +#!/usr/bin/env node +// Replays the DOS-Me M1+M2 Item migration PROPOSAL (DOS/Rate#1150) on the same throwaway +// PostgreSQL 17 stack as test-migration-replay.mjs: every reference migration, a small +// pre-migration catalog, the proposal twice (idempotency), its verification queries, the +// Item RPCs and triggers as the roles that call them, then the rollback twice and the +// proposal again. The proposal is not a migration: nothing here touches a real database. +// +// Usage: node scripts/test-item-migration-proposal.mjs (needs Docker) + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { repoRoot, startRateDb } from './lib/rate-db.mjs'; + +const proposalDir = path.join(repoRoot, 'Docs', 'Proposals'); +const read = (name) => readFileSync(path.join(proposalDir, name), 'utf8'); +const proposal = read('2026-10-28-dos-me-m1-m2-items.sql'); +const rollback = read('2026-10-28-dos-me-m1-m2-items-rollback.sql'); +const verify = read('2026-10-28-dos-me-m1-m2-items-verify.sql'); + +const container = `rate-item-proposal-${process.pid}`; +const removeContainer = () => + spawnSync('docker', ['rm', '--force', container], { timeout: 30_000 }); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + removeContainer(); + process.exit(130); + }); +} + +// The verification file's "expect 0" queries, by their V number. +const expectZero = [ + ...verify.matchAll( + /^-- (V\d+) [^\n]*expect 0\n(?:--[^\n]*\n)*(SELECT[\s\S]*?;)/gm + ), +].map(([, id, query]) => ({ id, query })); +assert.ok( + expectZero.length >= 10, + 'the verification file lists its expect-0 queries' +); + +const ids = { + laptops: 'c0000000-0000-4000-8000-000000000001', + earbuds: 'c0000000-0000-4000-8000-000000000002', + shop: 'e0000000-0000-4000-8000-000000000001', + shop2: 'e0000000-0000-4000-8000-000000000002', + zen1tb: 'a0000000-0000-4000-8000-000000000001', + wf: 'a0000000-0000-4000-8000-000000000002', + zenListing: 'b0000000-0000-4000-8000-000000000001', + zenListing2: 'b0000000-0000-4000-8000-000000000002', + wfListing: 'b0000000-0000-4000-8000-000000000003', + review: 'd0000000-0000-4000-8000-000000000001', +}; + +try { + const { sql, asRole } = startRateDb({ name: container }); + + // A pre-migration catalog: a sized laptop variant with two listings and a review, and an + // earbuds line keyed `unknown` (single-variant Item). + sql(` + INSERT INTO rate.categories (id, name, slug) VALUES + ('${ids.laptops}', 'Laptop', 'laptops'), ('${ids.earbuds}', 'Tai nghe', 'earbuds'); + INSERT INTO rate.merchants (id, platform, platform_shop_id, name, slug) VALUES + ('${ids.shop}', 'shopee', 's1', 'Shop 1', 'shop-1'), + ('${ids.shop2}', 'tiki', 's2', 'Shop 2', 'shop-2'); + INSERT INTO rate.master_products (id, brand, model, model_key, name, slug, category_id, status, + specifications, key_specs, min_price, max_price) + VALUES + ('${ids.zen1tb}', 'Asus', 'Zenbook S 14', 'asus:zenbook-s-14:1024gb', 'Asus Zenbook S 14 1TB', + 'asus-zenbook-s-14-1tb', '${ids.laptops}', 'published', + '{"hardware": {"chipset": "Core Ultra 7 258V", "storage_gb": 1024, "ram_gb": 32}}', + '{"chip": "Core Ultra 7", "storage": "1TB", "ram": "32GB"}', 30000000, 31000000), + ('${ids.wf}', 'Sony', 'WF-1000XM5', 'sony:wf-1000xm5:unknown', 'Sony WF-1000XM5', + 'sony-wf-1000xm5', '${ids.earbuds}', 'draft', '{}', '{}', 0, 0); + INSERT INTO rate.products (id, merchant_id, platform, platform_item_id, title, price_current, url, cluster_id) + VALUES + ('${ids.zenListing}', '${ids.shop}', 'shopee', 'z1', 'Asus Zenbook S 14 1TB', 30000000, 'https://shopee.vn/z1', '${ids.zen1tb}'), + ('${ids.zenListing2}', '${ids.shop2}', 'tiki', 'z2', 'Asus Zenbook S 14 1TB', 31000000, 'https://tiki.vn/z2', '${ids.zen1tb}'), + ('${ids.wfListing}', '${ids.shop}', 'shopee', 'w1', 'Sony WF-1000XM5', 5000000, 'https://shopee.vn/w1', '${ids.wf}'); + INSERT INTO rate.reviews (id, product_id, rating_star, comment, reviewer_type) + VALUES ('${ids.review}', '${ids.zenListing}', 4, 'Máy mỏng nhẹ, pin dùng cả ngày.', 'user'); + INSERT INTO rate.review_flags (review_id, verdict, include_in_rating, detector, detector_version, input_hash) + VALUES ('${ids.review}', 'authentic', true, 'replay', 'v1', 'h'); + UPDATE rate.reviews SET helpfulness_weight = 1 WHERE id = '${ids.review}'; + `); + + // The proposal applies twice on a live catalog. + sql(proposal); + sql(proposal); + + const checkVerify = (when) => { + for (const { id, query } of expectZero) { + assert.equal(sql(query), '0', `${id} after ${when}`); + } + }; + checkVerify('the backfill'); + + // Backfill: one Item per brand:model, sizes stripped from slug, name and specs. + assert.equal( + sql( + `SELECT slug || '|' || name || '|' || status FROM rate.items WHERE item_key = 'asus:zenbook-s-14'` + ), + 'asus-zenbook-s-14|Asus Zenbook S 14|published' + ); + assert.equal( + sql(`SELECT (specifications #>> '{hardware,chipset}') || '|' || coalesce(specifications #>> '{hardware,storage_gb}', '-') + || '|' || coalesce(key_specs ->> 'storage', '-') FROM rate.items WHERE item_key = 'asus:zenbook-s-14'`), + 'Core Ultra 7 258V|-|-' + ); + assert.equal( + sql( + `SELECT slug || '|' || status FROM rate.items WHERE item_key = 'sony:wf-1000xm5'` + ), + 'sony-wf-1000xm5|draft' + ); + assert.equal( + sql(`SELECT string_agg(DISTINCT variant_source, ',') FROM rate.products`), + 'legacy' + ); + // Item stats and the pooled rating, mirrored onto the variant. + assert.equal( + sql(`SELECT min_price::bigint || '|' || listing_count || '|' || merchant_count || '|' || user_rating_weighted + || '|' || authentic_review_count FROM rate.items WHERE item_key = 'asus:zenbook-s-14'`), + '30000000|2|2|4.00|1' + ); + assert.equal( + sql( + `SELECT user_rating_weighted || '|' || authentic_review_count FROM rate.master_products WHERE id = '${ids.zen1tb}'` + ), + '4.00|1' + ); + + // RLS: anon sees published Items only and runs no Item RPC. + assert.equal(asRole('anon', 'SELECT count(*) FROM rate.items'), '1'); + for (const fn of [ + `rate.match_item('Asus Zenbook S 14', 'Asus', NULL, 0.3)`, + `rate.refresh_item_stats('${ids.zen1tb}')`, + `rate.retire_item_unknown_variant('${ids.zen1tb}')`, + ]) { + assert.throws( + () => asRole('anon', `SELECT * FROM ${fn}`), + /permission denied for function/ + ); + } + + // The worker: reads draft Items, fuzzy-matches on Item names, and its inserts get their + // Item from the M1 trigger (SECURITY DEFINER; the worker has no write grant on items). + assert.equal(asRole('rate_worker', 'SELECT count(*) FROM rate.items'), '2'); + assert.equal( + asRole( + 'rate_worker', + `SELECT item_key FROM rate.match_item('Asus Zenbook S 14 OLED', 'Asus', 'Zenbook S 14', 0.3) LIMIT 1` + ), + 'asus:zenbook-s-14' + ); + assert.throws( + () => + asRole( + 'rate_worker', + `INSERT INTO rate.items (item_key, slug, name, brand, model) VALUES ('x:y', 'x-y', 'X', 'X', 'Y')` + ), + /permission denied for table items/ + ); + // It may update an Item's specs (RS#700), never its status or key. + asRole( + 'rate_worker', + `UPDATE rate.items SET key_specs = key_specs || '{"os": "Windows 11"}' WHERE item_key = 'asus:zenbook-s-14'` + ); + assert.equal( + sql( + `SELECT key_specs ->> 'os' FROM rate.items WHERE item_key = 'asus:zenbook-s-14'` + ), + 'Windows 11' + ); + for (const change of [ + `status = 'archived'`, + `item_key = 'x:y'`, + `min_price = 1`, + ]) { + assert.throws( + () => + asRole( + 'rate_worker', + `UPDATE rate.items SET ${change} WHERE item_key = 'asus:zenbook-s-14'` + ), + /permission denied for table items/, + change + ); + } + asRole( + 'rate_worker', + `INSERT INTO rate.master_products (brand, model, model_key, name, slug, category_id, status) + VALUES ('Asus', 'Zenbook S 14', 'asus:zenbook-s-14:512gb', 'Asus Zenbook S 14 512GB', 'asus-zenbook-s-14-512gb', '${ids.laptops}', 'draft'), + ('Apple', 'iPhone 17', 'apple:iphone-17:256gb', 'Apple iPhone 17 256GB', 'apple-iphone-17-256gb', NULL, 'published'), + ('Asus', 'Zenbook S 14', 'asus:zenbook-s-14:unknown', 'Asus Zenbook S 14', 'asus-zenbook-s-14-unknown', '${ids.laptops}', 'draft')` + ); + assert.equal( + sql(`SELECT count(DISTINCT item_id) || '|' || string_agg(variant_key, ',' ORDER BY variant_key) + FROM rate.master_products WHERE model_key LIKE 'asus:zenbook-s-14:%'`), + '1|1024gb,512gb,unknown' + ); + assert.equal( + sql( + `SELECT slug || '|' || name || '|' || status FROM rate.items WHERE item_key = 'apple:iphone-17'` + ), + 'apple-iphone-17|Apple iPhone 17|published', + 'a published variant creates a published Item' + ); + + // A listing's Item follows its variant. + const unknownId = sql( + `SELECT id FROM rate.master_products WHERE model_key = 'asus:zenbook-s-14:unknown'` + ); + const iphoneId = sql( + `SELECT id FROM rate.master_products WHERE model_key = 'apple:iphone-17:256gb'` + ); + asRole( + 'rate_worker', + `INSERT INTO rate.products (merchant_id, platform, platform_item_id, title, price_current, url, cluster_id, variant_source) + VALUES ('${ids.shop}', 'shopee', 'z3', 'Asus Zenbook S 14', 28000000, 'https://shopee.vn/z3', '${unknownId}', NULL)` + ); + assert.equal( + sql( + `SELECT p.item_id = mp.item_id FROM rate.products p JOIN rate.master_products mp ON mp.id = '${unknownId}' WHERE p.platform_item_id = 'z3'` + ), + 't' + ); + + // A hand-set variant survives a racing ingest; an override moves it on purpose. + sql( + `UPDATE rate.products SET variant_source = 'manual' WHERE id = '${ids.zenListing2}'` + ); + asRole( + 'rate_worker', + `UPDATE rate.products SET cluster_id = '${iphoneId}', variant_source = 'title' WHERE id = '${ids.zenListing2}'` + ); + assert.equal( + sql( + `SELECT cluster_id || '|' || variant_source FROM rate.products WHERE id = '${ids.zenListing2}'` + ), + `${ids.zen1tb}|manual` + ); + // The Item a listing leaves is refreshed by the move itself (no caller refresh). + sql(`BEGIN; SET LOCAL rate.variant_override = 'on'; + UPDATE rate.products SET cluster_id = '${iphoneId}' WHERE id = '${ids.zenListing2}'; COMMIT;`); + assert.equal( + sql( + `SELECT cluster_id || '|' || variant_source FROM rate.products WHERE id = '${ids.zenListing2}'` + ), + `${iphoneId}|manual` + ); + assert.equal( + sql( + `SELECT listing_count FROM rate.items WHERE item_key = 'asus:zenbook-s-14'` + ), + '2', + 'the left Item lost the moved listing (z1, z3 remain)' + ); + // The writer refreshes the Item the listing joins, as the importer does. + asRole( + 'rate_worker', + `SELECT rate.refresh_item_stats(item_id) FROM rate.master_products WHERE id = '${iphoneId}'` + ); + assert.equal( + sql( + `SELECT listing_count FROM rate.items WHERE item_key = 'apple:iphone-17'` + ), + '1' + ); + // A writer that knows no Items refreshes only the variant (the old RPC): its Item follows. + sql( + `UPDATE rate.products SET price_current = 29000000 WHERE id = '${ids.zenListing}'` + ); + asRole( + 'service_role', + `SELECT rate.refresh_master_market_stats('${ids.zen1tb}')` + ); + assert.equal( + sql( + `SELECT min_price::bigint FROM rate.items WHERE item_key = 'asus:zenbook-s-14'` + ), + '29000000' + ); + + // Retire: the multi-size Item's unknown variant goes; its listing keeps the Item. + assert.equal( + asRole( + 'rate_worker', + `SELECT rate.retire_item_unknown_variant(item_id) FROM rate.master_products WHERE id = '${unknownId}'` + ), + '1' + ); + assert.equal( + sql(`SELECT (p.cluster_id IS NULL) || '|' || (p.item_id = mp.item_id) || '|' || mp.status + FROM rate.products p, rate.master_products mp WHERE p.platform_item_id = 'z3' AND mp.id = '${unknownId}'`), + 'true|true|archived' + ); + assert.equal( + sql( + `SELECT unknown_variant_listing_count FROM rate.items WHERE item_key = 'asus:zenbook-s-14'` + ), + '1' + ); + // A writer that still holds the retired variant's id cannot put a listing back on it. + asRole( + 'rate_worker', + `INSERT INTO rate.products (merchant_id, platform, platform_item_id, title, price_current, url, cluster_id) + VALUES ('${ids.shop2}', 'tiki', 'z4', 'Asus Zenbook S 14', 28500000, 'https://tiki.vn/z4', '${unknownId}')` + ); + assert.equal( + sql(`SELECT (p.cluster_id IS NULL) || '|' || (p.item_id = mp.item_id) + FROM rate.products p, rate.master_products mp WHERE p.platform_item_id = 'z4' AND mp.id = '${unknownId}'`), + 'true|true' + ); + asRole( + 'rate_worker', + `SELECT rate.refresh_item_stats(item_id) FROM rate.master_products WHERE id = '${unknownId}'` + ); + // A caller's own override survives the retire call. + assert.equal( + sql(`BEGIN; SET LOCAL rate.variant_override = 'on'; + SELECT rate.retire_item_unknown_variant(item_id) FROM rate.master_products WHERE id = '${unknownId}'; + SELECT current_setting('rate.variant_override'); COMMIT;`), + '0\non' + ); + // Earbuds and an Item without a live unknown variant are left alone. + assert.equal( + sql( + `SELECT rate.retire_item_unknown_variant(item_id) FROM rate.master_products WHERE id = '${ids.wf}'` + ), + '0' + ); + assert.equal( + sql( + `SELECT rate.retire_item_unknown_variant(item_id) FROM rate.master_products WHERE id = '${unknownId}'` + ), + '0' + ); + + // A vote on a listing with no variant still rates its Item. + sql(`INSERT INTO rate.reviews (id, product_id, rating_star, comment, reviewer_type) + SELECT 'd0000000-0000-4000-8000-000000000002', id, 2, 'Nóng máy khi chạy nặng.', 'user' + FROM rate.products WHERE platform_item_id = 'z3'; + INSERT INTO rate.review_flags (review_id, verdict, include_in_rating, detector, detector_version, input_hash) + VALUES ('d0000000-0000-4000-8000-000000000002', 'authentic', true, 'replay', 'v1', 'h2'); + INSERT INTO auth.users (id) VALUES ('f0000000-0000-4000-8000-000000000001'); + INSERT INTO rate.review_votes (review_id, voter_id, vote) + VALUES ('d0000000-0000-4000-8000-000000000002', 'f0000000-0000-4000-8000-000000000001', 'up');`); + assert.equal( + sql( + `SELECT authentic_review_count FROM rate.items WHERE item_key = 'asus:zenbook-s-14'` + ), + '2', + 'the vote trigger refreshed the Item rating through products.item_id' + ); + + checkVerify('the trigger and RPC checks'); + + // Rollback (twice), then the proposal applies again on the same data. + sql(rollback); + sql(rollback); + assert.equal(sql(`SELECT to_regclass('rate.items') IS NULL`), 't'); + assert.equal( + sql(`SELECT count(*) FROM information_schema.columns + WHERE table_schema = 'rate' AND column_name IN ('item_id', 'variant_key', 'variant_source', 'sort_order')`), + '0' + ); + // The retired variant and its listing are back; ratings are per variant again. + assert.equal( + sql(`SELECT p.cluster_id || '|' || mp.status FROM rate.products p + JOIN rate.master_products mp ON mp.id = p.cluster_id WHERE p.platform_item_id = 'z3'`), + `${unknownId}|draft` + ); + assert.equal( + sql( + `SELECT authentic_review_count FROM rate.master_products WHERE id = '${ids.zen1tb}'` + ), + '1', + 'the pooled Item rating is not left on the variant' + ); + sql(proposal); + // The importer retires the restored unknown variant again on its next write. + sql( + `SELECT rate.retire_item_unknown_variant(item_id) FROM rate.master_products WHERE id = '${unknownId}'` + ); + checkVerify('rollback and re-apply'); + + console.log( + `Item migration proposal passed: applied twice, ${expectZero.length} verification checks, triggers and RPCs exercised, rollback and re-apply` + ); +} finally { + removeContainer(); +} From cc0ca9059bcf1e621667b7e9d93426a7d6d85eb9 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:43:19 +0700 Subject: [PATCH 35/69] fix(web): no review risk number on seller surfaces without reviews (#1266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): no review risk number on seller surfaces without reviews The home search inspector, the deep inspector and the product page's seller lines say "not enough data" for a store with no analysed reviews, and the CSV/JSON exports leave the score empty. A discontinued listing reads "Ngừng bán", not "Hết hàng". The directory now reads reviews for stores with a stored score but no persisted breakdown, as the profile does, so both agree on whether reviews were analysed. * fix(web): public merchant payloads carry no baseline review risk /api/v1/rate/merchants, /merchants/[id], the extension lookup and the directory JSON export now carry a null review_risk_score for a store with no analysed review, and the extension side panel says "chưa đủ dữ liệu". A seller whose breakdown has no review count keeps its stored score, so offer lines do not lose real scores. * fix(web): offer sellers without reviews read as no data A seller the importer never scored now carries a breakdown counting no review on offer lines, so the product page says "not enough data" as the merchant page does. The directory, lookup and inspect payloads also carry a null review_risk_score for such stores, the OpenAPI text says so, and the extension's lookup type allows null. Co-Authored-By: Claude Opus 5.5 --- apps/extension/src/sidepanel/sidepanel.ts | 14 +- apps/extension/src/types.ts | 3 +- apps/web/locales/cs.json | 14 +- apps/web/locales/en.json | 14 +- apps/web/locales/vi.json | 14 +- .../src/app/[locale]/merchants/[id]/page.tsx | 4 +- .../app/api/v1/rate/extension/lookup/route.ts | 8 +- apps/web/src/app/api/v1/rate/inspect/route.ts | 4 + apps/web/src/app/api/v1/rate/lookup/route.ts | 4 + .../app/api/v1/rate/merchants/[id]/route.ts | 3 +- .../api/v1/rate/merchants/directory/route.ts | 6 +- .../src/app/api/v1/rate/merchants/route.ts | 3 +- .../components/home/HeroSearchInspector.tsx | 24 +- .../inspect/DeepInspectWorkspace.tsx | 64 +++-- .../leaderboard/ProductOffersExplorer.tsx | 23 +- .../merchants/MerchantDirectoryExplorer.tsx | 12 +- .../merchants/MerchantShareAuditButton.tsx | 5 +- .../__tests__/merchant-review-risk.test.ts | 234 +++++++++++++++++- apps/web/src/lib/merchant-risk-tone.ts | 5 +- apps/web/src/lib/rate-api.ts | 86 ++++--- apps/web/src/lib/rate-domain-core.ts | 30 ++- .../__tests__/core-rate-architecture.test.ts | 9 +- apps/web/src/server/rate/api-handler.ts | 19 ++ apps/web/src/server/rate/openapi.ts | 6 +- 24 files changed, 501 insertions(+), 107 deletions(-) diff --git a/apps/extension/src/sidepanel/sidepanel.ts b/apps/extension/src/sidepanel/sidepanel.ts index 72f18b74c..2a658e817 100644 --- a/apps/extension/src/sidepanel/sidepanel.ts +++ b/apps/extension/src/sidepanel/sidepanel.ts @@ -213,12 +213,20 @@ interface MerchantTrustFeedItem { slug: string | null; is_official: boolean; dosafe_risk_score: number; - review_risk_score: number; + /** Null when the store has no analysed review (DOS/Rate#1258). */ + review_risk_score: number | null; risk_score: number; trust_score: number; product_count: number; } +// A store with no analysed review has no review risk score (DOS/Rate#1258). +function reviewRiskText(score: number | null | undefined): string { + return score == null + ? 'chưa đủ dữ liệu' + : formatSafeNumber(score, { min: 0, max: 100 }); +} + async function fetchAndRenderMerchantTrustDirectory( settings: ExtensionSettings, platform?: string @@ -278,7 +286,7 @@ async function fetchAndRenderMerchantTrustDirectory( ${m.is_official ? '[Mall]' : ''}
- ${escapeHtml(m.platform.toUpperCase())} (${formatSafeNumber(m.product_count, { min: 0 })} SP) - Rủi ro lừa đảo: ${formatSafeNumber(m.dosafe_risk_score, { min: 0, max: 100 })} | Rủi ro đánh giá: ${formatSafeNumber(m.review_risk_score, { min: 0, max: 100 })} + ${escapeHtml(m.platform.toUpperCase())} (${formatSafeNumber(m.product_count, { min: 0 })} SP) - Rủi ro lừa đảo: ${formatSafeNumber(m.dosafe_risk_score, { min: 0, max: 100 })} | Rủi ro đánh giá: ${reviewRiskText(m.review_risk_score)}
@@ -464,7 +472,7 @@ function renderPanelContent(
Rủi ro lừa đảo / đánh giá
-
${formatSafeNumber(merchant.dosafe_risk_score, { min: 0, max: 100 })} / ${formatSafeNumber(merchant.review_risk_score, { min: 0, max: 100 })}
+
${formatSafeNumber(merchant.dosafe_risk_score, { min: 0, max: 100 })} / ${reviewRiskText(merchant.review_risk_score)}
diff --git a/apps/extension/src/types.ts b/apps/extension/src/types.ts index 5cec72911..613c94262 100644 --- a/apps/extension/src/types.ts +++ b/apps/extension/src/types.ts @@ -125,7 +125,8 @@ export interface ExtensionLookupData { trust_score: number; risk_score: number; dosafe_risk_score: number; - review_risk_score: number; + /** Null when the store has no analysed review (DOS/Rate#1258). */ + review_risk_score: number | null; } | null; reviewSummary: { total_analyzed: number; diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index ec88c4a01..a87c0efdb 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -808,7 +808,8 @@ "checkedStatus": "Zkontrolováno", "errInspectFailed": "Tento odkaz se nepodařilo zkontrolovat.", "errNetwork": "Chyba sítě, zkuste to znovu.", - "loading": "Načítání..." + "loading": "Načítání...", + "reviewRiskNoData": "nedostatek dat" }, "merchants": { "dirMetaTitle": "Důvěryhodnost prodejců | Rate.vn", @@ -972,7 +973,8 @@ "mdAuthenticLineNoReviews": "- **Podíl skutečných recenzí:** zatím žádné recenze", "listingUnavailableBadge": "Není skladem", "listingUnavailableTitle": "Obchod jej hlásí jako vyprodaný nebo ukončený. Cena je naposledy zjištěná, ne aktuální.", - "listingUnavailableAudit": "Není skladem, sleva se neposuzuje" + "listingUnavailableAudit": "Není skladem, sleva se neposuzuje", + "listingDiscontinuedBadge": "Ukončený prodej" }, "productDetail": { "notFoundTitle": "Produkt nenalezen | Rate.vn", @@ -1181,7 +1183,10 @@ "heroPriceLine": "Od {price} ₫ · {count, plural, one {# prodejce} few {# prodejci} other {# prodejců}}", "specsShowAll": "Zobrazit všechny parametry", "specsShowLess": "Zobrazit méně parametrů", - "exportMenu": "Exportovat data" + "exportMenu": "Exportovat data", + "offersReviewRiskLabel": "Riziko recenzí", + "offersReviewRiskNoData": "nedostatek dat", + "offersDiscontinuedBadge": "Ukončený prodej" }, "homePage": { "catDescSmartphones": "Filtrovat podle čipsetu, RAM/ROM, obnovovací frekvence Hz, baterie mAh a nabíjení W", @@ -1784,7 +1789,8 @@ "trustBadge": "Důvěra: {score}/100", "merchantRiskLine": "Riziko podvodu: {scam}/100 • Riziko recenzí: {review}/100", "officialMallBadge": "Oficiální Mall", - "scamRiskShort": "Riziko podvodu: {score}/100" + "scamRiskShort": "Riziko podvodu: {score}/100", + "merchantRiskLineNoReviews": "Riziko podvodu: {scam}/100 • Riziko recenzí: nedostatek dat" }, "authPortal": { "metaLoginTitle": "Přihlášení k účtu | Rate.vn", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 1ab66ab87..0481bb07a 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -808,7 +808,8 @@ "checkedStatus": "Checked", "errInspectFailed": "Could not check this link.", "errNetwork": "Network error, please try again.", - "loading": "Loading..." + "loading": "Loading...", + "reviewRiskNoData": "not enough data" }, "merchants": { "dirMetaTitle": "Seller trust directory | Rate.vn", @@ -972,7 +973,8 @@ "mdAuthenticLineNoReviews": "- **Genuine review share:** no reviews yet", "listingUnavailableBadge": "Out of stock", "listingUnavailableTitle": "The store reports it sold out or discontinued. The price is the last one seen, not a current price.", - "listingUnavailableAudit": "Out of stock, no deal check" + "listingUnavailableAudit": "Out of stock, no deal check", + "listingDiscontinuedBadge": "Discontinued" }, "productDetail": { "notFoundTitle": "Product not found | Rate.vn", @@ -1181,7 +1183,10 @@ "heroPriceLine": "From {price} ₫ · {count, plural, one {# seller} other {# sellers}}", "specsShowAll": "Show all specs", "specsShowLess": "Show fewer specs", - "exportMenu": "Export data" + "exportMenu": "Export data", + "offersReviewRiskLabel": "Review risk", + "offersReviewRiskNoData": "not enough data", + "offersDiscontinuedBadge": "Discontinued" }, "homePage": { "catDescSmartphones": "Filter by chipset, RAM/ROM, refresh rate Hz, battery mAh & fast charge W", @@ -1784,7 +1789,8 @@ "trustBadge": "Trust: {score}/100", "merchantRiskLine": "Scam risk: {scam}/100 • Review risk: {review}/100", "officialMallBadge": "Official Mall", - "scamRiskShort": "Scam risk: {score}/100" + "scamRiskShort": "Scam risk: {score}/100", + "merchantRiskLineNoReviews": "Scam risk: {scam}/100 • Review risk: not enough data" }, "authPortal": { "metaLoginTitle": "Sign in | Rate.vn", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 34cd310f1..70b207452 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -808,7 +808,8 @@ "checkedStatus": "Đã kiểm tra", "errInspectFailed": "Không kiểm tra được link này.", "errNetwork": "Lỗi mạng, vui lòng thử lại.", - "loading": "Đang tải..." + "loading": "Đang tải...", + "reviewRiskNoData": "chưa đủ dữ liệu" }, "merchants": { "dirMetaTitle": "Độ tin cậy gian hàng trên các sàn | Rate.vn", @@ -972,7 +973,8 @@ "mdAuthenticLineNoReviews": "- **Tỷ lệ đánh giá thật:** chưa có đánh giá", "listingUnavailableBadge": "Hết hàng", "listingUnavailableTitle": "Gian hàng báo hết hàng hoặc ngừng bán. Giá là giá ghi nhận lần cuối, không phải giá đang bán.", - "listingUnavailableAudit": "Hết hàng, không xét khuyến mãi" + "listingUnavailableAudit": "Hết hàng, không xét khuyến mãi", + "listingDiscontinuedBadge": "Ngừng bán" }, "productDetail": { "notFoundTitle": "Không tìm thấy sản phẩm | Rate.vn", @@ -1181,7 +1183,10 @@ "heroPriceLine": "Từ {price} ₫ · {count} nơi bán", "specsShowAll": "Xem toàn bộ thông số", "specsShowLess": "Thu gọn thông số", - "exportMenu": "Xuất dữ liệu" + "exportMenu": "Xuất dữ liệu", + "offersReviewRiskLabel": "Rủi ro đánh giá", + "offersReviewRiskNoData": "chưa đủ dữ liệu", + "offersDiscontinuedBadge": "Ngừng bán" }, "homePage": { "catDescSmartphones": "Lọc theo chipset, RAM/ROM, tần số quét Hz, pin mAh & sạc nhanh W", @@ -1784,7 +1789,8 @@ "trustBadge": "Tin cậy: {score}/100", "merchantRiskLine": "Rủi ro lừa đảo: {scam}/100 • Rủi ro đánh giá: {review}/100", "officialMallBadge": "Mall chính hãng", - "scamRiskShort": "Rủi ro lừa đảo: {score}/100" + "scamRiskShort": "Rủi ro lừa đảo: {score}/100", + "merchantRiskLineNoReviews": "Rủi ro lừa đảo: {scam}/100 • Rủi ro đánh giá: chưa đủ dữ liệu" }, "authPortal": { "metaLoginTitle": "Đăng nhập tài khoản | Rate.vn", diff --git a/apps/web/src/app/[locale]/merchants/[id]/page.tsx b/apps/web/src/app/[locale]/merchants/[id]/page.tsx index db57f742f..6daa55a1a 100644 --- a/apps/web/src/app/[locale]/merchants/[id]/page.tsx +++ b/apps/web/src/app/[locale]/merchants/[id]/page.tsx @@ -996,7 +996,9 @@ export default async function MerchantProfilePage({ className="badge bg-secondary-subtle text-secondary-emphasis rounded-pill small mt-1 border" title={t('listingUnavailableTitle')} > - {t('listingUnavailableBadge')} + {item.availability === 'discontinued' + ? t('listingDiscontinuedBadge') + : t('listingUnavailableBadge')} )} diff --git a/apps/web/src/app/api/v1/rate/extension/lookup/route.ts b/apps/web/src/app/api/v1/rate/extension/lookup/route.ts index bbd9e863b..277ff2ed8 100644 --- a/apps/web/src/app/api/v1/rate/extension/lookup/route.ts +++ b/apps/web/src/app/api/v1/rate/extension/lookup/route.ts @@ -9,6 +9,7 @@ import { jsonApiError, parseBoundedStringParam, parseOptionalVndPriceParam, + withPublicReviewRisk, } from '@/server/rate/api-handler'; const CORS_HEADERS = { @@ -99,7 +100,12 @@ export async function GET(request: NextRequest) { return NextResponse.json( { ok: true, - data: result, + data: result.merchantProfile + ? { + ...result, + merchantProfile: withPublicReviewRisk(result.merchantProfile), + } + : result, }, { status: 200, diff --git a/apps/web/src/app/api/v1/rate/inspect/route.ts b/apps/web/src/app/api/v1/rate/inspect/route.ts index 26443b058..871252656 100644 --- a/apps/web/src/app/api/v1/rate/inspect/route.ts +++ b/apps/web/src/app/api/v1/rate/inspect/route.ts @@ -10,6 +10,7 @@ import { import { PUBLIC_API_CORS_HEADERS, jsonApiError, + withPublicReviewRisk, parseBoundedStringParam, parseOptionalVndPriceParam, resolveInspectObservedPrices, @@ -100,6 +101,9 @@ export async function GET(request: NextRequest) { ok: true, data: { ...inspection, + merchantProfile: inspection.merchantProfile + ? withPublicReviewRisk(inspection.merchantProfile) + : null, canonicalUrl, gatekeeper, priceVerdict, diff --git a/apps/web/src/app/api/v1/rate/lookup/route.ts b/apps/web/src/app/api/v1/rate/lookup/route.ts index 640bc7ed3..31391fa42 100644 --- a/apps/web/src/app/api/v1/rate/lookup/route.ts +++ b/apps/web/src/app/api/v1/rate/lookup/route.ts @@ -6,6 +6,7 @@ import { import { PUBLIC_API_CORS_HEADERS, jsonApiError, + withPublicReviewRisk, parseBoundedStringParam, } from '@/server/rate/api-handler'; @@ -49,6 +50,9 @@ export async function GET(request: NextRequest) { ok: true, data: { ...result, + merchantProfile: result.merchantProfile + ? withPublicReviewRisk(result.merchantProfile) + : null, radarSignal, }, }, diff --git a/apps/web/src/app/api/v1/rate/merchants/[id]/route.ts b/apps/web/src/app/api/v1/rate/merchants/[id]/route.ts index 4e95ebb09..73d6864bd 100644 --- a/apps/web/src/app/api/v1/rate/merchants/[id]/route.ts +++ b/apps/web/src/app/api/v1/rate/merchants/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCachedMerchantProfileById } from '@/server/rate'; import { PUBLIC_API_CORS_HEADERS, + withPublicReviewRisk, jsonApiError, } from '@/server/rate/api-handler'; @@ -54,7 +55,7 @@ export async function GET(_request: NextRequest, context: RouteContext) { return NextResponse.json( { ok: true, - data: profile, + data: withPublicReviewRisk(profile), }, { status: 200, diff --git a/apps/web/src/app/api/v1/rate/merchants/directory/route.ts b/apps/web/src/app/api/v1/rate/merchants/directory/route.ts index 26aa7f0a1..7e48f3f0c 100644 --- a/apps/web/src/app/api/v1/rate/merchants/directory/route.ts +++ b/apps/web/src/app/api/v1/rate/merchants/directory/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCachedMerchantsList } from '@/server/rate'; import { PUBLIC_API_CORS_HEADERS, + withPublicReviewRisk, jsonApiError, parseBoundedIntParam, } from '@/server/rate/api-handler'; @@ -32,7 +33,10 @@ export async function GET(request: NextRequest) { const merchants = await getCachedMerchantsList(); const page = pageMerchantDirectory(merchants, filters, offset, limit); return NextResponse.json( - { ok: true, data: page }, + { + ok: true, + data: { ...page, items: page.items.map(withPublicReviewRisk) }, + }, { status: 200, headers: { diff --git a/apps/web/src/app/api/v1/rate/merchants/route.ts b/apps/web/src/app/api/v1/rate/merchants/route.ts index eaf9a0ee6..e3cb80d79 100644 --- a/apps/web/src/app/api/v1/rate/merchants/route.ts +++ b/apps/web/src/app/api/v1/rate/merchants/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCachedMerchantsList } from '@/server/rate'; import { PUBLIC_API_CORS_HEADERS, + withPublicReviewRisk, jsonApiError, parseBoundedIntParam, parseBoundedNumberParam, @@ -61,7 +62,7 @@ export async function GET(request: NextRequest) { { ok: true, count: merchants.length, - data: merchants, + data: merchants.map(withPublicReviewRisk), }, { status: 200, diff --git a/apps/web/src/components/home/HeroSearchInspector.tsx b/apps/web/src/components/home/HeroSearchInspector.tsx index 287b9a923..f4bb9c932 100644 --- a/apps/web/src/components/home/HeroSearchInspector.tsx +++ b/apps/web/src/components/home/HeroSearchInspector.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; +import { hasReviewRiskEvidence } from '@/lib/merchant-risk-tone'; import { discountVerdictCopy } from '@/lib/discount-verdict-copy'; import { Link, useRouter } from '@/lib/navigation'; import { startNavigationProgress } from '@/components/elementary/NavigationProgress'; @@ -892,12 +893,23 @@ export default function HeroSearchInspector({ />
- {t.rich('merchantRiskLine', { - scam: inspectResult.merchantProfile.dosafe_risk_score, - review: - inspectResult.merchantProfile.review_risk_score, - b: (chunks) => {chunks}, - })}{' '} + {/* No analysed reviews: no review risk number + (DOS/Rate#1258). */} + {hasReviewRiskEvidence( + inspectResult.merchantProfile.review_risk_breakdown + ) + ? t.rich('merchantRiskLine', { + scam: inspectResult.merchantProfile + .dosafe_risk_score, + review: + inspectResult.merchantProfile.review_risk_score, + b: (chunks) => {chunks}, + }) + : t.rich('merchantRiskLineNoReviews', { + scam: inspectResult.merchantProfile + .dosafe_risk_score, + b: (chunks) => {chunks}, + })}{' '} •{' '} {t('trackingProductsCount', { diff --git a/apps/web/src/components/inspect/DeepInspectWorkspace.tsx b/apps/web/src/components/inspect/DeepInspectWorkspace.tsx index 6ac6f976a..c97753b36 100644 --- a/apps/web/src/components/inspect/DeepInspectWorkspace.tsx +++ b/apps/web/src/components/inspect/DeepInspectWorkspace.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useLocale, useTranslations } from 'next-intl'; +import { hasReviewRiskEvidence } from '@/lib/merchant-risk-tone'; import { discountVerdictCopy } from '@/lib/discount-verdict-copy'; import AnalyzerText from '@/components/reviews/AnalyzerText'; import { useDetailsDismiss } from '@/components/elementary/use-details-dismiss'; @@ -278,7 +279,7 @@ export default function DeepInspectWorkspace({ : []), ...(merchant ? [ - `- **${t('mdSeller')}:** ${merchant.name} (${merchant.platform.toUpperCase()}) | ${t('mdTrustScore')}: **${merchant.trust_score}/100** (${t('dosafeRiskLabel')}: ${merchant.dosafe_risk_score}/100, ${t('reviewRiskLabel')}: ${merchant.review_risk_score}/100)`, + `- **${t('mdSeller')}:** ${merchant.name} (${merchant.platform.toUpperCase()}) | ${t('mdTrustScore')}: **${merchant.trust_score}/100** (${t('dosafeRiskLabel')}: ${merchant.dosafe_risk_score}/100, ${t('reviewRiskLabel')}: ${hasReviewRiskEvidence(merchant.review_risk_breakdown) ? `${merchant.review_risk_score}/100` : t('reviewRiskNoData')})`, ] : []), `- **${t('mdLink')}:** ${shareUrl}`, @@ -941,31 +942,44 @@ export default function DeepInspectWorkspace({
{t('reviewRiskLabel')} - = 40 - ? 'text-danger' - : 'text-primary' - } - > - {Number(merchant.review_risk_score || 0).toFixed(0)}/100 - -
-
-
= 40 - ? 'bg-danger' - : 'bg-primary' - }`} - style={{ - width: `${Math.min( - 100, - Math.max(4, Number(merchant.review_risk_score || 0)) - )}%`, - }} - /> + {hasReviewRiskEvidence(merchant.review_risk_breakdown) ? ( + = 40 + ? 'text-danger' + : 'text-primary' + } + > + {Number(merchant.review_risk_score || 0).toFixed(0)} + /100 + + ) : ( + // No analysed reviews: no number (DOS/Rate#1258). + + {t('reviewRiskNoData')} + + )}
+ {hasReviewRiskEvidence(merchant.review_risk_breakdown) && ( +
+
= 40 + ? 'bg-danger' + : 'bg-primary' + }`} + style={{ + width: `${Math.min( + 100, + Math.max( + 4, + Number(merchant.review_risk_score || 0) + ) + )}%`, + }} + /> +
+ )}
- {t('offersUnavailableBadge')} + {offer.availability === 'discontinued' + ? t('offersDiscontinuedBadge') + : t('offersUnavailableBadge')} )}
@@ -762,11 +767,15 @@ export default function ProductOffersExplorer({ {formatOfferRiskScore100( offer.merchant.dosafe_risk_score )} - /100 | Review Risk:{' '} - {formatOfferRiskScore100( - offer.merchant.review_risk_score - )} - /100 + /100 | {t('offersReviewRiskLabel')}:{' '} + {/* No analysed reviews: no number (#1258). */} + {hasReviewRiskEvidence( + offer.merchant.review_risk_breakdown + ) + ? `${formatOfferRiskScore100( + offer.merchant.review_risk_score + )}/100` + : t('offersReviewRiskNoData')}
diff --git a/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx b/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx index 1f10ff69a..6f726cc2a 100644 --- a/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx +++ b/apps/web/src/components/merchants/MerchantDirectoryExplorer.tsx @@ -24,7 +24,10 @@ import { type MerchantSeedingTier, type MerchantSortOption, } from '@/lib/merchant-directory'; -import { hasReviewRiskEvidence } from '@/lib/merchant-risk-tone'; +import { + hasReviewRiskEvidence, + reviewRiskScoreOrNull, +} from '@/lib/merchant-risk-tone'; import { selectMerchantRadar } from '@/lib/deal-radar'; import { useDealRadarPayload } from '@/components/radar/use-deal-radar'; import { useLocale, useTranslations } from 'next-intl'; @@ -435,7 +438,10 @@ export default function MerchantDirectoryExplorer({ sanitizeMerchantAuditText(m.platform_shop_id, '', 80), m.is_official ? 'true' : 'false', String(sanitizeMerchantScore100(m.trust_score)), - String(sanitizeMerchantScore100(m.review_risk_score)), + // Empty without analysed reviews (DOS/Rate#1258). + hasReviewRiskEvidence(m.review_risk_breakdown) + ? String(sanitizeMerchantScore100(m.review_risk_score)) + : '', String(sanitizeMerchantCount(rStats.genuineDealCount)), String(sanitizeMerchantCount(rStats.fakeTrapCount)), String(sanitizeMerchantCount(m.review_risk_breakdown?.authentic_count)), @@ -476,6 +482,8 @@ export default function MerchantDirectoryExplorer({ merchantCount: filteredMerchants.length, merchants: filteredMerchants.map((m) => ({ ...m, + // Null without analysed reviews (DOS/Rate#1258). + review_risk_score: reviewRiskScoreOrNull(m), dealRadarStats: getMerchantRadarStats(m), })), }; diff --git a/apps/web/src/components/merchants/MerchantShareAuditButton.tsx b/apps/web/src/components/merchants/MerchantShareAuditButton.tsx index a72c35669..0dc773a35 100644 --- a/apps/web/src/components/merchants/MerchantShareAuditButton.tsx +++ b/apps/web/src/components/merchants/MerchantShareAuditButton.tsx @@ -205,7 +205,8 @@ export default function MerchantShareAuditButton({ safeTrustScore, safeTrustLevelLabel, safeDosafeRisk, - safeSeedingRisk, + // Empty without analysed reviews: no measured risk (DOS/Rate#1258). + safeTotalReviews > 0 ? safeSeedingRisk : '', safeGenuineDeals, safeFakeTraps, safeAuthenticReviews, @@ -238,7 +239,7 @@ export default function MerchantShareAuditButton({ trustScore: safeTrustScore, trustLevelLabel: safeTrustLevelLabel, dosafeRiskScore: safeDosafeRisk, - reviewSeedingRiskScore: safeSeedingRisk, + reviewSeedingRiskScore: safeTotalReviews > 0 ? safeSeedingRisk : null, genuineDealCount: safeGenuineDeals, fakeSaleTrapCount: safeFakeTraps, authenticReviews: safeAuthenticReviews, diff --git a/apps/web/src/lib/__tests__/merchant-review-risk.test.ts b/apps/web/src/lib/__tests__/merchant-review-risk.test.ts index 49a946909..4d4d612af 100644 --- a/apps/web/src/lib/__tests__/merchant-review-risk.test.ts +++ b/apps/web/src/lib/__tests__/merchant-review-risk.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import { filterMerchantDirectory } from '../merchant-directory'; -import { hasReviewRiskEvidence } from '../merchant-risk-tone'; +import { + hasReviewRiskEvidence, + reviewRiskScoreOrNull, +} from '../merchant-risk-tone'; import { filterAndSortMerchants, type MerchantSummary } from '../rate-domain'; // A store with no analysed reviews has no measured review risk: no number, @@ -36,14 +39,54 @@ function merchant( } as unknown as MerchantSummary; } -function main() { +async function main() { console.warn('🧪 merchant review risk suite (DOS/Rate#1249)'); assert.equal(hasReviewRiskEvidence({ total_reviews: 3 }), true); assert.equal(hasReviewRiskEvidence({ total_reviews: 0 }), false); assert.equal(hasReviewRiskEvidence({ total_reviews: Number.NaN }), false); - assert.equal(hasReviewRiskEvidence(null), false); - assert.equal(hasReviewRiskEvidence(undefined), false); + assert.equal(hasReviewRiskEvidence({ total_reviews: '0' }), false); + // No breakdown or no count says nothing: a stored score stays (#1258). + assert.equal(hasReviewRiskEvidence(null), true); + assert.equal(hasReviewRiskEvidence(undefined), true); + assert.equal(hasReviewRiskEvidence({}), true); + + // Public payloads carry null instead of a baseline (DOS/Rate#1258). + assert.equal( + reviewRiskScoreOrNull({ + review_risk_score: 4, + review_risk_breakdown: { total_reviews: 0 }, + }), + null + ); + assert.equal( + reviewRiskScoreOrNull({ + review_risk_score: 30.4, + review_risk_breakdown: { total_reviews: 12 }, + }), + 30 + ); + assert.equal( + reviewRiskScoreOrNull({ + review_risk_score: 68, + review_risk_breakdown: null, + }), + 68 + ); + assert.equal( + reviewRiskScoreOrNull({ + review_risk_score: null, + review_risk_breakdown: null, + }), + null + ); + assert.equal( + reviewRiskScoreOrNull({ + review_risk_score: 140, + review_risk_breakdown: { total_reviews: 3 }, + }), + 100 + ); // FPT Shop case: a 4/100 baseline with no reviews against measured stores. const noReviews = merchant('none', 4, 0, 96); @@ -97,7 +140,188 @@ function main() { ['low', 'high', 'none'] ); + await directoryReadsReviewsForStoredScores(); + await offerSellersWithoutReviewsReadAsNoData(); + console.warn(' ✅ merchant review risk suite passed'); } -main(); +// A store with a stored review score but no persisted breakdown: the +// directory reads its reviews for the breakdown, as the profile does, and +// keeps the stored score (DOS/Rate#1258). +async function directoryReadsReviewsForStoredScores() { + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://stub.supabase.co'; + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'stub-anon-key'; + const { getMerchantsList } = await import('../rate-api'); + const STORED = '00000000-0000-4000-8000-000000001258'; + const NONE = '00000000-0000-4000-8000-000000001259'; + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + ); + if (Number(url.searchParams.get('offset') ?? 0) > 0) return json([]); + const table = url.pathname.replace('/rest/v1/', ''); + if (table === 'merchants') { + return json([ + { + id: STORED, + platform: 'shopee', + platform_shop_id: 's', + name: 'Stored', + is_official: false, + risk_score: 0, + review_risk_score: null, + review_risk_breakdown: {}, + metadata: { review_risk_score: 30 }, + }, + { + id: NONE, + platform: 'fptshop', + platform_shop_id: 'n', + name: 'None', + is_official: false, + risk_score: 0, + review_risk_score: 4, + review_risk_breakdown: {}, + metadata: {}, + }, + ]); + } + if (table === 'products') { + return json([ + { id: 'p-stored', merchant_id: STORED, price_current: 1_000_000 }, + { id: 'p-none', merchant_id: NONE, price_current: 2_000_000 }, + ]); + } + if (table === 'reviews') { + return json( + decodeURIComponent(url.search).includes('p-stored') + ? [ + { + id: 'r1', + product_id: 'p-stored', + rating_star: 5, + comment: 'ok', + review_flags: { verdict: 'authentic' }, + }, + { + id: 'r2', + product_id: 'p-stored', + rating_star: 4, + comment: 'ok', + review_flags: { verdict: 'authentic' }, + }, + ] + : [] + ); + } + return json([]); + }) as typeof fetch; + + const list = await getMerchantsList({}); + const stored = list.find((m) => m.id === STORED); + const none = list.find((m) => m.id === NONE); + assert.ok(stored && none); + assert.equal(stored.review_risk_breakdown.total_reviews, 2); + assert.equal(stored.review_risk_score, 30, 'the stored score is kept'); + assert.equal(hasReviewRiskEvidence(stored.review_risk_breakdown), true); + assert.equal(hasReviewRiskEvidence(none.review_risk_breakdown), false); +} + +// A seller the importer never scored gets the baseline score on offer lines, +// with a breakdown counting no review, so the product page says "not enough +// data" as the merchant page does (DOS/Rate#1258). +async function offerSellersWithoutReviewsReadAsNoData() { + const { mapTrustViewRowToMerchantOffer, mapOfferRowToMerchantOffer } = + await import('../rate-api'); + const row = { + id: 'o1', + merchant_id: 'm1', + platform: 'shopee', + title: 'Phone', + price_current: 1_000_000, + url: 'https://shopee.vn/p', + merchant_name: 'Shop', + merchant_is_official: false, + price_original: null, + stock: 1, + rating_star: 5, + rating_count: 1, + }; + const unscored = mapTrustViewRowToMerchantOffer( + { ...row, review_risk_score: 0, review_risk_breakdown: {} }, + 0 + ); + assert.equal( + hasReviewRiskEvidence(unscored.merchant.review_risk_breakdown), + false + ); + const scored = mapTrustViewRowToMerchantOffer( + { + ...row, + review_risk_score: 30, + review_risk_breakdown: { total_reviews: 12 }, + }, + 0 + ); + assert.equal(scored.merchant.review_risk_score, 30); + assert.equal( + hasReviewRiskEvidence(scored.merchant.review_risk_breakdown), + true + ); + + const fallbackBaseline = mapOfferRowToMerchantOffer({ + id: 'o2', + platform: 'shopee', + title: 'Phone', + price_current: 1_000_000, + url: 'https://shopee.vn/p', + merchants: { + id: 'm2', + name: 'Shop', + platform: 'shopee', + review_risk_score: 0, + review_risk_breakdown: {}, + metadata: {}, + }, + } as unknown as Parameters[0]); + assert.equal( + hasReviewRiskEvidence(fallbackBaseline.merchant.review_risk_breakdown), + false + ); + const fallbackStored = mapOfferRowToMerchantOffer({ + id: 'o3', + platform: 'shopee', + title: 'Phone', + price_current: 1_000_000, + url: 'https://shopee.vn/p', + merchants: { + id: 'm3', + name: 'Shop', + platform: 'shopee', + review_risk_score: null, + review_risk_breakdown: null, + metadata: { review_risk_score: 25 }, + }, + } as unknown as Parameters[0]); + assert.equal(fallbackStored.merchant.review_risk_score, 25); + assert.equal( + hasReviewRiskEvidence(fallbackStored.merchant.review_risk_breakdown), + true, + 'a stored score without a breakdown stays' + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/web/src/lib/merchant-risk-tone.ts b/apps/web/src/lib/merchant-risk-tone.ts index 83c8ae6ce..7e6d5982b 100644 --- a/apps/web/src/lib/merchant-risk-tone.ts +++ b/apps/web/src/lib/merchant-risk-tone.ts @@ -16,4 +16,7 @@ export function merchantRiskTone(score: unknown): MerchantRiskTone { return n < MERCHANT_RISK_ALERT_FROM ? 'warn' : 'bad'; } -export { hasReviewRiskEvidence } from './rate-domain-core'; +export { + hasReviewRiskEvidence, + reviewRiskScoreOrNull, +} from './rate-domain-core'; diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index a65a34ba2..d54152e30 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -1069,6 +1069,16 @@ async function sellerExtrasForTrustViewRows( return extras; } +/** The breakdown of a seller with no analysed review (DOS/Rate#1258). */ +const NO_ANALYSED_REVIEWS_BREAKDOWN: Readonly> = { + total_reviews: 0, + authentic_count: 0, + coin_spam_count: 0, + seeding_fake_count: 0, + sabotage_or_bombing_count: 0, + suspicious_ratio_pct: 0, +}; + export function mapTrustViewRowToMerchantOffer( row: RawTrustViewRow, followerCount?: number, @@ -1103,12 +1113,16 @@ export function mapTrustViewRowToMerchantOffer( const priceCurrent = toSafeOfferPrice(safeRow.price_current); const cleanPlatform = sanitizeOfferString(safeRow.platform).toLowerCase(); const cleanLocation = sanitizeOfferString(safeRow.merchant_location); + // A baseline seller carries a breakdown counting no review, so every + // surface reads its score as "not enough data" (DOS/Rate#1258). const safeBreakdown = - safeRow.review_risk_breakdown && - typeof safeRow.review_risk_breakdown === 'object' && - !Array.isArray(safeRow.review_risk_breakdown) - ? safeRow.review_risk_breakdown - : null; + isUnscoredTrustViewRow(safeRow) && hasSeller + ? { ...NO_ANALYSED_REVIEWS_BREAKDOWN } + : safeRow.review_risk_breakdown && + typeof safeRow.review_risk_breakdown === 'object' && + !Array.isArray(safeRow.review_risk_breakdown) + ? safeRow.review_risk_breakdown + : null; return { id: sanitizeOfferString(safeRow.id), platform: cleanPlatform, @@ -1179,6 +1193,13 @@ export function mapOfferRowToMerchantOffer(item: RawOfferRow): MerchantOffer { 0 ); // An offer with no merchant row has no seller to give a baseline (#1099). + const hasStoredScore = + (merchantObj?.review_risk_score != null && + hasPersistedReviewRisk( + columnReviewRisk, + merchantObj.review_risk_breakdown + )) || + typeof meta?.review_risk_score === 'number'; const reviewRisk = merchantObj?.review_risk_score != null && hasPersistedReviewRisk(columnReviewRisk, merchantObj.review_risk_breakdown) @@ -1210,18 +1231,21 @@ export function mapOfferRowToMerchantOffer(item: RawOfferRow): MerchantOffer { Object.keys(merchantObj.review_risk_breakdown).length > 0 ? merchantObj.review_risk_breakdown : null; + // A baseline seller carries a breakdown counting no review (#1258). const breakdown = - topLevelBreakdown ?? - (meta?.review_risk_breakdown && - typeof meta.review_risk_breakdown === 'object' && - !Array.isArray(meta.review_risk_breakdown) && - Object.keys(meta.review_risk_breakdown as Record).length > - 0 - ? (meta.review_risk_breakdown as Record) - : meta && - ('review_risk_score' in meta || 'seeding_spam_ratio_pct' in meta) - ? meta - : null); + merchantObj && !hasStoredScore + ? { ...NO_ANALYSED_REVIEWS_BREAKDOWN } + : (topLevelBreakdown ?? + (meta?.review_risk_breakdown && + typeof meta.review_risk_breakdown === 'object' && + !Array.isArray(meta.review_risk_breakdown) && + Object.keys(meta.review_risk_breakdown as Record) + .length > 0 + ? (meta.review_risk_breakdown as Record) + : meta && + ('review_risk_score' in meta || 'seeding_spam_ratio_pct' in meta) + ? meta + : null)); const priceCurrent = toSafeOfferPrice(safeItem.price_current); const rawMerchantRating = typeof merchantObj?.rating_star === 'number' || @@ -2872,9 +2896,12 @@ export async function getMerchantsList( productIdsByMerchant.set(p.merchant_id, idList); } + // Reviews are read for every store without a persisted breakdown, also + // one with a stored score: the breakdown says whether reviews were + // analysed, as on the profile, which reads them too (DOS/Rate#1258). const unscoredProductIds: string[] = []; for (const m of merchants) { - if (!extractPersistedBreakdown(m) && !hasStoredReviewRisk(m)) { + if (!extractPersistedBreakdown(m)) { const mProdIds = productIdsByMerchant.get(m.id); if (mProdIds && mProdIds.length > 0) { unscoredProductIds.push(...mProdIds); @@ -2963,14 +2990,13 @@ export async function getMerchantsList( const livePrices = livePricesByMerchant.get(m.id) ?? []; const persistedBreakdown = extractPersistedBreakdown(m); const mReviews = mappedReviewsByMerchant.get(m.id) ?? []; - const dynamicRisk = - persistedBreakdown || hasStoredReviewRisk(m) - ? null - : computeDynamicReviewRisk(mReviews, { - isOfficial: isStrictOfferOfficialFlag(m.is_official), - platform: m.platform, - followerCount: Number(m.follower_count) || 0, - }); + const dynamicRisk = persistedBreakdown + ? null + : computeDynamicReviewRisk(mReviews, { + isOfficial: isStrictOfferOfficialFlag(m.is_official), + platform: m.platform, + followerCount: Number(m.follower_count) || 0, + }); const meta = ( m.metadata && typeof m.metadata === 'object' && !Array.isArray(m.metadata) ? m.metadata @@ -2983,10 +3009,12 @@ export async function getMerchantsList( ? toSafeRiskScore100(meta.dosafe_risk_score, 0) : baseRisk; const persistedReviewRisk = persistedReviewRiskOf(m); - const effectiveReviewRisk = Math.max( - dynamicRisk?.reviewRiskScore ?? 0, - persistedReviewRisk - ); + // A stored score covers all of the seller's reviews: it wins over the + // one computed here, as on the profile (#684). + const effectiveReviewRisk = + persistedBreakdown || hasStoredReviewRisk(m) + ? persistedReviewRisk + : Math.max(dynamicRisk?.reviewRiskScore ?? 0, persistedReviewRisk); const breakdown = persistedBreakdown ?? dynamicRisk?.breakdown ?? { total_reviews: 0, diff --git a/apps/web/src/lib/rate-domain-core.ts b/apps/web/src/lib/rate-domain-core.ts index b6d2b7305..4fb094dec 100644 --- a/apps/web/src/lib/rate-domain-core.ts +++ b/apps/web/src/lib/rate-domain-core.ts @@ -1938,14 +1938,34 @@ export function extractProductKeywordFromUrl(rawUrl?: string | null): string { } /** - * Whether a merchant's review risk score was measured on analysed reviews. - * With none, the score is only a platform baseline: it is shown as "not - * enough data", and is neither a low-risk filter match nor sorted as the - * lowest risk (DOS/Rate#1249). + * Whether a merchant's review risk score may be shown as a measurement. A + * breakdown that counts no analysed review (0, or a malformed count) means + * the score is only a platform baseline: it is shown as "not enough data", + * and is neither a low-risk filter match nor sorted as the lowest risk + * (DOS/Rate#1249). A missing breakdown or count says nothing either way, so + * a stored score stays (an offer's seller often carries no breakdown). */ export function hasReviewRiskEvidence( breakdown: { total_reviews?: unknown } | null | undefined ): boolean { - const total = Number(breakdown?.total_reviews); + const rawTotal = breakdown?.total_reviews; + if (rawTotal == null) return true; + const total = Number(rawTotal); return Number.isFinite(total) && total > 0; } + +/** + * The review risk score a public payload carries: null when the store has no + * analysed review to score (DOS/Rate#1258), else the score clamped to 0-100. + */ +export function reviewRiskScoreOrNull(merchant: { + review_risk_score?: number | string | null; + review_risk_breakdown?: { total_reviews?: unknown } | null; +}): number | null { + if (!hasReviewRiskEvidence(merchant?.review_risk_breakdown)) return null; + const raw = merchant?.review_risk_score; + const score = Number(raw); + if (raw == null || (typeof raw === 'string' && !raw.trim())) return null; + if (!Number.isFinite(score)) return null; + return Math.max(0, Math.min(100, Math.round(score))); +} diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 9d0430da9..58ecde853 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -9496,7 +9496,14 @@ async function runCoreArchitectureSuite() { assert.equal(trustOffer.rating_star, 0); assert.equal(trustOffer.merchant.follower_count, 0); assert.equal(trustOffer.merchant.location, null); - assert.equal(trustOffer.merchant.review_risk_breakdown, null); + // A never-scored seller (no score, malformed breakdown) carries a + // breakdown counting no review, so its baseline reads as no data + // (DOS/Rate#1258). + assert.equal( + (trustOffer.merchant.review_risk_breakdown as { total_reviews?: number }) + ?.total_reviews, + 0 + ); const mappedOffer = rateApiMod.mapOfferRowToMerchantOffer({ id: 'off-2\u0000', diff --git a/apps/web/src/server/rate/api-handler.ts b/apps/web/src/server/rate/api-handler.ts index 804a291ad..fbf16503a 100644 --- a/apps/web/src/server/rate/api-handler.ts +++ b/apps/web/src/server/rate/api-handler.ts @@ -1,5 +1,6 @@ import 'server-only'; import { NextResponse } from 'next/server'; +import { reviewRiskScoreOrNull } from '@/lib/merchant-risk-tone'; import { getCategorySpecTemplate, plausibleMinPrice, @@ -16,6 +17,24 @@ import { type OfferAvailability, } from '@/lib/product-market-stats'; +/** + * A merchant as public payloads carry it: `review_risk_score` is null when + * the store has no analysed review, so no client reads a platform baseline + * as a measurement (DOS/Rate#1258). + */ +export function withPublicReviewRisk< + T extends { + review_risk_score?: number | string | null; + review_risk_breakdown?: { total_reviews?: unknown } | null; + }, +>( + merchant: T +): Omit & { + review_risk_score: number | null; +} { + return { ...merchant, review_risk_score: reviewRiskScoreOrNull(merchant) }; +} + export const PUBLIC_API_CORS_HEADERS: Record = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index 24b5e098c..3a3fe4886 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -946,7 +946,7 @@ export function buildRateOpenApiSpec( summary: 'List e-commerce merchants with Dual Risk Score (DOSafe + AI Seeding Risk)', description: - 'Returns verified merchants (`rate.merchants`) with filter support for platform, Official Mall status, minimum trust score, search query, and sorting.', + 'Returns verified merchants (`rate.merchants`) with filter support for platform, Official Mall status, minimum trust score, search query, and sorting. `review_risk_score` is null for a store with no analysed review.', tags: ['Merchants & Dual Risk Score'], parameters: [ { @@ -1013,7 +1013,7 @@ export function buildRateOpenApiSpec( summary: 'Paginated merchant directory with risk tier, seeding tier & platform filters', description: - 'Returns one page (`items`, `matched`, `offset`) of the `/merchants` directory filtered by search query, platform, Official Mall status, trust risk tier (`safe`, `moderate`, `warning`), AI seeding tier (`low`, `elevated`), and sort order.', + 'Returns one page (`items`, `matched`, `offset`) of the `/merchants` directory filtered by search query, platform, Official Mall status, trust risk tier (`safe`, `moderate`, `warning`), AI seeding tier (`low`, `elevated`), and sort order. `review_risk_score` is null for a store with no analysed review.', tags: ['Merchants & Dual Risk Score'], parameters: [ { @@ -1103,7 +1103,7 @@ export function buildRateOpenApiSpec( summary: 'Get detailed merchant audit profile, seeding breakdown & active listings', description: - 'Fetches a single merchant by UUID, slug, or `platform_shop_id` along with its active marketplace listings and AI review seeding distribution.', + 'Fetches a single merchant by UUID, slug, or `platform_shop_id` along with its active marketplace listings and AI review seeding distribution. `review_risk_score` is null for a store with no analysed review.', tags: ['Merchants & Dual Risk Score'], parameters: [ { From 40f42ff664668f9e9abbd668858b88e37a9a3ce8 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:45:28 +0700 Subject: [PATCH 36/69] feat(web): item data layer, catalogue slug resolution and 308s behind RATE_ITEM_MODEL (#1264) * feat(web): item data layer, catalogue slug resolution and 308s behind RATE_ITEM_MODEL Task 7 of the Item / variant / listing model (design doc sections 4, 5.1, 7, 10), coded against the M1+M2 draft in PR #1242: - rate-api: getItemBySlug, getItemById, getItemVariants (published, ordered by sort_order then storage), getItemOffers (per variant, plus the "size not stated" listings with item_id and no cluster_id), and resolveCatalogSlug (an Item slug first, then an old variant slug). - server/rate/item-model: the web side of RATE_ITEM_MODEL (same values as the importer, anything unknown is off) and planItemRoute: an old variant URL 308s to /{category}/{item-slug}?v={variant_key} (Q1); an Item URL renders its ?v= variant, else the default, else the first, until the Item page ships (#1154), with the Item URL as canonical. - server/rate/item-route: cached resolution and variants. - cache-tags: revalidating a variant slug also busts its Item's tag. With the flag off nothing reads rate.items or the query string, so pages stay as they are and keep their static cache. Refs #1153 * fix(web): item routes keep isr, 404 unpublished items and redirect retired sizes Review of the previous commit: - reading ?v= on an ISR product route could fail or cache one variant for every query; the server no longer reads the query string, so an Item URL renders its default variant and the Item page (#1154) applies ?v= on the client; - a slug that is neither a published Item nor a variant of one now 404s instead of falling back to the old master lookup; - an archived variant's old URL (a retired unknown) 308s to its Item with no size preselected; - with the flag on but the Item tables missing (42P01, 42703, PGRST204/205) pages fall back to today's variant pages instead of failing. The four category pages are back to their dev versions. Refs #1153 * fix(web): unlinked masters keep their page and product routes stay static Review round 2: - a published master with item_id NULL (before the backfill) now resolves as unlinked and keeps today's page instead of a 404; - a missing M2 column on the variant read (42703, PGRST204) now falls back like a missing items table instead of failing the page; - a test pins every product route to ISR: no searchParams, headers() or cookies(), no dynamic opt-in, revalidate 300 and generateStaticParams. Refs #1153 Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 2 +- .../app/[locale]/smartphones/[slug]/page.tsx | 33 +- apps/web/src/lib/rate-api.ts | 401 ++++++++++++++++ .../server/rate/__tests__/item-model.test.ts | 437 ++++++++++++++++++ apps/web/src/server/rate/cache-tags.ts | 7 + apps/web/src/server/rate/item-model.ts | 122 +++++ apps/web/src/server/rate/item-route.ts | 78 ++++ 7 files changed, 1074 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/server/rate/__tests__/item-model.test.ts create mode 100644 apps/web/src/server/rate/item-model.ts create mode 100644 apps/web/src/server/rate/item-route.ts diff --git a/apps/web/package.json b/apps/web/package.json index ced0ea330..0fd88bc87 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index c7e2c297d..6ae0895f1 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -2,7 +2,8 @@ import type { Metadata } from 'next'; import { Fragment } from 'react'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '@/lib/navigation'; -import { notFound } from 'next/navigation'; +import { notFound, permanentRedirect } from 'next/navigation'; +import { resolveItemRoute } from '@/server/rate/item-route'; import { getCrossPlatformDealRadar, getCachedMasterProductBySlug, @@ -111,7 +112,15 @@ export async function generateCategoryProductMetadata( const { locale, slug } = await params; const lang = normalizeRateLocale(locale); const t = await getTranslations({ locale, namespace: 'productDetail' }); - const product = await getCachedMasterProductBySlug(slug); + // RATE_ITEM_MODEL on: an Item URL describes its selected variant and is + // canonical (Q1, DOS/Rate#1153). Off: null, the page as before. + const itemRoute = await resolveItemRoute({ slug, categoryBasePath }); + const product = + itemRoute?.kind === 'not_found' + ? null + : await getCachedMasterProductBySlug( + itemRoute?.kind === 'render' ? itemRoute.variantSlug : slug + ); if (!product) { return { title: t('notFoundTitle'), @@ -121,7 +130,9 @@ export async function generateCategoryProductMetadata( const marketStats = productMarketStats(product); const hasPrice = marketStats.minPrice > 0; const minPrice = marketStats.minPrice.toLocaleString('vi-VN'); - const canonicalUrl = `https://rate.vn${categoryBasePath}/${product.slug}`; + const canonicalUrl = `https://rate.vn${categoryBasePath}/${ + itemRoute?.kind === 'render' ? itemRoute.itemSlug : product.slug + }`; return { // The product name first; the site name, then the price give way to fit @@ -177,11 +188,21 @@ export async function ProductDetailView({ }) { const { locale, slug } = await params; setRequestLocale(locale); + // RATE_ITEM_MODEL on: an old variant URL 308s to its Item URL with `?v=`, + // and an Item URL renders its selected variant until the Item page ships + // (Q1, DOS/Rate#1153, #1154). Off: null, and the page is as before. + const itemRoute = await resolveItemRoute({ slug, categoryBasePath }); + if (itemRoute?.kind === 'redirect') permanentRedirect(itemRoute.to); + // Neither a published Item nor a variant of one: no fallback to the old + // master lookup, which would show a variant of an unpublished Item. + if (itemRoute?.kind === 'not_found') notFound(); + const productSlug = + itemRoute?.kind === 'render' ? itemRoute.variantSlug : slug; const [t, tNav, tSale, storedProduct] = await Promise.all([ getTranslations({ locale, namespace: 'productDetail' }), getTranslations({ locale, namespace: 'rate.nav' }), getTranslations({ locale, namespace: 'saleState' }), - getCachedMasterProductBySlug(slug), + getCachedMasterProductBySlug(productSlug), ]); if (!storedProduct) { @@ -275,7 +296,9 @@ export async function ProductDetailView({ const keySpecLine = buildKeySpecLine(product, categorySlug); const mainImage = firstImageUrl(product.images); - const canonicalUrl = `https://rate.vn${categoryBasePath}/${product.slug}`; + const canonicalUrl = `https://rate.vn${categoryBasePath}/${ + itemRoute?.kind === 'render' ? itemRoute.itemSlug : product.slug + }`; const structuredDataGraph = { '@context': 'https://schema.org', diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index d54152e30..525fcc09c 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -4429,3 +4429,404 @@ export async function inspectProductOrShopUrl( suggestions: fallbackSuggestions, }; } + +// --------------------------------------------------------------------------- +// Item / variant / listing model (DOS/Rate#1153; design doc +// `Docs/Proposals/2026-10-27-item-variant-listing-model.md`, sections 4, 5.1). +// These read `rate.items`, `master_products.item_id` and `products.item_id`, +// which exist only after the DOS-Me M1+M2 migration (#1150). Callers reach +// them only through `server/rate/item-model.ts` while `RATE_ITEM_MODEL` is on; +// with the flag off nothing here runs. +// --------------------------------------------------------------------------- + +/** A published `rate.items` row, as the web reads it. */ +export interface RateItem { + id: string; + item_key: string; + slug: string; + name: string; + brand: string; + model: string; + category_id: string | null; + default_variant_id: string | null; + min_price: number; + max_price: number; + price_variant_count: number; + merchant_count: number; + listing_count: number; + unknown_variant_listing_count: number; + updated_at: string | null; +} + +/** A published variant (`rate.master_products` row) of an Item. */ +export interface RateItemVariant { + id: string; + item_id: string; + slug: string; + name: string; + variant_key: string; + variant_name: string | null; + sort_order: number | null; + storage_gb: number | null; + min_price: number; + max_price: number; + merchant_count: number; +} + +/** An Item's offers: per variant, and the listings with no stated size. */ +export interface RateItemOffers { + byVariant: Map; + unknownVariant: MerchantOffer[]; +} + +/** + * `resolveCatalogSlug`: a catalogue slug is either an Item's own slug or an + * old variant slug (the URL every page used before Items, design doc 5.1). + */ +export type CatalogSlugResolution = + | { kind: 'item'; item: RateItem; variant: null } + | { + kind: 'variant'; + item: RateItem; + variant: RateItemVariant; + /** An archived variant, e.g. a retired `unknown` (design doc 7.2). */ + retired: boolean; + } + /** + * A master with no Item yet (`item_id` NULL before the backfill): its page + * stays today's variant page. + */ + | { kind: 'unlinked'; item: null; variant: null }; + +/** + * The Item tables or columns are missing (M1+M2 not applied where the flag + * is on). Callers treat it as "flag off" instead of failing every page. + */ +export class ItemModelUnavailableError extends Error { + constructor(what: string, cause: { message?: string; code?: string }) { + super( + `rate-api: item model unavailable (${what}): ${cause.code ?? ''} ${cause.message ?? ''}`.trim() + ); + this.name = 'ItemModelUnavailableError'; + } +} + +// undefined_table, undefined_column, and PostgREST's schema-cache misses. +const MISSING_SCHEMA_CODES = new Set([ + '42P01', + '42703', + 'PGRST204', + 'PGRST205', +]); + +/** Rethrows a missing-schema read error as ItemModelUnavailableError. */ +function throwIfItemSchemaMissing( + what: string +): (result: R) => R { + return (result) => { + const error = result.error as { message: string; code?: string } | null; + if (error?.code && MISSING_SCHEMA_CODES.has(error.code)) { + throw new ItemModelUnavailableError(what, error); + } + return result; + }; +} + +function itemReadError( + what: string, + error: { message: string; code?: string } +): Error { + return error.code && MISSING_SCHEMA_CODES.has(error.code) + ? new ItemModelUnavailableError(what, error) + : new RateDataUnavailableError(what, error); +} + +const ITEM_SELECT = + 'id, item_key, slug, name, brand, model, category_id, default_variant_id, min_price, max_price, price_variant_count, merchant_count, listing_count, unknown_variant_listing_count, updated_at'; +const ITEM_VARIANT_SELECT = + 'id, item_id, slug, name, variant_key, variant_name, sort_order, storage_gb, min_price, max_price, merchant_count'; + +// rate.items and the item columns are not in the generated Database types +// until #1150 lands, so these reads go through an untyped view of the client. +type UntypedRateQuery = { + select: (cols: string) => UntypedRateQuery; + eq: (col: string, val: unknown) => UntypedRateQuery; + is: (col: string, val: null) => UntypedRateQuery; + in: (col: string, vals: readonly unknown[]) => UntypedRateQuery; + order: ( + col: string, + opts?: { ascending?: boolean; nullsFirst?: boolean } + ) => UntypedRateQuery; + range: (from: number, to: number) => UntypedRateQuery; + maybeSingle: () => PromiseLike<{ + data: unknown; + error: { message: string; code?: string } | null; + }>; +} & PromiseLike<{ data: unknown[] | null; error: { message: string } | null }>; +const untypedRate = (): { from: (table: string) => UntypedRateQuery } => + getRateClient() as unknown as { from: (table: string) => UntypedRateQuery }; + +const toNonNegative = (raw: unknown): number => { + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 0; +}; + +function mapItemRow(raw: unknown): RateItem | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + const id = sanitizeRateEntityId(r.id); + const slug = typeof r.slug === 'string' ? r.slug : ''; + if (!id || !slug) return null; + const str = (v: unknown): string => (typeof v === 'string' ? v : ''); + return { + id, + item_key: str(r.item_key), + slug, + name: str(r.name), + brand: str(r.brand), + model: str(r.model), + category_id: sanitizeRateEntityId(r.category_id) || null, + default_variant_id: sanitizeRateEntityId(r.default_variant_id) || null, + min_price: toNonNegative(r.min_price), + max_price: toNonNegative(r.max_price), + price_variant_count: toNonNegative(r.price_variant_count), + merchant_count: toNonNegative(r.merchant_count), + listing_count: toNonNegative(r.listing_count), + unknown_variant_listing_count: toNonNegative( + r.unknown_variant_listing_count + ), + updated_at: typeof r.updated_at === 'string' ? r.updated_at : null, + }; +} + +function mapItemVariantRow(raw: unknown): RateItemVariant | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + const id = sanitizeRateEntityId(r.id); + const itemId = sanitizeRateEntityId(r.item_id); + const slug = typeof r.slug === 'string' ? r.slug : ''; + const variantKey = typeof r.variant_key === 'string' ? r.variant_key : ''; + if (!id || !itemId || !slug || !variantKey) return null; + const sortOrder = Number(r.sort_order); + const storage = Number(r.storage_gb); + return { + id, + item_id: itemId, + slug, + name: typeof r.name === 'string' ? r.name : '', + variant_key: variantKey, + variant_name: typeof r.variant_name === 'string' ? r.variant_name : null, + sort_order: + r.sort_order != null && Number.isFinite(sortOrder) ? sortOrder : null, + storage_gb: Number.isFinite(storage) && storage > 0 ? storage : null, + min_price: toNonNegative(r.min_price), + max_price: toNonNegative(r.max_price), + merchant_count: toNonNegative(r.merchant_count), + }; +} + +const isSafeCatalogSlug = (slug: string): boolean => + PRODUCT_SLUG_REGEX.test(slug) && + !slug.includes('..') && + !SENTINEL_RATE_API_TOKENS.has(slug.toLowerCase()); + +async function readItem( + column: 'slug' | 'id', + value: string +): Promise { + const { data, error } = await untypedRate() + .from('items') + .select(ITEM_SELECT) + .eq(column, value) + .eq('status', 'published') + .maybeSingle(); + if (error) { + log.error('item lookup failed', { column, value, message: error.message }); + throw itemReadError('item', error); + } + return mapItemRow(data); +} + +/** A published Item by its own slug. Needs M1+M2 (#1150). */ +export async function getItemBySlug(slug: string): Promise { + const clean = + typeof slug === 'string' ? slug.replace(/\p{Cc}+/gu, '').trim() : ''; + if (!isSafeCatalogSlug(clean)) return null; + return readItem('slug', clean); +} + +/** A published Item by id. Needs M1+M2 (#1150). */ +export async function getItemById(itemId: string): Promise { + const clean = sanitizeRateEntityId(itemId); + if (!clean) return null; + return readItem('id', clean); +} + +/** + * An Item's published variants: sized ones by storage, then the rest. A + * retired (archived) `unknown` variant is not one of them (design doc 4.4). + */ +export async function getItemVariants( + itemId: string +): Promise { + const clean = sanitizeRateEntityId(itemId); + if (!clean) return []; + const rows = await fetchAllRows( + 'item variants', + (from, to) => + untypedRate() + .from('master_products') + .select(ITEM_VARIANT_SELECT) + .eq('item_id', clean) + .eq('status', 'published') + .order('id') + .range(from, to) + .then( + throwIfItemSchemaMissing('item variants') + ) as unknown as PromiseLike<{ + data: unknown[] | null; + error: { message: string } | null; + }> + ); + return sortItemVariants( + rows.map(mapItemVariantRow).filter((v): v is RateItemVariant => v !== null) + ); +} + +/** sort_order first, then storage ascending, then the key, so the order is stable. */ +export function sortItemVariants( + variants: readonly RateItemVariant[] +): RateItemVariant[] { + return [...variants].sort((a, b) => { + const ao = a.sort_order ?? Number.MAX_SAFE_INTEGER; + const bo = b.sort_order ?? Number.MAX_SAFE_INTEGER; + if (ao !== bo) return ao - bo; + const as = a.storage_gb ?? Number.MAX_SAFE_INTEGER; + const bs = b.storage_gb ?? Number.MAX_SAFE_INTEGER; + if (as !== bs) return as - bs; + return a.variant_key.localeCompare(b.variant_key); + }); +} + +/** + * The listings of an Item with no stated size (`item_id` set, `cluster_id` + * NULL). The trust view has no `item_id` (#1242 leaves it unchanged), so the + * listing ids come from rate.products and their offers from the view. + */ +async function getItemUnknownVariantOffers( + itemId: string +): Promise { + const idRows = await fetchAllRows( + 'item unknown-variant listings', + (from, to) => + untypedRate() + .from('products') + .select('id, availability') + .eq('item_id', itemId) + .is('cluster_id', null) + .order('id') + .range(from, to) as unknown as PromiseLike<{ + data: unknown[] | null; + error: { message: string } | null; + }> + ); + const availability = new Map(); + const ids: string[] = []; + for (const raw of idRows as Array<{ id?: unknown; availability?: unknown }>) { + const id = typeof raw?.id === 'string' ? raw.id : ''; + if (!id) continue; + ids.push(id); + const a = toOfferAvailability(raw.availability); + if (a) availability.set(id, a); + } + if (ids.length === 0) return []; + const supabase = getRateClient(); + const viewRows: RawTrustViewRow[] = []; + for (let i = 0; i < ids.length; i += IN_FILTER_CHUNK_SIZE) { + const chunk = ids.slice(i, i + IN_FILTER_CHUNK_SIZE); + const rows = await fetchAllRows( + 'v_products_with_merchant_trust', + (from, to) => + supabase + .from('v_products_with_merchant_trust') + .select(TRUST_VIEW_SELECT) + .in('id', chunk) + .order('price_current', { ascending: true }) + .order('id') + .range(from, to) + ); + viewRows.push(...(rows as unknown as RawTrustViewRow[])); + } + const extras = await sellerExtrasForTrustViewRows(viewRows); + return viewRows + .map((row) => + withOfferAvailability( + mapTrustViewRowToMerchantOffer( + row, + extras.followerCounts.get(row.merchant_id), + extras.metadataDosafeRisks.get(row.merchant_id) + ), + availability + ) + ) + .sort((a, b) => a.price_current - b.price_current); +} + +/** + * An Item's offers grouped by variant, plus the "size not stated" group, + * which never feeds a variant's prices (design doc 4.4, Q2). + */ +export async function getItemOffers( + itemId: string, + variants: readonly RateItemVariant[] +): Promise { + const clean = sanitizeRateEntityId(itemId); + if (!clean) return { byVariant: new Map(), unknownVariant: [] }; + const [byVariant, unknownVariant] = await Promise.all([ + getMasterProductOffersBatch(variants.map((v) => v.id)), + getItemUnknownVariantOffers(clean), + ]); + return { byVariant, unknownVariant }; +} + +/** + * A catalogue slug as an Item: its own slug first, then an old variant slug + * (design doc 5.1, Q1). Null when neither is published. Needs M1+M2 (#1150). + */ +export async function resolveCatalogSlug( + slug: string +): Promise { + const clean = + typeof slug === 'string' ? slug.replace(/\p{Cc}+/gu, '').trim() : ''; + if (!isSafeCatalogSlug(clean)) return null; + const item = await readItem('slug', clean); + if (item) return { kind: 'item', item, variant: null }; + // An archived variant (a retired `unknown`, an archived duplicate) still + // redirects its old URL to its Item; it is never offered as a size. + const { data, error } = await untypedRate() + .from('master_products') + .select(`${ITEM_VARIANT_SELECT}, status`) + .eq('slug', clean) + .in('status', ['published', 'archived']) + .maybeSingle(); + if (error) { + log.error('variant slug lookup failed', { + slug: clean, + message: error.message, + }); + throw itemReadError('variant', error); + } + const variant = mapItemVariantRow(data); + if (!variant) { + // A master not yet linked to an Item keeps its page; only a slug that is + // no master at all (or a malformed row) is unknown here. + const row = data as { id?: unknown; item_id?: unknown } | null; + return row && sanitizeRateEntityId(row.id) && row.item_id == null + ? { kind: 'unlinked', item: null, variant: null } + : null; + } + const owner = await readItem('id', variant.item_id); + if (!owner) return null; + const retired = (data as { status?: unknown }).status === 'archived'; + return { kind: 'variant', item: owner, variant, retired }; +} diff --git a/apps/web/src/server/rate/__tests__/item-model.test.ts b/apps/web/src/server/rate/__tests__/item-model.test.ts new file mode 100644 index 000000000..3bd906a68 --- /dev/null +++ b/apps/web/src/server/rate/__tests__/item-model.test.ts @@ -0,0 +1,437 @@ +/** + * Item web data layer behind RATE_ITEM_MODEL (DOS/Rate#1153). rate.items does + * not exist in prod yet (#1150), so the flag-on path runs against a stubbed + * PostgREST built from the M1+M2 draft in PR #1242. + */ +import assert from 'node:assert/strict'; + +process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://stub.supabase.test'; +process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-test-key'; + +const ITEM_ID = '11111111-1111-4111-8111-111111111153'; +const V128 = '22222222-2222-4222-8222-222222222128'; +const V256 = '22222222-2222-4222-8222-222222222256'; +const UNKNOWN_LISTING = '33333333-3333-4333-8333-333333333333'; +const MERCHANT = '44444444-4444-4444-8444-444444444444'; + +const itemRow = { + id: ITEM_ID, + item_key: 'apple:iphone-16', + slug: 'apple-iphone-16', + name: 'Apple iPhone 16', + brand: 'Apple', + model: 'iPhone 16', + category_id: null, + default_variant_id: V256, + min_price: 21990000, + max_price: 25990000, + price_variant_count: 2, + merchant_count: 3, + listing_count: 4, + unknown_variant_listing_count: 1, + updated_at: '2026-10-29T00:00:00Z', +}; +const variantRow = (id: string, gb: number, sort: number | null) => ({ + id, + item_id: ITEM_ID, + slug: `apple-iphone-16-${gb}gb`, + name: `Apple iPhone 16 ${gb}GB`, + variant_key: `${gb}gb`, + variant_name: `${gb}GB`, + sort_order: sort, + storage_gb: gb, + min_price: gb === 128 ? 21990000 : 24990000, + max_price: gb === 128 ? 23990000 : 25990000, + merchant_count: 2, +}); + +let calls: string[] = []; +// Answer as a database without M1+M2 (PostgREST schema-cache miss). +let schemaMissing = false; +// Answer as a database whose master_products lacks the M2 columns. +let variantColumnsMissing = false; +globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + ); + calls.push(`${url.pathname}?${decodeURIComponent(url.search)}`); + const q = decodeURIComponent(url.search); + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json', 'content-range': '0-0/*' }, + }); + const offset = Number(url.searchParams.get('offset') ?? 0); + if (offset > 0) return json([]); + if (schemaMissing && url.pathname === '/rest/v1/items') { + return new Response( + JSON.stringify({ + code: 'PGRST205', + message: "Could not find the table 'rate.items' in the schema cache", + }), + { status: 404, headers: { 'content-type': 'application/json' } } + ); + } + if (variantColumnsMissing && url.pathname === '/rest/v1/master_products') { + return new Response( + JSON.stringify({ + code: '42703', + message: 'column master_products.sort_order does not exist', + }), + { status: 400, headers: { 'content-type': 'application/json' } } + ); + } + switch (url.pathname) { + case '/rest/v1/items': + if ( + q.includes('slug=eq.apple-iphone-16&') || + q.endsWith('slug=eq.apple-iphone-16') + ) + return json([itemRow]); + if (q.includes(`id=eq.${ITEM_ID}`)) return json([itemRow]); + return json([]); + case '/rest/v1/master_products': + if (q.includes('slug=eq.apple-iphone-16-128gb')) + return json([{ ...variantRow(V128, 128, null), status: 'published' }]); + if (q.includes('slug=eq.apple-iphone-16-unknown')) + return json([ + { + ...variantRow(V128, 128, null), + slug: 'apple-iphone-16-unknown', + variant_key: 'unknown', + storage_gb: null, + status: 'archived', + }, + ]); + // A published master the backfill has not linked to an Item yet. + if (q.includes('slug=eq.samsung-galaxy-s24-256gb')) + return json([ + { + ...variantRow(V128, 256, null), + item_id: null, + slug: 'samsung-galaxy-s24-256gb', + status: 'published', + }, + ]); + if (q.includes(`item_id=eq.${ITEM_ID}`)) + return json([variantRow(V256, 256, null), variantRow(V128, 128, null)]); + return json([]); + case '/rest/v1/products': + if ( + q.includes(`item_id=eq.${ITEM_ID}`) && + q.includes('cluster_id=is.null') + ) + return json([{ id: UNKNOWN_LISTING, availability: 'in_stock' }]); + return json([]); + case '/rest/v1/v_products_with_merchant_trust': + if (q.includes(UNKNOWN_LISTING)) + return json([ + { + id: UNKNOWN_LISTING, + cluster_id: null, + merchant_id: MERCHANT, + platform: 'shopee', + title: 'iPhone 16 chính hãng', + price_current: 20990000, + price_original: null, + stock: 5, + rating_star: 5, + rating_count: 1, + url: 'https://shopee.vn/product/1/2', + merchant_name: 'Shop', + merchant_is_official: false, + merchant_trust_score: 90, + }, + ]); + return json([]); + default: + return json([]); + } +}) as typeof fetch; + +async function main() { + console.warn('🧪 Item web data layer suite (DOS/Rate#1153)'); + const flag = await import('../item-model'); + const { planItemRoute, resolveWebItemModelMode, sanitizeVariantParam } = flag; + + // 1. The flag reads like the importer's: anything unknown is off. + assert.equal(resolveWebItemModelMode(undefined), 'off'); + assert.equal(resolveWebItemModelMode('ITEM'), 'item'); + assert.equal(resolveWebItemModelMode('nullable-variant'), 'nullable_variant'); + assert.equal(resolveWebItemModelMode('on'), 'off'); + assert.equal(sanitizeVariantParam(['256GB']), '256gb'); + assert.equal(sanitizeVariantParam('../x'), null); + + // 2. Flag off: no Item read at all, and no extra cache tag. + delete process.env.RATE_ITEM_MODEL; + const { resolveItemRoute } = await import('../item-route'); + const { resolveRevalidationTargets, productCacheTag } = + await import('../cache-tags'); + calls = []; + assert.equal( + await resolveItemRoute({ + slug: 'apple-iphone-16-128gb', + categoryBasePath: '/smartphones', + }), + null + ); + assert.deepEqual(calls, [], 'flag off reads nothing'); + assert.ok( + !resolveRevalidationTargets({ + slugs: ['apple-iphone-16-128gb'], + }).tags.includes(productCacheTag('apple-iphone-16')) + ); + + // 3. Route plans (Q1). + const { sortItemVariants } = await import('../../../lib/rate-api'); + const item = { ...itemRow } as never; + const variants = sortItemVariants([ + variantRow(V256, 256, null), + variantRow(V128, 128, null), + ] as never); + assert.deepEqual( + variants.map((v) => v.variant_key), + ['128gb', '256gb'], + 'sized variants by storage' + ); + const base = { categoryBasePath: '/smartphones', variants }; + assert.deepEqual( + planItemRoute({ + ...base, + resolution: null, + requestedSlug: 'x', + requestedVariant: null, + }), + { kind: 'not_found' } + ); + assert.deepEqual( + planItemRoute({ + ...base, + resolution: { + kind: 'variant', + item, + variant: variants[0]!, + retired: false, + }, + requestedSlug: 'apple-iphone-16-128gb', + requestedVariant: null, + }), + { kind: 'redirect', to: '/smartphones/apple-iphone-16?v=128gb' } + ); + // A single-variant Item that shares its variant's slug renders, no loop. + const shared = { ...variants[0]!, slug: 'apple-iphone-16' }; + assert.equal( + planItemRoute({ + ...base, + resolution: { kind: 'variant', item, variant: shared, retired: false }, + requestedSlug: 'apple-iphone-16', + requestedVariant: null, + }).kind, + 'render' + ); + const render = (v: string | null, vs = variants) => + planItemRoute({ + ...base, + variants: vs, + resolution: { kind: 'item', item, variant: null }, + requestedSlug: 'apple-iphone-16', + requestedVariant: v, + }); + assert.deepEqual(render('128gb'), { + kind: 'render', + variantSlug: 'apple-iphone-16-128gb', + itemSlug: 'apple-iphone-16', + variantKey: '128gb', + }); + assert.equal( + (render(null) as { variantKey: string }).variantKey, + '256gb', + 'default variant' + ); + assert.equal( + (render('1024gb') as { variantKey: string }).variantKey, + '256gb', + 'an unknown ?v= falls back to the default' + ); + assert.equal(render(null, []).kind, 'not_found'); + // A retired size redirects to the Item with no size preselected. + assert.deepEqual( + planItemRoute({ + ...base, + resolution: { + kind: 'variant', + item, + variant: { + ...variants[0]!, + slug: 'apple-iphone-16-unknown', + variant_key: 'unknown', + }, + retired: true, + }, + requestedSlug: 'apple-iphone-16-unknown', + requestedVariant: null, + }), + { kind: 'redirect', to: '/smartphones/apple-iphone-16' } + ); + assert.equal( + flag.itemSlugFromVariantSlug('apple-iphone-16-1024gb'), + 'apple-iphone-16' + ); + assert.equal( + flag.itemSlugFromVariantSlug('sony-wf-1000xm5-unknown'), + 'sony-wf-1000xm5' + ); + assert.equal( + flag.itemSlugFromVariantSlug('garmin-forerunner-265'), + 'garmin-forerunner-265' + ); + + // 4. Flag on: the data layer against the M1+M2 shape. + process.env.RATE_ITEM_MODEL = 'item'; + const api = await import('../../../lib/rate-api'); + calls = []; + const byVariant = await api.resolveCatalogSlug('apple-iphone-16-128gb'); + assert.equal(byVariant?.kind, 'variant'); + assert.equal(byVariant?.item.slug, 'apple-iphone-16'); + assert.equal(byVariant?.variant?.variant_key, '128gb'); + assert.ok( + calls.some( + (c) => + c.startsWith('/rest/v1/items?') && c.includes('status=eq.published') + ) + ); + const byItem = await api.resolveCatalogSlug('apple-iphone-16'); + assert.equal(byItem?.kind, 'item'); + assert.equal(await api.resolveCatalogSlug('no-such-slug'), null); + const retired = await api.resolveCatalogSlug('apple-iphone-16-unknown'); + assert.equal(retired?.kind === 'variant' && retired.retired, true); + assert.ok( + calls.some((c) => c.includes('status=in.(published,archived)')), + 'old variant URLs include retired ones' + ); + assert.equal(await api.resolveCatalogSlug('../etc'), null); + + const itemVariants = await api.getItemVariants(ITEM_ID); + assert.deepEqual( + itemVariants.map((v) => v.variant_key), + ['128gb', '256gb'] + ); + const offers = await api.getItemOffers(ITEM_ID, itemVariants); + assert.equal(offers.unknownVariant.length, 1); + assert.equal(offers.unknownVariant[0]?.id, UNKNOWN_LISTING); + assert.equal(offers.unknownVariant[0]?.availability, 'in_stock'); + assert.ok( + calls.some( + (c) => + c.startsWith('/rest/v1/products?') && c.includes('cluster_id=is.null') + ), + 'the unknown group reads listings with no variant' + ); + + // 5. Flag on: an old variant URL plans a 308, and revalidating it busts the Item. + const plan = await resolveItemRoute({ + slug: 'apple-iphone-16-128gb', + categoryBasePath: '/smartphones', + }); + assert.deepEqual(plan, { + kind: 'redirect', + to: '/smartphones/apple-iphone-16?v=128gb', + }); + // An Item URL renders its default variant; ?v= is applied client-side. + const itemPlan = await resolveItemRoute({ + slug: 'apple-iphone-16', + categoryBasePath: '/smartphones', + }); + assert.equal(itemPlan?.kind === 'render' && itemPlan.variantKey, '256gb'); + assert.deepEqual( + await resolveItemRoute({ + slug: 'apple-iphone-16-unknown', + categoryBasePath: '/smartphones', + }), + { kind: 'redirect', to: '/smartphones/apple-iphone-16' } + ); + // Flag on without M1+M2: the page falls back to today's variant page. + schemaMissing = true; + await assert.rejects( + () => api.resolveCatalogSlug('apple-iphone-16-256gb'), + (err: unknown) => err instanceof api.ItemModelUnavailableError + ); + assert.equal( + await resolveItemRoute({ + slug: 'apple-iphone-16-256gb', + categoryBasePath: '/smartphones', + }), + null + ); + schemaMissing = false; + // Variant columns missing behind a readable Item: same fallback. + variantColumnsMissing = true; + await assert.rejects( + () => api.getItemVariants(ITEM_ID), + (err: unknown) => err instanceof api.ItemModelUnavailableError + ); + assert.equal( + await resolveItemRoute({ + slug: 'apple-iphone-16', + categoryBasePath: '/smartphones', + }), + null + ); + variantColumnsMissing = false; + // A master with no Item yet keeps its page instead of a 404. + assert.deepEqual(await api.resolveCatalogSlug('samsung-galaxy-s24-256gb'), { + kind: 'unlinked', + item: null, + variant: null, + }); + assert.equal( + await resolveItemRoute({ + slug: 'samsung-galaxy-s24-256gb', + categoryBasePath: '/smartphones', + }), + null + ); + assert.ok( + resolveRevalidationTargets({ + slugs: ['apple-iphone-16-128gb'], + }).tags.includes(productCacheTag('apple-iphone-16')) + ); + delete process.env.RATE_ITEM_MODEL; + + // Product routes stay ISR whatever the flag: no request-time API (query + // string, headers, cookies) and no dynamic opt-in. `?v=` is client-side. + const fs = require('node:fs') as typeof import('node:fs'); + const path = require('node:path') as typeof import('node:path'); + const productRouteFiles = [ + 'smartphones', + 'earbuds', + 'laptops', + 'smartwatches', + 'tablets', + ].map((c) => + path.resolve(__dirname, `../../../app/[locale]/${c}/[slug]/page.tsx`) + ); + for (const file of [ + ...productRouteFiles, + path.resolve(__dirname, '../item-route.ts'), + ]) { + const src = fs.readFileSync(file, 'utf8'); + assert.doesNotMatch(src, /searchParams|\bheaders\(|\bcookies\(/, file); + assert.doesNotMatch(src, /export const dynamic\b|force-dynamic/, file); + } + for (const file of productRouteFiles) { + const src = fs.readFileSync(file, 'utf8'); + assert.match(src, /export const revalidate = 300;/, file); + assert.match(src, /export function generateStaticParams\(/, file); + } + console.warn('✅ Item web data layer suite passed'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/web/src/server/rate/cache-tags.ts b/apps/web/src/server/rate/cache-tags.ts index ecad50ecf..6ac658576 100644 --- a/apps/web/src/server/rate/cache-tags.ts +++ b/apps/web/src/server/rate/cache-tags.ts @@ -1,5 +1,6 @@ import 'server-only'; import { unstable_cache } from 'next/cache'; +import { isItemModelOn, itemSlugFromVariantSlug } from './item-model'; /** * Canonical Next.js Data Cache tags for Rate.vn (Audit C3 & Phase 7.3). @@ -175,6 +176,12 @@ export function resolveRevalidationTargets(input: RevalidationTargetsInput): { continue; } tagSet.add(productCacheTag(clean)); + // RATE_ITEM_MODEL on: a variant's change also refreshes its Item page + // (design doc 5.1 "Cache", DOS/Rate#1153). + if (isItemModelOn()) { + const itemSlug = itemSlugFromVariantSlug(clean); + if (itemSlug !== clean) tagSet.add(productCacheTag(itemSlug)); + } tagSet.add(RATE_CACHE_TAGS.PRODUCTS); tagSet.add(RATE_CACHE_TAGS.OFFERS); tagSet.add(RATE_CACHE_TAGS.PRICE_HISTORY); diff --git a/apps/web/src/server/rate/item-model.ts b/apps/web/src/server/rate/item-model.ts new file mode 100644 index 000000000..1348dabeb --- /dev/null +++ b/apps/web/src/server/rate/item-model.ts @@ -0,0 +1,122 @@ +import 'server-only'; +import type { + CatalogSlugResolution, + RateItemVariant, +} from '../../lib/rate-api'; + +/** + * Web rollout flag for the Item / variant / listing model (DOS/Rate#1153; + * design doc `Docs/Proposals/2026-10-27-item-variant-listing-model.md`, + * sections 5.1 and 7). Same env and values as the importer's + * `item-model-flag.ts`: + * - `off` (default, and any unknown value): today's pages. Nothing reads + * `rate.items` or the item columns, so the site runs on a database without + * the DOS-Me M1+M2 migration (#1150). + * - `item` / `nullable_variant`: catalogue slugs resolve to Items, old + * variant URLs 308 to the Item URL with `?v=`. Needs M1+M2. + */ +export const ITEM_MODEL_ENV = 'RATE_ITEM_MODEL'; + +const ITEM_MODEL_MODES = ['off', 'item', 'nullable_variant'] as const; +export type WebItemModelMode = (typeof ITEM_MODEL_MODES)[number]; + +export function resolveWebItemModelMode( + raw: unknown = process.env[ITEM_MODEL_ENV] +): WebItemModelMode { + if (typeof raw !== 'string') return 'off'; + const value = raw.trim().toLowerCase().replace(/-/g, '_'); + return (ITEM_MODEL_MODES as readonly string[]).includes(value) + ? (value as WebItemModelMode) + : 'off'; +} + +/** Whether pages read Items. Read per request, so a flag flip needs no build. */ +export function isItemModelOn(): boolean { + return resolveWebItemModelMode() !== 'off'; +} + +// A variant key in `?v=`: the literal third key segment, e.g. 256gb, 1024gb, +// standard, unknown, 256gb-cellular (design doc 4.2). +const VARIANT_PARAM_REGEX = /^[a-z0-9][a-z0-9-]{0,40}$/; + +export function sanitizeVariantParam(raw: unknown): string | null { + const value = Array.isArray(raw) ? raw[0] : raw; + if (typeof value !== 'string') return null; + const clean = value.trim().toLowerCase(); + return VARIANT_PARAM_REGEX.test(clean) ? clean : null; +} + +export type ItemRoutePlan = + | { kind: 'not_found' } + /** No Item for this master yet: today's variant page. */ + | { kind: 'legacy' } + | { kind: 'redirect'; to: string } + | { + kind: 'render'; + /** The variant whose page renders until the Item page ships (#1154). */ + variantSlug: string; + itemSlug: string; + variantKey: string; + }; + +/** + * What a product URL does under the Item model (Q1): + * - an old variant slug 308s to `/{category}/{item-slug}?v={variant_key}`, + * unless the Item shares that slug (a single-variant Item); + * - an Item slug renders the variant `?v=` names, else the Item's default + * variant, else its first. + */ +export function planItemRoute(input: { + resolution: CatalogSlugResolution | null; + requestedSlug: string; + requestedVariant: string | null; + categoryBasePath: string; + variants: readonly RateItemVariant[]; +}): ItemRoutePlan { + const { resolution, requestedSlug, requestedVariant, categoryBasePath } = + input; + if (!resolution) return { kind: 'not_found' }; + if (resolution.kind === 'unlinked') return { kind: 'legacy' }; + const { item } = resolution; + if (resolution.kind === 'variant' && item.slug !== requestedSlug) { + const base = categoryBasePath.replace(/\/+$/, ''); + const itemUrl = `${base}/${encodeURIComponent(item.slug)}`; + // A retired size (an archived `unknown`) is no size to preselect. + return { + kind: 'redirect', + to: resolution.retired + ? itemUrl + : `${itemUrl}?v=${encodeURIComponent(resolution.variant.variant_key)}`, + }; + } + const variants = input.variants; + const chosen = + (resolution.kind === 'variant' && !resolution.retired + ? resolution.variant + : null) ?? + (requestedVariant + ? variants.find((v) => v.variant_key === requestedVariant) + : undefined) ?? + variants.find((v) => v.id === item.default_variant_id) ?? + variants[0]; + if (!chosen) return { kind: 'not_found' }; + return { + kind: 'render', + variantSlug: chosen.slug, + itemSlug: item.slug, + variantKey: chosen.variant_key, + }; +} + +/** + * The Item slug an old variant slug most likely belongs to, by the rule M1 + * uses to create Items (`rate.item_slug_from_master`): the trailing size or + * `-standard` / `-unknown` segment dropped. Used to bust the Item page's cache + * when a variant changes; a collision-suffixed Item slug is busted by its own + * revalidation. + */ +export function itemSlugFromVariantSlug(slug: string): string { + const clean = String(slug ?? '').trim(); + const stripped = clean.replace(/-([0-9]+(gb|tb)|standard|unknown)$/i, ''); + return stripped || clean; +} diff --git a/apps/web/src/server/rate/item-route.ts b/apps/web/src/server/rate/item-route.ts new file mode 100644 index 000000000..bdd027ebc --- /dev/null +++ b/apps/web/src/server/rate/item-route.ts @@ -0,0 +1,78 @@ +import 'server-only'; +import { cache } from 'react'; +import { + ItemModelUnavailableError, + getItemVariants as _getItemVariants, + resolveCatalogSlug as _resolveCatalogSlug, + type RateItem, +} from '../../lib/rate-api'; +import { createLogger } from '@/lib/logger'; +import { + RATE_CACHE_TAGS, + productCacheTag, + withRateDataCache, +} from './cache-tags'; +import { isItemModelOn, planItemRoute, type ItemRoutePlan } from './item-model'; + +const log = createLogger('server.item-route'); + +// Cached like the variant pages: refreshed every 5 minutes, or when the slug's +// (or its Item's) product tag is revalidated (DOS/Rate#1153). +const _getCachedCatalogSlug = cache((slug: string) => + withRateDataCache(_resolveCatalogSlug, ['rate-catalog-slug', slug], { + revalidate: 300, + tags: [RATE_CACHE_TAGS.PRODUCTS, productCacheTag(slug)], + })(slug) +); + +const _getCachedItemVariants = cache((itemId: string, itemSlug: string) => + withRateDataCache(_getItemVariants, ['rate-item-variants', itemId], { + revalidate: 300, + tags: [RATE_CACHE_TAGS.PRODUCTS, productCacheTag(itemSlug)], + })(itemId) +); + +export function getCachedItemVariants(item: RateItem) { + return _getCachedItemVariants(item.id, item.slug); +} + +/** + * The Item route for a product URL, or null while `RATE_ITEM_MODEL` is off, + * or while the Item tables are missing (M1+M2 not applied): then the page is + * exactly as before. The query string is never read, so product pages keep + * their ISR cache: an Item URL renders its default variant, and `?v=` (which + * the redirects carry) is applied on the client by the Item page (#1154). + */ +export async function resolveItemRoute(input: { + slug: string; + categoryBasePath: string; +}): Promise { + if (!isItemModelOn()) return null; + try { + const resolution = await _getCachedCatalogSlug(input.slug); + const variants = + resolution?.kind === 'item' + ? await getCachedItemVariants(resolution.item) + : []; + const plan = planItemRoute({ + resolution, + requestedSlug: input.slug, + requestedVariant: null, + categoryBasePath: input.categoryBasePath, + variants, + }); + return plan.kind === 'legacy' ? null : plan; + } catch (err) { + if (err instanceof ItemModelUnavailableError) { + log.warn( + 'item model on but its tables are missing; serving variant pages', + { + slug: input.slug, + message: err.message, + } + ); + return null; + } + throw err; + } +} From 71289c2a60fc68b1c8f7eca71cb9aedf75cea869 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:46:31 +0700 Subject: [PATCH 37/69] fix(normalizer): a word starting with s after forerunner 265 is no suffix (#1265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(normalizer): a word starting with s after forerunner 265 is no suffix "Garmin Forerunner 265 Sạc nhanh" keyed garmin:forerunner-265s: the lookahead after the S only excluded ASCII letters, so the S of a Vietnamese word was read as the 265S suffix. Any letter or mark now blocks it; "265S", "265 S", "265-S" and "265S42mm" still key 265s. Closes #1262 Co-Authored-By: Claude Opus 5.5 --- .../shopee/__tests__/open-core-intelligence.test.ts | 13 ++++++++++++- apps/importer/src/shopee/product-normalizer.ts | 5 +++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 20c3b4f62..036376624 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -858,7 +858,18 @@ async function runSuite() { assert.equal(fr265s.is_benchmark_enriched, false, title); assert.equal(fr265s.battery_mah, null, title); } - assert.equal(keyOf('Garmin Forerunner 265 Sport'), 'garmin:forerunner-265:8gb'); + // A word that starts with S is no 265S suffix, Vietnamese ones included (DOS/Rate#1262). + for (const title of [ + 'Garmin Forerunner 265 Sport', + 'Garmin Forerunner 265 Sạc nhanh', + 'Garmin Forerunner 265 sale', + 'Garmin Forerunner 265 Sạc', + 'Garmin Forerunner 265 Sắc nét', + 'Garmin Forerunner 265 S/M', + ]) { + assert.equal(keyOf(title), 'garmin:forerunner-265:8gb', title); + } + assert.equal(keyOf('Garmin Forerunner 265 S'), 'garmin:forerunner-265s:unknown'); // A band size "S/M" after the 265 is no 265S. assert.equal(keyOf('Garmin Forerunner 265 S/M band'), 'garmin:forerunner-265:8gb'); assert.equal(keyOf('Garmin Forerunner 265 S-M band'), 'garmin:forerunner-265:8gb'); diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index a5bb3e3fa..4cfca5acf 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1728,10 +1728,11 @@ export class ProductNormalizer { model = 'ThinkPad X1 Carbon Gen 12'; } else if ( brand === 'Garmin' && - /forerunner\s*265[ -]?s(?![a-z])(?! ?[/-] ?[a-z])/i.test(modelTitle) + /forerunner\s*265[ -]?s(?![\p{L}\p{M}])(?! ?[/-] ?[a-z])/iu.test(modelTitle) ) { // The 265S is the 42mm model, not the 46mm 265 (DOS/Rate#1222): "265S", "265 S", - // "265-S", "265S42mm", but not a "265 S/M" band size or a word ("265 Sport"). + // "265-S", "265S42mm", but not a "265 S/M" band size or a word ("265 Sport", and + // "265 Sạc nhanh", whose next letter is not ASCII, DOS/Rate#1262). model = 'Forerunner 265S'; } else if (brand === 'Garmin' && /forerunner\s*265/i.test(modelTitle)) { model = 'Forerunner 265'; From 0e30eb958532091e9a5ff1b920b4e01592e23aa4 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:50:37 +0700 Subject: [PATCH 38/69] fix(web): localize the product page seller line labels (#1270) * fix(web): localize the product page seller line labels The offers table's seller line read "Trust: x%" and "DOSafe: x/100" in English on every locale. Both labels now come from the productDetail catalogue in vi, en and cs, and an e2e spec checks the line per locale. Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/offer-seller-line-locale.spec.ts | 24 +++++++++++++++++++ apps/web/locales/cs.json | 4 +++- apps/web/locales/en.json | 4 +++- apps/web/locales/vi.json | 4 +++- .../leaderboard/ProductOffersExplorer.tsx | 7 +++--- 5 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 apps/web/e2e/offer-seller-line-locale.spec.ts diff --git a/apps/web/e2e/offer-seller-line-locale.spec.ts b/apps/web/e2e/offer-seller-line-locale.spec.ts new file mode 100644 index 000000000..57aedecaf --- /dev/null +++ b/apps/web/e2e/offer-seller-line-locale.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; + +// The product page's seller line read "Trust: x%" and "DOSafe: x/100" in +// English on every locale (DOS/Rate#1269). +const PRODUCT = '/smartphones/e2e-bait-price-phone'; + +const CASES = [ + { prefix: '', trust: 'Độ tin cậy:', scam: 'Rủi ro lừa đảo:' }, + { prefix: '/en', trust: 'Trust:', scam: 'Scam risk:' }, + { prefix: '/cs', trust: 'Důvěryhodnost:', scam: 'Riziko podvodu:' }, +]; + +for (const { prefix, trust, scam } of CASES) { + test(`the seller line is localized on ${prefix || '/'}`, async ({ page }) => { + expect((await page.goto(`${prefix}${PRODUCT}`))?.status()).toBe(200); + const firstRow = page.locator('#offers-table tbody tr').first(); + await expect(firstRow).toContainText(trust); + await expect(firstRow).toContainText(scam); + await expect(firstRow).not.toContainText('DOSafe:'); + if (prefix !== '/en') { + await expect(firstRow).not.toContainText('Trust:'); + } + }); +} diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index a87c0efdb..3a881fcd2 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1186,7 +1186,9 @@ "exportMenu": "Exportovat data", "offersReviewRiskLabel": "Riziko recenzí", "offersReviewRiskNoData": "nedostatek dat", - "offersDiscontinuedBadge": "Ukončený prodej" + "offersDiscontinuedBadge": "Ukončený prodej", + "offersTrustBadge": "Důvěryhodnost: {score} %", + "offersScamRiskLabel": "Riziko podvodu" }, "homePage": { "catDescSmartphones": "Filtrovat podle čipsetu, RAM/ROM, obnovovací frekvence Hz, baterie mAh a nabíjení W", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 0481bb07a..56935b8ad 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1186,7 +1186,9 @@ "exportMenu": "Export data", "offersReviewRiskLabel": "Review risk", "offersReviewRiskNoData": "not enough data", - "offersDiscontinuedBadge": "Discontinued" + "offersDiscontinuedBadge": "Discontinued", + "offersTrustBadge": "Trust: {score}%", + "offersScamRiskLabel": "Scam risk" }, "homePage": { "catDescSmartphones": "Filter by chipset, RAM/ROM, refresh rate Hz, battery mAh & fast charge W", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 70b207452..4dd21ccc1 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -1186,7 +1186,9 @@ "exportMenu": "Xuất dữ liệu", "offersReviewRiskLabel": "Rủi ro đánh giá", "offersReviewRiskNoData": "chưa đủ dữ liệu", - "offersDiscontinuedBadge": "Ngừng bán" + "offersDiscontinuedBadge": "Ngừng bán", + "offersTrustBadge": "Độ tin cậy: {score}%", + "offersScamRiskLabel": "Rủi ro lừa đảo" }, "homePage": { "catDescSmartphones": "Lọc theo chipset, RAM/ROM, tần số quét Hz, pin mAh & sạc nhanh W", diff --git a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx index a3fc0e9fb..fea299eb0 100644 --- a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx +++ b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx @@ -751,11 +751,11 @@ export default function ProductOffersExplorer({ className={`badge ${trustBadgeClass} text-decoration-none small`} title={t('offersMerchantTrustTitle')} > - Trust: {trustScore}% ↗ + {t('offersTrustBadge', { score: trustScore })} ↗ ) : ( - Trust: {trustScore}% + {t('offersTrustBadge', { score: trustScore })} )}
@@ -763,7 +763,8 @@ export default function ProductOffersExplorer({ className="text-muted" style={{ fontSize: '0.8125rem' }} > - DOSafe:{' '} + {/* Every label from the catalogue (DOS/Rate#1269). */} + {t('offersScamRiskLabel')}:{' '} {formatOfferRiskScore100( offer.merchant.dosafe_risk_score )} From 1cf12a5f72453508b02383280419313e0165f6ab Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:52:09 +0700 Subject: [PATCH 39/69] fix(normalizer): a gifted or bundled case size is not the watch's (#1271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(normalizer): a gifted or bundled case size is not the watch's "Apple Watch Series 10 42mm GPS | Case 46mm" and ", tặng Silicone Case 46mm" keyed the 46mm: the bundled case was read as the watch's named case. Now: - the watch's own part ends at a gift or bundle word (tặng, quà tặng, kèm), like it ends at cho/for and '+'; - gift and accessory words (tặng, quà, gift, silicone, TPU, bao da) mark a part as an accessory, so its size is no named case; - a named case after '|' does not override a size the watch already has before it outside any band. Closes #1263 * fix(normalizer): cut a gift only at a separate part, read nfc titles Codex round 1 on DOS/Rate#1263: - the gift/bundle cut needs a ',' or '|' before tặng/kèm, so "kèm sạc 46mm GPS", "kèm bút 13 inch" keep the product's size; - a band-only watch size is unique once the gifted part is left out ("Silicone Band 42mm, tặng Silicone Case 46mm" keys 42mm); - the title is read in NFC, so a decomposed "tặng" is found. Refs #1263 * fix(normalizer): drop only a gifted accessory's size, not the rest Codex round 2 on DOS/Rate#1263: cutting the title at ', kèm' or '| tặng' dropped real sizes (", kèm sạc 46mm GPS", "| tặng bút 13 inch"), and without a separator "kèm ốp 46mm" was read as the watch's size again. Now only a size that follows tặng/kèm and names an accessory sold by size (ốp, case, bao da, silicone, TPU, cường lực, vỏ, hộp, ...) within four words is left out; the rest of the title is read as before. Refs #1263 * fix(normalizer): bao alone is no gifted accessory Codex round 3 on DOS/Rate#1263: "kèm bao test 13 inch" (guaranteed testing) dropped the iPad's own size. Only "bao da" counts. Refs #1263 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 31 +++++++++++ .../importer/src/shopee/product-normalizer.ts | 51 +++++++++++++++---- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 036376624..fedd303e9 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -438,6 +438,15 @@ async function runSuite() { ['Apple Watch Series 11 46mm GPS 64GB', 'apple:watch-series-11-46mm:64gb'], // The other size is another product. ['iPad Pro M4 13 inch 512GB', 'apple:ipad-pro-m4-13:512gb'], + // A bundle word inside the product's own part keeps its size (DOS/Rate#1263). + ['iPad Pro M4 kèm bút 13 inch WiFi 256GB', 'apple:ipad-pro-m4-13:256gb'], + ['MacBook Air M3 kèm chuột 15 inch 16GB 512GB', 'apple:macbook-air-m3-15:512gb'], + ['iPad Pro M4 | tặng bút 13 inch WiFi 256GB', 'apple:ipad-pro-m4-13:256gb'], + ['MacBook Air M3, kèm chuột 15 inch 16GB 512GB', 'apple:macbook-air-m3-15:512gb'], + ['iPad Pro M4 11 inch tặng bao da 13 inch 256GB', 'apple:ipad-pro-m4-11:256gb'], + // "bao test" (guaranteed testing) is no accessory. + ['iPad Pro M4 kèm bao test 13 inch WiFi 256GB', 'apple:ipad-pro-m4-13:256gb'], + ['MacBook Air M3 kèm bao test 15 inch 16GB 512GB', 'apple:macbook-air-m3-15:512gb'], ['iPad Pro 13" M5 1TB', 'apple:ipad-pro-m5-13:1024gb'], ['iPad Pro 11 2024 M4 256GB', 'apple:ipad-pro-m4-11:256gb'], ['iPad Pro 12.9 inch M2 128GB', 'apple:ipad-pro-m2-129:128gb'], @@ -648,6 +657,28 @@ async function runSuite() { 'Apple Watch Series 10 GPS 46mm (Sport Band 45mm, fits 42mm case)', 'apple:watch-series-10-46mm:64gb', ], + // A gifted or bundled case is not the watch's (DOS/Rate#1263). + ['Apple Watch Series 10 42mm GPS | Case 46mm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 42mm GPS, tặng Silicone Case 46mm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 42mm GPS tặng ốp 46mm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 42mm GPS | Ốp TPU Case 46mm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 42mm GPS | Bao da Case 46mm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 GPS, tặng Silicone Case 46mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 GPS | kèm ốp 46mm', 'apple:watch-series-10:64gb'], + [ + 'Apple Watch Series 10 GPS, tặng Silicone Case 46mm'.normalize('NFD'), + 'apple:watch-series-10:64gb', + ], + [ + 'Apple Watch Series 10 GPS Silicone Band 42mm, tặng Silicone Case 46mm', + 'apple:watch-series-10-42mm:64gb', + ], + ['Apple Watch Series 10 GPS kèm ốp 46mm', 'apple:watch-series-10:64gb'], + // A bundle word before no sized accessory keeps the product's size. + ['Apple Watch Series 10 kèm sạc 46mm GPS', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10, kèm sạc 46mm GPS', 'apple:watch-series-10-46mm:64gb'], + // Still the watch's: a named case after a band size, or a lone "| 46mm" case. + ['Apple Watch Series 10 GPS | Case 46mm', 'apple:watch-series-10-46mm:64gb'], ]; for (const [title, expectedKey] of namedCaseKeys1223) { assert.equal(keyOf(title), expectedKey, title); diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index 4cfca5acf..b142ee58f 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1276,9 +1276,16 @@ const WATCH_CASE_WORD = /(? = { ipad: sizePatterns(IPAD_SIZE), 'macbook air': sizePatterns(MACBOOK_AIR_SIZE), @@ -1293,7 +1300,9 @@ const APPLE_SIZE_PATTERNS: Record = { * "inch" wins over * a bare number (DOS/Rate#1113). */ -function readAppleSize(model: string, title: string): string { +function readAppleSize(model: string, rawTitle: string): string { + // Composed form, so "tặng" is found in a decomposed (NFD) title too. + const title = rawTitle.normalize('NFC'); const family = /^ipad (?:pro|air)\b/i.test(model) ? 'ipad' : /^macbook air\b/i.test(model) @@ -1306,7 +1315,15 @@ function readAppleSize(model: string, title: string): string { if (!family) return ''; const at = title.search(new RegExp(`\\b${family.split(' ')[0]}\\b`, 'i')); // A bundle's "+ bàn phím 11 inch" is another product; "GPS + Cellular" is the watch. - const own = title.slice(Math.max(0, at)).split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!; + // The size of a gifted or bundled accessory is not the product's: "tặng Silicone Case + // 46mm", "kèm ốp 46mm" lose that size; "kèm sạc 46mm GPS" and "kèm bút 13 inch" name + // no accessory with a size of its own and keep it (DOS/Rate#1263). + const withoutGift = title + .slice(Math.max(0, at)) + .replace(GIFTED_ACCESSORY_SIZE, (m, word: string, words: string) => + GIFT_ACCESSORY_NOUN.test(words) ? `${word}${words}` : m + ); + const own = withoutGift.split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!; if (family === 'watch') { // A size outside a band phrase ("dây", "band", "strap", "loop" or "bracelet" up to the // next ")", ",", "|", ";", en dash or " - ") is the case's. A band is sold by the case @@ -1371,9 +1388,27 @@ function readAppleSize(model: string, title: string): string { // Titanium Case" is the 46mm (DOS/Rate#1223). // The size must sit next to the case word, and the part must not be about a box, a // cover or what something fits ("kèm vỏ hộp 42mm", "fits 42mm case"). + // A "| Case 46mm" after a part that already gives the watch a size outside any band + // ("Series 10 42mm GPS | Case 46mm") is a bundled case, not the watch's (DOS/Rate#1263). + const sizedBeforePipe = (i: number) => + parts.some( + (sep, j) => + j < i && + sep === '|' && + parts + .slice(0, j) + .some( + (p, k) => + k % 2 === 0 && + WATCH_SIZE.test(p) && + !WATCH_BAND_WORD.test(p) && + !CASE_ACCESSORY_WORD.test(p.normalize('NFC')) + ) + ); const namedCase = parts - .filter((_, i) => i % 2 === 0) - .map(part => part.normalize('NFC')) + .map((part, i) => ({ part: part.normalize('NFC'), i })) + .filter(({ i }) => i % 2 === 0 && !sizedBeforePipe(i)) + .map(({ part }) => part) .filter( part => !WATCH_BAND_WORD.test(part) && @@ -1389,9 +1424,7 @@ function readAppleSize(model: string, title: string): string { const caseSize = outside.join(' ').match(WATCH_SIZE)?.[1]; if (caseSize) return `${caseSize}mm`; const bandSize = own.match(WATCH_SIZE)?.[1]; - const sizes = new Set( - [...title.slice(Math.max(0, at)).matchAll(new RegExp(WATCH_SIZE, 'gi'))].map(m => m[1]) - ); + const sizes = new Set([...withoutGift.matchAll(new RegExp(WATCH_SIZE, 'gi'))].map(m => m[1])); return bandSize && sizes.size === 1 ? `${bandSize}mm` : ''; } const [marked, bare] = APPLE_SIZE_PATTERNS[family]!; From b28c4eece79e5851979a7db7c15bb48cd71523d1 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:55:12 +0700 Subject: [PATCH 40/69] docs(proposals): dos-me m0 seed re-key data step with replay test (#1267) * docs(proposals): dos-me m0 seed re-key data step with rollback and replay test PROPOSAL, NOT APPLIED. Idempotent data-only SQL re-keying the six drifted flagship-seed masters to the normalizer keys and clearing storage_gb on earbuds, recording the old values in metadata; a rollback restoring them; read-only checks; and a CI replay that applies it twice on the inventory's six masters with listings, then the M1+M2 Item proposal on top, then both rollbacks, plus the skip when a target key is taken. The M1+M2 proposal now names M0 as its first step. The matching seed change ships right after the data step, in its own PR. Refs #1212 * docs(proposals): m0 review fixes and the worker grants sized for engine writes - M0: earbuds storage is cleared by category and by the re-keyed earbuds keys, so a master with a NULL category is covered; V2 uses a left join; V3's comment fits the parser; the replay has a NULL-category fixture. - M1+M2: the replay asserts the engine's worker write paths (syncArenaSpecsToItem's updated_at-guarded spec update, item_id reads on listings and variants); the .md sizes the rate_worker grants from them. No grant change: products and master_products carry table-level grants, so the new columns are covered. Refs #1212 Refs #1150 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + .gitignore | 3 + .../2026-10-28-dos-me-m1-m2-items.md | 30 +-- .../2026-10-28-dos-me-m1-m2-items.sql | 2 +- ...26-10-29-dos-me-m0-seed-rekey-rollback.sql | 39 ++++ ...2026-10-29-dos-me-m0-seed-rekey-verify.sql | 39 ++++ .../2026-10-29-dos-me-m0-seed-rekey.md | 56 +++++ .../2026-10-29-dos-me-m0-seed-rekey.sql | 77 +++++++ scripts/test-item-migration-proposal.mjs | 24 +++ scripts/test-m0-seed-rekey-proposal.mjs | 196 ++++++++++++++++++ 10 files changed, 453 insertions(+), 16 deletions(-) create mode 100644 Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-rollback.sql create mode 100644 Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-verify.sql create mode 100644 Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.md create mode 100644 Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.sql create mode 100644 scripts/test-m0-seed-rekey-proposal.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1487a94e..99a07ad38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,9 @@ jobs: - name: Replay the DOS-Me Item migration proposal (Docker) run: node scripts/test-item-migration-proposal.mjs + - name: Replay the DOS-Me M0 seed re-key proposal (Docker) + run: node scripts/test-m0-seed-rekey-proposal.mjs + - name: Check generated rate schema types match the migrations run: pnpm check:db-types diff --git a/.gitignore b/.gitignore index ad673198f..ab455691b 100644 --- a/.gitignore +++ b/.gitignore @@ -139,6 +139,9 @@ playwright-report/ !Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql !Docs/Proposals/2026-10-28-dos-me-m1-m2-items-rollback.sql !Docs/Proposals/2026-10-28-dos-me-m1-m2-items-verify.sql +!Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.sql +!Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-rollback.sql +!Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-verify.sql * - Copy* * - Bản sao* *.dump diff --git a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.md b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.md index f16e4651b..55bdd4f62 100644 --- a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.md +++ b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.md @@ -15,27 +15,27 @@ Design: [`2026-10-27-item-variant-listing-model.md`](./2026-10-27-item-variant-l ## Order of operations -1. **DOS/Rate#1123 first** (M0 re-key of the six drifted seed masters, with JOY's approval), so the Items get the normalizer's Item keys (`apple:watch-ultra-3`, `apple:airpods-pro-2`). +1. **M0 first: [`2026-10-29-dos-me-m0-seed-rekey.sql`](./2026-10-29-dos-me-m0-seed-rekey.sql)** (DOS/Rate#1212, #1123: re-key of the six drifted seed masters and the seed change right after, with JOY's approval), so the Items get the normalizer's Item keys (`apple:watch-ultra-3`, `apple:airpods-pro-2`). 2. This migration on preview, run the verification file, then beta, then prod. 3. The importer keeps `RATE_ITEM_MODEL` off until Rate Services handles NULL `cluster_id` (design task 13). Flag `item` can come first; `nullable_variant` after task 13. 4. Mirror the applied file into `supabase/migrations/` and regenerate `database.types.ts` (as #1147 did). ## Contents and the contracts they satisfy -| Piece | Contract / source | -| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------- | -| `rate.items` (design 4.3 columns), RLS: public reads published, `service_role` all, `rate_worker` reads all and updates spec columns only (description, specifications, key_specs, spec_provenance, images, thumbnail, metadata), never status, key, prices or counts | matcher reads draft Items (#1179); seed spec sync writes as service role (#1208); Rate Services needs SELECT, UPDATE and the two RPCs (RS#700) | -| `master_products.item_id`, `variant_key`, `sort_order`; `products.item_id`, `variant_source` (+ CHECK) | pipeline writes (#1179, #1190) | -| index `rate.products(item_id)` | Rate Services filters by Item (RS#688, PR #689) | -| M1 sync triggers: a master inserted or re-keyed gets its Item from its key (draft, published with its variant); its listings follow; a listing's Item follows its variant | design 4.3 compatibility sync; `itemIdOfMaster` in the matcher fails loudly without it | -| `match_item(search_title, p_brand, p_model, p_threshold)` -> `item_id, item_key, name, similarity` | matcher Item Tier 2 (#1179) | -| `refresh_item_stats(p_item_id)`: prices from published variants, listing / unknown-variant / merchant counts, **and the pooled Item rating** mirrored onto the variants | pipeline and review analyzer (#1190, #1208) | -| `refresh_master_product_rating(p_master_id)`: same signature, rates the whole Item when the master has one | design 4.5 | -| `refresh_review_vote_counts`: resolves the Item through `products.item_id`, so votes on a listing with no variant count | design 4.5 | -| `retire_item_unknown_variant(p_item_id)`: one transaction, Item row locked; listings of a multi-size Item's live `unknown` variant move to no variant, the variant is archived, stats refreshed; earbuds never | Codex P1 left open on #1179 | -| `products` BEFORE trigger: a `manual` variant is never moved by a later write (an ingest that read the row before it was hand-set included); admin tools move it with `SET LOCAL rate.variant_override = 'on'` | Codex P1 on #1179 (read-then-upsert race) | -| `products` AFTER trigger: the Item and variant a listing leaves are refreshed by the statement that moves it, under the row lock | Codex finding on #1190 (overlapping ingests leave the middle Item stale) | -| Backfill (design 7.1) with an up-front slug-collision abort; Item specs and key specs without storage and RAM | design 7.1, #1208 rule "no storage on an Item" | +| Piece | Contract / source | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `rate.items` (design 4.3 columns), RLS: public reads published, `service_role` all, `rate_worker` reads all and updates spec columns only (description, specifications, key_specs, spec_provenance, images, thumbnail, metadata), never status, key, prices or counts | matcher reads draft Items (#1179); seed spec sync writes as service role (#1208); Rate Services itself only SELECTs `id, item_key, name` and EXECUTEs `refresh_item_stats` (RS PO, RS#700); the grants are sized for our engine when Rate Services runs it as `rate_worker`: `syncArenaSpecsToItem` (#1213) updates `specifications, key_specs, updated_at` guarded by `updated_at`, ingest writes `products.item_id / variant_source` (table-level grant on products and master_products, so the new columns are covered; no column-scoped SELECT exists on them), the matcher EXECUTEs `match_item`. All asserted in the CI replay | +| `master_products.item_id`, `variant_key`, `sort_order`; `products.item_id`, `variant_source` (+ CHECK) | pipeline writes (#1179, #1190) | +| index `rate.products(item_id)` | Rate Services filters by Item (RS#688, PR #689) | +| M1 sync triggers: a master inserted or re-keyed gets its Item from its key (draft, published with its variant); its listings follow; a listing's Item follows its variant | design 4.3 compatibility sync; `itemIdOfMaster` in the matcher fails loudly without it | +| `match_item(search_title, p_brand, p_model, p_threshold)` -> `item_id, item_key, name, similarity` | matcher Item Tier 2 (#1179) | +| `refresh_item_stats(p_item_id)`: prices from published variants, listing / unknown-variant / merchant counts, **and the pooled Item rating** mirrored onto the variants | pipeline and review analyzer (#1190, #1208) | +| `refresh_master_product_rating(p_master_id)`: same signature, rates the whole Item when the master has one | design 4.5 | +| `refresh_review_vote_counts`: resolves the Item through `products.item_id`, so votes on a listing with no variant count | design 4.5 | +| `retire_item_unknown_variant(p_item_id)`: one transaction, Item row locked; listings of a multi-size Item's live `unknown` variant move to no variant, the variant is archived, stats refreshed; earbuds never | Codex P1 left open on #1179 | +| `products` BEFORE trigger: a `manual` variant is never moved by a later write (an ingest that read the row before it was hand-set included); admin tools move it with `SET LOCAL rate.variant_override = 'on'` | Codex P1 on #1179 (read-then-upsert race) | +| `products` AFTER trigger: the Item and variant a listing leaves are refreshed by the statement that moves it, under the row lock | Codex finding on #1190 (overlapping ingests leave the middle Item stale) | +| Backfill (design 7.1) with an up-front slug-collision abort; Item specs and key specs without storage and RAM | design 7.1, #1208 rule "no storage on an Item" | ## Concurrency and compatibility diff --git a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql index 8474c0ee6..8d06868f7 100644 --- a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql +++ b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql @@ -4,7 +4,7 @@ -- prod). Zero DDL on prod from DOS/Rate. Rollback: 2026-10-28-dos-me-m1-m2-items-rollback.sql. -- Verification: 2026-10-28-dos-me-m1-m2-items-verify.sql. -- --- Order: DOS/Rate#1123 (re-key of the six drifted seed masters, M0) runs first, so the Items +-- Order: M0 (2026-10-29-dos-me-m0-seed-rekey.sql, DOS/Rate#1212 / #1123) runs first, so the Items -- are created with the normalizer's Item keys. -- -- Idempotent: every step is IF NOT EXISTS, guarded by pg_constraint / pg_trigger, CREATE OR diff --git a/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-rollback.sql b/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-rollback.sql new file mode 100644 index 000000000..33563a79a --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-rollback.sql @@ -0,0 +1,39 @@ +-- PROPOSAL, NOT APPLIED. Rollback of 2026-10-29-dos-me-m0-seed-rekey.sql (DOS/Rate#1212). +-- Run only BEFORE M1+M2 (once Items exist, roll M1+M2 back first: Items are keyed on the new +-- model_keys). Revert the seed change in the same step. Re-runnable: it only touches rows +-- that still carry the M0 markers in metadata. + +BEGIN; + +-- 1. Restore the previous keys. A key taken again meanwhile (by a newly seeded master) is +-- skipped with a NOTICE and left for a manual decision. +DO $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT id, model_key, metadata ->> 'm0_rekeyed_from' AS old_key + FROM rate.master_products + WHERE metadata ? 'm0_rekeyed_from' + LOOP + IF EXISTS (SELECT 1 FROM rate.master_products WHERE model_key = r.old_key AND id <> r.id) THEN + RAISE NOTICE 'M0 rollback skip %: % is taken again', r.model_key, r.old_key; + ELSE + UPDATE rate.master_products + SET model_key = r.old_key, + metadata = metadata - 'm0_rekeyed_from', + updated_at = now() + WHERE id = r.id; + END IF; + END LOOP; +END +$$; + +-- 2. Restore the earbuds storage values. +UPDATE rate.master_products + SET storage_gb = (metadata ->> 'm0_storage_gb_before')::numeric, + metadata = metadata - 'm0_storage_gb_before', + updated_at = now() + WHERE metadata ? 'm0_storage_gb_before'; + +COMMIT; diff --git a/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-verify.sql b/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-verify.sql new file mode 100644 index 000000000..ec97d9047 --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-verify.sql @@ -0,0 +1,39 @@ +-- PROPOSAL. Read-only checks for 2026-10-29-dos-me-m0-seed-rekey.sql (DOS/Rate#1212). Run +-- before (as the pre-check) and after. `scripts/test-m0-seed-rekey-proposal.mjs` runs the +-- expect-0 ones on the replay stack. + +-- V1 No master still on a drifted seed key. expect 0 +SELECT count(*) AS drifted_keys +FROM rate.master_products +WHERE model_key IN ('samsung:galaxy-buds3-pro:64gb', 'apple:airpods-pro-3:standard', + 'sony:wf-1000xm5:64gb', 'sony:wf-1000xm6:standard', + 'apple:airpods-pro-2-usbc:64gb', 'apple:watch-ultra-3-49mm:64gb'); + +-- V2 Earbuds carry no storage size, in the key or the column, category or not. expect 0 +SELECT count(*) AS sized_earbuds +FROM rate.master_products mp LEFT JOIN rate.categories c ON c.id = mp.category_id +WHERE (c.slug = 'earbuds' OR mp.model_key IN ('samsung:galaxy-buds3-pro:unknown', 'apple:airpods-pro-3:unknown', + 'sony:wf-1000xm5:unknown', 'sony:wf-1000xm6:unknown', + 'apple:airpods-pro-2:unknown')) + AND (split_part(mp.model_key, ':', 3) <> 'unknown' OR mp.storage_gb IS NOT NULL); + +-- V3 No two masters share a model_key (also after a re-key). expect 0 +SELECT count(*) AS duplicate_keys +FROM (SELECT model_key FROM rate.master_products GROUP BY 1 HAVING count(*) > 1) d; + +-- V4 Every listing still points at an existing master. expect 0 +SELECT count(*) AS orphan_listings +FROM rate.products p +WHERE p.cluster_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM rate.master_products mp WHERE mp.id = p.cluster_id); + +-- V5 Pre-check (information): every key this step touches, old and new, with its row. +SELECT model_key, id, status, storage_gb, slug +FROM rate.master_products +WHERE model_key IN ('samsung:galaxy-buds3-pro:64gb', 'samsung:galaxy-buds3-pro:unknown', + 'apple:airpods-pro-3:standard', 'apple:airpods-pro-3:unknown', + 'sony:wf-1000xm5:64gb', 'sony:wf-1000xm5:unknown', + 'sony:wf-1000xm6:standard', 'sony:wf-1000xm6:unknown', + 'apple:airpods-pro-2-usbc:64gb', 'apple:airpods-pro-2:unknown', + 'apple:watch-ultra-3-49mm:64gb', 'apple:watch-ultra-3:64gb') +ORDER BY model_key; diff --git a/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.md b/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.md new file mode 100644 index 000000000..83463531f --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.md @@ -0,0 +1,56 @@ +# Proposal: DOS-Me data step M0, seed key re-key (DOS/Rate#1212, #1123) + +**Status:** proposal for JOY, **not applied**. A production data write: JOY approves and applies it through DOS-Me (preview, then beta, then prod), **before** the M1+M2 Item migration ([`2026-10-28-dos-me-m1-m2-items.md`](./2026-10-28-dos-me-m1-m2-items.md)). + +Inventory: the M0 comment on #1150 (2026-10-28, anon read of prod: 37 masters, 6 drifted keys, no duplicate, no slug collision). Design: [`2026-10-27-item-variant-listing-model.md`](./2026-10-27-item-variant-listing-model.md), section 7 "M0". + +## Files + +| File | What | +| :----------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`2026-10-29-dos-me-m0-seed-rekey.sql`](./2026-10-29-dos-me-m0-seed-rekey.sql) | Data only, one transaction, idempotent. | +| [`2026-10-29-dos-me-m0-seed-rekey-rollback.sql`](./2026-10-29-dos-me-m0-seed-rekey-rollback.sql) | Restores keys and storage from the recorded markers; re-runnable. Only before M1+M2 (or after rolling M1+M2 back). | +| [`2026-10-29-dos-me-m0-seed-rekey-verify.sql`](./2026-10-29-dos-me-m0-seed-rekey-verify.sql) | Read-only: V1 to V4 expect 0 after; V5 is the pre-check listing every touched key. | +| [`scripts/test-m0-seed-rekey-proposal.mjs`](../../scripts/test-m0-seed-rekey-proposal.mjs) | CI replay: the six drifted masters with listings, M0 twice, its checks, M1+M2 on top (all Item checks pass, Items under the normalizer keys), both rollbacks, and the skip when a target key is taken. | + +## What it changes + +| Stored `model_key` | New `model_key` | Status (inventory) | Listings | +| :------------------------------ | :--------------------------------- | :----------------- | -------: | +| `samsung:galaxy-buds3-pro:64gb` | `samsung:galaxy-buds3-pro:unknown` | published | 4 | +| `apple:airpods-pro-3:standard` | `apple:airpods-pro-3:unknown` | published | 9 | +| `sony:wf-1000xm5:64gb` | `sony:wf-1000xm5:unknown` | draft | 8 | +| `sony:wf-1000xm6:standard` | `sony:wf-1000xm6:unknown` | draft | 9 | +| `apple:airpods-pro-2-usbc:64gb` | `apple:airpods-pro-2:unknown` | draft | 4 | +| `apple:watch-ultra-3-49mm:64gb` | `apple:watch-ultra-3:64gb` | draft | 4 | + +- **Re-key in place.** Ids and slugs do not change, so listings, price alerts, reviews and public URLs keep pointing at the same rows; nothing is archived or merged. A rename whose target key is already taken is skipped with a NOTICE and shows in V1 (a reviewed merge, never an automatic one; the inventory found none). +- **Earbuds carry no storage:** `storage_gb = NULL` on every earbuds master (today 64, 64, 64, 1, 1 on the five rows above). +- **Markers for the rollback:** `metadata.m0_rekeyed_from` (old key) and `metadata.m0_storage_gb_before`. +- `apple:airpods-pro-2-usbc` becomes Item `apple:airpods-pro-2`: the normalizer does not split USB-C from Lightning (only USB-C is sold new today). + +## Order of operations + +1. Pre-check: run V5 (and V1 to V4) on beta and prod; compare with the table above. +2. Apply this file (beta, verify, then prod, verify). +3. **Right after**, merge the seed change below. Do not run `jobs/backfill-specs.ts --apply` between steps 2 and 3: it looks masters up by the seed key and inserts one when none is found, which would re-create the old keys as duplicates. +4. Then M1+M2 (`2026-10-28-dos-me-m1-m2-items.sql`). + +## Seed change that ships right after (not in this PR) + +`apps/importer/src/shopee/flagship-seed-data.ts`: + +| Seed | `model_key` | `storage_gb` | +| :----------------------- | :-------------------------------------------------------------------- | :----------- | +| AirPods Pro 2 USB-C | `apple:airpods-pro-2-usbc:64gb` -> `apple:airpods-pro-2:unknown` | 64 -> `null` | +| Sony WF-1000XM5 | `sony:wf-1000xm5:64gb` -> `sony:wf-1000xm5:unknown` | 64 -> `null` | +| Samsung Galaxy Buds3 Pro | `samsung:galaxy-buds3-pro:64gb` -> `samsung:galaxy-buds3-pro:unknown` | 64 -> `null` | +| Apple Watch Ultra 3 | `apple:watch-ultra-3-49mm:64gb` -> `apple:watch-ultra-3:64gb` | unchanged | +| AirPods Pro 3 USB-C | `apple:airpods-pro-3:standard` -> `apple:airpods-pro-3:unknown` | -> `null` | +| Sony WF-1000XM6 | `sony:wf-1000xm6:standard` -> `sony:wf-1000xm6:unknown` | -> `null` | + +(The `FlagshipDeviceSeed.storage_gb` type is `number | null`, so `null` needs no type change.) The Core session opens this as a separate PR once JOY schedules the data step; it must not merge before the data is re-keyed. + +## Rollback + +`2026-10-29-dos-me-m0-seed-rekey-rollback.sql`, with the seed change reverted in the same step. If M1+M2 is applied, roll it back first (its Items are keyed on the new keys). A key that a newly seeded master took meanwhile is skipped with a NOTICE. diff --git a/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.sql b/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.sql new file mode 100644 index 000000000..404e87d77 --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.sql @@ -0,0 +1,77 @@ +-- PROPOSAL, NOT APPLIED. DOS-Me data step M0 for the Item model (DOS/Rate#1212, #1123; +-- design Docs/Proposals/2026-10-27-item-variant-listing-model.md section 7 "M0"; inventory in +-- the #1150 comment of 2026-10-28). JOY reviews and applies it through DOS-Me (preview, beta, +-- prod) BEFORE 2026-10-28-dos-me-m1-m2-items.sql. Rollback: +-- 2026-10-29-dos-me-m0-seed-rekey-rollback.sql. Verification: +-- 2026-10-29-dos-me-m0-seed-rekey-verify.sql. +-- +-- What it does (data only, no DDL): +-- 1. Re-keys the six flagship-seed masters whose stored model_key the normalizer never +-- produces, so new listings hit Tier 1 and M2 creates their Items under the normalizer's +-- Item keys. Ids and slugs do not change, so listings, alerts, reviews and public URLs +-- keep pointing at the same rows. A rename whose target key is already taken by another +-- master is skipped with a NOTICE (that pair needs a reviewed merge, not a rename; the +-- inventory found none). Duplicates are never deleted. +-- 2. Clears storage_gb on earbuds masters (earbuds have no storage axis, #643/#657). +-- Each change records the previous value in metadata (m0_rekeyed_from, m0_storage_gb_before) +-- so the rollback can restore it. +-- +-- The matching seed change (flagship-seed-data.ts keys, earbuds storage_gb: null) must merge +-- right AFTER this is applied, and `jobs/backfill-specs.ts --apply` must not run in between: +-- it looks masters up by the seed key and inserts one when none is found. +-- +-- Idempotent: a master already on its new key is not touched; the storage step only touches +-- rows that still hold a storage. + +BEGIN; + +-- 1. Re-key ---------------------------------------------------------------------------------- + +DO $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT * FROM (VALUES + ('samsung:galaxy-buds3-pro:64gb', 'samsung:galaxy-buds3-pro:unknown'), + ('apple:airpods-pro-3:standard', 'apple:airpods-pro-3:unknown'), + ('sony:wf-1000xm5:64gb', 'sony:wf-1000xm5:unknown'), + ('sony:wf-1000xm6:standard', 'sony:wf-1000xm6:unknown'), + -- The normalizer does not split USB-C from Lightning: one Item, apple:airpods-pro-2. + ('apple:airpods-pro-2-usbc:64gb', 'apple:airpods-pro-2:unknown'), + -- The Watch Ultra comes in one case size, so its key carries none. + ('apple:watch-ultra-3-49mm:64gb', 'apple:watch-ultra-3:64gb') + ) AS t(old_key, new_key) + LOOP + IF NOT EXISTS (SELECT 1 FROM rate.master_products WHERE model_key = r.old_key) THEN + CONTINUE; + ELSIF EXISTS (SELECT 1 FROM rate.master_products WHERE model_key = r.new_key) THEN + RAISE NOTICE 'M0 skip %: % already exists, merge manually', r.old_key, r.new_key; + ELSE + UPDATE rate.master_products + SET model_key = r.new_key, + metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('m0_rekeyed_from', r.old_key), + updated_at = now() + WHERE model_key = r.old_key; + END IF; + END LOOP; +END +$$; + +-- 2. Earbuds carry no storage ---------------------------------------------------------------- + +-- By category, and by the re-keyed earbuds keys for a master whose category_id is NULL +-- (backfill-specs can insert one without a category). +UPDATE rate.master_products mp + SET metadata = COALESCE(mp.metadata, '{}'::jsonb) + || jsonb_build_object('m0_storage_gb_before', mp.storage_gb), + storage_gb = NULL, + updated_at = now() + WHERE mp.storage_gb IS NOT NULL + AND (mp.model_key IN ('samsung:galaxy-buds3-pro:unknown', 'apple:airpods-pro-3:unknown', + 'sony:wf-1000xm5:unknown', 'sony:wf-1000xm6:unknown', + 'apple:airpods-pro-2:unknown') + OR EXISTS (SELECT 1 FROM rate.categories c + WHERE c.id = mp.category_id AND c.slug = 'earbuds')); + +COMMIT; diff --git a/scripts/test-item-migration-proposal.mjs b/scripts/test-item-migration-proposal.mjs index f87f75663..77fc4dfd4 100644 --- a/scripts/test-item-migration-proposal.mjs +++ b/scripts/test-item-migration-proposal.mjs @@ -174,6 +174,30 @@ try { ), 'Windows 11' ); + // The engine's write paths when Rate Services runs it as the worker: syncArenaSpecsToItem + // (#1213) updates specs guarded by updated_at and reads the id back; ingest and the + // matcher read item_id from listings and variants. + const itemStamp = sql( + `SELECT updated_at FROM rate.items WHERE item_key = 'asus:zenbook-s-14'` + ); + assert.equal( + asRole( + 'rate_worker', + `UPDATE rate.items SET specifications = specifications || '{"platform": {"os": "Windows 11"}}', + key_specs = key_specs, updated_at = now() + WHERE id = (SELECT id FROM rate.items WHERE item_key = 'asus:zenbook-s-14') + AND updated_at = '${itemStamp}' RETURNING item_key` + ), + 'asus:zenbook-s-14' + ); + assert.equal( + asRole( + 'rate_worker', + `SELECT (count(*) > 0)::text FROM rate.products p + JOIN rate.master_products mp ON mp.id = p.cluster_id AND mp.item_id = p.item_id` + ), + 'true' + ); for (const change of [ `status = 'archived'`, `item_key = 'x:y'`, diff --git a/scripts/test-m0-seed-rekey-proposal.mjs b/scripts/test-m0-seed-rekey-proposal.mjs new file mode 100644 index 000000000..e0db4e50e --- /dev/null +++ b/scripts/test-m0-seed-rekey-proposal.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +// Replays the DOS-Me M0 seed re-key PROPOSAL (DOS/Rate#1212) on the migration replay stack: +// every reference migration, the six drifted seed masters as the M0 inventory found them +// (with listings), the proposal twice, its verification, then the M1+M2 Item proposal on top +// (the intended order), both rollbacks, and the skip when a target key is already taken. +// The proposal is not a migration: nothing here touches a real database. +// +// Usage: node scripts/test-m0-seed-rekey-proposal.mjs (needs Docker) + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { repoRoot, startRateDb } from './lib/rate-db.mjs'; + +const read = (name) => + readFileSync(path.join(repoRoot, 'Docs', 'Proposals', name), 'utf8'); +const m0 = read('2026-10-29-dos-me-m0-seed-rekey.sql'); +const m0Rollback = read('2026-10-29-dos-me-m0-seed-rekey-rollback.sql'); +const m0Verify = read('2026-10-29-dos-me-m0-seed-rekey-verify.sql'); +const items = read('2026-10-28-dos-me-m1-m2-items.sql'); +const itemsRollback = read('2026-10-28-dos-me-m1-m2-items-rollback.sql'); +const itemsVerify = read('2026-10-28-dos-me-m1-m2-items-verify.sql'); + +const container = `rate-m0-proposal-${process.pid}`; +const removeContainer = () => + spawnSync('docker', ['rm', '--force', container], { timeout: 30_000 }); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + removeContainer(); + process.exit(130); + }); +} + +const expectZero = (file) => + [ + ...file.matchAll( + /^-- (V\d+) [^\n]*expect 0\n(?:--[^\n]*\n)*(SELECT[\s\S]*?;)/gm + ), + ].map(([, id, query]) => ({ id, query })); +const m0Checks = expectZero(m0Verify); +const itemChecks = expectZero(itemsVerify); +assert.equal( + m0Checks.length, + 4, + 'the M0 verification file lists its expect-0 queries' +); + +// The six drifted seed masters (M0 inventory, #1150 comment) and one earbuds line already on +// its normalizer key but still holding a storage value. +const masters = [ + [ + 'samsung:galaxy-buds3-pro:64gb', + 'samsung-galaxy-buds3-pro', + 'earbuds', + 64, + 'published', + ], + [ + 'apple:airpods-pro-3:standard', + 'apple-airpods-pro-3-usb-c', + 'earbuds', + 1, + 'published', + ], + ['sony:wf-1000xm5:64gb', 'sony-wf-1000xm5', 'earbuds', 64, 'draft'], + ['sony:wf-1000xm6:standard', 'sony-wf-1000xm6', 'earbuds', 1, 'draft'], + // No category: backfill-specs can insert a master without one. + [ + 'apple:airpods-pro-2-usbc:64gb', + 'apple-airpods-pro-2-usb-c', + null, + 64, + 'draft', + ], + [ + 'apple:watch-ultra-3-49mm:64gb', + 'apple-watch-ultra-3-49mm-64gb', + 'smartwatches', + 64, + 'draft', + ], + ['jbl:tune-beam:unknown', 'jbl-tune-beam', 'earbuds', 64, 'draft'], +]; +const id = (i) => `a0000000-0000-4000-8000-00000000000${i + 1}`; +const listing = (i) => `b0000000-0000-4000-8000-00000000000${i + 1}`; + +try { + const { sql } = startRateDb({ name: container }); + const checkAll = (checks, when) => { + for (const { id: v, query } of checks) + assert.equal(sql(query), '0', `${v} after ${when}`); + }; + + sql(` + INSERT INTO rate.categories (id, name, slug) VALUES + ('c0000000-0000-4000-8000-000000000001', 'Tai nghe', 'earbuds'), + ('c0000000-0000-4000-8000-000000000002', 'Đồng hồ', 'smartwatches'); + INSERT INTO rate.merchants (id, platform, platform_shop_id, name, slug) + VALUES ('e0000000-0000-4000-8000-000000000001', 'shopee', 's1', 'Shop 1', 'shop-1'); + ${masters + .map( + ([key, slug, cat, storage, status], i) => ` + INSERT INTO rate.master_products (id, brand, model, model_key, name, slug, category_id, storage_gb, status) + SELECT '${id(i)}', split_part('${key}', ':', 1), split_part('${key}', ':', 2), '${key}', '${slug}', '${slug}', + c.id, ${storage}, '${status}' + FROM (SELECT 1) one LEFT JOIN rate.categories c ON c.slug = '${cat ?? ''}'; + INSERT INTO rate.products (id, merchant_id, platform, platform_item_id, title, price_current, url, cluster_id) + VALUES ('${listing(i)}', 'e0000000-0000-4000-8000-000000000001', 'shopee', 'l${i}', '${slug}', 5000000, + 'https://shopee.vn/l${i}', '${id(i)}');` + ) + .join('\n')} + `); + assert.equal( + sql(m0Checks[0].query), + '6', + 'V1 sees the six drifted keys before M0' + ); + + sql(m0); + sql(m0); + checkAll(m0Checks, 'M0'); + // Ids, slugs and listings stay; the old key and storage are recorded for the rollback. + assert.equal( + sql(`SELECT model_key || '|' || slug || '|' || coalesce(storage_gb::text, '-') || '|' || (metadata ->> 'm0_rekeyed_from') + FROM rate.master_products WHERE id = '${id(4)}'`), + 'apple:airpods-pro-2:unknown|apple-airpods-pro-2-usb-c|-|apple:airpods-pro-2-usbc:64gb' + ); + assert.equal( + sql( + `SELECT model_key || '|' || storage_gb FROM rate.master_products WHERE id = '${id(5)}'` + ), + 'apple:watch-ultra-3:64gb|64', + 'a watch keeps its storage' + ); + assert.equal( + sql( + `SELECT count(*) FROM rate.products p JOIN rate.master_products mp ON mp.id = p.cluster_id` + ), + String(masters.length) + ); + + // The intended order: M1+M2 after M0 creates the Items under the normalizer's Item keys. + sql(items); + checkAll(itemChecks, 'M0 then M1+M2'); + assert.equal( + sql(`SELECT string_agg(item_key, ',' ORDER BY item_key) FROM rate.items`), + [ + 'apple:airpods-pro-2', + 'apple:airpods-pro-3', + 'apple:watch-ultra-3', + 'jbl:tune-beam', + 'samsung:galaxy-buds3-pro', + 'sony:wf-1000xm5', + 'sony:wf-1000xm6', + ].join(',') + ); + + // Rollback order: M1+M2 first, then M0 (twice). + sql(itemsRollback); + sql(m0Rollback); + sql(m0Rollback); + assert.equal( + sql( + `SELECT string_agg(model_key || '=' || coalesce(storage_gb::text, '-'), ',' ORDER BY id) FROM rate.master_products` + ), + masters.map(([key, , , storage]) => `${key}=${storage}`).join(',') + ); + assert.equal( + sql( + `SELECT count(*) FROM rate.master_products WHERE metadata ? 'm0_rekeyed_from' OR metadata ? 'm0_storage_gb_before'` + ), + '0' + ); + + // A target key already taken by another master: that rename is skipped, never merged. + sql(`INSERT INTO rate.master_products (brand, model, model_key, name, slug, status) + VALUES ('apple', 'watch-ultra-3', 'apple:watch-ultra-3:64gb', 'Apple Watch Ultra 3', 'apple-watch-ultra-3', 'draft')`); + sql(m0); + assert.equal( + sql(`SELECT model_key FROM rate.master_products WHERE id = '${id(5)}'`), + 'apple:watch-ultra-3-49mm:64gb' + ); + assert.equal( + sql(m0Checks[0].query), + '1', + 'V1 reports the pair left for a manual merge' + ); + + console.log( + `M0 seed re-key proposal passed: applied twice, ${m0Checks.length} checks, M1+M2 on top, both rollbacks, taken-key skip` + ); +} finally { + removeContainer(); +} From 0fd5d96050612babf54fe2d55534f3aec01f10aa Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:13:35 +0700 Subject: [PATCH 41/69] feat(web): directory, facets, leaderboard, stats and sitemap per item (#1276) * feat(web): directory, facets, leaderboard, stats and sitemap per item Task 9 of the Item / variant / listing model, behind RATE_ITEM_MODEL: category pages and the home grid show one card per Item (the best-ranked variant's card under the Item name, a "from" price over its variants and size chips linking to ?v=), pages are cut after grouping, brand facets and "products tracked" count Items, the leaderboard ranks one slot per Item, and the sitemap lists Item URLs dated by their newest variant. With the flag off, or with the Item tables missing, every surface reads and renders as before. * fix(web): item catalogue reads every variant and keeps compare keys Review of the previous commit: the catalogue page reads matching variants in batches until one comes back short, so no Item falls past the 500-row read; price and merchant sorts follow the numbers the Item card shows; an Item card stands for its cheapest size, so compare and the watchlist save the size whose price is shown; hero suggestions keep variant slugs; API v1 leaderboard and product facets stay per variant until group=item; any failed Item read shows the variant pages instead of failing; the category JSON-LD lists Item URLs; size chips name their product. Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 4 +- apps/web/locales/en.json | 4 +- apps/web/locales/vi.json | 4 +- apps/web/package.json | 2 +- .../web/src/app/[locale]/leaderboard/page.tsx | 4 +- apps/web/src/app/[locale]/page.tsx | 27 +- .../web/src/app/[locale]/smartphones/page.tsx | 34 ++- .../web/src/app/api/v1/rate/products/route.ts | 6 +- .../src/components/product/ProductCard.tsx | 29 +- .../src/lib/__tests__/item-directory.test.ts | 204 ++++++++++++++ apps/web/src/lib/item-directory.ts | 234 ++++++++++++++++ apps/web/src/lib/leaderboard-engine.ts | 7 +- apps/web/src/lib/rate-api.ts | 229 +++++++++++++++ .../rate/__tests__/item-directory.test.ts | 264 ++++++++++++++++++ apps/web/src/server/rate/index.ts | 58 +++- apps/web/src/server/rate/item-directory.ts | 154 ++++++++++ 16 files changed, 1234 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/lib/__tests__/item-directory.test.ts create mode 100644 apps/web/src/lib/item-directory.ts create mode 100644 apps/web/src/server/rate/__tests__/item-directory.test.ts create mode 100644 apps/web/src/server/rate/item-directory.ts diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 3a881fcd2..6d62e7565 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1868,7 +1868,9 @@ "priceHistory": "Historie cen", "priceHistoryTitle": "Ověřit historii cen a odhalit falešné slevy", "noImage": "Bez obrázku", - "verified": "Normalizované parametry" + "verified": "Normalizované parametry", + "fromPrice": "Cena od", + "sizesLabel": "Varianty úložiště" }, "priceVerdict": { "labelFake": "Varování: falešná sleva", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 56935b8ad..37bba27c5 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1868,7 +1868,9 @@ "priceHistory": "Price history", "priceHistoryTitle": "Check the price history and spot fake discounts", "noImage": "No image", - "verified": "Specs normalized" + "verified": "Specs normalized", + "fromPrice": "From", + "sizesLabel": "Sizes" }, "priceVerdict": { "labelFake": "Fake discount alert", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 4dd21ccc1..4da82fd82 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -1868,7 +1868,9 @@ "priceHistory": "Lịch sử giá", "priceHistoryTitle": "Xem lịch sử giá và phát hiện giảm giá ảo", "noImage": "Chưa có ảnh", - "verified": "Đã chuẩn hóa thông số" + "verified": "Đã chuẩn hóa thông số", + "fromPrice": "Giá từ", + "sizesLabel": "Các dung lượng" }, "priceVerdict": { "labelFake": "Cảnh báo giảm giá ảo", diff --git a/apps/web/package.json b/apps/web/package.json index 0fd88bc87..243b0a433 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/leaderboard/page.tsx b/apps/web/src/app/[locale]/leaderboard/page.tsx index f038fea75..901bfefed 100644 --- a/apps/web/src/app/[locale]/leaderboard/page.tsx +++ b/apps/web/src/app/[locale]/leaderboard/page.tsx @@ -10,6 +10,7 @@ import { Link } from '@/lib/navigation'; import { getCachedFilteredReviewCountsByMaster, getCachedMasterProducts, + groupCatalogRowsByItem, getCrossPlatformDealRadar, } from '@/server/rate'; import { @@ -92,7 +93,8 @@ export default async function LeaderboardPage({ params }: PageProps) { categorySlug, limit: 100, sort: 'rating_desc', - }) + // RATE_ITEM_MODEL on: one slot per Item (DOS/Rate#1155). + }).then(groupCatalogRowsByItem) ) ), // No fallback: the page is cached, so a null radar would be served for diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index 74b5097e5..d2ef1ca6a 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -12,10 +12,13 @@ import { getCachedMerchantsList, getCachedRatePlatformStats, getCrossPlatformDealRadar, + groupCatalogRowsByItem, type CrossPlatformDealRadarResponse, type MerchantSummary, type RatePlatformStats, } from '@/server/rate'; +import { catalogRowSlug, itemSizeChips } from '@/lib/item-directory'; +import { formatStorageLabel } from '@/lib/category-filters'; import type { MasterProduct } from '@repo/shared-data'; import { BsArrowLeftRight, @@ -168,6 +171,10 @@ export default async function Home({ catSlugById.set(c.id, c.slug); } + // RATE_ITEM_MODEL on: one grid card per Item (DOS/Rate#1155). + const gridProducts = await groupCatalogRowsByItem(masterProducts).catch( + () => masterProducts + ); const quickCompareSlugs = masterProducts .filter((p) => getMasterProductHref(p, catSlugById).startsWith('/smartphones/') @@ -253,7 +260,11 @@ export default async function Home({
- {masterProducts.slice(0, 6).map((storedItem) => { + {gridProducts.slice(0, 6).map((storedItem) => { // An earbud card carries no RAM, storage or screen chip // (DOS/Rate#1142). const item = applyCategorySpecTemplate( @@ -483,7 +494,10 @@ export default async function Home({ ); const thumb = productImageUrl(item); const keySpecs = item.key_specs || {}; - const productHref = getMasterProductHref(item, catSlugById); + const productHref = getMasterProductHref( + { ...item, slug: catalogRowSlug(item) }, + catSlugById + ); const rawUser = Number( item.user_rating_weighted || item.average_rating || 0 ); @@ -519,8 +533,13 @@ export default async function Home({ + isPlainListing(params) + ? getCachedMasterProducts(params) + : getMasterProducts(params); const [facets, pageRows, categoryDealRadar] = await Promise.all([ getCachedCategoryDirectoryFacets(categorySlug), - // The plain listing (only the bounded page and a known sort) is most of - // the traffic and shares the data cache. Filtered reads stay uncached: - // free-form values (search, prices, brands) would each create an entry - // (DOS/Rate#495). - isPlainListing(filterParams) - ? getCachedMasterProducts(filterParams) - : getMasterProducts(filterParams), + // RATE_ITEM_MODEL on: one card per Item, so the page is cut after + // grouping every matching variant (DOS/Rate#1155); off: as before. + getItemCatalogPage( + (offset, limit) => readVariants({ ...filterParams, limit, offset }), + filterParams.offset ?? 0, + filterParams.limit ?? CATALOG_PAGE_SIZE + 1, + filterParams.sort + ).then((itemRows) => itemRows ?? readVariants(filterParams)), getCrossPlatformDealRadar({ categorySlug: categorySlug as CanonicalCategorySlug, // Max page size: card badges and exports need a signal for EVERY product, @@ -375,7 +386,7 @@ export async function CategoryCatalogContent({ '@type': 'ListItem', position: (page - 1) * CATALOG_PAGE_SIZE + index + 1, name: item.name, - url: absoluteUrl(`${basePath}/${item.slug}`), + url: absoluteUrl(`${basePath}/${catalogRowSlug(item)}`), })), }, ], @@ -849,7 +860,12 @@ export async function CategoryCatalogContent({
)} + {sizeChips.length > 0 && ( +
    + {sizeChips.map((chip) => ( +
  • + + {chip.label} + +
  • + ))} +
+ )} +
{saleState === 'on_sale' && safeMinPrice > 0 ? ( <> -
{t('refPrice')}
+
+ {sizeChips.length > 0 ? t('fromPrice') : t('refPrice')} +
{formatProductCardVnd(safeMinPrice)}
diff --git a/apps/web/src/lib/__tests__/item-directory.test.ts b/apps/web/src/lib/__tests__/item-directory.test.ts new file mode 100644 index 000000000..a09f542ed --- /dev/null +++ b/apps/web/src/lib/__tests__/item-directory.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import type { MasterProduct } from '@repo/shared-data'; +import { + catalogRowSlug, + countBrandsByItem, + groupRowsByItem, + itemSizeChips, + sortItemRows, + sitemapRowsByItem, + type ItemLinkInput, +} from '../item-directory'; + +// One card, one leaderboard slot, one brand count and one sitemap URL per +// Item (DOS/Rate#1155). + +const row = ( + id: string, + slug: string, + minPrice: number, + maxPrice = minPrice, + extra: Partial = {} +): MasterProduct => + ({ + id, + slug, + name: slug, + brand: 'Apple', + min_price: minPrice, + max_price: maxPrice, + merchant_count: 1, + ...extra, + }) as MasterProduct; + +function main() { + console.warn('🧪 item directory suite (DOS/Rate#1155)'); + + const iphone: ItemLinkInput = { + item: { + id: 'item-15', + slug: 'apple-iphone-15', + name: 'Apple iPhone 15', + merchant_count: 7, + }, + variants: [ + { variant_key: '128gb', storage_gb: 128, min_price: 18_000_000 }, + { variant_key: '256gb', storage_gb: 256, min_price: 21_000_000 }, + ], + }; + const links = new Map([ + ['m-128', iphone], + ['m-256', iphone], + ]); + + // Ranked order kept: the Item sits where its best variant ranked. + const grouped = groupRowsByItem( + [ + row('m-256', 'apple-iphone-15-256gb', 21_000_000, 23_000_000), + row('m-other', 'samsung-galaxy-s24', 19_000_000), + row('m-128', 'apple-iphone-15-128gb', 18_000_000, 19_500_000), + ], + links + ); + assert.deepEqual( + grouped.map((r) => r.id), + ['m-128', 'm-other'], + 'one row per Item, where its best-ranked variant was, standing for its cheapest size' + ); + const card = grouped[0]!; + assert.equal(card.name, 'Apple iPhone 15'); + assert.equal( + card.slug, + 'apple-iphone-15-128gb', + 'compare and watch keys are the cheapest variant, whose price the card shows' + ); + assert.equal(card.item_slug, 'apple-iphone-15'); + assert.equal(card.min_price, 18_000_000, 'from price over its variants'); + assert.equal(card.max_price, 23_000_000); + assert.equal(card.merchant_count, 7, 'Item merchants, all listings'); + assert.equal(catalogRowSlug(card), 'apple-iphone-15'); + assert.equal(catalogRowSlug(grouped[1]!), 'samsung-galaxy-s24'); + assert.equal(grouped[1]!.item_slug, undefined, 'no Item: row unchanged'); + + // A variant with no price does not zero the Item's "from" price. + const unpriced = groupRowsByItem( + [row('m-128', 'a', 0), row('m-256', 'b', 21_000_000)], + links + ); + assert.equal(unpriced[0]!.min_price, 21_000_000); + + // No links (flag off or unlinked masters): rows as they were. + const plain = [row('x', 'x', 1), row('y', 'y', 2)]; + assert.deepEqual(groupRowsByItem(plain, new Map()), plain); + + // Size chips: sized variants only, and only when there is a choice. + assert.deepEqual( + itemSizeChips(card, '/smartphones/apple-iphone-15', (gb) => `${gb}GB`), + [ + { label: '128GB', href: '/smartphones/apple-iphone-15?v=128gb' }, + { label: '256GB', href: '/smartphones/apple-iphone-15?v=256gb' }, + ] + ); + assert.deepEqual( + itemSizeChips( + { ...card, item_variants: [card.item_variants![0]!] }, + '/x', + String + ), + [] + ); + assert.deepEqual( + itemSizeChips( + { + ...card, + item_variants: [ + { variant_key: 'unknown', storage_gb: null, min_price: 0 }, + { variant_key: 'standard', storage_gb: null, min_price: 0 }, + ], + }, + '/x', + String + ), + [], + 'earbuds and other sizeless Items get no chips' + ); + assert.deepEqual(itemSizeChips(grouped[1]!, '/x', String), []); + + // Brand facets: one count per Item, not per variant. + assert.deepEqual( + countBrandsByItem([ + { brand: 'apple', key: 'item:15' }, + { brand: 'Apple', key: 'item:15' }, + { brand: 'Apple', key: 'item:16' }, + { brand: 'Samsung', key: 'master:s24' }, + { brand: ' ', key: 'master:blank' }, + ]), + [ + { name: 'Apple', count: 2 }, + { name: 'Samsung', count: 1 }, + ] + ); + + // Sitemap: one Item URL, dated by its newest variant. + const slugs = new Map([ + ['apple-iphone-15-128gb', 'apple-iphone-15'], + ['apple-iphone-15-256gb', 'apple-iphone-15'], + ]); + assert.deepEqual( + sitemapRowsByItem( + [ + { slug: 'apple-iphone-15-128gb', updated_at: '2026-10-01T00:00:00Z' }, + { slug: 'galaxy-s24', updated_at: '2026-10-02T00:00:00Z' }, + { slug: 'apple-iphone-15-256gb', updated_at: '2026-10-05T00:00:00Z' }, + ], + slugs + ), + [ + { slug: 'apple-iphone-15', updated_at: '2026-10-05T00:00:00Z' }, + { slug: 'galaxy-s24', updated_at: '2026-10-02T00:00:00Z' }, + ] + ); + + // Price sorts follow the Item's "from" price, not its first variant: a + // descending read meets an Item's dearest size first (Codex review). + const desc = groupRowsByItem( + [ + row('m-256', 'a-256', 30_000_000), + row('m-other', 'b', 20_000_000), + row('m-128', 'a-128', 5_000_000), + ], + links + ); + assert.deepEqual( + sortItemRows(desc, 'price_desc').map((r) => r.id), + ['m-other', 'm-128'] + ); + assert.deepEqual( + sortItemRows( + [ + { ...row('few', 'few', 1), merchant_count: 2 }, + { ...card, merchant_count: 7 }, + ], + 'merchants_desc' + ).map((r) => r.id), + ['m-128', 'few'], + 'merchant sort by the count the card shows' + ); + assert.deepEqual( + sortItemRows( + [row('z', 'z', 0), row('y', 'y', 2), row('x', 'x', 1)], + 'price_asc' + ).map((r) => r.id), + ['x', 'y', 'z'], + 'unpriced rows last' + ); + assert.deepEqual( + sortItemRows(desc, 'rating_desc').map((r) => r.id), + desc.map((r) => r.id), + 'other sorts keep the read order' + ); + + console.warn(' ✅ item directory suite passed'); +} + +main(); diff --git a/apps/web/src/lib/item-directory.ts b/apps/web/src/lib/item-directory.ts new file mode 100644 index 000000000..e78b94062 --- /dev/null +++ b/apps/web/src/lib/item-directory.ts @@ -0,0 +1,234 @@ +import type { MasterProduct } from '@repo/shared-data'; + +// Directory, leaderboard, facets and sitemap per Item (DOS/Rate#1155; design +// doc `Docs/Proposals/2026-10-27-item-variant-listing-model.md`, 5.1): one +// card, one leaderboard slot and one sitemap URL per Item, built from the +// variant rows the catalogue reads already return. Pure: the server layer +// reads the links and applies these only while `RATE_ITEM_MODEL` is on. + +/** One size of an Item, as a chip on its card. */ +export interface ItemVariantChip { + /** The literal key segment: 256gb, 1024gb, standard, unknown. */ + variant_key: string; + storage_gb: number | null; + min_price: number; +} + +/** + * A catalogue row standing for a whole Item: the best-ranked of its variants + * in this read, with the Item's name and a price range over its matching + * variants. `slug` stays the variant's (deal radar and compare are keyed by + * variant); links use `item_slug`. + */ +export type ItemCatalogRow = MasterProduct & { + item_slug?: string; + /** The Item's published sizes, in order; absent on a row with no Item. */ + item_variants?: ItemVariantChip[]; +}; + +/** The Item fields grouping needs (a subset of `MasterItemLink`). */ +export interface ItemLinkInput { + item: { id: string; slug: string; name: string; merchant_count: number }; + variants: readonly { + variant_key: string; + storage_gb: number | null; + min_price: number; + }[]; +} + +const positivePrice = (raw: unknown): number => { + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : 0; +}; + +/** + * One row per Item, in the order of each Item's first (best-ranked) variant; + * a row with no Item stays as it is. The price range covers the variants + * this read returned, so a `?storage=` filter shows the matching sizes' + * prices (the filter means "has a variant of that size"). + */ +export function groupRowsByItem( + rows: readonly T[], + links: ReadonlyMap +): Array { + const out: Array = []; + const byItem = new Map(); + for (const row of rows) { + const link = row?.id ? links.get(row.id) : undefined; + if (!link) { + out.push(row as T & ItemCatalogRow); + continue; + } + const min = positivePrice(row.min_price); + const max = Math.max(positivePrice(row.max_price), min); + const at = byItem.get(link.item.id); + if (at === undefined) { + byItem.set(link.item.id, out.length); + out.push({ + ...row, + name: link.item.name || row.name, + min_price: min, + max_price: max, + merchant_count: Math.max( + Number(link.item.merchant_count) || 0, + Number(row.merchant_count) || 0 + ), + item_slug: link.item.slug, + item_variants: link.variants.map((v) => ({ + variant_key: v.variant_key, + storage_gb: v.storage_gb, + min_price: positivePrice(v.min_price), + })), + }); + continue; + } + const card = out[at]!; + const cardMin = positivePrice(card.min_price); + const cardMax = Math.max(positivePrice(card.max_price), max); + if (min > 0 && (cardMin === 0 || min < cardMin)) { + // The cheapest size stands for the Item: its slug is what compare and + // the watchlist save, so the card's "from" price is that size's price. + out[at] = { + ...row, + name: card.name, + min_price: min, + max_price: cardMax, + merchant_count: card.merchant_count, + item_slug: card.item_slug, + item_variants: card.item_variants, + }; + } else { + card.max_price = cardMax; + } + } + return out; +} + +/** The link target of a catalogue row: its Item when it has one. */ +export function catalogRowSlug(row: { + slug: string; + item_slug?: string; +}): string { + return row.item_slug || row.slug; +} + +/** Brand facets with one count per grouping key (an Item, or a lone master). */ +export function countBrandsByItem( + rows: readonly { brand: string; key: string }[] +): { name: string; count: number }[] { + const byLower = new Map }>(); + for (const r of rows) { + const brand = typeof r.brand === 'string' ? r.brand.trim() : ''; + if (!brand || !r.key) continue; + const lower = brand.toLowerCase(); + const entry = byLower.get(lower); + if (entry) { + entry.keys.add(r.key); + // Prefer a cased spelling ("Apple") over an all-lowercase one. + if (entry.name === entry.name.toLowerCase() && brand !== lower) { + entry.name = brand; + } + } else { + byLower.set(lower, { name: brand, keys: new Set([r.key]) }); + } + } + return Array.from(byLower.values()) + .map((e) => ({ name: e.name, count: e.keys.size })) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); +} + +/** + * Sitemap product entries per Item: one URL per Item (its slug) with the + * newest `updated_at` over its variants; a master with no Item keeps its own + * URL. `itemSlugByVariantSlug` maps a variant slug to its Item's slug. + */ +export function sitemapRowsByItem< + R extends { slug: string; updated_at: string | null }, +>(rows: readonly R[], itemSlugByVariantSlug: ReadonlyMap): R[] { + const out: R[] = []; + const bySlug = new Map(); + for (const row of rows) { + const itemSlug = itemSlugByVariantSlug.get(row.slug); + if (!itemSlug) { + out.push(row); + continue; + } + const at = bySlug.get(itemSlug); + if (at === undefined) { + bySlug.set(itemSlug, out.length); + out.push({ ...row, slug: itemSlug }); + continue; + } + const kept = out[at]!; + const keptTime = Date.parse(kept.updated_at ?? '') || 0; + const rowTime = Date.parse(row.updated_at ?? '') || 0; + if (rowTime > keptTime) out[at] = { ...kept, updated_at: row.updated_at }; + } + return out; +} + +/** A size chip on an Item card: its label and the Item URL preselecting it. */ +export interface ItemSizeChip { + label: string; + href: string; +} + +/** + * The size chips of an Item card: its sized variants, each linking to the + * Item URL with `?v=` (design doc 5.1). None for a row with no Item or with + * fewer than two sizes: a single size is no choice. + */ +export function itemSizeChips( + row: ItemCatalogRow, + itemHref: string, + formatStorage: (gb: number) => string +): ItemSizeChip[] { + const sized = (row.item_variants ?? []).filter( + (v) => typeof v.storage_gb === 'number' && v.storage_gb > 0 + ); + if (sized.length < 2) return []; + return sized.map((v) => ({ + label: formatStorage(v.storage_gb as number), + href: `${itemHref}?v=${encodeURIComponent(v.variant_key)}`, + })); +} + +/** + * Item rows in the order a price or merchant sort promises, by the numbers + * the card shows: grouping keeps each Item where its first variant ranked, + * which for a descending price sort is its dearest size (DOS/Rate#1155). + * Stable, unpriced rows last; any other sort keeps the read's order. + */ +export function sortItemRows( + rows: readonly T[], + sort: string | undefined +): T[] { + if (sort === 'merchants_desc') { + // By the merchant count the card shows (the Item's, all listings). + return rows + .map((row, index) => ({ row, index })) + .sort( + (a, b) => + positivePrice( + (b.row as { merchant_count?: unknown }).merchant_count + ) - + positivePrice( + (a.row as { merchant_count?: unknown }).merchant_count + ) || a.index - b.index + ) + .map((e) => e.row); + } + if (sort !== 'price_asc' && sort !== 'price_desc') return [...rows]; + const dir = sort === 'price_asc' ? 1 : -1; + return rows + .map((row, index) => ({ row, index, price: positivePrice(row.min_price) })) + .sort((a, b) => { + if (a.price === 0 || b.price === 0) { + return ( + (a.price === 0 ? 1 : 0) - (b.price === 0 ? 1 : 0) || a.index - b.index + ); + } + return (a.price - b.price) * dir || a.index - b.index; + }) + .map((e) => e.row); +} diff --git a/apps/web/src/lib/leaderboard-engine.ts b/apps/web/src/lib/leaderboard-engine.ts index 58c0c4492..7dbb7c455 100644 --- a/apps/web/src/lib/leaderboard-engine.ts +++ b/apps/web/src/lib/leaderboard-engine.ts @@ -4,6 +4,7 @@ import { getMasterProductHref, resolveProductImageUrl, } from './rate-domain-core'; +import { catalogRowSlug, type ItemCatalogRow } from './item-directory'; import type { CanonicalCategorySlug, MasterProduct } from './rate-api'; import type { KeySpecs, ProductSpecifications } from '@repo/shared-data'; @@ -399,7 +400,11 @@ export function scoreMasterProductForLeaderboard( const safeCriterion = normalizeLeaderboardCriterion(criterion); const categorySlug = getMasterProductCategorySlug(safeItem); const categoryPath = getCategoryRouteHref(categorySlug); - const productHref = getMasterProductHref(safeItem); + // An Item's row links to the Item (DOS/Rate#1155). + const productHref = getMasterProductHref({ + ...safeItem, + slug: catalogRowSlug(safeItem as ItemCatalogRow), + }); const thumbnail = resolveProductImageUrl({ slug: safeItem.slug, diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 525fcc09c..96f717278 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -3,6 +3,7 @@ import type { Database, MasterProduct } from '@repo/shared-data'; import { createLogger } from '@/lib/logger'; import { isPendingReview } from '@/lib/review-display'; import { listingReviewSummary } from '@/lib/analyzer-copy'; +import { countBrandsByItem } from '@/lib/item-directory'; import { implausiblyLowOfferIds, plausibleOffers, @@ -4830,3 +4831,231 @@ export async function resolveCatalogSlug( const retired = (data as { status?: unknown }).status === 'archived'; return { kind: 'variant', item: owner, variant, retired }; } + +/** A catalogue master's Item, with every published variant of that Item. */ +export interface MasterItemLink { + item: RateItem; + variants: RateItemVariant[]; +} + +/** + * The published Item of each master, by master id, with the Item's + * published variants (DOS/Rate#1155). A master with no Item yet, or whose + * Item is not published, is absent: it stays its own catalogue entry. + * Needs M1+M2 (#1150); a missing table or column throws + * ItemModelUnavailableError. + */ +export async function getItemLinksForMasters( + masterIds: readonly string[] +): Promise> { + const ids = Array.from( + new Set(masterIds.map((id) => sanitizeRateEntityId(id)).filter(Boolean)) + ); + const links = new Map(); + if (ids.length === 0) return links; + const db = untypedRate(); + const read = async ( + what: string, + keys: readonly string[], + query: (chunk: string[]) => UntypedRateQuery + ): Promise => + readInChunks( + keys, + what, + (chunk) => + query(chunk).then(throwIfItemSchemaMissing(what)) as PromiseLike<{ + data: unknown[] | null; + error: { message?: string } | null; + }> + ); + + const masterRows = (await read('item links', ids, (chunk) => + db.from('master_products').select('id, item_id').in('id', chunk) + )) as Array<{ id?: unknown; item_id?: unknown }>; + const itemIdByMaster = new Map(); + for (const row of masterRows) { + const id = sanitizeRateEntityId(row.id); + const itemId = sanitizeRateEntityId(row.item_id); + if (id && itemId) itemIdByMaster.set(id, itemId); + } + const itemIds = Array.from(new Set(itemIdByMaster.values())); + if (itemIds.length === 0) return links; + + const [itemRows, variantRows] = await Promise.all([ + read('items', itemIds, (chunk) => + db + .from('items') + .select(ITEM_SELECT) + .in('id', chunk) + .eq('status', 'published') + ), + read('item variants', itemIds, (chunk) => + db + .from('master_products') + .select(ITEM_VARIANT_SELECT) + .in('item_id', chunk) + .eq('status', 'published') + ), + ]); + const itemsById = new Map(); + for (const raw of itemRows) { + const item = mapItemRow(raw); + if (item) itemsById.set(item.id, item); + } + const variantsByItem = new Map(); + for (const raw of variantRows) { + const variant = mapItemVariantRow(raw); + if (!variant) continue; + const list = variantsByItem.get(variant.item_id) ?? []; + list.push(variant); + variantsByItem.set(variant.item_id, list); + } + for (const [masterId, itemId] of itemIdByMaster) { + const item = itemsById.get(itemId); + if (!item) continue; + links.set(masterId, { + item, + variants: sortItemVariants(variantsByItem.get(itemId) ?? []), + }); + } + return links; +} + +/** + * Brand facets counted in Items, not variants (DOS/Rate#1155): one count per + * published Item of the category, and one per published master not linked + * to an Item yet. Needs M1+M2 (#1150). + */ +export async function getCategoryItemBrandFacets( + categoryId: string +): Promise<{ name: string; count: number }[]> { + const cleanId = sanitizeRateEntityId(categoryId); + if (!cleanId) return []; + const rows = (await fetchAllRows( + 'item brand facets', + (from, to) => + untypedRate() + .from('master_products') + .select('id, brand, item_id') + .eq('category_id', cleanId) + .eq('status', 'published') + .order('id') + .range(from, to) + .then( + throwIfItemSchemaMissing('item brand facets') + ) as unknown as PromiseLike<{ + data: unknown[] | null; + error: { message: string } | null; + }> + )) as Array<{ id?: unknown; brand?: unknown; item_id?: unknown }>; + const linkedIds = Array.from( + new Set(rows.map((r) => sanitizeRateEntityId(r.item_id)).filter(Boolean)) + ); + const publishedItems = new Set(); + if (linkedIds.length > 0) { + const itemRows = (await readInChunks( + linkedIds, + 'item brand facets', + (chunk) => + untypedRate() + .from('items') + .select('id') + .in('id', chunk) + .eq('status', 'published') + .then(throwIfItemSchemaMissing('item brand facets')) as PromiseLike<{ + data: unknown[] | null; + error: { message?: string } | null; + }> + )) as Array<{ id?: unknown }>; + for (const r of itemRows) { + const id = sanitizeRateEntityId(r.id); + if (id) publishedItems.add(id); + } + } + return countBrandsByItem( + rows.map((r) => { + const itemId = sanitizeRateEntityId(r.item_id); + return { + brand: typeof r.brand === 'string' ? r.brand : '', + // A master whose Item is not published counts as itself. + key: + itemId && publishedItems.has(itemId) + ? `item:${itemId}` + : `master:${String(r.id ?? '')}`, + }; + }) + ); +} + +/** Published Items, for "products tracked" (DOS/Rate#1155). Needs M1+M2. */ +export async function countPublishedItems(): Promise { + const { count, error } = await ( + getRateClient() as unknown as { + from: (table: string) => { + select: ( + cols: string, + opts: { count: 'exact'; head: true } + ) => { + eq: ( + col: string, + val: unknown + ) => PromiseLike<{ + count: number | null; + error: { message: string; code?: string } | null; + }>; + }; + }; + } + ) + .from('items') + .select('id', { count: 'exact', head: true }) + .eq('status', 'published'); + if (error) throw itemReadError('item count', error); + return typeof count === 'number' && count > 0 ? count : 0; +} + +/** + * Variant slug to published Item slug, over every published master linked + * to a published Item (DOS/Rate#1155: the sitemap lists Item URLs). Needs + * M1+M2 (#1150). + */ +export async function getItemSlugByVariantSlug(): Promise> { + const db = untypedRate(); + const page = + (what: string, table: string, cols: string) => (from: number, to: number) => + db + .from(table) + .select(cols) + .eq('status', 'published') + .order('id') + .range(from, to) + .then(throwIfItemSchemaMissing(what)) as unknown as PromiseLike<{ + data: unknown[] | null; + error: { message: string } | null; + }>; + const [masterRows, itemRows] = await Promise.all([ + fetchAllRows( + 'sitemap item links', + page('sitemap item links', 'master_products', 'slug, item_id') + ), + fetchAllRows('sitemap items', page('sitemap items', 'items', 'id, slug')), + ]); + const itemSlugById = new Map(); + for (const raw of itemRows as Array<{ id?: unknown; slug?: unknown }>) { + const id = sanitizeRateEntityId(raw.id); + if (id && typeof raw.slug === 'string' && raw.slug) { + itemSlugById.set(id, raw.slug); + } + } + const out = new Map(); + for (const raw of masterRows as Array<{ + slug?: unknown; + item_id?: unknown; + }>) { + const itemSlug = itemSlugById.get(sanitizeRateEntityId(raw.item_id)); + if (itemSlug && typeof raw.slug === 'string' && raw.slug) { + out.set(raw.slug, itemSlug); + } + } + return out; +} diff --git a/apps/web/src/server/rate/__tests__/item-directory.test.ts b/apps/web/src/server/rate/__tests__/item-directory.test.ts new file mode 100644 index 000000000..462bf8708 --- /dev/null +++ b/apps/web/src/server/rate/__tests__/item-directory.test.ts @@ -0,0 +1,264 @@ +/** + * Directory, leaderboard, facets, stats and sitemap per Item behind + * RATE_ITEM_MODEL (DOS/Rate#1155), against a stubbed PostgREST shaped like + * the M1+M2 draft (PR #1242). + */ +import assert from 'node:assert/strict'; +import type { MasterProduct } from '@repo/shared-data'; + +process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://stub.supabase.test'; +process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-test-key'; + +const ITEM = '11111111-1111-4111-8111-111111111155'; +const DRAFT_ITEM = '11111111-1111-4111-8111-111111111156'; +const V128 = '22222222-2222-4222-8222-222222222128'; +const V256 = '22222222-2222-4222-8222-222222222256'; +const LONE = '22222222-2222-4222-8222-222222222999'; +const CATEGORY = '55555555-5555-4555-8555-555555555555'; + +const itemRow = { + id: ITEM, + item_key: 'apple:iphone-16', + slug: 'apple-iphone-16', + name: 'Apple iPhone 16', + brand: 'Apple', + model: 'iPhone 16', + category_id: CATEGORY, + default_variant_id: V128, + min_price: 21990000, + max_price: 25990000, + price_variant_count: 2, + merchant_count: 5, + listing_count: 6, + unknown_variant_listing_count: 1, + updated_at: '2026-10-29T00:00:00Z', +}; +const variantRow = (id: string, gb: number) => ({ + id, + item_id: ITEM, + slug: `apple-iphone-16-${gb}gb`, + name: `Apple iPhone 16 ${gb}GB`, + variant_key: `${gb}gb`, + variant_name: `${gb}GB`, + sort_order: null, + storage_gb: gb, + min_price: gb === 128 ? 21990000 : 24990000, + max_price: gb === 128 ? 23990000 : 25990000, + merchant_count: 2, +}); + +let calls: string[] = []; +let schemaMissing = false; +let itemsDown = false; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + ); + const q = decodeURIComponent(url.search); + calls.push(`${url.pathname}?${q}`); + const json = (body: unknown, headers: Record = {}) => + new Response(init?.method === 'HEAD' ? null : JSON.stringify(body), { + status: 200, + headers: { + 'content-type': 'application/json', + 'content-range': '0-0/*', + ...headers, + }, + }); + if (Number(url.searchParams.get('offset') ?? 0) > 0) return json([]); + if (itemsDown && url.pathname === '/rest/v1/items') { + return new Response( + JSON.stringify({ code: '57014', message: 'canceling statement' }), + { status: 500, headers: { 'content-type': 'application/json' } } + ); + } + if ( + schemaMissing && + (url.pathname === '/rest/v1/items' || q.includes('item_id')) + ) { + return new Response( + JSON.stringify({ code: 'PGRST205', message: 'no rate.items' }), + { status: 404, headers: { 'content-type': 'application/json' } } + ); + } + switch (url.pathname) { + case '/rest/v1/items': + if (init?.method === 'HEAD') return json([], { 'content-range': '*/3' }); + if (q.includes('select=id,slug')) { + return json([{ id: ITEM, slug: 'apple-iphone-16' }]); + } + if (q.includes('select=id&')) return json([{ id: ITEM }]); + return json([itemRow]); + case '/rest/v1/master_products': + if (q.includes('select=id,item_id&')) { + return json([ + { id: V128, item_id: ITEM }, + { id: V256, item_id: ITEM }, + { id: LONE, item_id: null }, + ]); + } + if (q.includes('select=slug,item_id')) { + return json([ + { slug: 'apple-iphone-16-128gb', item_id: ITEM }, + { slug: 'apple-iphone-16-256gb', item_id: ITEM }, + { slug: 'samsung-galaxy-s24', item_id: null }, + { slug: 'pixel-10', item_id: DRAFT_ITEM }, + ]); + } + if (q.includes('select=id,brand,item_id')) { + return json([ + { id: V128, brand: 'Apple', item_id: ITEM }, + { id: V256, brand: 'Apple', item_id: ITEM }, + { id: LONE, brand: 'Samsung', item_id: null }, + // Its Item is a draft: the master counts as itself. + { id: 'draft-variant', brand: 'Google', item_id: DRAFT_ITEM }, + ]); + } + if (q.includes('item_id=in.')) { + return json([variantRow(V256, 256), variantRow(V128, 128)]); + } + return json([]); + default: + return json([]); + } +}) as typeof fetch; + +const row = (id: string, slug: string, min: number): MasterProduct => + ({ + id, + slug, + name: slug, + brand: 'Apple', + min_price: min, + max_price: min, + merchant_count: 1, + }) as MasterProduct; + +async function main() { + console.warn('🧪 Item directory server suite (DOS/Rate#1155)'); + const dir = await import('../item-directory'); + const api = await import('../../../lib/rate-api'); + + const rows = [ + row(V256, 'apple-iphone-16-256gb', 24990000), + row(LONE, 'samsung-galaxy-s24', 19000000), + row(V128, 'apple-iphone-16-128gb', 21990000), + ]; + + // Flag off: rows as they were, no page, and nothing reads the Item tables. + delete process.env.RATE_ITEM_MODEL; + calls = []; + assert.equal(await dir.groupCatalogRowsByItem(rows), rows); + assert.equal( + await dir.getItemCatalogPage(async () => rows, 0, 10), + null, + 'flag off: the caller reads its variants as before' + ); + assert.equal(await dir.getItemBrandFacets(CATEGORY, 'smartphones'), null); + assert.equal(await dir.getItemCount(), null); + assert.equal(await dir.getItemSlugByVariantSlug(), null); + assert.deepEqual(calls, [], 'flag off: no database read'); + + // Flag on: one row per Item, pages cut after grouping. + process.env.RATE_ITEM_MODEL = 'item'; + const grouped = await dir.groupCatalogRowsByItem(rows); + assert.deepEqual( + grouped.map((r) => [r.id, r.item_slug ?? null]), + [ + [V128, 'apple-iphone-16'], + [LONE, null], + ] + ); + assert.equal(grouped[0]!.min_price, 21990000); + assert.deepEqual( + grouped[0]!.item_variants!.map((v) => v.variant_key), + ['128gb', '256gb'] + ); + const page2 = await dir.getItemCatalogPage( + async (offset) => (offset === 0 ? rows : []), + 1, + 5 + ); + assert.deepEqual( + page2!.map((r) => r.id), + [LONE], + 'offset counts Items, not variants' + ); + + // Past 500 variants the read continues in batches, so no Item falls off + // the last page (Codex review): 600 lone variants, 2 batches, page 2. + const many = Array.from({ length: 600 }, (_, i) => + row(`lone-${i}`, `lone-${i}`, 1000 + i) + ); + const batches: number[] = []; + const tail = await dir.getItemCatalogPage( + async (offset, limit) => { + batches.push(offset); + return many.slice(offset, offset + limit); + }, + 580, + 30 + ); + assert.deepEqual(batches, [0, 500]); + assert.equal(tail!.length, 20); + assert.equal(tail!.at(-1)!.id, 'lone-599'); + + // Brand facets in Items; a master of a draft Item counts as itself. + assert.deepEqual(await api.getCategoryItemBrandFacets(CATEGORY), [ + { name: 'Apple', count: 1 }, + { name: 'Google', count: 1 }, + { name: 'Samsung', count: 1 }, + ]); + assert.equal(await api.countPublishedItems(), 3); + const slugs = await api.getItemSlugByVariantSlug(); + assert.equal(slugs.get('apple-iphone-16-256gb'), 'apple-iphone-16'); + assert.equal(slugs.has('samsung-galaxy-s24'), false); + assert.equal(slugs.has('pixel-10'), false, 'draft Items are not listed'); + + // Flag on, Item tables missing (M1+M2 not applied): variants as before. + schemaMissing = true; + assert.equal(await dir.groupCatalogRowsByItem(rows), rows); + assert.equal(await dir.getItemCatalogPage(async () => rows, 0, 10), null); + assert.equal(await dir.getItemBrandFacets(CATEGORY, 'smartphones'), null); + assert.equal(await dir.getItemCount(), null); + schemaMissing = false; + + // Flag on, an Item read failing for another reason: variants too, never a + // failed page (review round 1). + itemsDown = true; + assert.equal(await dir.groupCatalogRowsByItem(rows), rows); + assert.equal(await dir.getItemCount(), null); + itemsDown = false; + delete process.env.RATE_ITEM_MODEL; + + // The cached pages this touches keep their cache mode: the leaderboard + // stays ISR, and nothing here reads request-time APIs. + const fs = require('node:fs') as typeof import('node:fs'); + const path = require('node:path') as typeof import('node:path'); + const leaderboard = fs.readFileSync( + path.resolve(__dirname, '../../../app/[locale]/leaderboard/page.tsx'), + 'utf8' + ); + assert.match(leaderboard, /export const revalidate = 60;/); + assert.doesNotMatch(leaderboard, /force-dynamic/); + for (const file of [ + path.resolve(__dirname, '../item-directory.ts'), + path.resolve(__dirname, '../../../lib/item-directory.ts'), + ]) { + assert.doesNotMatch( + fs.readFileSync(file, 'utf8'), + /searchParams|\bheaders\(|\bcookies\(/, + file + ); + } + console.warn('✅ Item directory server suite passed'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/web/src/server/rate/index.ts b/apps/web/src/server/rate/index.ts index c13e81917..4790208f1 100644 --- a/apps/web/src/server/rate/index.ts +++ b/apps/web/src/server/rate/index.ts @@ -1,5 +1,11 @@ import 'server-only'; import { cache } from 'react'; +import { sitemapRowsByItem } from '@/lib/item-directory'; +import { + getItemBrandFacets, + getItemCount, + getItemSlugByVariantSlug, +} from './item-directory'; import { compareMasterProducts as _compareMasterProducts, getCategoryDirectoryFacets as _getCategoryDirectoryFacets, @@ -278,12 +284,32 @@ const _getCachedCategoryDirectoryFacetsByKey = cache( )(cleanCat) ); -export function getCachedCategoryDirectoryFacets(categorySlug?: string) { +/** Facets counted in variants whatever the Item flag (API v1, #1155). */ +export function getCachedVariantCategoryDirectoryFacets(categorySlug?: string) { return _getCachedCategoryDirectoryFacetsByKey( normalizeCachedFacetCategory(categorySlug) ); } +export const getCachedCategoryDirectoryFacets = cache( + async (categorySlug?: string) => { + const facets = await _getCachedCategoryDirectoryFacetsByKey( + normalizeCachedFacetCategory(categorySlug) + ); + // RATE_ITEM_MODEL on: brand counts and the total are Items (#1155). + const current = facets.currentCategory; + const itemFacets = current + ? await getItemBrandFacets(current.id, current.slug) + : null; + if (!itemFacets) return facets; + return { + ...facets, + brands: itemFacets.brands, + totalUnfiltered: itemFacets.brands.reduce((acc, b) => acc + b.count, 0), + }; + } +); + const _getCachedMasterProductOffersByKey = cache((cleanId: string) => withRateDataCache( _getMasterProductOffers, @@ -511,12 +537,21 @@ export function getCachedMerchantProfileById(merchantId: string) { return _getCachedMerchantProfileByIdKey(cleanId); } -export const getCachedRatePlatformStats = cache(() => - withRateDataCache(_getRatePlatformStats, ['rate-platform-stats'], { - revalidate: 600, - tags: [RATE_CACHE_TAGS.STATS], - })() -); +export const getCachedRatePlatformStats = cache(async () => { + const stats = await withRateDataCache( + _getRatePlatformStats, + ['rate-platform-stats'], + { + revalidate: 600, + tags: [RATE_CACHE_TAGS.STATS], + } + )(); + // RATE_ITEM_MODEL on: "products tracked" counts Items (#1155). + const itemCount = await getItemCount(); + return itemCount == null + ? stats + : { ...stats, masterProductsCount: itemCount }; +}); /** Stored reviews kept out of the rating, per master id (DOS/Rate#1139). */ export const getCachedFilteredReviewCountsByMaster = cache(() => @@ -588,8 +623,12 @@ const _getCachedSitemapRowsData = withRateDataCache( */ export const getCachedSitemapRows = cache(async (): Promise => { const rows = await _getCachedSitemapRowsData(); + // RATE_ITEM_MODEL on: one URL per Item, dated by its newest variant (#1155). + const itemSlugs = await getItemSlugByVariantSlug(); return { - products: rows.products, + products: itemSlugs + ? sitemapRowsByItem(rows.products, itemSlugs) + : rows.products, merchants: rows.merchants, categorySlugById: new Map(rows.categories), }; @@ -644,3 +683,6 @@ export { type CrossPlatformPriceSpreadItem, type DealRadarVerdict, } from './deals'; + +export { getItemCatalogPage, groupCatalogRowsByItem } from './item-directory'; +export type { ItemCatalogRow } from '@/lib/item-directory'; diff --git a/apps/web/src/server/rate/item-directory.ts b/apps/web/src/server/rate/item-directory.ts new file mode 100644 index 000000000..06bcd56e6 --- /dev/null +++ b/apps/web/src/server/rate/item-directory.ts @@ -0,0 +1,154 @@ +import 'server-only'; +import { createHash } from 'node:crypto'; +import type { MasterProduct } from '@repo/shared-data'; +import { + ItemModelUnavailableError, + countPublishedItems as _countPublishedItems, + getCategoryItemBrandFacets as _getCategoryItemBrandFacets, + getItemLinksForMasters as _getItemLinksForMasters, + getItemSlugByVariantSlug as _getItemSlugByVariantSlug, + type MasterItemLink, +} from '../../lib/rate-api'; +import { + groupRowsByItem, + sortItemRows, + type ItemCatalogRow, +} from '@/lib/item-directory'; +import { createLogger } from '@/lib/logger'; +import { RATE_CACHE_TAGS, withRateDataCache } from './cache-tags'; +import { isItemModelOn } from './item-model'; + +// Directory, leaderboard, facets, stats and sitemap per Item while +// `RATE_ITEM_MODEL` is on (DOS/Rate#1155). With the flag off, or with the +// Item tables missing (M1+M2 not applied), every helper here gives back what +// the caller already had, so pages render exactly as before. + +const log = createLogger('server.item-directory'); + +/** Runs `read` under the Item model; null when it is off or unavailable. */ +async function whenItemModel( + what: string, + read: () => Promise +): Promise { + if (!isItemModelOn()) return null; + try { + return await read(); + } catch (err) { + // Any failed Item read shows the variant pages, which still work: a + // listing, facet or stats outage must not follow from the Item layer. + const message = err instanceof Error ? err.message : String(err); + if (err instanceof ItemModelUnavailableError) { + log.warn('item model unavailable, showing variants', { what, message }); + } else { + log.error('item read failed, showing variants', { what, message }); + } + return null; + } +} + +// Keyed by the master ids (hashed: a page holds up to 500), refreshed with +// the product data the rows came from. +const getCachedItemLinks = async ( + masterIds: readonly string[] +): Promise> => { + const ids = Array.from(new Set(masterIds)).sort(); + const key = createHash('sha256').update(ids.join(',')).digest('hex'); + const entries = await withRateDataCache( + async (list: string[]) => [...(await _getItemLinksForMasters(list))], + ['rate-item-links', key], + { revalidate: 300, tags: [RATE_CACHE_TAGS.PRODUCTS] } + )(ids); + return new Map(entries); +}; + +/** + * Catalogue rows grouped into one row per Item (one card, one leaderboard + * slot); the rows unchanged while the Item model is off. + */ +export async function groupCatalogRowsByItem( + rows: readonly T[] +): Promise> { + const grouped = await whenItemModel('catalogue rows', async () => + groupRowsByItem( + rows, + await getCachedItemLinks(rows.map((r) => r.id).filter(Boolean)) + ) + ); + return grouped ?? (rows as Array); +} + +// The catalogue read returns at most 500 variants; a category is read in +// batches until one comes back short. 20 batches (10,000 variants) is far +// above the catalogue; past it the page is cut and logged. +const CATALOG_BATCH_SIZE = 500; +const MAX_CATALOG_BATCHES = 20; + +/** + * One page of Item cards, or null while the Item model is off. Every + * matching variant is read (`readBatch(offset, limit)`, in batches), grouped + * into Items, ordered by the Item's price for price sorts, and only then + * cut, so a page holds `limit` Items and no Item falls between pages. + */ +export async function getItemCatalogPage( + readBatch: (offset: number, limit: number) => Promise, + offset: number, + limit: number, + sort?: string +): Promise | null> { + return whenItemModel('catalogue page', async () => { + const rows: T[] = []; + for (let batch = 0; ; batch++) { + if (batch === MAX_CATALOG_BATCHES) { + log.warn('item catalogue read cut', { variants: rows.length }); + break; + } + const page = await readBatch( + batch * CATALOG_BATCH_SIZE, + CATALOG_BATCH_SIZE + ); + rows.push(...page); + if (page.length < CATALOG_BATCH_SIZE) break; + } + const grouped = groupRowsByItem( + rows, + await getCachedItemLinks(rows.map((r) => r.id).filter(Boolean)) + ); + return sortItemRows(grouped, sort).slice(offset, offset + limit); + }); +} + +/** Brand facets counted in Items for a category, or null when off. */ +export function getItemBrandFacets(categoryId: string, categorySlug: string) { + return whenItemModel('brand facets', () => + withRateDataCache( + _getCategoryItemBrandFacets, + ['rate-item-brand-facets', categoryId], + { + revalidate: 600, + tags: [RATE_CACHE_TAGS.PRODUCTS, RATE_CACHE_TAGS.CATEGORIES], + } + )(categoryId).then((brands) => ({ brands, categorySlug })) + ); +} + +/** Published Items ("products tracked"), or null when off. */ +export function getItemCount() { + return whenItemModel('item count', () => + withRateDataCache(_countPublishedItems, ['rate-item-count'], { + revalidate: 600, + tags: [RATE_CACHE_TAGS.STATS, RATE_CACHE_TAGS.PRODUCTS], + })() + ); +} + +/** Variant slug to Item slug for the sitemap, or null when off. */ +export function getItemSlugByVariantSlug() { + return whenItemModel('sitemap item slugs', async () => { + const entries = await withRateDataCache( + async () => [...(await _getItemSlugByVariantSlug())], + ['rate-item-slug-by-variant'], + { revalidate: 3600, tags: [RATE_CACHE_TAGS.PRODUCTS] } + )(); + return new Map(entries); + }); +} From 872743ef244ea482b7ce4dd9e4551d6357040004 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:17:25 +0700 Subject: [PATCH 42/69] test(e2e): item-model e2e job with the m0 and m1+m2 proposals and item routes (#1273) * test(e2e): item-model e2e job with the m0 and m1+m2 proposals and item routes A second E2E job runs `scripts/e2e/run.mjs --item-model`: after the usual seed it applies the DOS-Me M0 and M1+M2 proposal SQL and an Item fixture (one multi-size Item with a retired unknown variant, one draft Item), serves the app with RATE_ITEM_MODEL=nullable_variant, and runs only apps/web/e2e/item-model. The specs assert the #1153 routes: an old variant URL 308s to the Item URL with ?v=, a retired variant 308s without it, the Item URL is its own canonical with or without ?v=, and non-public slugs 404. The default E2E job ignores that folder and stays flag-off. Refs #1159 * test(e2e): set RATE_ITEM_MODEL only for --item-model runs The default run's environment stays exactly as before. Refs #1159 * test(e2e): item-model review fixes for the draft fixture and build reuse - The draft fixture is its own Item (e2e:item-draft, slug no longer shared with the default seed) whose variant stays published while an admin sets the Item to draft, so only the Item route can 404 it. - Item listings carry brand and category like the other seed rows. - run.mjs records the mode a build served and refuses a --skip-build run in the other mode, so cached ISR pages and redirects never cross. Refs #1159 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 40 +++++++++++ .gitignore | 2 + apps/web/e2e/item-model/item-routes.spec.ts | 76 ++++++++++++++++++++ apps/web/e2e/item-model/playwright.config.ts | 30 ++++++++ apps/web/e2e/item-model/seed.sql | 50 +++++++++++++ apps/web/e2e/playwright.config.ts | 2 + scripts/e2e/run.mjs | 41 ++++++++++- 7 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 apps/web/e2e/item-model/item-routes.spec.ts create mode 100644 apps/web/e2e/item-model/playwright.config.ts create mode 100644 apps/web/e2e/item-model/seed.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99a07ad38..3380bf475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,3 +153,43 @@ jobs: playwright-report/ test-results/e2e/ retention-days: 7 + + # RATE_ITEM_MODEL=nullable_variant on the DOS-Me M0 + M1+M2 proposals (DOS/Rate#1159): + # the Item routes, redirects and canonicals. The default E2E job above stays flag-off. + e2e-item-model: + name: E2E (Playwright, Item model) + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10.34.5 + + - name: Setup Node.js environment + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run Item-model browser E2E against a throwaway database + run: node scripts/e2e/run.mjs --item-model + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: playwright-report-item-model + path: | + playwright-report-item-model/ + test-results/e2e-item-model/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index ab455691b..5a45935c8 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,7 @@ Rate.code-workspace # Playwright HTML report playwright-report/ +playwright-report-item-model/ # Secrets and local data that must never be committed (DOS/Rate#153): # Windows Explorer copies (English "x - Copy.env", Vietnamese "x - Bản sao"), @@ -135,6 +136,7 @@ playwright-report/ !supabase/migrations/*.sql # Reviewed E2E fixture: synthetic catalog rows only, no credentials. !apps/web/e2e/seed.sql +!apps/web/e2e/item-model/seed.sql # Reviewed DOS-Me migration proposal (DOS/Rate#1150): DDL only, no data, no credentials. !Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql !Docs/Proposals/2026-10-28-dos-me-m1-m2-items-rollback.sql diff --git a/apps/web/e2e/item-model/item-routes.spec.ts b/apps/web/e2e/item-model/item-routes.spec.ts new file mode 100644 index 000000000..34292f956 --- /dev/null +++ b/apps/web/e2e/item-model/item-routes.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; + +// RATE_ITEM_MODEL=nullable_variant on the M0 + M1+M2 proposal schema (DOS/Rate#1159): +// the #1153 routes for one multi-size Item (seed.sql in this folder). +const ITEM = '/smartphones/e2e-item-phone'; +const CANONICAL = 'https://rate.vn/smartphones/e2e-item-phone'; + +const canonicalOf = async ( + request: import('@playwright/test').APIRequestContext, + path: string +) => { + const res = await request.get(path); + expect(res.status(), path).toBe(200); + return (await res.text()).match(/ { + for (const [variant, key] of [ + ['e2e-item-phone-256gb', '256gb'], + ['e2e-item-phone-128gb', '128gb'], + ]) { + const res = await request.get(`/smartphones/${variant}`, { + maxRedirects: 0, + }); + expect(res.status(), variant).toBe(308); + expect(res.headers().location, variant).toMatch( + new RegExp(`/smartphones/e2e-item-phone\\?v=${key}$`) + ); + } +}); + +test('a retired variant URL 308s to the Item URL without a size', async ({ + request, +}) => { + const res = await request.get('/smartphones/e2e-item-phone-unknown', { + maxRedirects: 0, + }); + expect(res.status()).toBe(308); + expect(res.headers().location).toMatch(/\/smartphones\/e2e-item-phone$/); +}); + +test('the Item URL renders and is its own canonical, with or without ?v=', async ({ + request, +}) => { + expect(await canonicalOf(request, ITEM)).toBe(CANONICAL); + expect(await canonicalOf(request, `${ITEM}?v=256gb`)).toBe(CANONICAL); + expect(await canonicalOf(request, `${ITEM}?v=not-a-size`)).toBe(CANONICAL); +}); + +test('a followed variant redirect lands on the canonical Item page', async ({ + page, +}) => { + const res = await page.goto('/smartphones/e2e-item-phone-256gb'); + expect(res?.status()).toBe(200); + expect(page.url()).toMatch(/\/smartphones\/e2e-item-phone\?v=256gb$/); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + 'href', + CANONICAL + ); +}); + +test('a slug that is no published Item or variant of one is a 404', async ({ + request, +}) => { + for (const path of [ + '/smartphones/e2e-no-such-item', + // A draft Item and its (published) variant are not public. + '/smartphones/e2e-item-draft', + '/smartphones/e2e-item-draft-128gb', + ]) { + const res = await request.get(path, { maxRedirects: 0 }); + expect(res.status(), path).toBe(404); + } +}); diff --git a/apps/web/e2e/item-model/playwright.config.ts b/apps/web/e2e/item-model/playwright.config.ts new file mode 100644 index 000000000..f2ed61bc1 --- /dev/null +++ b/apps/web/e2e/item-model/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +// The RATE_ITEM_MODEL=nullable_variant run (DOS/Rate#1159): started by +// `scripts/e2e/run.mjs --item-model`, which applies the DOS-Me M0 + M1+M2 +// proposals and apps/web/e2e/item-model/seed.sql on the throwaway database. +// Only the specs in this folder run here; the default run ignores them. +export default defineConfig({ + testDir: '.', + outputDir: '../../../../test-results/e2e-item-model', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [ + ['github'], + [ + 'html', + { + open: 'never', + outputFolder: '../../../../playwright-report-item-model', + }, + ], + ] + : 'list', + use: { + baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3100', + trace: 'retain-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/apps/web/e2e/item-model/seed.sql b/apps/web/e2e/item-model/seed.sql new file mode 100644 index 000000000..de1528662 --- /dev/null +++ b/apps/web/e2e/item-model/seed.sql @@ -0,0 +1,50 @@ +-- Item-model E2E fixture (DOS/Rate#1159), applied by `scripts/e2e/run.mjs --item-model` +-- after apps/web/e2e/seed.sql and the DOS-Me M0 + M1+M2 proposals +-- (Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.sql, 2026-10-28-dos-me-m1-m2-items.sql). +-- Synthetic catalog rows only, no credentials. +-- +-- One multi-size Item (`e2e:item-phone`: 128gb and 256gb published, a retired `unknown`) +-- and one Item an admin set back to draft while its variant stays published +-- (`e2e:item-draft`), so only the Item route can 404 it. The M1 trigger creates the Items +-- from the masters' keys. + +INSERT INTO rate.master_products (id, brand, model, model_key, name, slug, category_id, status, + storage_gb, min_price, max_price, merchant_count) +VALUES + ('17e00000-0000-4000-8000-000000000128', 'E2E', 'Item Phone', 'e2e:item-phone:128gb', + 'E2E Item Phone 128GB', 'e2e-item-phone-128gb', 'c0000000-0000-4000-8000-000000000001', + 'published', 128, 10990000, 10990000, 1), + ('17e00000-0000-4000-8000-000000000256', 'E2E', 'Item Phone', 'e2e:item-phone:256gb', + 'E2E Item Phone 256GB', 'e2e-item-phone-256gb', 'c0000000-0000-4000-8000-000000000001', + 'published', 256, 12990000, 12990000, 1), + ('17e00000-0000-4000-8000-0000000000aa', 'E2E', 'Item Phone', 'e2e:item-phone:unknown', + 'E2E Item Phone', 'e2e-item-phone-unknown', 'c0000000-0000-4000-8000-000000000001', + 'published', NULL, 9990000, 9990000, 1), + ('17e00000-0000-4000-8000-0000000000df', 'E2E', 'Item Draft', 'e2e:item-draft:128gb', + 'E2E Item Draft 128GB', 'e2e-item-draft-128gb', 'c0000000-0000-4000-8000-000000000001', + 'published', 128, 8990000, 8990000, 1) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO rate.products (id, merchant_id, platform, platform_item_id, title, brand, + category_id, price_current, url, cluster_id) +VALUES + ('27e00000-0000-4000-8000-000000000128', '11111111-1111-4111-8111-111111111111', 'shopee', + 'e2e_item_128', 'E2E Item Phone 128GB', 'E2E', 'c0000000-0000-4000-8000-000000000001', + 10990000, 'https://shopee.vn/e2e-item-128', '17e00000-0000-4000-8000-000000000128'), + ('27e00000-0000-4000-8000-000000000256', '11111111-1111-4111-8111-111111111111', 'shopee', + 'e2e_item_256', 'E2E Item Phone 256GB', 'E2E', 'c0000000-0000-4000-8000-000000000001', + 12990000, 'https://shopee.vn/e2e-item-256', '17e00000-0000-4000-8000-000000000256'), + ('27e00000-0000-4000-8000-0000000000aa', '11111111-1111-4111-8111-111111111112', 'tiki', + 'e2e_item_unknown', 'E2E Item Phone', 'E2E', 'c0000000-0000-4000-8000-000000000001', + 9990000, 'https://tiki.vn/e2e-item-unknown', '17e00000-0000-4000-8000-0000000000aa') +ON CONFLICT (id) DO NOTHING; + +-- The 128GB variant is the Item's default; the unknown variant is retired the way the +-- importer does it (its listing keeps the Item with no variant, the variant is archived). +UPDATE rate.items SET default_variant_id = '17e00000-0000-4000-8000-000000000128' +WHERE item_key = 'e2e:item-phone'; +SELECT rate.retire_item_unknown_variant(id) FROM rate.items WHERE item_key = 'e2e:item-phone'; +SELECT rate.refresh_item_stats(id) FROM rate.items WHERE item_key LIKE 'e2e:%'; +-- An admin unpublishes the Item; its variant stays published (the variant lookup alone +-- would still find it). +UPDATE rate.items SET status = 'draft' WHERE item_key = 'e2e:item-draft'; diff --git a/apps/web/e2e/playwright.config.ts b/apps/web/e2e/playwright.config.ts index a1b7b6432..5aa8dad93 100644 --- a/apps/web/e2e/playwright.config.ts +++ b/apps/web/e2e/playwright.config.ts @@ -4,6 +4,8 @@ import { defineConfig, devices } from '@playwright/test'; // a throwaway PostgREST seeded from apps/web/e2e/seed.sql. export default defineConfig({ testDir: '.', + // RATE_ITEM_MODEL specs run only in `run.mjs --item-model` (DOS/Rate#1159). + testIgnore: ['item-model/**'], outputDir: '../../../test-results/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, diff --git a/scripts/e2e/run.mjs b/scripts/e2e/run.mjs index eb34c890f..e5e1b6621 100644 --- a/scripts/e2e/run.mjs +++ b/scripts/e2e/run.mjs @@ -9,8 +9,11 @@ // 3. `next build` (unless --skip-build) and `next start` against that proxy. // 4. Playwright specs in apps/web/e2e. // -// Usage: node scripts/e2e/run.mjs [--skip-build] [--serve] [-- ] +// Usage: node scripts/e2e/run.mjs [--skip-build] [--serve] [--item-model] [-- ] // --serve keeps the stack up for manual debugging instead of running the specs. +// --item-model (DOS/Rate#1159) also applies the DOS-Me M0 + M1+M2 proposals and +// apps/web/e2e/item-model/seed.sql, serves the app with RATE_ITEM_MODEL=nullable_variant +// and runs only the specs in apps/web/e2e/item-model. // Needs Docker and a Playwright Chromium (`pnpm exec playwright install chromium`). import { spawn, spawnSync } from 'node:child_process'; @@ -26,6 +29,7 @@ import { pruneStaleStacks } from './stale-stacks.mjs'; const args = process.argv.slice(2); const skipBuild = args.includes('--skip-build'); const serveOnly = args.includes('--serve'); +const itemModel = args.includes('--item-model'); const playwrightArgs = args.includes('--') ? args.slice(args.indexOf('--') + 1) : []; @@ -120,6 +124,23 @@ const SUPABASE_PORT_FILE = path.join( 'e2e-supabase-port' ); const isValidPort = (p) => Number.isInteger(p) && p > 0 && p < 65536; +// The mode the last build served (DOS/Rate#1159): its ISR pages and redirects are cached +// under .next, so a --skip-build run in the other mode would serve them. +const MODE_FILE = path.join(repoRoot, 'apps', 'web', '.next', 'e2e-mode'); +const runMode = itemModel ? 'item-model' : 'default'; +if (skipBuild) { + let builtMode = 'default'; + try { + builtMode = readFileSync(MODE_FILE, 'utf8').trim() || 'default'; + } catch { + // a build from before the mode file: the default mode + } + if (builtMode !== runMode) { + throw new Error( + `--skip-build reuses a ${builtMode} build; this is a ${runMode} run, rebuild without --skip-build` + ); + } +} function recordedSupabasePort() { try { @@ -335,6 +356,16 @@ try { db.sql( readFileSync(path.join(repoRoot, 'apps', 'web', 'e2e', 'seed.sql'), 'utf8') ); + if (itemModel) { + // The proposals in the order JOY applies them, then the Item fixture. + for (const file of [ + ['Docs', 'Proposals', '2026-10-29-dos-me-m0-seed-rekey.sql'], + ['Docs', 'Proposals', '2026-10-28-dos-me-m1-m2-items.sql'], + ['apps', 'web', 'e2e', 'item-model', 'seed.sql'], + ]) { + db.sql(readFileSync(path.join(repoRoot, ...file), 'utf8')); + } + } docker( [ @@ -393,6 +424,9 @@ try { // only fills keys that are absent): default output, local public URL. NEXT_OUTPUT: '', APP_PUBLIC_URL: 'http://localhost', + // --item-model only; read per request by the web app, set for the build too + // so both agree. The default run sets nothing here. + ...(itemModel ? { RATE_ITEM_MODEL: 'nullable_variant' } : {}), }; if (!skipBuild) { // --ui=stream: turbo's TUI would take over the terminal from a detached child. @@ -404,6 +438,7 @@ try { throw new Error(`next build failed: ${err.message}`); }); writeFileSync(SUPABASE_PORT_FILE, String(supabasePort)); + writeFileSync(MODE_FILE, runMode); } // unstable_cache persists in .next/cache/fetch-cache, outside the build @@ -445,7 +480,9 @@ try { 'playwright', 'test', '-c', - 'apps/web/e2e/playwright.config.ts', + itemModel + ? 'apps/web/e2e/item-model/playwright.config.ts' + : 'apps/web/e2e/playwright.config.ts', ...playwrightArgs, ], { From d9f492060b1f2aff92cb0617542f4e09d12dd6b3 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:18:03 +0700 Subject: [PATCH 43/69] feat(web): item product page with size picker, unknown-size group and productgroup json-ld (#1272) * feat(web): product page per item with size picker, unknown-size group and productgroup json-ld Behind RATE_ITEM_MODEL (off: the page is unchanged). An Item URL now shows the Item: its name and "from" price over its sizes, a size picker with each size's own price, each size's price history and offer table, and the listings whose size is not stated in their own group outside the "from" price (Q2). The JSON-LD is a ProductGroup with one Product per size and one Item-level rating. Same-Item sizes no longer show as competitors. `?v=` is read in the browser only, so product routes stay ISR; the guard test now also covers the new client components and loader. Closes #1154 * fix(web): item page prices follow the selected size and failed sizes drop out Review round 1 of #1272: - the price strip (price, range, all-time low, price watch, share card) is now a per-size panel, so picking 256GB no longer shows or watches the 128GB price; - panels are keyed per size, so an offer chosen in one size's history or a filter does not carry over to another size; - the hero and meta "from" come from the sized variants only; the Item row's merchant_count counts unknown-size sellers (Q2); - a sold-out default size gives way to the first priced one; - a size whose read fails is left out, and so is the unknown-size group, instead of failing the whole Item page; - the JSON-LD leaves out sizes with no price and adds inProductGroupWithID and images per size; brand comes from the Item; - the picker announces the selected size and price (aria-live). Refs #1154 * fix(web): item page renders a size that loaded and its title quotes the same sizes Review round 2 of #1272: - when the planned size's read fails, the page renders the first loaded priced size (else the first loaded) instead of re-reading the failed one, and the picker's default follows it; - the title, description and Open Graph price come from the same loaded sizes as the hero and picker, not from every planned size. Refs #1154 Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 7 +- apps/web/locales/en.json | 7 +- apps/web/locales/vi.json | 7 +- .../app/[locale]/smartphones/[slug]/page.tsx | 530 +++++++++++++----- .../product/item/ItemVariantPanels.tsx | 27 + .../product/item/ItemVariantPicker.tsx | 60 ++ .../product/item/selected-variant.ts | 42 ++ .../__tests__/product-market-stats.test.ts | 5 +- apps/web/src/lib/item-page.ts | 132 +++++ apps/web/src/lib/rate-api.ts | 2 +- .../server/rate/__tests__/item-model.test.ts | 137 +++++ apps/web/src/server/rate/item-model.ts | 10 + apps/web/src/server/rate/item-page.ts | 130 +++++ apps/web/src/server/rate/item-route.ts | 7 +- 14 files changed, 953 insertions(+), 150 deletions(-) create mode 100644 apps/web/src/components/product/item/ItemVariantPanels.tsx create mode 100644 apps/web/src/components/product/item/ItemVariantPicker.tsx create mode 100644 apps/web/src/components/product/item/selected-variant.ts create mode 100644 apps/web/src/lib/item-page.ts create mode 100644 apps/web/src/server/rate/item-page.ts diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 6d62e7565..50b303eef 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1188,7 +1188,12 @@ "offersReviewRiskNoData": "nedostatek dat", "offersDiscontinuedBadge": "Ukončený prodej", "offersTrustBadge": "Důvěryhodnost: {score} %", - "offersScamRiskLabel": "Riziko podvodu" + "offersScamRiskLabel": "Riziko podvodu", + "itemVariantLabel": "Úložiště", + "itemVariantPrice": "Od {price} ₫", + "itemVariantNoPrice": "Zatím bez ceny", + "unknownSizeTitle": "Neuvedené úložiště ({count} zdrojů)", + "unknownSizeNote": "Prodejce neuvádí velikost úložiště, proto tyto ceny nejsou součástí ceny „od“ produktu." }, "homePage": { "catDescSmartphones": "Filtrovat podle čipsetu, RAM/ROM, obnovovací frekvence Hz, baterie mAh a nabíjení W", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 37bba27c5..da98d60f0 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1188,7 +1188,12 @@ "offersReviewRiskNoData": "not enough data", "offersDiscontinuedBadge": "Discontinued", "offersTrustBadge": "Trust: {score}%", - "offersScamRiskLabel": "Scam risk" + "offersScamRiskLabel": "Scam risk", + "itemVariantLabel": "Storage", + "itemVariantPrice": "From {price} ₫", + "itemVariantNoPrice": "No price yet", + "unknownSizeTitle": "Storage not stated ({count} sources)", + "unknownSizeNote": "The seller does not state the storage, so these prices are not part of the product's \"from\" price." }, "homePage": { "catDescSmartphones": "Filter by chipset, RAM/ROM, refresh rate Hz, battery mAh & fast charge W", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 4da82fd82..204d894e6 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -1188,7 +1188,12 @@ "offersReviewRiskNoData": "chưa đủ dữ liệu", "offersDiscontinuedBadge": "Ngừng bán", "offersTrustBadge": "Độ tin cậy: {score}%", - "offersScamRiskLabel": "Rủi ro lừa đảo" + "offersScamRiskLabel": "Rủi ro lừa đảo", + "itemVariantLabel": "Dung lượng", + "itemVariantPrice": "Từ {price} ₫", + "itemVariantNoPrice": "Chưa có giá", + "unknownSizeTitle": "Chưa rõ dung lượng ({count} nguồn)", + "unknownSizeNote": "Người bán không ghi rõ dung lượng, nên các giá này không tính vào giá \"từ\" của sản phẩm." }, "homePage": { "catDescSmartphones": "Lọc theo chipset, RAM/ROM, tần số quét Hz, pin mAh & sạc nhanh W", diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index 6ae0895f1..ee52990d3 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -4,6 +4,19 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '@/lib/navigation'; import { notFound, permanentRedirect } from 'next/navigation'; import { resolveItemRoute } from '@/server/rate/item-route'; +import { + loadItemPageContext, + pickRenderedSize, + type ItemPageVariant, +} from '@/server/rate/item-page'; +import { + buildItemProductGroupNode, + itemVariantLabel, + itemFromStats, + withoutItemSiblings, +} from '@/lib/item-page'; +import ItemVariantPicker from '@/components/product/item/ItemVariantPicker'; +import ItemVariantPanels from '@/components/product/item/ItemVariantPanels'; import { getCrossPlatformDealRadar, getCachedMasterProductBySlug, @@ -115,20 +128,37 @@ export async function generateCategoryProductMetadata( // RATE_ITEM_MODEL on: an Item URL describes its selected variant and is // canonical (Q1, DOS/Rate#1153). Off: null, the page as before. const itemRoute = await resolveItemRoute({ slug, categoryBasePath }); + // The sizes the page itself shows, so the title quotes the same "from". + const itemPage = + itemRoute?.kind === 'render' && itemRoute.variants.length > 1 + ? await loadItemPageContext(itemRoute.item, itemRoute.variants) + : null; + const renderedSize = + itemPage && itemRoute?.kind === 'render' + ? pickRenderedSize(itemPage.variants, itemRoute.variantSlug) + : null; const product = itemRoute?.kind === 'not_found' ? null : await getCachedMasterProductBySlug( - itemRoute?.kind === 'render' ? itemRoute.variantSlug : slug + renderedSize?.variant.slug ?? + (itemRoute?.kind === 'render' ? itemRoute.variantSlug : slug) ); if (!product) { return { title: t('notFoundTitle'), }; } - // The stored stats, like every other page shows (DOS/Rate#1141). - const marketStats = productMarketStats(product); + // The stored stats, like every other page shows (DOS/Rate#1141). An Item + // URL is the Item: its name and the "from" price over its sizes (#1154). + const itemMeta = itemRoute?.kind === 'render' ? itemRoute.item : null; + const isItemGroup = (itemPage?.variants.length ?? 0) > 1; + const marketStats = + itemPage && isItemGroup + ? itemFromStats(itemPage.variants.map((v) => v.product)) + : productMarketStats(product); const hasPrice = marketStats.minPrice > 0; + const name = (isItemGroup ? itemMeta?.name : null) || product.name; const minPrice = marketStats.minPrice.toLocaleString('vi-VN'); const canonicalUrl = `https://rate.vn${categoryBasePath}/${ itemRoute?.kind === 'render' ? itemRoute.itemSlug : product.slug @@ -140,28 +170,28 @@ export async function generateCategoryProductMetadata( title: fitTitle([ ...(hasPrice ? [ - t('metaTitle', { name: product.name, minPrice }), - t('metaTitleShort', { name: product.name, minPrice }), + t('metaTitle', { name, minPrice }), + t('metaTitleShort', { name, minPrice }), ] : []), - `${product.name} | Rate.vn`, - product.name, + `${name} | Rate.vn`, + name, ]), description: fitDescription( hasPrice ? t('metaDesc', { - name: product.name, + name, brand: product.brand, merchantCount: marketStats.sellerCount, }) - : t('metaDescNoStock', { name: product.name, brand: product.brand }) + : t('metaDescNoStock', { name, brand: product.brand }) ), alternates: { canonical: canonicalUrl, }, openGraph: { ...defaultOpenGraph(lang), - title: t('ogTitle', { name: product.name }), + title: t('ogTitle', { name }), description: hasPrice ? t('ogDesc', { minPrice }) : t('ogDescNoStock'), url: canonicalUrl, images: [firstImageUrl(product.images) ?? SHARE_IMAGE], @@ -196,8 +226,20 @@ export async function ProductDetailView({ // Neither a published Item nor a variant of one: no fallback to the old // master lookup, which would show a variant of an unpublished Item. if (itemRoute?.kind === 'not_found') notFound(); + // RATE_ITEM_MODEL on and an Item URL: every size's offers and history, and + // the listings whose size is not stated (#1154). Null otherwise. A size + // whose read failed is left out, and the page renders a size that loaded. + const itemPage = + itemRoute?.kind === 'render' && itemRoute.variants.length > 0 + ? await loadItemPageContext(itemRoute.item, itemRoute.variants) + : null; + const renderedSize = + itemPage && itemRoute?.kind === 'render' + ? pickRenderedSize(itemPage.variants, itemRoute.variantSlug) + : null; const productSlug = - itemRoute?.kind === 'render' ? itemRoute.variantSlug : slug; + renderedSize?.variant.slug ?? + (itemRoute?.kind === 'render' ? itemRoute.variantSlug : slug); const [t, tNav, tSale, storedProduct] = await Promise.all([ getTranslations({ locale, namespace: 'productDetail' }), getTranslations({ locale, namespace: 'rate.nav' }), @@ -208,6 +250,8 @@ export async function ProductDetailView({ if (!storedProduct) { notFound(); } + const itemVariants = itemPage?.variants ?? []; + const isItemGroup = itemVariants.length > 1; const categorySlug = categoryBasePath.replace(/^\//, '') || 'smartphones'; // Each category shows only the spec groups and fields it has: earbuds have @@ -258,16 +302,27 @@ export async function ProductDetailView({ // which also holds sold-out listings at their last price (DOS/Rate#1141). const marketStats = productMarketStats(product); const currentMinPrice = marketStats.minPrice; + // An Item's other sizes are the same device, not competitors (#1154). const relatedCompetitors = rankRelatedCompetitors( - categoryPeers, + itemPage + ? withoutItemSiblings( + categoryPeers, + itemVariants.map((v) => v.variant.slug) + ) + : categoryPeers, product.slug, currentMinPrice ); + // The Item page's name and "from" price span its sizes. + const displayName = itemPage?.item.name || product.name; + const heroStats = isItemGroup + ? itemFromStats(itemVariants.map((v) => v.product)) + : marketStats; const specs = product.specifications as ProductSpecifications; const keySpecs = product.key_specs as KeySpecs; - const minPriceFormatted = currentMinPrice.toLocaleString('vi-VN'); const sellerCount = marketStats.sellerCount; + const heroMinPriceFormatted = heroStats.minPrice.toLocaleString('vi-VN'); // Why there is no price, when there is none (DOS/Rate#1188). const saleState = masterSaleState({ minPrice: currentMinPrice, @@ -277,7 +332,6 @@ export async function ProductDetailView({ }); const saleStateKey = saleState === 'on_sale' ? null : saleStateLabelKey(saleState); - const maxPriceFormatted = marketStats.maxPrice.toLocaleString('vi-VN'); const authenticCount = product.authentic_review_count && product.authentic_review_count > 0 @@ -300,6 +354,60 @@ export async function ProductDetailView({ itemRoute?.kind === 'render' ? itemRoute.itemSlug : product.slug }`; + const productDescription = + currentMinPrice > 0 + ? t('metaDesc', { + name: product.name, + brand: product.brand, + merchantCount: sellerCount, + }) + : t('metaDescNoStock', { + name: product.name, + brand: product.brand, + }); + const aggregateRating = + displayUserRating > 0 && authenticCount > 0 + ? { + '@type': 'AggregateRating', + ratingValue: Number(displayUserRating.toFixed(2)), + reviewCount: authenticCount, + bestRating: 5, + worstRating: 1, + } + : null; + const itemGroupNode = + itemPage && isItemGroup + ? buildItemProductGroupNode({ + itemKey: itemPage.item.item_key, + name: displayName, + brand: itemPage.item.brand || product.brand, + canonicalUrl, + description: + heroStats.minPrice > 0 + ? t('metaDesc', { + name: displayName, + brand: itemPage.item.brand || product.brand, + merchantCount: heroStats.sellerCount, + }) + : t('metaDescNoStock', { + name: displayName, + brand: itemPage.item.brand || product.brand, + }), + images: usableImageUrls(product.images).map(absoluteUrl), + variants: itemVariants.map((v) => { + const stats = productMarketStats(v.product); + return { + variantKey: v.variant.variant_key, + label: itemVariantLabel(v.variant), + minPrice: stats.minPrice, + maxPrice: stats.maxPrice, + sellerCount: stats.sellerCount, + }; + }), + aggregateRating, + }) + : null; + const structuredDataGraph = { '@context': 'https://schema.org', '@graph': [ @@ -321,12 +429,12 @@ export async function ProductDetailView({ { '@type': 'ListItem', position: 3, - name: product.name, + name: displayName, item: canonicalUrl, }, ], }, - { + itemGroupNode ?? { '@type': 'Product', name: product.name, brand: { @@ -335,17 +443,7 @@ export async function ProductDetailView({ }, sku: product.model_key, url: canonicalUrl, - description: - currentMinPrice > 0 - ? t('metaDesc', { - name: product.name, - brand: product.brand, - merchantCount: sellerCount, - }) - : t('metaDescNoStock', { - name: product.name, - brand: product.brand, - }), + description: productDescription, image: usableImageUrls(product.images).map(absoluteUrl), // Only with a buyable price: a lowPrice of 0 is not an offer. ...(currentMinPrice > 0 @@ -361,21 +459,173 @@ export async function ProductDetailView({ }, } : {}), - ...(displayUserRating > 0 && authenticCount > 0 - ? { - aggregateRating: { - '@type': 'AggregateRating', - ratingValue: Number(displayUserRating.toFixed(2)), - reviewCount: authenticCount, - bestRating: 5, - worstRating: 1, - }, - } - : {}), + ...(aggregateRating ? { aggregateRating } : {}), }, ], }; + // The history chart and the offer table of one size; the variant page + // renders them for its own master, an Item page once per size (#1154). + const renderPriceHistory = (v: { + product: { name: string }; + priceHistory: typeof priceHistory; + minPrice: number; + radarSignal: typeof radarSignal; + }) => ( + 0 ? v.minPrice : null, + }} + radarSignal={v.radarSignal} + /> + ); + const renderOffersBody = (v: { + product: { name: string; slug: string }; + offers: typeof offers; + priceHistory: typeof priceHistory; + radarSignal: typeof radarSignal; + emptyText: string; + }) => ( + <> +
+
+ + {t('offersEyebrow')} + +

+

+
+ + {t('offersMedianBadge')} + +
+ + {v.offers.length === 0 ? ( +

{v.emptyText}

+ ) : ( + + )} + + ); + const pageEmptyOffersText = saleStateKey + ? tSale(`${saleStateKey}Hint`) + : t('noOffersYet'); + const variantPanelInput = (v: ItemPageVariant) => ({ + product: v.product, + offers: v.offers, + priceHistory: v.priceHistory, + radarSignal: v.radarSignal, + minPrice: productMarketStats(v.product).minPrice, + emptyText: + v.product.id === product.id ? pageEmptyOffersText : t('noOffersYet'), + }); + // The price strip of one size: price, range, all-time low, price watch and + // the share card; an Item page renders it once per size (#1154). + const renderPriceStrip = (v: { + product: typeof product; + offers: typeof offers; + priceHistory: typeof priceHistory; + radarSignal: typeof radarSignal; + stats: typeof marketStats; + saleState: typeof saleState; + saleStateKey: typeof saleStateKey; + }) => ( +
+ ); + const variantStripInput = (v: ItemPageVariant) => { + const inStock = v.offers.filter((o) => !isOfferUnavailable(o)); + const stats = productMarketStats(v.product); + const sizeSaleState = masterSaleState({ + minPrice: stats.minPrice, + listingCount: listingCountForSaleState(v.offers.length, inStock.length), + metadata: v.product.metadata, + availabilityStatus: (v.product.specifications as ProductSpecifications) + ?.availability?.status, + }); + return { + product: v.product, + offers: v.offers, + priceHistory: v.priceHistory, + radarSignal: v.radarSignal, + stats, + saleState: sizeSaleState, + saleStateKey: + sizeSaleState === 'on_sale' ? null : saleStateLabelKey(sizeSaleState), + }; + }; + const defaultVariantKey = + renderedSize?.variant.variant_key ?? + (itemRoute?.kind === 'render' ? itemRoute.variantKey : ''); + return ( @@ -403,7 +653,7 @@ export async function ProductDetailView({
  • - {product.name} + {displayName}
  • @@ -456,7 +706,7 @@ export async function ProductDetailView({ {[product.brand, product.has_5g ? '5G' : ''] .filter(Boolean) .join(' · ')} - {product.variant_name && ( + {product.variant_name && !isItemGroup && ( <> {product.brand || product.has_5g ? ' · ' : ''} @@ -464,20 +714,39 @@ export async function ProductDetailView({ )}

    -

    {product.name}

    +

    {displayName}

    {/* Price in the first screen on phones, ahead of the scoreboard (DOS/Rate#605); the full price strip stays further down. */} - {currentMinPrice > 0 && sellerCount > 0 && ( + {heroStats.minPrice > 0 && heroStats.sellerCount > 0 && (

    {t.rich('heroPriceLine', { - price: minPriceFormatted, - count: sellerCount, + price: heroMinPriceFormatted, + count: heroStats.sellerCount, strong: (chunks) => ( {chunks} ), })}

    )} + {isItemGroup && ( + { + const stats = productMarketStats(v.product); + return { + key: v.variant.variant_key, + label: itemVariantLabel(v.variant), + priceLabel: + stats.minPrice > 0 + ? t('itemVariantPrice', { + price: stats.minPrice.toLocaleString('vi-VN'), + }) + : t('itemVariantNoPrice'), + }; + })} + /> + )} {/* Below lg the scoreboard starts past the first phone screen, so the buyer score is repeated next to the price (DOS/Rate#630). */} {displayUserRating > 0 && ( @@ -904,71 +1173,25 @@ export async function ProductDetailView({
    {/* Neutral Market Price Reference Strip (De-commercialized per DESIGN.md v3.0) */} - + {isItemGroup ? ( + ({ + key: v.variant.variant_key, + node: renderPriceStrip(variantStripInput(v)), + }))} + /> + ) : ( + renderPriceStrip({ + product, + offers, + priceHistory, + radarSignal, + stats: marketStats, + saleState, + saleStateKey, + }) + )}
    {t('devInfoToggle')} @@ -1083,51 +1306,70 @@ export async function ProductDetailView({ })()} {/* SECTION #3: Time-Series Price History Chart, Fake Discount Detector & Neutral Merchant Audit */} - 0 ? currentMinPrice : null, - }} - radarSignal={radarSignal} - /> + {isItemGroup ? ( + ({ + key: v.variant.variant_key, + node: renderPriceHistory(variantPanelInput(v)), + }))} + /> + ) : ( + renderPriceHistory({ + product, + priceHistory, + minPrice: currentMinPrice, + radarSignal, + }) + )} {/* Neutral Merchant Reference & Trust Audit Table */}
    -
    -
    - - {t('offersEyebrow')} - -

    -

    -
    - - {t('offersMedianBadge')} - -
    - - {offers.length === 0 ? ( -

    - {saleStateKey ? tSale(`${saleStateKey}Hint`) : t('noOffersYet')} -

    - ) : ( - ({ + key: v.variant.variant_key, + node: renderOffersBody(variantPanelInput(v)), + }))} /> + ) : ( + renderOffersBody({ + product, + offers, + priceHistory, + radarSignal, + emptyText: pageEmptyOffersText, + }) )}
    + {/* Listings whose size is not stated: their own group, never part + of the "from" price (Q2, DOS/Rate#1154). */} + {itemPage && itemPage.unknownOffers.length > 0 && ( +
    +

    + {t('unknownSizeTitle', { + count: itemPage.unknownOffers.length, + })} +

    +

    {t('unknownSizeNote')}

    + +
    + )} + {/* Interactive Value Efficiency & 2-Year Ownership Calculator (#62) */} {(() => { const lbScored = scoreMasterProductForLeaderboard( diff --git a/apps/web/src/components/product/item/ItemVariantPanels.tsx b/apps/web/src/components/product/item/ItemVariantPanels.tsx new file mode 100644 index 000000000..ff3740cb3 --- /dev/null +++ b/apps/web/src/components/product/item/ItemVariantPanels.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { Fragment, type ReactNode } from 'react'; +import { useSelectedVariant } from './selected-variant'; + +/** + * Shows the server-rendered panel of the selected size. The server renders + * every size's panel; the static HTML holds the default one (#1154). + */ +export default function ItemVariantPanels({ + panels, + defaultKey, +}: { + panels: readonly { key: string; node: ReactNode }[]; + defaultKey: string; +}) { + const selected = useSelectedVariant( + panels.map((p) => p.key), + defaultKey + ); + const panel = + panels.find((p) => p.key === selected) ?? + panels.find((p) => p.key === defaultKey); + // Keyed per size, so a panel's own state (a chosen offer, a filter) does + // not carry over to another size's data. + return panel ? {panel.node} : null; +} diff --git a/apps/web/src/components/product/item/ItemVariantPicker.tsx b/apps/web/src/components/product/item/ItemVariantPicker.tsx new file mode 100644 index 000000000..9c30f8d50 --- /dev/null +++ b/apps/web/src/components/product/item/ItemVariantPicker.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { selectVariant, useSelectedVariant } from './selected-variant'; + +export interface ItemVariantOption { + key: string; + label: string; + /** The size's "from" price, already formatted, or its no-price note. */ + priceLabel: string; +} + +/** Size chips of an Item page; each shows its own "from" price (#1154). */ +export default function ItemVariantPicker({ + options, + defaultKey, + label, +}: { + options: readonly ItemVariantOption[]; + defaultKey: string; + label: string; +}) { + const selected = useSelectedVariant( + options.map((o) => o.key), + defaultKey + ); + const current = options.find((o) => o.key === selected); + return ( + <> +
    + {options.map((option) => { + const active = option.key === selected; + return ( + + ); + })} +
    + {/* The price history and offers below switch with the size. */} + + {current ? `${current.label}: ${current.priceLabel}` : ''} + + + ); +} diff --git a/apps/web/src/components/product/item/selected-variant.ts b/apps/web/src/components/product/item/selected-variant.ts new file mode 100644 index 000000000..97c56f8e0 --- /dev/null +++ b/apps/web/src/components/product/item/selected-variant.ts @@ -0,0 +1,42 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; +import { cleanVariantKey } from '@/lib/item-page'; + +// The size on an Item page lives in `?v=` and is read in the browser only, so +// the page itself stays static (ISR) for every size (DOS/Rate#1154, Q1). +const VARIANT_EVENT = 'rate:item-variant'; + +function subscribe(onChange: () => void): () => void { + window.addEventListener('popstate', onChange); + window.addEventListener(VARIANT_EVENT, onChange); + return () => { + window.removeEventListener('popstate', onChange); + window.removeEventListener(VARIANT_EVENT, onChange); + }; +} + +function readVariantParam(): string | null { + return cleanVariantKey(new URLSearchParams(window.location.search).get('v')); +} + +/** The selected size: `?v=` when it names one of `keys`, else the default. */ +export function useSelectedVariant( + keys: readonly string[], + defaultKey: string +): string { + const requested = useSyncExternalStore( + subscribe, + readVariantParam, + () => null + ); + return requested && keys.includes(requested) ? requested : defaultKey; +} + +/** Selects a size: rewrites `?v=` in place (no navigation, no new entry). */ +export function selectVariant(key: string): void { + const url = new URL(window.location.href); + url.searchParams.set('v', key); + window.history.replaceState(window.history.state, '', url); + window.dispatchEvent(new Event(VARIANT_EVENT)); +} diff --git a/apps/web/src/lib/__tests__/product-market-stats.test.ts b/apps/web/src/lib/__tests__/product-market-stats.test.ts index 7b4eb1e59..2efa00dc7 100644 --- a/apps/web/src/lib/__tests__/product-market-stats.test.ts +++ b/apps/web/src/lib/__tests__/product-market-stats.test.ts @@ -163,7 +163,10 @@ async function main() { /computeProductRadarSignal\(product, inStockOffers\)/, 'The radar note never quotes a sold-out listing' ); - assert.match(page, /currentMinPrice: currentMinPrice > 0 \? currentMinPrice/); + // The history's "current lowest" is the "from" price of the size it shows: + // the page's own on a variant page, each size's on an Item page (#1154). + assert.match(page, /currentMinPrice: v\.minPrice > 0 \? v\.minPrice/); + assert.match(page, /minPrice: currentMinPrice,/); const productApi = read('../../app/api/v1/rate/products/[slug]/route.ts'); assert.match( diff --git a/apps/web/src/lib/item-page.ts b/apps/web/src/lib/item-page.ts new file mode 100644 index 000000000..c6004d463 --- /dev/null +++ b/apps/web/src/lib/item-page.ts @@ -0,0 +1,132 @@ +/** + * Pure helpers for the product page per Item (DOS/Rate#1154; design doc + * `Docs/Proposals/2026-10-27-item-variant-listing-model.md`, section 5.1). + */ + +/** The `?v=` value shape the redirects write (a literal `variant_key`). */ +const VARIANT_KEY_REGEX = /^[a-z0-9][a-z0-9-]{0,40}$/; + +export function cleanVariantKey(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const clean = raw.trim().toLowerCase(); + return VARIANT_KEY_REGEX.test(clean) ? clean : null; +} + +/** + * A variant's display label: its stored name, else its size from storage + * (1024 GB reads as 1TB), else the key itself. Labels are never keys (4.2). + */ +export function itemVariantLabel(variant: { + variant_key: string; + variant_name?: string | null; + storage_gb?: number | null; +}): string { + const name = variant.variant_name?.trim(); + if (name) return name; + const gb = Number(variant.storage_gb); + if (Number.isFinite(gb) && gb > 0) { + return gb >= 1024 && gb % 1024 === 0 ? `${gb / 1024}TB` : `${gb}GB`; + } + const sized = /^(\d+)(gb|tb)$/i.exec(variant.variant_key); + if (sized) { + const n = Number(sized[1]); + return sized[2]!.toLowerCase() === 'gb' && n >= 1024 && n % 1024 === 0 + ? `${n / 1024}TB` + : `${n}${sized[2]!.toUpperCase()}`; + } + return variant.variant_key; +} + +/** + * The Item's "from" stats over its sized variants only: the cheapest priced + * size, the top of the dearest, and the most sellers any one size has (a + * floor for distinct sellers). Listings whose size is not stated never count + * (Q2); the Item row's merchant_count includes them, so it is not used. + */ +export function itemFromStats( + variants: readonly { + min_price?: number | null; + max_price?: number | null; + merchant_count?: number | null; + }[] +): { minPrice: number; maxPrice: number; sellerCount: number } { + const priced = variants.filter((v) => Number(v.min_price) > 0); + if (priced.length === 0) return { minPrice: 0, maxPrice: 0, sellerCount: 0 }; + const minPrice = Math.min(...priced.map((v) => Number(v.min_price))); + const maxPrice = Math.max( + ...priced.map((v) => + Math.max(Number(v.max_price) || 0, Number(v.min_price)) + ) + ); + const sellerCount = Math.max( + 1, + ...priced.map((v) => Math.max(0, Math.floor(Number(v.merchant_count) || 0))) + ); + return { minPrice, maxPrice, sellerCount }; +} + +/** Category peers without the Item's own sizes: a sibling is no competitor. */ +export function withoutItemSiblings( + peers: readonly T[], + siblingSlugs: Iterable +): T[] { + const skip = new Set(siblingSlugs); + return peers.filter((peer) => !skip.has(peer.slug)); +} + +export interface ItemJsonLdVariant { + variantKey: string; + label: string; + minPrice: number; + maxPrice: number; + sellerCount: number; +} + +/** + * The schema.org `ProductGroup` node of an Item page: one Product per priced + * size, each with its own sku and AggregateOffer, and one Item-level rating + * (Q4). A size with no price is left out: a variant Product needs an offer. + * The variant URLs carry `?v=`, and the group URL is the canonical (Q1). + */ +export function buildItemProductGroupNode(input: { + itemKey: string; + name: string; + brand: string; + canonicalUrl: string; + description: string; + images: readonly string[]; + variants: readonly ItemJsonLdVariant[]; + aggregateRating?: Record | null; +}): Record { + return { + '@type': 'ProductGroup', + name: input.name, + brand: { '@type': 'Brand', name: input.brand }, + productGroupID: input.itemKey, + url: input.canonicalUrl, + description: input.description, + image: [...input.images], + variesBy: ['https://schema.org/size'], + hasVariant: input.variants + .filter((v) => v.minPrice > 0) + .map((v) => ({ + '@type': 'Product', + name: `${input.name} ${v.label}`, + sku: `${input.itemKey}:${v.variantKey}`, + inProductGroupWithID: input.itemKey, + size: v.label, + url: `${input.canonicalUrl}?v=${encodeURIComponent(v.variantKey)}`, + ...(input.images.length > 0 ? { image: [...input.images] } : {}), + offers: { + '@type': 'AggregateOffer', + priceCurrency: 'VND', + lowPrice: v.minPrice, + highPrice: Math.max(v.maxPrice, v.minPrice), + offerCount: v.sellerCount, + }, + })), + ...(input.aggregateRating + ? { aggregateRating: input.aggregateRating } + : {}), + }; +} diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 96f717278..f91a290e8 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -4714,7 +4714,7 @@ export function sortItemVariants( * NULL). The trust view has no `item_id` (#1242 leaves it unchanged), so the * listing ids come from rate.products and their offers from the view. */ -async function getItemUnknownVariantOffers( +export async function getItemUnknownVariantOffers( itemId: string ): Promise { const idRows = await fetchAllRows( diff --git a/apps/web/src/server/rate/__tests__/item-model.test.ts b/apps/web/src/server/rate/__tests__/item-model.test.ts index 3bd906a68..4ed67d480 100644 --- a/apps/web/src/server/rate/__tests__/item-model.test.ts +++ b/apps/web/src/server/rate/__tests__/item-model.test.ts @@ -246,6 +246,8 @@ async function main() { variantSlug: 'apple-iphone-16-128gb', itemSlug: 'apple-iphone-16', variantKey: '128gb', + item, + variants, }); assert.equal( (render(null) as { variantKey: string }).variantKey, @@ -258,6 +260,16 @@ async function main() { 'an unknown ?v= falls back to the default' ); assert.equal(render(null, []).kind, 'not_found'); + // A sold-out default size (256gb) gives way to the first priced one. + assert.equal( + ( + render( + null, + variants.map((v) => (v.id === V256 ? { ...v, min_price: 0 } : v)) + ) as { variantKey: string } + ).variantKey, + '128gb' + ); // A retired size redirects to the Item with no size preselected. assert.deepEqual( planItemRoute({ @@ -415,9 +427,25 @@ async function main() { ].map((c) => path.resolve(__dirname, `../../../app/[locale]/${c}/[slug]/page.tsx`) ); + // The Item page reads `?v=` in the browser (window.location), never through + // useSearchParams, which would need a Suspense bailout (#1154). + for (const file of [ + 'selected-variant.ts', + 'ItemVariantPicker.tsx', + 'ItemVariantPanels.tsx', + ].map((f) => + path.resolve(__dirname, `../../../components/product/item/${f}`) + )) { + assert.doesNotMatch( + fs.readFileSync(file, 'utf8'), + /useSearchParams|useRouter|searchParams:/, + file + ); + } for (const file of [ ...productRouteFiles, path.resolve(__dirname, '../item-route.ts'), + path.resolve(__dirname, '../item-page.ts'), ]) { const src = fs.readFileSync(file, 'utf8'); assert.doesNotMatch(src, /searchParams|\bheaders\(|\bcookies\(/, file); @@ -428,6 +456,115 @@ async function main() { assert.match(src, /export const revalidate = 300;/, file); assert.match(src, /export function generateStaticParams\(/, file); } + // The page renders a size that loaded: the planned one, else a priced one. + const { pickRenderedSize } = await import('../item-page'); + const size = (slug: string, min_price: number) => + ({ variant: { slug }, product: { min_price } }) as unknown as Parameters< + typeof pickRenderedSize + >[0][number]; + const loaded = [size('a-128gb', 0), size('a-256gb', 24990000)]; + assert.equal(pickRenderedSize(loaded, 'a-128gb')?.variant.slug, 'a-128gb'); + assert.equal(pickRenderedSize(loaded, 'a-512gb')?.variant.slug, 'a-256gb'); + assert.equal(pickRenderedSize([], 'a-512gb'), null); + // Item page helpers (#1154). + const page = await import('../../../lib/item-page'); + assert.equal( + page.itemVariantLabel({ variant_key: '256gb', storage_gb: 256 }), + '256GB' + ); + assert.equal( + page.itemVariantLabel({ variant_key: '1024gb', storage_gb: 1024 }), + '1TB' + ); + assert.equal(page.itemVariantLabel({ variant_key: '1tb' }), '1TB'); + assert.equal( + page.itemVariantLabel({ + variant_key: '256gb', + variant_name: '256GB Titan', + }), + '256GB Titan' + ); + assert.equal(page.itemVariantLabel({ variant_key: 'standard' }), 'standard'); + assert.equal(page.cleanVariantKey(' 256GB '), '256gb'); + assert.equal(page.cleanVariantKey('../x'), null); + assert.equal(page.cleanVariantKey(['256gb']), null); + assert.deepEqual( + page + .withoutItemSiblings( + [ + { slug: 'apple-iphone-16-128gb' }, + { slug: 'samsung-galaxy-s24-256gb' }, + ], + ['apple-iphone-16-128gb', 'apple-iphone-16-256gb'] + ) + .map((p) => p.slug), + ['samsung-galaxy-s24-256gb'], + 'same-Item sizes are no competitors' + ); + const group = page.buildItemProductGroupNode({ + itemKey: 'apple:iphone-16', + name: 'Apple iPhone 16', + brand: 'Apple', + canonicalUrl: 'https://rate.vn/smartphones/apple-iphone-16', + description: 'd', + images: [], + variants: [ + { + variantKey: '128gb', + label: '128GB', + minPrice: 21990000, + maxPrice: 23000000, + sellerCount: 3, + }, + { + variantKey: '256gb', + label: '256GB', + minPrice: 0, + maxPrice: 0, + sellerCount: 0, + }, + ], + aggregateRating: { + '@type': 'AggregateRating', + ratingValue: 4.5, + reviewCount: 10, + }, + }) as { + '@type': string; + url: string; + variesBy: string[]; + aggregateRating?: unknown; + hasVariant: { + sku: string; + url: string; + size: string; + offers?: { lowPrice: number; offerCount: number }; + }[]; + }; + assert.equal(group['@type'], 'ProductGroup'); + assert.equal(group.url, 'https://rate.vn/smartphones/apple-iphone-16'); + assert.deepEqual(group.variesBy, ['https://schema.org/size']); + assert.ok(group.aggregateRating, 'one Item-level rating'); + assert.equal(group.hasVariant[0]!.sku, 'apple:iphone-16:128gb'); + assert.equal( + group.hasVariant[0]!.url, + 'https://rate.vn/smartphones/apple-iphone-16?v=128gb' + ); + assert.equal(group.hasVariant[0]!.offers?.lowPrice, 21990000); + assert.equal(group.hasVariant.length, 1, 'a size with no price is left out'); + assert.deepEqual( + page.itemFromStats([ + { min_price: 24990000, max_price: 25990000, merchant_count: 2 }, + { min_price: 21990000, max_price: 23990000, merchant_count: 4 }, + { min_price: 0, max_price: 0, merchant_count: 0 }, + ]), + { minPrice: 21990000, maxPrice: 25990000, sellerCount: 4 } + ); + assert.deepEqual(page.itemFromStats([{ min_price: null }]), { + minPrice: 0, + maxPrice: 0, + sellerCount: 0, + }); console.warn('✅ Item web data layer suite passed'); } diff --git a/apps/web/src/server/rate/item-model.ts b/apps/web/src/server/rate/item-model.ts index 1348dabeb..b2906c2a6 100644 --- a/apps/web/src/server/rate/item-model.ts +++ b/apps/web/src/server/rate/item-model.ts @@ -1,6 +1,7 @@ import 'server-only'; import type { CatalogSlugResolution, + RateItem, RateItemVariant, } from '../../lib/rate-api'; @@ -57,6 +58,9 @@ export type ItemRoutePlan = variantSlug: string; itemSlug: string; variantKey: string; + item: RateItem; + /** Published variants in display order; empty for a shared-slug Item. */ + variants: readonly RateItemVariant[]; }; /** @@ -97,6 +101,10 @@ export function planItemRoute(input: { (requestedVariant ? variants.find((v) => v.variant_key === requestedVariant) : undefined) ?? + // The Item's default size while it has a price, else the first priced + // size, so the first view never says "sold out" beside a "from" price. + variants.find((v) => v.id === item.default_variant_id && v.min_price > 0) ?? + variants.find((v) => v.min_price > 0) ?? variants.find((v) => v.id === item.default_variant_id) ?? variants[0]; if (!chosen) return { kind: 'not_found' }; @@ -105,6 +113,8 @@ export function planItemRoute(input: { variantSlug: chosen.slug, itemSlug: item.slug, variantKey: chosen.variant_key, + item, + variants, }; } diff --git a/apps/web/src/server/rate/item-page.ts b/apps/web/src/server/rate/item-page.ts new file mode 100644 index 000000000..9e496a205 --- /dev/null +++ b/apps/web/src/server/rate/item-page.ts @@ -0,0 +1,130 @@ +import 'server-only'; +import { cache } from 'react'; +import { + getItemUnknownVariantOffers as _getItemUnknownVariantOffers, + type MasterProduct, + type MerchantOffer, + type MasterProductPriceHistorySummary, + type RateItem, + type RateItemVariant, +} from '../../lib/rate-api'; +import { + computeProductRadarSignal, + getCachedMasterProductBySlug, + getCachedMasterProductOffers, + getCachedMasterProductPriceHistory, +} from '@/server/rate'; +import type { RadarSignal } from '@/lib/deal-radar'; +import { isOfferUnavailable } from '@/lib/product-market-stats'; +import { createLogger } from '@/lib/logger'; +import { + RATE_CACHE_TAGS, + productCacheTag, + withRateDataCache, +} from './cache-tags'; + +const log = createLogger('server.item-page'); + +/** One size of an Item with the data its offers and history panels need. */ +export interface ItemPageVariant { + variant: RateItemVariant; + product: MasterProduct; + offers: MerchantOffer[]; + priceHistory: MasterProductPriceHistorySummary; + radarSignal: RadarSignal; +} + +export interface ItemPageContext { + item: RateItem; + /** Sizes with a published master, in display order. */ + variants: ItemPageVariant[]; + /** Listings of the Item whose size is not stated (Q2): never a "from". */ + unknownOffers: MerchantOffer[]; +} + +/** + * The size an Item URL renders: the planned one while its read succeeded, + * else the first loaded size with a price, else the first loaded size. Null + * when no size loaded, and the page reads the planned size as before. + */ +export function pickRenderedSize( + loaded: readonly ItemPageVariant[], + plannedSlug: string +): ItemPageVariant | null { + return ( + loaded.find((v) => v.variant.slug === plannedSlug) ?? + loaded.find((v) => Number(v.product.min_price) > 0) ?? + loaded[0] ?? + null + ); +} + +const _getCachedUnknownOffers = cache((itemId: string, itemSlug: string) => + withRateDataCache( + _getItemUnknownVariantOffers, + ['rate-item-unknown', itemId], + { + revalidate: 300, + tags: [ + RATE_CACHE_TAGS.PRODUCTS, + RATE_CACHE_TAGS.OFFERS, + productCacheTag(itemSlug), + ], + } + )(itemId) +); + +/** + * Everything the Item page shows per size, read through the same cached + * per-master readers as the variant pages (DOS/Rate#1154). A size whose + * master is gone, or whose read fails, is left out rather than shown with no + * offers; so is the "storage not stated" group when its read fails. The size + * the page renders is read again by the page itself, which fails as before. + */ +export async function loadItemPageContext( + item: RateItem, + variants: readonly RateItemVariant[] +): Promise { + const [loaded, unknownOffers] = await Promise.all([ + Promise.all( + variants.map(async (variant): Promise => { + try { + const product = await getCachedMasterProductBySlug(variant.slug); + if (!product) return null; + const offers = await getCachedMasterProductOffers(product.id); + const priceHistory = await getCachedMasterProductPriceHistory( + product.id, + offers + ); + const inStock = offers.filter((o) => !isOfferUnavailable(o)); + return { + variant, + product, + offers, + priceHistory, + radarSignal: computeProductRadarSignal(product, inStock), + }; + } catch (err) { + log.error('item size read failed; size left out', { + item: item.slug, + variant: variant.slug, + message: err instanceof Error ? err.message : String(err), + }); + return null; + } + }) + ), + _getCachedUnknownOffers(item.id, item.slug).catch((err: unknown) => { + log.error('unknown-size offers read failed; group left out', { + item: item.slug, + message: err instanceof Error ? err.message : String(err), + }); + return [] as MerchantOffer[]; + }), + ]); + return { + item, + variants: loaded.filter((v): v is ItemPageVariant => v !== null), + unknownOffers, + }; +} diff --git a/apps/web/src/server/rate/item-route.ts b/apps/web/src/server/rate/item-route.ts index bdd027ebc..0aa10da38 100644 --- a/apps/web/src/server/rate/item-route.ts +++ b/apps/web/src/server/rate/item-route.ts @@ -50,8 +50,13 @@ export async function resolveItemRoute(input: { if (!isItemModelOn()) return null; try { const resolution = await _getCachedCatalogSlug(input.slug); + // Variants are read only for a URL that renders: an Item URL, or a + // single-variant Item sharing its variant's slug. + const renders = + resolution?.kind === 'item' || + (resolution?.kind === 'variant' && resolution.item.slug === input.slug); const variants = - resolution?.kind === 'item' + renders && resolution.item ? await getCachedItemVariants(resolution.item) : []; const plan = planItemRoute({ From 018ad882d9a098ba1c8f2e1f30bc5ac1a86541d9 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:27:13 +0700 Subject: [PATCH 44/69] feat(api): item fields, group=item and an item-aware extension lookup (#1274) * feat(api): item fields, group=item and an item-aware extension lookup Design task 11 (DOS/Rate#1158, design doc 5.2), behind RATE_ITEM_MODEL; with the flag off, or the Item tables missing, responses are unchanged. - /products/{slug} takes an Item or old variant slug and ?v=; product is the selected variant and the response adds item, variants, selected_variant and unknown_variant_offers. - /products?group=item lists Items with their variants (category, brands, q, price and sort; spec filters answer 400 until search_items_v1). The default stays the variant list (Q7). - extension lookup: every size of the Item from the DB, no invented 256GB, the Item URL, a stated size selects its variant, and a listing with no variant (cluster_id NULL) is matched by item_id. - OpenAPI: RateItem and RateItemVariant schemas, group and v params, and the slug description no longer claims a model_key. - web /watchlist?add= adds the product once (the extension link), and the side panel adds nothing while the size is unknown. * fix(api): choose the item variant from the listing, then the page Review round 1 on DOS/Rate#1158: - extension: the listing's own variant wins (an exact listing keeps it), then the one variant of a stated size, then a single-variant Item; a stated size the Item lacks is ambiguous and gets no verdict; an item-only listing gets the same choice; a listing whose Item has no published variant blocks the catalogue guess; - /products/{slug}: the Item fields are read before the product, and a missing products.item_id (unknown-variant offers) falls back to the whole response as before; - group=item: spec filters answer 400 only once the Item tables answered; a price filter skips Items with no price; - OpenAPI: the Item docs are added only while the flag is on; - /watchlist?add=: safe under a second effect run (Strict Mode); the parameter is dropped after the add; - tests: listing/size/single-variant/no-variant cases, flag-off lookup fields, OpenAPI with the flag off, fallbacks with tables missing. Refs #1158 * fix(api): check the item tables before an empty group=item list Codex round 2 on DOS/Rate#1158: an unknown category answered an empty Item list before the missing-tables fallback, so a spec filter got 400 where the variant list answers 200. Refs #1158 * Merge remote-tracking branch 'origin/dev' into feat/1158-api-item-fields # Conflicts: # apps/web/package.json # apps/web/src/lib/rate-api.ts Co-Authored-By: Claude Opus 5.5 --- apps/extension/src/sidepanel/sidepanel.ts | 7 +- apps/extension/src/types.ts | 13 + apps/web/package.json | 2 +- .../api/v1/rate/__tests__/item-fields.test.ts | 498 ++++++++++++++++++ .../app/api/v1/rate/products/[slug]/route.ts | 26 +- .../web/src/app/api/v1/rate/products/route.ts | 87 +++ .../watchlist/WatchlistWorkspace.tsx | 40 ++ apps/web/src/lib/rate-api.ts | 131 ++++- apps/web/src/server/rate/extension.ts | 116 +++- apps/web/src/server/rate/item-api.ts | 372 +++++++++++++ apps/web/src/server/rate/openapi.ts | 142 ++++- 11 files changed, 1398 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/app/api/v1/rate/__tests__/item-fields.test.ts create mode 100644 apps/web/src/server/rate/item-api.ts diff --git a/apps/extension/src/sidepanel/sidepanel.ts b/apps/extension/src/sidepanel/sidepanel.ts index 2a658e817..78c848455 100644 --- a/apps/extension/src/sidepanel/sidepanel.ts +++ b/apps/extension/src/sidepanel/sidepanel.ts @@ -586,9 +586,12 @@ function renderPanelContent( )}`, '/inspect' ); + // Alerts are per size: an Item page with no stated size adds nothing until + // a size is picked (DOS/Rate#1158). + const sizeUnknown = Boolean(lookup.item) && !lookup.selectedVariant; const watchlistUrl = resolveSafeApiRelativeUrl( settings.apiBaseUrl, - master?.slug + master?.slug && !sizeUnknown ? `/watchlist?add=${encodeURIComponent(master.slug)}` : '/watchlist', '/watchlist' @@ -612,7 +615,7 @@ function renderPanelContent( ${escapeHtml(assessment.badgeLabel)} ${radarPillHtml}
    -
    ${escapeHtml(master?.name || signal.title)}
    +
    ${escapeHtml((sizeUnknown ? lookup.item?.name : master?.name) || signal.title)}

    ${escapeHtml(assessment.explanation)}

    diff --git a/apps/extension/src/types.ts b/apps/extension/src/types.ts index 613c94262..b63ed7f9d 100644 --- a/apps/extension/src/types.ts +++ b/apps/extension/src/types.ts @@ -147,6 +147,19 @@ export interface ExtensionLookupData { 'smartphones' | 'tablets' | 'laptops' | 'smartwatches' | 'earbuds'; compareUrl: string | null; productDetailUrl: string | null; + /** + * Set when rate.vn runs the Item model (DOS/Rate#1158): the product line + * across sizes, and the size the page states (null: not stated). + */ + item?: { + id: string; + slug: string; + name: string; + min_price: number; + max_price: number; + price_variant_count: number; + }; + selectedVariant?: string | null; leaderboardUrl?: string | null; versusUrl?: string | null; topRivalName?: string | null; diff --git a/apps/web/package.json b/apps/web/package.json index 243b0a433..da529f1e2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/api/v1/rate/__tests__/item-fields.test.ts b/apps/web/src/app/api/v1/rate/__tests__/item-fields.test.ts new file mode 100644 index 000000000..083a61be1 --- /dev/null +++ b/apps/web/src/app/api/v1/rate/__tests__/item-fields.test.ts @@ -0,0 +1,498 @@ +/** + * API v1 and the extension lookup under the Item model (DOS/Rate#1158, + * design doc 5.2), against a stubbed PostgREST in the M1+M2 shape (#1150): + * - flag off: no Item read, and the same bytes as before (no new keys); + * - flag on, Item tables missing: the same bytes as flag off; + * - flag on: `/products/{slug}` adds `item`, `variants`, `selected_variant` + * and `unknown_variant_offers`; `/products?group=item` lists Items; + * - the extension lookup returns every size of the Item from the DB, no + * invented 256GB, the Item URL, and a listing with no stated size. + */ +import assert from 'node:assert/strict'; + +process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://stub.supabase.test'; +process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon-test-key'; + +const ITEM_ID = '11111111-1111-4111-8111-111111111158'; +const V128 = '22222222-2222-4222-8222-222222222128'; +const V256 = '22222222-2222-4222-8222-222222222256'; +const UNKNOWN_LISTING = '33333333-3333-4333-8333-333333333333'; +const MERCHANT = '44444444-4444-4444-8444-444444444444'; + +const itemRow = { + id: ITEM_ID, + item_key: 'apple:iphone-16', + slug: 'apple-iphone-16', + name: 'Apple iPhone 16', + brand: 'Apple', + model: 'iPhone 16', + category_id: null, + default_variant_id: V256, + min_price: 21990000, + max_price: 25990000, + price_variant_count: 2, + merchant_count: 3, + listing_count: 4, + unknown_variant_listing_count: 1, + updated_at: '2026-10-29T00:00:00Z', +}; + +// A published variant row: the master's own columns plus the M2 ones. +const master = (id: string, gb: number) => ({ + id, + item_id: ITEM_ID, + slug: `apple-iphone-16-${gb}gb`, + name: `Apple iPhone 16 ${gb}GB`, + brand: 'Apple', + model: 'iPhone 16', + status: 'published', + variant_key: `${gb}gb`, + variant_name: `${gb}GB`, + sort_order: null, + storage_gb: gb, + ram_gb: 8, + min_price: gb === 128 ? 21990000 : 24990000, + max_price: gb === 128 ? 23990000 : 25990000, + merchant_count: 2, + specifications: { category_slug: 'smartphones' }, + key_specs: {}, +}); +const M128 = master(V128, 128); +const M256 = master(V256, 256); + +let calls: string[] = []; +let schemaMissing = false; +// Only `products.item_id` is missing (the unknown-variant offers read). +let offersSchemaMissing = false; +// The Item's published variants: both sizes, one, or none. +let variantSet: 'both' | 'one' | 'none' = 'both'; +const OTHER = { + ...master('55555555-5555-4555-8555-555555555555', 256), + item_id: null, + slug: 'samsung-galaxy-s25-256gb', + name: 'Samsung Galaxy S25 256GB', + brand: 'Samsung', + model: 'Galaxy S25', +}; +// What the catalogue search RPC answers (the extension's model guess). +let catalog: unknown[] = []; + +globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + ); + const q = decodeURIComponent(url.search); + calls.push(`${url.pathname}?${q}`); + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json', 'content-range': '0-0/*' }, + }); + if (Number(url.searchParams.get('offset') ?? 0) > 0) return json([]); + const itemTable = + url.pathname === '/rest/v1/items' || + (url.pathname === '/rest/v1/master_products' && + (q.includes('item_id') || q.includes('variant_key'))) || + (url.pathname === '/rest/v1/products' && q.includes('item_id')); + if ( + (schemaMissing && itemTable) || + (offersSchemaMissing && + url.pathname === '/rest/v1/products' && + q.includes('cluster_id=is.null') && + q.includes('item_id=eq.')) + ) { + return new Response( + JSON.stringify({ + code: 'PGRST205', + message: "Could not find the table 'rate.items' in the schema cache", + }), + { status: 404, headers: { 'content-type': 'application/json' } } + ); + } + switch (url.pathname) { + case '/rest/v1/rpc/search_master_products': + return json(catalog); + case '/rest/v1/items': + if (/slug=eq\.apple-iphone-16(&|$)/.test(q)) return json([itemRow]); + if (q.includes(`id=eq.${ITEM_ID}`)) return json([itemRow]); + // The Item list: published only. + if (q.includes('status=eq.published') && !q.includes('slug=')) + return json([itemRow]); + return json([]); + case '/rest/v1/master_products': { + const variants = + variantSet === 'both' + ? [M256, M128] + : variantSet === 'one' + ? [M256] + : []; + if (q.includes('slug=eq.apple-iphone-16-128gb')) return json([M128]); + if (q.includes('slug=eq.apple-iphone-16-256gb')) return json([M256]); + if (q.includes(`slug=eq.${OTHER.slug}`)) return json([OTHER]); + if (q.includes(`id=eq.${V128}`)) return json([M128]); + if ( + q.includes(`item_id=eq.${ITEM_ID}`) || + q.includes(`item_id=in.(${ITEM_ID})`) + ) + return json(variants); + return json([]); + } + case '/rest/v1/products': + // A listing on the 128GB variant. + if (q.includes('111222333') && q.includes('cluster_id=not.is.null')) + return json([{ cluster_id: V128, platform_item_id: '111222333' }]); + if ( + q.includes('cluster_id=is.null') && + q.includes(`item_id=eq.${ITEM_ID}`) + ) + return json([{ id: UNKNOWN_LISTING, availability: 'in_stock' }]); + if (q.includes('cluster_id=is.null') && q.includes('platform_item_id')) + return json( + q.includes('999888777') + ? [{ item_id: ITEM_ID, platform_item_id: '999888777' }] + : [] + ); + return json([]); + case '/rest/v1/v_products_with_merchant_trust': + if (q.includes(UNKNOWN_LISTING)) + return json([ + { + id: UNKNOWN_LISTING, + cluster_id: null, + merchant_id: MERCHANT, + platform: 'shopee', + title: 'iPhone 16 chính hãng', + price_current: 20990000, + price_original: null, + stock: 5, + rating_star: 5, + rating_count: 1, + url: 'https://shopee.vn/product/1/999888777', + merchant_name: 'Shop', + merchant_is_official: false, + merchant_trust_score: 90, + }, + ]); + return json([]); + default: + return json([]); + } +}) as typeof fetch; + +async function detail(slug: string, query = '') { + const { NextRequest } = await import('next/server'); + const route = await import('../products/[slug]/route'); + calls = []; + const res = await route.GET( + new NextRequest(`http://localhost/api/v1/rate/products/${slug}${query}`), + { params: Promise.resolve({ slug }) } as never + ); + return { status: res.status, text: await res.text() }; +} + +async function list(query: string) { + const { NextRequest } = await import('next/server'); + const route = await import('../products/route'); + calls = []; + const res = await route.GET( + new NextRequest(`http://localhost/api/v1/rate/products?${query}`) + ); + return { status: res.status, text: await res.text() }; +} + +const setFlag = (value: string | undefined) => { + if (value === undefined) delete process.env.RATE_ITEM_MODEL; + else process.env.RATE_ITEM_MODEL = value; +}; + +async function testFlagOffUnchanged() { + setFlag(undefined); + const off = await detail('apple-iphone-16-128gb'); + assert.equal(off.status, 200); + assert.ok(!calls.some((c) => c.startsWith('/rest/v1/items'))); + const body = JSON.parse(off.text); + for (const key of [ + 'item', + 'variants', + 'selected_variant', + 'unknown_variant_offers', + ]) { + assert.ok(!(key in body.data), `flag off adds no ${key}`); + } + // An Item slug is no product while the flag is off. + assert.equal((await detail('apple-iphone-16')).status, 404); + + // Flag on but M1+M2 missing: the same bytes as flag off. + setFlag('item'); + schemaMissing = true; + const missing = await detail('apple-iphone-16-128gb'); + assert.equal(missing.text, off.text); + // `group=item` with a spec filter: the variant list as before, not a 400. + setFlag(undefined); + catalog = [M128]; + const offFiltered = await list('category=all&group=item&ram=8'); + setFlag('item'); + const missingFiltered = await list('category=all&group=item&ram=8'); + assert.equal(missingFiltered.status, 200); + assert.equal(missingFiltered.text, offFiltered.text); + // Also for a category with no row: the tables are checked before an empty list. + setFlag(undefined); + const offCategory = await list('category=smartphones&group=item&ram=8'); + setFlag('item'); + assert.equal( + (await list('category=smartphones&group=item&ram=8')).text, + offCategory.text + ); + catalog = []; + schemaMissing = false; + // Only the unknown-variant offers read fails: the whole response is as before. + offersSchemaMissing = true; + assert.equal((await detail('apple-iphone-16-128gb')).text, off.text); + offersSchemaMissing = false; + + // `group=item` with the flag off answers the variant list, byte for byte. + setFlag(undefined); + catalog = [M128, M256]; + const plain = await list('category=all'); + const grouped = await list('category=all&group=item'); + assert.equal(grouped.text, plain.text); + + const { lookupExtensionProduct } = await import('@/server/rate/extension'); + calls = []; + const lookup = await lookupExtensionProduct({ + title: 'Apple iPhone 16', + mode: 'pill', + }); + assert.ok(!('item' in lookup) && !('selectedVariant' in lookup)); + // Today's lookup: siblings from the search hits, the first hit's page. + assert.equal(lookup.productDetailUrl, '/smartphones/apple-iphone-16-128gb'); + assert.equal(lookup.matchStatus, 'ambiguous_variant'); + assert.deepEqual( + lookup.variantCandidates.map((v) => [v.storageGb, v.slug]), + [ + [128, 'apple-iphone-16-128gb'], + [256, 'apple-iphone-16-256gb'], + ] + ); + + // The OpenAPI document is unchanged with the flag off. + const { buildRateOpenApiSpec } = await import('@/server/rate/openapi'); + const offSpec = JSON.stringify(buildRateOpenApiSpec('https://rate.vn')); + assert.ok(!offSpec.includes('RateItem')); + setFlag('item'); + const onSpec = buildRateOpenApiSpec('https://rate.vn'); + assert.ok(onSpec.components.schemas.RateItem); + assert.ok( + onSpec.paths['/api/v1/rate/products']!.get!.parameters!.some( + (p) => p.name === 'group' + ) + ); + setFlag(undefined); + assert.equal( + JSON.stringify(buildRateOpenApiSpec('https://rate.vn')), + offSpec + ); + assert.ok(!calls.some((c) => c.startsWith('/rest/v1/items'))); + catalog = []; + console.warn( + ' ✅ Flag off (and flag on without M1+M2): responses unchanged' + ); +} + +async function testDetailItemFields() { + setFlag('item'); + // An Item slug answers its default variant (256GB) with the Item fields. + let res = await detail('apple-iphone-16'); + assert.equal(res.status, 200); + let data = JSON.parse(res.text).data; + assert.equal(data.product.slug, 'apple-iphone-16-256gb'); + assert.equal(data.item.slug, 'apple-iphone-16'); + assert.equal(data.item.unknown_variant_listing_count, 1); + assert.ok(!('default_variant_id' in data.item), 'internal columns stay out'); + assert.deepEqual( + data.variants.map((v: { variant_key: string }) => v.variant_key), + ['128gb', '256gb'] + ); + assert.equal(data.selected_variant, '256gb'); + assert.equal(data.unknown_variant_offers.length, 1); + assert.equal(data.unknown_variant_offers[0].id, UNKNOWN_LISTING); + + // `?v=` picks the size; an old variant slug keeps its own. + data = JSON.parse((await detail('apple-iphone-16', '?v=128gb')).text).data; + assert.equal(data.product.slug, 'apple-iphone-16-128gb'); + assert.equal(data.selected_variant, '128gb'); + data = JSON.parse((await detail('apple-iphone-16-128gb')).text).data; + assert.equal(data.product.slug, 'apple-iphone-16-128gb'); + assert.equal(data.item.slug, 'apple-iphone-16'); + // An unknown `?v=` falls back to the slug's own size. + data = JSON.parse( + (await detail('apple-iphone-16-128gb', '?v=9tb')).text + ).data; + assert.equal(data.selected_variant, '128gb'); + res = await detail('apple-iphone-17'); + assert.equal(res.status, 404); + console.warn(' ✅ /products/{slug} adds the Item fields with the flag on'); +} + +async function testGroupItem() { + setFlag('item'); + let res = await list('category=all&group=item'); + assert.equal(res.status, 200); + let body = JSON.parse(res.text); + assert.equal(body.group, 'item'); + assert.equal(body.data.length, 1); + assert.equal(body.data[0].slug, 'apple-iphone-16'); + assert.deepEqual( + body.data[0].variants.map((v: { variant_key: string }) => v.variant_key), + ['128gb', '256gb'] + ); + const itemsCall = calls.find((c) => c.startsWith('/rest/v1/items'))!; + assert.match(itemsCall, /status=eq\.published/); + + // Filters reach the Item query; spec filters are per variant: 400. + res = await list( + 'category=all&group=item&q=iphone%2516&minPrice=1000000&sort=price_asc' + ); + assert.equal(res.status, 200); + const filtered = calls.find((c) => c.startsWith('/rest/v1/items'))!; + // The "%" the caller sent is no wildcard: only the two the query adds. + assert.match(filtered, /name=ilike\.%iphone\+16%&/); + assert.match(filtered, /min_price=gte\.1000000/); + assert.match(filtered, /order=min_price\.asc/); + for (const query of [ + 'ram=8', + 'has5g=1', + 'sort=rating_desc', + 'storageGb=256', + ]) { + res = await list(`category=all&group=item&${query}`); + assert.equal(res.status, 400, query); + assert.match(JSON.parse(res.text).error, /group=item/, query); + } + + // Without `group=item`, the flag-on list is the variant list. + catalog = [M128]; + body = JSON.parse((await list('category=all')).text); + assert.equal(body.group, undefined); + assert.equal(body.data[0].slug, 'apple-iphone-16-128gb'); + catalog = []; + console.warn(' ✅ /products?group=item lists Items with the flag on'); +} + +async function testExtensionLookup() { + setFlag('item'); + const { lookupExtensionProduct } = await import('@/server/rate/extension'); + // No size in the title: every size of the Item, none chosen, the Item URL. + catalog = [M128]; + let lookup = await lookupExtensionProduct({ + title: 'Apple iPhone 16', + mode: 'pill', + }); + assert.equal(lookup.item?.slug, 'apple-iphone-16'); + assert.equal(lookup.selectedVariant, null); + assert.equal(lookup.matchStatus, 'ambiguous_variant'); + assert.deepEqual( + lookup.variantCandidates.map((v) => [v.storageGb, v.label]), + [ + [128, '128GB'], + [256, '256GB'], + ] + ); + assert.equal(lookup.productDetailUrl, '/smartphones/apple-iphone-16'); + + // A stated size picks that variant, even when the search guessed another. + lookup = await lookupExtensionProduct({ + title: 'Apple iPhone 16 256GB', + mode: 'pill', + }); + assert.equal(lookup.masterProduct?.slug, 'apple-iphone-16-256gb'); + assert.equal(lookup.selectedVariant, '256gb'); + assert.notEqual(lookup.matchStatus, 'ambiguous_variant'); + assert.equal(lookup.productDetailUrl, '/smartphones/apple-iphone-16?v=256gb'); + + // A listing with no variant (cluster_id NULL) is still its Item's. + catalog = []; + lookup = await lookupExtensionProduct({ + url: 'https://shopee.vn/product/1/999888777', + title: 'iPhone 16 chính hãng', + mode: 'pill', + }); + assert.equal(lookup.item?.slug, 'apple-iphone-16'); + assert.equal(lookup.selectedVariant, null); + assert.equal(lookup.matchStatus, 'ambiguous_variant'); + assert.equal(lookup.masterProduct?.slug, 'apple-iphone-16-256gb'); + assert.equal(lookup.variantCandidates.length, 2); + + // ... and a size the page states selects that variant. + lookup = await lookupExtensionProduct({ + url: 'https://shopee.vn/product/1/999888777', + title: 'iPhone 16 128GB chính hãng', + mode: 'pill', + }); + assert.equal(lookup.masterProduct?.slug, 'apple-iphone-16-128gb'); + assert.equal(lookup.selectedVariant, '128gb'); + assert.notEqual(lookup.matchStatus, 'ambiguous_variant'); + + // A listing on a variant keeps it, whatever size its title names. + lookup = await lookupExtensionProduct({ + url: 'https://shopee.vn/product/1/111222333', + title: 'iPhone 16 256GB chính hãng', + mode: 'pill', + }); + assert.equal(lookup.masterProduct?.slug, 'apple-iphone-16-128gb'); + assert.equal(lookup.selectedVariant, '128gb'); + + // A size the Item does not have is no size: no variant judges the page. + catalog = [M128]; + lookup = await lookupExtensionProduct({ + title: 'Apple iPhone 16 512GB', + price: 30000000, + mode: 'pill', + }); + assert.equal(lookup.matchStatus, 'ambiguous_variant'); + assert.equal(lookup.selectedVariant, null); + // An ambiguous size gets no market verdict. + assert.equal(lookup.priceAssessment.verdict, 'insufficient_data'); + + // A single-variant Item: its one size is the size. + variantSet = 'one'; + catalog = [M256]; + lookup = await lookupExtensionProduct({ + title: 'Apple iPhone 16', + mode: 'pill', + }); + assert.equal(lookup.selectedVariant, '256gb'); + assert.notEqual(lookup.matchStatus, 'ambiguous_variant'); + + // The listing's Item has no published variant: no guess at another product. + variantSet = 'none'; + catalog = [OTHER]; + lookup = await lookupExtensionProduct({ + url: 'https://shopee.vn/product/1/999888777', + title: 'Samsung Galaxy S25 256GB', + mode: 'pill', + }); + assert.equal(lookup.matchStatus, 'not_found'); + assert.equal(lookup.masterProduct, null); + variantSet = 'both'; + catalog = []; + setFlag(undefined); + console.warn(' ✅ Extension lookup returns the Item and all its sizes'); +} + +async function main() { + console.warn('🧪 API v1 Item fields suite (DOS/Rate#1158)'); + await testFlagOffUnchanged(); + await testDetailItemFields(); + await testGroupItem(); + await testExtensionLookup(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts index 5c2fb13c4..f23dab173 100644 --- a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts +++ b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts @@ -15,6 +15,10 @@ import { PUBLIC_API_CORS_HEADERS, jsonApiError, } from '@/server/rate/api-handler'; +import { + buildApiItemFields, + resolveApiItemContext, +} from '@/server/rate/item-api'; type RouteContext = { params: Promise<{ slug: string }>; @@ -35,8 +39,13 @@ export async function OPTIONS() { * The product carries the category's spec view, as rate.vn shows it: no * screen, RAM, storage or camera for earbuds and no 5G for a watch * (DOS/Rate#1166). + * With `RATE_ITEM_MODEL` on, the slug may be an Item's or an old variant's; + * `product` is the selected variant (`?v=`, the slug's own, else the Item's + * default) and the response adds `item`, `variants`, `selected_variant` and + * `unknown_variant_offers` (DOS/Rate#1158). With the flag off the response + * is unchanged. */ -export async function GET(_request: NextRequest, context: RouteContext) { +export async function GET(request: NextRequest, context: RouteContext) { try { const { slug } = await context.params; const cleanSlug = (slug ?? '').trim(); @@ -53,7 +62,19 @@ export async function GET(_request: NextRequest, context: RouteContext) { ); } - const storedProduct = await getCachedMasterProductBySlug(cleanSlug); + let itemContext = await resolveApiItemContext( + cleanSlug, + request.nextUrl?.searchParams?.get('v') + ); + // Read before the product, so missing Item columns fall back to the + // whole response as before, not only to fewer fields. + const itemFields = itemContext + ? await buildApiItemFields(itemContext) + : null; + if (!itemFields) itemContext = null; + const storedProduct = await getCachedMasterProductBySlug( + itemContext ? itemContext.selected.slug : cleanSlug + ); if (!storedProduct) { return NextResponse.json( @@ -101,6 +122,7 @@ export async function GET(_request: NextRequest, context: RouteContext) { reviewSummary, reviews, radarSignal, + ...(itemFields ?? {}), }, }, { diff --git a/apps/web/src/app/api/v1/rate/products/route.ts b/apps/web/src/app/api/v1/rate/products/route.ts index e6d298700..f1ec30185 100644 --- a/apps/web/src/app/api/v1/rate/products/route.ts +++ b/apps/web/src/app/api/v1/rate/products/route.ts @@ -22,6 +22,8 @@ import { specFiltersOutsideCategory, } from '@/server/rate/api-handler'; import type { MasterProduct } from '@/lib/rate-api'; +import { listApiItems } from '@/server/rate/item-api'; +import { isItemModelOn } from '@/server/rate/item-model'; export async function OPTIONS() { return new NextResponse(null, { @@ -71,6 +73,76 @@ async function getAllCategoryProductsWithSpecFilters( return kept.slice(offset, wanted); } +const ITEM_GROUP_SORTS = new Set([ + 'default', + 'price_asc', + 'price_desc', + 'merchants_desc', + 'newest', +] as const); + +/** + * `group=item`, or null to answer the variant list (flag off, or the Item + * tables missing). Spec filters and rating sort are per variant until + * `search_items_v1` (#1155), so they answer 400 here. + */ +async function answerItemGroup( + categorySlug: string, + filterParams: MasterProductFilterParams +): Promise { + if (!isItemModelOn()) return null; + const specFilters = [ + filterParams.minRating !== undefined && 'minRating', + filterParams.ramGb?.length && 'ramGb', + filterParams.storageGb?.length && 'storageGb', + filterParams.minBatteryMah !== undefined && 'minBatteryMah', + filterParams.minRefreshRateHz !== undefined && 'minRefreshHz', + filterParams.minFastChargingW !== undefined && 'minFastChargeW', + filterParams.has5g && 'has5g', + filterParams.hasNfc && 'hasNfc', + filterParams.maxScreenSizeInch !== undefined && 'compactScreen', + filterParams.sort === 'rating_desc' && 'sort=rating_desc', + ].filter((f): f is string => typeof f === 'string'); + const sort = filterParams.sort ?? 'default'; + const rows = await listApiItems(categorySlug, { + brands: filterParams.brands, + search: filterParams.search, + minPrice: filterParams.minPrice, + maxPrice: filterParams.maxPrice, + sort: ITEM_GROUP_SORTS.has(sort as never) ? (sort as 'default') : 'default', + limit: filterParams.limit ?? 24, + offset: filterParams.offset ?? 0, + }); + // Only once the Item tables answered: without them the request is the + // variant list as before, filters included (DOS/Rate#1158). + if (rows === null) return null; + if (specFilters.length > 0) { + return jsonApiError( + 400, + `Filters not available with group=item: ${specFilters.join(', ')}`, + undefined, + PUBLIC_API_CORS_HEADERS + ); + } + return NextResponse.json( + { + ok: true, + category: categorySlug, + group: 'item', + count: rows.length, + offset: filterParams.offset ?? 0, + data: rows, + }, + { + status: 200, + headers: { + ...PUBLIC_API_CORS_HEADERS, + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + }, + } + ); +} + const VALID_PRODUCT_SORTS = new Set< NonNullable >([ @@ -93,6 +165,11 @@ const VALID_PRODUCT_SORTS = new Set< * (DOS/Rate#1184). On `category=all` such a filter keeps only products whose * own category has it: `has5g=1` returns 5G phones, tablets and laptops, * never watches or earbuds. + * With `RATE_ITEM_MODEL` on, `group=item` lists Items (one row per product + * line, with its variants) instead of variants; it takes the category, brand, + * search, price and sort filters, and answers 400 for spec filters, which + * stay per variant. Without `group=item`, or with the flag off, the response + * is the variant list as before (DOS/Rate#1158, Q7). */ export async function GET(request: NextRequest) { try { @@ -171,6 +248,16 @@ export async function GET(request: NextRequest) { offset, }; + if ( + (sp.get('group') ?? '') + .replace(/\p{Cc}+/gu, '') + .trim() + .toLowerCase() === 'item' + ) { + const itemResponse = await answerItemGroup(categorySlug, filterParams); + if (itemResponse) return itemResponse; + } + const unsupportedFilters = specFiltersOutsideCategory( categorySlug, filterParams diff --git a/apps/web/src/components/watchlist/WatchlistWorkspace.tsx b/apps/web/src/components/watchlist/WatchlistWorkspace.tsx index d967eab73..3f338ec91 100644 --- a/apps/web/src/components/watchlist/WatchlistWorkspace.tsx +++ b/apps/web/src/components/watchlist/WatchlistWorkspace.tsx @@ -182,6 +182,46 @@ export default function WatchlistWorkspace({ }); }; + // `/watchlist?add=` (the browser extension's link, DOS/Rate#1158): add + // that product once, then drop the parameter so a reload adds nothing. The + // add is idempotent (by slug), so a second effect run (Strict Mode) is safe. + useEffect(() => { + const url = new URL(window.location.href); + if (!url.searchParams.has('add')) return; + const raw = (url.searchParams.get('add') ?? '').trim(); + // The product route's own slug rule. + const slug = /^[a-zA-Z0-9._-]{1,120}$/.test(raw) ? raw : ''; + const dropParam = () => { + const now = new URL(window.location.href); + now.searchParams.delete('add'); + window.history.replaceState( + null, + '', + `${now.pathname}${now.search}${now.hash}` + ); + }; + if (!slug || getWatchlist().some((x) => x.slug === slug)) { + dropParam(); + return; + } + fetch(`/api/v1/rate/products/${encodeURIComponent(slug)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((json) => { + const product = json?.ok ? (json.data?.product as MasterProduct) : null; + if ( + product?.slug === slug && + !getWatchlist().some((x) => x.slug === slug) + ) { + handleQuickAddCatalog(product); + } + }) + .catch(() => { + // An unknown or unreachable product adds nothing. + }) + .finally(dropParam); + // Runs once per page load. + }, []); + const handleUpdateTarget = async (item: WatchlistItem) => { const cleanTarget = sanitizeWatchlistTargetPrice( editTarget, diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index f91a290e8..bcf3f2e9a 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -4554,6 +4554,9 @@ type UntypedRateQuery = { eq: (col: string, val: unknown) => UntypedRateQuery; is: (col: string, val: null) => UntypedRateQuery; in: (col: string, vals: readonly unknown[]) => UntypedRateQuery; + gte: (col: string, val: unknown) => UntypedRateQuery; + lte: (col: string, val: unknown) => UntypedRateQuery; + ilike: (col: string, pattern: string) => UntypedRateQuery; order: ( col: string, opts?: { ascending?: boolean; nullsFirst?: boolean } @@ -4726,7 +4729,10 @@ export async function getItemUnknownVariantOffers( .eq('item_id', itemId) .is('cluster_id', null) .order('id') - .range(from, to) as unknown as PromiseLike<{ + .range(from, to) + .then( + throwIfItemSchemaMissing('item unknown-variant listings') + ) as unknown as PromiseLike<{ data: unknown[] | null; error: { message: string } | null; }> @@ -4881,7 +4887,7 @@ export async function getItemLinksForMasters( const itemIds = Array.from(new Set(itemIdByMaster.values())); if (itemIds.length === 0) return links; - const [itemRows, variantRows] = await Promise.all([ + const [itemRows, variantsByItem] = await Promise.all([ read('items', itemIds, (chunk) => db .from('items') @@ -4889,33 +4895,19 @@ export async function getItemLinksForMasters( .in('id', chunk) .eq('status', 'published') ), - read('item variants', itemIds, (chunk) => - db - .from('master_products') - .select(ITEM_VARIANT_SELECT) - .in('item_id', chunk) - .eq('status', 'published') - ), + getItemVariantsBatch(itemIds), ]); const itemsById = new Map(); for (const raw of itemRows) { const item = mapItemRow(raw); if (item) itemsById.set(item.id, item); } - const variantsByItem = new Map(); - for (const raw of variantRows) { - const variant = mapItemVariantRow(raw); - if (!variant) continue; - const list = variantsByItem.get(variant.item_id) ?? []; - list.push(variant); - variantsByItem.set(variant.item_id, list); - } for (const [masterId, itemId] of itemIdByMaster) { const item = itemsById.get(itemId); if (!item) continue; links.set(masterId, { item, - variants: sortItemVariants(variantsByItem.get(itemId) ?? []), + variants: variantsByItem.get(itemId) ?? [], }); } return links; @@ -5059,3 +5051,106 @@ export async function getItemSlugByVariantSlug(): Promise> { } return out; } + +/** `listItems` filters: what API v1 `group=item` supports (DOS/Rate#1158). */ +export interface ItemListParams { + categoryId?: string | null; + brands?: readonly string[]; + search?: string; + minPrice?: number; + maxPrice?: number; + sort?: 'default' | 'price_asc' | 'price_desc' | 'merchants_desc' | 'newest'; + limit: number; + offset: number; +} + +/** + * Published Items for API v1 `group=item`, one row per Item with its "from" + * price. Spec filters stay variant-level until `search_items_v1` (#1155). + * Needs M1+M2 (#1150). + */ +export async function listItems(params: ItemListParams): Promise { + let query = untypedRate() + .from('items') + .select(ITEM_SELECT) + .eq('status', 'published'); + const categoryId = params.categoryId + ? sanitizeRateEntityId(params.categoryId) + : ''; + if (categoryId) query = query.eq('category_id', categoryId); + if (params.brands && params.brands.length > 0) { + query = query.in('brand', params.brands.slice(0, 50)); + } + const search = (params.search ?? '') + .replace(/\p{Cc}+/gu, ' ') + // LIKE wildcards, escapes and PostgREST filter syntax are no search text. + .replace(/[%_*\\,()]/g, ' ') + .trim() + .slice(0, 160); + if (search) query = query.ilike('name', `%${search}%`); + // A price filter means a price: an Item with none (0) is no match. + if ( + typeof params.minPrice === 'number' || + typeof params.maxPrice === 'number' + ) { + query = query.gte('min_price', Math.max(1, params.minPrice ?? 1)); + } + if (typeof params.maxPrice === 'number') { + query = query.lte('min_price', params.maxPrice); + } + const sort = params.sort ?? 'default'; + query = + sort === 'price_asc' + ? query.order('min_price', { ascending: true }) + : sort === 'price_desc' + ? query.order('min_price', { ascending: false }) + : sort === 'merchants_desc' + ? query.order('merchant_count', { ascending: false }) + : sort === 'newest' + ? query.order('updated_at', { ascending: false }) + : query.order('listing_count', { ascending: false }); + const { data, error } = await query + .order('id') + .range(params.offset, params.offset + params.limit - 1); + if (error) { + log.error('item list failed', { message: error.message }); + throw itemReadError('items', error as { message: string; code?: string }); + } + return (data ?? []).map(mapItemRow).filter((i): i is RateItem => i !== null); +} + +/** + * The published variants of several Items, by Item id, each in variant order + * (DOS/Rate#1158; shared with `getItemLinksForMasters`, #1155). Needs M1+M2 + * (#1150); a missing table or column throws ItemModelUnavailableError. + */ +export async function getItemVariantsBatch( + itemIds: readonly string[] +): Promise> { + const ids = Array.from( + new Set(itemIds.map((id) => sanitizeRateEntityId(id)).filter(Boolean)) + ); + const byItem = new Map(ids.map((id) => [id, []])); + if (ids.length === 0) return byItem; + const rows = await readInChunks( + ids, + 'item variants', + (chunk) => + untypedRate() + .from('master_products') + .select(ITEM_VARIANT_SELECT) + .in('item_id', chunk) + .eq('status', 'published') + .then(throwIfItemSchemaMissing('item variants')) as PromiseLike<{ + data: unknown[] | null; + error: { message?: string } | null; + }> + ); + for (const variant of rows.map(mapItemVariantRow)) { + if (variant) byItem.get(variant.item_id)?.push(variant); + } + for (const [id, variants] of byItem) { + byItem.set(id, sortItemVariants(variants)); + } + return byItem; +} diff --git a/apps/web/src/server/rate/extension.ts b/apps/web/src/server/rate/extension.ts index a4e6207cc..892925eac 100644 --- a/apps/web/src/server/rate/extension.ts +++ b/apps/web/src/server/rate/extension.ts @@ -38,6 +38,15 @@ import { import { computeProductRadarSignal } from './deals'; import type { RadarSignal } from '../../lib/deal-radar'; import { createLogger } from '@/lib/logger'; +import { + chooseItemVariant, + itemProductPath, + resolveListingItemContext, + resolveVariantItemContext, + toApiItem, + type ApiItem, + type ApiItemContext, +} from './item-api'; const log = createLogger('server.extension'); @@ -227,6 +236,10 @@ export interface ExtensionLookupResponse { topRivalName?: string | null; leaderboardStandings?: ProductLeaderboardBadgeInfo[] | null; radarSignal?: RadarSignal | null; + /** RATE_ITEM_MODEL on only: the matched Item (DOS/Rate#1158). */ + item?: ApiItem; + /** RATE_ITEM_MODEL on only: the variant key the page states, or null. */ + selectedVariant?: string | null; } const ACCESSORY_KEYWORDS_VI_EN = [ @@ -1150,6 +1163,32 @@ export async function lookupExtensionProduct( } } + // A listing with no stated size is still its Item's (RATE_ITEM_MODEL on, + // design doc 5.2): shown with the default variant and no size chosen. + let itemContext: ApiItemContext | null = null; + if ( + !matchedMaster && + !listingOfUnpublishedModel && + candidateItemIds.size > 0 + ) { + const listingItem = await resolveListingItemContext( + Array.from(candidateItemIds) + ); + if (listingItem === 'no_variant') { + // The listing's Item is known but has no page yet: no catalogue guess. + listingOfUnpublishedModel = true; + } else if (listingItem) { + const byVariant = await getMasterProductBySlug(listingItem.selected.slug); + if (byVariant) { + itemContext = listingItem; + matchedMaster = byVariant; + matchStatus = 'canonical_model'; + } else { + listingOfUnpublishedModel = true; + } + } + } + // Filter catalog candidates by storageGb if known if ( !matchedMaster && @@ -1216,6 +1255,23 @@ export async function lookupExtensionProduct( matchStatus = 'canonical_model'; } + // The Item and all its variants from the DB, not only the siblings among the + // search hits. The listing's own variant wins, then a stated size the Item + // has once, then a single-variant Item (RATE_ITEM_MODEL on). + const pageSize = { storageGb: hasStorage ? storageGb : null }; + itemContext = itemContext + ? chooseItemVariant(itemContext, { ...pageSize, listingVariantId: null }) + : await resolveVariantItemContext( + matchedMaster.slug, + pageSize.storageGb, + matchStatus === 'exact_listing' ? matchedMaster.id : null + ); + if (itemContext && itemContext.selected.slug !== matchedMaster.slug) { + const chosen = await getMasterProductBySlug(itemContext.selected.slug); + if (chosen) matchedMaster = chosen; + else itemContext = { ...itemContext, sizeChosen: false }; + } + // Build variant candidates across same model family const sameFamilyVariants = catalogCandidates.filter( (c) => @@ -1224,17 +1280,39 @@ export async function lookupExtensionProduct( c.model.toLowerCase() === matchedMaster!.model.toLowerCase() && typeof c.storage_gb === 'number' ); - const variantCandidates: ExtensionVariantCandidate[] = sameFamilyVariants.map( - (v) => ({ - storageGb: v.storage_gb || 256, - label: `${v.storage_gb || 256}GB`, - slug: v.slug, - minPrice: v.min_price, - maxPrice: v.max_price, - }) - ); - - if (variantCandidates.length > 1 && !storageGb) { + const variantCandidates: ExtensionVariantCandidate[] = itemContext + ? // Sized variants only: an unknown size is never shown as 256GB. + hasStorage + ? itemContext.variants + .filter((v) => typeof v.storage_gb === 'number' && v.storage_gb > 0) + .map((v) => ({ + storageGb: v.storage_gb!, + label: v.variant_name || `${v.storage_gb}GB`, + slug: v.slug, + minPrice: v.min_price, + maxPrice: v.max_price, + })) + : [] + : sameFamilyVariants.map((v) => ({ + storageGb: v.storage_gb || 256, + label: `${v.storage_gb || 256}GB`, + slug: v.slug, + minPrice: v.min_price, + maxPrice: v.max_price, + })); + + if (itemContext) { + // Ambiguous when no size is known among several, or the page states a size + // the Item does not have: then no variant's prices may judge the page. + if ( + itemContext.sizeMismatch || + (!itemContext.sizeChosen && variantCandidates.length > 1) + ) { + matchStatus = 'ambiguous_variant'; + } else if (matchStatus === 'ambiguous_variant') { + matchStatus = 'canonical_model'; + } + } else if (variantCandidates.length > 1 && !storageGb) { matchStatus = 'ambiguous_variant'; } @@ -1385,7 +1463,13 @@ export async function lookupExtensionProduct( priceHistory: mode === 'full' ? priceHistory : null, categorySlug: categoryPath, compareUrl: `/compare?cat=${categoryPath}&slugs=${encodeURIComponent(matchedMaster.slug)}`, - productDetailUrl: `/${categoryPath}/${encodeURIComponent(matchedMaster.slug)}`, + productDetailUrl: itemContext + ? itemProductPath( + categoryPath, + itemContext.item.slug, + itemContext.sizeChosen ? itemContext.selected.variant_key : null + ) + : `/${categoryPath}/${encodeURIComponent(matchedMaster.slug)}`, leaderboardUrl: `/leaderboard?cat=${categoryPath}`, versusUrl: topRival ? `/compare/${encodeURIComponent(matchedMaster.slug)}-vs-${encodeURIComponent(topRival.slug)}` @@ -1393,6 +1477,14 @@ export async function lookupExtensionProduct( topRivalName: topRival?.name || null, leaderboardStandings, radarSignal, + ...(itemContext + ? { + item: toApiItem(itemContext.item), + selectedVariant: itemContext.sizeChosen + ? itemContext.selected.variant_key + : null, + } + : {}), }; } diff --git a/apps/web/src/server/rate/item-api.ts b/apps/web/src/server/rate/item-api.ts new file mode 100644 index 000000000..1427d156a --- /dev/null +++ b/apps/web/src/server/rate/item-api.ts @@ -0,0 +1,372 @@ +import 'server-only'; +import { + ItemModelUnavailableError, + RateDataUnavailableError, + getItemById, + getItemOffers, + getRateClient, + getItemVariants, + getItemVariantsBatch, + listItems, + resolveCatalogSlug, + type ItemListParams, + type MerchantOffer, + type RateItem, + type RateItemVariant, +} from '../../lib/rate-api'; +import { createLogger } from '@/lib/logger'; +import { isItemModelOn, sanitizeVariantParam } from './item-model'; + +const log = createLogger('server.item-api'); + +/** + * API v1 and the extension under the Item model (DOS/Rate#1158, design doc + * 5.2). Everything here is additive: with `RATE_ITEM_MODEL` off, or while the + * Item tables are missing, callers get null and answer exactly as before. + */ + +/** A catalogue slug read as an Item, with the variant a response is about. */ +export interface ApiItemContext { + item: RateItem; + variants: RateItemVariant[]; + /** The variant the response's `product` is: `?v=`, the slug's, the default, the first. */ + selected: RateItemVariant; + /** True when the slug or `?v=` named the size; false for a default pick. */ + sizeChosen: boolean; + /** The page states a size the Item does not have: no variant may be judged. */ + sizeMismatch?: boolean; +} + +/** The public Item object of API v1. */ +export interface ApiItem { + id: string; + item_key: string; + slug: string; + name: string; + brand: string; + model: string; + category_id: string | null; + min_price: number; + max_price: number; + price_variant_count: number; + merchant_count: number; + listing_count: number; + unknown_variant_listing_count: number; +} + +/** A public variant of an Item. */ +export interface ApiItemVariant { + id: string; + slug: string; + name: string; + variant_key: string; + variant_name: string | null; + storage_gb: number | null; + min_price: number; + max_price: number; + merchant_count: number; +} + +/** The fields `/products/{slug}` adds under the Item model. */ +export interface ApiItemFields { + item: ApiItem; + variants: ApiItemVariant[]; + selected_variant: string | null; + unknown_variant_offers: MerchantOffer[]; +} + +export function toApiItem(item: RateItem): ApiItem { + return { + id: item.id, + item_key: item.item_key, + slug: item.slug, + name: item.name, + brand: item.brand, + model: item.model, + category_id: item.category_id, + min_price: item.min_price, + max_price: item.max_price, + price_variant_count: item.price_variant_count, + merchant_count: item.merchant_count, + listing_count: item.listing_count, + unknown_variant_listing_count: item.unknown_variant_listing_count, + }; +} + +export function toApiItemVariant(variant: RateItemVariant): ApiItemVariant { + return { + id: variant.id, + slug: variant.slug, + name: variant.name, + variant_key: variant.variant_key, + variant_name: variant.variant_name, + storage_gb: variant.storage_gb, + min_price: variant.min_price, + max_price: variant.max_price, + merchant_count: variant.merchant_count, + }; +} + +/** Runs an Item read; null when the Item tables are missing (M1+M2 not applied). */ +async function orUnavailable( + what: string, + read: () => Promise +): Promise { + try { + return await read(); + } catch (err) { + if (err instanceof ItemModelUnavailableError) { + log.warn('item model on but its tables are missing', { + what, + message: err.message, + }); + return null; + } + throw err; + } +} + +/** + * The Item a catalogue slug belongs to, and the variant to answer with: the + * one `?v=` names, else the old variant slug's own size, else the Item's + * default variant, else its first. Null while the flag is off, the tables are + * missing, or the slug is no published Item or variant of one (callers then + * answer as before). + */ +export async function resolveApiItemContext( + slug: string, + rawVariant?: unknown +): Promise { + if (!isItemModelOn()) return null; + return orUnavailable('slug', async () => { + const resolution = await resolveCatalogSlug(slug); + if (!resolution || resolution.kind === 'unlinked') return null; + const variants = await getItemVariants(resolution.item.id); + const requested = sanitizeVariantParam(rawVariant); + const byParam = requested + ? variants.find((v) => v.variant_key === requested) + : undefined; + const bySlug = + resolution.kind === 'variant' && !resolution.retired + ? variants.find((v) => v.id === resolution.variant.id) + : undefined; + const selected = + byParam ?? + bySlug ?? + variants.find((v) => v.id === resolution.item.default_variant_id) ?? + variants[0]; + if (!selected) return null; + return { + item: resolution.item, + variants, + selected, + sizeChosen: Boolean(byParam ?? bySlug), + }; + }); +} + +/** + * The Item fields of `/products/{slug}` for a resolved context, or null when + * the Item columns turn out to be missing (the route then answers as before). + */ +export async function buildApiItemFields( + context: ApiItemContext +): Promise { + const offers = await orUnavailable('offers', () => + getItemOffers(context.item.id, []) + ); + if (!offers) return null; + return { + item: toApiItem(context.item), + variants: context.variants.map(toApiItemVariant), + selected_variant: context.selected.variant_key, + unknown_variant_offers: offers.unknownVariant, + }; +} + +/** The Item URL on rate.vn, with the variant preselected when one is known. */ +export function itemProductPath( + categoryPath: string, + itemSlug: string, + variantKey?: string | null +): string { + const base = `/${categoryPath}/${encodeURIComponent(itemSlug)}`; + return variantKey ? `${base}?v=${encodeURIComponent(variantKey)}` : base; +} + +// undefined_table, undefined_column, and PostgREST's schema-cache misses. +const MISSING_SCHEMA_CODES = new Set([ + '42P01', + '42703', + 'PGRST204', + 'PGRST205', +]); + +/** + * The variant an extension lookup answers with (design doc 4.4, 5.2): + * - the listing's own variant when the listing has one (`listingVariantId`); + * - else the one variant of the size the page states, when exactly one has it; + * a stated size the Item lacks (or shares between variants) chooses none and + * sets `sizeMismatch` when no variant has it; + * - else the Item's only variant, when it has one; + * - else none: the default variant is shown with no size chosen. + */ +export function chooseItemVariant( + context: ApiItemContext, + page: { storageGb: number | null; listingVariantId: string | null } +): ApiItemContext { + const base = { + ...context, + sizeChosen: false, + sizeMismatch: false, + }; + const own = page.listingVariantId + ? context.variants.find((v) => v.id === page.listingVariantId) + : undefined; + if (own) return { ...base, selected: own, sizeChosen: true }; + if (page.storageGb) { + const sized = context.variants.filter( + (v) => v.storage_gb === page.storageGb + ); + if (sized.length === 1) { + return { ...base, selected: sized[0]!, sizeChosen: true }; + } + return { ...base, sizeMismatch: sized.length === 0 }; + } + if (context.variants.length === 1) { + return { ...base, selected: context.variants[0]!, sizeChosen: true }; + } + return base; +} + +/** + * The Item of a marketplace listing with no variant (`cluster_id` NULL, + * `item_id` set, design doc 4.4), with the Item's default variant and no + * size chosen yet. `'no_variant'` when the listing's Item is known but has no + * published variant (or is not published): the lookup must not guess another + * product then. Null while the flag is off, the columns are missing, or no + * such listing exists. + */ +export async function resolveListingItemContext( + platformItemIds: readonly string[] +): Promise { + if (!isItemModelOn() || platformItemIds.length === 0) return null; + const client = getRateClient(); + if (!client) return null; + // `item_id` is not in the generated types until #1150 lands. + const { data, error } = await ( + client.from('products') as unknown as { + select: (cols: string) => { + in: ( + col: string, + values: readonly string[] + ) => { + is: ( + col: string, + value: null + ) => { + not: ( + col: string, + op: string, + value: null + ) => { + limit: (n: number) => PromiseLike<{ + data: Array<{ item_id: string | null }> | null; + error: { message: string; code?: string } | null; + }>; + }; + }; + }; + }; + } + ) + .select('item_id, platform_item_id') + .in('platform_item_id', platformItemIds) + .is('cluster_id', null) + .not('item_id', 'is', null) + .limit(5); + if (error) { + if (error.code && MISSING_SCHEMA_CODES.has(error.code)) { + log.warn('item model on but products.item_id is missing', { + message: error.message, + }); + return null; + } + log.error('listing item lookup failed', { message: error.message }); + throw new RateDataUnavailableError('products', error); + } + const itemId = (data ?? []).find( + (r) => typeof r.item_id === 'string' + )?.item_id; + if (!itemId) return null; + return orUnavailable('listing item', async () => { + const item = await getItemById(itemId); + if (!item) return 'no_variant' as const; + const variants = await getItemVariants(item.id); + const selected = + variants.find((v) => v.id === item.default_variant_id) ?? variants[0]; + return selected + ? { item, variants, selected, sizeChosen: false } + : ('no_variant' as const); + }); +} + +/** + * A matched variant's Item context, its variant chosen by `chooseItemVariant`: + * `listingVariantId` is the variant the listing itself is on (an exact + * listing match), null for a catalogue guess. + */ +export async function resolveVariantItemContext( + variantSlug: string, + storageGb: number | null, + listingVariantId: string | null +): Promise { + const context = await resolveApiItemContext(variantSlug); + if (!context) return null; + return chooseItemVariant( + { ...context, selected: context.selected }, + { storageGb, listingVariantId } + ); +} + +/** One row of `/products?group=item`: the Item and its variants. */ +export interface ApiItemListRow extends ApiItem { + variants: ApiItemVariant[]; +} + +/** + * `/products?group=item` (DOS/Rate#1158): a page of published Items with their + * variants. Null while the flag is off or the Item tables are missing, so the + * route answers the variant list as before. + */ +export async function listApiItems( + categorySlug: string, + params: Omit +): Promise { + if (!isItemModelOn()) return null; + return orUnavailable('item list', async () => { + let categoryId: string | null = null; + if (categorySlug && categorySlug !== 'all') { + const client = getRateClient(); + const { data, error } = await client + .from('categories') + .select('id') + .eq('slug', categorySlug) + .maybeSingle(); + if (error) throw new RateDataUnavailableError('categories', error); + if (!data) { + // An unknown category lists nothing, but only once the Item tables + // are known to exist: without them the route answers as before. + await listItems({ limit: 1, offset: 0 }); + return []; + } + categoryId = (data as { id: string }).id; + } + const items = await listItems({ ...params, categoryId }); + const variants = await getItemVariantsBatch(items.map((i) => i.id)); + return items.map((item) => ({ + ...toApiItem(item), + variants: (variants.get(item.id) ?? []).map(toApiItemVariant), + })); + }); +} diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index 3a3fe4886..de3ee7f10 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -1,5 +1,6 @@ import 'server-only'; import { EXTENSION_CAPABILITY_MATRIX } from './extension'; +import { isItemModelOn } from './item-model'; export interface OpenApiParameter { name: string; @@ -131,7 +132,7 @@ export function normalizeOpenApiServerOrigin( } } -export function buildRateOpenApiSpec( +function buildBaseRateOpenApiSpec( baseUrl = 'https://rate.vn' ): RateOpenApiDocument { const safeBaseUrl = normalizeOpenApiServerOrigin(baseUrl); @@ -1709,3 +1710,142 @@ export function buildRateOpenApiSpec( }, }; } + +/** + * The OpenAPI document. The Item model's parameters, fields and schemas are + * documented only while `RATE_ITEM_MODEL` is on, so with the flag off the + * document is exactly as before (DOS/Rate#1158). + */ +export function buildRateOpenApiSpec( + ...args: Parameters +): RateOpenApiDocument { + const spec = buildBaseRateOpenApiSpec(...args); + return isItemModelOn() ? withItemModelDocs(spec) : spec; +} + +type JsonObject = Record; + +const responseSchema = (op: OpenApiOperation | undefined): JsonObject | null => + (op?.responses['200']?.content?.['application/json']?.schema as + JsonObject | undefined) ?? null; + +const propertiesOf = (schema: JsonObject | null): JsonObject | null => + schema && typeof schema.properties === 'object' && schema.properties + ? (schema.properties as JsonObject) + : null; + +/** Adds the Item model's API v1 surface to the document (design doc 5.2). */ +export function withItemModelDocs( + spec: RateOpenApiDocument +): RateOpenApiDocument { + const list = spec.paths['/api/v1/rate/products']?.get; + if (list) { + const params = list.parameters ?? []; + const at = params.findIndex((p) => p.name === 'sort'); + params.splice(at < 0 ? params.length : at, 0, { + name: 'group', + in: 'query', + description: + 'Set to `item` to list Items (one row per product line, with its size variants) instead of variants. Takes `category`, `brands`, `q`, `minPrice`, `maxPrice` (on the Item "from" price) and the price, merchant and newest sorts; spec filters and `rating_desc` answer 400. The default stays `variant`.', + schema: { type: 'string', enum: ['variant', 'item'], default: 'variant' }, + }); + list.parameters = params; + const ok = list.responses['200']; + if (ok) { + ok.description = `${ok.description} With \`group=item\`: \`group\` is \`item\` and \`data\` holds \`RateItem\` rows, each with \`variants\` (\`RateItemVariant[]\`).`; + } + } + + const detail = spec.paths['/api/v1/rate/products/{slug}']?.get; + if (detail) { + const slug = detail.parameters?.find((p) => p.name === 'slug'); + if (slug) { + slug.description = + 'Product slug (e.g. apple-iphone-16-128gb) or Item slug (e.g. apple-iphone-16).'; + } + detail.parameters = [ + ...(detail.parameters ?? []), + { + name: 'v', + in: 'query', + description: + 'The variant key to answer with (e.g. `256gb`). Without it, an old variant slug answers its own size and an Item slug its default variant.', + schema: { type: 'string' }, + }, + ]; + const data = propertiesOf( + (propertiesOf(responseSchema(detail))?.data as JsonObject) ?? null + ); + if (data) { + const product = data.product as JsonObject | undefined; + if (product) { + product.description = + "The selected variant (`?v=`, the slug's own size, else the Item's default)."; + } + data.item = { $ref: '#/components/schemas/RateItem' }; + data.variants = { + type: 'array', + description: 'Every published size of the Item.', + items: { $ref: '#/components/schemas/RateItemVariant' }, + }; + data.selected_variant = { + type: 'string', + description: 'The variant key `product` is.', + }; + data.unknown_variant_offers = { + type: 'array', + description: + 'Offers of the Item whose listing states no size, in the same shape as `offers`. They never feed a variant price.', + items: { type: 'object' }, + }; + } + } + + const lookup = spec.paths['/api/v1/rate/extension/lookup']?.get; + const lookupProps = propertiesOf(responseSchema(lookup)); + if (lookupProps) { + lookupProps.item = { $ref: '#/components/schemas/RateItem' }; + lookupProps.selectedVariant = { + type: ['string', 'null'], + description: + "The variant key of the size the listing or page states, or of the Item's only variant; null when it is not known (`matchStatus` is then `ambiguous_variant` when the Item has several sizes). `variantCandidates` lists the Item's sized variants, empty for a category without storage.", + }; + } + + spec.components.schemas.RateItem = { + type: 'object', + description: + 'A product line across its sizes. Prices are "from" prices over its published variants; counts include listings with no stated size.', + properties: { + id: { type: 'string', format: 'uuid' }, + item_key: { type: 'string', example: 'apple:iphone-16' }, + slug: { type: 'string', example: 'apple-iphone-16' }, + name: { type: 'string' }, + brand: { type: 'string' }, + model: { type: 'string' }, + category_id: { type: ['string', 'null'], format: 'uuid' }, + min_price: { type: 'number' }, + max_price: { type: 'number' }, + price_variant_count: { type: 'integer' }, + merchant_count: { type: 'integer' }, + listing_count: { type: 'integer' }, + unknown_variant_listing_count: { type: 'integer' }, + }, + }; + spec.components.schemas.RateItemVariant = { + type: 'object', + description: 'One size of an Item.', + properties: { + id: { type: 'string', format: 'uuid' }, + slug: { type: 'string', example: 'apple-iphone-16-256gb' }, + name: { type: 'string' }, + variant_key: { type: 'string', example: '256gb' }, + variant_name: { type: ['string', 'null'] }, + storage_gb: { type: ['integer', 'null'] }, + min_price: { type: 'number' }, + max_price: { type: 'number' }, + merchant_count: { type: 'integer' }, + }, + }; + return spec; +} From 9f31963e4a495d996ac6cc299019ae6158c7bb5c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:43:36 +0700 Subject: [PATCH 45/69] =?UTF-8?q?fix(normalizer):=20keep=20v=E1=BB=8F-mate?= =?UTF-8?q?rial=20and=20gift-only=20sizes,=20linear=20pipe=20check=20(#128?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(normalizer): keep vỏ-material and gift-only sizes, linear pipe check Follow-up to DOS/Rate#1263: - "vỏ" followed by a material (nhôm, titan, thép...) is the watch's own case and "hộp" has no size, so "kèm vỏ nhôm 46mm" keeps 46mm; - a size only the gifted accessory names is read when the product names none ("kèm bao da 11 inch", "tặng ốp 13 inch"); a size the product names still wins; - the '| Case' check is one pass: a 250-part title takes under 1 ms instead of about 40 ms. Closes #1277 * fix(normalizer): vỏ-material only for watches, gift size only when none Codex round 1 on DOS/Rate#1277: - the vỏ-material and hộp exceptions apply to watches only; for an iPad or a MacBook a vỏ/hộp gift is an accessory, so "13 kèm vỏ nhôm 11 inch" keeps 13; - the gift's size is read only when the rest of the title names no size at all, never when it names one it leaves unresolved. Refs #1277 * fix(normalizer): a number after cho/for does not block the gift size Codex round 2 on DOS/Rate#1277: "tặng bao da 13 inch cho học sinh lớp 11" read the 11 after "cho" as a size the product names, so the gift's 13 was not used. The check now covers the same part the size is read from. Refs #1277 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 47 +++++++++++-- .../importer/src/shopee/product-normalizer.ts | 68 +++++++++++++------ 2 files changed, 92 insertions(+), 23 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index fedd303e9..f1351ede5 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -446,6 +446,21 @@ async function runSuite() { ['iPad Pro M4 11 inch tặng bao da 13 inch 256GB', 'apple:ipad-pro-m4-11:256gb'], // "bao test" (guaranteed testing) is no accessory. ['iPad Pro M4 kèm bao test 13 inch WiFi 256GB', 'apple:ipad-pro-m4-13:256gb'], + // A size only the gift names: an 11 inch case fits the 11 inch iPad (DOS/Rate#1277). + ['iPad Pro M4 Wifi kèm bao da 11 inch 256GB', 'apple:ipad-pro-m4-11:256gb'], + ['MacBook Air tặng ốp 13 inch M4 16GB 512GB', 'apple:macbook-air-m4-13:512gb'], + // For an iPad or a MacBook, a "vỏ"/"hộp" gift is an accessory whatever its material. + ['iPad Pro M4 13 kèm vỏ nhôm 11 inch 256GB', 'apple:ipad-pro-m4-13:256gb'], + ['MacBook Air M4 13 kèm hộp 15 inch 16GB 512GB', 'apple:macbook-air-m4-13:512gb'], + // A number after "cho/for" (a school year, an age) is no size of the product. + [ + 'iPad Pro M4 Wifi 256GB tặng bao da 13 inch cho học sinh lớp 11', + 'apple:ipad-pro-m4-13:256gb', + ], + [ + 'MacBook Air M4 16GB 512GB tặng ốp 13 inch for students from 15', + 'apple:macbook-air-m4-13:512gb', + ], ['MacBook Air M3 kèm bao test 15 inch 16GB 512GB', 'apple:macbook-air-m3-15:512gb'], ['iPad Pro 13" M5 1TB', 'apple:ipad-pro-m5-13:1024gb'], ['iPad Pro 11 2024 M4 256GB', 'apple:ipad-pro-m4-11:256gb'], @@ -663,17 +678,30 @@ async function runSuite() { ['Apple Watch Series 10 42mm GPS tặng ốp 46mm', 'apple:watch-series-10-42mm:64gb'], ['Apple Watch Series 10 42mm GPS | Ốp TPU Case 46mm', 'apple:watch-series-10-42mm:64gb'], ['Apple Watch Series 10 42mm GPS | Bao da Case 46mm', 'apple:watch-series-10-42mm:64gb'], - ['Apple Watch Series 10 GPS, tặng Silicone Case 46mm', 'apple:watch-series-10:64gb'], - ['Apple Watch Series 10 GPS | kèm ốp 46mm', 'apple:watch-series-10:64gb'], + // A size only the gift names is the best evidence left: a 46mm case fits the 46mm + // watch (DOS/Rate#1277). + ['Apple Watch Series 10 GPS, tặng Silicone Case 46mm', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 GPS | kèm ốp 46mm', 'apple:watch-series-10-46mm:64gb'], [ 'Apple Watch Series 10 GPS, tặng Silicone Case 46mm'.normalize('NFD'), - 'apple:watch-series-10:64gb', + 'apple:watch-series-10-46mm:64gb', ], [ 'Apple Watch Series 10 GPS Silicone Band 42mm, tặng Silicone Case 46mm', 'apple:watch-series-10-42mm:64gb', ], - ['Apple Watch Series 10 GPS kèm ốp 46mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 GPS kèm ốp 46mm', 'apple:watch-series-10-46mm:64gb'], + // "vỏ" with a material is the watch's own case, and "hộp" (box) no accessory size. + ['Apple Watch Series 10 GPS kèm vỏ nhôm 46mm', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 42mm GPS kèm vỏ titan 42mm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 GPS kèm hộp 42mm', 'apple:watch-series-10-42mm:64gb'], + // A gifted size never overrides a size the watch names itself. + ['Apple Watch Series 10 46mm GPS, tặng ốp 42mm', 'apple:watch-series-10-46mm:64gb'], + // A size the rest names but leaves open is never replaced by the gift's. + [ + 'Apple Watch Series 10 GPS Sport Band 42mm mặt 46mm, tặng ốp 42mm', + 'apple:watch-series-10:64gb', + ], // A bundle word before no sized accessory keeps the product's size. ['Apple Watch Series 10 kèm sạc 46mm GPS', 'apple:watch-series-10-46mm:64gb'], ['Apple Watch Series 10, kèm sạc 46mm GPS', 'apple:watch-series-10-46mm:64gb'], @@ -683,6 +711,17 @@ async function runSuite() { for (const [title, expectedKey] of namedCaseKeys1223) { assert.equal(keyOf(title), expectedKey, title); } + // The "| Case" check is linear in the parts: a 250-part title stays fast (it took + // about 29 ms when it was cubic, DOS/Rate#1277). + // Worst case: no sized part before the "|"s, so the old check rescanned them all. + const manyParts = `Apple Watch Series 10 GPS 42mm Sport Band${' | a'.repeat(250)}`; + keyOf(manyParts); + const runs = Array.from({ length: 5 }, () => { + const t0 = performance.now(); + keyOf(manyParts); + return performance.now() - t0; + }).sort((x, y) => x - y); + assert.ok(runs[2]! < 10, `250-part title took ${runs[2]!.toFixed(1)} ms`); // The registry has one entry per line: a sized title keeps the line's benchmark, but // the entry's screen and battery describe only the size it was written for. A watch // entry pinned to one case (`case_mm`) does not describe another case at all: the diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index b142ee58f..8df328ca1 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1286,6 +1286,10 @@ const GIFTED_ACCESSORY_SIZE = // An accessory sold by the size it fits: a case, cover, film or box. const GIFT_ACCESSORY_NOUN = /(? = { ipad: sizePatterns(IPAD_SIZE), 'macbook air': sizePatterns(MACBOOK_AIR_SIZE), @@ -1321,9 +1325,31 @@ function readAppleSize(model: string, rawTitle: string): string { const withoutGift = title .slice(Math.max(0, at)) .replace(GIFTED_ACCESSORY_SIZE, (m, word: string, words: string) => - GIFT_ACCESSORY_NOUN.test(words) ? `${word}${words}` : m + (family === 'watch' ? WATCH_GIFT_ACCESSORY_NOUN : GIFT_ACCESSORY_NOUN).test(words) + ? `${word}${words}` + : m ); - const own = withoutGift.split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!; + // A size only the gift names is still the best evidence of the product's: an 11 inch + // case fits an 11 inch iPad (DOS/Rate#1277). + const productPart = title.slice(Math.max(0, at)); + // Only when the rest names no size at all: a size it names but leaves unresolved (a + // band list, "mặt 46mm" inside a band phrase) is never replaced by the gift's. + const namesSize = + family === 'watch' + ? WATCH_SIZE.test(withoutGift) + : // The same part the size is read from: not what follows "cho/for" or "+". + APPLE_SIZE_PATTERNS[family]!.some(re => + re.test(withoutGift.split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!) + ); + return ( + readFamilySize(family, withoutGift) || + (namesSize || withoutGift === productPart ? '' : readFamilySize(family, productPart)) + ); +} + +/** `readAppleSize` over one reading of the product's part of the title. */ +function readFamilySize(family: string, scope: string): string { + const own = scope.split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!; if (family === 'watch') { // A size outside a band phrase ("dây", "band", "strap", "loop" or "bracelet" up to the // next ")", ",", "|", ";", en dash or " - ") is the case's. A band is sold by the case @@ -1390,24 +1416,28 @@ function readAppleSize(model: string, rawTitle: string): string { // cover or what something fits ("kèm vỏ hộp 42mm", "fits 42mm case"). // A "| Case 46mm" after a part that already gives the watch a size outside any band // ("Series 10 42mm GPS | Case 46mm") is a bundled case, not the watch's (DOS/Rate#1263). - const sizedBeforePipe = (i: number) => - parts.some( - (sep, j) => - j < i && - sep === '|' && - parts - .slice(0, j) - .some( - (p, k) => - k % 2 === 0 && - WATCH_SIZE.test(p) && - !WATCH_BAND_WORD.test(p) && - !CASE_ACCESSORY_WORD.test(p.normalize('NFC')) - ) - ); + // One pass: a part is after such a "|" once a sized part came before a "|" before it + // (linear in the parts, DOS/Rate#1277). + const afterSizedPipe: boolean[] = []; + let sizedSeen = false; + let armed = false; + parts.forEach((p, k) => { + if (k % 2 === 1) { + if (p === '|' && sizedSeen) armed = true; + return; + } + afterSizedPipe[k] = armed; + if ( + WATCH_SIZE.test(p) && + !WATCH_BAND_WORD.test(p) && + !CASE_ACCESSORY_WORD.test(p.normalize('NFC')) + ) { + sizedSeen = true; + } + }); const namedCase = parts .map((part, i) => ({ part: part.normalize('NFC'), i })) - .filter(({ i }) => i % 2 === 0 && !sizedBeforePipe(i)) + .filter(({ i }) => i % 2 === 0 && !afterSizedPipe[i]) .map(({ part }) => part) .filter( part => @@ -1424,7 +1454,7 @@ function readAppleSize(model: string, rawTitle: string): string { const caseSize = outside.join(' ').match(WATCH_SIZE)?.[1]; if (caseSize) return `${caseSize}mm`; const bandSize = own.match(WATCH_SIZE)?.[1]; - const sizes = new Set([...withoutGift.matchAll(new RegExp(WATCH_SIZE, 'gi'))].map(m => m[1])); + const sizes = new Set([...scope.matchAll(new RegExp(WATCH_SIZE, 'gi'))].map(m => m[1])); return bandSize && sizes.size === 1 ? `${bandSize}mm` : ''; } const [marked, bare] = APPLE_SIZE_PATTERNS[family]!; From e658ee60e533193eee73574b447192d5a61fc79c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:43:58 +0700 Subject: [PATCH 46/69] feat(web): compare, versus and watchlist per item (#1282) * feat(web): compare, versus and watchlist per item Task 10 of the Item / variant / listing model, behind RATE_ITEM_MODEL: compare accepts Item or variant slugs, `slug@256gb` pins a size, and compares one size per Item; versus URLs name Items, an old variant pair 308s to its Item pair and a same-Item pair is a 404; the sitemap lists each Item versus pair once; the watchlist reads an Item slug as its default size with `size_required`, a price alert on an Item with several sizes or a retired size asks for one (422 SIZE_REQUIRED) instead of moving it to another size, and an Item sharing its slug with one of its sizes watches that size. With the flag off, or with the Item tables missing or failing, nothing changes. Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 2 +- .../app/[locale]/compare/[versus]/page.tsx | 26 ++- apps/web/src/app/[locale]/compare/page.tsx | 15 +- .../src/app/api/v1/rate/watchlist/route.ts | 33 +++- apps/web/src/app/sitemap.ts | 42 +++-- .../src/lib/__tests__/item-compare.test.ts | 170 ++++++++++++++++++ apps/web/src/lib/item-compare.ts | 141 +++++++++++++++ .../rate/__tests__/item-directory.test.ts | 42 ++++- apps/web/src/server/rate/item-compare.ts | 111 ++++++++++++ apps/web/src/server/rate/item-directory.ts | 2 +- apps/web/src/server/rate/item-route.ts | 5 + 11 files changed, 565 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/lib/__tests__/item-compare.test.ts create mode 100644 apps/web/src/lib/item-compare.ts create mode 100644 apps/web/src/server/rate/item-compare.ts diff --git a/apps/web/package.json b/apps/web/package.json index da529f1e2..6da1c716c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/compare/[versus]/page.tsx b/apps/web/src/app/[locale]/compare/[versus]/page.tsx index d03516f65..cca21ea09 100644 --- a/apps/web/src/app/[locale]/compare/[versus]/page.tsx +++ b/apps/web/src/app/[locale]/compare/[versus]/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; +import { notFound, permanentRedirect } from 'next/navigation'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { BsTrophy } from 'react-icons/bs'; import { Link } from '@/lib/navigation'; @@ -9,6 +9,7 @@ import { getMasterProductCategorySlug, getMasterProductHref, } from '@/server/rate'; +import { resolveVersusPlan } from '@/server/rate/item-compare'; import { normalizeRateLocale } from '@/lib/rate-i18n'; import { defaultOpenGraph, fitTitle, fitDescription } from '@/lib/seo'; import { scoreMasterProductForLeaderboard } from '@/lib/leaderboard-engine'; @@ -62,14 +63,23 @@ export async function generateMetadata({ return notFoundMeta(); } - const compared = await getCachedCompareMasterProducts(parsed.join(',')); + // RATE_ITEM_MODEL on: the Item pair is the page (DOS/Rate#1156). + const plan = await resolveVersusPlan(parsed[0], parsed[1]); + if (plan && plan.kind !== 'render') { + return notFoundMeta(); + } + const compared = await getCachedCompareMasterProducts( + (plan ? [plan.slugA, plan.slugB] : parsed).join(',') + ); const a = compared[0]; const b = compared[1]; if (!a || !b) { return notFoundMeta(); } - const canonicalUrl = `https://rate.vn/compare/${a.slug}-vs-${b.slug}`; + const canonicalUrl = plan + ? `https://rate.vn${plan.canonical}` + : `https://rate.vn/compare/${a.slug}-vs-${b.slug}`; const fullTitle = t('versusMetaTitle', { nameA: a.name, nameB: b.name }); // Both names first; the fixed text, then the site name, give way to fit a // search result (DOS/Rate#777). The share card keeps the full title. @@ -110,8 +120,16 @@ export default async function VersusComparePage({ params }: VersusPageProps) { notFound(); } + // RATE_ITEM_MODEL on: versus URLs name Items, an old variant pair 308s to + // its Item pair and a same-Item pair is no versus (DOS/Rate#1156); off: + // null, and the page is as before. + const plan = await resolveVersusPlan(parsed[0], parsed[1]); + if (plan?.kind === 'redirect') permanentRedirect(plan.to); + if (plan?.kind === 'not_found') notFound(); + const pair = plan?.kind === 'render' ? [plan.slugA, plan.slugB] : parsed; + const [compared, directory] = await Promise.all([ - getCachedCompareMasterProducts(parsed.join(',')), + getCachedCompareMasterProducts(pair.join(',')), getCachedMasterProducts({ categorySlug: 'all', limit: 60, diff --git a/apps/web/src/app/[locale]/compare/page.tsx b/apps/web/src/app/[locale]/compare/page.tsx index 2195dbb00..6918afdfe 100644 --- a/apps/web/src/app/[locale]/compare/page.tsx +++ b/apps/web/src/app/[locale]/compare/page.tsx @@ -7,6 +7,7 @@ import { getCachedMasterProducts, getMasterProductCategorySlug, } from '@/server/rate'; +import { resolveCompareSlugs } from '@/server/rate/item-compare'; import { normalizeRateLocale } from '@/lib/rate-i18n'; import { defaultOpenGraph, fitTitle, fitDescription } from '@/lib/seo'; import CompareWorkspace from '@/components/compare/CompareWorkspace'; @@ -50,6 +51,14 @@ function parseSlugsCsv(slugsParam?: string): string[] { ).slice(0, 4); } +/** + * The slugs to compare. RATE_ITEM_MODEL on: Item or variant slugs, with + * `slug@256gb` pinning a size, one per Item (DOS/Rate#1156); off: as sent. + */ +async function comparedSlugs(requested: string[]): Promise { + return (await resolveCompareSlugs(requested)) ?? requested; +} + export async function generateMetadata({ params, searchParams, @@ -64,7 +73,7 @@ export async function generateMetadata({ if (requestedSlugs.length >= 2) { const compared = await getCachedCompareMasterProducts( - requestedSlugs.join(',') + (await comparedSlugs(requestedSlugs)).join(',') ); if (compared.length >= 2) { const names = compared.map((p) => p.name).join(' vs '); @@ -118,7 +127,9 @@ export default async function ComparePhonesPage({ // Fixed key, so it shares the data cache; only the compared slugs vary. getCachedMasterProducts({ categorySlug: 'all', limit: 50 }), requestedSlugs.length > 0 - ? getCachedCompareMasterProducts(requestedSlugs.join(',')) + ? comparedSlugs(requestedSlugs).then((slugs) => + getCachedCompareMasterProducts(slugs.join(',')) + ) : Promise.resolve([]), ]); diff --git a/apps/web/src/app/api/v1/rate/watchlist/route.ts b/apps/web/src/app/api/v1/rate/watchlist/route.ts index 0eedeb661..471f05277 100644 --- a/apps/web/src/app/api/v1/rate/watchlist/route.ts +++ b/apps/web/src/app/api/v1/rate/watchlist/route.ts @@ -7,6 +7,7 @@ import { getCachedMasterProductOffers, getMasterProductOffersBatch, } from '@/server/rate'; +import { resolveWatchlistSlug } from '@/server/rate/item-compare'; import { radarExportFields } from '@/lib/deal-radar'; import { PUBLIC_API_CORS_HEADERS, @@ -110,9 +111,19 @@ export async function GET(request: NextRequest) { ); } + // RATE_ITEM_MODEL on: an Item slug watches its size, or its default + // size until one is chosen (`size_required`, DOS/Rate#1156); off: null. + const itemTargets = new Map< + string, + { variantSlug: string; sizeRequired: boolean } + >(); const masterEntries = await Promise.all( slugs.map(async (slug) => { - const master = await getCachedMasterProductBySlug(slug); + const target = await resolveWatchlistSlug(slug); + if (target) itemTargets.set(slug, target); + const master = await getCachedMasterProductBySlug( + target?.variantSlug ?? slug + ); return master ? ([slug, master] as const) : null; }) ); @@ -155,6 +166,9 @@ export async function GET(request: NextRequest) { best_offer: bestOffer, deal_radar: radarExportFields(radarSignal), radar_signal: radarSignal, + ...(itemTargets.get(slug)?.sizeRequired + ? { size_required: true as const } + : {}), }, ] as const; }); @@ -234,7 +248,22 @@ export async function POST(request: NextRequest) { { status: 400, headers: WATCHLIST_CORS_HEADERS } ); } - const { slug, targetPrice, locale } = parsed.data; + const { slug: requestedSlug, targetPrice, locale } = parsed.data; + + // RATE_ITEM_MODEL on: alerts stay per size (Q5). An Item with several + // sizes needs one chosen first (DOS/Rate#1156); off: the slug as sent. + const itemTarget = await resolveWatchlistSlug(requestedSlug); + if (itemTarget?.sizeRequired) { + return NextResponse.json( + { + ok: false, + error: 'Choose a size before setting a price alert', + code: 'SIZE_REQUIRED', + }, + { status: 422, headers: WATCHLIST_CORS_HEADERS } + ); + } + const slug = itemTarget?.variantSlug ?? requestedSlug; const masterProduct = await getCachedMasterProductBySlug(slug); if (!masterProduct) { diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index ff3595e7b..24a82c8ca 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -7,6 +7,7 @@ import { getMasterProductCategorySlug, getMasterProductHref, } from '@/server/rate'; +import { getItemSlugByVariantSlug } from '@/server/rate/item-directory'; import { buildPopular1v1BattlesForCategory, VALID_LEADERBOARD_CATEGORIES, @@ -140,9 +141,11 @@ export default async function sitemap(): Promise { try { // Product and merchant URLs come from the whole catalogue; the curated // 1v1 battles only need the top-ranked products. - const [rows, products] = await Promise.all([ + const [rows, products, itemSlugs] = await Promise.all([ getCachedSitemapRows(), getCachedMasterProducts({ categorySlug: 'all', limit: 500 }), + // RATE_ITEM_MODEL on: versus pairs name Items (DOS/Rate#1156); off: null. + getItemSlugByVariantSlug(), ]); const catSlugById = rows.categorySlugById; @@ -179,21 +182,34 @@ export default async function sitemap(): Promise { } ); + const versusSeen = new Set(); const versusEntries: MetadataRoute.Sitemap = VALID_LEADERBOARD_CATEGORIES.flatMap((category) => - buildPopular1v1BattlesForCategory(stampedProducts, category, 3).map( + buildPopular1v1BattlesForCategory(stampedProducts, category, 3).flatMap( (pair) => { - const lastModified = versusLastModified( - dates, - pair.productA.slug, - pair.productB.slug - ); - return { - url: `${baseUrl}${pair.versusHref}`, - ...(lastModified ? { lastModified } : {}), - changeFrequency: 'daily' as const, - priority: 0.83, - }; + const slugA = + itemSlugs?.get(pair.productA.slug) ?? pair.productA.slug; + const slugB = + itemSlugs?.get(pair.productB.slug) ?? pair.productB.slug; + const href = itemSlugs + ? `/compare/${slugA}-vs-${slugB}` + : pair.versusHref; + // Two sizes of one Item are no versus, and a pair is listed once. + // One entry per Item pair, whichever way round it was ranked. + const pairKey = [slugA, slugB].sort().join('|'); + if (itemSlugs && (slugA === slugB || versusSeen.has(pairKey))) { + return []; + } + versusSeen.add(pairKey); + const lastModified = versusLastModified(dates, slugA, slugB); + return [ + { + url: `${baseUrl}${href}`, + ...(lastModified ? { lastModified } : {}), + changeFrequency: 'daily' as const, + priority: 0.83, + }, + ]; } ) ); diff --git a/apps/web/src/lib/__tests__/item-compare.test.ts b/apps/web/src/lib/__tests__/item-compare.test.ts new file mode 100644 index 000000000..9894467f6 --- /dev/null +++ b/apps/web/src/lib/__tests__/item-compare.test.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { + compareTarget, + dedupeCompareTargets, + parseCompareToken, + pickItemVariant, + planVersusRoute, + type CompareResolution, +} from '../item-compare'; + +// Compare, versus and watchlist per Item (DOS/Rate#1156). + +const item16 = { + id: 'item-16', + slug: 'apple-iphone-16', + default_variant_id: 'v256', +}; +const v128 = { + id: 'v128', + slug: 'apple-iphone-16-128gb', + variant_key: '128gb', +}; +const v256 = { + id: 'v256', + slug: 'apple-iphone-16-256gb', + variant_key: '256gb', +}; +const variants16 = [v128, v256]; +const asItem: CompareResolution = { + kind: 'item', + item: item16, + variants: variants16, +}; +const asVariant = ( + variant: typeof v128, + retired = false +): CompareResolution => ({ + kind: 'variant', + item: item16, + variant, + variants: variants16, + retired, +}); +const s24: CompareResolution = { + kind: 'item', + item: { + id: 'item-s24', + slug: 'samsung-galaxy-s24', + default_variant_id: null, + }, + variants: [ + { id: 's1', slug: 'samsung-galaxy-s24-256gb', variant_key: '256gb' }, + ], +}; + +function main() { + console.warn('🧪 item compare suite (DOS/Rate#1156)'); + + assert.deepEqual(parseCompareToken('apple-iphone-16@128gb'), { + slug: 'apple-iphone-16', + variantKey: '128gb', + }); + assert.deepEqual(parseCompareToken(' Apple-iPhone-16 '), { + slug: 'apple-iphone-16', + variantKey: null, + }); + assert.deepEqual(parseCompareToken('a@b@c'), { + slug: 'a@b@c', + variantKey: null, + }); + + assert.equal(pickItemVariant(item16, variants16, '128gb'), v128); + assert.equal(pickItemVariant(item16, variants16, '1tb'), v256, 'default'); + assert.equal( + pickItemVariant({ ...item16, default_variant_id: null }, variants16, null), + v128, + 'first' + ); + assert.equal(pickItemVariant(item16, [], null), null); + + // Item slug: its default size, or the pinned one. + assert.equal( + compareTarget(asItem, parseCompareToken('apple-iphone-16'))?.variantSlug, + 'apple-iphone-16-256gb' + ); + assert.equal( + compareTarget(asItem, parseCompareToken('apple-iphone-16@128gb')) + ?.variantSlug, + 'apple-iphone-16-128gb' + ); + // Old variant slug: itself; a retired one: the Item's default. + assert.equal( + compareTarget(asVariant(v128), parseCompareToken('apple-iphone-16-128gb')) + ?.variantSlug, + 'apple-iphone-16-128gb' + ); + assert.equal( + compareTarget( + asVariant(v128, true), + parseCompareToken('apple-iphone-16-unknown') + )?.variantSlug, + 'apple-iphone-16-256gb' + ); + assert.deepEqual( + compareTarget({ kind: 'unlinked' }, parseCompareToken('pixel-10')), + { variantSlug: 'pixel-10', itemId: null, itemSlug: null } + ); + assert.equal(compareTarget(null, parseCompareToken('nope')), null); + + // One entry per Item: two sizes of one Item are no comparison. + assert.deepEqual( + dedupeCompareTargets([ + { variantSlug: 'apple-iphone-16-128gb', itemId: 'item-16' }, + null, + { variantSlug: 'apple-iphone-16-256gb', itemId: 'item-16' }, + { variantSlug: 'pixel-10', itemId: null }, + { variantSlug: 'pixel-10', itemId: null }, + ]).map((t) => t.variantSlug), + ['apple-iphone-16-128gb', 'pixel-10'] + ); + + // Versus: an old variant pair 308s to the Item pair. + assert.deepEqual( + planVersusRoute( + { slug: 'apple-iphone-16-128gb', resolution: asVariant(v128) }, + { slug: 'samsung-galaxy-s24-256gb', resolution: s24 } + ), + { kind: 'redirect', to: '/compare/apple-iphone-16-vs-samsung-galaxy-s24' } + ); + // The Item pair renders the default sizes, canonical on itself. + assert.deepEqual( + planVersusRoute( + { slug: 'apple-iphone-16', resolution: asItem }, + { slug: 'samsung-galaxy-s24', resolution: s24 } + ), + { + kind: 'render', + slugA: 'apple-iphone-16-256gb', + slugB: 'samsung-galaxy-s24-256gb', + canonical: '/compare/apple-iphone-16-vs-samsung-galaxy-s24', + } + ); + // Same Item on both sides: no versus. + assert.deepEqual( + planVersusRoute( + { slug: 'apple-iphone-16-128gb', resolution: asVariant(v128) }, + { slug: 'apple-iphone-16-256gb', resolution: asVariant(v256) } + ), + { kind: 'not_found' } + ); + // Neither side has an Item: today's page; an unknown side: 404. + assert.deepEqual( + planVersusRoute( + { slug: 'a', resolution: { kind: 'unlinked' } }, + { slug: 'b', resolution: { kind: 'unlinked' } } + ), + { kind: 'legacy' } + ); + assert.deepEqual( + planVersusRoute( + { slug: 'apple-iphone-16', resolution: asItem }, + { slug: 'gone', resolution: null } + ), + { kind: 'not_found' } + ); + + console.warn(' ✅ item compare suite passed'); +} + +main(); diff --git a/apps/web/src/lib/item-compare.ts b/apps/web/src/lib/item-compare.ts new file mode 100644 index 000000000..315ea5d96 --- /dev/null +++ b/apps/web/src/lib/item-compare.ts @@ -0,0 +1,141 @@ +// Compare, versus and watchlist per Item (DOS/Rate#1156; design doc +// `Docs/Proposals/2026-10-27-item-variant-listing-model.md`, 5.1). Pure: the +// server layer resolves slugs and applies these only while +// `RATE_ITEM_MODEL` is on. + +/** The Item and variant fields these plans read. */ +export interface CompareItem { + id: string; + slug: string; + default_variant_id: string | null; +} +export interface CompareVariant { + id: string; + slug: string; + variant_key: string; +} + +/** What a compare slug resolves to (the shape of `resolveCatalogSlug`). */ +export type CompareResolution = + | { kind: 'item'; item: CompareItem; variants: readonly CompareVariant[] } + | { + kind: 'variant'; + item: CompareItem; + variant: CompareVariant; + variants: readonly CompareVariant[]; + retired: boolean; + } + | { kind: 'unlinked' } + | null; + +// `slug@256gb` pins a size (design doc 5.1); the key is the literal segment. +const PIN_REGEX = /^([a-z0-9][a-z0-9._-]{0,159})@([a-z0-9][a-z0-9-]{0,40})$/; + +/** A compare token: an Item or variant slug, optionally pinned to a size. */ +export function parseCompareToken(raw: string): { + slug: string; + variantKey: string | null; +} { + const clean = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + const pinned = PIN_REGEX.exec(clean); + return pinned + ? { slug: pinned[1]!, variantKey: pinned[2]! } + : { slug: clean, variantKey: null }; +} + +/** The size an Item shows: the pinned one, else its default, else its first. */ +export function pickItemVariant( + item: CompareItem, + variants: readonly CompareVariant[], + variantKey: string | null +): CompareVariant | null { + return ( + (variantKey && variants.find((v) => v.variant_key === variantKey)) || + variants.find((v) => v.id === item.default_variant_id) || + variants[0] || + null + ); +} + +/** + * The variant a compare token compares, with its Item: an Item slug gives + * its pinned or default size, an old variant slug itself (a retired size + * gives the Item's default), a master with no Item itself. + */ +export function compareTarget( + resolution: CompareResolution, + token: { slug: string; variantKey: string | null } +): { + variantSlug: string; + itemId: string | null; + itemSlug: string | null; +} | null { + if (!resolution) return null; + if (resolution.kind === 'unlinked') { + return { variantSlug: token.slug, itemId: null, itemSlug: null }; + } + const { item, variants } = resolution; + const own = + resolution.kind === 'variant' && !resolution.retired && !token.variantKey + ? resolution.variant + : null; + const variant = own ?? pickItemVariant(item, variants, token.variantKey); + return variant + ? { variantSlug: variant.slug, itemId: item.id, itemSlug: item.slug } + : null; +} + +/** + * Compare targets in request order, one per Item: a second token naming the + * same Item is dropped (a same-Item pair is no comparison). + */ +export function dedupeCompareTargets< + T extends { variantSlug: string; itemId: string | null }, +>(targets: readonly (T | null)[]): T[] { + const seenItems = new Set(); + const seenSlugs = new Set(); + const out: T[] = []; + for (const t of targets) { + if (!t || seenSlugs.has(t.variantSlug)) continue; + if (t.itemId && seenItems.has(t.itemId)) continue; + if (t.itemId) seenItems.add(t.itemId); + seenSlugs.add(t.variantSlug); + out.push(t); + } + return out; +} + +export type VersusPlan = + /** Neither side has an Item: today's versus page. */ + | { kind: 'legacy' } + | { kind: 'not_found' } + /** An old variant pair: 308 to the Item pair. */ + | { kind: 'redirect'; to: string } + | { kind: 'render'; slugA: string; slugB: string; canonical: string }; + +/** + * A versus URL under Items (design doc 5.1): `{itemA}-vs-{itemB}`. An old + * variant pair 308s to its Item pair; a same-Item pair is no versus. + */ +export function planVersusRoute( + sideA: { slug: string; resolution: CompareResolution }, + sideB: { slug: string; resolution: CompareResolution } +): VersusPlan { + const a = compareTarget(sideA.resolution, parseCompareToken(sideA.slug)); + const b = compareTarget(sideB.resolution, parseCompareToken(sideB.slug)); + if (!a || !b) return { kind: 'not_found' }; + if (!a.itemId && !b.itemId) return { kind: 'legacy' }; + if (a.itemId && a.itemId === b.itemId) return { kind: 'not_found' }; + const urlSlugA = a.itemSlug ?? a.variantSlug; + const urlSlugB = b.itemSlug ?? b.variantSlug; + const canonical = `/compare/${urlSlugA}-vs-${urlSlugB}`; + if (urlSlugA !== sideA.slug || urlSlugB !== sideB.slug) { + return { kind: 'redirect', to: canonical }; + } + return { + kind: 'render', + slugA: a.variantSlug, + slugB: b.variantSlug, + canonical, + }; +} diff --git a/apps/web/src/server/rate/__tests__/item-directory.test.ts b/apps/web/src/server/rate/__tests__/item-directory.test.ts index 462bf8708..b3227ce53 100644 --- a/apps/web/src/server/rate/__tests__/item-directory.test.ts +++ b/apps/web/src/server/rate/__tests__/item-directory.test.ts @@ -118,7 +118,7 @@ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { { id: 'draft-variant', brand: 'Google', item_id: DRAFT_ITEM }, ]); } - if (q.includes('item_id=in.')) { + if (q.includes('item_id=in.') || q.includes(`item_id=eq.${ITEM}`)) { return json([variantRow(V256, 256), variantRow(V128, 128)]); } return json([]); @@ -235,6 +235,38 @@ async function main() { itemsDown = false; delete process.env.RATE_ITEM_MODEL; + // Compare, versus and watchlist per Item (DOS/Rate#1156). + const cmp = await import('../item-compare'); + calls = []; + assert.equal(await cmp.resolveCompareSlugs(['apple-iphone-16']), null); + assert.equal(await cmp.resolveVersusPlan('a', 'b'), null); + assert.equal(await cmp.resolveWatchlistSlug('apple-iphone-16'), null); + assert.deepEqual(calls, [], 'flag off: no database read'); + process.env.RATE_ITEM_MODEL = 'item'; + assert.deepEqual( + await cmp.resolveCompareSlugs(['apple-iphone-16@128gb']), + ['apple-iphone-16-128gb'], + 'a pinned size compares that size' + ); + assert.deepEqual(await cmp.resolveWatchlistSlug('apple-iphone-16'), { + variantSlug: 'apple-iphone-16-128gb', + sizeRequired: true, + }); + // An Item sharing its slug with one of its sizes watches that size + // (review round 1): the stub answers every Item slug with the Item. + assert.deepEqual(await cmp.resolveWatchlistSlug('apple-iphone-16-128gb'), { + variantSlug: 'apple-iphone-16-128gb', + sizeRequired: false, + }); + itemsDown = true; + assert.equal( + await cmp.resolveCompareSlugs(['apple-iphone-16']), + null, + 'a failing Item read compares the slugs as sent' + ); + itemsDown = false; + delete process.env.RATE_ITEM_MODEL; + // The cached pages this touches keep their cache mode: the leaderboard // stays ISR, and nothing here reads request-time APIs. const fs = require('node:fs') as typeof import('node:fs'); @@ -244,9 +276,17 @@ async function main() { 'utf8' ); assert.match(leaderboard, /export const revalidate = 60;/); + const versus = fs.readFileSync( + path.resolve(__dirname, '../../../app/[locale]/compare/[versus]/page.tsx'), + 'utf8' + ); + assert.match(versus, /export const revalidate = 60;/); + assert.doesNotMatch(versus, /searchParams|force-dynamic/); assert.doesNotMatch(leaderboard, /force-dynamic/); for (const file of [ path.resolve(__dirname, '../item-directory.ts'), + path.resolve(__dirname, '../item-compare.ts'), + path.resolve(__dirname, '../../../lib/item-compare.ts'), path.resolve(__dirname, '../../../lib/item-directory.ts'), ]) { assert.doesNotMatch( diff --git a/apps/web/src/server/rate/item-compare.ts b/apps/web/src/server/rate/item-compare.ts new file mode 100644 index 000000000..f4c8d77f7 --- /dev/null +++ b/apps/web/src/server/rate/item-compare.ts @@ -0,0 +1,111 @@ +import 'server-only'; +import { + compareTarget, + dedupeCompareTargets, + parseCompareToken, + pickItemVariant, + planVersusRoute, + type CompareResolution, + type VersusPlan, +} from '@/lib/item-compare'; +import { whenItemModel } from './item-directory'; +import { + getCachedCatalogSlugResolution, + getCachedItemVariants, +} from './item-route'; + +// Compare, versus and watchlist per Item while `RATE_ITEM_MODEL` is on +// (DOS/Rate#1156). Off, or with the Item tables missing or failing, every +// helper returns null and the caller does exactly what it did before. + +const SLUG_REGEX = /^[a-z0-9][a-z0-9._-]{0,159}$/; + +async function resolveSlug(slug: string): Promise { + if (!SLUG_REGEX.test(slug)) return null; + const resolution = await getCachedCatalogSlugResolution(slug); + if (!resolution) return null; + if (resolution.kind === 'unlinked') return { kind: 'unlinked' }; + const variants = await getCachedItemVariants(resolution.item); + return resolution.kind === 'item' + ? { kind: 'item', item: resolution.item, variants } + : { + kind: 'variant', + item: resolution.item, + variant: resolution.variant, + variants, + retired: resolution.retired, + }; +} + +/** + * The variant slugs to compare for the requested tokens (Item or variant + * slugs, `slug@256gb` pinning a size), one per Item, in request order; null + * while the Item model is off. + */ +export function resolveCompareSlugs( + tokens: readonly string[] +): Promise { + return whenItemModel('compare slugs', async () => { + const targets = await Promise.all( + tokens.map(async (raw) => { + const token = parseCompareToken(raw); + return compareTarget(await resolveSlug(token.slug), token); + }) + ); + return dedupeCompareTargets(targets).map((t) => t.variantSlug); + }); +} + +/** The versus route for two URL slugs; null while the Item model is off. */ +export function resolveVersusPlan( + slugA: string, + slugB: string +): Promise { + return whenItemModel('versus route', async () => { + const [resolutionA, resolutionB] = await Promise.all([ + resolveSlug(parseCompareToken(slugA).slug), + resolveSlug(parseCompareToken(slugB).slug), + ]); + const plan = planVersusRoute( + { slug: slugA, resolution: resolutionA }, + { slug: slugB, resolution: resolutionB } + ); + return plan.kind === 'legacy' ? null : plan; + }); +} + +/** + * The variant a watchlist slug watches. Alerts stay per size (Q5): an old + * variant slug watches its size, an Item with one size that size, and an + * Item with several needs a size first (`sizeRequired`, with its default + * size for the price shown meanwhile). Null while the Item model is off or + * the slug is no Item. + */ +export function resolveWatchlistSlug( + slug: string +): Promise<{ variantSlug: string; sizeRequired: boolean } | null> { + return whenItemModel('watchlist slug', async () => { + const resolution = await resolveSlug(slug); + if (!resolution || resolution.kind === 'unlinked') return null; + if (resolution.kind === 'variant' && !resolution.retired) { + return { variantSlug: resolution.variant.slug, sizeRequired: false }; + } + const { item, variants } = resolution; + // An Item may share its slug with one of its sizes (design doc 5.1): + // that size is the one watched, as before the Item model. + const sameSlug = variants.find((v) => v.slug === slug); + if (resolution.kind === 'item' && sameSlug) { + return { variantSlug: sameSlug.slug, sizeRequired: false }; + } + const shown = pickItemVariant(item, variants, null); + // A retired size is never swapped for another one silently: the alert + // was for that size, so a size is asked for again (review round 1). + const retired = resolution.kind === 'variant'; + return shown + ? { + variantSlug: shown.slug, + sizeRequired: retired || variants.length > 1, + } + : null; + }); +} diff --git a/apps/web/src/server/rate/item-directory.ts b/apps/web/src/server/rate/item-directory.ts index 06bcd56e6..48e396dee 100644 --- a/apps/web/src/server/rate/item-directory.ts +++ b/apps/web/src/server/rate/item-directory.ts @@ -26,7 +26,7 @@ import { isItemModelOn } from './item-model'; const log = createLogger('server.item-directory'); /** Runs `read` under the Item model; null when it is off or unavailable. */ -async function whenItemModel( +export async function whenItemModel( what: string, read: () => Promise ): Promise { diff --git a/apps/web/src/server/rate/item-route.ts b/apps/web/src/server/rate/item-route.ts index 0aa10da38..090369235 100644 --- a/apps/web/src/server/rate/item-route.ts +++ b/apps/web/src/server/rate/item-route.ts @@ -32,6 +32,11 @@ const _getCachedItemVariants = cache((itemId: string, itemSlug: string) => })(itemId) ); +/** A catalogue slug's Item or variant, cached like the product pages. */ +export function getCachedCatalogSlugResolution(slug: string) { + return _getCachedCatalogSlug(slug); +} + export function getCachedItemVariants(item: RateItem) { return _getCachedItemVariants(item.id, item.slug); } From 6bd98c45a7af19f5b725f6dfc6d358db99445624 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:44:18 +0700 Subject: [PATCH 47/69] fix(web): read the merchant risk split, label the column as combined risk (#1280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): read the merchant risk split, label the column as combined risk Rate Services now writes the merchant risk split (RS#708): the review_risk_score column is the combined risk, and the breakdown (mirrored in metadata) carries combined_risk_score and the review-only review_risk_score, measured over total_reviews (0 when none). - storedRiskSplit reads that shape; an older breakdown keeps the current reading until the auditor rewrites it; - offer lines (trust view and offer rows), the merchant list and the merchant profile take review_risk_score as the review-only part, which counts only with analysed reviews, and the combined score feeds risk_score and trust; - the offer line and its CSV show the combined risk under its own label, next to the review risk ("chưa đủ dữ liệu" without reviews); - the merchant page drops the sabotage tile, which reads 0 by construction; its JSON-LD reviewCount already uses total_reviews; - OpenAPI says which field is which. Closes #1268 * fix(web): only the full risk split replaces the legacy review risk reading Review round 1 of #1280: - a source counts as the split only with numeric combined_risk_score, review_risk_score and total_reviews; older metadata with a combined score next to a review score (no count) keeps today's reading, so its review risk no longer drops to 0 (the extension pipeline suite pins this); - the breakdown nested in metadata is read too, in the same order as extractPersistedBreakdown; - with a split, its combined score is used instead of an older metadata combined score, and it never drops below the column. Refs #1268 * fix(web): a risk split only in metadata keeps its review score on offer lines Review round 2 of #1280: with the split only in metadata.review_risk_breakdown (an empty column breakdown), the offer row mapper counted the seller as unscored and swapped in a no-reviews breakdown, hiding the review-only score. The split now counts as a stored score, so its own breakdown is kept. Refs #1268 Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 1 + apps/web/locales/en.json | 1 + apps/web/locales/vi.json | 1 + .../src/app/[locale]/merchants/[id]/page.tsx | 22 +-- .../leaderboard/ProductOffersExplorer.tsx | 4 + .../__tests__/merchant-review-risk.test.ts | 167 ++++++++++++++++++ apps/web/src/lib/rate-api.ts | 86 ++++++--- apps/web/src/lib/rate-domain-core.ts | 44 +++++ apps/web/src/server/rate/openapi.ts | 6 +- 9 files changed, 290 insertions(+), 42 deletions(-) diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 50b303eef..6035571ba 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1184,6 +1184,7 @@ "specsShowAll": "Zobrazit všechny parametry", "specsShowLess": "Zobrazit méně parametrů", "exportMenu": "Exportovat data", + "offersCombinedRiskLabel": "Celkové riziko", "offersReviewRiskLabel": "Riziko recenzí", "offersReviewRiskNoData": "nedostatek dat", "offersDiscontinuedBadge": "Ukončený prodej", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index da98d60f0..0c8418c13 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1184,6 +1184,7 @@ "specsShowAll": "Show all specs", "specsShowLess": "Show fewer specs", "exportMenu": "Export data", + "offersCombinedRiskLabel": "Combined risk", "offersReviewRiskLabel": "Review risk", "offersReviewRiskNoData": "not enough data", "offersDiscontinuedBadge": "Discontinued", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 204d894e6..4fe6cca23 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -1184,6 +1184,7 @@ "specsShowAll": "Xem toàn bộ thông số", "specsShowLess": "Thu gọn thông số", "exportMenu": "Xuất dữ liệu", + "offersCombinedRiskLabel": "Rủi ro tổng hợp", "offersReviewRiskLabel": "Rủi ro đánh giá", "offersReviewRiskNoData": "chưa đủ dữ liệu", "offersDiscontinuedBadge": "Ngừng bán", diff --git a/apps/web/src/app/[locale]/merchants/[id]/page.tsx b/apps/web/src/app/[locale]/merchants/[id]/page.tsx index 6daa55a1a..287994f5b 100644 --- a/apps/web/src/app/[locale]/merchants/[id]/page.tsx +++ b/apps/web/src/app/[locale]/merchants/[id]/page.tsx @@ -519,8 +519,11 @@ export default async function MerchantProfilePage({ )}

    + {/* No sabotage tile: Rate Services counts sabotage and review + bombing among the authentic reviews, so it would always + read 0 (DOS/Rate#1268). */}
    -
    +
    {brk.authentic_count} @@ -533,7 +536,7 @@ export default async function MerchantProfilePage({
    -
    +
    {brk.coin_spam_count} @@ -546,7 +549,7 @@ export default async function MerchantProfilePage({
    -
    +
    {brk.seeding_fake_count} @@ -559,19 +562,6 @@ export default async function MerchantProfilePage({
    -
    -
    -
    - {brk.sabotage_or_bombing_count} -
    -
    - {t('statSabotage')} -
    -
    -
    diff --git a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx index fea299eb0..b8f353476 100644 --- a/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx +++ b/apps/web/src/components/leaderboard/ProductOffersExplorer.tsx @@ -337,6 +337,7 @@ export default function ProductOffersExplorer({ 'IsOfficialMall', 'TrustScorePercent', 'DosafeRiskScore', + 'CombinedRiskScore', 'ReviewRiskScore', 'PriceCurrentVND', 'PriceOriginalVND', @@ -359,6 +360,7 @@ export default function ProductOffersExplorer({ o.merchant.is_official ? 'true' : 'false', String(trust), offerRiskScoreCsvCell(o.merchant.dosafe_risk_score), + offerRiskScoreCsvCell(o.merchant.risk_score), hasReviewRiskEvidence(o.merchant.review_risk_breakdown) ? offerRiskScoreCsvCell(o.merchant.review_risk_score) : '', @@ -768,6 +770,8 @@ export default function ProductOffersExplorer({ {formatOfferRiskScore100( offer.merchant.dosafe_risk_score )} + /100 | {t('offersCombinedRiskLabel')}:{' '} + {formatOfferRiskScore100(offer.merchant.risk_score)} /100 | {t('offersReviewRiskLabel')}:{' '} {/* No analysed reviews: no number (#1258). */} {hasReviewRiskEvidence( diff --git a/apps/web/src/lib/__tests__/merchant-review-risk.test.ts b/apps/web/src/lib/__tests__/merchant-review-risk.test.ts index 4d4d612af..4ff8eac9d 100644 --- a/apps/web/src/lib/__tests__/merchant-review-risk.test.ts +++ b/apps/web/src/lib/__tests__/merchant-review-risk.test.ts @@ -142,6 +142,7 @@ async function main() { await directoryReadsReviewsForStoredScores(); await offerSellersWithoutReviewsReadAsNoData(); + await combinedColumnIsNotReviewRisk(); console.warn(' ✅ merchant review risk suite passed'); } @@ -321,6 +322,172 @@ async function offerSellersWithoutReviewsReadAsNoData() { ); } +// Rate Services' split (RS#708): the column is the combined risk, and the +// breakdown carries the review-only part over `total_reviews` (#1268). +async function combinedColumnIsNotReviewRisk() { + const { + mapTrustViewRowToMerchantOffer, + mapOfferRowToMerchantOffer, + persistedReviewRiskOf, + hasStoredReviewRisk, + storedRiskSplit, + } = await import('../rate-api'); + const noReviews = { + combined_risk_score: 42, + review_risk_score: 15, + total_reviews: 0, + authentic_count: 0, + }; + const measured = { ...noReviews, review_risk_score: 28, total_reviews: 40 }; + assert.deepEqual(storedRiskSplit(noReviews), { + combined: 42, + reviewOnly: null, + }); + assert.deepEqual(storedRiskSplit(measured), { + combined: 42, + reviewOnly: 28, + }); + assert.deepEqual( + storedRiskSplit({}, { review_risk_breakdown: measured }), + { combined: 42, reviewOnly: 28 }, + 'the breakdown nested in metadata' + ); + assert.equal( + storedRiskSplit({ total_reviews: 12 }, { combined_risk_score: 80 }), + null, + 'a legacy breakdown with an older combined score is no split' + ); + assert.deepEqual( + storedRiskSplit( + {}, + { combined_risk_score: 7, review_risk_score: 3, total_reviews: 0 } + ), + { combined: 7, reviewOnly: null }, + 'metadata mirrors the split' + ); + // Older metadata carried a combined score next to a review score, with no + // review count: it is no split and keeps today's reading. + const legacyMeta = { + dosafe_risk_score: 15, + review_risk_score: 68, + combined_risk_score: 48, + }; + assert.equal(storedRiskSplit({}, legacyMeta), null); + assert.equal( + persistedReviewRiskOf({ review_risk_breakdown: {}, metadata: legacyMeta }), + 68 + ); + // The combined score never drops below the column. + assert.equal(storedRiskSplit(measured, undefined, 60)?.combined, 60); + assert.equal( + storedRiskSplit({ total_reviews: 12 }), + null, + 'an older breakdown keeps the current reading' + ); + + const row = { + id: 'o1', + merchant_id: 'm1', + platform: 'shopee', + title: 'Phone', + price_current: 1_000_000, + url: 'https://shopee.vn/p', + merchant_name: 'Shop', + merchant_is_official: false, + price_original: null, + stock: 1, + rating_star: 5, + rating_count: 1, + }; + const viewNoReviews = mapTrustViewRowToMerchantOffer( + { ...row, review_risk_score: 42, review_risk_breakdown: noReviews }, + 0 + ); + assert.equal( + hasReviewRiskEvidence(viewNoReviews.merchant.review_risk_breakdown), + false, + 'no analysed review: no review risk number' + ); + assert.notEqual(viewNoReviews.merchant.review_risk_score, 42); + assert.equal(viewNoReviews.merchant.risk_score, 42, 'combined drives trust'); + const viewMeasured = mapTrustViewRowToMerchantOffer( + { ...row, review_risk_score: 42, review_risk_breakdown: measured }, + 0 + ); + assert.equal(viewMeasured.merchant.review_risk_score, 28); + assert.equal(viewMeasured.merchant.risk_score, 42); + + const offer = mapOfferRowToMerchantOffer({ + id: 'o2', + platform: 'shopee', + title: 'Phone', + price_current: 1_000_000, + url: 'https://shopee.vn/p', + merchants: { + id: 'm2', + name: 'Shop', + platform: 'shopee', + review_risk_score: 42, + review_risk_breakdown: measured, + metadata: {}, + }, + } as unknown as Parameters[0]); + assert.equal(offer.merchant.review_risk_score, 28); + assert.equal(offer.merchant.risk_score, 42); + // Older metadata's combined score does not override the split's. + const staleMeta = mapOfferRowToMerchantOffer({ + id: 'o3', + platform: 'shopee', + title: 'Phone', + price_current: 1_000_000, + url: 'https://shopee.vn/p', + merchants: { + id: 'm3', + name: 'Shop', + platform: 'shopee', + review_risk_score: 42, + review_risk_breakdown: measured, + metadata: { combined_risk_score: 80 }, + }, + } as unknown as Parameters[0]); + assert.equal(staleMeta.merchant.risk_score, 42); + // A split only in metadata keeps its breakdown and review-only score. + const metaOnly = mapOfferRowToMerchantOffer({ + id: 'o4', + platform: 'shopee', + title: 'Phone', + price_current: 1_000_000, + url: 'https://shopee.vn/p', + merchants: { + id: 'm4', + name: 'Shop', + platform: 'shopee', + review_risk_score: 0, + review_risk_breakdown: {}, + metadata: { review_risk_breakdown: measured }, + }, + } as unknown as Parameters[0]); + assert.equal(metaOnly.merchant.review_risk_score, 28); + assert.equal( + hasReviewRiskEvidence(metaOnly.merchant.review_risk_breakdown), + true + ); + + // Merchant list and profile read the review-only part, and the combined + // score still caps trust. + const merchantRow = { + review_risk_score: 42, + review_risk_breakdown: noReviews, + metadata: {}, + }; + assert.equal(persistedReviewRiskOf(merchantRow), 0); + assert.equal(hasStoredReviewRisk(merchantRow), true); + assert.equal( + persistedReviewRiskOf({ ...merchantRow, review_risk_breakdown: measured }), + 28 + ); +} + main().catch((err) => { console.error(err); process.exit(1); diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index bcf3f2e9a..0cc30f169 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -25,6 +25,7 @@ import { getMasterProductCategorySlug, type MerchantListFilterParams, extractPersistedBreakdown, + storedRiskSplit, filterAndSortMerchants, sanitizePostgrestFilterToken, type ParsedEcommerceUrl, @@ -54,6 +55,7 @@ export { getMasterProductCategorySlug, getMasterProductHref, extractPersistedBreakdown, + storedRiskSplit, filterAndSortMerchants, sanitizePostgrestFilterToken, parseEcommerceUrlOrQuery, @@ -1102,15 +1104,24 @@ export function mapTrustViewRowToMerchantOffer( const hasSeller = sanitizeOfferString(safeRow.merchant_id) !== ''; // The view reads 0 for a seller the importer never scored; use the same // baseline as the merchant pages (#685). - const reviewRisk = !isUnscoredTrustViewRow(safeRow) - ? persistedReviewRisk - : hasSeller - ? baselineReviewRisk({ - isOfficial, - platform: safeRow.platform, - followerCount, - }) - : null; + // RS#708: the column is the combined risk; the review-only part counts + // only with analysed reviews (DOS/Rate#1268). + const riskSplit = storedRiskSplit( + safeRow.review_risk_breakdown, + undefined, + safeRow.review_risk_score + ); + const reviewRisk = riskSplit + ? (riskSplit.reviewOnly ?? 0) + : !isUnscoredTrustViewRow(safeRow) + ? persistedReviewRisk + : hasSeller + ? baselineReviewRisk({ + isOfficial, + platform: safeRow.platform, + followerCount, + }) + : null; const priceCurrent = toSafeOfferPrice(safeRow.price_current); const cleanPlatform = sanitizeOfferString(safeRow.platform).toLowerCase(); const cleanLocation = sanitizeOfferString(safeRow.merchant_location); @@ -1145,11 +1156,13 @@ export function mapTrustViewRowToMerchantOffer( rating_star: null, follower_count: toSafeNonNegativeVoteCount(followerCount, 100_000_000), location: cleanLocation || null, - risk_score: Math.max( - combinedRisk, - dosafeRisk ?? 0, - reviewRisk ?? baselineReviewRisk({ platform: safeRow.platform }) - ), + risk_score: riskSplit + ? Math.max(combinedRisk, dosafeRisk ?? 0, riskSplit.combined) + : Math.max( + combinedRisk, + dosafeRisk ?? 0, + reviewRisk ?? baselineReviewRisk({ platform: safeRow.platform }) + ), dosafe_risk_score: dosafeRisk, review_risk_score: reviewRisk, review_risk_breakdown: safeBreakdown, @@ -1193,17 +1206,29 @@ export function mapOfferRowToMerchantOffer(item: RawOfferRow): MerchantOffer { merchantObj?.review_risk_score, 0 ); + // RS#708: the column is the combined risk; the review-only part counts + // only with analysed reviews (DOS/Rate#1268). + const riskSplit = storedRiskSplit( + merchantObj?.review_risk_breakdown, + meta, + merchantObj?.review_risk_score + ); // An offer with no merchant row has no seller to give a baseline (#1099). const hasStoredScore = + riskSplit != null || (merchantObj?.review_risk_score != null && hasPersistedReviewRisk( columnReviewRisk, merchantObj.review_risk_breakdown )) || typeof meta?.review_risk_score === 'number'; - const reviewRisk = - merchantObj?.review_risk_score != null && - hasPersistedReviewRisk(columnReviewRisk, merchantObj.review_risk_breakdown) + const reviewRisk = riskSplit + ? (riskSplit.reviewOnly ?? 0) + : merchantObj?.review_risk_score != null && + hasPersistedReviewRisk( + columnReviewRisk, + merchantObj.review_risk_breakdown + ) ? columnReviewRisk : typeof meta?.review_risk_score === 'number' ? toSafeRiskScore100(meta.review_risk_score, 0) @@ -1214,8 +1239,10 @@ export function mapOfferRowToMerchantOffer(item: RawOfferRow): MerchantOffer { followerCount: Number(merchantObj.follower_count) || 0, }) : null; - const metaCombined = - typeof meta?.combined_risk_score === 'number' + // The split's combined score when there is one; older metadata otherwise. + const metaCombined = riskSplit + ? riskSplit.combined + : typeof meta?.combined_risk_score === 'number' ? toSafeRiskScore100(meta.combined_risk_score, 0) : 0; // An unknown seller is never safer than the baseline in trust and sorting. @@ -2573,6 +2600,9 @@ export function persistedReviewRiskOf(row: { metadata?: unknown; }): number { if (!row || typeof row !== 'object' || Array.isArray(row)) return 0; + // RS#708: the column is the combined risk, not the review-only part. + const split = storedRiskSplit(row.review_risk_breakdown, row.metadata); + if (split) return split.reviewOnly ?? 0; const column = toSafeRiskScore100(row.review_risk_score, 0); if ( row.review_risk_score != null && @@ -2602,6 +2632,7 @@ export function hasStoredReviewRisk(row: { metadata?: unknown; }): boolean { if (!row || typeof row !== 'object' || Array.isArray(row)) return false; + if (storedRiskSplit(row.review_risk_breakdown, row.metadata)) return true; if ( row.review_risk_score != null && hasPersistedReviewRisk( @@ -3025,11 +3056,14 @@ export async function getMerchantsList( sabotage_or_bombing_count: 0, suspicious_ratio_pct: 0, }; + // The split's combined score when there is one; older metadata otherwise. const metaCombined = - typeof meta.combined_risk_score === 'number' && + storedRiskSplit(m.review_risk_breakdown, m.metadata, m.review_risk_score) + ?.combined ?? + (typeof meta.combined_risk_score === 'number' && Number.isFinite(meta.combined_risk_score) ? toSafeRiskScore100(meta.combined_risk_score, 0) - : 0; + : 0); const combinedRisk = Math.min( 100, Math.max(baseRisk, dosafeRisk, effectiveReviewRisk, metaCombined) @@ -3547,11 +3581,17 @@ export async function getMerchantProfileBase( ? persistedReviewRisk : Math.max(dynamicRisk.reviewRiskScore, persistedReviewRisk); const breakdown = persistedBreakdown ?? dynamicRisk.breakdown; + // The split's combined score when there is one; older metadata otherwise. const metaCombined = - typeof meta.combined_risk_score === 'number' && + storedRiskSplit( + merchantRow.review_risk_breakdown, + merchantRow.metadata, + merchantRow.review_risk_score + )?.combined ?? + (typeof meta.combined_risk_score === 'number' && Number.isFinite(meta.combined_risk_score) ? toSafeRiskScore100(meta.combined_risk_score, 0) - : 0; + : 0); const combinedRisk = Math.min( 100, Math.max(baseRisk, dosafeRisk, effectiveReviewRisk, metaCombined) diff --git a/apps/web/src/lib/rate-domain-core.ts b/apps/web/src/lib/rate-domain-core.ts index 4fb094dec..672e2adef 100644 --- a/apps/web/src/lib/rate-domain-core.ts +++ b/apps/web/src/lib/rate-domain-core.ts @@ -1937,6 +1937,50 @@ export function extractProductKeywordFromUrl(rawUrl?: string | null): string { } } +/** + * The merchant risk split Rate Services writes (RS#708, DOS/Rate#1268): the + * `review_risk_score` column is the combined risk, and the breakdown (which + * metadata mirrors) carries `combined_risk_score` and the review-only + * `review_risk_score`, measured over `total_reviews` (0 when none). A source + * counts as the split only with numeric `combined_risk_score`, + * `review_risk_score` and `total_reviews`: older metadata already carried a combined score next to a + * review score, and keeps today's reading, as does an older breakdown. The + * combined score is never below the column (`column`), should they drift. + */ +export function storedRiskSplit( + breakdown: unknown, + metadata?: unknown, + column?: unknown +): { combined: number; reviewOnly: number | null } | null { + const asRecord = (raw: unknown): Record | null => + raw && typeof raw === 'object' && !Array.isArray(raw) + ? (raw as Record) + : null; + const score = (raw: unknown): number | null => + typeof raw === 'number' && Number.isFinite(raw) + ? Math.max(0, Math.min(100, Math.round(raw))) + : null; + // The column breakdown, then the one nested in metadata, then metadata's + // top level, as extractPersistedBreakdown reads them. + const meta = asRecord(metadata); + for (const source of [ + asRecord(breakdown), + asRecord(meta?.review_risk_breakdown), + meta, + ]) { + const combined = score(source?.combined_risk_score); + const total = source?.total_reviews; + if (!source || combined == null) continue; + if (typeof total !== 'number' || !Number.isFinite(total)) continue; + if (score(source.review_risk_score) == null) continue; + return { + combined: Math.max(combined, score(column) ?? 0), + reviewOnly: total > 0 ? score(source.review_risk_score) : null, + }; + } + return null; +} + /** * Whether a merchant's review risk score may be shown as a measurement. A * breakdown that counts no analysed review (0, or a malformed count) means diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index de3ee7f10..fbbe89549 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -947,7 +947,7 @@ function buildBaseRateOpenApiSpec( summary: 'List e-commerce merchants with Dual Risk Score (DOSafe + AI Seeding Risk)', description: - 'Returns verified merchants (`rate.merchants`) with filter support for platform, Official Mall status, minimum trust score, search query, and sorting. `review_risk_score` is null for a store with no analysed review.', + 'Returns verified merchants (`rate.merchants`) with filter support for platform, Official Mall status, minimum trust score, search query, and sorting. `risk_score` is the combined risk; `review_risk_score` is only the part measured on reviews, null for a store with no analysed review.', tags: ['Merchants & Dual Risk Score'], parameters: [ { @@ -1014,7 +1014,7 @@ function buildBaseRateOpenApiSpec( summary: 'Paginated merchant directory with risk tier, seeding tier & platform filters', description: - 'Returns one page (`items`, `matched`, `offset`) of the `/merchants` directory filtered by search query, platform, Official Mall status, trust risk tier (`safe`, `moderate`, `warning`), AI seeding tier (`low`, `elevated`), and sort order. `review_risk_score` is null for a store with no analysed review.', + 'Returns one page (`items`, `matched`, `offset`) of the `/merchants` directory filtered by search query, platform, Official Mall status, trust risk tier (`safe`, `moderate`, `warning`), AI seeding tier (`low`, `elevated`), and sort order. `risk_score` is the combined risk; `review_risk_score` is only the part measured on reviews, null for a store with no analysed review.', tags: ['Merchants & Dual Risk Score'], parameters: [ { @@ -1104,7 +1104,7 @@ function buildBaseRateOpenApiSpec( summary: 'Get detailed merchant audit profile, seeding breakdown & active listings', description: - 'Fetches a single merchant by UUID, slug, or `platform_shop_id` along with its active marketplace listings and AI review seeding distribution. `review_risk_score` is null for a store with no analysed review.', + 'Fetches a single merchant by UUID, slug, or `platform_shop_id` along with its active marketplace listings and AI review seeding distribution. `risk_score` is the combined risk; `review_risk_score` is only the part measured on reviews, null for a store with no analysed review.', tags: ['Merchants & Dual Risk Score'], parameters: [ { From 4542e2ab8020eba5c007d7a4e9a369df34a4de3a Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:50:35 +0700 Subject: [PATCH 48/69] docs(proposals): dos-me item catalogue rpcs and m3/m4 with rollbacks and replay (#1279) * docs(proposals): dos-me item catalogue rpcs and m3/m4 with rollbacks and replay PROPOSALS, NOT APPLIED. - Item RPCs (any time after M1+M2): search_items_v1 (one row per published Item, storage as "has a variant of that size", matching price, total_count), get_category_brand_facets_v2 and search_catalog_autocomplete_v2, granted to anon, authenticated, service_role and rate_worker; rollback drops them. - M3 + M4 (after the compatibility window): M3 backs up and retires the unknown variant of every multi-size Item through the M2 RPC; M4 checks its preconditions, sets item_id NOT NULL / CHECK, adds the listing-variant-Item guard and fills default variants. Rollback restores from the backup table. - A CI replay runs M0, M1+M2, the RPCs (as anon and rate_worker), M3/M4 twice, every check, the constraints and guard, both rollbacks and a re-apply. The .md lists the window conditions and the deviations (sync triggers kept, no master-side guard). Refs #1160 * fix(proposals): m3 backup from retire markers, null-status live, alert and earbuds aborts, split validate, default-variant trigger, richer search_items_v1 * fix(proposals): lock price alerts during m3, matching_max_price from max_price, default-variant trigger on model_key Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + .gitignore | 5 + .../2026-10-28-dos-me-m1-m2-items.sql | 6 +- .../2026-10-29-dos-me-item-rpcs-rollback.sql | 11 + .../Proposals/2026-10-29-dos-me-item-rpcs.sql | 265 +++++++++++++++ .../2026-10-29-dos-me-m3-m4-rollback.sql | 63 ++++ .../2026-10-29-dos-me-m3-m4-verify.sql | 49 +++ Docs/Proposals/2026-10-29-dos-me-m3-m4.md | 52 +++ Docs/Proposals/2026-10-29-dos-me-m3-m4.sql | 191 +++++++++++ scripts/test-m3-m4-item-rpcs-proposal.mjs | 307 ++++++++++++++++++ 10 files changed, 949 insertions(+), 3 deletions(-) create mode 100644 Docs/Proposals/2026-10-29-dos-me-item-rpcs-rollback.sql create mode 100644 Docs/Proposals/2026-10-29-dos-me-item-rpcs.sql create mode 100644 Docs/Proposals/2026-10-29-dos-me-m3-m4-rollback.sql create mode 100644 Docs/Proposals/2026-10-29-dos-me-m3-m4-verify.sql create mode 100644 Docs/Proposals/2026-10-29-dos-me-m3-m4.md create mode 100644 Docs/Proposals/2026-10-29-dos-me-m3-m4.sql create mode 100644 scripts/test-m3-m4-item-rpcs-proposal.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3380bf475..e10de01c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,9 @@ jobs: - name: Replay the DOS-Me M0 seed re-key proposal (Docker) run: node scripts/test-m0-seed-rekey-proposal.mjs + - name: Replay the DOS-Me Item RPC and M3/M4 proposals (Docker) + run: node scripts/test-m3-m4-item-rpcs-proposal.mjs + - name: Check generated rate schema types match the migrations run: pnpm check:db-types diff --git a/.gitignore b/.gitignore index 5a45935c8..48f5c2c3e 100644 --- a/.gitignore +++ b/.gitignore @@ -144,6 +144,11 @@ playwright-report-item-model/ !Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey.sql !Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-rollback.sql !Docs/Proposals/2026-10-29-dos-me-m0-seed-rekey-verify.sql +!Docs/Proposals/2026-10-29-dos-me-item-rpcs.sql +!Docs/Proposals/2026-10-29-dos-me-item-rpcs-rollback.sql +!Docs/Proposals/2026-10-29-dos-me-m3-m4.sql +!Docs/Proposals/2026-10-29-dos-me-m3-m4-rollback.sql +!Docs/Proposals/2026-10-29-dos-me-m3-m4-verify.sql * - Copy* * - Bản sao* *.dump diff --git a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql index 8d06868f7..2cc370d2d 100644 --- a/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql +++ b/Docs/Proposals/2026-10-28-dos-me-m1-m2-items.sql @@ -667,7 +667,7 @@ BEGIN END IF; SELECT mp.id INTO v_unknown FROM rate.master_products mp - WHERE mp.item_id = p_item_id AND mp.variant_key = 'unknown' AND mp.status <> 'archived'; + WHERE mp.item_id = p_item_id AND mp.variant_key = 'unknown' AND mp.status IS DISTINCT FROM 'archived'; IF v_unknown IS NULL THEN -- Already retired: move any listing a racing writer put back on it. SELECT mp.id INTO v_unknown @@ -697,9 +697,9 @@ BEGIN SELECT mp.status INTO v_status FROM rate.master_products mp WHERE mp.id = v_unknown FOR UPDATE; -- Re-check under the locks: a concurrent call may have retired it, or the sized variant -- may have been archived meanwhile. - IF v_status IS NULL OR v_status = 'archived' OR NOT EXISTS ( + IF NOT FOUND OR v_status IS NOT DISTINCT FROM 'archived' OR NOT EXISTS ( SELECT 1 FROM rate.master_products mp - WHERE mp.item_id = p_item_id AND mp.status <> 'archived' + WHERE mp.item_id = p_item_id AND mp.status IS DISTINCT FROM 'archived' AND mp.variant_key ~ '^[0-9]+(gb|tb)$') THEN RETURN 0; END IF; diff --git a/Docs/Proposals/2026-10-29-dos-me-item-rpcs-rollback.sql b/Docs/Proposals/2026-10-29-dos-me-item-rpcs-rollback.sql new file mode 100644 index 000000000..7292a92f5 --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-item-rpcs-rollback.sql @@ -0,0 +1,11 @@ +-- PROPOSAL, NOT APPLIED. Rollback of 2026-10-29-dos-me-item-rpcs.sql (DOS/Rate#1160). New +-- functions only, no data: dropping them is enough. Turn the web's Item catalogue off first +-- (RATE_ITEM_MODEL off) or it falls back to the variant RPCs. Re-runnable. + +BEGIN; +DROP FUNCTION IF EXISTS rate.search_catalog_autocomplete_v2(TEXT, INTEGER); +DROP FUNCTION IF EXISTS rate.get_category_brand_facets_v2(UUID); +DROP FUNCTION IF EXISTS rate.search_items_v1( + UUID, TEXT[], TEXT, NUMERIC, NUMERIC, NUMERIC, INTEGER[], BOOLEAN, TEXT, INTEGER, INTEGER +); +COMMIT; diff --git a/Docs/Proposals/2026-10-29-dos-me-item-rpcs.sql b/Docs/Proposals/2026-10-29-dos-me-item-rpcs.sql new file mode 100644 index 000000000..f851baeab --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-item-rpcs.sql @@ -0,0 +1,265 @@ +-- PROPOSAL, NOT APPLIED. DOS-Me migration: Item catalogue RPCs (DOS/Rate#1160, #1275; design +-- Docs/Proposals/2026-10-27-item-variant-listing-model.md section 5.1). Applies any time AFTER +-- 2026-10-28-dos-me-m1-m2-items.sql (needs rate.items and master_products.item_id); it does not +-- wait for M3/M4. Rollback: 2026-10-29-dos-me-item-rpcs-rollback.sql. +-- +-- New functions only; the variant RPCs (search_master_products, get_category_brand_facets, +-- search_catalog_autocomplete) are untouched, so the flag-off web keeps working. +-- Idempotent: CREATE OR REPLACE and grants. + +BEGIN; + +-- 1. search_items_v1: one row per published Item, with the published variants the filters +-- match. Same filter names as search_master_products where they apply; `p_storage_gb` means +-- "has a variant of that size" (design 5.1); price filters and sorts use the cheapest priced +-- matching variant (`matching_min_price`). `p_search` matches the Item's and its published +-- variants' names, keys and chips. The card links to the representative variant (the +-- cheapest priced matching one, else the first matching one) and shows `chips`, one per +-- published variant with `matching` set. `total_count` is the number of matching Items (for +-- paging), the same on every row. +CREATE OR REPLACE FUNCTION rate.search_items_v1( + p_category_id UUID DEFAULT NULL, + p_brands TEXT[] DEFAULT NULL, + p_search TEXT DEFAULT NULL, + p_min_price NUMERIC DEFAULT NULL, + p_max_price NUMERIC DEFAULT NULL, + p_min_rating NUMERIC DEFAULT NULL, + p_storage_gb INTEGER[] DEFAULT NULL, + p_has_5g BOOLEAN DEFAULT NULL, + p_sort TEXT DEFAULT 'default', + p_limit INTEGER DEFAULT 24, + p_offset INTEGER DEFAULT 0 +) +RETURNS TABLE ( + item_id UUID, + item_key TEXT, + slug TEXT, + name TEXT, + brand TEXT, + model TEXT, + category_id UUID, + thumbnail TEXT, + min_price NUMERIC, + max_price NUMERIC, + matching_min_price NUMERIC, + matching_max_price NUMERIC, + price_variant_count INTEGER, + merchant_count INTEGER, + listing_count INTEGER, + user_rating_weighted NUMERIC, + authentic_review_count INTEGER, + expert_rating NUMERIC, + average_rating NUMERIC, + total_reviews INTEGER, + default_variant_id UUID, + representative_variant_id UUID, + representative_variant_slug TEXT, + variant_keys TEXT[], + matching_variant_keys TEXT[], + chips JSONB, + created_at TIMESTAMPTZ, + total_count BIGINT +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = '' +AS $$ + WITH v AS ( + SELECT m.id, m.slug, m.item_id, m.variant_key, m.min_price, m.max_price, m.storage_gb, m.sort_order, + COALESCE( + (p_storage_gb IS NULL OR cardinality(p_storage_gb) = 0 OR m.storage_gb = ANY (p_storage_gb)) + AND (p_has_5g IS NULL OR p_has_5g = false OR m.has_5g = true), + false) AS matching, + pg_catalog.concat_ws(' ', m.name, m.model_key, COALESCE(m.key_specs ->> 'chip', '')) AS search_text + FROM rate.master_products m + WHERE m.status = 'published' AND m.item_id IS NOT NULL + ), + per_item AS ( + SELECT + v.item_id, + pg_catalog.array_agg(v.variant_key ORDER BY v.sort_order NULLS LAST, v.storage_gb NULLS LAST, v.variant_key) + AS variant_keys, + pg_catalog.array_agg(v.variant_key ORDER BY v.sort_order NULLS LAST, v.storage_gb NULLS LAST, v.variant_key) + FILTER (WHERE v.matching) AS matching_variant_keys, + min(v.min_price) FILTER (WHERE v.matching AND v.min_price > 0) AS matching_min_price, + max(GREATEST(v.max_price, v.min_price)) FILTER (WHERE v.matching AND v.min_price > 0) AS matching_max_price, + (pg_catalog.array_agg(v.id ORDER BY (v.min_price > 0) DESC NULLS LAST, v.min_price NULLS LAST, + v.sort_order NULLS LAST, v.storage_gb NULLS LAST, v.variant_key) FILTER (WHERE v.matching))[1] + AS representative_variant_id, + pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object( + 'variant_id', v.id, 'variant_key', v.variant_key, 'slug', v.slug, + 'storage_gb', v.storage_gb, 'min_price', NULLIF(v.min_price, 0), 'matching', v.matching) + ORDER BY v.sort_order NULLS LAST, v.storage_gb NULLS LAST, v.variant_key) AS chips, + pg_catalog.string_agg(v.search_text, ' ') AS variant_search_text + FROM v + GROUP BY v.item_id + ), + matched AS ( + SELECT i.*, p.variant_keys, p.matching_variant_keys, p.matching_min_price, p.matching_max_price, + p.representative_variant_id, p.chips + FROM rate.items i + JOIN per_item p ON p.item_id = i.id + WHERE i.status = 'published' + AND p.matching_variant_keys IS NOT NULL + AND (p_category_id IS NULL OR i.category_id = p_category_id) + AND ( + p_brands IS NULL + OR cardinality(p_brands) = 0 + OR pg_catalog.lower(pg_catalog.btrim(i.brand)) = ANY ( + SELECT pg_catalog.lower(pg_catalog.btrim(b)) FROM unnest(p_brands) AS b + ) + ) + AND ( + p_search IS NULL + OR pg_catalog.btrim(p_search) = '' + OR pg_catalog.concat_ws(' ', i.brand, i.name, i.model, i.item_key, + COALESCE(i.key_specs ->> 'chip', ''), + COALESCE(i.specifications #>> '{hardware,chipset}', ''), + p.variant_search_text) + ILIKE '%' || pg_catalog.regexp_replace(pg_catalog.btrim(p_search), '([%_\\])', '\\\1', 'g') || '%' ESCAPE '\' + ) + AND (p_min_price IS NULL OR p_min_price <= 0 OR p.matching_min_price >= p_min_price) + AND (p_max_price IS NULL OR p_max_price <= 0 OR p.matching_min_price <= p_max_price) + AND ( + p_min_rating IS NULL + OR p_min_rating <= 0 + OR COALESCE(NULLIF(i.user_rating_weighted, 0), NULLIF(i.expert_rating, 0), i.average_rating, 0) + >= p_min_rating + ) + ) + SELECT + m.id, m.item_key, m.slug, m.name, m.brand, m.model, m.category_id, + COALESCE(m.thumbnail, m.images[1]), + m.min_price, m.max_price, m.matching_min_price, m.matching_max_price, m.price_variant_count, + m.merchant_count, m.listing_count, m.user_rating_weighted, m.authentic_review_count, + m.expert_rating, m.average_rating, m.total_reviews, m.default_variant_id, + m.representative_variant_id, rv.slug, + m.variant_keys, m.matching_variant_keys, m.chips, m.created_at, + pg_catalog.count(*) OVER () + FROM matched m + LEFT JOIN rate.master_products rv ON rv.id = m.representative_variant_id + ORDER BY + CASE WHEN p_sort = 'price_asc' THEN m.matching_min_price END ASC NULLS LAST, + CASE WHEN p_sort = 'price_desc' THEN m.matching_min_price END DESC NULLS LAST, + CASE WHEN p_sort = 'merchants_desc' THEN m.merchant_count END DESC NULLS LAST, + CASE WHEN p_sort = 'rating_desc' THEN COALESCE(NULLIF(m.user_rating_weighted, 0), m.average_rating, 0) END + DESC NULLS LAST, + m.created_at DESC, + m.id ASC + LIMIT GREATEST(1, LEAST(COALESCE(p_limit, 24), 200)) + OFFSET GREATEST(0, COALESCE(p_offset, 0)); +$$; + +-- 2. Brand facets counted by published Item (design 5.1). +CREATE OR REPLACE FUNCTION rate.get_category_brand_facets_v2(p_category_id UUID) +RETURNS TABLE (brand TEXT, count INTEGER) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = '' +AS $$ + SELECT i.brand, COUNT(*)::INTEGER AS count + FROM rate.items i + WHERE i.status = 'published' + AND i.category_id = p_category_id + AND i.brand IS NOT NULL + AND pg_catalog.btrim(i.brand) <> '' + AND EXISTS (SELECT 1 FROM rate.master_products m + WHERE m.item_id = i.id AND m.status = 'published') + GROUP BY i.brand + ORDER BY count DESC, i.brand ASC; +$$; + +-- 3. Autocomplete by Item. SECURITY DEFINER like search_catalog_autocomplete: anon has no +-- USAGE on the pg_trgm schema; the body reads published Items only. Same result shape as v1 +-- with entity_kind 'item'. +CREATE OR REPLACE FUNCTION rate.search_catalog_autocomplete_v2( + p_query TEXT, + p_limit INTEGER DEFAULT 8 +) +RETURNS TABLE ( + entity_kind TEXT, + id UUID, + slug TEXT, + title TEXT, + subtitle TEXT, + brand_or_platform TEXT, + thumbnail TEXT, + min_price NUMERIC, + max_price NUMERIC, + rating NUMERIC, + review_count INTEGER, + merchant_or_official_count INTEGER, + similarity_score REAL +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_trgm_schema TEXT; + v_clean_q TEXT := pg_catalog.left(pg_catalog.btrim(COALESCE(p_query, '')), 100); + v_escaped_q TEXT; + v_limit INTEGER := LEAST(GREATEST(COALESCE(p_limit, 8), 1), 25); +BEGIN + IF v_clean_q = '' THEN + RETURN; + END IF; + v_escaped_q := pg_catalog.replace(pg_catalog.replace(pg_catalog.replace( + v_clean_q, '\', '\\'), '%', '\%'), '_', '\_'); + SELECT n.nspname INTO v_trgm_schema + FROM pg_catalog.pg_extension e JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'pg_trgm'; + IF v_trgm_schema IS NULL THEN + RAISE EXCEPTION 'rate.search_catalog_autocomplete_v2: pg_trgm extension is not installed'; + END IF; + RETURN QUERY EXECUTE pg_catalog.format( + 'SELECT * FROM ( + SELECT + ''item''::TEXT, + i.id, + i.slug, + i.name, + COALESCE(i.key_specs ->> ''chip'', i.model), + i.brand, + COALESCE(i.thumbnail, i.images[1]), + i.min_price, + i.max_price, + COALESCE(NULLIF(i.user_rating_weighted, 0), i.average_rating, 0)::NUMERIC, + COALESCE(NULLIF(i.authentic_review_count, 0), i.total_reviews, 0)::INTEGER, + COALESCE(i.merchant_count, 0)::INTEGER, + GREATEST( + %I.similarity(pg_catalog.lower(i.name), pg_catalog.lower($1)), + %I.similarity(pg_catalog.lower(i.brand || '' '' || i.model), pg_catalog.lower($1)), + CASE WHEN pg_catalog.lower(i.name) LIKE ''%%'' || pg_catalog.lower($2) || ''%%'' ESCAPE ''\'' + THEN 0.75::real ELSE 0.0::real END + ) AS score + FROM rate.items i + WHERE i.status = ''published'' + AND EXISTS (SELECT 1 FROM rate.master_products m + WHERE m.item_id = i.id AND m.status = ''published'') + ) s + WHERE s.score >= 0.2 + ORDER BY s.score DESC, s.id + LIMIT $3', + v_trgm_schema, v_trgm_schema + ) USING v_clean_q, v_escaped_q, v_limit; +END $$; + +-- 4. Grants: the web reads as anon / authenticated; the worker and service role too. +REVOKE EXECUTE ON FUNCTION rate.search_items_v1( + UUID, TEXT[], TEXT, NUMERIC, NUMERIC, NUMERIC, INTEGER[], BOOLEAN, TEXT, INTEGER, INTEGER +) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION rate.get_category_brand_facets_v2(UUID) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION rate.search_catalog_autocomplete_v2(TEXT, INTEGER) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION rate.search_items_v1( + UUID, TEXT[], TEXT, NUMERIC, NUMERIC, NUMERIC, INTEGER[], BOOLEAN, TEXT, INTEGER, INTEGER +) TO anon, authenticated, service_role, rate_worker; +GRANT EXECUTE ON FUNCTION rate.get_category_brand_facets_v2(UUID) + TO anon, authenticated, service_role, rate_worker; +GRANT EXECUTE ON FUNCTION rate.search_catalog_autocomplete_v2(TEXT, INTEGER) + TO anon, authenticated, service_role, rate_worker; + +COMMIT; diff --git a/Docs/Proposals/2026-10-29-dos-me-m3-m4-rollback.sql b/Docs/Proposals/2026-10-29-dos-me-m3-m4-rollback.sql new file mode 100644 index 000000000..b46431b36 --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-m3-m4-rollback.sql @@ -0,0 +1,63 @@ +-- PROPOSAL, NOT APPLIED. Rollback of 2026-10-29-dos-me-m3-m4.sql (DOS/Rate#1160). M4 first, +-- then M3 from its backup table. Run with the writers paused. Re-runnable. + +-- M4 --------------------------------------------------------------------------------------- +BEGIN; +DROP TRIGGER IF EXISTS trg_master_products_fill_default_variant ON rate.master_products; +DROP FUNCTION IF EXISTS rate.items_fill_default_variant(); +DROP TRIGGER IF EXISTS trg_products_variant_item_guard ON rate.products; +DROP FUNCTION IF EXISTS rate.products_variant_item_guard(); +ALTER TABLE rate.products DROP CONSTRAINT IF EXISTS products_item_id_not_null; +ALTER TABLE rate.master_products ALTER COLUMN item_id DROP NOT NULL; +-- default_variant_id values stay: nullable data M1+M2 already allowed. +COMMIT; + +-- M3 --------------------------------------------------------------------------------------- +BEGIN; +DO $$ +DECLARE + r record; + v_skipped bigint; +BEGIN + IF to_regclass('rate.m3_unknown_variant_backup') IS NULL THEN + RETURN; + END IF; + -- The variants first (so the listings' triggers see them live), then their listings, with + -- the hand-set-variant guard switched off for this transaction. + PERFORM set_config('rate.variant_override', 'on', true); + FOR r IN + SELECT DISTINCT ON (b.master_id) b.master_id, b.master_status + FROM rate.m3_unknown_variant_backup b + WHERE b.listing_id IS NULL + ORDER BY b.master_id, b.id + LOOP + UPDATE rate.master_products mp + SET status = r.master_status, + metadata = mp.metadata - 'status_before_retire' - 'retired_by' + WHERE mp.id = r.master_id; + END LOOP; + -- Only a listing still carrying the retire marker for that variant: any later variant or + -- Item change cleared the marker (M1 trigger), and that change wins over the rollback. + UPDATE rate.products p + SET cluster_id = b.listing_cluster_id, + variant_source = b.listing_variant_src, + metadata = p.metadata - 'retired_unknown_variant' - 'variant_source_before_retire' + FROM rate.m3_unknown_variant_backup b + WHERE b.listing_id = p.id + AND p.cluster_id IS NULL + AND p.item_id = b.item_id + AND p.metadata ->> 'retired_unknown_variant' = b.master_id::text; + SELECT count(*) INTO v_skipped + FROM rate.m3_unknown_variant_backup b + JOIN rate.products p ON p.id = b.listing_id + WHERE p.cluster_id IS DISTINCT FROM b.listing_cluster_id; + IF v_skipped > 0 THEN + RAISE NOTICE 'M3 rollback: % listing(s) changed after M3 were left as they are', v_skipped; + END IF; + FOR r IN SELECT DISTINCT b.master_id, b.item_id FROM rate.m3_unknown_variant_backup b LOOP + PERFORM rate.refresh_master_market_stats(r.master_id); + PERFORM rate.refresh_item_stats(r.item_id); + END LOOP; +END $$; +DROP TABLE IF EXISTS rate.m3_unknown_variant_backup; +COMMIT; diff --git a/Docs/Proposals/2026-10-29-dos-me-m3-m4-verify.sql b/Docs/Proposals/2026-10-29-dos-me-m3-m4-verify.sql new file mode 100644 index 000000000..d7610950e --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-m3-m4-verify.sql @@ -0,0 +1,49 @@ +-- PROPOSAL. Read-only checks for 2026-10-29-dos-me-m3-m4.sql and 2026-10-29-dos-me-item-rpcs.sql +-- (DOS/Rate#1160; design 7.3). Run the M1+M2 verification file too. The CI replay +-- (scripts/test-m3-m4-item-rpcs-proposal.mjs) runs the expect-0 ones. + +-- V1 No listing on a live unknown variant of a multi-size Item (after M3). expect 0 +SELECT count(*) AS unknown_on_multi_size +FROM rate.products p +JOIN rate.master_products mp ON mp.id = p.cluster_id +WHERE mp.variant_key = 'unknown' + AND EXISTS (SELECT 1 FROM rate.master_products s WHERE s.item_id = mp.item_id + AND s.variant_key ~ '^[0-9]+(gb|tb)$' AND s.status <> 'archived'); + +-- V2 Every variant and every listing has an Item (M4 preconditions). expect 0 +SELECT (SELECT count(*) FROM rate.master_products WHERE item_id IS NULL) + + (SELECT count(*) FROM rate.products WHERE item_id IS NULL) AS without_item; + +-- V3 A listing's variant belongs to its Item. expect 0 +SELECT count(*) AS listing_item_mismatch +FROM rate.products p JOIN rate.master_products mp ON mp.id = p.cluster_id +WHERE mp.item_id IS DISTINCT FROM p.item_id; + +-- V4 Every Item with a live variant names a default variant of its own. expect 0 +SELECT count(*) AS default_variant_missing +FROM rate.items i +WHERE EXISTS (SELECT 1 FROM rate.master_products m WHERE m.item_id = i.id AND m.status <> 'archived') + AND NOT EXISTS (SELECT 1 FROM rate.master_products m + WHERE m.id = i.default_variant_id AND m.item_id = i.id); + +-- V5 Earbuds and other single-variant Items still have their variant (M3 never retires them). expect 0 +SELECT count(*) AS single_variant_retired +FROM rate.items i +LEFT JOIN rate.categories c ON c.id = i.category_id +WHERE (c.slug = 'earbuds' OR NOT EXISTS ( + SELECT 1 FROM rate.master_products s + WHERE s.item_id = i.id AND s.variant_key ~ '^[0-9]+(gb|tb)$')) + AND EXISTS (SELECT 1 FROM rate.m3_unknown_variant_backup b + WHERE b.item_id = i.id AND b.listing_id IS NULL); + +-- V6 (information) M3's work: variants retired and listings moved to "no variant". +SELECT (SELECT count(*) FROM rate.m3_unknown_variant_backup WHERE listing_id IS NULL) AS variants_retired, + (SELECT count(*) FROM rate.m3_unknown_variant_backup WHERE listing_id IS NOT NULL) AS listings_moved, + (SELECT count(*) FROM rate.products WHERE cluster_id IS NULL AND item_id IS NOT NULL) AS no_variant_now; + +-- V7 (information) Price alerts left on retired unknown variants (an alert needs a size). +SELECT mp.model_key, count(a.id) AS alerts +FROM rate.master_products mp +JOIN rate.price_alerts a ON a.master_product_id = mp.id +WHERE mp.variant_key = 'unknown' AND mp.status = 'archived' +GROUP BY 1 ORDER BY 2 DESC; diff --git a/Docs/Proposals/2026-10-29-dos-me-m3-m4.md b/Docs/Proposals/2026-10-29-dos-me-m3-m4.md new file mode 100644 index 000000000..d7efd6ca5 --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-m3-m4.md @@ -0,0 +1,52 @@ +# Proposal: Item catalogue RPCs, and DOS-Me M3 + M4 (DOS/Rate#1160) + +**Status:** proposals for JOY, **not applied**. Zero DDL on prod from DOS/Rate; JOY applies them through DOS-Me (preview, beta, prod). Design: [`2026-10-27-item-variant-listing-model.md`](./2026-10-27-item-variant-listing-model.md), sections 4.2, 4.3, 5.1, 7, 7.2, 7.3. + +## Files + +| File | What | When | +| :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------- | +| [`2026-10-29-dos-me-item-rpcs.sql`](./2026-10-29-dos-me-item-rpcs.sql) (+ [`-rollback`](./2026-10-29-dos-me-item-rpcs-rollback.sql)) | `search_items_v1`, `get_category_brand_facets_v2`, `search_catalog_autocomplete_v2`; new functions only | any time after M1+M2 | +| [`2026-10-29-dos-me-m3-m4.sql`](./2026-10-29-dos-me-m3-m4.sql) (+ [`-rollback`](./2026-10-29-dos-me-m3-m4-rollback.sql), [`-verify`](./2026-10-29-dos-me-m3-m4-verify.sql)) | M3 (retire `unknown` on multi-size Items, with a backup table) and M4 (constraints, guard, default variants), two transactions | after the compatibility window | +| [`scripts/test-m3-m4-item-rpcs-proposal.mjs`](../../scripts/test-m3-m4-item-rpcs-proposal.mjs) | CI replay: M0, M1+M2, RPCs twice (called as `anon` and `rate_worker`), M3/M4 twice, every check, constraints and guard, both rollbacks twice, re-apply | every CI run | + +## Order of operations + +1. M0 ([`2026-10-29-dos-me-m0-seed-rekey.sql`](./2026-10-29-dos-me-m0-seed-rekey.sql)), then the seed change. +2. M1+M2 ([`2026-10-28-dos-me-m1-m2-items.sql`](./2026-10-28-dos-me-m1-m2-items.sql)). +3. **Item RPCs** (this proposal), whenever the web's Item catalogue (#1155, #1275) should stop grouping variants in the web. +4. The **compatibility window** (below). +5. **M3**, verify (V1, V5, V6, V7), then **M4**, verify (V2, V3, V4) and the M1+M2 verification file. + +## Compatibility window: conditions before M3/M4 + +- `RATE_ITEM_MODEL` is on for the importer and the web, and has run for at least one promote cycle. +- Rate Services ships Item-aware code (design task 13): its auditors treat `cluster_id IS NULL` as "no variant", its refreshes are keyed by listing and Item, and its payloads carry `item_id`. M3 creates such listings in bulk. +- The M1+M2 verification file returns 0 on every expect-0 check (the reconciliation after the last writer changed). +- No listing without an Item (`SELECT count(*) FROM rate.products WHERE item_id IS NULL` = 0): M4 aborts otherwise. +- The sitemap has been re-crawled with Item URLs (about 30 days, design section 7). +- Active price alerts on `unknown` variants of multi-size Items (V7 before M3) are decided: an alert needs a size, so each is moved to a sized variant or closed. M3 aborts while one is active. +- The importer and the Rate Services writers are **paused** for M3 and for each rollback: M3 retires and backs up in one transaction, and a listing written in between would not be in the backup. + +## What the files do + +**Item RPCs** (SECURITY INVOKER except autocomplete, which is DEFINER like v1 because anon has no USAGE on the pg_trgm schema; all read published Items and published variants only; EXECUTE for `anon`, `authenticated`, `service_role`, `rate_worker`): + +- `search_items_v1(...)`: one row per published Item with at least one published variant matching the filters. Same filter names as `search_master_products` where they apply; `p_storage_gb` means "has a variant of that size"; `p_has_5g` filters variants; `p_search` reads the Item and its published variants (name, key, chip); price filters and sorts use `matching_min_price` (cheapest priced matching variant). Returns the Item card fields and ratings (`user_rating_weighted`, `expert_rating`, `average_rating`, review counts), `matching_min_price` / `matching_max_price`, the representative variant (`representative_variant_id` and `_slug`: the cheapest priced matching variant, else the first matching one, so the card links to the size the filter asked for), `chips` (one JSON object per published variant: `variant_id`, `variant_key`, `slug`, `storage_gb`, `min_price`, `matching`), `variant_keys`, `matching_variant_keys`, `default_variant_id` and `total_count` for paging. Limit capped at 200. +- `get_category_brand_facets_v2(p_category_id)`: brands counted by published Item. +- `search_catalog_autocomplete_v2(p_query, p_limit)`: v1's result shape with `entity_kind = 'item'`. + +**M3:** aborts if an earbuds master still has a sized key (apply M0 first) or an active price alert sits on an `unknown` variant it would retire. Then, for every Item with a live sized variant (never earbuds) and a live `unknown` variant (a NULL status counts as live), calls `rate.retire_item_unknown_variant` (M2): the listings keep their Item with `cluster_id = NULL`, the variant is archived, stats refresh. It then backs up the variant and exactly the listings the RPC marked (`metadata.retired_unknown_variant`) into `rate.m3_unknown_variant_backup` (RLS on, service role only). Single-variant Items are untouched. + +**M4**, two transactions: (1) aborts unless every variant and listing has an Item; then `master_products.item_id NOT NULL`, `products_item_id_not_null` added as a CHECK `NOT VALID`, the `products_variant_item_guard` trigger (a listing's variant belongs to its Item), `items.default_variant_id` filled where unset (cheapest priced published variant, else the first published, else the first live one) and the `master_products_fill_default_variant` trigger, which names a default for an Item that has none when a live variant is written; (2) `VALIDATE CONSTRAINT` on its own, which holds only SHARE UPDATE EXCLUSIVE, so the listings table stays readable and writable during the scan. + +## Deviations from the design (decisions for JOY) + +- **M4 keeps the M1 sync triggers.** The design drops them in M4. The importer still relies on `master_products_sync_item` to create a new Item when it seeds a master (the matcher's `itemIdOfMaster` reads the Item the trigger made), and the listing trigger also carries the hand-set-variant guard and the item-follows-variant rule. Dropping them waits until the importer inserts Items itself (a later step); the M4 guard is the backstop meanwhile. +- **No master-side guard on `item_id` changes.** The design adds a companion trigger rejecting a variant's Item change while it has listings; M1's `master_products_move_listings` already moves those listings in the same statement, and V3 checks the result. +- **The M1+M2 retire RPC now treats a NULL status as live** (`IS DISTINCT FROM 'archived'`), the same as M3; the M1+M2 proposal file is updated in this PR (still not applied). + +## Rollback + +- RPCs: drop the three functions (turn the web's Item catalogue off first). +- M3/M4 (writers paused): drops the fill trigger, the guard, the CHECK and the NOT NULL, then restores the retired variants' status and their listings from the backup table (only listings still without a variant, on the same Item, still carrying the retire marker for that variant; any other listing changed after M3 and is left alone, with a NOTICE giving the count), refreshes stats, and drops the backup table. `default_variant_id` stays (nullable data M1+M2 already allowed). diff --git a/Docs/Proposals/2026-10-29-dos-me-m3-m4.sql b/Docs/Proposals/2026-10-29-dos-me-m3-m4.sql new file mode 100644 index 000000000..4c8a7b243 --- /dev/null +++ b/Docs/Proposals/2026-10-29-dos-me-m3-m4.sql @@ -0,0 +1,191 @@ +-- PROPOSAL, NOT APPLIED. DOS-Me migrations M3 (retire `unknown` on multi-size Items) and M4 +-- (constraints) for the Item model (DOS/Rate#1160; design +-- Docs/Proposals/2026-10-27-item-variant-listing-model.md sections 4.2, 4.3, 7, 7.2). +-- Applies only AFTER the compatibility window (see 2026-10-29-dos-me-m3-m4.md): M0 and M1+M2 +-- applied, every writer Item-aware (importer flag on, Rate Services task 13 handling NULL +-- cluster_id), and the M1+M2 reconciliation queries at zero. Run M3 with the importer and +-- Rate Services writers paused. JOY applies it through DOS-Me. +-- Rollback: 2026-10-29-dos-me-m3-m4-rollback.sql. Verification: +-- 2026-10-29-dos-me-m3-m4-verify.sql. +-- +-- Separate transactions (M3; M4 constraints; M4 validation) so each step can ship and be +-- verified on its own; every step aborts with a clear message when a precondition does not +-- hold, and every step is idempotent. + +-- M3 --------------------------------------------------------------------------------------- +BEGIN; + +-- What M3 changed, for the rollback (design 7.2): one row per retired variant (listing_id +-- NULL) and one per listing moved off it. +CREATE TABLE IF NOT EXISTS rate.m3_unknown_variant_backup ( + id bigserial PRIMARY KEY, + item_id uuid NOT NULL, + master_id uuid NOT NULL, + master_status text, + listing_id uuid, + listing_cluster_id uuid, + listing_variant_src text, + retired_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE rate.m3_unknown_variant_backup ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON rate.m3_unknown_variant_backup FROM PUBLIC, anon, authenticated; +GRANT ALL ON rate.m3_unknown_variant_backup TO service_role; + +-- Preconditions (design 7.3): no earbuds master with a sized key (seed drift, fixed by M0), +-- and no price alert on an unknown variant M3 would archive (an alert needs a size; JOY +-- decides per alert before M3: move it to a sized variant or close it). The web keeps +-- running, so price_alerts is locked against writes (reads continue) until M3 commits: no +-- alert can appear between this check and the retire. +LOCK TABLE rate.price_alerts IN SHARE MODE; +DO $$ +DECLARE + v_sized_earbuds text; + v_alerted text; +BEGIN + SELECT string_agg(mp.model_key, ', ') INTO v_sized_earbuds + FROM rate.master_products mp JOIN rate.categories c ON c.id = mp.category_id + WHERE c.slug = 'earbuds' AND split_part(mp.model_key, ':', 3) <> 'unknown'; + IF v_sized_earbuds IS NOT NULL THEN + RAISE EXCEPTION 'M3: earbuds masters with a sized key (apply M0 first): %', v_sized_earbuds; + END IF; + SELECT string_agg(DISTINCT u.model_key, ', ') INTO v_alerted + FROM rate.master_products u + JOIN rate.price_alerts a ON a.master_product_id = u.id AND a.is_active + WHERE u.variant_key = 'unknown' AND u.status IS DISTINCT FROM 'archived' + AND EXISTS (SELECT 1 FROM rate.master_products s + WHERE s.item_id = u.item_id AND s.status IS DISTINCT FROM 'archived' + AND s.variant_key ~ '^[0-9]+(gb|tb)$'); + IF v_alerted IS NOT NULL THEN + RAISE EXCEPTION 'M3: price alerts on unknown variants to retire, decide them first: %', v_alerted; + END IF; +END $$; + +-- Every multi-size Item (a live sized variant, never earbuds) still holding a live unknown +-- variant: retire it through the M2 RPC (same lock order and markers as the importer's +-- calls), then back up exactly what the RPC moved, read from its markers (they include the +-- listings its second sweep caught). +DO $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT i.id AS item_id, u.id AS master_id, u.status + FROM rate.items i + JOIN rate.master_products u + ON u.item_id = i.id AND u.variant_key = 'unknown' AND u.status IS DISTINCT FROM 'archived' + LEFT JOIN rate.categories c ON c.id = i.category_id + WHERE COALESCE(c.slug, '') <> 'earbuds' + AND EXISTS (SELECT 1 FROM rate.master_products s + WHERE s.item_id = i.id AND s.status IS DISTINCT FROM 'archived' + AND s.variant_key ~ '^[0-9]+(gb|tb)$') + ORDER BY i.id + LOOP + PERFORM rate.retire_item_unknown_variant(r.item_id); + IF (SELECT status FROM rate.master_products WHERE id = r.master_id) = 'archived' THEN + INSERT INTO rate.m3_unknown_variant_backup (item_id, master_id, master_status) + VALUES (r.item_id, r.master_id, r.status); + INSERT INTO rate.m3_unknown_variant_backup + (item_id, master_id, master_status, listing_id, listing_cluster_id, listing_variant_src) + SELECT r.item_id, r.master_id, r.status, p.id, r.master_id, + p.metadata ->> 'variant_source_before_retire' + FROM rate.products p + WHERE p.metadata ->> 'retired_unknown_variant' = r.master_id::text; + END IF; + END LOOP; +END $$; + +COMMIT; + +-- M4 (constraints) --------------------------------------------------------------------------- +BEGIN; + +-- Preconditions: every variant and every listing has an Item (a listing that never matched +-- must be matched or archived first, design 7.1). +DO $$ +DECLARE + v_masters bigint; + v_listings bigint; +BEGIN + SELECT count(*) INTO v_masters FROM rate.master_products WHERE item_id IS NULL; + SELECT count(*) INTO v_listings FROM rate.products WHERE item_id IS NULL; + IF v_masters > 0 OR v_listings > 0 THEN + RAISE EXCEPTION 'M4: % master(s) and % listing(s) without item_id; reconcile first', v_masters, v_listings; + END IF; +END $$; + +-- master_products is small (one row per variant): the NOT NULL scan is short. +ALTER TABLE rate.master_products ALTER COLUMN item_id SET NOT NULL; +-- The listings CHECK is added NOT VALID here (no scan) and validated in its own transaction +-- below, which takes only SHARE UPDATE EXCLUSIVE, so reads and writes keep running. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint + WHERE conrelid = 'rate.products'::regclass AND conname = 'products_item_id_not_null') THEN + ALTER TABLE rate.products ADD CONSTRAINT products_item_id_not_null CHECK (item_id IS NOT NULL) NOT VALID; + END IF; +END $$; + +-- A listing's variant must belong to the listing's Item (design 4.3). The M1 sync trigger +-- (trg_products_sync_item, which runs first by name) already derives item_id from the +-- variant; this guard is the backstop for any write that gets around it. +CREATE OR REPLACE FUNCTION rate.products_variant_item_guard() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = '' +AS $$ +BEGIN + IF NEW.cluster_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM rate.master_products mp + WHERE mp.id = NEW.cluster_id AND mp.item_id = NEW.item_id) THEN + RAISE EXCEPTION 'listing % variant % is not a variant of item %', NEW.id, NEW.cluster_id, NEW.item_id + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END $$; +REVOKE EXECUTE ON FUNCTION rate.products_variant_item_guard() FROM PUBLIC, anon, authenticated; +DROP TRIGGER IF EXISTS trg_products_variant_item_guard ON rate.products; +CREATE TRIGGER trg_products_variant_item_guard + BEFORE INSERT OR UPDATE OF cluster_id, item_id ON rate.products + FOR EACH ROW EXECUTE FUNCTION rate.products_variant_item_guard(); + +-- Every Item names its default variant: the cheapest priced published one, else the first +-- published one, else the first live one (by sort_order, storage, key). Only where unset. +UPDATE rate.items i +SET default_variant_id = d.id +FROM ( + SELECT DISTINCT ON (m.item_id) m.item_id, m.id + FROM rate.master_products m + WHERE m.status IS DISTINCT FROM 'archived' + ORDER BY m.item_id, + (m.status = 'published') DESC NULLS LAST, + (m.min_price > 0) DESC NULLS LAST, + m.min_price ASC NULLS LAST, + m.sort_order NULLS LAST, m.storage_gb NULLS LAST, m.variant_key, m.id +) d +WHERE d.item_id = i.id AND i.default_variant_id IS NULL; + +-- ...and an Item created later (or whose default was cleared) gets its first live variant +-- as default when that variant is written (lock order variant, then Item). +CREATE OR REPLACE FUNCTION rate.items_fill_default_variant() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF NEW.status IS DISTINCT FROM 'archived' THEN + UPDATE rate.items SET default_variant_id = NEW.id + WHERE id = NEW.item_id AND default_variant_id IS NULL; + END IF; + RETURN NULL; +END $$; +REVOKE EXECUTE ON FUNCTION rate.items_fill_default_variant() FROM PUBLIC, anon, authenticated; +DROP TRIGGER IF EXISTS trg_master_products_fill_default_variant ON rate.master_products; +CREATE TRIGGER trg_master_products_fill_default_variant + AFTER INSERT OR UPDATE OF status, item_id, model_key ON rate.master_products + FOR EACH ROW EXECUTE FUNCTION rate.items_fill_default_variant(); + +COMMIT; + +-- M4 (validation, own transaction: SHARE UPDATE EXCLUSIVE only) ------------------------------- +ALTER TABLE rate.products VALIDATE CONSTRAINT products_item_id_not_null; diff --git a/scripts/test-m3-m4-item-rpcs-proposal.mjs b/scripts/test-m3-m4-item-rpcs-proposal.mjs new file mode 100644 index 000000000..e9b674fd5 --- /dev/null +++ b/scripts/test-m3-m4-item-rpcs-proposal.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +// Replays the DOS-Me Item RPC and M3/M4 PROPOSALS (DOS/Rate#1160) on the migration replay +// stack, after M0 and M1+M2 in the order JOY applies them: a small catalog (a multi-size +// Item with a live unknown variant, an earbuds Item, a draft Item), the Item RPCs twice and +// called as anon and rate_worker, M3 and M4 twice, every verification query, the +// constraints and guard, both rollbacks twice, and a full re-apply. Nothing touches a real +// database. +// +// Usage: node scripts/test-m3-m4-item-rpcs-proposal.mjs (needs Docker) + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { repoRoot, startRateDb } from './lib/rate-db.mjs'; + +const read = (name) => + readFileSync(path.join(repoRoot, 'Docs', 'Proposals', name), 'utf8'); +const m0 = read('2026-10-29-dos-me-m0-seed-rekey.sql'); +const m1m2 = read('2026-10-28-dos-me-m1-m2-items.sql'); +const m1m2Verify = read('2026-10-28-dos-me-m1-m2-items-verify.sql'); +const rpcs = read('2026-10-29-dos-me-item-rpcs.sql'); +const rpcsRollback = read('2026-10-29-dos-me-item-rpcs-rollback.sql'); +const m3m4 = read('2026-10-29-dos-me-m3-m4.sql'); +const m3m4Rollback = read('2026-10-29-dos-me-m3-m4-rollback.sql'); +const m3m4Verify = read('2026-10-29-dos-me-m3-m4-verify.sql'); + +const container = `rate-m3-m4-proposal-${process.pid}`; +const removeContainer = () => + spawnSync('docker', ['rm', '--force', container], { timeout: 30_000 }); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + removeContainer(); + process.exit(130); + }); +} + +const expectZero = (file) => + [ + ...file.matchAll( + /^-- (V\d+) [^\n]*expect 0\n(?:--[^\n]*\n)*(SELECT[\s\S]*?;)/gm + ), + ].map(([, id, query]) => ({ id, query })); +const m3m4Checks = expectZero(m3m4Verify); +const m1m2Checks = expectZero(m1m2Verify); +assert.equal( + m3m4Checks.length, + 5, + 'the M3/M4 verification lists its expect-0 queries' +); + +const PHONES = 'c0000000-0000-4000-8000-000000000001'; +const EARBUDS = 'c0000000-0000-4000-8000-000000000002'; +const m = { + p128: 'a0000000-0000-4000-8000-000000000128', + p256: 'a0000000-0000-4000-8000-000000000256', + pUnknown: 'a0000000-0000-4000-8000-0000000000aa', + buds: 'a0000000-0000-4000-8000-0000000000bd', + draft: 'a0000000-0000-4000-8000-0000000000df', +}; + +try { + const { sql, asRole } = startRateDb({ name: container }); + const checkAll = (checks, when) => { + for (const { id, query } of checks) + assert.equal(sql(query), '0', `${id} after ${when}`); + }; + + sql(` + INSERT INTO rate.categories (id, name, slug) VALUES + ('${PHONES}', 'Điện thoại', 'smartphones'), ('${EARBUDS}', 'Tai nghe', 'earbuds'); + INSERT INTO rate.merchants (id, platform, platform_shop_id, name, slug) VALUES + ('e0000000-0000-4000-8000-000000000001', 'shopee', 's1', 'Shop 1', 'shop-1'); + INSERT INTO rate.master_products (id, brand, model, model_key, name, slug, category_id, status, + storage_gb, min_price, max_price, has_5g, key_specs) + VALUES + ('${m.p128}', 'Acme', 'Phone X', 'acme:phone-x:128gb', 'Acme Phone X 128GB', 'acme-phone-x-128gb', + '${PHONES}', 'published', 128, 10000000, 10000000, true, '{"chip": "Acme A1"}'), + ('${m.p256}', 'Acme', 'Phone X', 'acme:phone-x:256gb', 'Acme Phone X 256GB', 'acme-phone-x-256gb', + '${PHONES}', 'published', 256, 12000000, 15000000, true, '{"chip": "Acme A1"}'), + ('${m.pUnknown}', 'Acme', 'Phone X', 'acme:phone-x:unknown', 'Acme Phone X', 'acme-phone-x-unknown', + '${PHONES}', 'published', NULL, 0, 0, true, '{}'), + ('${m.buds}', 'Acme', 'Buds', 'acme:buds:unknown', 'Acme Buds', 'acme-buds', + '${EARBUDS}', 'published', NULL, 2000000, 2000000, false, '{}'), + ('${m.draft}', 'Acme', 'Draft', 'acme:draft:128gb', 'Acme Draft 128GB', 'acme-draft-128gb', + '${PHONES}', 'draft', 128, 5000000, 5000000, false, '{}'); + INSERT INTO rate.products (id, merchant_id, platform, platform_item_id, title, price_current, url, cluster_id) + VALUES + ('b0000000-0000-4000-8000-000000000001', 'e0000000-0000-4000-8000-000000000001', 'shopee', 'u1', + 'Acme Phone X', 9500000, 'https://shopee.vn/u1', '${m.pUnknown}'), + ('b0000000-0000-4000-8000-000000000002', 'e0000000-0000-4000-8000-000000000001', 'shopee', 'u2', + 'Acme Phone X', 9600000, 'https://shopee.vn/u2', '${m.pUnknown}'), + ('b0000000-0000-4000-8000-000000000003', 'e0000000-0000-4000-8000-000000000001', 'shopee', 'x128', + 'Acme Phone X 128GB', 10000000, 'https://shopee.vn/x128', '${m.p128}'), + ('b0000000-0000-4000-8000-000000000004', 'e0000000-0000-4000-8000-000000000001', 'shopee', 'b1', + 'Acme Buds', 2000000, 'https://shopee.vn/b1', '${m.buds}'); + `); + + sql(m0); + sql(m1m2); + sql(rpcs); + sql(rpcs); + + // Item RPCs, as the web (anon) and the worker call them. + const search = (args) => + asRole( + 'anon', + `SELECT slug || '|' || array_to_string(matching_variant_keys, ',') || '|' || coalesce(matching_min_price::bigint::text, '-') || '|' || total_count + FROM rate.search_items_v1(${args}) ORDER BY slug` + ); + assert.equal( + search(`p_category_id => '${PHONES}'`), + 'acme-phone-x|128gb,256gb,unknown|10000000|1', + 'one row per published Item; the draft Item is not listed' + ); + assert.equal( + search(`p_category_id => '${PHONES}', p_storage_gb => ARRAY[256]`), + 'acme-phone-x|256gb|12000000|1', + '?storage= means "has a variant of that size"; the price is that size' + ); + assert.equal( + search(`p_search => 'phone x'`), + 'acme-phone-x|128gb,256gb,unknown|10000000|1' + ); + assert.equal( + search(`p_category_id => '${PHONES}', p_storage_gb => ARRAY[512]`), + '' + ); + assert.equal( + search(`p_search => '256GB'`), + 'acme-phone-x|128gb,256gb,unknown|10000000|1', + 'the search reads variant names too' + ); + assert.equal( + asRole( + 'anon', + `SELECT representative_variant_slug || '|' || matching_max_price::bigint || '|' || + (SELECT string_agg((c ->> 'variant_key') || ':' || (c ->> 'matching'), ',') + FROM jsonb_array_elements(chips) c) + FROM rate.search_items_v1(p_category_id => '${PHONES}', p_storage_gb => ARRAY[256])` + ), + 'acme-phone-x-256gb|15000000|128gb:false,256gb:true,unknown:false', + 'the card links to the cheapest matching variant and shows every size as a chip' + ); + assert.equal( + asRole( + 'anon', + `SELECT representative_variant_slug FROM rate.search_items_v1(p_category_id => '${PHONES}')` + ), + 'acme-phone-x-128gb', + 'an unpriced unknown variant is never the representative' + ); + assert.equal( + asRole( + 'anon', + `SELECT brand || '=' || count FROM rate.get_category_brand_facets_v2('${PHONES}')` + ), + 'Acme=1' + ); + assert.equal( + asRole( + 'anon', + `SELECT entity_kind || '|' || slug FROM rate.search_catalog_autocomplete_v2('acme phone', 5) LIMIT 1` + ), + 'item|acme-phone-x' + ); + assert.equal( + asRole( + 'rate_worker', + `SELECT count(*) FROM rate.search_items_v1(p_limit => 5)` + ), + '2' + ); + + // M3 + M4, twice. + sql(m3m4); + sql(m3m4); + checkAll(m3m4Checks, 'M3/M4'); + checkAll(m1m2Checks, 'M3/M4 (M1+M2 checks)'); + assert.equal( + sql(`SELECT status FROM rate.master_products WHERE id = '${m.pUnknown}'`), + 'archived' + ); + assert.equal( + sql( + `SELECT count(*) FROM rate.products WHERE cluster_id IS NULL AND item_id IS NOT NULL` + ), + '2', + 'the unknown variant listings keep their Item with no variant' + ); + assert.equal( + sql(`SELECT count(*) FILTER (WHERE listing_id IS NULL) || '|' || count(*) FILTER (WHERE listing_id IS NOT NULL) + FROM rate.m3_unknown_variant_backup`), + '1|2', + 'one retired variant and its two listings backed up, once' + ); + assert.equal( + sql(`SELECT status FROM rate.master_products WHERE id = '${m.buds}'`), + 'published', + 'earbuds keep their single unknown variant' + ); + assert.equal( + sql( + `SELECT default_variant_id FROM rate.items WHERE item_key = 'acme:phone-x'` + ), + m.p128, + 'the default variant is the cheapest priced published one' + ); + // After M3 the archived unknown no longer counts as a matching size. + assert.equal( + search(`p_category_id => '${PHONES}'`), + 'acme-phone-x|128gb,256gb|10000000|1' + ); + + // M4 constraints. + assert.throws( + () => + sql(`INSERT INTO rate.products (merchant_id, platform, platform_item_id, title, price_current, url) + VALUES ('e0000000-0000-4000-8000-000000000001', 'shopee', 'orphan', 'X', 1, 'https://shopee.vn/o')`), + /products_item_id_not_null/ + ); + // The M1 sync trigger re-derives item_id, so switch it off to reach the NOT NULL. + assert.throws( + () => + sql(`BEGIN; + ALTER TABLE rate.master_products DISABLE TRIGGER trg_master_products_sync_item; + UPDATE rate.master_products SET item_id = NULL WHERE id = '${m.p128}'; + COMMIT;`), + /null value in column "item_id"/ + ); + sql('ROLLBACK;'); + // A new variant of an Item without a default fills it in. + sql( + `UPDATE rate.items SET default_variant_id = NULL WHERE item_key = 'acme:phone-x'` + ); + sql( + `UPDATE rate.master_products SET status = 'published' WHERE id = '${m.p256}'` + ); + assert.equal( + sql( + `SELECT default_variant_id FROM rate.items WHERE item_key = 'acme:phone-x'` + ), + m.p256, + 'the fill trigger names a default variant when it is missing' + ); + sql( + `UPDATE rate.items SET default_variant_id = '${m.p128}' WHERE item_key = 'acme:phone-x'` + ); + assert.throws( + () => + sql(`BEGIN; + ALTER TABLE rate.products DISABLE TRIGGER trg_products_sync_item; + UPDATE rate.products SET item_id = (SELECT id FROM rate.items WHERE item_key = 'acme:buds') + WHERE platform_item_id = 'x128'; + COMMIT;`), + /is not a variant of item/ + ); + sql('ROLLBACK;'); + + // Rollbacks (twice each), then everything again. + sql(m3m4Rollback); + sql(m3m4Rollback); + assert.equal( + sql(`SELECT status || '|' || (SELECT count(*) FROM rate.products WHERE cluster_id = '${m.pUnknown}') + FROM rate.master_products WHERE id = '${m.pUnknown}'`), + 'published|2', + 'M3 rollback puts the unknown variant and its listings back' + ); + assert.equal( + sql(`SELECT to_regclass('rate.m3_unknown_variant_backup') IS NULL`), + 't' + ); + assert.equal( + sql( + `SELECT count(*) FROM pg_constraint WHERE conname = 'products_item_id_not_null'` + ), + '0' + ); + sql(rpcsRollback); + sql(rpcsRollback); + assert.equal( + sql( + `SELECT to_regprocedure('rate.search_catalog_autocomplete_v2(text,integer)') IS NULL` + ), + 't' + ); + sql(rpcs); + // An active price alert on an unknown variant M3 would retire stops M3 (design 7.2). + sql(`INSERT INTO rate.price_alerts (master_product_id, client_fingerprint, target_price) + VALUES ('${m.pUnknown}', 'fp-1', 9000000)`); + assert.throws(() => sql(m3m4), /price alerts on unknown variants/); + assert.equal( + sql(`SELECT status FROM rate.master_products WHERE id = '${m.pUnknown}'`), + 'published', + 'the aborted M3 changed nothing' + ); + sql(`UPDATE rate.price_alerts SET is_active = false`); + sql(m3m4); + checkAll(m3m4Checks, 'rollback and re-apply'); + + console.log( + `Item RPC and M3/M4 proposals passed: RPCs as anon and rate_worker, M3/M4 twice, ${m3m4Checks.length} checks, constraints, rollbacks and re-apply` + ); +} finally { + removeContainer(); +} From 5d59ac46c35b31f898dd3244a66d278763a6aec8 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:53:03 +0700 Subject: [PATCH 49/69] fix(web): leaderboard ranks 100 items and links item pairs (#1286) * fix(web): leaderboard ranks 100 items and links item pairs Part of the #1155 follow-ups, behind RATE_ITEM_MODEL: each leaderboard tab reads past the first 100 variants until it has its 100 best-rated Items, the reviews kept out of an Item's rating are summed over all its sizes, the leaderboard JSON-LD and versus suggestions link Item URLs instead of variant URLs that 308, and a card shows one chip per size label. Flag off, everything reads and renders as before. Co-Authored-By: Claude Opus 5.5 --- .../web/src/app/[locale]/leaderboard/page.tsx | 39 ++++++++++++---- .../src/lib/__tests__/item-directory.test.ts | 45 +++++++++++++++++-- apps/web/src/lib/item-directory.ts | 43 +++++++++++++++--- apps/web/src/lib/leaderboard-engine.ts | 6 ++- 4 files changed, 114 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/[locale]/leaderboard/page.tsx b/apps/web/src/app/[locale]/leaderboard/page.tsx index 901bfefed..270f41a1b 100644 --- a/apps/web/src/app/[locale]/leaderboard/page.tsx +++ b/apps/web/src/app/[locale]/leaderboard/page.tsx @@ -10,7 +10,7 @@ import { Link } from '@/lib/navigation'; import { getCachedFilteredReviewCountsByMaster, getCachedMasterProducts, - groupCatalogRowsByItem, + getItemCatalogPage, getCrossPlatformDealRadar, } from '@/server/rate'; import { @@ -19,6 +19,7 @@ import { } from '@/lib/rate-api'; import { normalizeRateLocale, type SupportedRateLocale } from '@/lib/rate-i18n'; import { defaultOpenGraph } from '@/lib/seo'; +import { catalogRowSlug, sumCountsByItemRow } from '@/lib/item-directory'; import PageShell from '../components/page-shell'; import CompareTrayBar from '@/components/compare/CompareTrayBar'; import LeaderboardExplorer from '@/components/leaderboard/LeaderboardExplorer'; @@ -89,12 +90,29 @@ export default async function LeaderboardPage({ params }: PageProps) { await Promise.all([ Promise.all( [...VALID_CATEGORIES].map((categorySlug) => - getCachedMasterProducts({ - categorySlug, - limit: 100, - sort: 'rating_desc', - // RATE_ITEM_MODEL on: one slot per Item (DOS/Rate#1155). - }).then(groupCatalogRowsByItem) + // RATE_ITEM_MODEL on: the 100 best-rated Items, read past the + // first 100 variants so sizes do not crowd a tab (#1155, #1275); + // off: null, and the 100 best-rated variants as before. + getItemCatalogPage( + (offset, limit) => + getCachedMasterProducts({ + categorySlug, + limit, + offset, + sort: 'rating_desc', + }), + 0, + 100, + 'rating_desc' + ).then( + (items) => + items ?? + getCachedMasterProducts({ + categorySlug, + limit: 100, + sort: 'rating_desc', + }) + ) ) ), // No fallback: the page is cached, so a null radar would be served for @@ -145,7 +163,7 @@ export default async function LeaderboardPage({ params }: PageProps) { '@type': 'ListItem', position: index + 1, name: p.name, - url: `${siteUrl}${getMasterProductHref(p)}`, + url: `${siteUrl}${getMasterProductHref({ ...p, slug: catalogRowSlug(p) })}`, })), }; @@ -354,7 +372,10 @@ export default async function LeaderboardPage({ params }: PageProps) { diff --git a/apps/web/src/lib/__tests__/item-directory.test.ts b/apps/web/src/lib/__tests__/item-directory.test.ts index a09f542ed..55486fefb 100644 --- a/apps/web/src/lib/__tests__/item-directory.test.ts +++ b/apps/web/src/lib/__tests__/item-directory.test.ts @@ -5,6 +5,7 @@ import { countBrandsByItem, groupRowsByItem, itemSizeChips, + sumCountsByItemRow, sortItemRows, sitemapRowsByItem, type ItemLinkInput, @@ -42,8 +43,18 @@ function main() { merchant_count: 7, }, variants: [ - { variant_key: '128gb', storage_gb: 128, min_price: 18_000_000 }, - { variant_key: '256gb', storage_gb: 256, min_price: 21_000_000 }, + { + id: 'm-128', + variant_key: '128gb', + storage_gb: 128, + min_price: 18_000_000, + }, + { + id: 'm-256', + variant_key: '256gb', + storage_gb: 256, + min_price: 21_000_000, + }, ], }; const links = new Map([ @@ -112,8 +123,8 @@ function main() { { ...card, item_variants: [ - { variant_key: 'unknown', storage_gb: null, min_price: 0 }, - { variant_key: 'standard', storage_gb: null, min_price: 0 }, + { id: 'u', variant_key: 'unknown', storage_gb: null, min_price: 0 }, + { id: 's', variant_key: 'standard', storage_gb: null, min_price: 0 }, ], }, '/x', @@ -123,6 +134,32 @@ function main() { 'earbuds and other sizeless Items get no chips' ); assert.deepEqual(itemSizeChips(grouped[1]!, '/x', String), []); + // One chip per label: a later connectivity axis (Q3) reads the same. + assert.deepEqual( + itemSizeChips( + { + ...card, + item_variants: [ + ...card.item_variants!, + { + id: 'm-256c', + variant_key: '256gb-cellular', + storage_gb: 256, + min_price: 0, + }, + ], + }, + '/x', + (gb) => `${gb}GB` + ).map((c) => c.href), + ['/x?v=128gb', '/x?v=256gb'] + ); + + // Reviews kept out of an Item's rating are all its sizes' (#1275). + assert.deepEqual( + sumCountsByItemRow(grouped, { 'm-128': 2, 'm-256': 3, 'm-other': 1 }), + { 'm-128': 5, 'm-256': 3, 'm-other': 1 } + ); // Brand facets: one count per Item, not per variant. assert.deepEqual( diff --git a/apps/web/src/lib/item-directory.ts b/apps/web/src/lib/item-directory.ts index e78b94062..b0fb6550c 100644 --- a/apps/web/src/lib/item-directory.ts +++ b/apps/web/src/lib/item-directory.ts @@ -8,6 +8,8 @@ import type { MasterProduct } from '@repo/shared-data'; /** One size of an Item, as a chip on its card. */ export interface ItemVariantChip { + /** The variant (`rate.master_products`) id. */ + id: string; /** The literal key segment: 256gb, 1024gb, standard, unknown. */ variant_key: string; storage_gb: number | null; @@ -30,6 +32,7 @@ export type ItemCatalogRow = MasterProduct & { export interface ItemLinkInput { item: { id: string; slug: string; name: string; merchant_count: number }; variants: readonly { + id: string; variant_key: string; storage_gb: number | null; min_price: number; @@ -75,6 +78,7 @@ export function groupRowsByItem( ), item_slug: link.item.slug, item_variants: link.variants.map((v) => ({ + id: v.id, variant_key: v.variant_key, storage_gb: v.storage_gb, min_price: positivePrice(v.min_price), @@ -186,11 +190,20 @@ export function itemSizeChips( const sized = (row.item_variants ?? []).filter( (v) => typeof v.storage_gb === 'number' && v.storage_gb > 0 ); - if (sized.length < 2) return []; - return sized.map((v) => ({ - label: formatStorage(v.storage_gb as number), - href: `${itemHref}?v=${encodeURIComponent(v.variant_key)}`, - })); + // One chip per size label: `256gb` and a later `256gb-cellular` (design + // Q3) would read the same, so the first in variant order stands. + const chips: ItemSizeChip[] = []; + const labels = new Set(); + for (const v of sized) { + const label = formatStorage(v.storage_gb as number); + if (labels.has(label)) continue; + labels.add(label); + chips.push({ + label, + href: `${itemHref}?v=${encodeURIComponent(v.variant_key)}`, + }); + } + return chips.length < 2 ? [] : chips; } /** @@ -232,3 +245,23 @@ export function sortItemRows( }) .map((e) => e.row); } + +/** + * Per-master counts summed over each Item row's sizes, keyed by the row's + * id: an Item's rating covers all its listings, so the reviews kept out of + * it are all its sizes' (DOS/Rate#1275). Rows with no Item keep their count. + */ +export function sumCountsByItemRow( + rows: readonly ItemCatalogRow[], + countsByMaster: Readonly> +): Record { + const out: Record = { ...countsByMaster }; + for (const row of rows) { + if (!row.item_variants || row.item_variants.length === 0) continue; + const ids = new Set([row.id, ...row.item_variants.map((v) => v.id)]); + let total = 0; + for (const id of ids) total += Number(countsByMaster[id]) || 0; + out[row.id] = total; + } + return out; +} diff --git a/apps/web/src/lib/leaderboard-engine.ts b/apps/web/src/lib/leaderboard-engine.ts index 7dbb7c455..069ddeebf 100644 --- a/apps/web/src/lib/leaderboard-engine.ts +++ b/apps/web/src/lib/leaderboard-engine.ts @@ -813,7 +813,11 @@ export function buildPopular1v1BattlesForCategory( ) continue; - const versusSlug = `${itemA.slug}-vs-${itemB.slug}`; + // The page each row links to names the versus side: its Item under the + // Item model, so the link needs no 308 hop (DOS/Rate#1275). + const sideSlug = (item: typeof itemA) => + item.productHref.split('/').pop() || item.slug; + const versusSlug = `${sideSlug(itemA)}-vs-${sideSlug(itemB)}`; const scoreDelta5 = Number( Math.abs(itemA.combinedScore5 - itemB.combinedScore5).toFixed(2) ); From 54ac7be440b50e12fe1e62d99cadb56aacddb223 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:54:29 +0700 Subject: [PATCH 50/69] feat(web): watchlist asks for a size when an entry is an item (#1284) * feat(web): watchlist asks for a size when an entry is an item Price alerts stay per size. With RATE_ITEM_MODEL on, an entry the watchlist API marks `size_required`, or a save answered 422 SIZE_REQUIRED, shows the Item's sizes from the product API; choosing one replaces the entry with that size, its price and a target 5% below. With the flag off the API never sends either, so nothing shows. * fix(web): size picker keeps a watched size and moves the cloud alert Review of the previous commit: choosing a size already on the watchlist drops the Item entry and keeps that size's own target; the chosen size's cloud alert is saved when signed in; when the sizes cannot be read the entry says so and links to its product page; the size group is labelled by its title; sync-all names SIZE_REQUIRED in the page's language. Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 6 +- apps/web/locales/en.json | 6 +- apps/web/locales/vi.json | 6 +- apps/web/package.json | 2 +- .../watchlist/WatchlistWorkspace.tsx | 141 +++++++++++++++++- .../src/lib/__tests__/watchlist-sizes.test.ts | 80 ++++++++++ apps/web/src/lib/watchlist-sizes.ts | 64 ++++++++ 7 files changed, 299 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/__tests__/watchlist-sizes.test.ts create mode 100644 apps/web/src/lib/watchlist-sizes.ts diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 6035571ba..ac8ebed56 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -658,7 +658,11 @@ "quickToggleBtnWatched": "Hlídáno", "quickToggleBtnUnwatched": "Hlídat cenu", "quickToggleShortUnwatched": "Hlídat cenu", - "quickToggleShortWatched": "Hlídáno" + "quickToggleShortWatched": "Hlídáno", + "sizeRequiredTitle": "Vyberte velikost úložiště: cenová upozornění se nastavují pro každou zvlášť.", + "sizeRequiredCta": "Vybrat velikost", + "sizeRequiredUnavailable": "Varianty se nepodařilo načíst.", + "sizeRequiredProductLink": "Otevřít stránku produktu" }, "deals": { "metaTitle": "Výhodné nabídky ověřené v 10 obchodech | Rate.vn", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 0c8418c13..13d5adfe2 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -658,7 +658,11 @@ "quickToggleBtnWatched": "Watching price", "quickToggleBtnUnwatched": "Watch price", "quickToggleShortUnwatched": "Watch price", - "quickToggleShortWatched": "Watching" + "quickToggleShortWatched": "Watching", + "sizeRequiredTitle": "Choose a size to watch: price alerts are set per size.", + "sizeRequiredCta": "Choose a size", + "sizeRequiredUnavailable": "The sizes could not be loaded.", + "sizeRequiredProductLink": "Open the product page" }, "deals": { "metaTitle": "Best-value tech deals, checked across 10 stores | Rate.vn", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 4fe6cca23..9ea1cc6a2 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -658,7 +658,11 @@ "quickToggleBtnWatched": "Đang canh giá", "quickToggleBtnUnwatched": "Canh giá", "quickToggleShortUnwatched": "Canh giá", - "quickToggleShortWatched": "Đang canh" + "quickToggleShortWatched": "Đang canh", + "sizeRequiredTitle": "Chọn dung lượng để theo dõi giá: cảnh báo giá đặt theo từng dung lượng.", + "sizeRequiredCta": "Chọn dung lượng", + "sizeRequiredUnavailable": "Chưa tải được danh sách dung lượng.", + "sizeRequiredProductLink": "Mở trang sản phẩm" }, "deals": { "metaTitle": "Deal công nghệ đã kiểm tra trên 10 sàn | Rate.vn", diff --git a/apps/web/package.json b/apps/web/package.json index 6da1c716c..787064e18 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/components/watchlist/WatchlistWorkspace.tsx b/apps/web/src/components/watchlist/WatchlistWorkspace.tsx index 3f338ec91..78ca68f76 100644 --- a/apps/web/src/components/watchlist/WatchlistWorkspace.tsx +++ b/apps/web/src/components/watchlist/WatchlistWorkspace.tsx @@ -38,6 +38,12 @@ import { jsonWriteHeaders, } from '@/lib/auth/access-token'; import { productImageUrl } from '@/lib/rate-domain-core'; +import { formatStorageLabel } from '@/lib/category-filters'; +import { + isSizeRequiredAnswer, + watchlistSizeChoices, + type WatchlistSizeChoice, +} from '@/lib/watchlist-sizes'; interface WatchlistWorkspaceProps { catalogProducts: MasterProduct[]; @@ -62,6 +68,8 @@ interface LiveWatchlistDeal { is_official_mall: boolean; } | null; radar_signal?: RadarSignal; + /** The entry names an Item with several sizes (DOS/Rate#1278). */ + size_required?: true; } const PLATFORM_DISPLAY_LABELS: Record = { @@ -222,6 +230,73 @@ export default function WatchlistWorkspace({ // Runs once per page load. }, []); + // An entry naming a whole Item needs a size: alerts stay per size + // (DOS/Rate#1278). The sizes come from the product API's `variants`. + const [sizePicker, setSizePicker] = useState<{ + slug: string; + choices: WatchlistSizeChoice[]; + } | null>(null); + + // The entry whose sizes could not be read: it links to its product page. + const [sizePickerFailed, setSizePickerFailed] = useState(null); + + const openSizePicker = (slug: string) => { + setSizePickerFailed(null); + const fail = () => { + setSizePickerFailed(slug); + setSizePicker((open) => (open?.slug === slug ? null : open)); + }; + fetch(`/api/v1/rate/products/${encodeURIComponent(slug)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((json) => { + const choices = watchlistSizeChoices( + json?.ok ? json.data?.variants : null, + formatStorageLabel + ); + if (choices.length > 0) setSizePicker({ slug, choices }); + else fail(); + }) + .catch(fail); + }; + + const chooseSize = async ( + item: WatchlistItem, + choice: WatchlistSizeChoice + ) => { + setSizePicker(null); + // A size already watched keeps its own entry and target. + const already = getWatchlist().find((x) => x.slug === choice.slug); + removeWatchlistItem(item.slug); + if (already) return; + const targetPrice = + choice.minPrice > 0 + ? computeWatchlistTargetPrice(choice.minPrice, 5) + : item.targetPrice; + upsertWatchlistItem({ + ...item, + slug: choice.slug, + name: choice.name, + priceWhenAdded: choice.minPrice || item.priceWhenAdded, + targetPrice, + }); + // The cloud alert moves to the chosen size too, when signed in. + const headers = await authorizedJsonHeaders(); + if (!headers) return; + try { + await fetch('/api/v1/rate/watchlist', { + method: 'POST', + headers, + body: JSON.stringify({ + slug: choice.slug, + targetPrice, + locale: locale || 'vi', + }), + }); + } catch { + // Local watchlist already updated + } + }; + const handleUpdateTarget = async (item: WatchlistItem) => { const cleanTarget = sanitizeWatchlistTargetPrice( editTarget, @@ -236,7 +311,7 @@ export default function WatchlistWorkspace({ const headers = await authorizedJsonHeaders(); if (!headers) return; try { - await fetch('/api/v1/rate/watchlist', { + const res = await fetch('/api/v1/rate/watchlist', { method: 'POST', headers, body: JSON.stringify({ @@ -245,6 +320,12 @@ export default function WatchlistWorkspace({ locale: locale || 'vi', }), }); + if (res.status === 422) { + const payload = await res.json().catch(() => null); + if (isSizeRequiredAnswer(res.status, payload)) { + openSizePicker(item.slug); + } + } } catch { // Local watchlist already updated } @@ -275,6 +356,7 @@ export default function WatchlistWorkspace({ ok?: boolean; persistedInCloud?: boolean; error?: string; + code?: string; } | null; return { unauthorized: res.status === 401, @@ -282,7 +364,11 @@ export default function WatchlistWorkspace({ res.ok && payload?.ok === true && payload.persistedInCloud === true, - error: typeof payload?.error === 'string' ? payload.error : null, + error: isSizeRequiredAnswer(res.status, payload) + ? t('sizeRequiredTitle') + : typeof payload?.error === 'string' + ? payload.error + : null, }; }) ); @@ -1047,6 +1133,57 @@ export default function WatchlistWorkspace({
    + {(dealInfo?.size_required || + sizePicker?.slug === item.slug) && ( +
    +
    + {t('sizeRequiredTitle')} +
    + {sizePickerFailed === item.slug && ( +
    + {t('sizeRequiredUnavailable')}{' '} + + {t('sizeRequiredProductLink')} + +
    + )} + {sizePicker?.slug === item.slug ? ( +
    + {sizePicker.choices.map((choice) => ( + + ))} +
    + ) : ( + + )} +
    + )} + {bestOffer && (
    (n >= 1024 ? `${n / 1024}TB` : `${n}GB`); + + assert.deepEqual( + watchlistSizeChoices( + [ + { + slug: 'apple-iphone-16-128gb', + name: 'Apple iPhone 16 128GB', + variant_key: '128gb', + storage_gb: 128, + min_price: 19_990_000, + }, + { + slug: 'apple-iphone-16-1024gb', + name: 'Apple iPhone 16 1TB', + variant_key: '1024gb', + storage_gb: 1024, + min_price: 0, + }, + // Same label as the first: one choice per label. + { + slug: 'apple-iphone-16-128gb-cellular', + name: 'x', + variant_key: '128gb-cellular', + storage_gb: 128, + min_price: 1, + }, + // A sizeless variant reads its key; a bad slug is dropped. + { + slug: 'buds-standard', + name: '', + variant_key: 'standard', + storage_gb: null, + min_price: 3_000_000, + }, + { slug: 'bad slug', variant_key: '256gb', storage_gb: 256 }, + ], + gb + ), + [ + { + slug: 'apple-iphone-16-128gb', + name: 'Apple iPhone 16 128GB', + label: '128GB', + minPrice: 19_990_000, + }, + { + slug: 'apple-iphone-16-1024gb', + name: 'Apple iPhone 16 1TB', + label: '1TB', + minPrice: 0, + }, + { + slug: 'buds-standard', + name: 'buds-standard', + label: 'standard', + minPrice: 3_000_000, + }, + ] + ); + assert.deepEqual(watchlistSizeChoices(null, gb), []); + assert.deepEqual(watchlistSizeChoices({ slug: 'x' }, gb), []); + + assert.equal(isSizeRequiredAnswer(422, { code: 'SIZE_REQUIRED' }), true); + assert.equal(isSizeRequiredAnswer(422, { code: 'VALIDATION' }), false); + assert.equal(isSizeRequiredAnswer(400, { code: 'SIZE_REQUIRED' }), false); + assert.equal(isSizeRequiredAnswer(422, null), false); + + console.warn(' ✅ watchlist sizes suite passed'); +} + +main(); diff --git a/apps/web/src/lib/watchlist-sizes.ts b/apps/web/src/lib/watchlist-sizes.ts new file mode 100644 index 000000000..3904a0ea7 --- /dev/null +++ b/apps/web/src/lib/watchlist-sizes.ts @@ -0,0 +1,64 @@ +// The size picker of a watchlist entry that names a whole Item (DOS/Rate#1278). +// Price alerts stay per size (design doc Q5): while `RATE_ITEM_MODEL` is on, +// the watchlist API answers such an entry with `size_required`, and a save +// with 422 `SIZE_REQUIRED`, until a size is chosen. Pure, for the client. + +/** A size as `/api/v1/rate/products/{slug}` lists it under `variants`. */ +export interface WatchlistSizeVariant { + slug: string; + name: string; + variant_key: string; + storage_gb: number | null; + min_price: number; +} + +export interface WatchlistSizeChoice { + slug: string; + name: string; + label: string; + minPrice: number; +} + +/** The sizes to choose from, in the API's order, one per label. */ +export function watchlistSizeChoices( + rawVariants: unknown, + formatStorage: (gb: number) => string +): WatchlistSizeChoice[] { + if (!Array.isArray(rawVariants)) return []; + const out: WatchlistSizeChoice[] = []; + const labels = new Set(); + for (const raw of rawVariants as Partial[]) { + const slug = typeof raw?.slug === 'string' ? raw.slug : ''; + if (!/^[a-zA-Z0-9._-]{1,120}$/.test(slug)) continue; + const gb = Number(raw.storage_gb); + const label = + Number.isFinite(gb) && gb > 0 + ? formatStorage(gb) + : typeof raw.variant_key === 'string' + ? raw.variant_key + : ''; + if (!label || labels.has(label)) continue; + labels.add(label); + const price = Number(raw.min_price); + out.push({ + slug, + name: typeof raw.name === 'string' && raw.name ? raw.name : slug, + label, + minPrice: Number.isFinite(price) && price > 0 ? price : 0, + }); + } + return out; +} + +/** Whether a save answer means "choose a size first". */ +export function isSizeRequiredAnswer( + status: number, + payload: unknown +): boolean { + return ( + status === 422 && + !!payload && + typeof payload === 'object' && + (payload as { code?: unknown }).code === 'SIZE_REQUIRED' + ); +} From 0997ca7f2dcd27d1d40c33c6dce35a3a79f75ef8 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:58:51 +0700 Subject: [PATCH 51/69] fix(web): item pages under their own category, unknown-size history verdicts (#1287) * fix(web): item pages live under their own category; unknown-size history Item page follow-ups of #1154 (DOS/Rate#1281), behind RATE_ITEM_MODEL: - the route category is checked against the Item's category_id: an Item URL under another category, and an old variant URL redirected from one, 308 to the Item's own category; an Item whose category is no catalogue category is not found; an Item with no category keeps the requested one; - the "storage not stated" group gets its listings' price-history verdicts, cached per Item and per their current prices. Refs #1281 * fix(web): read an uncached item category, canonical of a redirected url Codex round 1 on DOS/Rate#1281: - a category id missing from the cached map is read by id before the Item is called lost, so a new category is never a 404; - metadata of a URL the page 308s away points its canonical at the redirect target, not the requested category; - the unknown-size history cache key hashes the listings' prices. Refs #1281 Co-Authored-By: Claude Opus 5.5 --- .../app/[locale]/smartphones/[slug]/page.tsx | 9 +- .../server/rate/__tests__/item-model.test.ts | 140 ++++++++++++++++++ apps/web/src/server/rate/item-model.ts | 29 +++- apps/web/src/server/rate/item-page.ts | 51 +++++++ apps/web/src/server/rate/item-route.ts | 47 ++++++ 5 files changed, 274 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index ee52990d3..7d5fa68d1 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -128,6 +128,13 @@ export async function generateCategoryProductMetadata( // RATE_ITEM_MODEL on: an Item URL describes its selected variant and is // canonical (Q1, DOS/Rate#1153). Off: null, the page as before. const itemRoute = await resolveItemRoute({ slug, categoryBasePath }); + // A URL the page 308s away (an old variant URL, an Item under another + // category) is never its own canonical: it points where the page goes. + if (itemRoute?.kind === 'redirect') { + return { + alternates: { canonical: `https://rate.vn${itemRoute.to.split('?')[0]}` }, + }; + } // The sizes the page itself shows, so the title quotes the same "from". const itemPage = itemRoute?.kind === 'render' && itemRoute.variants.length > 1 @@ -1365,7 +1372,7 @@ export async function ProductDetailView({ productName={displayName} productSlug={itemPage.item.slug} offers={itemPage.unknownOffers} - byOffer={{}} + byOffer={itemPage.unknownByOffer} />
    )} diff --git a/apps/web/src/server/rate/__tests__/item-model.test.ts b/apps/web/src/server/rate/__tests__/item-model.test.ts index 4ed67d480..8be3b8709 100644 --- a/apps/web/src/server/rate/__tests__/item-model.test.ts +++ b/apps/web/src/server/rate/__tests__/item-model.test.ts @@ -45,6 +45,10 @@ const variantRow = (id: string, gb: number, sort: number | null) => ({ merchant_count: 2, }); +const CAT_PHONES = '55555555-5555-4555-8555-000000000001'; +const CAT_TABLETS = '55555555-5555-4555-8555-000000000002'; +const CAT_OTHER = '55555555-5555-4555-8555-000000000003'; +const CAT_NEW = '55555555-5555-4555-8555-000000000004'; let calls: string[] = []; // Answer as a database without M1+M2 (PostgREST schema-cache miss). let schemaMissing = false; @@ -86,6 +90,14 @@ globalThis.fetch = (async (input: RequestInfo | URL) => { ); } switch (url.pathname) { + case '/rest/v1/categories': + // A category the cached list does not know yet, read by id. + if (q.includes(`id=eq.${CAT_NEW}`)) return json([{ slug: 'tablets' }]); + return json([ + { id: CAT_PHONES, slug: 'smartphones' }, + { id: CAT_TABLETS, slug: 'tablets' }, + { id: CAT_OTHER, slug: 'cameras' }, + ]); case '/rest/v1/items': if ( q.includes('slug=eq.apple-iphone-16&') || @@ -289,6 +301,70 @@ async function main() { }), { kind: 'redirect', to: '/smartphones/apple-iphone-16' } ); + // The Item's own category wins over the requested one (DOS/Rate#1281). + assert.deepEqual( + planItemRoute({ + ...base, + categoryBasePath: '/tablets', + itemCategoryPath: '/smartphones', + resolution: { kind: 'item', item, variant: null }, + requestedSlug: 'apple-iphone-16', + requestedVariant: null, + }), + { kind: 'redirect', to: '/smartphones/apple-iphone-16' }, + 'a phone Item under /tablets 308s to /smartphones' + ); + assert.deepEqual( + planItemRoute({ + ...base, + categoryBasePath: '/tablets', + itemCategoryPath: '/smartphones', + resolution: { + kind: 'variant', + item, + variant: variants[0]!, + retired: false, + }, + requestedSlug: 'apple-iphone-16-128gb', + requestedVariant: null, + }), + { kind: 'redirect', to: '/smartphones/apple-iphone-16?v=128gb' }, + 'an old variant URL under another category 308s to the Item category' + ); + assert.deepEqual( + planItemRoute({ + ...base, + categoryBasePath: '/tablets', + itemCategoryPath: '/smartphones', + resolution: { kind: 'variant', item, variant: shared, retired: false }, + requestedSlug: 'apple-iphone-16', + requestedVariant: null, + }), + { kind: 'redirect', to: '/smartphones/apple-iphone-16?v=128gb' }, + 'a shared-slug Item under another category 308s too, no loop' + ); + assert.equal( + planItemRoute({ + ...base, + itemCategoryPath: '/smartphones', + resolution: { kind: 'item', item, variant: null }, + requestedSlug: 'apple-iphone-16', + requestedVariant: null, + }).kind, + 'render', + 'the right category renders' + ); + assert.deepEqual( + planItemRoute({ + ...base, + itemCategoryPath: false, + resolution: { kind: 'item', item, variant: null }, + requestedSlug: 'apple-iphone-16', + requestedVariant: null, + }), + { kind: 'not_found' }, + 'an Item in no catalogue category is not found' + ); assert.equal( flag.itemSlugFromVariantSlug('apple-iphone-16-1024gb'), 'apple-iphone-16' @@ -366,6 +442,70 @@ async function main() { }), { kind: 'redirect', to: '/smartphones/apple-iphone-16' } ); + // The Item's category_id is checked against the route (DOS/Rate#1281). + (itemRow as { category_id: string | null }).category_id = CAT_PHONES; + assert.deepEqual( + await resolveItemRoute({ + slug: 'apple-iphone-16', + categoryBasePath: '/tablets', + }), + { kind: 'redirect', to: '/smartphones/apple-iphone-16' } + ); + assert.deepEqual( + await resolveItemRoute({ + slug: 'apple-iphone-16-128gb', + categoryBasePath: '/tablets', + }), + { kind: 'redirect', to: '/smartphones/apple-iphone-16?v=128gb' } + ); + assert.equal( + ( + await resolveItemRoute({ + slug: 'apple-iphone-16', + categoryBasePath: '/smartphones', + }) + )?.kind, + 'render' + ); + (itemRow as { category_id: string | null }).category_id = CAT_NEW; + assert.deepEqual( + await resolveItemRoute({ + slug: 'apple-iphone-16', + categoryBasePath: '/smartphones', + }), + { kind: 'redirect', to: '/tablets/apple-iphone-16' }, + 'a category missing from the cached map is read, not a 404' + ); + (itemRow as { category_id: string | null }).category_id = CAT_OTHER; + assert.deepEqual( + await resolveItemRoute({ + slug: 'apple-iphone-16', + categoryBasePath: '/smartphones', + }), + { kind: 'not_found' } + ); + (itemRow as { category_id: string | null }).category_id = null; + + // The unknown-size group carries its listings' history verdicts (#1281). + const { loadItemPageContext } = await import('../item-page'); + calls = []; + const pageContext = await loadItemPageContext( + itemRow as never, + await api.getItemVariants(ITEM_ID) + ); + assert.equal(pageContext.unknownOffers.length, 1); + assert.ok( + UNKNOWN_LISTING in pageContext.unknownByOffer, + 'the unknown-size listing has a history verdict' + ); + assert.ok( + calls.some( + (c) => + c.startsWith('/rest/v1/price_history?') && c.includes(UNKNOWN_LISTING) + ), + 'its price history is read' + ); + // Flag on without M1+M2: the page falls back to today's variant page. schemaMissing = true; await assert.rejects( diff --git a/apps/web/src/server/rate/item-model.ts b/apps/web/src/server/rate/item-model.ts index b2906c2a6..c58332cc5 100644 --- a/apps/web/src/server/rate/item-model.ts +++ b/apps/web/src/server/rate/item-model.ts @@ -76,14 +76,28 @@ export function planItemRoute(input: { requestedVariant: string | null; categoryBasePath: string; variants: readonly RateItemVariant[]; + /** + * The Item's own category path (`/tablets`), from its `category_id`: + * `undefined` or null when it has none (no check), `false` when its + * category is no catalogue category (the URL is not found). + */ + itemCategoryPath?: string | null | false; }): ItemRoutePlan { const { resolution, requestedSlug, requestedVariant, categoryBasePath } = input; if (!resolution) return { kind: 'not_found' }; if (resolution.kind === 'unlinked') return { kind: 'legacy' }; const { item } = resolution; + if (input.itemCategoryPath === false) return { kind: 'not_found' }; + // An Item lives under its own category: `/tablets/` and an old + // variant URL under another category 308 there (design doc 5.1, + // DOS/Rate#1281). Without a category the requested one is kept. + const ownBase = (input.itemCategoryPath || categoryBasePath).replace( + /\/+$/, + '' + ); if (resolution.kind === 'variant' && item.slug !== requestedSlug) { - const base = categoryBasePath.replace(/\/+$/, ''); + const base = ownBase; const itemUrl = `${base}/${encodeURIComponent(item.slug)}`; // A retired size (an archived `unknown`) is no size to preselect. return { @@ -93,6 +107,19 @@ export function planItemRoute(input: { : `${itemUrl}?v=${encodeURIComponent(resolution.variant.variant_key)}`, }; } + if (ownBase !== categoryBasePath.replace(/\/+$/, '')) { + // The server never reads `?v=`, so a wrong-category Item URL keeps the + // size it names only when it was an old variant slug. + const keepSize = resolution.kind === 'variant' && !resolution.retired; + return { + kind: 'redirect', + to: `${ownBase}/${encodeURIComponent(item.slug)}${ + keepSize + ? `?v=${encodeURIComponent(resolution.variant.variant_key)}` + : '' + }`, + }; + } const variants = input.variants; const chosen = (resolution.kind === 'variant' && !resolution.retired diff --git a/apps/web/src/server/rate/item-page.ts b/apps/web/src/server/rate/item-page.ts index 9e496a205..ed83d74e4 100644 --- a/apps/web/src/server/rate/item-page.ts +++ b/apps/web/src/server/rate/item-page.ts @@ -1,7 +1,9 @@ import 'server-only'; +import { createHash } from 'node:crypto'; import { cache } from 'react'; import { getItemUnknownVariantOffers as _getItemUnknownVariantOffers, + getMasterProductPriceHistory as _getPriceHistoryForOffers, type MasterProduct, type MerchantOffer, type MasterProductPriceHistorySummary, @@ -40,6 +42,8 @@ export interface ItemPageContext { variants: ItemPageVariant[]; /** Listings of the Item whose size is not stated (Q2): never a "from". */ unknownOffers: MerchantOffer[]; + /** The price-history verdict of each of those listings (DOS/Rate#1281). */ + unknownByOffer: MasterProductPriceHistorySummary['byOffer']; } /** @@ -74,6 +78,40 @@ const _getCachedUnknownOffers = cache((itemId: string, itemSlug: string) => )(itemId) ); +// The history verdicts of the unknown-size listings, keyed by Item and by the +// listings' current prices, like a variant's history (DOS/Rate#1281). Only +// `byOffer` is used, so the all-time low/high reads are skipped. +const _getCachedUnknownByOffer = cache( + (itemId: string, itemSlug: string, offers: MerchantOffer[]) => + withRateDataCache( + async (id: string) => + (await _getPriceHistoryForOffers(id, offers, { allTime: false })) + .byOffer, + [ + 'rate-item-unknown-history', + itemId, + // A hash, so the key stays short however many listings there are. + createHash('sha256') + .update( + offers + .map( + (o) => `${o.id}:${o.price_current}:${o.price_original ?? ''}` + ) + .join('|') + ) + .digest('hex'), + ], + { + revalidate: 600, + tags: [ + RATE_CACHE_TAGS.PRICE_HISTORY, + RATE_CACHE_TAGS.OFFERS, + productCacheTag(itemSlug), + ], + } + )(itemId) +); + /** * Everything the Item page shows per size, read through the same cached * per-master readers as the variant pages (DOS/Rate#1154). A size whose @@ -122,9 +160,22 @@ export async function loadItemPageContext( return [] as MerchantOffer[]; }), ]); + const unknownByOffer = + unknownOffers.length === 0 + ? {} + : await _getCachedUnknownByOffer(item.id, item.slug, unknownOffers).catch( + (err: unknown) => { + log.error('unknown-size history read failed; no verdicts', { + item: item.slug, + message: err instanceof Error ? err.message : String(err), + }); + return {}; + } + ); return { item, variants: loaded.filter((v): v is ItemPageVariant => v !== null), unknownOffers, + unknownByOffer, }; } diff --git a/apps/web/src/server/rate/item-route.ts b/apps/web/src/server/rate/item-route.ts index 090369235..49bc3d428 100644 --- a/apps/web/src/server/rate/item-route.ts +++ b/apps/web/src/server/rate/item-route.ts @@ -2,11 +2,14 @@ import 'server-only'; import { cache } from 'react'; import { ItemModelUnavailableError, + RateDataUnavailableError, getItemVariants as _getItemVariants, + getRateClient, resolveCatalogSlug as _resolveCatalogSlug, type RateItem, } from '../../lib/rate-api'; import { createLogger } from '@/lib/logger'; +import { isCanonicalCategorySlug } from '@/lib/rate-domain-core'; import { RATE_CACHE_TAGS, productCacheTag, @@ -41,6 +44,47 @@ export function getCachedItemVariants(item: RateItem) { return _getCachedItemVariants(item.id, item.slug); } +// Category id to slug, as pairs (the Data Cache stores JSON). +const _getCachedCategorySlugs = cache(() => + withRateDataCache( + async (): Promise> => { + const { data, error } = await getRateClient() + .from('categories') + .select('id, slug'); + if (error) throw new RateDataUnavailableError('categories', error); + return ((data ?? []) as Array<{ id: string; slug: string }>).map((c) => [ + c.id, + c.slug, + ]); + }, + ['rate-category-slugs'], + { revalidate: 3600, tags: [RATE_CACHE_TAGS.CATEGORIES] } + )() +); + +/** + * The Item's category path (`/tablets`): null when it has no category, false + * when its category is no catalogue category (DOS/Rate#1281). + */ +async function itemCategoryPath( + item: RateItem +): Promise { + if (!item.category_id) return null; + let slug = new Map(await _getCachedCategorySlugs()).get(item.category_id); + if (slug === undefined) { + // A category newer than the cached map: read it before calling the Item + // lost; a read error is thrown, never a 404. + const { data, error } = await getRateClient() + .from('categories') + .select('slug') + .eq('id', item.category_id) + .maybeSingle(); + if (error) throw new RateDataUnavailableError('categories', error); + slug = (data as { slug?: string } | null)?.slug; + } + return slug && isCanonicalCategorySlug(slug) ? `/${slug}` : false; +} + /** * The Item route for a product URL, or null while `RATE_ITEM_MODEL` is off, * or while the Item tables are missing (M1+M2 not applied): then the page is @@ -70,6 +114,9 @@ export async function resolveItemRoute(input: { requestedVariant: null, categoryBasePath: input.categoryBasePath, variants, + itemCategoryPath: resolution?.item + ? await itemCategoryPath(resolution.item) + : null, }); return plan.kind === 'legacy' ? null : plan; } catch (err) { From 70bf46e94f6e5f38f9c25b32f76fb0ca0486d9cf Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:01:01 +0700 Subject: [PATCH 52/69] docs(proposals): item model go-live runbook for joy (#1288) * docs(proposals): item model go-live runbook with vietnamese version Co-Authored-By: Claude Opus 5.5 --- .../2026-10-29-item-model-go-live.html | 199 ++++++++++++++++++ .../2026-10-29-item-model-go-live.md | 124 +++++++++++ 2 files changed, 323 insertions(+) create mode 100644 Docs/Proposals/2026-10-29-item-model-go-live.html create mode 100644 Docs/Proposals/2026-10-29-item-model-go-live.md diff --git a/Docs/Proposals/2026-10-29-item-model-go-live.html b/Docs/Proposals/2026-10-29-item-model-go-live.html new file mode 100644 index 000000000..712d2d0fe --- /dev/null +++ b/Docs/Proposals/2026-10-29-item-model-go-live.html @@ -0,0 +1,199 @@ + + + + + + + Kế hoạch bật mô hình Item + + + +
    + Chưa áp dụng gì + Chờ JOY duyệt 9 quyết định +

    Kế hoạch bật mô hình Item (sản phẩm, phiên bản, listing)

    +

    Ngày 29/10/2026 - Issue DOS/Rate#1150 - Bản tiếng Anh: 2026-10-29-item-model-go-live.md - Người soạn: session Core của Rate Core PO

    + +
    +

    Tóm tắt

    +

    Trang này gom toàn bộ các bước đưa mô hình Item lên thật vào một chỗ, để JOY duyệt một lần. Item là một sản phẩm (ví dụ iPhone 17 Pro), phiên bản (variant) là một dung lượng của nó (256GB), listing là một tin bán trên sàn. Có 9 bước theo thứ tự, mỗi bước ghi: thay đổi gì, kiểm tra thế nào (chỉ đọc), hoàn tác thế nào, ai chạy. Cuối trang là 9 quyết định JOY cần chốt, mỗi câu có khuyến nghị sẵn.

    +

    Mọi file SQL đã được chạy thử trong CI (kiểm thử tự động trên GitHub) trên một database tạm: chạy hai lần, kiểm tra, hoàn tác hai lần, chạy lại. Chưa bước nào đụng tới Supabase prod dùng chung.

    +
    + +

    1. Luật chung cho mọi bước database

    +
      +
    • JOY áp dụng qua DOS-Me: preview, rồi beta, rồi prod, kiểm tra sau mỗi chặng. DOS/Rate không bao giờ chạy DDL (lệnh đổi cấu trúc bảng) hay ghi dữ liệu lên prod. Luồng beta của DOS-Me vẫn đang khóa (ROADMAP 6.4), nên prod phải chờ luồng này mở lại.
    • +
    • File kiểm tra chỉ đọc (chỉ có SELECT). Mọi dòng ghi "expect 0" phải trả về 0.
    • +
    • Sau mỗi lần áp dụng lên prod: chép file đã chạy vào supabase/migrations/ và sinh lại database.types.ts (một PR bên Rate, giống #1147).
    • +
    • Tạm dừng các tiến trình ghi nghĩa là dừng job importer và worker của Rate Services trong lúc chạy (web vẫn chạy bình thường).
    • +
    • Hoàn tác theo thứ tự ngược lại với lúc áp dụng.
    • +
    + +

    2. Thứ tự tổng quát

    +
    + + + + + + + + + + + +
    #BướcAi chạyPhải chờ
    1#1123 / M0: đổi khóa (key) seed lệch chuẩn, chỉ sửa dữ liệuJOY qua DOS-Mequyết định 8
    2PR sửa seed (flagship-seed-data.ts), ngay sau M0Core mở PR, PO mergebước 1 đã lên prod
    3M1+M2: bảng rate.items, cột mới, trigger, RPC, điền dữ liệuJOY qua DOS-Mebước 2; quyết định 1 đến 5
    4RPC danh mục Item (tìm kiếm, đếm theo hãng, gợi ý khi gõ)JOY qua DOS-Mebước 3
    5Rate Services nâng phiên bản vendor/rateRate Services (RS PO mở PR), JOY mergebước 3
    6Bật cờ RATE_ITEM_MODEL=item, rồi nullable_variantBiến môi trường importer (Rate Services), biến môi trường Vercel (web)bước 4 và 5
    7Giai đoạn tương thíchmọi bên; PO theo dõibước 6, ít nhất một vòng promote và khoảng 30 ngày
    8M3: gỡ phiên bản "unknown" ở Item nhiều dung lượngJOY qua DOS-Me, dừng tiến trình ghibước 7; quyết định 4
    9M4: ràng buộc và phiên bản mặc địnhJOY qua DOS-Mebước 8; quyết định 6, 7
    +
    +
    +

    Lưu ý về thứ tự. #1123 và M0 là một bước: M0 là file SQL đã review cho #1123, cộng thêm ba khóa tai nghe lệch chuẩn khác. PR sửa seed phải merge ngay sau M0, trước M1+M2. Giữa M0 và PR seed tuyệt đối không chạy jobs/backfill-specs.ts --apply: job này tìm sản phẩm theo khóa seed cũ, không thấy sẽ tạo mới, sinh ra bản trùng.

    +
    + +

    3. Chi tiết từng bước

    + +

    Bước 1. #1123 / M0: đổi khóa seed

    +
    +
    Thay đổi
    Sáu sản phẩm seed bị lệch được đổi sang khóa chuẩn của bộ chuẩn hóa (normalizer), sửa tại chỗ: id, slug, listing, cảnh báo giá, đánh giá giữ nguyên. Tai nghe đặt storage_gb = NULL. Ghi dấu vào metadata.m0_rekeyed_from và m0_storage_gb_before để hoàn tác. Nếu khóa đích đã có người dùng thì bỏ qua và báo NOTICE, không bao giờ tự gộp.
    +
    File
    2026-10-29-dos-me-m0-seed-rekey.sql (chỉ dữ liệu, một transaction, chạy lại an toàn).
    +
    Kiểm tra
    Trước khi chạy: V5 trong 2026-10-29-dos-me-m0-seed-rekey-verify.sql, so với bảng sáu dòng trong đề xuất M0. Sau khi chạy: V1 đến V4 trả về 0.
    +
    Hoàn tác
    2026-10-29-dos-me-m0-seed-rekey-rollback.sql, đồng thời revert PR seed. Chỉ làm được khi M1+M2 chưa áp dụng (nếu đã áp dụng thì hoàn tác M1+M2 trước).
    +
    Ai chạy
    JOY qua DOS-Me.
    +
    + +

    Bước 2. PR sửa seed

    +
    +
    Thay đổi
    apps/importer/src/shopee/flagship-seed-data.ts dùng sáu khóa mới; tai nghe storage_gb: null. Core mở PR khi JOY lên lịch M0; không được merge trước khi M0 lên prod.
    +
    Kiểm tra
    CI xanh; truy vấn chỉ đọc xác nhận không có model_key nào xuất hiện hai lần trong rate.master_products.
    +
    Hoàn tác
    Revert PR (cùng lúc với hoàn tác M0).
    +
    Ai chạy
    Core mở, PO merge.
    +
    + +

    Bước 3. M1+M2: bảng Item

    +
    +
    Thay đổi
    Bảng rate.items (RLS, tức phân quyền theo dòng: ai cũng đọc được Item đã xuất bản; rate_worker đọc tất cả, chỉ được sửa cột thông số). Cột mới master_products.item_id / variant_key / sort_order và products.item_id / variant_source. Trigger đồng bộ: sản phẩm mới tự có Item theo khóa, listing đi theo. RPC match_item, refresh_item_stats, retire_item_unknown_variant; hàm điểm đánh giá và lượt vote hiểu Item nhưng giữ nguyên chữ ký. Điền dữ liệu cho bảng mới. Một transaction, chạy lại an toàn.
    +
    File
    2026-10-28-dos-me-m1-m2-items.sql.
    +
    Kiểm tra
    2026-10-28-dos-me-m1-m2-items-verify.sql, V1 đến V15, chạy trước và sau. Về sau file này cũng dùng làm bước đối soát.
    +
    Hoàn tác
    2026-10-28-dos-me-m1-m2-items-rollback.sql, dừng tiến trình ghi và tắt cờ về off trước. Khôi phục thân cũ của hai hàm điểm đánh giá. Không bao giờ TRUNCATE rate.items.
    +
    Ai chạy
    JOY qua DOS-Me.
    +
    + +

    Bước 4. RPC danh mục Item

    +
    +
    Thay đổi
    Chỉ thêm hàm mới: search_items_v1, get_category_brand_facets_v2, search_catalog_autocomplete_v2; quyền gọi cho anon, authenticated, service_role, rate_worker. Các RPC cũ mà web dùng khi tắt cờ không bị đụng tới.
    +
    File
    2026-10-29-dos-me-item-rpcs.sql.
    +
    Kiểm tra (chỉ đọc)
    Với vai trò anon: SELECT count(*) FROM rate.search_items_v1(p_limit => 200) lớn hơn 0, và total_count bằng số Item đã xuất bản có ít nhất một phiên bản đã xuất bản. SELECT * FROM rate.search_catalog_autocomplete_v2('iphone', 5) trả các dòng entity_kind = 'item'.
    +
    Hoàn tác
    Tắt cờ web, rồi chạy 2026-10-29-dos-me-item-rpcs-rollback.sql.
    +
    Ai chạy
    JOY qua DOS-Me.
    +
    + +

    Bước 5. Rate Services nâng vendor/rate

    +
    +
    Thay đổi
    Rate Services chuyển submodule vendor/rate sang một commit dev của DOS/Rate có importer hiểu Item (#1179, #1190, #1208, #1213 và các PR sau). Phần việc Item của chính Rate Services đã merge: RS#685 (cluster_id NULL nghĩa là chưa rõ phiên bản), RS#687 (xác minh theo Item, job mang id Item, làm mới theo Item), RS#689.
    +
    Kiểm tra
    CI của Rate Services xanh; khi cờ vẫn off, một vòng promote bình thường không phát sinh lỗi mới.
    +
    Hoàn tác
    Trả submodule về commit cũ.
    +
    Ai chạy
    Rate Services (RS PO mở PR), JOY merge.
    +
    + +

    Bước 6. Bật cờ

    +

    RATE_ITEM_MODEL có ba giá trị: off (mặc định; giá trị lạ cũng hiểu là off), item (ghép theo Item trước, ghi item_id; listing nào cũng vẫn có phiên bản), nullable_variant (như item, thêm: listing không ghi dung lượng thuộc Item nhiều dung lượng được ghi cluster_id = NULL).

    +
    +
    Thứ tự
    Importer item, rồi web item (Vercel Preview trước, kiểm tra, rồi Production), rồi importer nullable_variant.
    +
    Kiểm tra
    Sau mỗi lần đổi: file kiểm tra M1+M2 trả 0 ở mọi dòng expect 0. Trên web, URL phiên bản cũ chuyển hướng 308 sang trang Item (job E2E Item trong CI đã kiểm tra việc này).
    +
    Hoàn tác
    Đặt lại biến môi trường về off và deploy lại. Dữ liệu ghi trong lúc bật vẫn hợp lệ khi tắt (trigger đồng bộ giữ các cột cũ khớp nhau).
    +
    Ai chạy
    Biến môi trường importer ở Rate Services; biến môi trường web ở Vercel (JOY).
    +
    + +

    Bước 7. Giai đoạn tương thích (điều kiện trước M3/M4)

    +
      +
    • Cờ RATE_ITEM_MODEL đã bật cho importer và web qua ít nhất một vòng promote.
    • +
    • Code hiểu Item của Rate Services đã chạy (bước 5).
    • +
    • File kiểm tra M1+M2 trả 0 sau khi tiến trình ghi cuối cùng đã đổi code (bước đối soát).
    • +
    • Không còn listing thiếu Item: SELECT count(*) FROM rate.products WHERE item_id IS NULL bằng 0 (nếu không, M4 tự dừng).
    • +
    • Sitemap đã được Google crawl lại với URL Item (khoảng 30 ngày).
    • +
    • Các cảnh báo giá đang bật trên phiên bản "unknown" của Item nhiều dung lượng đã được xử lý (V7 trong file kiểm tra M3/M4): chuyển sang phiên bản có dung lượng hoặc đóng lại. Còn cái nào đang bật thì M3 tự dừng.
    • +
    • Hoàn tác giai đoạn này: tắt cờ về off.
    • +
    + +

    Bước 8. M3: gỡ phiên bản "unknown" ở Item nhiều dung lượng

    +
    +
    Thay đổi
    Tự dừng nếu còn tai nghe mang khóa có dung lượng, hoặc còn cảnh báo giá đang bật trên phiên bản "unknown" sắp gỡ. Bảng price_alerts bị khóa ghi trong lúc chạy. Sau đó lưu trữ (archive) phiên bản "unknown" còn sống của mọi Item nhiều dung lượng qua retire_item_unknown_variant; listing của nó giữ Item nhưng không có phiên bản; số liệu được tính lại. Bảng sao lưu rate.m3_unknown_variant_backup (bật RLS, chỉ service role đọc) ghi đúng những gì đã đổi. Item một dung lượng (tai nghe, dòng không có dung lượng) không bị đụng.
    +
    File
    2026-10-29-dos-me-m3-m4.sql, transaction đầu. Dừng tiến trình ghi.
    +
    Kiểm tra
    2026-10-29-dos-me-m3-m4-verify.sql: V1 và V5 trả 0; V6 (việc đã làm) và V7 (cảnh báo giá) để tham khảo.
    +
    Hoàn tác
    2026-10-29-dos-me-m3-m4-rollback.sql, dừng tiến trình ghi: khôi phục phiên bản và chỉ những listing còn mang dấu của M3 (NOTICE báo số listing đã đổi sau đó), tính lại số liệu, xóa bảng sao lưu.
    +
    Ai chạy
    JOY qua DOS-Me.
    +
    + +

    Bước 9. M4: ràng buộc

    +
    +
    Thay đổi
    Tự dừng nếu còn phiên bản hoặc listing thiếu Item. Sau đó: master_products.item_id NOT NULL; ràng buộc CHECK products_item_id_not_null (thêm ở dạng NOT VALID, kiểm tra trong transaction riêng nên bảng listing vẫn đọc ghi được); trigger chặn listing gắn phiên bản của Item khác; điền items.default_variant_id, kèm trigger tự điền cho Item mới.
    +
    File
    2026-10-29-dos-me-m3-m4.sql, transaction thứ hai và thứ ba.
    +
    Kiểm tra
    V2, V3, V4 trong file kiểm tra M3/M4 trả 0, và chạy lại file kiểm tra M1+M2.
    +
    Hoàn tác
    Transaction đầu của file hoàn tác M3/M4 chỉ gỡ M4; transaction thứ hai mới gỡ M3.
    +
    Ai chạy
    JOY qua DOS-Me.
    +
    + +

    4. Các quyết định JOY cần chốt

    +

    Mỗi câu có khuyến nghị; nếu JOY đồng ý thì khuyến nghị là mặc định.

    + +
    1. (M1+M2) match_item lọc theo hãng, không theo model. Thiết kế có tham số model, nhưng lọc theo model thì chỉ tìm được đúng model đó, việc mà bước ghép chính xác (Tier 1) đã làm rồi. Khuyến nghị: lọc theo hãng.
    +
    2. (M1+M2) Item chưa có đánh giá lưu điểm 0, giống các cột của phiên bản hiện nay; web hiện "chưa có đánh giá" khi số đánh giá bằng 0. Khuyến nghị: giữ 0.
    +
    3. (M1+M2) Không sửa view rate.v_products_with_merchant_trust. Thêm item_id vào view làm hỏng việc chạy lại migration 20261018153000; ai cần item_id thì đọc từ rate.products. Khuyến nghị: giữ nguyên view.
    +
    4. (M1+M2, M3 cập nhật) Cảnh báo giá trên phiên bản "unknown". Cảnh báo giá cần một dung lượng cụ thể. M1+M2 để nguyên cảnh báo trên phiên bản đó; M3 giờ từ chối chạy khi còn cảnh báo đang bật, nên trước M3 JOY xử lý từng cái: chuyển sang phiên bản có dung lượng hoặc đóng. Khuyến nghị: xử lý từng cảnh báo theo V7 trước M3.
    +
    5. (M1+M2) Tên công tắc cho admin: rate.variant_override. Công cụ admin nào gán lại phiên bản đã chọn tay thì bật công tắc này trong transaction của nó. Khuyến nghị: duyệt tên này.
    +
    6. (M3/M4) M4 giữ lại các trigger đồng bộ của M1. Thiết kế định bỏ chúng ở M4, nhưng importer vẫn dựa vào chúng để tạo Item mới khi seed sản phẩm. Trigger chặn của M4 làm lớp bảo vệ cuối; bỏ trigger đồng bộ để sau, khi importer tự tạo Item. Khuyến nghị: giữ lại.
    +
    7. (M3/M4) Không thêm trigger chặn phía phiên bản khi đổi Item của nó. Trigger dời listing của M1 đã dời listing ngay trong cùng câu lệnh, và V3 kiểm tra kết quả. Khuyến nghị: không thêm.
    +
    8. (M0) Duyệt việc ghi dữ liệu M0, kể cả gộp apple:airpods-pro-2-usbc thành Item apple:airpods-pro-2. Bộ chuẩn hóa không tách bản USB-C với Lightning; hàng mới hiện chỉ còn bản USB-C. Khuyến nghị: duyệt.
    +
    9. (CI) Đặt job E2E (Playwright, Item model) thành check bắt buộc trên dev. Job chạy xanh, không cần chạy lại, ở mọi lần CI của PR #1273 (khoảng 4 phút). Đây là cài đặt bảo vệ nhánh (branch protection) của repo. Khuyến nghị: bắt buộc.
    +
    + + diff --git a/Docs/Proposals/2026-10-29-item-model-go-live.md b/Docs/Proposals/2026-10-29-item-model-go-live.md new file mode 100644 index 000000000..8b21df290 --- /dev/null +++ b/Docs/Proposals/2026-10-29-item-model-go-live.md @@ -0,0 +1,124 @@ +# Runbook: Item model go-live (DOS/Rate#1150) + +**Status:** runbook for JOY, **nothing applied**. One page to approve the whole Item / variant / listing rollout in one pass. Vietnamese version for JOY: [`2026-10-29-item-model-go-live.html`](./2026-10-29-item-model-go-live.html). + +Design (approved 2026-10-27, #1129): [`2026-10-27-item-variant-listing-model.md`](./2026-10-27-item-variant-listing-model.md). Per-step proposals: + +- M0: [`2026-10-29-dos-me-m0-seed-rekey.md`](./2026-10-29-dos-me-m0-seed-rekey.md) (#1212, #1123) +- M1+M2: [`2026-10-28-dos-me-m1-m2-items.md`](./2026-10-28-dos-me-m1-m2-items.md) (#1150) +- Item RPCs, M3, M4: [`2026-10-29-dos-me-m3-m4.md`](./2026-10-29-dos-me-m3-m4.md) (#1160, PR #1279) + +Every SQL file is replayed in CI on a throwaway PostgreSQL 17 stack (apply twice, checks, rollback twice, re-apply). No step has touched the shared prod Supabase. + +## Rules for every database step + +- **JOY applies it through DOS-Me**: preview, then beta, then prod, verifying after each. DOS/Rate never runs DDL or data writes on prod. The DOS-Me beta flow is still locked (ROADMAP 6.4); until it reopens, prod waits. +- **Verification files are read-only** (`SELECT` only). Every check marked "expect 0" must return 0. +- After each prod apply, mirror the applied file into `supabase/migrations/` and regenerate `database.types.ts` (a Rate PR, as #1147 did). +- **Writers paused** means: the importer jobs and the Rate Services workers stopped for the duration (the web keeps running). +- Rollbacks run in reverse order of the steps applied. + +## Order at a glance + +| # | Step | Who runs it | Blocks on | +| :-- | :--------------------------------------------------- | :--------------------------------------------- | :---------------------------------------------- | +| 1 | #1123 / M0: seed key re-key (data) | JOY via DOS-Me | decision 8 | +| 2 | Seed PR (`flagship-seed-data.ts`), right after M0 | Core opens, PO merges | step 1 on prod | +| 3 | M1+M2: `rate.items`, columns, triggers, RPCs, fill | JOY via DOS-Me | step 2; decisions 1 to 5 | +| 4 | Item catalogue RPCs | JOY via DOS-Me | step 3 | +| 5 | Rate Services bump of `vendor/rate` | Rate Services (RS PO), JOY merges | step 3 | +| 6 | Flag `RATE_ITEM_MODEL=item`, then `nullable_variant` | Importer env (Rate Services), Vercel env (web) | steps 4 and 5 | +| 7 | Compatibility window | everyone; PO tracks | step 6, at least one promote cycle and ~30 days | +| 8 | M3: retire `unknown` on multi-size Items | JOY via DOS-Me, writers paused | step 7; decision 4 | +| 9 | M4: constraints and default variants | JOY via DOS-Me | step 8; decisions 6, 7 | + +Note on the order: #1123 and M0 are one step (M0 is the reviewed SQL for #1123, plus three more drifted earbuds keys). The seed PR must merge **right after M0, before M1+M2**, and `jobs/backfill-specs.ts --apply` must not run between M0 and the seed PR: it looks masters up by seed key and would re-create the old keys as duplicates. + +## Steps + +### 1. #1123 / M0: seed key re-key + +- **Changes:** six drifted seed masters get the normalizer's keys in place (ids, slugs, listings, alerts, reviews unchanged); earbuds masters get `storage_gb = NULL`; markers `metadata.m0_rekeyed_from`, `m0_storage_gb_before`. A target key already taken is skipped with a NOTICE (never an automatic merge). +- **File:** `2026-10-29-dos-me-m0-seed-rekey.sql` (data only, one transaction, idempotent). +- **Verify:** before, run V5 of `2026-10-29-dos-me-m0-seed-rekey-verify.sql` and compare with the six rows in the M0 proposal; after, V1 to V4 return 0. +- **Roll back:** `2026-10-29-dos-me-m0-seed-rekey-rollback.sql` and revert the seed PR in the same step. Only while M1+M2 is not applied (roll M1+M2 back first otherwise). +- **Who:** JOY through DOS-Me. + +### 2. Seed PR + +- **Changes:** `apps/importer/src/shopee/flagship-seed-data.ts` uses the six new keys; earbuds `storage_gb: null`. The Core session opens it once JOY schedules M0; it must not merge before M0 is on prod. +- **Verify:** CI green; read-only check that no `model_key` appears twice in `rate.master_products`. +- **Roll back:** revert the PR (together with the M0 rollback). +- **Who:** Core opens, PO merges. + +### 3. M1+M2: Items + +- **Changes:** `rate.items` (RLS: public reads published; `rate_worker` reads all, updates spec columns only), `master_products.item_id / variant_key / sort_order`, `products.item_id / variant_source`, sync triggers (a master gets its Item from its key; listings follow), RPCs `match_item`, `refresh_item_stats`, `retire_item_unknown_variant`, Item-aware rating and vote functions with the same signatures, and the backfill. One transaction, idempotent. +- **File:** `2026-10-28-dos-me-m1-m2-items.sql`. +- **Verify:** `2026-10-28-dos-me-m1-m2-items-verify.sql`, V1 to V15, before and after (the same file is the reconciliation later). +- **Roll back:** `2026-10-28-dos-me-m1-m2-items-rollback.sql`, writers paused and the flags `off` first. Restores the two rating functions' previous bodies. Never `TRUNCATE rate.items`. +- **Who:** JOY through DOS-Me. + +### 4. Item catalogue RPCs + +- **Changes:** new functions only: `search_items_v1`, `get_category_brand_facets_v2`, `search_catalog_autocomplete_v2`; EXECUTE for `anon`, `authenticated`, `service_role`, `rate_worker`. The variant RPCs the flag-off web uses are untouched. +- **File:** `2026-10-29-dos-me-item-rpcs.sql`. +- **Verify (read-only):** as `anon`, `SELECT count(*) FROM rate.search_items_v1(p_limit => 200)` is non-zero and its `total_count` equals `SELECT count(*) FROM rate.items i WHERE i.status = 'published' AND EXISTS (SELECT 1 FROM rate.master_products m WHERE m.item_id = i.id AND m.status = 'published')`; `SELECT * FROM rate.search_catalog_autocomplete_v2('iphone', 5)` returns `entity_kind = 'item'` rows. +- **Roll back:** turn the web flag off, then `2026-10-29-dos-me-item-rpcs-rollback.sql`. +- **Who:** JOY through DOS-Me. + +### 5. Rate Services bump + +- **Changes:** Rate Services moves its `vendor/rate` pin to a DOS/Rate `dev` commit with the Item-aware importer (#1179, #1190, #1208, #1213 and later). Its own Item work already merged (RS#685 NULL `cluster_id` is "no variant", RS#687 Item verification, job Item ids, Item refresh, RS#689). +- **Verify:** Rate Services CI green on the bump; with the flag still `off`, one normal promote cycle shows no new errors. +- **Roll back:** revert the pin. +- **Who:** Rate Services (RS PO opens the PR), JOY merges. + +### 6. Flags + +`RATE_ITEM_MODEL` has three values: `off` (default; any unknown value reads as `off`), `item` (Item-first matching, writes `item_id`; every listing still has a variant), `nullable_variant` (as `item`, and a listing with no stated size on a multi-size Item gets `cluster_id = NULL`). + +- **Order:** importer `item` -> web `item` (Vercel Preview, check, then Production) -> importer `nullable_variant`. +- **Verify:** after each switch, the M1+M2 verification file returns 0 on every expect-0 check; on the web, an old variant URL 308-redirects to its Item page (the Item E2E job covers this in CI). +- **Roll back:** set the env back to `off` and redeploy; data written meanwhile stays valid under `off` (the sync triggers keep the old columns consistent). +- **Who:** importer env in Rate Services; web env in Vercel (JOY). + +### 7. Compatibility window (conditions before M3/M4) + +- `RATE_ITEM_MODEL` on for the importer and the web for at least one promote cycle. +- Rate Services Item-aware code shipped (step 5). +- M1+M2 verification at 0 after the last writer changed (the reconciliation). +- No listing without an Item: `SELECT count(*) FROM rate.products WHERE item_id IS NULL` = 0 (M4 aborts otherwise). +- Sitemap re-crawled with Item URLs (about 30 days). +- Active price alerts on `unknown` variants of multi-size Items decided (V7 of the M3/M4 verify file): each moved to a sized variant or closed. M3 aborts while one is active. +- **Roll back the window:** flags `off`. + +### 8. M3: retire `unknown` on multi-size Items + +- **Changes:** aborts on a sized earbuds key or an active alert on an `unknown` to retire (`price_alerts` locked against writes while it runs). Then archives the live `unknown` variant of every multi-size Item through `retire_item_unknown_variant`; its listings keep their Item with no variant; stats refresh. Backup table `rate.m3_unknown_variant_backup` (RLS on, service role only) records exactly what changed. Single-size Items (earbuds, sizeless lines) untouched. +- **File:** `2026-10-29-dos-me-m3-m4.sql`, first transaction. Writers paused. +- **Verify:** `2026-10-29-dos-me-m3-m4-verify.sql` V1 and V5 return 0; V6 (work done) and V7 (alerts) for information. +- **Roll back:** `2026-10-29-dos-me-m3-m4-rollback.sql`, writers paused: restores the variants and only the listings still carrying the retire marker (a NOTICE counts any changed since), refreshes stats, drops the backup table. +- **Who:** JOY through DOS-Me. + +### 9. M4: constraints + +- **Changes:** aborts unless every variant and listing has an Item; `master_products.item_id NOT NULL`; `products_item_id_not_null` CHECK (added NOT VALID, validated in its own transaction so the listings table stays readable and writable); guard trigger (a listing's variant belongs to its Item); `items.default_variant_id` filled, plus a trigger filling it for new Items. +- **File:** `2026-10-29-dos-me-m3-m4.sql`, second and third transactions. +- **Verify:** M3/M4 verify V2, V3, V4 return 0, and the M1+M2 verification file again. +- **Roll back:** the first transaction of the same rollback file undoes M4 alone; its second transaction then undoes M3. +- **Who:** JOY through DOS-Me. + +## Open decisions for JOY + +Each has a recommendation, which is the default if JOY agrees. + +1. **(M1+M2) `match_item` filters by brand, not by model.** The design names a model argument; filtering by it only finds the exact model, which Tier 1 already does. **Recommend: brand.** +2. **(M1+M2) An Item with no reviews stores rating 0**, like the variant columns today; the web shows "no rating" when the review count is 0. **Recommend: keep 0.** +3. **(M1+M2) `rate.v_products_with_merchant_trust` is not changed.** Adding `item_id` breaks a re-run of `20261018153000`; readers take `item_id` from `rate.products`. **Recommend: leave the view.** +4. **(M1+M2, updated by M3) Price alerts on an `unknown` variant.** An alert needs a size. M1+M2 leaves them on the variant; M3 now refuses to run while one is active, so JOY decides each one before M3: move it to a sized variant or close it. **Recommend: decide per alert from V7 before M3.** +5. **(M1+M2) Admin override name `rate.variant_override`.** Any admin tool that re-assigns a hand-set variant sets it locally. **Recommend: approve the name.** +6. **(M3/M4) M4 keeps the M1 sync triggers.** The design drops them; the importer still relies on them to create a new Item when it seeds a master. The M4 guard is the backstop; dropping them waits until the importer inserts Items itself. **Recommend: keep them.** +7. **(M3/M4) No master-side guard on a variant's Item change.** M1's move-listings trigger moves the listings in the same statement, and V3 checks the result. **Recommend: no extra guard.** +8. **(M0) Approve the M0 data write, including `apple:airpods-pro-2-usbc` becoming Item `apple:airpods-pro-2`** (the normalizer does not split USB-C from Lightning; only USB-C is sold new). **Recommend: approve.** +9. **(CI) Make `E2E (Playwright, Item model)` a required check on `dev`.** It ran green with no retry on every run of PR #1273 (about 4 minutes). Branch protection is a repository setting. **Recommend: required.** From f67509b340747dbd47d3b2d734c3dad5e72d4e91 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:01:55 +0700 Subject: [PATCH 53/69] fix(web): item catalogue, versus and sitemap edge cases (#1289) * fix(web): item catalogue, versus and sitemap edge cases Part 2 of the #1155 follow-ups, behind RATE_ITEM_MODEL: when the Item links fail, the catalogue page is cut from the variants it already read instead of reading them again; a master whose Item is not published compares as a plain master, as with the flag off; an Item slug holding "-vs-" keeps its variant slug as a versus side; the sitemap's versus pairs use the same variant-to-Item map as its product URLs. * fix(web): no versus page for a slug holding -vs- Review of the previous commit: a side whose URL slug holds "-vs-" (Item or variant) cannot parse back, so it is a 404 instead of a redirect to an unparsable URL, and the sitemap skips such pairs; a failing Item links read is logged as an error, a missing schema as a warning. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/sitemap.ts | 16 +++++++++---- .../src/lib/__tests__/item-compare.test.ts | 16 +++++++++++++ apps/web/src/lib/item-compare.ts | 5 ++++ apps/web/src/lib/rate-api.ts | 5 ++++ .../rate/__tests__/item-directory.test.ts | 23 +++++++++++++++++- apps/web/src/server/rate/index.ts | 1 + apps/web/src/server/rate/item-compare.ts | 5 +++- apps/web/src/server/rate/item-directory.ts | 24 +++++++++++++++---- 8 files changed, 84 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 24a82c8ca..6809000ff 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -7,7 +7,6 @@ import { getMasterProductCategorySlug, getMasterProductHref, } from '@/server/rate'; -import { getItemSlugByVariantSlug } from '@/server/rate/item-directory'; import { buildPopular1v1BattlesForCategory, VALID_LEADERBOARD_CATEGORIES, @@ -141,12 +140,13 @@ export default async function sitemap(): Promise { try { // Product and merchant URLs come from the whole catalogue; the curated // 1v1 battles only need the top-ranked products. - const [rows, products, itemSlugs] = await Promise.all([ + const [rows, products] = await Promise.all([ getCachedSitemapRows(), getCachedMasterProducts({ categorySlug: 'all', limit: 500 }), - // RATE_ITEM_MODEL on: versus pairs name Items (DOS/Rate#1156); off: null. - getItemSlugByVariantSlug(), ]); + // RATE_ITEM_MODEL on: versus pairs name Items with the map the product + // URLs were built from (DOS/Rate#1156, #1275); off: null. + const itemSlugs = rows.itemSlugByVariantSlug ?? null; const catSlugById = rows.categorySlugById; const dates = buildSitemapLastModified( @@ -197,7 +197,13 @@ export default async function sitemap(): Promise { // Two sizes of one Item are no versus, and a pair is listed once. // One entry per Item pair, whichever way round it was ranked. const pairKey = [slugA, slugB].sort().join('|'); - if (itemSlugs && (slugA === slugB || versusSeen.has(pairKey))) { + if ( + itemSlugs && + (slugA === slugB || + versusSeen.has(pairKey) || + slugA.includes('-vs-') || + slugB.includes('-vs-')) + ) { return []; } versusSeen.add(pairKey); diff --git a/apps/web/src/lib/__tests__/item-compare.test.ts b/apps/web/src/lib/__tests__/item-compare.test.ts index 9894467f6..e10673da1 100644 --- a/apps/web/src/lib/__tests__/item-compare.test.ts +++ b/apps/web/src/lib/__tests__/item-compare.test.ts @@ -164,6 +164,22 @@ function main() { { kind: 'not_found' } ); + // A slug holding "-vs-" cannot be a versus side: no versus page (#1275). + const vsItem: CompareResolution = { + kind: 'item', + item: { id: 'item-vs', slug: 'case-vs-cover', default_variant_id: null }, + variants: [ + { id: 'cv', slug: 'case-vs-cover-std', variant_key: 'standard' }, + ], + }; + assert.deepEqual( + planVersusRoute( + { slug: 'case-vs-cover-std', resolution: vsItem }, + { slug: 'samsung-galaxy-s24', resolution: s24 } + ), + { kind: 'not_found' } + ); + console.warn(' ✅ item compare suite passed'); } diff --git a/apps/web/src/lib/item-compare.ts b/apps/web/src/lib/item-compare.ts index 315ea5d96..7cc8c40a8 100644 --- a/apps/web/src/lib/item-compare.ts +++ b/apps/web/src/lib/item-compare.ts @@ -128,6 +128,11 @@ export function planVersusRoute( if (a.itemId && a.itemId === b.itemId) return { kind: 'not_found' }; const urlSlugA = a.itemSlug ?? a.variantSlug; const urlSlugB = b.itemSlug ?? b.variantSlug; + // A slug holding "-vs-" cannot be one side of a versus URL (it would not + // parse back), so there is no versus page for it (#1275). + if (urlSlugA.includes('-vs-') || urlSlugB.includes('-vs-')) { + return { kind: 'not_found' }; + } const canonical = `/compare/${urlSlugA}-vs-${urlSlugB}`; if (urlSlugA !== sideA.slug || urlSlugB !== sideB.slug) { return { kind: 'redirect', to: canonical }; diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 0cc30f169..8734e949a 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -2482,6 +2482,11 @@ export interface SitemapRows { }>; merchants: Array<{ id: string; updated_at: string | null }>; categorySlugById: Map; + /** + * RATE_ITEM_MODEL on: variant slug to Item slug, the map the product rows + * were grouped with, so the sitemap's versus pairs use the same (#1275). + */ + itemSlugByVariantSlug?: ReadonlyMap | null; } /** diff --git a/apps/web/src/server/rate/__tests__/item-directory.test.ts b/apps/web/src/server/rate/__tests__/item-directory.test.ts index b3227ce53..1a2fb4932 100644 --- a/apps/web/src/server/rate/__tests__/item-directory.test.ts +++ b/apps/web/src/server/rate/__tests__/item-directory.test.ts @@ -222,7 +222,11 @@ async function main() { // Flag on, Item tables missing (M1+M2 not applied): variants as before. schemaMissing = true; assert.equal(await dir.groupCatalogRowsByItem(rows), rows); - assert.equal(await dir.getItemCatalogPage(async () => rows, 0, 10), null); + // The variants already read are the page, ungrouped (#1275). + assert.deepEqual( + (await dir.getItemCatalogPage(async () => rows, 0, 10))?.map((r) => r.id), + rows.map((r) => r.id) + ); assert.equal(await dir.getItemBrandFacets(CATEGORY, 'smartphones'), null); assert.equal(await dir.getItemCount(), null); schemaMissing = false; @@ -230,6 +234,23 @@ async function main() { // Flag on, an Item read failing for another reason: variants too, never a // failed page (review round 1). itemsDown = true; + // The catalogue page cuts the variants it read instead of reading them + // again when the Item links fail (#1275). + let catalogueReads = 0; + assert.deepEqual( + ( + await dir.getItemCatalogPage( + async () => { + catalogueReads += 1; + return rows; + }, + 0, + 2 + ) + )?.map((r) => r.id), + [V256, LONE] + ); + assert.equal(catalogueReads, 1); assert.equal(await dir.groupCatalogRowsByItem(rows), rows); assert.equal(await dir.getItemCount(), null); itemsDown = false; diff --git a/apps/web/src/server/rate/index.ts b/apps/web/src/server/rate/index.ts index 4790208f1..b7e10e93b 100644 --- a/apps/web/src/server/rate/index.ts +++ b/apps/web/src/server/rate/index.ts @@ -631,6 +631,7 @@ export const getCachedSitemapRows = cache(async (): Promise => { : rows.products, merchants: rows.merchants, categorySlugById: new Map(rows.categories), + itemSlugByVariantSlug: itemSlugs, }; }); diff --git a/apps/web/src/server/rate/item-compare.ts b/apps/web/src/server/rate/item-compare.ts index f4c8d77f7..573bc82f2 100644 --- a/apps/web/src/server/rate/item-compare.ts +++ b/apps/web/src/server/rate/item-compare.ts @@ -23,7 +23,10 @@ const SLUG_REGEX = /^[a-z0-9][a-z0-9._-]{0,159}$/; async function resolveSlug(slug: string): Promise { if (!SLUG_REGEX.test(slug)) return null; const resolution = await getCachedCatalogSlugResolution(slug); - if (!resolution) return null; + // No published Item for this slug: it compares as a plain master, as with + // the flag off (a master of a draft Item, or an unknown slug that the + // compare read then drops) (#1275). + if (!resolution) return { kind: 'unlinked' }; if (resolution.kind === 'unlinked') return { kind: 'unlinked' }; const variants = await getCachedItemVariants(resolution.item); return resolution.kind === 'item' diff --git a/apps/web/src/server/rate/item-directory.ts b/apps/web/src/server/rate/item-directory.ts index 48e396dee..a5f13c12a 100644 --- a/apps/web/src/server/rate/item-directory.ts +++ b/apps/web/src/server/rate/item-directory.ts @@ -109,11 +109,27 @@ export async function getItemCatalogPage( rows.push(...page); if (page.length < CATALOG_BATCH_SIZE) break; } - const grouped = groupRowsByItem( - rows, - await getCachedItemLinks(rows.map((r) => r.id).filter(Boolean)) + // The variants are read already: if the Item links fail, this page is + // the variant page the caller would read again (same filters, sort and + // window), so it is cut from them instead (#1275). + const links = await getCachedItemLinks( + rows.map((r) => r.id).filter(Boolean) + ).catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof ItemModelUnavailableError) { + log.warn('item links unavailable, showing variants', { message }); + } else { + log.error('item links read failed, showing variants', { message }); + } + return null; + }); + if (!links) { + return rows.slice(offset, offset + limit) as Array; + } + return sortItemRows(groupRowsByItem(rows, links), sort).slice( + offset, + offset + limit ); - return sortItemRows(grouped, sort).slice(offset, offset + limit); }); } From 7901172cfb314f113d5819d523ecfc07f04becdc Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:10:36 +0700 Subject: [PATCH 54/69] fix(web): item versus links, filtered size chips and the read cap (#1293) * fix(web): item versus links, filtered size chips and the read cap Behind RATE_ITEM_MODEL: the compare page's 1v1 link, the matchup switcher and the scoreboard swap link name Item pairs (no 308 hop), from a slug map the pages pass only while the flag is on; with a ?storage= filter a card's chips list only the matching sizes, as its price does; a category too large to group in 20 read batches shows its variant listing instead of a page cut short or empty. * test(web): item slug map for in-app versus links Co-Authored-By: Claude Opus 5.5 --- .../app/[locale]/compare/[versus]/page.tsx | 10 +++++++ apps/web/src/app/[locale]/compare/page.tsx | 6 +++++ .../web/src/app/[locale]/smartphones/page.tsx | 3 ++- .../components/compare/CompareWorkspace.tsx | 10 ++++++- .../compare/VersusInteractiveScoreboard.tsx | 6 ++++- .../compare/VersusMatchupSwitcher.tsx | 9 +++++-- .../src/lib/__tests__/item-compare.test.ts | 21 +++++++++++++++ .../src/lib/__tests__/item-directory.test.ts | 11 ++++++++ apps/web/src/lib/item-compare.ts | 18 +++++++++++++ apps/web/src/lib/item-directory.ts | 11 ++++++-- .../rate/__tests__/item-directory.test.ts | 18 +++++++++++++ apps/web/src/server/rate/item-directory.ts | 26 +++++++++++++++++-- 12 files changed, 140 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/[locale]/compare/[versus]/page.tsx b/apps/web/src/app/[locale]/compare/[versus]/page.tsx index cca21ea09..30a92ce11 100644 --- a/apps/web/src/app/[locale]/compare/[versus]/page.tsx +++ b/apps/web/src/app/[locale]/compare/[versus]/page.tsx @@ -10,6 +10,7 @@ import { getMasterProductHref, } from '@/server/rate'; import { resolveVersusPlan } from '@/server/rate/item-compare'; +import { getItemSlugsFor } from '@/server/rate/item-directory'; import { normalizeRateLocale } from '@/lib/rate-i18n'; import { defaultOpenGraph, fitTitle, fitDescription } from '@/lib/seo'; import { scoreMasterProductForLeaderboard } from '@/lib/leaderboard-engine'; @@ -146,6 +147,12 @@ export default async function VersusComparePage({ params }: VersusPageProps) { const catSlug = getMasterProductCategorySlug(prodA); const scoreA = scoreMasterProductForLeaderboard(prodA, 'combined'); const scoreB = scoreMasterProductForLeaderboard(prodB, 'combined'); + // RATE_ITEM_MODEL on: in-app versus links name Items (DOS/Rate#1290). + const itemSlugBySlug = await getItemSlugsFor([ + ...compared.map((p) => p.slug), + ...directory.map((p) => p.slug), + ]); + const itemSlugProps = itemSlugBySlug ? { itemSlugBySlug } : {}; // Summary ItemList: each item links to its product page, which carries the // Product markup (DOS/Rate#1059). @@ -219,6 +226,7 @@ export default async function VersusComparePage({ params }: VersusPageProps) { scoreA={scoreA} scoreB={scoreB} locale={locale} + {...itemSlugProps} />
    @@ -239,6 +248,7 @@ export default async function VersusComparePage({ params }: VersusPageProps) { initialCategory={catSlug} hasExplicitSlugs={true} locale={locale} + {...itemSlugProps} />
    diff --git a/apps/web/src/app/[locale]/compare/page.tsx b/apps/web/src/app/[locale]/compare/page.tsx index 6918afdfe..15455bb88 100644 --- a/apps/web/src/app/[locale]/compare/page.tsx +++ b/apps/web/src/app/[locale]/compare/page.tsx @@ -8,6 +8,7 @@ import { getMasterProductCategorySlug, } from '@/server/rate'; import { resolveCompareSlugs } from '@/server/rate/item-compare'; +import { getItemSlugsFor } from '@/server/rate/item-directory'; import { normalizeRateLocale } from '@/lib/rate-i18n'; import { defaultOpenGraph, fitTitle, fitDescription } from '@/lib/seo'; import CompareWorkspace from '@/components/compare/CompareWorkspace'; @@ -149,6 +150,10 @@ export default async function ComparePhonesPage({ (p) => getMasterProductCategorySlug(p) === activeCategory ) : sameCategoryCandidates.slice(0, 3); + // RATE_ITEM_MODEL on: versus links name Items (DOS/Rate#1290); off: null. + const itemSlugBySlug = await getItemSlugsFor( + initialProducts.map((p) => p.slug) + ); return ( @@ -193,6 +198,7 @@ export default async function ComparePhonesPage({ initialCategory={activeCategory} hasExplicitSlugs={requestedSlugs.length > 0} locale={locale} + {...(itemSlugBySlug ? { itemSlugBySlug } : {})} />
    diff --git a/apps/web/src/app/[locale]/smartphones/page.tsx b/apps/web/src/app/[locale]/smartphones/page.tsx index d23534ea4..12975986d 100644 --- a/apps/web/src/app/[locale]/smartphones/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/page.tsx @@ -864,7 +864,8 @@ export async function CategoryCatalogContent({ sizeChips={itemSizeChips( item, `${basePath}/${catalogRowSlug(item)}`, - formatStorageLabel + formatStorageLabel, + storageGb )} categoryPath={basePath} name={item.name} diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index 7450c828e..b07346fec 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -71,6 +71,7 @@ import { import { compareRowIsShown, known5g } from '@/lib/product-spec-rows'; import ContentText from '@/components/elementary/ContentText'; import { productImageUrl } from '@/lib/rate-domain-core'; +import { versusHref } from '@/lib/item-compare'; interface CompareWorkspaceProps { initialProducts: MasterProduct[]; @@ -78,6 +79,8 @@ interface CompareWorkspaceProps { initialCategory?: CanonicalCategorySlug; hasExplicitSlugs?: boolean; locale?: string; + /** RATE_ITEM_MODEL on: variant slug to Item slug, for versus links. */ + itemSlugBySlug?: Readonly>; } type CompareTranslator = ReturnType>; @@ -825,6 +828,7 @@ export default function CompareWorkspace({ initialCategory = 'smartphones', hasExplicitSlugs = false, locale = 'vi', + itemSlugBySlug, }: CompareWorkspaceProps) { const t = useTranslations('compare'); const router = useRouter(); @@ -1697,7 +1701,11 @@ export default function CompareWorkspace({ {products.length === 2 && products[0] && products[1] && ( {t('deep1v1PageBtn')} diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx index 1dbdd8842..0fd7c6690 100644 --- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx +++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx @@ -20,6 +20,7 @@ import { escapeCsvCell } from '@/lib/csv-safe'; import { isVersusScoreTie, versusVerdictFor } from '@/lib/versus-verdict'; import RadarBadge from '@/components/radar/RadarBadge'; import { useDealRadarIndex } from '@/components/radar/use-deal-radar'; +import { versusHref } from '@/lib/item-compare'; export type ScoredVersusItem = Omit; @@ -27,6 +28,8 @@ interface VersusInteractiveScoreboardProps { readonly scoreA: ScoredVersusItem; readonly scoreB: ScoredVersusItem; readonly locale?: string; + /** RATE_ITEM_MODEL on: variant slug to Item slug, for the swap link. */ + readonly itemSlugBySlug?: Readonly>; } export type PersonaPresetKey = @@ -171,6 +174,7 @@ export function computeVersusWeightedResult( export default function VersusInteractiveScoreboard({ scoreA, scoreB, + itemSlugBySlug, }: VersusInteractiveScoreboardProps) { const t = useTranslations('compare'); const locale = useLocale(); @@ -556,7 +560,7 @@ export default function VersusInteractiveScoreboard({ JSON diff --git a/apps/web/src/components/compare/VersusMatchupSwitcher.tsx b/apps/web/src/components/compare/VersusMatchupSwitcher.tsx index ae5441838..4d15da2dd 100644 --- a/apps/web/src/components/compare/VersusMatchupSwitcher.tsx +++ b/apps/web/src/components/compare/VersusMatchupSwitcher.tsx @@ -11,6 +11,7 @@ import { type CanonicalCategorySlug, type MasterProduct, } from '@/lib/rate-domain'; +import { versusHref } from '@/lib/item-compare'; export function formatVersusPeerPriceBadge(value: unknown): string { const num = Number(value); @@ -45,6 +46,8 @@ interface VersusMatchupSwitcherProps { readonly categorySlug: CanonicalCategorySlug; readonly catalogCandidates: MasterProduct[]; readonly locale?: string; + /** RATE_ITEM_MODEL on: variant slug to Item slug, for versus links. */ + readonly itemSlugBySlug?: Readonly>; } export default function VersusMatchupSwitcher({ @@ -52,6 +55,7 @@ export default function VersusMatchupSwitcher({ currentSlugB, categorySlug, catalogCandidates, + itemSlugBySlug, }: VersusMatchupSwitcherProps) { const t = useTranslations('compare'); const router = useRouter(); @@ -79,8 +83,9 @@ export default function VersusMatchupSwitcher({ const handleLaunchMatchup = (e: React.FormEvent) => { e.preventDefault(); if (!canLaunchMatchup) return; - startNavigationProgress(`/compare/${effectiveSlugA}-vs-${effectiveSlugB}`); - router.push(`/compare/${effectiveSlugA}-vs-${effectiveSlugB}`); + const href = versusHref(effectiveSlugA, effectiveSlugB, itemSlugBySlug); + startNavigationProgress(href); + router.push(href); }; if (sameCategoryPeers.length < 2) { diff --git a/apps/web/src/lib/__tests__/item-compare.test.ts b/apps/web/src/lib/__tests__/item-compare.test.ts index e10673da1..5f3fb6415 100644 --- a/apps/web/src/lib/__tests__/item-compare.test.ts +++ b/apps/web/src/lib/__tests__/item-compare.test.ts @@ -5,6 +5,7 @@ import { parseCompareToken, pickItemVariant, planVersusRoute, + versusHref, type CompareResolution, } from '../item-compare'; @@ -180,6 +181,26 @@ function main() { { kind: 'not_found' } ); + // In-app versus links name Items when mapped (#1290). + const map = { + 'apple-iphone-16-128gb': 'apple-iphone-16', + 'case-a-std': 'case-vs-cover', + }; + assert.equal( + versusHref('apple-iphone-16-128gb', 'pixel-10', map), + '/compare/apple-iphone-16-vs-pixel-10' + ); + assert.equal( + versusHref('apple-iphone-16-128gb', 'pixel-10'), + '/compare/apple-iphone-16-128gb-vs-pixel-10', + 'no map: as before' + ); + assert.equal( + versusHref('case-a-std', 'pixel-10', map), + '/compare/case-a-std-vs-pixel-10', + 'an Item slug holding -vs- keeps the variant slug' + ); + console.warn(' ✅ item compare suite passed'); } diff --git a/apps/web/src/lib/__tests__/item-directory.test.ts b/apps/web/src/lib/__tests__/item-directory.test.ts index 55486fefb..22a298054 100644 --- a/apps/web/src/lib/__tests__/item-directory.test.ts +++ b/apps/web/src/lib/__tests__/item-directory.test.ts @@ -155,6 +155,17 @@ function main() { ['/x?v=128gb', '/x?v=256gb'] ); + // A ?storage= filter keeps only the matching sizes' chips (#1290). + assert.deepEqual( + itemSizeChips(card, '/x', (gb) => `${gb}GB`, [128]), + [], + 'one matching size is no choice' + ); + assert.equal( + itemSizeChips(card, '/x', (gb) => `${gb}GB`, [128, 256]).length, + 2 + ); + // Reviews kept out of an Item's rating are all its sizes' (#1275). assert.deepEqual( sumCountsByItemRow(grouped, { 'm-128': 2, 'm-256': 3, 'm-other': 1 }), diff --git a/apps/web/src/lib/item-compare.ts b/apps/web/src/lib/item-compare.ts index 7cc8c40a8..80b41b9c9 100644 --- a/apps/web/src/lib/item-compare.ts +++ b/apps/web/src/lib/item-compare.ts @@ -144,3 +144,21 @@ export function planVersusRoute( canonical, }; } + +/** + * A versus URL for two catalogue slugs, naming their Items when + * `itemSlugBySlug` maps them (RATE_ITEM_MODEL on), so in-app links need no + * 308 hop (DOS/Rate#1290). A side whose Item slug holds "-vs-" keeps its + * own slug; with no map the URL is as before. + */ +export function versusHref( + slugA: string, + slugB: string, + itemSlugBySlug?: Readonly> | null +): string { + const side = (slug: string) => { + const itemSlug = itemSlugBySlug?.[slug]; + return itemSlug && !itemSlug.includes('-vs-') ? itemSlug : slug; + }; + return `/compare/${side(slugA)}-vs-${side(slugB)}`; +} diff --git a/apps/web/src/lib/item-directory.ts b/apps/web/src/lib/item-directory.ts index b0fb6550c..b5173a392 100644 --- a/apps/web/src/lib/item-directory.ts +++ b/apps/web/src/lib/item-directory.ts @@ -185,10 +185,17 @@ export interface ItemSizeChip { export function itemSizeChips( row: ItemCatalogRow, itemHref: string, - formatStorage: (gb: number) => string + formatStorage: (gb: number) => string, + /** A `?storage=` filter: only the matching sizes, as the price (#1290). */ + onlyStorage?: readonly number[] ): ItemSizeChip[] { + const filter = + onlyStorage && onlyStorage.length > 0 ? new Set(onlyStorage) : null; const sized = (row.item_variants ?? []).filter( - (v) => typeof v.storage_gb === 'number' && v.storage_gb > 0 + (v) => + typeof v.storage_gb === 'number' && + v.storage_gb > 0 && + (!filter || filter.has(v.storage_gb)) ); // One chip per size label: `256gb` and a later `256gb-cellular` (design // Q3) would read the same, so the first in variant order stands. diff --git a/apps/web/src/server/rate/__tests__/item-directory.test.ts b/apps/web/src/server/rate/__tests__/item-directory.test.ts index 1a2fb4932..e50bee596 100644 --- a/apps/web/src/server/rate/__tests__/item-directory.test.ts +++ b/apps/web/src/server/rate/__tests__/item-directory.test.ts @@ -161,6 +161,7 @@ async function main() { assert.equal(await dir.getItemBrandFacets(CATEGORY, 'smartphones'), null); assert.equal(await dir.getItemCount(), null); assert.equal(await dir.getItemSlugByVariantSlug(), null); + assert.equal(await dir.getItemSlugsFor(['apple-iphone-16-128gb']), null); assert.deepEqual(calls, [], 'flag off: no database read'); // Flag on: one row per Item, pages cut after grouping. @@ -207,6 +208,23 @@ async function main() { assert.equal(tail!.length, 20); assert.equal(tail!.at(-1)!.id, 'lone-599'); + // In-app versus links: only the slugs asked for, mapped to Items (#1290). + assert.deepEqual( + await dir.getItemSlugsFor(['apple-iphone-16-256gb', 'samsung-galaxy-s24']), + { 'apple-iphone-16-256gb': 'apple-iphone-16' } + ); + + // Past the read cap the caller shows its variant listing (#1290). + assert.equal( + await dir.getItemCatalogPage( + async (_offset, limit) => + Array.from({ length: limit }, (_, i) => row(`x-${i}`, `x-${i}`, 1)), + 0, + 10 + ), + null + ); + // Brand facets in Items; a master of a draft Item counts as itself. assert.deepEqual(await api.getCategoryItemBrandFacets(CATEGORY), [ { name: 'Apple', count: 1 }, diff --git a/apps/web/src/server/rate/item-directory.ts b/apps/web/src/server/rate/item-directory.ts index a5f13c12a..243ce5a41 100644 --- a/apps/web/src/server/rate/item-directory.ts +++ b/apps/web/src/server/rate/item-directory.ts @@ -99,8 +99,13 @@ export async function getItemCatalogPage( const rows: T[] = []; for (let batch = 0; ; batch++) { if (batch === MAX_CATALOG_BATCHES) { - log.warn('item catalogue read cut', { variants: rows.length }); - break; + // Too many variants to group in the web: the variant listing, + // rather than a page cut short or empty (#1290). Needs the Item + // search RPCs (#1279). + log.error('item catalogue too large to group, showing variants', { + variants: rows.length, + }); + return null; } const page = await readBatch( batch * CATALOG_BATCH_SIZE, @@ -168,3 +173,20 @@ export function getItemSlugByVariantSlug() { return new Map(entries); }); } + +/** + * Item slugs for the given variant slugs, for in-app versus links (#1290); + * null while the Item model is off, so pages pass nothing new. + */ +export async function getItemSlugsFor( + slugs: readonly string[] +): Promise | null> { + const map = await getItemSlugByVariantSlug(); + if (!map) return null; + const out: Record = {}; + for (const slug of slugs) { + const itemSlug = map.get(slug); + if (itemSlug) out[slug] = itemSlug; + } + return out; +} From 3388764380e065068435b2287d7752b6def26b01 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:18:35 +0700 Subject: [PATCH 55/69] fix(web): restored compare tray links item pairs without a 308 (#1295) * fix(web): restored compare tray links item pairs without a 308 With RATE_ITEM_MODEL on, the compare page's 1v1 link also names the Item pair for products the client added or restored from the tray: their Item slug is read from the product API once per slug. With the flag off the page passes no slug map and nothing is read. * fix(web): the compare page's slug map covers slugs with no item A slug the server resolved but found no Item for maps to itself, so the compare workspace does not read it again on the client. Co-Authored-By: Claude Opus 5.5 --- .../components/compare/CompareWorkspace.tsx | 44 ++++++++++++++++++- .../src/lib/__tests__/item-compare.test.ts | 23 ++++++++++ apps/web/src/lib/item-compare.ts | 15 +++++++ .../rate/__tests__/item-directory.test.ts | 5 ++- apps/web/src/server/rate/item-directory.ts | 7 ++- 5 files changed, 87 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index b07346fec..1966008da 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -71,7 +71,7 @@ import { import { compareRowIsShown, known5g } from '@/lib/product-spec-rows'; import ContentText from '@/components/elementary/ContentText'; import { productImageUrl } from '@/lib/rate-domain-core'; -import { versusHref } from '@/lib/item-compare'; +import { itemSlugFromProductAnswer, versusHref } from '@/lib/item-compare'; interface CompareWorkspaceProps { initialProducts: MasterProduct[]; @@ -868,6 +868,46 @@ export default function CompareWorkspace({ storedProducts.map((p) => applyCategorySpecTemplate(activeCategory, p)), [storedProducts, activeCategory] ); + // RATE_ITEM_MODEL on (the page passed a map): products the client added + // or restored from the tray are read as Items too, so the 1v1 link names + // the Item pair without a 308 (DOS/Rate#1294). Off: no map, no reads. + const [clientItemSlugs, setClientItemSlugs] = useState< + Record + >({}); + const pairSlugA = products.length === 2 ? (products[0]?.slug ?? '') : ''; + const pairSlugB = products.length === 2 ? (products[1]?.slug ?? '') : ''; + useEffect(() => { + if (!itemSlugBySlug || !pairSlugA || !pairSlugB) return; + const missing = [pairSlugA, pairSlugB].filter( + (slug) => !itemSlugBySlug[slug] && !(slug in clientItemSlugs) + ); + if (missing.length === 0) return; + let cancelled = false; + void Promise.all( + missing.map((slug) => + fetch(`/api/v1/rate/products/${encodeURIComponent(slug)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((json: unknown) => [ + slug, + itemSlugFromProductAnswer(json, slug), + ]) + .catch(() => [slug, slug]) + ) + ).then((entries) => { + if (!cancelled) { + setClientItemSlugs((prev) => ({ + ...prev, + ...Object.fromEntries(entries), + })); + } + }); + return () => { + cancelled = true; + }; + }, [itemSlugBySlug, pairSlugA, pairSlugB, clientItemSlugs]); + const versusSlugMap = itemSlugBySlug + ? { ...itemSlugBySlug, ...clientItemSlugs } + : undefined; const specGroups = useMemo( () => filterSpecGroupsForCategory(allSpecGroups, activeCategory), [allSpecGroups, activeCategory] @@ -1704,7 +1744,7 @@ export default function CompareWorkspace({ href={versusHref( products[0].slug, products[1].slug, - itemSlugBySlug + versusSlugMap )} className="btn btn-sm btn-dark rounded-pill fw-medium px-3 py-2" > diff --git a/apps/web/src/lib/__tests__/item-compare.test.ts b/apps/web/src/lib/__tests__/item-compare.test.ts index 5f3fb6415..a005a354a 100644 --- a/apps/web/src/lib/__tests__/item-compare.test.ts +++ b/apps/web/src/lib/__tests__/item-compare.test.ts @@ -6,6 +6,7 @@ import { pickItemVariant, planVersusRoute, versusHref, + itemSlugFromProductAnswer, type CompareResolution, } from '../item-compare'; @@ -201,6 +202,28 @@ function main() { 'an Item slug holding -vs- keeps the variant slug' ); + // A restored tray product reads its Item from the product API (#1294). + assert.equal( + itemSlugFromProductAnswer( + { ok: true, data: { item: { slug: 'apple-iphone-16' } } }, + 'apple-iphone-16-128gb' + ), + 'apple-iphone-16' + ); + assert.equal( + itemSlugFromProductAnswer({ ok: true, data: {} }, 'pixel-10'), + 'pixel-10', + 'no Item (flag off or unlinked): the slug itself' + ); + assert.equal(itemSlugFromProductAnswer(null, 'x'), 'x'); + assert.equal( + itemSlugFromProductAnswer( + { ok: true, data: { item: { slug: 'Bad Slug' } } }, + 'x' + ), + 'x' + ); + console.warn(' ✅ item compare suite passed'); } diff --git a/apps/web/src/lib/item-compare.ts b/apps/web/src/lib/item-compare.ts index 80b41b9c9..30e740529 100644 --- a/apps/web/src/lib/item-compare.ts +++ b/apps/web/src/lib/item-compare.ts @@ -162,3 +162,18 @@ export function versusHref( }; return `/compare/${side(slugA)}-vs-${side(slugB)}`; } + +/** + * The Item slug a `/api/v1/rate/products/{slug}` answer names (its `item`, + * RATE_ITEM_MODEL on), else the slug itself (DOS/Rate#1294). + */ +export function itemSlugFromProductAnswer(json: unknown, slug: string): string { + const itemSlug = + json && typeof json === 'object' && (json as { ok?: unknown }).ok === true + ? (json as { data?: { item?: { slug?: unknown } } }).data?.item?.slug + : undefined; + return typeof itemSlug === 'string' && + /^[a-z0-9][a-z0-9._-]{0,159}$/.test(itemSlug) + ? itemSlug + : slug; +} diff --git a/apps/web/src/server/rate/__tests__/item-directory.test.ts b/apps/web/src/server/rate/__tests__/item-directory.test.ts index e50bee596..37c4b4ef5 100644 --- a/apps/web/src/server/rate/__tests__/item-directory.test.ts +++ b/apps/web/src/server/rate/__tests__/item-directory.test.ts @@ -211,7 +211,10 @@ async function main() { // In-app versus links: only the slugs asked for, mapped to Items (#1290). assert.deepEqual( await dir.getItemSlugsFor(['apple-iphone-16-256gb', 'samsung-galaxy-s24']), - { 'apple-iphone-16-256gb': 'apple-iphone-16' } + { + 'apple-iphone-16-256gb': 'apple-iphone-16', + 'samsung-galaxy-s24': 'samsung-galaxy-s24', + } ); // Past the read cap the caller shows its variant listing (#1290). diff --git a/apps/web/src/server/rate/item-directory.ts b/apps/web/src/server/rate/item-directory.ts index 243ce5a41..e6442a4b8 100644 --- a/apps/web/src/server/rate/item-directory.ts +++ b/apps/web/src/server/rate/item-directory.ts @@ -184,9 +184,8 @@ export async function getItemSlugsFor( const map = await getItemSlugByVariantSlug(); if (!map) return null; const out: Record = {}; - for (const slug of slugs) { - const itemSlug = map.get(slug); - if (itemSlug) out[slug] = itemSlug; - } + // A slug with no Item maps to itself: it is resolved, so the compare + // workspace does not read it again on the client (#1294). + for (const slug of slugs) out[slug] = map.get(slug) ?? slug; return out; } From eb48aeab4cfc660ae3aef64c3c5aa46679957e4b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:19:30 +0700 Subject: [PATCH 56/69] feat(web): load item size panels on demand for items with many sizes (#1296) * fix(web): item pages live under their own category; unknown-size history Item page follow-ups of #1154 (DOS/Rate#1281), behind RATE_ITEM_MODEL: - the route category is checked against the Item's category_id: an Item URL under another category, and an old variant URL redirected from one, 308 to the Item's own category; an Item whose category is no catalogue category is not found; an Item with no category keeps the requested one; - the "storage not stated" group gets its listings' price-history verdicts, cached per Item and per their current prices. Refs #1281 * fix(web): read an uncached item category, canonical of a redirected url Codex round 1 on DOS/Rate#1281: - a category id missing from the cached map is read by id before the Item is called lost, so a new category is never a 404; - metadata of a URL the page 308s away points its canonical at the redirect target, not the requested category; - the unknown-size history cache key hashes the listings' prices. Refs #1281 * feat(web): load item size panels on demand for items with many sizes DOS/Rate#1285, behind RATE_ITEM_MODEL. An Item page rendered every size's price strip, history and offer table into the RSC payload, about 30 KB per size (#1281). Above 4 sizes, or when the other sizes' offers and history pass about 150 KB, the page now renders only the default size; another size loads when picked from a cached route, /api/v1/rate/item-size/{variant slug} (revalidate 300), and shows with the same client components (price strip, history, offers), keyed per size. Smaller Items render as before; the page stays ISR. - e2e: a six-size Item in the item-model seed; the spec logs the RSC bytes of the lazy and an eager Item and checks a picked size loads. - client messages: the saleState labels and two loading strings. * fix(web): size route only for a published item's sizes, fresh data Codex round 1 on DOS/Rate#1285: - the size route answers only a published size of a published Item (not a draft Item, a retired size or a master with no Item); - the browser shares only the request in flight per size, so a later pick reads fresh data instead of a page-lifetime cache; - a failed size has a retry button; - the 150 KB trigger counts UTF-8 bytes. Refs #1285 * Merge remote-tracking branch 'origin/dev' into feat/1285-lazy-item-size-panels # Conflicts: # apps/web/src/server/rate/item-route.ts Co-Authored-By: Claude Opus 5.5 --- .../e2e/item-model/item-size-panels.spec.ts | 61 +++++ apps/web/e2e/item-model/seed.sql | 36 +++ apps/web/locales/cs.json | 5 +- apps/web/locales/en.json | 5 +- apps/web/locales/vi.json | 5 +- .../app/[locale]/smartphones/[slug]/page.tsx | 135 ++++++---- .../app/api/v1/rate/item-size/[slug]/route.ts | 65 +++++ .../product/item/ItemLazySizePanel.tsx | 252 ++++++++++++++++++ apps/web/src/lib/client-messages.ts | 2 + apps/web/src/lib/item-size-panel.ts | 64 +++++ .../server/rate/__tests__/item-model.test.ts | 53 +++- apps/web/src/server/rate/item-page.ts | 106 +++++++- 12 files changed, 732 insertions(+), 57 deletions(-) create mode 100644 apps/web/e2e/item-model/item-size-panels.spec.ts create mode 100644 apps/web/src/app/api/v1/rate/item-size/[slug]/route.ts create mode 100644 apps/web/src/components/product/item/ItemLazySizePanel.tsx create mode 100644 apps/web/src/lib/item-size-panel.ts diff --git a/apps/web/e2e/item-model/item-size-panels.spec.ts b/apps/web/e2e/item-model/item-size-panels.spec.ts new file mode 100644 index 000000000..fee1f7984 --- /dev/null +++ b/apps/web/e2e/item-model/item-size-panels.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; + +// Lazy size panels on an Item with many sizes (DOS/Rate#1285): the six-size Item in +// seed.sql renders only its default size on the server and loads the others when +// picked; the two-size Item still renders both. The RSC payload sizes are logged as +// test annotations, so the CI report records them. +const BIG = '/smartphones/e2e-item-big'; +const SMALL = '/smartphones/e2e-item-phone'; +const listing = (gb: number, l: number) => `E2E Item Big ${gb}GB listing ${l}`; + +const rscBytes = async ( + request: import('@playwright/test').APIRequestContext, + path: string +) => { + const res = await request.get(path, { headers: { RSC: '1' } }); + expect(res.status(), path).toBe(200); + return (await res.text()).length; +}; + +test('a six-size Item ships only its default size and loads the others on demand', async ({ + page, + request, +}, testInfo) => { + const big = await rscBytes(request, BIG); + const small = await rscBytes(request, SMALL); + testInfo.annotations.push( + { type: 'rsc-bytes six-size Item (lazy)', description: String(big) }, + { type: 'rsc-bytes two-size Item (eager)', description: String(small) } + ); + // What the five other sizes' panel data would have added, from the size route. + let others = 0; + for (const gb of [128, 256, 512, 1024, 2048]) { + const res = await request.get( + `/api/v1/rate/item-size/e2e-item-big-${gb}gb` + ); + expect(res.status(), `${gb}GB`).toBe(200); + others += (await res.text()).length; + } + testInfo.annotations.push({ + type: 'size-route json bytes, five other sizes', + description: String(others), + }); + + const html = await (await request.get(BIG)).text(); + expect(html).toContain(listing(64, 1)); + expect(html, 'no other size is server-rendered').not.toContain( + listing(256, 1) + ); + + await page.goto(`${BIG}?v=256gb`); + await expect(page.getByText(listing(256, 1)).first()).toBeVisible(); + await expect(page.getByText(listing(64, 1))).toHaveCount(0); +}); + +test('a two-size Item still renders both sizes on the server', async ({ + request, +}) => { + const html = await (await request.get(SMALL)).text(); + expect(html).toContain('E2E Item Phone 128GB'); + expect(html).toContain('E2E Item Phone 256GB'); +}); diff --git a/apps/web/e2e/item-model/seed.sql b/apps/web/e2e/item-model/seed.sql index de1528662..5ca12d693 100644 --- a/apps/web/e2e/item-model/seed.sql +++ b/apps/web/e2e/item-model/seed.sql @@ -48,3 +48,39 @@ SELECT rate.refresh_item_stats(id) FROM rate.items WHERE item_key LIKE 'e2e:%'; -- An admin unpublishes the Item; its variant stays published (the variant lookup alone -- would still find it). UPDATE rate.items SET status = 'draft' WHERE item_key = 'e2e:item-draft'; + +-- A six-size Item (DOS/Rate#1285): above the lazy threshold, so its page renders only the +-- default size's panels and loads the others from /api/v1/rate/item-size/{slug}. Six +-- listings per size with ten history points each, so the payload difference shows. +INSERT INTO rate.master_products (id, brand, model, model_key, name, slug, category_id, status, + storage_gb, min_price, max_price, merchant_count) +SELECT ('17e00000-0000-4000-8000-0000000b' || lpad(g::text, 4, '0'))::uuid, 'E2E', 'Item Big', + 'e2e:item-big:' || (64 * 2 ^ (g - 1))::int || 'gb', + 'E2E Item Big ' || (64 * 2 ^ (g - 1))::int || 'GB', + 'e2e-item-big-' || (64 * 2 ^ (g - 1))::int || 'gb', + 'c0000000-0000-4000-8000-000000000001', 'published', (64 * 2 ^ (g - 1))::int, + 10000000 + g * 1000000, 10000000 + g * 1000000 + 500000, 2 +FROM generate_series(1, 6) AS g +ON CONFLICT (id) DO NOTHING; + +INSERT INTO rate.products (id, merchant_id, platform, platform_item_id, title, brand, + category_id, price_current, url, cluster_id) +SELECT ('27e00000-0000-4000-8000-0000000b' || lpad((g * 10 + l)::text, 4, '0'))::uuid, + CASE WHEN l % 2 = 0 THEN '11111111-1111-4111-8111-111111111111'::uuid + ELSE '11111111-1111-4111-8111-111111111112'::uuid END, + CASE WHEN l % 2 = 0 THEN 'shopee' ELSE 'tiki' END, + 'e2e_item_big_' || g || '_' || l, + 'E2E Item Big ' || (64 * 2 ^ (g - 1))::int || 'GB listing ' || l, + 'E2E', 'c0000000-0000-4000-8000-000000000001', + 10000000 + g * 1000000 + l * 100000, + 'https://shopee.vn/e2e-item-big-' || g || '-' || l, + ('17e00000-0000-4000-8000-0000000b' || lpad(g::text, 4, '0'))::uuid +FROM generate_series(1, 6) AS g, generate_series(1, 6) AS l +ON CONFLICT (id) DO NOTHING; + +INSERT INTO rate.price_history (product_id, price, recorded_at) +SELECT ('27e00000-0000-4000-8000-0000000b' || lpad((g * 10 + l)::text, 4, '0'))::uuid, + 10000000 + g * 1000000 + l * 100000 + d * 50000, now() - (d || ' days')::interval +FROM generate_series(1, 6) AS g, generate_series(1, 6) AS l, generate_series(1, 10) AS d; + +SELECT rate.refresh_item_stats(id) FROM rate.items WHERE item_key = 'e2e:item-big'; diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index ac8ebed56..a43fe2f35 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -1198,7 +1198,10 @@ "itemVariantPrice": "Od {price} ₫", "itemVariantNoPrice": "Zatím bez ceny", "unknownSizeTitle": "Neuvedené úložiště ({count} zdrojů)", - "unknownSizeNote": "Prodejce neuvádí velikost úložiště, proto tyto ceny nejsou součástí ceny „od“ produktu." + "unknownSizeNote": "Prodejce neuvádí velikost úložiště, proto tyto ceny nejsou součástí ceny „od“ produktu.", + "sizePanelLoading": "Načítání této varianty…", + "sizePanelLoadFailed": "Tuto variantu se nepodařilo načíst.", + "sizePanelRetry": "Zkusit znovu" }, "homePage": { "catDescSmartphones": "Filtrovat podle čipsetu, RAM/ROM, obnovovací frekvence Hz, baterie mAh a nabíjení W", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index 13d5adfe2..c2e50a10a 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -1198,7 +1198,10 @@ "itemVariantPrice": "From {price} ₫", "itemVariantNoPrice": "No price yet", "unknownSizeTitle": "Storage not stated ({count} sources)", - "unknownSizeNote": "The seller does not state the storage, so these prices are not part of the product's \"from\" price." + "unknownSizeNote": "The seller does not state the storage, so these prices are not part of the product's \"from\" price.", + "sizePanelLoading": "Loading this size…", + "sizePanelLoadFailed": "Could not load this size.", + "sizePanelRetry": "Try again" }, "homePage": { "catDescSmartphones": "Filter by chipset, RAM/ROM, refresh rate Hz, battery mAh & fast charge W", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index 9ea1cc6a2..e28e64a52 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -1198,7 +1198,10 @@ "itemVariantPrice": "Từ {price} ₫", "itemVariantNoPrice": "Chưa có giá", "unknownSizeTitle": "Chưa rõ dung lượng ({count} nguồn)", - "unknownSizeNote": "Người bán không ghi rõ dung lượng, nên các giá này không tính vào giá \"từ\" của sản phẩm." + "unknownSizeNote": "Người bán không ghi rõ dung lượng, nên các giá này không tính vào giá \"từ\" của sản phẩm.", + "sizePanelLoading": "Đang tải phiên bản này…", + "sizePanelLoadFailed": "Không tải được phiên bản này.", + "sizePanelRetry": "Thử lại" }, "homePage": { "catDescSmartphones": "Lọc theo chipset, RAM/ROM, tần số quét Hz, pin mAh & sạc nhanh W", diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index 7d5fa68d1..f31ebac0e 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -5,6 +5,7 @@ import { Link } from '@/lib/navigation'; import { notFound, permanentRedirect } from 'next/navigation'; import { resolveItemRoute } from '@/server/rate/item-route'; import { + itemSizePanelBytes, loadItemPageContext, pickRenderedSize, type ItemPageVariant, @@ -17,6 +18,10 @@ import { } from '@/lib/item-page'; import ItemVariantPicker from '@/components/product/item/ItemVariantPicker'; import ItemVariantPanels from '@/components/product/item/ItemVariantPanels'; +import ItemLazySizePanel, { + type ItemSizePanelKind, +} from '@/components/product/item/ItemLazySizePanel'; +import { shouldLazyLoadSizes } from '@/lib/item-size-panel'; import { getCrossPlatformDealRadar, getCachedMasterProductBySlug, @@ -632,6 +637,50 @@ export async function ProductDetailView({ const defaultVariantKey = renderedSize?.variant.variant_key ?? (itemRoute?.kind === 'render' ? itemRoute.variantKey : ''); + // An Item with many sizes (above 4, or about 150 KB of other sizes' data) + // renders only its default size here; the others load when picked, from a + // cached route, so the payload does not grow with every size (#1285). + const defaultSize = itemVariants.find( + (v) => v.variant.variant_key === defaultVariantKey + ); + const lazySizes = + isItemGroup && + defaultSize !== undefined && + shouldLazyLoadSizes( + itemVariants.length, + itemSizePanelBytes(itemVariants.filter((v) => v !== defaultSize)) + ); + const sizeSlugs = Object.fromEntries( + itemVariants.map((v) => [v.variant.variant_key, v.variant.slug]) + ); + const sizeStripContext = { + categoryPath: categoryBasePath, + userScore5: Number(displayUserRating.toFixed(2)), + expertScore10: expertRating10, + authenticReviews: authenticCount, + keySpecLine, + }; + const renderSizePanels = ( + kind: ItemSizePanelKind, + render: (v: ItemPageVariant) => React.ReactNode + ) => + lazySizes && defaultSize ? ( + + ) : ( + ({ + key: v.variant.variant_key, + node: render(v), + }))} + /> + ); return ( @@ -1180,25 +1229,19 @@ export async function ProductDetailView({
    {/* Neutral Market Price Reference Strip (De-commercialized per DESIGN.md v3.0) */} - {isItemGroup ? ( - ({ - key: v.variant.variant_key, - node: renderPriceStrip(variantStripInput(v)), - }))} - /> - ) : ( - renderPriceStrip({ - product, - offers, - priceHistory, - radarSignal, - stats: marketStats, - saleState, - saleStateKey, - }) - )} + {isItemGroup + ? renderSizePanels('strip', (v) => + renderPriceStrip(variantStripInput(v)) + ) + : renderPriceStrip({ + product, + offers, + priceHistory, + radarSignal, + stats: marketStats, + saleState, + saleStateKey, + })}
    {t('devInfoToggle')} @@ -1313,45 +1356,33 @@ export async function ProductDetailView({ })()} {/* SECTION #3: Time-Series Price History Chart, Fake Discount Detector & Neutral Merchant Audit */} - {isItemGroup ? ( - ({ - key: v.variant.variant_key, - node: renderPriceHistory(variantPanelInput(v)), - }))} - /> - ) : ( - renderPriceHistory({ - product, - priceHistory, - minPrice: currentMinPrice, - radarSignal, - }) - )} + {isItemGroup + ? renderSizePanels('history', (v) => + renderPriceHistory(variantPanelInput(v)) + ) + : renderPriceHistory({ + product, + priceHistory, + minPrice: currentMinPrice, + radarSignal, + })} {/* Neutral Merchant Reference & Trust Audit Table */}
    - {isItemGroup ? ( - ({ - key: v.variant.variant_key, - node: renderOffersBody(variantPanelInput(v)), - }))} - /> - ) : ( - renderOffersBody({ - product, - offers, - priceHistory, - radarSignal, - emptyText: pageEmptyOffersText, - }) - )} + {isItemGroup + ? renderSizePanels('offers', (v) => + renderOffersBody(variantPanelInput(v)) + ) + : renderOffersBody({ + product, + offers, + priceHistory, + radarSignal, + emptyText: pageEmptyOffersText, + })}
    {/* Listings whose size is not stated: their own group, never part diff --git a/apps/web/src/app/api/v1/rate/item-size/[slug]/route.ts b/apps/web/src/app/api/v1/rate/item-size/[slug]/route.ts new file mode 100644 index 000000000..4154feaad --- /dev/null +++ b/apps/web/src/app/api/v1/rate/item-size/[slug]/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server'; +import { loadItemSizePanel } from '@/server/rate/item-page'; +import { + PUBLIC_API_CORS_HEADERS, + jsonApiError, +} from '@/server/rate/api-handler'; + +// Cached like the product pages: the route reads nothing from the request but +// its path, so each size's answer is cached and refreshed every 5 minutes. +export const revalidate = 300; + +export function generateStaticParams(): { slug: string }[] { + return []; +} + +type RouteContext = { params: Promise<{ slug: string }> }; + +/** + * GET /api/v1/rate/item-size/{variant slug} + * One size of an Item page, loaded when the size is picked on an Item with + * many sizes (DOS/Rate#1285): its price strip data, history and offers. Only + * while `RATE_ITEM_MODEL` is on; 404 otherwise. + */ +export async function GET(_request: Request, context: RouteContext) { + try { + const { slug } = await context.params; + const cleanSlug = (slug ?? '').trim(); + if ( + !cleanSlug || + cleanSlug.length > 120 || + !/^[a-zA-Z0-9._-]+$/.test(cleanSlug) + ) { + return jsonApiError( + 400, + 'Invalid product slug', + undefined, + PUBLIC_API_CORS_HEADERS + ); + } + const data = await loadItemSizePanel(cleanSlug); + if (!data) { + return NextResponse.json( + { ok: false, error: 'Product not found', code: 'NOT_FOUND' }, + { status: 404, headers: PUBLIC_API_CORS_HEADERS } + ); + } + return NextResponse.json( + { ok: true, data }, + { + status: 200, + headers: { + ...PUBLIC_API_CORS_HEADERS, + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + }, + } + ); + } catch (error) { + return jsonApiError( + 500, + 'Failed to load the size', + error, + PUBLIC_API_CORS_HEADERS + ); + } +} diff --git a/apps/web/src/components/product/item/ItemLazySizePanel.tsx b/apps/web/src/components/product/item/ItemLazySizePanel.tsx new file mode 100644 index 000000000..6d5b13548 --- /dev/null +++ b/apps/web/src/components/product/item/ItemLazySizePanel.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { Fragment, useEffect, useState, type ReactNode } from 'react'; +import { useTranslations } from 'next-intl'; +import { BsShieldCheck } from 'react-icons/bs'; +import PriceHistorySection from '@/components/price-history/PriceHistorySection'; +import ProductOffersExplorer from '@/components/leaderboard/ProductOffersExplorer'; +import ProductShareAuditButton from '@/components/leaderboard/ProductShareAuditButton'; +import PriceWatchButton from '@/components/watchlist/PriceWatchButton'; +import { + itemSizePanelPath, + type ItemSizePanelData, + type ItemSizeStripContext, +} from '@/lib/item-size-panel'; +import { useSelectedVariant } from './selected-variant'; + +export type ItemSizePanelKind = 'strip' | 'history' | 'offers'; + +// The request in flight per size, shared by the size's three panels. It is +// dropped once settled, so a later pick reads fresh data (the route itself is +// cached for 5 minutes). +const inFlight = new Map>(); + +function loadSize(slug: string): Promise { + let load = inFlight.get(slug); + if (!load) { + load = fetch(itemSizePanelPath(slug)) + .then((res) => (res.ok ? res.json() : Promise.reject(res.status))) + .then((json: { ok?: boolean; data?: ItemSizePanelData }) => + json?.ok && json.data ? json.data : Promise.reject('no data') + ); + const settle = () => inFlight.delete(slug); + load.then(settle, settle); + inFlight.set(slug, load); + } + return load; +} + +function useSizePanel( + slug: string | undefined, + attempt: number +): { + data: ItemSizePanelData | null; + failed: boolean; +} { + const [state, setState] = useState<{ + slug?: string; + data: ItemSizePanelData | null; + failed: boolean; + }>({ data: null, failed: false }); + useEffect(() => { + if (!slug) return; + let live = true; + loadSize(slug).then( + (data) => live && setState({ slug, data, failed: false }), + () => live && setState({ slug, data: null, failed: true }) + ); + return () => { + live = false; + }; + }, [slug, attempt]); + return state.slug === slug + ? { data: state.data, failed: state.failed } + : { data: null, failed: false }; +} + +/** The price strip of one size, as the server renders it on the variant page. */ +export function ItemSizePriceStrip({ + data, + context, +}: { + data: ItemSizePanelData; + context: ItemSizeStripContext; +}) { + const t = useTranslations('productDetail'); + const tSale = useTranslations('saleState'); + const { stats, priceHistory, product } = data; + return ( +
    +
    +
    +
    + {t('priceReferenceLabel', { count: stats.sellerCount })} +
    +
    + {stats.minPrice > 0 ? ( + `${stats.minPrice.toLocaleString('vi-VN')} ₫` + ) : data.saleStateKey ? ( + + {tSale(data.saleStateKey)} + + ) : ( + t('priceNoneInStock') + )} + {stats.minPrice > 0 && stats.minPrice !== stats.maxPrice && ( + + ~ {stats.maxPrice.toLocaleString('vi-VN')} ₫ + + )} + {priceHistory.lowestEverPrice != null && ( + + {t('allTimeLowBadge', { + price: priceHistory.lowestEverPrice.toLocaleString('vi-VN'), + })} + + )} +
    +
    + +
    +
    + ); +} + +/** The offer table of one size, as the server renders it on the variant page. */ +export function ItemSizeOffers({ data }: { data: ItemSizePanelData }) { + const t = useTranslations('productDetail'); + return ( + <> +
    +
    + + {t('offersEyebrow')} + +

    +

    +
    + + {t('offersMedianBadge')} + +
    + {data.offers.length === 0 ? ( +

    {t('noOffersYet')}

    + ) : ( + + )} + + ); +} + +function renderSize( + kind: ItemSizePanelKind, + data: ItemSizePanelData, + context: ItemSizeStripContext +): ReactNode { + if (kind === 'strip') + return ; + if (kind === 'offers') return ; + return ( + 0 ? data.stats.minPrice : null, + }} + radarSignal={data.radarSignal} + /> + ); +} + +/** + * One panel slot of an Item with many sizes (DOS/Rate#1285): the default size + * comes server-rendered; another size is loaded when it is picked and shown + * with the same components. Keyed per size, so no panel state carries over. + */ +export default function ItemLazySizePanel({ + kind, + defaultKey, + defaultNode, + slugs, + context, +}: { + kind: ItemSizePanelKind; + defaultKey: string; + defaultNode: ReactNode; + /** variant_key to variant slug, every size of the Item. */ + slugs: Record; + context: ItemSizeStripContext; +}) { + const t = useTranslations('productDetail'); + const selected = useSelectedVariant(Object.keys(slugs), defaultKey); + const lazy = selected !== defaultKey; + const [attempt, setAttempt] = useState(0); + const { data, failed } = useSizePanel( + lazy ? slugs[selected] : undefined, + attempt + ); + if (!lazy) return {defaultNode}; + if (data) { + return ( + {renderSize(kind, data, context)} + ); + } + return ( +
    +

    + {failed ? t('sizePanelLoadFailed') : t('sizePanelLoading')} + {failed && ( + + )} +

    +
    + ); +} diff --git a/apps/web/src/lib/client-messages.ts b/apps/web/src/lib/client-messages.ts index 6cfc3c908..0ec719af0 100644 --- a/apps/web/src/lib/client-messages.ts +++ b/apps/web/src/lib/client-messages.ts @@ -23,6 +23,8 @@ export const CLIENT_MESSAGE_SCOPES = { catalog: ['catalog.sidebar', 'catalog.toolbar', 'catalog.schemas'], productDetail: [ 'productDetail', + // Sale-state labels of a size loaded on demand (DOS/Rate#1285). + 'saleState', 'reviews', 'analyzerCopy', 'priceVerdict', diff --git a/apps/web/src/lib/item-size-panel.ts b/apps/web/src/lib/item-size-panel.ts new file mode 100644 index 000000000..ba5eb4548 --- /dev/null +++ b/apps/web/src/lib/item-size-panel.ts @@ -0,0 +1,64 @@ +/** + * One size of an Item page as the browser loads it on demand (DOS/Rate#1285). + * An Item with many sizes renders only its default size on the server; the + * other sizes come from `/api/v1/rate/item-size/{variant slug}` when picked, + * so the RSC payload does not grow with every size (#1281 measured about + * 30 KB per size). Types and the trigger only: no server code. + */ +import type { + MasterProductPriceHistorySummary, + MerchantOffer, +} from './rate-api'; +import type { RadarSignal } from './deal-radar'; +import type { SaleState } from './sale-state'; + +export interface ItemSizePanelData { + product: { + id: string; + slug: string; + name: string; + brand: string; + model_key: string; + image: string | null; + }; + offers: MerchantOffer[]; + priceHistory: MasterProductPriceHistorySummary; + radarSignal: RadarSignal; + stats: { minPrice: number; maxPrice: number; sellerCount: number }; + saleState: SaleState; + /** The `saleState` message key when the size is not on sale, else null. */ + saleStateKey: string | null; +} + +/** Item-level facts every size's price strip shows the same. */ +export interface ItemSizeStripContext { + categoryPath: string; + userScore5: number; + expertScore10: number | null; + authenticReviews: number; + keySpecLine: string; +} + +/** More sizes than this load on demand. */ +export const LAZY_ITEM_SIZES_ABOVE = 4; +/** Or when the other sizes' panel data would add about this many bytes. */ +export const LAZY_ITEM_PANEL_BYTES = 150_000; + +/** + * Whether an Item page loads its other sizes on demand: above 4 sizes, or + * when the other sizes' offers and history would add about 150 KB. + */ +export function shouldLazyLoadSizes( + sizeCount: number, + otherSizesBytes: number +): boolean { + return ( + sizeCount > LAZY_ITEM_SIZES_ABOVE || + (sizeCount > 1 && otherSizesBytes >= LAZY_ITEM_PANEL_BYTES) + ); +} + +/** The size panel route of a variant slug. */ +export function itemSizePanelPath(variantSlug: string): string { + return `/api/v1/rate/item-size/${encodeURIComponent(variantSlug)}`; +} diff --git a/apps/web/src/server/rate/__tests__/item-model.test.ts b/apps/web/src/server/rate/__tests__/item-model.test.ts index 8be3b8709..da539e502 100644 --- a/apps/web/src/server/rate/__tests__/item-model.test.ts +++ b/apps/web/src/server/rate/__tests__/item-model.test.ts @@ -552,7 +552,34 @@ async function main() { slugs: ['apple-iphone-16-128gb'], }).tags.includes(productCacheTag('apple-iphone-16')) ); + // The size route answers one size's panel data while the flag is on. + const sizeRouteMod = + await import('../../../app/api/v1/rate/item-size/[slug]/route'); + const sizeGet = (slug: string) => + sizeRouteMod.GET(new Request('http://localhost/x'), { + params: Promise.resolve({ slug }), + }); + let sizeRes = await sizeGet('apple-iphone-16-128gb'); + assert.equal(sizeRes.status, 200); + const sizeBody = (await sizeRes.json()) as { + data: import('../../../lib/item-size-panel').ItemSizePanelData; + }; + assert.equal(sizeBody.data.product.slug, 'apple-iphone-16-128gb'); + assert.ok(Array.isArray(sizeBody.data.offers)); + assert.ok( + sizeBody.data.priceHistory && 'byOffer' in sizeBody.data.priceHistory + ); + assert.equal(typeof sizeBody.data.saleState, 'string'); + assert.match(sizeRes.headers.get('cache-control') ?? '', /s-maxage=300/); + assert.equal((await sizeGet('../etc')).status, 400); + assert.equal((await sizeGet('no-such-product')).status, 404); + // Only a published size of a published Item: not a retired size, not a + // master with no Item yet. + assert.equal((await sizeGet('apple-iphone-16-unknown')).status, 404); + assert.equal((await sizeGet('samsung-galaxy-s24-256gb')).status, 404); delete process.env.RATE_ITEM_MODEL; + sizeRes = await sizeGet('apple-iphone-16-128gb'); + assert.equal(sizeRes.status, 404, 'flag off: the size route answers nothing'); // Product routes stay ISR whatever the flag: no request-time API (query // string, headers, cookies) and no dynamic opt-in. `?v=` is client-side. @@ -573,6 +600,7 @@ async function main() { 'selected-variant.ts', 'ItemVariantPicker.tsx', 'ItemVariantPanels.tsx', + 'ItemLazySizePanel.tsx', ].map((f) => path.resolve(__dirname, `../../../components/product/item/${f}`) )) { @@ -591,11 +619,34 @@ async function main() { assert.doesNotMatch(src, /searchParams|\bheaders\(|\bcookies\(/, file); assert.doesNotMatch(src, /export const dynamic\b|force-dynamic/, file); } - for (const file of productRouteFiles) { + // The size route is cached too: it reads only its path (DOS/Rate#1285). + const sizeRoute = path.resolve( + __dirname, + '../../../app/api/v1/rate/item-size/[slug]/route.ts' + ); + for (const file of [...productRouteFiles, sizeRoute]) { const src = fs.readFileSync(file, 'utf8'); assert.match(src, /export const revalidate = 300;/, file); assert.match(src, /export function generateStaticParams\(/, file); } + { + const src = fs.readFileSync(sizeRoute, 'utf8'); + assert.doesNotMatch(src, /searchParams|\bheaders\(|\bcookies\(/); + assert.doesNotMatch(src, /export const dynamic\b|force-dynamic/); + assert.doesNotMatch(src, /_request\.(url|nextUrl|headers)/); + } + + // Lazy sizes (DOS/Rate#1285): above 4 sizes, or ~150 KB of other sizes. + const lazy = await import('../../../lib/item-size-panel'); + assert.equal(lazy.shouldLazyLoadSizes(4, 0), false); + assert.equal(lazy.shouldLazyLoadSizes(5, 0), true); + assert.equal(lazy.shouldLazyLoadSizes(2, 149_999), false); + assert.equal(lazy.shouldLazyLoadSizes(2, 150_000), true); + assert.equal(lazy.shouldLazyLoadSizes(1, 500_000), false); + assert.equal( + lazy.itemSizePanelPath('apple-iphone-16-256gb'), + '/api/v1/rate/item-size/apple-iphone-16-256gb' + ); // The page renders a size that loaded: the planned one, else a priced one. const { pickRenderedSize } = await import('../item-page'); const size = (slug: string, min_price: number) => diff --git a/apps/web/src/server/rate/item-page.ts b/apps/web/src/server/rate/item-page.ts index ed83d74e4..4b7b696f8 100644 --- a/apps/web/src/server/rate/item-page.ts +++ b/apps/web/src/server/rate/item-page.ts @@ -17,7 +17,21 @@ import { getCachedMasterProductPriceHistory, } from '@/server/rate'; import type { RadarSignal } from '@/lib/deal-radar'; -import { isOfferUnavailable } from '@/lib/product-market-stats'; +import { + isOfferUnavailable, + productMarketStats, +} from '@/lib/product-market-stats'; +import type { ProductSpecifications } from '@repo/shared-data'; +import type { ItemSizePanelData } from '@/lib/item-size-panel'; +import { + listingCountForSaleState, + masterSaleState, + saleStateLabelKey, +} from '@/lib/sale-state'; +import { productImageUrl } from '@/lib/rate-domain-core'; +import { isItemModelOn } from './item-model'; +import { getCachedCatalogSlugResolution } from './item-route'; +import { ItemModelUnavailableError } from '../../lib/rate-api'; import { createLogger } from '@/lib/logger'; import { RATE_CACHE_TAGS, @@ -179,3 +193,93 @@ export async function loadItemPageContext( unknownByOffer, }; } + +/** + * One size's panels as plain data (DOS/Rate#1285): what the price strip, the + * history chart and the offer table need, with the same stats and sale state + * the server-rendered panels use. + */ +export function toItemSizePanelData(v: { + product: MasterProduct; + offers: MerchantOffer[]; + priceHistory: MasterProductPriceHistorySummary; + radarSignal: RadarSignal; +}): ItemSizePanelData { + const inStock = v.offers.filter((o) => !isOfferUnavailable(o)); + const stats = productMarketStats(v.product); + const saleState = masterSaleState({ + minPrice: stats.minPrice, + listingCount: listingCountForSaleState(v.offers.length, inStock.length), + metadata: v.product.metadata, + availabilityStatus: (v.product.specifications as ProductSpecifications) + ?.availability?.status, + }); + return { + product: { + id: v.product.id, + slug: v.product.slug, + name: v.product.name, + brand: v.product.brand, + model_key: v.product.model_key, + image: productImageUrl(v.product), + }, + offers: v.offers, + priceHistory: v.priceHistory, + radarSignal: v.radarSignal, + stats: { + minPrice: stats.minPrice, + maxPrice: stats.maxPrice, + sellerCount: stats.sellerCount, + }, + saleState, + saleStateKey: saleState === 'on_sale' ? null : saleStateLabelKey(saleState), + }; +} + +/** About how many bytes the given sizes' panel data adds to a page. */ +export function itemSizePanelBytes( + variants: readonly ItemPageVariant[] +): number { + return variants.reduce( + (sum, v) => + sum + + // UTF-8 bytes, as sent: Vietnamese titles take more than one each. + Buffer.byteLength(JSON.stringify(v.offers), 'utf8') + + Buffer.byteLength(JSON.stringify(v.priceHistory), 'utf8'), + 0 + ); +} + +/** + * One size's panel data for `/api/v1/rate/item-size/{slug}`, through the same + * cached readers as the page. Null while `RATE_ITEM_MODEL` is off or the + * Item tables are missing, and for a slug that is no published size of a + * published Item (a draft Item, a retired size, a master with no Item). + */ +export async function loadItemSizePanel( + variantSlug: string +): Promise { + if (!isItemModelOn()) return null; + let resolution; + try { + resolution = await getCachedCatalogSlugResolution(variantSlug); + } catch (err) { + if (err instanceof ItemModelUnavailableError) return null; + throw err; + } + if (resolution?.kind !== 'variant' || resolution.retired) return null; + const product = await getCachedMasterProductBySlug(variantSlug); + if (!product) return null; + const offers = await getCachedMasterProductOffers(product.id); + const priceHistory = await getCachedMasterProductPriceHistory( + product.id, + offers + ); + const inStock = offers.filter((o) => !isOfferUnavailable(o)); + return toItemSizePanelData({ + product, + offers, + priceHistory, + radarSignal: computeProductRadarSignal(product, inStock), + }); +} From 4eca23d59cd62bbcbceedc655fc90914eafa66d4 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:25:22 +0700 Subject: [PATCH 57/69] fix(normalizer): a gift size never stands in for a product size the reader misses (#1292) * fix(normalizer): a gift size never stands in for a product size the reader misses * fix(normalizer): bare-size guards for warranty, percent, ram and os; worded watch size outside bands; comma gift lists * fix(normalizer): instalment counts, gifted worded sizes and size lists are no watch or screen size Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 41 +++++ .../importer/src/shopee/product-normalizer.ts | 144 ++++++++++++++---- 2 files changed, 155 insertions(+), 30 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index f1351ede5..56589a7ea 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -452,6 +452,31 @@ async function runSuite() { // For an iPad or a MacBook, a "vỏ"/"hộp" gift is an accessory whatever its material. ['iPad Pro M4 13 kèm vỏ nhôm 11 inch 256GB', 'apple:ipad-pro-m4-13:256gb'], ['MacBook Air M4 13 kèm hộp 15 inch 16GB 512GB', 'apple:macbook-air-m4-13:512gb'], + // A bare size before a Vietnamese word is still the product's, never the gift's + // (DOS/Rate#1291). + ['MacBook Air M3 13 tặng túi 15 inch', 'apple:macbook-air-m3-13:unknown'], + ['MacBook Air M3 13 hãng tặng túi 15 inch', 'apple:macbook-air-m3-13:unknown'], + ['iPad Pro M4 13 tặng ốp 11 inch', 'apple:ipad-pro-m4-13:unknown'], + // A size the reader does not take ("10.9") or a gift listing several sizes: no size. + ['iPad Air 10.9 tặng ốp 11 inch', 'apple:ipad-air:unknown'], + ['iPad Pro M4 tặng ốp 11 inch/13 inch', 'apple:ipad-pro-m4:unknown'], + ['iPad Pro M4 tặng ốp 11 inch / 11.0 inch', 'apple:ipad-pro-m4-11:unknown'], + ['iPad Pro M4 tặng ốp 11 inch, 13 inch', 'apple:ipad-pro-m4:unknown'], + ['MacBook Pro M4 512GB macOS 14 tặng túi 16 inch', 'apple:macbook-pro-m4-16:512gb'], + // Warranty, return days, battery hours, a discount, RAM, an OS version or a charger's + // watts are no screen size: the gift's size still stands in. + ['iPad Air M2 128GB Chính hãng BH 12 tháng tặng ốp 11 inch', 'apple:ipad-air-m2-11:128gb'], + ['iPad Air M2 128GB Đổi trả 15 ngày tặng ốp 11 inch', 'apple:ipad-air-m2-11:128gb'], + ['iPad Pro M4 256GB Pin 10 giờ tặng bao da 13 inch', 'apple:ipad-pro-m4-13:256gb'], + ['iPad Air M2 128GB giảm 10% tặng ốp 11 inch', 'apple:ipad-air-m2-11:128gb'], + ['iPad Air M2 128GB hỗ trợ trả góp 12 kỳ tặng ốp 11 inch', 'apple:ipad-air-m2-11:128gb'], + ['MacBook Air M3 RAM 16 tặng túi 13 inch', 'apple:macbook-air-m3-13:unknown'], + ['MacBook Air M3 macOS 15 tặng túi 13 inch', 'apple:macbook-air-m3-13:unknown'], + ['MacBook Air M3 15 W tặng túi 13 inch', 'apple:macbook-air-m3-13:unknown'], + // ...and never a size of its own. + ['MacBook Air M2 8GB 256GB bảo hành 15 tháng', 'apple:macbook-air-m2:256gb'], + ['MacBook Pro M3 18GB 512GB bảo hành 14 tháng', 'apple:macbook-pro-m3:512gb'], + ['MacBook Air M2 8GB 256GB 15 gói quà', 'apple:macbook-air-m2:256gb'], // A number after "cho/for" (a school year, an age) is no size of the product. [ 'iPad Pro M4 Wifi 256GB tặng bao da 13 inch cho học sinh lớp 11', @@ -702,6 +727,22 @@ async function runSuite() { 'Apple Watch Series 10 GPS Sport Band 42mm mặt 46mm, tặng ốp 42mm', 'apple:watch-series-10:64gb', ], + // "size 46" is the watch's; a box or "vỏ nhôm" gift yields to a case the rest names; + // a gift listing several sizes fits either, so no size (DOS/Rate#1291). + ['Apple Watch Series 10 size 46 tặng ốp 42mm', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 kèm hộp 46mm, GPS 42mm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 kèm vỏ nhôm 46mm, GPS 42mm', 'apple:watch-series-10-42mm:64gb'], + ['Apple Watch Series 10 GPS tặng ốp 42mm / 46mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 GPS 46 tặng ốp 42mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 GPS giảm 40% tặng ốp 46mm', 'apple:watch-series-10-46mm:64gb'], + // A size listed right after the box's is a list, not the watch's own case. + ['Apple Watch Series 10 kèm hộp 46mm | 42mm', 'apple:watch-series-10-46mm:64gb'], + // "size 46" beats a box's size; a band's "size 42" is not the watch's. + ['Apple Watch Series 10 kèm hộp 42mm, size 46 GPS', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 kèm vỏ nhôm 42mm, size 46 GPS', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 Sport Band size 42 cho mặt 46mm', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 GPS 46 tặng ốp size 42', 'apple:watch-series-10:64gb'], + ['Apple Watch Series 10 GPS size 42/46', 'apple:watch-series-10:64gb'], // A bundle word before no sized accessory keeps the product's size. ['Apple Watch Series 10 kèm sạc 46mm GPS', 'apple:watch-series-10-46mm:64gb'], ['Apple Watch Series 10, kèm sạc 46mm GPS', 'apple:watch-series-10-46mm:64gb'], diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index 8df328ca1..e88f6f640 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1253,16 +1253,19 @@ function isProductCodeToken(token: string): boolean { // part code ("Z15S"), after "RAM" or an OS name ("macOS 14"), or before a storage, a // RAM/ROM pair or a core count ("16GB", "16G", "16/512", "16-core", "16 nhân CPU"). // Titles carry single spaces, so each gap is one optional space (CodeQL -// js/polynomial-redos). +// js/polynomial-redos). A unit ends where a letter does, Vietnamese ones included: "13 +// tặng" or "13 hãng" is a bare 13, not "13 t(b)" or "13 h"; "15 tháng" (warranty), "10%" +// or "15 gói" is no size (DOS/Rate#1291). +const UNIT_END = String.raw`(?![\p{L}\p{M}\d])`; const SIZE_NOT_PRECEDED_BY = String.raw`(? [ - new RegExp(String.raw`(? - (family === 'watch' ? WATCH_GIFT_ACCESSORY_NOUN : GIFT_ACCESSORY_NOUN).test(words) - ? `${word}${words}` - : m + const productPart = title.slice(Math.max(0, at)); + const giftSizes = new Set(); + const stripGift = (scope: string, noun: RegExp, sizes = new Set()) => + scope.replace(GIFTED_ACCESSORY_SIZE, (m, word: string, words: string) => { + if (!noun.test(words)) return m; + for (const s of m.slice(word.length + words.length).matchAll(GIFT_SIZE_VALUE)) { + sizes.add( + s[1]! + .replace(/ /g, '') + .replace(',', '.') + .replace(/\.0(?=mm|$)/, '') + .toLowerCase() + ); + } + return `${word}${words}`; + }); + const withoutGift = stripGift( + productPart, + family === 'watch' ? WATCH_GIFT_ACCESSORY_NOUN : GIFT_ACCESSORY_NOUN, + giftSizes + ); + // For a watch, "kèm vỏ nhôm 46mm" or "kèm hộp 46mm" names the watch's case only when the + // rest of the title gives it no case of its own: "Series 10 kèm hộp 46mm, GPS 42mm" is + // the 42mm (DOS/Rate#1291). A band size alone does not count as its own (DOS/Rate#1277). + if (family === 'watch') { + // A size listed right after the gift's ("kèm hộp 46mm | 42mm") is a list, not the case. + const withoutBox = withoutGift.replace( + GIFTED_ACCESSORY_SIZE, + (m, word: string, words: string, offset: number, whole: string) => + GIFT_ACCESSORY_NOUN.test(words) && !LISTED_AFTER_GIFT.test(whole.slice(offset + m.length)) + ? `${word}${words}` + : m ); + if (withoutBox !== withoutGift) { + const own = readFamilySize(family, withoutBox, false) || readWordedWatchSize(withoutBox); + if (own) return own; + } + } + const read = readFamilySize(family, withoutGift); + if (read) return read; + // The same part the size is read from: not what follows "cho/for" or "+". + const ownPart = withoutGift.split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!; + if (family === 'watch') { + const worded = readWordedWatchSize(withoutGift); + if (worded) return worded; + } // A size only the gift names is still the best evidence of the product's: an 11 inch - // case fits an 11 inch iPad (DOS/Rate#1277). - const productPart = title.slice(Math.max(0, at)); - // Only when the rest names no size at all: a size it names but leaves unresolved (a - // band list, "mặt 46mm" inside a band phrase) is never replaced by the gift's. + // case fits an 11 inch iPad (DOS/Rate#1277). Only when the rest names no size at all (a + // size it names but leaves unresolved, such as a band list or "mặt 46mm" inside a band + // phrase, or one in a form no reading takes, such as "size 46" or "10.9", is never + // replaced by the gift's), and only when the gift names one size ("tặng ốp 42mm / + // 46mm" fits either). const namesSize = family === 'watch' - ? WATCH_SIZE.test(withoutGift) - : // The same part the size is read from: not what follows "cho/for" or "+". - APPLE_SIZE_PATTERNS[family]!.some(re => - re.test(withoutGift.split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!) - ); - return ( - readFamilySize(family, withoutGift) || - (namesSize || withoutGift === productPart ? '' : readFamilySize(family, productPart)) - ); + ? WATCH_SIZE.test(withoutGift) || WATCH_BARE_SIZE.test(ownPart) + : APPLE_SIZE_PATTERNS[family]!.some(re => re.test(ownPart)) || SCREEN_BARE_SIZE.test(ownPart); + return namesSize || withoutGift === productPart || giftSizes.size !== 1 + ? '' + : readFamilySize(family, productPart); } -/** `readAppleSize` over one reading of the product's part of the title. */ -function readFamilySize(family: string, scope: string): string { +/** + * A watch's "size 46" in the product's own part, outside a band phrase ("Sport Band size + * 42 cho mặt 46mm" names the band's), when it names one size (DOS/Rate#1291). + */ +function readWordedWatchSize(scope: string): string { + const own = scope.split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!; + const sizes = new Set(); + for (const part of own.split(/[),|;\u2013]| - /)) { + for (const m of part.matchAll(WATCH_SIZE_WORD)) { + const before = part.slice(0, m.index).normalize('NFC'); + if (WATCH_BAND_WORD.test(before) || CASE_ACCESSORY_WORD.test(before)) return ''; + sizes.add(m[1]!); + } + } + return sizes.size === 1 ? `${[...sizes][0]}mm` : ''; +} + +/** + * `readAppleSize` over one reading of the product's part of the title. `withBand` false + * leaves out a watch size only a band names. + */ +function readFamilySize(family: string, scope: string, withBand = true): string { const own = scope.split(/ (?:cho|for) | ?\+ (?!cellular|lte)/i)[0]!; if (family === 'watch') { // A size outside a band phrase ("dây", "band", "strap", "loop" or "bracelet" up to the @@ -1455,7 +1539,7 @@ function readFamilySize(family: string, scope: string): string { if (caseSize) return `${caseSize}mm`; const bandSize = own.match(WATCH_SIZE)?.[1]; const sizes = new Set([...scope.matchAll(new RegExp(WATCH_SIZE, 'gi'))].map(m => m[1])); - return bandSize && sizes.size === 1 ? `${bandSize}mm` : ''; + return withBand && bandSize && sizes.size === 1 ? `${bandSize}mm` : ''; } const [marked, bare] = APPLE_SIZE_PATTERNS[family]!; const size = (own.match(marked) ?? own.match(bare))?.[1]; From b1fd90d321993b7cce280ef119de9dfed802e598 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:42:44 +0700 Subject: [PATCH 58/69] fix(web): compare links with ?cat= open on their category (#1316) * fix(web): compare links with ?cat= open on their category Links across the site and the extension write `/compare?cat=...` while the compare page read only `category`, so they opened on smartphones. The page now reads both; `category` wins when both are set. * fix(web): an empty compare category parameter is no value Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 2 +- apps/web/src/app/[locale]/compare/page.tsx | 7 +++++-- apps/web/src/lib/__tests__/compare-params.test.ts | 15 +++++++++++++++ apps/web/src/lib/compare-params.ts | 15 +++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/lib/__tests__/compare-params.test.ts create mode 100644 apps/web/src/lib/compare-params.ts diff --git a/apps/web/package.json b/apps/web/package.json index 787064e18..7aa1380c1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/compare/page.tsx b/apps/web/src/app/[locale]/compare/page.tsx index 15455bb88..f9fd9113b 100644 --- a/apps/web/src/app/[locale]/compare/page.tsx +++ b/apps/web/src/app/[locale]/compare/page.tsx @@ -9,6 +9,7 @@ import { } from '@/server/rate'; import { resolveCompareSlugs } from '@/server/rate/item-compare'; import { getItemSlugsFor } from '@/server/rate/item-directory'; +import { compareCategoryParam } from '@/lib/compare-params'; import { normalizeRateLocale } from '@/lib/rate-i18n'; import { defaultOpenGraph, fitTitle, fitDescription } from '@/lib/seo'; import CompareWorkspace from '@/components/compare/CompareWorkspace'; @@ -22,7 +23,7 @@ export const dynamic = 'force-dynamic'; type PageProps = { params: Promise<{ locale: string }>; - searchParams: Promise<{ slugs?: string; category?: string }>; + searchParams: Promise<{ slugs?: string; category?: string; cat?: string }>; }; const VALID_CATEGORIES = new Set([ @@ -121,7 +122,9 @@ export default async function ComparePhonesPage({ setRequestLocale(locale); const lang = normalizeRateLocale(locale); const t = await getTranslations({ locale: lang, namespace: 'compare' }); - const { slugs: slugsParam, category: categoryParam } = await searchParams; + const resolvedSearch = await searchParams; + const slugsParam = resolvedSearch.slugs; + const categoryParam = compareCategoryParam(resolvedSearch); const requestedSlugs = parseSlugsCsv(slugsParam); const [catalogCandidates, requestedProducts] = await Promise.all([ diff --git a/apps/web/src/lib/__tests__/compare-params.test.ts b/apps/web/src/lib/__tests__/compare-params.test.ts new file mode 100644 index 000000000..6315f23b8 --- /dev/null +++ b/apps/web/src/lib/__tests__/compare-params.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { compareCategoryParam } from '../compare-params'; + +// Compare links written with ?cat= open on their category (DOS/Rate#1303). +assert.equal(compareCategoryParam({ cat: 'earbuds' }), 'earbuds'); +assert.equal(compareCategoryParam({ category: 'laptops' }), 'laptops'); +assert.equal( + compareCategoryParam({ category: 'laptops', cat: 'earbuds' }), + 'laptops', + 'category wins' +); +assert.equal(compareCategoryParam({ cat: ['tablets', 'x'] }), 'tablets'); +assert.equal(compareCategoryParam({}), undefined); +assert.equal(compareCategoryParam({ category: '', cat: 'earbuds' }), 'earbuds'); +console.log('✅ compare params'); diff --git a/apps/web/src/lib/compare-params.ts b/apps/web/src/lib/compare-params.ts new file mode 100644 index 000000000..66cb67d87 --- /dev/null +++ b/apps/web/src/lib/compare-params.ts @@ -0,0 +1,15 @@ +/** + * The compare page's category query parameter. Links across the site write + * `?cat=` (leaderboard, versus, home, category pages, the extension) while + * the page read only `?category=`, so those links opened on smartphones + * (DOS/Rate#1303). Both are read; `category` wins when both are set. + */ +export function compareCategoryParam(params: { + category?: string | string[]; + cat?: string | string[]; +}): string | undefined { + const first = (v: string | string[] | undefined) => + Array.isArray(v) ? v[0] : v; + // An empty value is no value, as in the API routes. + return first(params.category) || first(params.cat) || undefined; +} From db049c4acb729d30aeff36ae455b8ddaca0fe3dd Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:48:33 +0700 Subject: [PATCH 59/69] fix(extension): price list, badge, empty and error states, labels (#1319) * fix(extension): price list, badge, empty and error states, labels The side panel orders in-stock offers first and marks sold-out, discontinued and bait rows (the lookup now flags bait offers), shows a short state for not-found and accessory pages, an error card with retry for failed lookups, and no 0.0 stars or 100% genuine without reviews. The badge and pill warn on a bait price and never say OK for an unchosen size or an accessory; labels are Vietnamese and the panel is labelled for screen readers. * fix(extension): keep the crowd signal and ignore late lookup answers Review of the previous commit: the opt-in crowd signal is sent whatever the lookup answered, as before (#1175); only the tab's latest lookup may set or clear its pill, badge and panel, so a late failure for an earlier product no longer clears the current one; an open side panel reloads when the page's lookup fails; a bait price shows the amber verdict style in the panel too. * fix(extension): no crowd signal after a network error, as before A network error used to throw before the opt-in crowd signal; the lookup now reports it as a status-less failure, so the signal is skipped there to keep what the extension sends unchanged (#1175). Co-Authored-By: Claude Opus 5.5 --- apps/extension/CHROMEWEBSTORE.md | 2 +- .../src/background/service-worker.ts | 176 +++++--- apps/extension/src/content/content-script.ts | 65 ++- apps/extension/src/sidepanel/panel-view.ts | 322 ++++++++++++++ apps/extension/src/sidepanel/sidepanel.html | 40 +- apps/extension/src/sidepanel/sidepanel.ts | 202 +++++---- apps/extension/src/test-extension-pipeline.ts | 392 ++++++++++++++++++ apps/extension/src/types.ts | 12 + apps/extension/src/verdict-display.ts | 110 +++++ apps/web/src/server/rate/extension.ts | 23 +- 10 files changed, 1123 insertions(+), 221 deletions(-) create mode 100644 apps/extension/src/sidepanel/panel-view.ts create mode 100644 apps/extension/src/verdict-display.ts diff --git a/apps/extension/CHROMEWEBSTORE.md b/apps/extension/CHROMEWEBSTORE.md index 8b5bed549..da1b3c0e1 100644 --- a/apps/extension/CHROMEWEBSTORE.md +++ b/apps/extension/CHROMEWEBSTORE.md @@ -45,7 +45,7 @@ This table lists exactly what `manifest.json` requests; `test-extension-pipeline 1. **Where it runs:** the content script loads on the 10 supported stores listed above (`shopee.vn`, `lazada.vn`, `tiki.vn`, `www.tiktok.com`, `shop.tiktok.com`, `cellphones.com.vn`, `thegioididong.com`, `fptshop.com.vn`, `hoanghamobile.com`, `didongviet.vn`, `phongvu.vn`). It acts only on pages it recognises as a product page; it never opens or fetches pages the user did not open. 2. **Lookup (on by default, no opt-in):** on a recognised product page the extension sends `GET /api/v1/rate/extension/lookup` with the store, the product URL with query parameters and tracking tokens (`utm_*`, `sp_atk`, `xptdk`, `fbclid`, `gclid`) stripped locally, the product title, the store's item id and shop id, the selected variant, the listed price and the original price. The request is read-only and carries no account or personal data. -3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (Opt-In)" in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue and never change public prices directly. +3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật)" in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue and never change public prices directly. 4. **Stored in the browser only:** the server address and the opt-in choice (`chrome.storage.local`), and a session-only cache of the products viewed in each tab, the latest side panel search and their lookup results (`chrome.storage.session`). 5. **Side panel requests:** text the user types into the side panel search is sent to the same lookup endpoint; the side panel also reads public deals and merchant lists from the Rate.vn API. 6. **Privacy policy:** https://rate.vn/privacy-policy has a section on the extension. diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index e2ca34e9a..eaa7fcd5a 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -17,6 +17,7 @@ import { sanitizeExtensionProductSignal, selectFreshestSessionSignal, } from '../security-guards.js'; +import { resolveActionBadge } from '../verdict-display.js'; const DEFAULT_SETTINGS: ExtensionSettings = { apiBaseUrl: DEFAULT_API_BASE_URL, @@ -40,10 +41,46 @@ async function getSettings(): Promise { } } +/** + * A lookup either answers (data may be null: nothing matched) or fails with + * the HTTP status, `null` for a network error or timeout, so the panel can + * say which (DOS/Rate#1300). + */ +type LookupOutcome = + | { ok: true; data: ExtensionLookupData | null } + | { ok: false; status: number | null }; + +async function requestLookup(endpoint: string): Promise { + let response: Response; + try { + response = await fetch(endpoint, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Rate-Extension-Version': '2.0.0', + }, + signal: AbortSignal.timeout(10_000), + }); + } catch { + return { ok: false, status: null }; + } + if (!response.ok) return { ok: false, status: response.status }; + try { + const json = (await response.json()) as { + ok?: boolean; + data?: ExtensionLookupData; + }; + if (json.ok === false) return { ok: false, status: response.status }; + return { ok: true, data: json.data ?? null }; + } catch { + return { ok: false, status: response.status }; + } +} + async function fetchExtensionLookup( rawSignal: ExtractedPageProductSignal, mode: 'pill' | 'full' -): Promise { +): Promise { const signal = sanitizeExtensionProductSignal(rawSignal); const settings = await getSettings(); const params = new URLSearchParams(); @@ -63,22 +100,9 @@ async function fetchExtensionLookup( params.set('originalPrice', String(signal.originalPrice)); } - const endpoint = `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}`; - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Rate-Extension-Version': '2.0.0', - }, - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) return null; - const json = (await response.json()) as { - ok?: boolean; - data?: ExtensionLookupData; - }; - return json.ok && json.data ? json.data : null; + return requestLookup( + `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}` + ); } const SUPPORTED_CROWD_PLATFORMS = new Set([ @@ -94,6 +118,9 @@ const SUPPORTED_CROWD_PLATFORMS = new Set([ 'phongvu', ]); +// The latest product lookup per tab (DOS/Rate#1300). +let detectRequestCounter = 0; +const latestDetectByTab = new Map(); async function maybeSendOptInCrowdSignal( rawSignal: ExtractedPageProductSignal ): Promise { @@ -129,23 +156,13 @@ async function updateActionBadge( ): Promise { if (!tabId) return; try { - if (!lookup || lookup.matchStatus === 'not_found') { - await chrome.action.setBadgeText({ text: '', tabId }); - return; - } - const verdict = lookup.priceAssessment.verdict; - if (verdict === 'inflated_fake_sale') { - await chrome.action.setBadgeText({ text: 'ẢO!', tabId }); - await chrome.action.setBadgeBackgroundColor({ color: '#d83030', tabId }); - } else if (verdict === 'genuine_deal') { - await chrome.action.setBadgeText({ text: 'TỐT', tabId }); - await chrome.action.setBadgeBackgroundColor({ color: '#2dba69', tabId }); - } else if (verdict === 'above_market') { - await chrome.action.setBadgeText({ text: 'CAO', tabId }); - await chrome.action.setBadgeBackgroundColor({ color: '#f59e0b', tabId }); - } else { - await chrome.action.setBadgeText({ text: 'OK', tabId }); - await chrome.action.setBadgeBackgroundColor({ color: '#3b82f6', tabId }); + const badge = resolveActionBadge(lookup); + await chrome.action.setBadgeText({ text: badge.text, tabId }); + if (badge.color) { + await chrome.action.setBadgeBackgroundColor({ + color: badge.color, + tabId, + }); } } catch { // Ignore badge errors if tab closed @@ -191,6 +208,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if (msgType === 'RATE_PRODUCT_DETECTED') { + const requestId = ++detectRequestCounter; + if (sender.tab?.id) latestDetectByTab.set(sender.tab.id, requestId); + // Only the tab's latest lookup may set its badge, stored lookup or + // panel: an earlier one can answer after a later one (DOS/Rate#1300). + const isLatest = () => + !sender.tab?.id || latestDetectByTab.get(sender.tab.id) === requestId; (async () => { try { const signal = sanitizeExtensionProductSignal( @@ -218,8 +241,33 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } await chrome.storage.session.set(signalPayload); - const lookup = await fetchExtensionLookup(signal, 'pill'); + const outcome = await fetchExtensionLookup(signal, 'pill'); + // Fire-and-forget Opt-In Quarantine Crowd Signal if user enabled it. + // Sent whenever the server answered, as before (#1175): a network + // error threw before it then, so it still sends nothing (#1300). + if (outcome.ok || outcome.status !== null) { + void maybeSendOptInCrowdSignal(signal); + } + // A failed lookup clears the previous product's badge (#1300). + const lookup = outcome.ok ? outcome.data : null; + if (!isLatest()) { + sendResponse({ ok: outcome.ok, data: lookup, stale: true }); + return; + } await updateActionBadge(tabId, lookup); + if (!outcome.ok) { + // An open side panel reloads for this page instead of keeping the + // previous product (it listens to this key). + if (!isBackgroundTab) { + await chrome.storage.session.set({ lookup_tab_latest: null }); + } + sendResponse({ + ok: false, + status: outcome.status, + error: 'Lookup failed', + }); + return; + } if (lookup) { const lookupPayload: Record = { @@ -231,13 +279,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { await chrome.storage.session.set(lookupPayload); } - // Fire-and-forget Opt-In Quarantine Crowd Signal if user enabled it - void maybeSendOptInCrowdSignal(signal); - sendResponse({ ok: true, data: lookup }); } catch (err) { + if (isLatest()) await updateActionBadge(sender.tab?.id, null); sendResponse({ ok: false, + status: null, error: err instanceof Error ? err.message : 'Lookup failed', }); } @@ -335,16 +382,26 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } } - const fullLookup = await fetchExtensionLookup(effectiveSignal, 'full'); - sendResponse({ - ok: true, - settings, - signal: effectiveSignal, - data: fullLookup, - }); + const outcome = await fetchExtensionLookup(effectiveSignal, 'full'); + sendResponse( + outcome.ok + ? { + ok: true, + settings, + signal: effectiveSignal, + data: outcome.data, + } + : { + ok: false, + status: outcome.status, + settings, + signal: effectiveSignal, + } + ); } catch (err) { sendResponse({ ok: false, + status: null, error: err instanceof Error ? err.message : 'Full lookup failed', }); } @@ -417,31 +474,21 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } params.set('title', rawQuery); - const endpoint = `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}`; - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Rate-Extension-Version': '2.0.0', - }, - signal: AbortSignal.timeout(10_000), - }); - - if (!response.ok) { + const outcome = await requestLookup( + `${settings.apiBaseUrl}${RATE_API_V1_ENDPOINTS.EXTENSION_LOOKUP}?${params.toString()}` + ); + if (!outcome.ok) { + // The panel tells a rate limit, a server error and a network + // error apart (DOS/Rate#1300). sendResponse({ - ok: true, + ok: false, + status: outcome.status, settings, signal: syntheticSignal, - data: null, }); return; } - - const json = (await response.json()) as { - ok?: boolean; - data?: ExtensionLookupData; - }; - const fullLookup = json.ok && json.data ? json.data : null; + const fullLookup = outcome.data; if (fullLookup?.platform) { syntheticSignal.platform = fullLookup.platform; @@ -465,6 +512,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } catch (err) { sendResponse({ ok: false, + status: null, error: err instanceof Error ? err.message : 'Quick query lookup failed', }); diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index 5e6c57eb4..effc4688d 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -6,6 +6,7 @@ import { shouldRenderTrustPill, } from '../security-guards.js'; import type { ExtensionLookupData } from '../types.js'; +import { buildPillAriaLabel, resolvePillTheme } from '../verdict-display.js'; const HOST_ID = 'rate-vn-trust-pill-host'; @@ -27,39 +28,6 @@ function escapeHtml(raw: string): string { .replace(/'/g, '''); } -function getVerdictTheme(verdict: string): { - bg: string; - border: string; - dot: string; -} { - switch (verdict) { - case 'inflated_fake_sale': - return { - bg: 'rgba(216, 48, 48, 0.16)', - border: '#d83030', - dot: '#d83030', - }; - case 'genuine_deal': - return { - bg: 'rgba(45, 186, 105, 0.16)', - border: '#2dba69', - dot: '#2dba69', - }; - case 'above_market': - return { - bg: 'rgba(245, 158, 11, 0.16)', - border: '#f59e0b', - dot: '#f59e0b', - }; - default: - return { - bg: 'rgba(59, 130, 246, 0.16)', - border: '#3b82f6', - dot: '#3b82f6', - }; - } -} - /** * Render an isolated Shadow DOM Floating Trust Pill on supported e-commerce pages. * Zero host CSS pollution; clicking opens the Chrome Native Side Panel. @@ -80,14 +48,22 @@ function renderShadowTrustPill(lookup: ExtensionLookupData): void { const shadow = host.shadowRoot || host.attachShadow({ mode: 'open' }); const assessment = lookup.priceAssessment; - const theme = getVerdictTheme(assessment.verdict); + // A bait price uses the warning theme, not the neutral one (#1298). + const theme = resolvePillTheme( + assessment.verdict, + assessment.implausiblyLow + ); const rawRating = lookup.masterProduct?.user_rating_weighted || lookup.masterProduct?.average_rating || null; const trustedRating = normalizeFiveStarRating(rawRating); - const authenticRatio = lookup.reviewSummary?.authentic_pct ?? null; + // No share of genuine reviews when none was analysed (#1301). + const authenticRatio = + (lookup.reviewSummary?.total_analyzed ?? 0) > 0 + ? (lookup.reviewSummary?.authentic_pct ?? null) + : null; const cheaperSummary = assessment.cheaperOfferCount > 0 && assessment.maxSavingsAmount > 0 @@ -206,7 +182,7 @@ function renderShadowTrustPill(lookup: ExtensionLookupData): void { flex-shrink: 0; } -
    +
    @@ -215,7 +191,7 @@ function renderShadowTrustPill(lookup: ExtensionLookupData): void { ${radarChipHtml} ${ trustedRating - ? `★ ${formatSafeNumber(trustedRating, { min: 0, max: 5, decimals: 1 })} (${formatSafeNumber(authenticRatio ?? 100, { min: 0, max: 100, fallback: 100 })}% thật)` + ? `★ ${formatSafeNumber(trustedRating, { min: 0, max: 5, decimals: 1 })}${authenticRatio == null ? '' : ` (${formatSafeNumber(authenticRatio, { min: 0, max: 100 })}% thật)`}` : '' }
    @@ -241,6 +217,9 @@ function renderShadowTrustPill(lookup: ExtensionLookupData): void { }); } +// Lookups sent from this page, so a late answer for an earlier product is +// ignored (DOS/Rate#1300). +let detectGeneration = 0; let lastProcessedSignature = ''; async function inspectCurrentPage(): Promise { @@ -255,6 +234,9 @@ async function inspectCurrentPage(): Promise { const signature = `${signal.canonicalUrl}|${signal.selectedVariant || ''}|${signal.price || ''}`; if (signature === lastProcessedSignature) return; lastProcessedSignature = signature; + // Only the latest page's answer may draw or clear the pill: on a + // single-page shop an earlier lookup can answer after a later one. + const generation = ++detectGeneration; try { const response = (await chrome.runtime.sendMessage({ @@ -262,13 +244,18 @@ async function inspectCurrentPage(): Promise { signal, })) as { ok?: boolean; data?: ExtensionLookupData | null }; + if (generation !== detectGeneration) return; if (response?.ok && response.data) { renderShadowTrustPill(response.data); - } else if (response?.ok && !response.data) { + } else { + // Nothing matched or the lookup failed: never leave the previous + // product's pill on a single-page shop (DOS/Rate#1300). removeTrustPillHost(); } } catch { - // Ignore transient messaging errors during page unload + // Messaging failed (page unloading, worker restarting): drop the pill + // rather than show a stale one (DOS/Rate#1300). + if (generation === detectGeneration) removeTrustPillHost(); } }); } diff --git a/apps/extension/src/sidepanel/panel-view.ts b/apps/extension/src/sidepanel/panel-view.ts new file mode 100644 index 000000000..a71da5afc --- /dev/null +++ b/apps/extension/src/sidepanel/panel-view.ts @@ -0,0 +1,322 @@ +/** + * Pure HTML builders for the side panel: no DOM and no chrome APIs, so the + * test suite can render them. Every interpolated value goes through + * escapeHtml or a formatSafe* helper. + */ +import type { + ExtensionLookupData, + ExtensionOfferItem, + ExtractedPageProductSignal, +} from '../types.js'; +import { + escapeSafeExternalHref, + formatSafeExtensionVnd, + formatSafeNumber, + normalizeFiveStarRating, + resolveSafeApiRelativeUrl, +} from '../security-guards.js'; + +export function escapeHtml(raw: string): string { + return raw + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +type PanelOffer = Pick< + ExtensionOfferItem, + 'id' | 'price_current' | 'availability' | 'implausiblyLow' +>; + +export function isOfferSoldOut(offer: PanelOffer): boolean { + return ( + offer.availability === 'out_of_stock' || + offer.availability === 'discontinued' + ); +} + +/** Whether the row is a price someone can buy at now (DOS/Rate#1297). */ +export function isOfferBuyable(offer: PanelOffer): boolean { + return !isOfferSoldOut(offer) && !offer.implausiblyLow; +} + +function hasValidPrice(offer: PanelOffer): boolean { + return ( + typeof offer.price_current === 'number' && + Number.isFinite(offer.price_current) && + offer.price_current > 0 + ); +} + +/** + * In-stock, plausible offers first, then bait prices, then sold-out and + * discontinued rows; each group by price, ties in server order + * (DOS/Rate#1297). + */ +export function orderOffersForPanel(offers: T[]): T[] { + const group = (o: T) => (isOfferSoldOut(o) ? 2 : o.implausiblyLow ? 1 : 0); + return offers + .map((offer, index) => ({ offer, index })) + .sort((a, b) => { + const byGroup = group(a.offer) - group(b.offer); + if (byGroup !== 0) return byGroup; + const aValid = hasValidPrice(a.offer); + const bValid = hasValidPrice(b.offer); + if (aValid !== bValid) return aValid ? -1 : 1; + const byPrice = aValid + ? a.offer.price_current - b.offer.price_current + : 0; + return byPrice !== 0 ? byPrice : a.index - b.index; + }) + .map(({ offer }) => offer); +} + +/** The cheapest row a shopper can buy at; never sold out or bait (#1297). */ +export function cheapestBuyableOfferId(offers: PanelOffer[]): string | null { + const buyable = orderOffersForPanel(offers).find( + (o) => isOfferBuyable(o) && hasValidPrice(o) + ); + return buyable ? buyable.id : null; +} + +/** The marks the product page shows on a row (DOS/Rate#1297). */ +export function offerMarks(offer: PanelOffer): string[] { + const marks: string[] = []; + if (offer.availability === 'out_of_stock') marks.push('Hết hàng'); + if (offer.availability === 'discontinued') marks.push('Ngừng bán'); + if (offer.implausiblyLow) marks.push('Giá thấp bất thường'); + return marks; +} + +/** A star rating, or null when unknown or zero (DOS/Rate#1301). */ +export function ratingStarText(raw: unknown): string | null { + const rating = normalizeFiveStarRating(raw); + return rating == null + ? null + : `★ ${formatSafeNumber(rating, { min: 0, max: 5, decimals: 1 })}`; +} + +const OFFICIAL_BADGE_HTML = + 'Chính hãng'; + +function renderOfferRowHtml( + off: ExtensionOfferItem, + cheapest: boolean +): string { + const marks = offerMarks(off); + const muted = !isOfferBuyable(off); + const star = ratingStarText(off.rating_star); + const meta = [ + escapeHtml(String(off.platform ?? '').toUpperCase()), + star, + // The combined score, as the product page labels it (DOS/Rate#1280). + `Rủi ro tổng hợp: ${formatSafeNumber(off.merchant.risk_score, { min: 0, max: 100 })}/100`, + ].filter(Boolean); + const marksHtml = [ + ...(cheapest + ? ['Rẻ nhất'] + : []), + ...marks.map( + (m) => + `${escapeHtml(m)}` + ), + ].join(' '); + const priceTitle = isOfferSoldOut(off) + ? ' title="Giá ghi nhận lần cuối, không phải giá đang bán"' + : ''; + return ` +
    +
    +
    + ${escapeHtml(off.merchant.name)} + ${off.merchant.is_official ? OFFICIAL_BADGE_HTML : ''} + ${marksHtml} +
    +
    + ${meta.join(' - ')} +
    +
    + + ${formatSafeExtensionVnd(off.price_current)} + +
    + `; +} + +/** The store price list card, or '' with no offer (DOS/Rate#1297). */ +export function renderOffersCardHtml(offers: ExtensionOfferItem[]): string { + if (offers.length === 0) return ''; + const ordered = orderOffersForPanel(offers); + const cheapestId = cheapestBuyableOfferId(ordered); + return ` +
    +
    So Sánh Giá Các Gian Hàng (${formatSafeNumber(offers.length, { min: 0 })})
    + ${ordered + .slice(0, 5) + .map((off) => renderOfferRowHtml(off, off.id === cheapestId)) + .join('')} +
    + `; +} + +/** + * The review card. With nothing analysed it says so instead of "100% genuine + * of 0 reviews", and an unknown rating shows no star (DOS/Rate#1301). + */ +export function renderReviewCardHtml( + reviews: ExtensionLookupData['reviewSummary'], + master: Pick< + NonNullable, + 'user_rating_weighted' | 'average_rating' + > | null +): string { + if (!reviews) return ''; + const star = ratingStarText( + master?.user_rating_weighted || master?.average_rating + ); + const analysed = Number(reviews.total_analyzed); + const hasReviews = Number.isFinite(analysed) && analysed > 0; + return ` +
    +
    Chất lượng đánh giá (đã lọc đánh giá ảo)
    +
    +
    +
    Điểm người dùng (đã lọc)
    +
    ${star ? `${star} / 5` : 'Chưa có'}
    +
    +
    +
    Tỷ Lệ Đánh Giá Thật
    + ${ + hasReviews + ? `
    ${formatSafeNumber(reviews.authentic_pct, { min: 0, max: 100 })}% (${formatSafeNumber(analysed, { min: 0 })} đánh giá)
    ` + : '
    Chưa có đánh giá
    ' + } +
    +
    + ${ + hasReviews && reviews.ai_summary + ? `

    ${escapeHtml(reviews.ai_summary)}

    ` + : '' + } +
    + `; +} + +/** + * The lookup found no product, or the page is an accessory: the panel shows + * a short state, not a product card (DOS/Rate#1299). + */ +export function isLookupWithoutProduct( + lookup: Pick< + ExtensionLookupData, + 'matchStatus' | 'isAccessoryOrPart' | 'priceAssessment' + > | null +): boolean { + if (!lookup) return true; + return ( + lookup.matchStatus === 'not_found' || + lookup.matchStatus === 'accessory_skip' || + lookup.isAccessoryOrPart || + lookup.priceAssessment?.verdict === 'accessory_skip' + ); +} + +export function renderNotFoundHtml( + signal: Pick, + apiBaseUrl: string, + accessory: boolean +): string { + const query = String(signal.title ?? '').trim(); + const searchUrl = resolveSafeApiRelativeUrl( + apiBaseUrl, + query ? `/inspect?q=${encodeURIComponent(query)}` : '/inspect', + '/inspect' + ); + return ` +
    +
    Chưa có sản phẩm này trên Rate.vn
    + ${query ? `
    ${escapeHtml(query)}
    ` : ''} +

    + ${ + accessory + ? 'Đây có vẻ là phụ kiện hoặc linh kiện. Rate.vn chưa so sánh giá cho loại hàng này.' + : 'Rate.vn chưa có dữ liệu giá và đánh giá cho sản phẩm này.' + } +

    + +
    + `; +} + +export type LookupFailureKind = 'rate_limited' | 'server' | 'network' | 'other'; + +/** `null` status: the request never got an answer (DOS/Rate#1300). */ +export function classifyLookupFailure( + status: number | null | undefined +): LookupFailureKind { + if (typeof status !== 'number' || !Number.isFinite(status) || status <= 0) { + return 'network'; + } + if (status === 429) return 'rate_limited'; + if (status >= 500) return 'server'; + return 'other'; +} + +const LOOKUP_FAILURE_TEXT: Record = { + rate_limited: 'Bạn tra cứu hơi nhanh, thử lại sau ít phút.', + server: 'Rate.vn đang gặp sự cố, thử lại sau.', + network: 'Không kết nối được Rate.vn. Kiểm tra mạng rồi thử lại.', + other: 'Không tra cứu được sản phẩm này lúc này.', +}; + +/** An error card with a retry button (`data-action="retry"`), #1300. */ +export function renderLookupErrorHtml( + kind: LookupFailureKind, + productTitle?: string | null +): string { + const title = String(productTitle ?? '').trim(); + return ` + + `; +} + +export interface PanelActionLink { + href: string; + label: string; + primary?: boolean; +} + +/** + * The product card's links, two per row, each once: no rival means no + * versus link, not a second "Kiểm tra chi tiết" (DOS/Rate#1299). `href` + * values are already escaped by resolveSafeApiRelativeUrl. + */ +export function renderActionRowsHtml(links: PanelActionLink[]): string { + const rows: string[] = []; + for (let i = 0; i < links.length; i += 2) { + const cells = links + .slice(i, i + 2) + .map( + (l) => + `${escapeHtml(l.label)}` + ) + .join('\n '); + rows.push( + `
    0 ? ' style="margin-top: 6px;"' : ''}> + ${cells} +
    ` + ); + } + return rows.join('\n '); +} diff --git a/apps/extension/src/sidepanel/sidepanel.html b/apps/extension/src/sidepanel/sidepanel.html index 2fc056ca1..6478cb6e1 100644 --- a/apps/extension/src/sidepanel/sidepanel.html +++ b/apps/extension/src/sidepanel/sidepanel.html @@ -97,7 +97,8 @@ color: #34d399; border: 1px solid rgba(45, 186, 105, 0.45); } - .verdict-above_market { + .verdict-above_market, + .verdict-implausibly_low { background: rgba(245, 158, 11, 0.18); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.45); @@ -182,6 +183,31 @@ color: #34d399; white-space: nowrap; } + /* Sold-out, discontinued and bait rows: a last price, not an offer (DOS/Rate#1297). */ + .offer-price-muted { + color: var(--text-muted); + font-weight: 600; + } + .offer-mark { + display: inline-block; + font-size: 12px; + font-weight: 700; + padding: 1px 6px; + border-radius: 999px; + margin-left: 4px; + } + .offer-mark-muted { + background: rgba(156, 163, 175, 0.18); + color: #d1d5db; + } + .offer-mark-warn { + background: rgba(245, 158, 11, 0.18); + color: #fbbf24; + } + .offer-mark-best { + background: rgba(45, 186, 105, 0.18); + color: #34d399; + } .btn-row { display: flex; gap: 8px; @@ -213,6 +239,9 @@ text-align: center; text-decoration: none; } + button.btn-secondary { + font-family: inherit; + } .privacy-row { display: flex; align-items: flex-start; @@ -237,7 +266,7 @@
    - Rate.vn Intelligence + Rate.vn
    @@ -247,6 +276,7 @@
    -
    +

    Đang đồng bộ thông tin sản phẩm từ trang đang mở...

    @@ -273,12 +303,12 @@
    - +
    diff --git a/apps/extension/src/sidepanel/sidepanel.ts b/apps/extension/src/sidepanel/sidepanel.ts index 78c848455..f24045b6d 100644 --- a/apps/extension/src/sidepanel/sidepanel.ts +++ b/apps/extension/src/sidepanel/sidepanel.ts @@ -10,24 +10,25 @@ import { filterValidSparklinePrices, formatSafeExtensionVnd, formatSafeNumber, - normalizeFiveStarRating, resolveSafeApiRelativeUrl, sanitizeVerdictClassSuffix, } from '../security-guards.js'; +import { + classifyLookupFailure, + escapeHtml, + isLookupWithoutProduct, + renderActionRowsHtml, + renderLookupErrorHtml, + renderNotFoundHtml, + renderOffersCardHtml, + renderReviewCardHtml, + type PanelActionLink, +} from './panel-view.js'; function formatVnd(val: number | null | undefined): string { return formatSafeExtensionVnd(val); } -function escapeHtml(raw: string): string { - return raw - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - function buildSvgSparkline(points: ExtensionPriceHistoryPoint[]): string { if (!points || points.length < 2) { return `
    Đang tích lũy thêm các mốc thời gian lịch sử giá.
    `; @@ -160,7 +161,7 @@ async function fetchAndRenderDealRadar(
    ${escapeHtml(s.productName)}
    - ${escapeHtml(s.cheapestPlatform.toUpperCase())} (${formatVnd(s.minPrice)}) vs ${escapeHtml(s.priciestPlatform.toUpperCase())} (${formatVnd(s.maxPrice)}) + ${escapeHtml(s.cheapestPlatform.toUpperCase())} (${formatVnd(s.minPrice)}) so với ${escapeHtml(s.priciestPlatform.toUpperCase())} (${formatVnd(s.maxPrice)})
    @@ -178,7 +179,7 @@ async function fetchAndRenderDealRadar( traps.length > 0 ? `
    ${escapeHtml(m.platform.toUpperCase())} (${formatSafeNumber(m.product_count, { min: 0 })} SP) - Rủi ro lừa đảo: ${formatSafeNumber(m.dosafe_risk_score, { min: 0, max: 100 })} | Rủi ro đánh giá: ${reviewRiskText(m.review_risk_score)}
    - Trust ${formatSafeNumber(m.trust_score, { min: 0, max: 100 })}/100 + Tin cậy ${formatSafeNumber(m.trust_score, { min: 0, max: 100 })}/100
    `; @@ -326,15 +327,18 @@ function renderPanelContent( return; } - if (!lookup) { + // Not found or an accessory: a short state with a search link, no product + // card and no product buttons (DOS/Rate#1299). A failed lookup never gets + // here; it renders an error card (DOS/Rate#1300). + if (!lookup || isLookupWithoutProduct(lookup)) { + const accessory = Boolean( + lookup && + (lookup.isAccessoryOrPart || + lookup.matchStatus === 'accessory_skip' || + lookup.priceAssessment?.verdict === 'accessory_skip') + ); root.innerHTML = ` -
    -
    Không kết nối được Rate API
    -
    ${escapeHtml(signal.title)}
    -

    - Kiểm tra lại địa chỉ máy chủ Rate API (${escapeHtml(settings.apiBaseUrl)}). -

    -
    + ${renderNotFoundHtml(signal, settings.apiBaseUrl, accessory)}
    `; @@ -408,66 +412,17 @@ function renderPanelContent( const offersList = lookup.allOffers.length > 0 ? lookup.allOffers : lookup.topCheaperOffers; - const offersHtml = - offersList.length > 0 - ? ` -
    -
    So Sánh Giá Các Gian Hàng (${offersList.length})
    - ${offersList - .slice(0, 5) - .map( - (off) => ` -
    -
    -
    - ${escapeHtml(off.merchant.name)} - ${off.merchant.is_official ? '[Mall]' : ''} -
    -
    - ${escapeHtml(off.platform.toUpperCase())} - ★ ${formatSafeNumber(off.rating_star, { min: 0, max: 5, decimals: 1 })} - Rủi ro shop: ${formatSafeNumber(off.merchant.risk_score, { min: 0, max: 100 })}/100 -
    -
    - - ${formatVnd(off.price_current)} - -
    - ` - ) - .join('')} -
    - ` - : ''; + const offersHtml = renderOffersCardHtml(offersList); - const reviewHtml = reviews - ? ` -
    -
    Chất lượng đánh giá (đã lọc đánh giá ảo)
    -
    -
    -
    Điểm User Đã Lọc
    -
    ★ ${formatSafeNumber(normalizeFiveStarRating(master?.user_rating_weighted || master?.average_rating) ?? 0, { min: 0, max: 5, decimals: 1 })} / 5
    -
    -
    -
    Tỷ Lệ Đánh Giá Thật
    -
    ${formatSafeNumber(reviews.authentic_pct, { min: 0, max: 100 })}% (${formatSafeNumber(reviews.total_analyzed, { min: 0 })} đánh giá)
    -
    -
    - ${ - reviews.ai_summary - ? `

    ${escapeHtml(reviews.ai_summary)}

    ` - : '' - } -
    - ` - : ''; + const reviewHtml = renderReviewCardHtml(reviews, master); const merchantRiskHtml = merchant ? `
    -
    Độ Tin Cậy Gian Hàng (Dual Risk Score)
    +
    Độ Tin Cậy Gian Hàng
    -
    Trust Score
    +
    Điểm tin cậy
    ${formatSafeNumber(merchant.trust_score, { min: 0, max: 100 })}/100
    @@ -565,7 +520,8 @@ function renderPanelContent( const compareUrl = resolveSafeApiRelativeUrl( settings.apiBaseUrl, lookup.compareUrl, - `/compare?cat=${categorySlug}` + // The compare page reads `category` (DOS/Rate#1299). + `/compare?category=${categorySlug}` ); const leaderboardUrl = resolveSafeApiRelativeUrl( settings.apiBaseUrl, @@ -576,7 +532,7 @@ function renderPanelContent( ? resolveSafeApiRelativeUrl( settings.apiBaseUrl, lookup.versusUrl, - `/compare?cat=${categorySlug}` + `/compare?category=${categorySlug}` ) : null; const inspectUrl = resolveSafeApiRelativeUrl( @@ -609,10 +565,26 @@ function renderPanelContent( : '' : ''; + // Each link once; with no rival the versus slot is simply absent + // (DOS/Rate#1299). + const actionLinks: PanelActionLink[] = [ + { href: detailUrl, label: 'Xem Trên Rate.vn', primary: true }, + { href: compareUrl, label: 'So Sánh Cấu Hình' }, + { href: leaderboardUrl, label: '🏆 Bảng Xếp Hạng' }, + ...(versusUrl ? [{ href: versusUrl, label: '⚔️ Đối Đầu 1v1' }] : []), + { href: inspectUrl, label: '🔬 Kiểm tra chi tiết' }, + { href: watchlistUrl, label: '🔔 Canh Giá Thật' }, + ]; + root.innerHTML = `
    - ${escapeHtml(assessment.badgeLabel)} + ${escapeHtml(assessment.badgeLabel)} ${radarPillHtml}
    ${escapeHtml((sizeUnknown ? lookup.item?.name : master?.name) || signal.title)}
    @@ -627,22 +599,7 @@ function renderPanelContent(
    ${formatVnd(assessment.marketMedianPrice)}
    - - - + ${renderActionRowsHtml(actionLinks)}
    ${leaderboardCardHtml} ${variantButtonsHtml} @@ -687,6 +644,7 @@ async function loadSidePanel(selectedVariant?: string): Promise { settings?: ExtensionSettings; signal?: ExtractedPageProductSignal | null; data?: ExtensionLookupData | null; + status?: number | null; }; const settings: ExtensionSettings = res?.settings || { @@ -701,6 +659,13 @@ async function loadSidePanel(selectedVariant?: string): Promise { inpApiBase.value = settings.apiBaseUrl; } + if (res?.ok === false) { + renderLookupError(root, res.status, res.signal?.title, () => { + void loadSidePanel(selectedVariant); + }); + return; + } + renderPanelContent( root, res?.signal || null, @@ -710,16 +675,32 @@ async function loadSidePanel(selectedVariant?: string): Promise { void loadSidePanel(variantLabel); } ); - } catch (err) { - root.innerHTML = ` -
    -
    Lỗi tải dữ liệu
    -

    ${escapeHtml(err instanceof Error ? err.message : 'Không thể tải dữ liệu.')}

    -
    - `; + } catch { + renderLookupError(root, null, null, () => { + void loadSidePanel(selectedVariant); + }); } } +/** + * A failed lookup shows why and a retry button, never "not a product page" + * or the server settings hint (DOS/Rate#1300). + */ +function renderLookupError( + root: HTMLElement, + status: number | null | undefined, + productTitle: string | null | undefined, + retry: () => void +): void { + root.innerHTML = renderLookupErrorHtml( + classifyLookupFailure(status), + productTitle + ); + root + .querySelector('[data-action="retry"]') + ?.addEventListener('click', retry); +} + async function runQuickQuery(query: string): Promise { const root = document.getElementById('panel-root'); if (!root || !query.trim()) return; @@ -740,6 +721,7 @@ async function runQuickQuery(query: string): Promise { signal?: ExtractedPageProductSignal | null; data?: ExtensionLookupData | null; error?: string; + status?: number | null; }; const settings: ExtensionSettings = res?.settings || { @@ -747,6 +729,13 @@ async function runQuickQuery(query: string): Promise { crowdContributeOptIn: false, }; + if (res?.ok === false) { + renderLookupError(root, res.status, res.signal?.title ?? query, () => { + void runQuickQuery(query); + }); + return; + } + renderPanelContent( root, res?.signal || null, @@ -756,13 +745,10 @@ async function runQuickQuery(query: string): Promise { void loadSidePanel(variantLabel); } ); - } catch (err) { - root.innerHTML = ` -
    -
    Lỗi tra cứu nhanh
    -

    ${escapeHtml(err instanceof Error ? err.message : 'Không thể kiểm tra sản phẩm.')}

    -
    - `; + } catch { + renderLookupError(root, null, query, () => { + void runQuickQuery(query); + }); } } diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts index be40a34db..3ecdc75a7 100644 --- a/apps/extension/src/test-extension-pipeline.ts +++ b/apps/extension/src/test-extension-pipeline.ts @@ -25,6 +25,11 @@ import { selectFreshestSessionSignal, shouldRenderTrustPill, } from './security-guards.js'; +import type { + ExtensionLookupData, + ExtensionOfferItem, + ExtensionPriceVerdict, +} from './types.js'; import * as extModule from '../../web/src/server/rate/extension.js'; import * as rateApiRootMod from '../../web/src/lib/rate-api.js'; const { @@ -3670,6 +3675,393 @@ async function runTests() { ); } + // Side panel price list: order and marks (DOS/Rate#1297) + { + const { + cheapestBuyableOfferId, + offerMarks, + orderOffersForPanel, + renderOffersCardHtml, + } = await import('./sidepanel/panel-view.js'); + const offer = ( + id: string, + price: number, + extra: Partial = {} + ): ExtensionOfferItem => ({ + id, + platform: 'shopee', + title: id, + price_current: price, + price_original: null, + rating_star: 4.8, + rating_count: 10, + historical_sold: 0, + url: `https://shopee.vn/${id}-i.1.2`, + merchant: { + id: `m-${id}`, + name: `Shop ${id}`, + is_official: false, + risk_score: 12, + dosafe_risk_score: 5, + review_risk_score: null, + }, + ...extra, + }); + const offers = [ + offer('sold', 9_000_000, { availability: 'out_of_stock' }), + offer('bait', 2_000_000, { implausiblyLow: true }), + offer('b', 21_000_000), + offer('gone', 8_000_000, { availability: 'discontinued' }), + offer('a', 20_000_000, { availability: 'in_stock' }), + offer('a2', 20_000_000), + ]; + assert.deepEqual( + orderOffersForPanel(offers).map((o) => o.id), + ['a', 'a2', 'b', 'bait', 'gone', 'sold'], + 'in-stock plausible offers first, by price, ties in server order' + ); + assert.equal(cheapestBuyableOfferId(offers), 'a'); + assert.equal( + cheapestBuyableOfferId([offers[0]!, offers[1]!]), + null, + 'a sold-out or bait row is never the cheapest' + ); + assert.deepEqual(offerMarks(offers[0]!), ['Hết hàng']); + assert.deepEqual(offerMarks(offers[3]!), ['Ngừng bán']); + assert.deepEqual(offerMarks(offers[1]!), ['Giá thấp bất thường']); + assert.deepEqual(offerMarks(offers[2]!), []); + + const html = renderOffersCardHtml(offers); + assert.ok(html.includes('So Sánh Giá Các Gian Hàng (6)')); + assert.ok( + html.indexOf('Shop a\n') < html.indexOf('Shop bait'), + 'the cheapest real offer is listed before the bait row' + ); + assert.ok(html.includes('Giá thấp bất thường')); + assert.ok(html.includes('Ngừng bán')); + assert.ok( + !html.includes('Shop sold'), + 'only the first five rows show; the sold-out row is last' + ); + assert.equal((html.match(/Rẻ nhất/g) || []).length, 1); + assert.ok( + /Shop a\s+(?:]*>Rẻ nhất)/.test(html), + 'the cheapest mark sits on the in-stock row' + ); + assert.equal((html.match(/offer-price-muted/g) || []).length, 2); + assert.ok(html.includes('Rủi ro tổng hợp: 12/100'), '#1280 wording'); + assert.ok(!html.includes('Rủi ro shop')); + assert.ok(!html.includes('[Mall]')); + assert.equal(renderOffersCardHtml([]), ''); + const escaped = renderOffersCardHtml([ + offer('x', 1_000_000, { + merchant: { ...offer('x', 1).merchant, name: '' }, + }), + ]); + assert.ok(!escaped.includes(' = {}, + implausiblyLow?: boolean + ) => + ({ + matchStatus: 'exact_listing', + isAccessoryOrPart: false, + priceAssessment: { verdict, implausiblyLow }, + ...extra, + }) as unknown as ExtensionLookupData; + assert.deepEqual(resolveActionBadge(null), { text: '', color: null }); + assert.deepEqual( + resolveActionBadge(lookup('insufficient_data', {}, true)), + { text: '!', color: '#f59e0b' }, + 'a bait price warns' + ); + assert.equal(resolveActionBadge(lookup('insufficient_data')).text, '?'); + assert.equal(resolveActionBadge(lookup('accessory_skip')).text, ''); + assert.equal( + resolveActionBadge( + lookup('fair_market_price', { matchStatus: 'accessory_skip' }) + ).text, + '' + ); + assert.equal( + resolveActionBadge( + lookup('fair_market_price', { isAccessoryOrPart: true }) + ).text, + '' + ); + assert.equal( + resolveActionBadge(lookup('genuine_deal', { matchStatus: 'not_found' })) + .text, + '' + ); + assert.equal(resolveActionBadge(lookup('inflated_fake_sale')).text, 'ẢO!'); + assert.equal(resolveActionBadge(lookup('genuine_deal')).text, 'TỐT'); + assert.equal(resolveActionBadge(lookup('above_market')).text, 'CAO'); + assert.equal(resolveActionBadge(lookup('fair_market_price')).text, 'OK'); + for (const verdict of [ + 'insufficient_data', + 'accessory_skip', + ] as ExtensionPriceVerdict[]) { + assert.notEqual(resolveActionBadge(lookup(verdict)).text, 'OK', verdict); + } + assert.equal( + resolvePillTheme('insufficient_data', true).border, + '#f59e0b', + 'a bait price uses the warning pill' + ); + assert.equal(resolvePillTheme('insufficient_data').border, '#3b82f6'); + assert.equal(resolvePillTheme('inflated_fake_sale').border, '#d83030'); + console.log(' ✅ [Badge and pill per verdict (#1298)] Verified'); + } + + // Not found and accessory states, action links (DOS/Rate#1299) + { + const { isLookupWithoutProduct, renderActionRowsHtml, renderNotFoundHtml } = + await import('./sidepanel/panel-view.js'); + const base = { + matchStatus: 'exact_listing', + isAccessoryOrPart: false, + priceAssessment: { verdict: 'fair_market_price' }, + } as unknown as ExtensionLookupData; + assert.equal(isLookupWithoutProduct(null), true); + assert.equal(isLookupWithoutProduct(base), false); + assert.equal( + isLookupWithoutProduct({ ...base, matchStatus: 'not_found' }), + true + ); + assert.equal( + isLookupWithoutProduct({ ...base, matchStatus: 'accessory_skip' }), + true + ); + assert.equal( + isLookupWithoutProduct({ ...base, isAccessoryOrPart: true }), + true + ); + assert.equal( + isLookupWithoutProduct({ + ...base, + priceAssessment: { + ...base.priceAssessment, + verdict: 'accessory_skip', + }, + }), + true + ); + const notFound = renderNotFoundHtml( + { title: 'Ốp lưng iPhone 16 & ' }, + 'https://rate.vn', + false + ); + assert.ok(notFound.includes('Chưa có sản phẩm này trên Rate.vn')); + assert.ok( + notFound.includes( + `https://rate.vn/inspect?q=${encodeURIComponent('Ốp lưng iPhone 16 & ').replace(/&/g, '&')}` + ), + 'search link carries the page title' + ); + assert.ok(!notFound.includes(''), 'the title is escaped'); + for (const product of [ + 'Trung Vị Thị Trường', + 'smartphones', + 'Bảng Xếp Hạng', + 'Canh Giá', + ]) { + assert.ok(!notFound.includes(product), `no product UI: ${product}`); + } + assert.ok( + renderNotFoundHtml( + { title: 'Cáp sạc' }, + 'https://rate.vn', + true + ).includes('phụ kiện') + ); + const rows = renderActionRowsHtml([ + { href: 'https://rate.vn/p', label: 'Xem Trên Rate.vn', primary: true }, + { href: 'https://rate.vn/compare?category=tablets', label: 'So Sánh' }, + { href: 'https://rate.vn/leaderboard', label: 'Bảng Xếp Hạng' }, + { href: 'https://rate.vn/inspect?q=x', label: '🔬 Kiểm tra chi tiết' }, + { href: 'https://rate.vn/watchlist', label: '🔔 Canh Giá Thật' }, + ]); + assert.equal((rows.match(/Kiểm tra chi tiết/g) || []).length, 1); + assert.equal((rows.match(/class="btn-row"/g) || []).length, 3); + assert.ok(rows.includes('Canh Giá Thật')); + const panelSource = fs.readFileSync( + path.resolve(__dirname, 'sidepanel/sidepanel.ts'), + 'utf8' + ); + assert.ok( + !/\/compare\?cat=/.test(panelSource), + 'the compare page reads ?category=' + ); + console.log( + ' ✅ [Not found and accessory states, single inspect link (#1299)] Verified' + ); + } + + // Lookup error states (DOS/Rate#1300) + { + const { classifyLookupFailure, renderLookupErrorHtml } = + await import('./sidepanel/panel-view.js'); + assert.equal(classifyLookupFailure(429), 'rate_limited'); + assert.equal(classifyLookupFailure(500), 'server'); + assert.equal(classifyLookupFailure(503), 'server'); + assert.equal(classifyLookupFailure(null), 'network'); + assert.equal(classifyLookupFailure(undefined), 'network'); + assert.equal(classifyLookupFailure(404), 'other'); + const limited = renderLookupErrorHtml('rate_limited', 'iPhone 16'); + assert.ok(limited.includes('Bạn tra cứu hơi nhanh, thử lại sau ít phút')); + assert.ok(limited.includes('data-action="retry"')); + assert.ok(limited.includes('iPhone 16')); + assert.ok( + renderLookupErrorHtml('server').includes('Rate.vn đang gặp sự cố') + ); + assert.ok( + renderLookupErrorHtml('network').includes('Không kết nối được Rate.vn') + ); + for (const kind of ['rate_limited', 'server', 'network'] as const) { + const html = renderLookupErrorHtml(kind); + assert.ok(!html.includes('Chưa nhận diện trang sản phẩm'), kind); + assert.ok(!/máy chủ|Rate API/.test(html), `${kind}: no settings hint`); + } + // The content script drops the pill and the worker clears the badge on + // any failure, not only on an empty answer. + const contentSource = fs.readFileSync( + path.resolve(__dirname, 'content/content-script.ts'), + 'utf8' + ); + assert.ok(!contentSource.includes('response?.ok && !response.data')); + const workerSource = fs.readFileSync( + path.resolve(__dirname, 'background/service-worker.ts'), + 'utf8' + ); + assert.ok(workerSource.includes('status: outcome.status')); + assert.ok( + workerSource.includes('await updateActionBadge(sender.tab?.id, null)') + ); + console.log( + ' ✅ [Lookup error states and stale pill clearing (#1300)] Verified' + ); + } + + // Empty reviews and unknown ratings (DOS/Rate#1301) + { + const { ratingStarText, renderReviewCardHtml } = + await import('./sidepanel/panel-view.js'); + const empty = renderReviewCardHtml( + { + total_analyzed: 0, + authentic_pct: 100, + suspicious_pct: 0, + fake_pct: 0, + pros: [], + cons: [], + ai_summary: null, + }, + { user_rating_weighted: 0, average_rating: 0 } + ); + assert.ok(empty.includes('Chưa có đánh giá')); + assert.ok(!empty.includes('100%'), 'no percentage when nothing analysed'); + assert.ok(!empty.includes('★'), 'no star for an unknown rating'); + assert.ok(!empty.includes('0.0')); + const filled = renderReviewCardHtml( + { + total_analyzed: 40, + authentic_pct: 85, + suspicious_pct: 10, + fake_pct: 5, + pros: [], + cons: [], + ai_summary: null, + }, + { user_rating_weighted: 4.6, average_rating: 4.5 } + ); + assert.ok(filled.includes('★ 4.6 / 5')); + assert.ok(filled.includes('85% (40 đánh giá)')); + assert.equal(ratingStarText(0), null); + assert.equal(ratingStarText(null), null); + assert.equal(ratingStarText(4.75), '★ 4.8'); + console.log(' ✅ [Empty reviews and unknown ratings (#1301)] Verified'); + } + + // Vietnamese labels and accessible names (DOS/Rate#1302) + { + const { buildPillAriaLabel } = await import('./verdict-display.js'); + const panelHtml = fs.readFileSync( + path.resolve(__dirname, 'sidepanel/sidepanel.html'), + 'utf8' + ); + const panelSource = fs.readFileSync( + path.resolve(__dirname, 'sidepanel/sidepanel.ts'), + 'utf8' + ); + const viewSource = fs.readFileSync( + path.resolve(__dirname, 'sidepanel/panel-view.ts'), + 'utf8' + ); + for (const english of [ + 'Rate.vn Intelligence', + 'Dual Risk Score', + 'Trust Score', + 'Trust ${', + 'Fake Sale', + '[Mall]', + 'Rate API BFF', + 'Rủi ro shop', + 'Opt-In', + 'Điểm User', + ]) { + for (const [name, text] of [ + ['sidepanel.html', panelHtml], + ['sidepanel.ts', panelSource], + ['panel-view.ts', viewSource], + ] as const) { + assert.ok(!text.includes(english), `${name} still says "${english}"`); + } + } + assert.ok(panelSource.includes('Điểm tin cậy')); + assert.ok( + /
    /.test(panelHtml), + 'results are announced' + ); + const quickInput = panelHtml.slice( + panelHtml.indexOf('id="inp-quick-query"'), + panelHtml.indexOf('/>', panelHtml.indexOf('id="inp-quick-query"')) + ); + assert.ok( + /aria-label="[^"]+"/.test(quickInput), + 'quick lookup is labelled' + ); + const pillLabel = buildPillAriaLabel( + 'Giá thấp bất thường', + 'Đáy thị trường: 20.000.000 ₫' + ); + assert.ok(pillLabel.includes('Giá thấp bất thường'), 'verdict is spoken'); + assert.ok(pillLabel.includes('Mở bảng phân tích giá')); + const contentSource = fs.readFileSync( + path.resolve(__dirname, 'content/content-script.ts'), + 'utf8' + ); + assert.ok( + contentSource.includes( + 'aria-label="${escapeHtml(buildPillAriaLabel(assessment.badgeLabel, cheaperSummary))}"' + ), + 'the pill names its verdict' + ); + console.log( + ' ✅ [Vietnamese labels and accessible names (#1302)] Verified' + ); + } + console.log('🎉 All Rate Browser Extension MV3 v2 tests passed!'); } diff --git a/apps/extension/src/types.ts b/apps/extension/src/types.ts index b63ed7f9d..50cda687a 100644 --- a/apps/extension/src/types.ts +++ b/apps/extension/src/types.ts @@ -48,6 +48,11 @@ export interface ExtensionPriceAssessment { historicalMaxPrice: number | null; cheaperOfferCount: number; maxSavingsAmount: number; + /** + * Set with `insufficient_data` when the page's price is far below every + * other store (DOS/Rate#797): a warning, never "OK" (DOS/Rate#1298). + */ + implausiblyLow?: boolean; } export interface ExtensionOfferItem { @@ -60,6 +65,13 @@ export interface ExtensionOfferItem { rating_count: number; historical_sold: number; url: string; + /** + * A sold-out or discontinued listing keeps its last price + * (DOS/Rate#1141, #1297); absent when the server did not read it. + */ + availability?: 'in_stock' | 'out_of_stock' | 'discontinued' | null; + /** A bait price far below every other in-stock store (DOS/Rate#1297). */ + implausiblyLow?: boolean; merchant: { id: string; name: string; diff --git a/apps/extension/src/verdict-display.ts b/apps/extension/src/verdict-display.ts new file mode 100644 index 000000000..f2953a8c1 --- /dev/null +++ b/apps/extension/src/verdict-display.ts @@ -0,0 +1,110 @@ +import type { ExtensionLookupData } from './types.js'; + +type BadgeLookup = Pick< + ExtensionLookupData, + 'matchStatus' | 'isAccessoryOrPart' | 'priceAssessment' +>; + +export interface ActionBadge { + text: string; + /** `null`: leave the colour as it is (the badge is empty). */ + color: string | null; +} + +const WARNING_COLOR = '#f59e0b'; + +/** + * The toolbar badge for a lookup. A bait price warns, and an unchosen size + * or an accessory never reads "OK" (DOS/Rate#1298). + */ +export function resolveActionBadge( + lookup: BadgeLookup | null | undefined +): ActionBadge { + if (!lookup || lookup.matchStatus === 'not_found') { + return { text: '', color: null }; + } + const verdict = lookup.priceAssessment?.verdict; + if ( + lookup.isAccessoryOrPart || + lookup.matchStatus === 'accessory_skip' || + verdict === 'accessory_skip' + ) { + return { text: '', color: null }; + } + if (lookup.priceAssessment?.implausiblyLow) { + return { text: '!', color: WARNING_COLOR }; + } + switch (verdict) { + case 'inflated_fake_sale': + return { text: 'ẢO!', color: '#d83030' }; + case 'genuine_deal': + return { text: 'TỐT', color: '#2dba69' }; + case 'above_market': + return { text: 'CAO', color: WARNING_COLOR }; + case 'fair_market_price': + return { text: 'OK', color: '#3b82f6' }; + default: + return { text: '?', color: '#6b7280' }; + } +} + +export interface PillTheme { + bg: string; + border: string; + dot: string; +} + +/** The trust pill's colours; a bait price uses the warning theme (#1298). */ +export function resolvePillTheme( + verdict: string, + implausiblyLow?: boolean | null +): PillTheme { + if (implausiblyLow) { + return { + bg: 'rgba(245, 158, 11, 0.16)', + border: WARNING_COLOR, + dot: WARNING_COLOR, + }; + } + switch (verdict) { + case 'inflated_fake_sale': + return { + bg: 'rgba(216, 48, 48, 0.16)', + border: '#d83030', + dot: '#d83030', + }; + case 'genuine_deal': + return { + bg: 'rgba(45, 186, 105, 0.16)', + border: '#2dba69', + dot: '#2dba69', + }; + case 'above_market': + return { + bg: 'rgba(245, 158, 11, 0.16)', + border: WARNING_COLOR, + dot: WARNING_COLOR, + }; + default: + return { + bg: 'rgba(59, 130, 246, 0.16)', + border: '#3b82f6', + dot: '#3b82f6', + }; + } +} + +/** + * The pill's accessible name: the verdict and the summary a sighted user + * reads, then what activating it does (DOS/Rate#1302). Plain text; the + * caller escapes it for the attribute. + */ +export function buildPillAriaLabel( + badgeLabel: string, + summary: string +): string { + const parts = [`Rate.vn: ${badgeLabel.trim()}`]; + if (summary.trim()) parts.push(summary.trim()); + parts.push('Mở bảng phân tích giá'); + return parts.join('. '); +} diff --git a/apps/web/src/server/rate/extension.ts b/apps/web/src/server/rate/extension.ts index 892925eac..0fb43165e 100644 --- a/apps/web/src/server/rate/extension.ts +++ b/apps/web/src/server/rate/extension.ts @@ -2,6 +2,7 @@ import 'server-only'; import { applyCategorySpecTemplate, getCategorySpecTemplate, + implausiblyLowOfferIds, isImplausiblyLowPrice, minimumListingPriceVnd, plausibleOffers, @@ -211,6 +212,13 @@ export interface ExtensionPriceAssessment { implausiblyLow?: true; } +/** + * An offer in the lookup's lists. `implausiblyLow` marks a bait price far + * below every other in-stock store, so the side panel can flag the row + * (DOS/Rate#797, #1297). + */ +export type ExtensionOffer = MerchantOffer & { implausiblyLow?: true }; + export interface ExtensionLookupResponse { matchStatus: ExtensionMatchStatus; platform: SupportedEcommercePlatform | null; @@ -224,8 +232,8 @@ export interface ExtensionLookupResponse { priceAssessment: ExtensionPriceAssessment; masterProduct: MasterProduct | null; matchedOffer: MerchantOffer | null; - topCheaperOffers: MerchantOffer[]; - allOffers: MerchantOffer[]; + topCheaperOffers: ExtensionOffer[]; + allOffers: ExtensionOffer[]; merchantProfile: MerchantSummary | null; reviewSummary: ReviewAnalysisSummary | null; priceHistory: MasterProductPriceHistorySummary | null; @@ -1440,6 +1448,13 @@ export async function lookupExtensionProduct( : null; const radarSignal = computeProductRadarSignal(matchedMaster, offers); + // Bait prices are judged among the in-stock listings, as the market numbers + // are; the flag lets the side panel mark the row (DOS/Rate#1297). + const baitIds = implausiblyLowOfferIds(inStockOffers); + const flagBait = (o: MerchantOffer): ExtensionOffer => + baitIds.has(o.id) ? { ...o, implausiblyLow: true } : o; + const flaggedCheaperOffers = topCheaperOffers.map(flagBait); + return { matchStatus, platform, @@ -1456,8 +1471,8 @@ export async function lookupExtensionProduct( // storage or camera, no watch 5G (DOS/Rate#1184). masterProduct: applyCategorySpecTemplate(categoryPath, matchedMaster), matchedOffer, - topCheaperOffers, - allOffers: mode === 'full' ? offers : topCheaperOffers, + topCheaperOffers: flaggedCheaperOffers, + allOffers: mode === 'full' ? offers.map(flagBait) : flaggedCheaperOffers, merchantProfile, reviewSummary, priceHistory: mode === 'full' ? priceHistory : null, From cc531ea9c1146d9093ab97a6fd0a85c90418c2cf Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:56:42 +0700 Subject: [PATCH 60/69] fix(web): a post-login target that normalizes to //host goes home (#1312) * fix(web): a post-login target that normalizes to //host goes home The callbackUrl sanitizer checked the raw value for a leading '//', but dot segments normalize into a protocol-relative path after parsing, which the router reads as another host. The parsed pathname is now checked too. The locale-prefix 308 also collapses leading slashes, so it never emits a two-slash Location. Closes #1305 Co-Authored-By: Claude Opus 5.5 --- .../src/components/auth/RateAuthPortal.tsx | 8 ++++++- apps/web/src/proxy.ts | 3 ++- .../__tests__/core-rate-architecture.test.ts | 22 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/auth/RateAuthPortal.tsx b/apps/web/src/components/auth/RateAuthPortal.tsx index 0cfbaa4d9..698533c0b 100644 --- a/apps/web/src/components/auth/RateAuthPortal.tsx +++ b/apps/web/src/components/auth/RateAuthPortal.tsx @@ -46,7 +46,13 @@ export function sanitizeAuthRedirectTarget(rawTarget: unknown): string { } try { const parsed = new URL(cleaned, 'https://rate.vn'); - if (parsed.origin !== 'https://rate.vn') { + // Dot segments ("/.//x", "/%2e//x", "/a/..//x") normalize to a "//x" path, + // which a router reads as another host: never a target. + if ( + parsed.origin !== 'https://rate.vn' || + parsed.pathname.startsWith('//') || + parsed.pathname.includes('\\') + ) { return '/'; } return `${parsed.pathname}${parsed.search}${parsed.hash}`; diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 5f1619c58..f1333d3f7 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -65,7 +65,8 @@ export function localeProxy(req: NextRequest): NextResponse { const localePrefixMatch = pathname.match(/^\/(vi|en|cs)(\/.*)?$/); if (localePrefixMatch) { const matchedLocale = localePrefixMatch[1]!; - const cleanPath = localePrefixMatch[2] || '/'; + // One leading slash: "/en//host" must not become a protocol-relative Location. + const cleanPath = `/${(localePrefixMatch[2] ?? '').replace(/^\/+/, '')}`; const redirectUrl = req.nextUrl.clone(); redirectUrl.pathname = cleanPath; const response = NextResponse.redirect(redirectUrl, 308); diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 58ecde853..f9b57515d 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -367,6 +367,17 @@ async function runCoreArchitectureSuite() { 'https://rate.vn/compare' ); assert.equal(redirectCsCompare.cookies.get('NEXT_LOCALE')?.value, 'cs'); + // A doubled slash after the locale stays on the site (no "//host" target). + const redirectEnDoubleSlash = middleware( + new NextRequest('https://rate.vn/en//evil.example/x', { + headers: { 'x-forwarded-proto': 'https', host: 'rate.vn' }, + }) + ); + assert.equal(redirectEnDoubleSlash.status, 308); + assert.equal( + redirectEnDoubleSlash.headers.get('location'), + 'https://rate.vn/evil.example/x' + ); const unprefixedEnRes = middleware( new NextRequest('https://rate.vn/', { @@ -12872,6 +12883,17 @@ async function runCoreArchitectureSuite() { assert.equal(sanitizeAuthRedirectTarget('javascript:alert(1)'), '/'); assert.equal(sanitizeAuthRedirectTarget(null), '/'); assert.equal(sanitizeAuthRedirectTarget(`/${'a'.repeat(600)}`), '/'); + // Dot segments that normalize to a protocol-relative path. + for (const target of [ + '/.//evil.example/phish', + '/%2e//evil.example/phish', + '/%2E//evil.example', + '/a/..//evil.example/phish', + '/././/evil.example', + ]) { + assert.equal(sanitizeAuthRedirectTarget(target), '/', target); + } + assert.equal(sanitizeAuthRedirectTarget('/a/../watchlist'), '/watchlist'); assert.equal(outcomeFromJobState('skipped_no_consent'), 'noConsent'); assert.equal(outcomeFromJobState('rejected_invalid_url'), 'invalidUrl'); From f39ad3b057ebe7e1561b8cd356895b259a1598b2 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:57:24 +0700 Subject: [PATCH 61/69] fix(web): a vote flushes only its product's caches, and only when it changes (#1320) * fix(web): a vote flushes only its product's caches, and only when it changes DOS/Rate#1306 (web part). Every vote request revalidated the global products, stats and reviews tags, and the database limit counts rows per review, so repeated actions on one review could keep the catalogue caches cold. - the stored vote is read first: the same vote again is not written and flushes nothing; - a changed vote revalidates the review caches and its product's page tag, not the global products and stats tags; - flushes are throttled per voter (6 per minute per instance); votes past that still count and the caches catch up on their own interval. The database guard change is proposed on the issue for JOY. Refs #1306 * test(web): vote route revalidates its product tag, not the global ones Refs #1306 * fix(web): a vote refreshes only its product's review caches Codex round 1 on DOS/Rate#1306: - review readers and summaries are also tagged per master (masterReviewsCacheTag); a vote revalidates that tag and the product's page tag, no longer the global reviews tag; - a report over a stored up/down vote counts as a change (the report clears the vote), so it is written. Refs #1306 Co-Authored-By: Claude Opus 5.5 --- .../src/app/api/reviews/[id]/vote/route.ts | 92 +++++++++++++++++-- .../api/v1/rate/__tests__/write-paths.test.ts | 59 +++++++++++- .../__tests__/core-rate-architecture.test.ts | 9 +- apps/web/src/server/rate/cache-tags.ts | 12 +++ apps/web/src/server/rate/index.ts | 5 +- 5 files changed, 164 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/api/reviews/[id]/vote/route.ts b/apps/web/src/app/api/reviews/[id]/vote/route.ts index 2db47da45..97a152ce3 100644 --- a/apps/web/src/app/api/reviews/[id]/vote/route.ts +++ b/apps/web/src/app/api/reviews/[id]/vote/route.ts @@ -1,7 +1,11 @@ import { revalidateTag } from 'next/cache'; import { NextRequest, NextResponse } from 'next/server'; -import { RATE_CACHE_TAGS } from '@/server/rate'; import { + masterReviewsCacheTag, + productCacheTag, +} from '@/server/rate/cache-tags'; +import { + BoundedSlidingWindowRateLimiter, resolveReviewVoteInput, sanitizePublicApiError, type ReviewReportReason, @@ -11,6 +15,16 @@ import { createLogger, serializeError } from '@/lib/logger'; const log = createLogger('api.reviews.vote'); +// Cache flushes per voter (DOS/Rate#1306): a vote that changes counts +// revalidates the review caches and its product's page, at most this often +// per voter on one instance. Votes past it still count; the caches catch up +// at their own revalidate interval. +const voteRevalidationLimiter = new BoundedSlidingWindowRateLimiter({ + windowMs: 60_000, + maxRequests: 6, + maxBuckets: 5_000, +}); + const REVIEW_VOTE_CORS_HEADERS: Record = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', @@ -203,6 +217,25 @@ export async function POST( ); } } else { + // The same vote again changes nothing: no write, no cache flush. Its + // own row is readable to the voter (RLS); if it is not, vote as before. + const { data: current } = await rateClient + .from('review_votes') + .select('vote, reported, report_reason') + .eq('review_id', reviewId) + .eq('voter_id', voterId) + .maybeSingle(); + const sameVote = + current !== null && + current !== undefined && + (voteType === 'report' + ? current.reported === true && + (current.vote ?? null) === null && + (current.report_reason ?? null) === reportReason + : current.vote === voteType && current.reported !== true); + if (sameVote) votesChanged = false; + } + if (voteType !== 'clear' && votesChanged) { const votePayload: { review_id: string; voter_id: string; @@ -286,13 +319,14 @@ export async function POST( ); } - for (const tag of votesChanged - ? [ - RATE_CACHE_TAGS.REVIEWS, - RATE_CACHE_TAGS.PRODUCTS, - RATE_CACHE_TAGS.STATS, - ] - : []) { + // This product's reviews and page only, not the whole catalogue + // (DOS/Rate#1306); global review lists and stats catch up on their own + // interval. + const revalidate = + votesChanged && voteRevalidationLimiter.allow(voterId) + ? await reviewProductTags(rateClient, reviewId) + : []; + for (const tag of revalidate) { try { revalidateTag(tag, 'max'); } catch { @@ -330,3 +364,45 @@ export async function POST( ); } } + +/** + * The cache tags of a review's product (its variant master): its reviews and + * its page. None when they cannot be read; the caches then catch up on their + * own interval. + */ +async function reviewProductTags( + rateClient: Awaited< + ReturnType + > extends infer A + ? A extends { ok: true; rateClient: infer C } + ? C + : never + : never, + reviewId: string +): Promise { + try { + const { data: review } = await rateClient + .from('reviews') + .select('product_id') + .eq('id', reviewId) + .maybeSingle(); + if (!review?.product_id) return []; + const { data: listing } = await rateClient + .from('products') + .select('cluster_id') + .eq('id', review.product_id) + .maybeSingle(); + if (!listing?.cluster_id) return []; + const { data: master } = await rateClient + .from('master_products') + .select('slug') + .eq('id', listing.cluster_id) + .maybeSingle(); + return [ + masterReviewsCacheTag(listing.cluster_id), + ...(master?.slug ? [productCacheTag(master.slug)] : []), + ]; + } catch { + return []; + } +} diff --git a/apps/web/src/app/api/v1/rate/__tests__/write-paths.test.ts b/apps/web/src/app/api/v1/rate/__tests__/write-paths.test.ts index 7b8e15d6a..f6ce674e8 100644 --- a/apps/web/src/app/api/v1/rate/__tests__/write-paths.test.ts +++ b/apps/web/src/app/api/v1/rate/__tests__/write-paths.test.ts @@ -15,6 +15,8 @@ const ANON_KEY = 'anon-test-key'; const SERVICE_KEY = 'service-role-test-key'; const GOOD_TOKEN = 'good-user-token'; const VOTER_TOKEN = 'eligible-voter-token'; +// An eligible voter whose stored vote is already "up" (DOS/Rate#1306). +const SAME_VOTE_TOKEN = 'same-vote-token'; // What the stubbed PostgREST answers a VOTER_TOKEN vote with (code 42501). let voteRejectionMessage = 'review_votes: account is not eligible to vote on reviews'; @@ -88,9 +90,19 @@ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const { pathname } = new URL(url); // A voter the route accepts but the database guard rejects (DOS/Rate#771). + if ( + pathname === '/rest/v1/review_votes' && + (init?.method ?? 'GET') === 'GET' && + headers.authorization === `Bearer ${SAME_VOTE_TOKEN}` + ) { + return jsonResponse(200, [ + { vote: 'up', reported: false, report_reason: null }, + ]); + } if ( pathname === '/auth/v1/user' && - headers.authorization === `Bearer ${VOTER_TOKEN}` + (headers.authorization === `Bearer ${VOTER_TOKEN}` || + headers.authorization === `Bearer ${SAME_VOTE_TOKEN}`) ) { return jsonResponse(200, { ...USER, @@ -671,6 +683,51 @@ async function testCookieSessions() { ); assert.equal((await grantErrorVoteRes.json()).code, 'VOTE_REJECTED'); + // The same vote again writes nothing, so it cannot flush caches (#1306). + calls.length = 0; + await postVote( + new Request(`https://rate.vn/api/reviews/${reviewId}/vote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${SAME_VOTE_TOKEN}`, + }, + body: JSON.stringify({ voteType: 'up' }), + }) as VoteReq, + { params: Promise.resolve({ id: reviewId }) } + ); + assert.ok( + calls.some( + (c) => c.method === 'GET' && c.url.includes('/rest/v1/review_votes') + ), + 'the stored vote is read first (#1306)' + ); + assert.ok( + !calls.some( + (c) => c.method === 'POST' && c.url.includes('/rest/v1/review_votes') + ), + 'an unchanged vote is not written again (#1306)' + ); + // A report over a stored up-vote changes the vote (it clears it): written. + calls.length = 0; + await postVote( + new Request(`https://rate.vn/api/reviews/${reviewId}/vote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${SAME_VOTE_TOKEN}`, + }, + body: JSON.stringify({ voteType: 'report', reportReason: 'spam' }), + }) as VoteReq, + { params: Promise.resolve({ id: reviewId }) } + ); + assert.ok( + calls.some( + (c) => c.method === 'POST' && c.url.includes('/rest/v1/review_votes') + ), + 'a report over an up-vote is written (#1306)' + ); + // An ineligible voter (no created_at, no confirmation) can still clear a vote. const clearRes = await postVote( new Request(`https://rate.vn/api/reviews/${reviewId}/vote`, { diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index f9b57515d..72806d265 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -5300,11 +5300,16 @@ async function runCoreArchitectureSuite() { path.join(repoRoot, 'apps/web/src/app/api/reviews/[id]/vote/route.ts'), 'utf8' ); + // A vote flushes only the review caches and its product's page, not the + // global stats (DOS/Rate#1306); a new review still refreshes the stats. assert.ok( reviewsRouteSrc402.includes('RATE_CACHE_TAGS.STATS') && - voteRouteSrc402.includes('RATE_CACHE_TAGS.STATS') && + voteRouteSrc402.includes('masterReviewsCacheTag(') && + !voteRouteSrc402.includes('RATE_CACHE_TAGS.REVIEWS') && + voteRouteSrc402.includes('productCacheTag(') && + !voteRouteSrc402.includes('RATE_CACHE_TAGS.PRODUCTS') && voteRouteSrc402.includes('authenticateRateRequest('), - 'POST /api/v1/rate/reviews and POST /api/reviews/[id]/vote must revalidate RATE_CACHE_TAGS.STATS and use authenticateRateRequest (#402)' + 'POST /api/v1/rate/reviews must revalidate RATE_CACHE_TAGS.STATS; POST /api/reviews/[id]/vote must revalidate the review caches and its product tag only, and use authenticateRateRequest (#402, #1306)' ); } diff --git a/apps/web/src/server/rate/cache-tags.ts b/apps/web/src/server/rate/cache-tags.ts index 6ac658576..a8c0c0038 100644 --- a/apps/web/src/server/rate/cache-tags.ts +++ b/apps/web/src/server/rate/cache-tags.ts @@ -29,6 +29,18 @@ export function productCacheTag(slug: string): string { .slice(0, 120)}`; } +/** + * One product's (variant master's) reviews and review summary, so a vote can + * refresh them without flushing every product's reviews (DOS/Rate#1306). + */ +export function masterReviewsCacheTag(masterId: string): string { + return `rate:reviews:${String(masterId ?? '') + .replace(/\p{Cc}+/gu, '') + .trim() + .toLowerCase() + .slice(0, 120)}`; +} + export function categoryCacheTag(categorySlug: string): string { return `rate:category:${String(categorySlug ?? '') .replace(/\p{Cc}+/gu, '') diff --git a/apps/web/src/server/rate/index.ts b/apps/web/src/server/rate/index.ts index b7e10e93b..a0600c962 100644 --- a/apps/web/src/server/rate/index.ts +++ b/apps/web/src/server/rate/index.ts @@ -84,6 +84,7 @@ import { canonicalizeCacheParams, categoryCacheTag, isSafeRevalidationPath, + masterReviewsCacheTag, merchantCacheTag, productCacheTag, resolveRevalidationTargets, @@ -333,7 +334,7 @@ const _getCachedMasterProductReviewsByKey = cache((cleanId: string) => ['rate-master-product-reviews', cleanId], { revalidate: 300, - tags: [RATE_CACHE_TAGS.REVIEWS], + tags: [RATE_CACHE_TAGS.REVIEWS, masterReviewsCacheTag(cleanId)], } )(cleanId) ); @@ -371,7 +372,7 @@ export const getCachedMasterProductReviewSummary = cache( ['rate-master-product-review-summary', cleanId, reviewsKey], { revalidate: 300, - tags: [RATE_CACHE_TAGS.REVIEWS], + tags: [RATE_CACHE_TAGS.REVIEWS, masterReviewsCacheTag(cleanId)], } )(cleanId); } From 5b4ee2cb6a5f38619c3518e2ffc87e1014c0c52c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:57:46 +0700 Subject: [PATCH 62/69] =?UTF-8?q?fix(normalizer):=20keep=20the=20size=20be?= =?UTF-8?q?fore=20n=C4=83m=20;=20dates=20and=20units=20are=20not=20s?= =?UTF-8?q?izes=20(#1317)=20(#1321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(normalizer): a size before "năm " is kept; dates, ram, macos names and amounts are no size * fix(normalizer): a size before "RAM 8GB", SSD or ROM stays the screen's; no short price units Co-Authored-By: Claude Opus 5.5 --- .../__tests__/open-core-intelligence.test.ts | 29 +++++++++++++++++++ .../importer/src/shopee/product-normalizer.ts | 16 ++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts index 56589a7ea..56ab53c14 100644 --- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts +++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts @@ -477,6 +477,34 @@ async function runSuite() { ['MacBook Air M2 8GB 256GB bảo hành 15 tháng', 'apple:macbook-air-m2:256gb'], ['MacBook Pro M3 18GB 512GB bảo hành 14 tháng', 'apple:macbook-pro-m3:512gb'], ['MacBook Air M2 8GB 256GB 15 gói quà', 'apple:macbook-air-m2:256gb'], + // " năm " names the size and the year (DOS/Rate#1317); a "năm" + // with no year after it is a duration. + ['Máy tính bảng Apple iPad Pro 11 năm 2022 M2 128GB Wifi', 'apple:ipad-pro-m2-11:128gb'], + ['Laptop Apple MacBook Pro 14 năm 2023 M3 8GB 512GB', 'apple:macbook-pro-m3-14:512gb'], + ['MacBook Air 13 năm 2022 M2 8GB 256GB', 'apple:macbook-air-m2-13:256gb'], + ['MacBook Air 15 năm 2023 M2 8GB 256GB', 'apple:macbook-air-m2-15:256gb'], + ['iPad Air 11 năm 2024 M2 128GB', 'apple:ipad-air-m2-11:128gb'], + ['iPad Pro 12.9 năm 2022 M2 128GB', 'apple:ipad-pro-m2-129:128gb'], + ['MacBook Air M2 13 năm', 'apple:macbook-air-m2:unknown'], + // A date ("11/11"), RAM or SSD, a macOS name, a duration or an amount is no + // size, so the gift's size still stands in. + ['iPad Pro M4 Sale 11/11 tặng ốp 13 inch', 'apple:ipad-pro-m4-13:unknown'], + ['MacBook Pro M3 16 RAM 512 SSD tặng túi 14 inch', 'apple:macbook-pro-m3-14:unknown'], + ['MacBook Air M3 macOS Sonoma 14 tặng túi 13 inch', 'apple:macbook-air-m3-13:unknown'], + ['iPad Air M2 BH 12 months tặng ốp 11 inch', 'apple:ipad-air-m2-11:unknown'], + // ...but a size before "RAM 8GB" (a RAM size follows), "SSD" or "ROM" is the + // screen's, and short words like "Tr", "k", "đ" are no unit. + ['iPad Pro M4 11 RAM 8GB ROM 256GB tặng ốp 13 inch', 'apple:ipad-pro-m4-11:256gb'], + ['Macbook Air M2 13 RAM 8GB SSD 256GB', 'apple:macbook-air-m2-13:256gb'], + ['MacBook Pro M3 14 RAM 18GB 512GB', 'apple:macbook-pro-m3-14:512gb'], + ['MacBook Air M1 13 SSD 256GB', 'apple:macbook-air-m1-13:256gb'], + ['iPad Pro M4 11 ROM 256GB', 'apple:ipad-pro-m4-11:256gb'], + ['MacBook Pro 14 Tr M3 8GB 512GB', 'apple:macbook-pro-m3-14:512gb'], + ['iPad Pro 11 k M4 256GB', 'apple:ipad-pro-m4-11:256gb'], + // A bare gift number is the gift's: the product's own size after it wins. + ['iPad Pro M4 tặng ốp 11 | 13 WiFi', 'apple:ipad-pro-m4-13:unknown'], + ['iPad Pro M4 13 tặng ốp 11 tặng bút', 'apple:ipad-pro-m4-13:unknown'], + ['iPad Pro M4 tặng ốp 11 tặng bút', 'apple:ipad-pro-m4-11:unknown'], // A number after "cho/for" (a school year, an age) is no size of the product. [ 'iPad Pro M4 Wifi 256GB tặng bao da 13 inch cho học sinh lớp 11', @@ -735,6 +763,7 @@ async function runSuite() { ['Apple Watch Series 10 GPS tặng ốp 42mm / 46mm', 'apple:watch-series-10:64gb'], ['Apple Watch Series 10 GPS 46 tặng ốp 42mm', 'apple:watch-series-10:64gb'], ['Apple Watch Series 10 GPS giảm 40% tặng ốp 46mm', 'apple:watch-series-10-46mm:64gb'], + ['Apple Watch Series 10 giảm 44 nghìn tặng ốp 46mm', 'apple:watch-series-10-46mm:64gb'], // A size listed right after the box's is a list, not the watch's own case. ['Apple Watch Series 10 kèm hộp 46mm | 42mm', 'apple:watch-series-10-46mm:64gb'], // "size 46" beats a box's size; a band's "size 42" is not the watch's. diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index e88f6f640..9980b6fe9 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -1256,9 +1256,11 @@ function isProductCodeToken(token: string): boolean { // js/polynomial-redos). A unit ends where a letter does, Vietnamese ones included: "13 // tặng" or "13 hãng" is a bare 13, not "13 t(b)" or "13 h"; "15 tháng" (warranty), "10%" // or "15 gói" is no size (DOS/Rate#1291). +// "16 RAM 512" is the RAM; "13 RAM 8GB" (a RAM size follows) is the screen's 13 +// (DOS/Rate#1317). const UNIT_END = String.raw`(?![\p{L}\p{M}\d])`; -const SIZE_NOT_PRECEDED_BY = String.raw`(? Date: Thu, 29 Oct 2026 07:57:56 +0700 Subject: [PATCH 63/69] fix(web): reviews by product id serve published products only (#1314) * fix(web): reviews by product id serve published products only * fix(web): merchant profile reads no reviews of unpublished products * fix(web): platform stats sample no reviews of unpublished products Co-Authored-By: Claude Opus 5.5 --- .../api/v1/rate/__tests__/write-paths.test.ts | 54 +++++++++++++++++ apps/web/src/app/api/v1/rate/reviews/route.ts | 10 ++++ .../__tests__/merchant-profile-reads.test.ts | 29 +++++++++ apps/web/src/lib/rate-api.ts | 60 ++++++++++++++++--- .../__tests__/core-rate-architecture.test.ts | 20 +++++++ apps/web/src/server/rate/index.ts | 19 ++++++ 6 files changed, 184 insertions(+), 8 deletions(-) diff --git a/apps/web/src/app/api/v1/rate/__tests__/write-paths.test.ts b/apps/web/src/app/api/v1/rate/__tests__/write-paths.test.ts index f6ce674e8..3c83d5ff6 100644 --- a/apps/web/src/app/api/v1/rate/__tests__/write-paths.test.ts +++ b/apps/web/src/app/api/v1/rate/__tests__/write-paths.test.ts @@ -38,6 +38,7 @@ const USER = { email: 'owner@rate.test', }; const MASTER_ID = '99999999-8888-4777-8666-555555555555'; +const DRAFT_MASTER_ID = '99999999-8888-4777-8666-444444444444'; process.env.NEXT_PUBLIC_SUPABASE_URL = SUPABASE_URL; process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = ANON_KEY; @@ -136,6 +137,18 @@ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const r = rpcResponder(pathname.slice('/rest/v1/rpc/'.length), body); return jsonResponse(r.status, r.json); } + // A draft product: a status-filtered read finds nothing (DOS/Rate#1309). + if ( + pathname === '/rest/v1/master_products' && + new URL(url).searchParams.get('id') === `eq.${DRAFT_MASTER_ID}` + ) { + return jsonResponse( + 200, + new URL(url).searchParams.get('status') === 'eq.published' + ? null + : { id: DRAFT_MASTER_ID, status: 'draft' } + ); + } if (pathname === '/rest/v1/master_products') { return jsonResponse(200, [ { @@ -339,6 +352,46 @@ async function testReviews() { ); } +// GET reviews by id serves published products only, like the slug path (DOS/Rate#1309). +async function testReviewsByIdPublishedOnly() { + const { GET } = await import('../reviews/route'); + type Req = Parameters[0]; + const get = (id: string) => + GET({ + nextUrl: new URL( + `http://localhost/api/v1/rate/reviews?masterProductId=${id}` + ), + headers: new Headers(), + } as unknown as Req); + + calls = []; + let res = await get(DRAFT_MASTER_ID); + assert.equal(res.status, 404); + assert.equal((await res.json()).code, 'NOT_FOUND'); + const statusRead = calls.find((c) => + c.url.includes('/rest/v1/master_products') + ); + assert.ok( + statusRead?.url.includes('status=eq.published'), + 'the id is checked against published products' + ); + assert.equal( + calls.filter( + (c) => c.url.includes('/rest/v1/products') || c.url.includes('/reviews') + ).length, + 0, + 'no listing or review is read for a draft product' + ); + + calls = []; + res = await get(MASTER_ID); + assert.equal(res.status, 200); + assert.equal((await res.json()).masterProductId, MASTER_ID); + console.log( + ' ✅ GET /api/v1/rate/reviews?masterProductId= serves published products only (#1309)' + ); +} + async function testWatchlist() { const { POST } = await import('../watchlist/route'); type Req = Parameters[0]; @@ -1606,6 +1659,7 @@ async function testMerchantCacheRevalidateAndDomainBounds() { async function main() { console.log('🧪 Public write-path security suite (#155)'); await testReviews(); + await testReviewsByIdPublishedOnly(); await testWatchlist(); await testCookieSessions(); await testFilterBoundsAndWatchlistPrices(); diff --git a/apps/web/src/app/api/v1/rate/reviews/route.ts b/apps/web/src/app/api/v1/rate/reviews/route.ts index e51f24340..2a975004f 100644 --- a/apps/web/src/app/api/v1/rate/reviews/route.ts +++ b/apps/web/src/app/api/v1/rate/reviews/route.ts @@ -7,6 +7,7 @@ import { getCachedMasterProductBySlug, getCachedMasterProductReviews, getCachedMasterProductReviewSummary, + isCachedPublishedMasterProduct, } from '@/server/rate'; import { PUBLIC_API_CORS_HEADERS, @@ -55,6 +56,15 @@ export async function GET(request: NextRequest) { let resolvedMasterId: string; if (parsedQuery.masterProductId) { + // Same answer as the slug path: a draft or archived product is not found. + if ( + !(await isCachedPublishedMasterProduct(parsedQuery.masterProductId)) + ) { + return NextResponse.json( + { ok: false, error: 'Product not found', code: 'NOT_FOUND' }, + { status: 404, headers: REVIEWS_CORS_HEADERS } + ); + } resolvedMasterId = parsedQuery.masterProductId; } else { const product = await getCachedMasterProductBySlug(parsedQuery.slug!); diff --git a/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts b/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts index 5307bdd94..30e53e985 100644 --- a/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts +++ b/apps/web/src/lib/__tests__/merchant-profile-reads.test.ts @@ -46,6 +46,7 @@ const listingRow = { }; const listingUrls: string[] = []; +const reviewQueries: string[] = []; type Route = 'merchant' | 'merchantBySlug' | 'listings' | 'other'; @@ -85,6 +86,7 @@ function installFetch(options: { ); const route = routeOf(url); if (route === 'listings') listingUrls.push(url.search); + if (route === 'reviews') reviewQueries.push(decodeURIComponent(url.search)); const entry: Recorded = { route, start: clock++, end: -1 }; calls.push(entry); await new Promise((resolve) => setTimeout(resolve, delay)); @@ -501,6 +503,33 @@ async function main() { await new Promise((resolve) => setTimeout(resolve, 50)); assert.deepEqual(unhandled, [], 'no read may fail unhandled'); + // 6. Reviews of a listing whose product is not published stay out of the + // profile; an unclustered listing's reviews are read (DOS/Rate#1309). + { + const DRAFT_LISTING_ID = '00000000-0000-4000-a000-000000001309'; + installFetch({ + listings: [ + listingRow, + { + ...listingRow, + id: DRAFT_LISTING_ID, + cluster_id: '00000000-0000-4000-9000-000000001309', + }, + { ...listingRow, id: UNCLUSTERED_LISTING_ID, cluster_id: null }, + ], + }); + reviewQueries.length = 0; + const base = await getMerchantProfileBase(MERCHANT_ID); + assert.ok(base); + const read = reviewQueries.join(' '); + assert.ok(read.includes(LISTING_ID), 'a published listing is read'); + assert.ok(read.includes(UNCLUSTERED_LISTING_ID), 'an unclustered one too'); + assert.ok( + !read.includes(DRAFT_LISTING_ID), + 'a listing of an unpublished product has no review read' + ); + } + console.warn(' ✅ merchant profile reads suite passed'); } diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 8734e949a..efd023514 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -1599,6 +1599,31 @@ export function resolveIncludeInRating( * Fetch reviews across listings grouped under a master product, * including JReviews vote metrics and AI transparency flags. */ +/** + * Whether a master product id names a published product. Reads that take an id + * from a request (reviews by `masterProductId`) serve only what the slug path + * would (DOS/Rate#1309). + */ +export async function isPublishedMasterProduct( + masterId: string +): Promise { + const supabase = getRateClient(); + const { data, error } = await supabase + .from('master_products') + .select('id') + .eq('id', masterId) + .eq('status', 'published') + .maybeSingle(); + if (error) { + log.error('master product status lookup failed', { + masterId, + message: error.message, + }); + throw new RateDataUnavailableError('master product', error); + } + return Boolean(data); +} + export async function getMasterProductReviews( masterId: string ): Promise { @@ -3363,9 +3388,7 @@ export async function getMerchantProfileBase( const clusterIds = Array.from( new Set(rawProducts.map((p) => p.cluster_id).filter(Boolean)) ) as string[]; - const productIds = rawProducts.map((p) => p.id); - - const [masterRows, catsRes, reviewRows] = await Promise.all([ + const [masterRows, catsRes] = await Promise.all([ // Published only: the product page serves no other status, so a draft // master would be linked to a 404 (DOS/Rate#769). Its listings show as // unclustered. @@ -3379,15 +3402,27 @@ export async function getMerchantProfileBase( .eq('status', 'published') ), supabase.from('categories').select('id, slug'), - readInChunks(productIds, 'merchant profile', (chunk) => + ]); + // Reviews of a listing whose product is not published (draft, archived) stay + // out, as on the product's own review paths (DOS/Rate#1309); an unclustered + // listing's reviews show. + const publishedMasterIds = new Set( + (masterRows as Array<{ id: string }>).map((m) => m.id) + ); + const productIds = rawProducts + .filter((p) => !p.cluster_id || publishedMasterIds.has(p.cluster_id)) + .map((p) => p.id); + const reviewRows = await readInChunks( + productIds, + 'merchant profile', + (chunk) => supabase .from('reviews') .select('*, review_flags(*)') .in('product_id', chunk) .order('created_at', { ascending: false }) .limit(60) - ), - ]); + ); if (catsRes.error) { throw new RateDataUnavailableError('merchant profile', catsRes.error); } @@ -3908,8 +3943,17 @@ export async function getRatePlatformStats(): Promise { }); } - const mappedReviews: RatePlatformStats['recentReviewsSample'] = - rawReviews.map((r) => { + // A review of a listing whose product is not published (draft, archived) is + // not sampled; an unclustered listing's is (DOS/Rate#1309). masterRows holds + // the published masters only. + const sampledListingIds = new Set( + prodRows + .filter((p) => !p.cluster_id || masterSlugById.has(p.cluster_id)) + .map((p) => p.id) + ); + const mappedReviews: RatePlatformStats['recentReviewsSample'] = rawReviews + .filter((r) => sampledListingIds.has(r.product_id)) + .map((r) => { const rawVf = Array.isArray(r.review_flags) ? r.review_flags[0] : r.review_flags; diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 72806d265..7adf57aa7 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -4736,6 +4736,16 @@ async function runCoreArchitectureSuite() { created_at: '2026-10-18T10:00:00.000Z', review_flags: [], }, + // A review of a draft product's listing (DOS/Rate#1309). + { + id: 'rev-stats-draft', + product_id: 'prod-sample-draft', + author_name: 'An', + rating_star: 1, + comment: 'Bản nháp, chưa được xuất bản.', + created_at: '2026-10-18T11:00:00.000Z', + review_flags: [], + }, ]), { status: 200, headers: { 'content-type': 'application/json' } } ); @@ -4752,6 +4762,11 @@ async function runCoreArchitectureSuite() { title: 'Samsung Galaxy S24 Ultra 256GB Chính Hãng', cluster_id: 'mp-stats-1', }, + { + id: 'prod-sample-draft', + title: 'Samsung Galaxy S25 Edge (draft)', + cluster_id: 'mp-stats-draft', + }, ]), { status: 200, headers: { 'content-type': 'application/json' } } ); @@ -4778,6 +4793,11 @@ async function runCoreArchitectureSuite() { 'samsung-galaxy-s24-ultra', 'getRatePlatformStats must resolve recent review masterSlug via scoped .in(id, sampleProductIds) (#386)' ); + assert.deepEqual( + platformStats.recentReviewsSample.map((r) => r.id), + ['rev-stats-1'], + 'a review of an unpublished product is not sampled (DOS/Rate#1309)' + ); assert.ok( !statsFetchedUrls.some( (req) => diff --git a/apps/web/src/server/rate/index.ts b/apps/web/src/server/rate/index.ts index a0600c962..1a9bff1b0 100644 --- a/apps/web/src/server/rate/index.ts +++ b/apps/web/src/server/rate/index.ts @@ -17,6 +17,7 @@ import { getMasterProductOffersBatch as _getMasterProductOffersBatch, getMasterProductPriceHistory as _getMasterProductPriceHistory, getMasterProductReviews as _getMasterProductReviews, + isPublishedMasterProduct as _isPublishedMasterProduct, getMasterProductReviewSummary as _getMasterProductReviewSummary, getMasterProducts as _getMasterProducts, attachMerchantDiscountAnalysis as _attachMerchantDiscountAnalysis, @@ -339,6 +340,24 @@ const _getCachedMasterProductReviewsByKey = cache((cleanId: string) => )(cleanId) ); +const _getCachedIsPublishedMasterProductByKey = cache((cleanId: string) => + withRateDataCache( + _isPublishedMasterProduct, + ['rate-master-product-published', cleanId], + { + revalidate: 300, + tags: [RATE_CACHE_TAGS.PRODUCTS], + } + )(cleanId) +); + +/** Whether the id names a published master product (DOS/Rate#1309). */ +export function isCachedPublishedMasterProduct(masterProductId: string) { + const cleanId = sanitizeCachedEntityId(masterProductId); + if (!cleanId) return Promise.resolve(false); + return _getCachedIsPublishedMasterProductByKey(cleanId); +} + export function getCachedMasterProductReviews(masterProductId: string) { const cleanId = sanitizeCachedEntityId(masterProductId); if (!cleanId) return Promise.resolve([]); From 01e7de59819f38e6b56107b7904bbfa0a2830ec3 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:58:39 +0700 Subject: [PATCH 64/69] fix(web): rate-limit inspect and lookup and cache their results (#1322) * fix(web): rate-limit inspect and lookup and cache their results DOS/Rate#1307. /api/v1/rate/inspect and /lookup ran several database reads per distinct query with no rate limit, and their result was cached for one request only. - both routes share a per-IP limit of 30 per minute per instance (429 with Retry-After); a durable shared limit is #1247; - the inspection result is cached in the Data Cache for 2 minutes per normalized query (hashed key), also for the inspect page; - the client key prefers the platform-set x-vercel-forwarded-for over a client-supplied forwarded chain (also for the extension routes). Closes #1307 * fix(web): trust the vercel ip header only on vercel; skip oversized cache entries Codex round 1 on DOS/Rate#1307: - x-vercel-forwarded-for is read only when VERCEL=1; elsewhere it is client input and the forwarded chain is used as before; - an inspection result over 1.5 MB (Next skips entries over 2 MB) is cached as a marker and read uncached, instead of a failed write. Refs #1307 Co-Authored-By: Claude Opus 5.5 --- .../api/v1/rate/__tests__/spec-scope.test.ts | 28 +++++++++++++ apps/web/src/app/api/v1/rate/inspect/route.ts | 6 +++ apps/web/src/app/api/v1/rate/lookup/route.ts | 6 +++ .../__tests__/core-rate-architecture.test.ts | 12 ++++++ apps/web/src/server/rate/api-handler.ts | 33 ++++++++++++++++ apps/web/src/server/rate/index.ts | 39 +++++++++++++++++-- 6 files changed, 121 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts index 541b5331f..7c371517f 100644 --- a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts +++ b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts @@ -321,11 +321,39 @@ async function testExtensionLookup() { console.warn(' ✅ Extension lookup returns the category spec view'); } +// Inspect and lookup share a per-IP limit (DOS/Rate#1307). +async function testInspectRateLimit() { + const { NextRequest } = await import('next/server'); + const inspect = await import('../inspect/route'); + const lookup = await import('../lookup/route'); + const ask = (route: typeof inspect | typeof lookup, ip: string) => + route.GET( + new NextRequest('http://localhost/api/v1/rate/inspect?q=iphone', { + headers: { 'x-forwarded-for': ip }, + }) + ); + for (let i = 0; i < 30; i++) { + const res = await ask(i % 2 ? inspect : lookup, '198.51.100.20'); + assert.notEqual(res.status, 429, `request ${i + 1}`); + } + const limited = await ask(inspect, '198.51.100.20'); + assert.equal(limited.status, 429); + assert.equal(limited.headers.get('retry-after'), '60'); + assert.equal((await ask(lookup, '198.51.100.20')).status, 429); + assert.notEqual( + (await ask(inspect, '198.51.100.21')).status, + 429, + 'another client is not limited' + ); + console.warn(' ✅ Inspect and lookup are rate-limited per client IP'); +} + async function main() { console.warn('🧪 API v1 category spec view suite (DOS/Rate#1184)'); await testProductFilters(); await testDetailAndCompare(); await testExtensionLookup(); + await testInspectRateLimit(); } main().catch((err) => { diff --git a/apps/web/src/app/api/v1/rate/inspect/route.ts b/apps/web/src/app/api/v1/rate/inspect/route.ts index 871252656..442d2c314 100644 --- a/apps/web/src/app/api/v1/rate/inspect/route.ts +++ b/apps/web/src/app/api/v1/rate/inspect/route.ts @@ -9,6 +9,9 @@ import { } from '@/server/rate/extension'; import { PUBLIC_API_CORS_HEADERS, + extractClientRateLimitKey, + inspectRateLimitResponse, + inspectRateLimiter, jsonApiError, withPublicReviewRisk, parseBoundedStringParam, @@ -31,6 +34,9 @@ export async function OPTIONS() { */ export async function GET(request: NextRequest) { try { + if (!inspectRateLimiter.allow(extractClientRateLimitKey(request.headers))) { + return inspectRateLimitResponse(PUBLIC_API_CORS_HEADERS); + } const sp = request.nextUrl.searchParams; const rawQuery = parseBoundedStringParam( sp.get('q') || sp.get('url'), diff --git a/apps/web/src/app/api/v1/rate/lookup/route.ts b/apps/web/src/app/api/v1/rate/lookup/route.ts index 31391fa42..8876196ca 100644 --- a/apps/web/src/app/api/v1/rate/lookup/route.ts +++ b/apps/web/src/app/api/v1/rate/lookup/route.ts @@ -5,6 +5,9 @@ import { } from '@/server/rate'; import { PUBLIC_API_CORS_HEADERS, + extractClientRateLimitKey, + inspectRateLimitResponse, + inspectRateLimiter, jsonApiError, withPublicReviewRisk, parseBoundedStringParam, @@ -25,6 +28,9 @@ export async function OPTIONS() { */ export async function GET(request: NextRequest) { try { + if (!inspectRateLimiter.allow(extractClientRateLimitKey(request.headers))) { + return inspectRateLimitResponse(PUBLIC_API_CORS_HEADERS); + } const sp = request.nextUrl.searchParams; const rawQuery = parseBoundedStringParam( sp.get('q') || sp.get('url'), diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 7adf57aa7..668f82ada 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -9080,6 +9080,18 @@ async function runCoreArchitectureSuite() { 'x-forwarded-for': ' 203.0.113.10\t99 , 10.0.0.1 ', }); assert.equal(extractClientRateLimitKey(wsHeaders), '203.0.113.1099'); + // On Vercel the platform-set IP wins over a client-supplied forwarded + // chain; elsewhere that header is client input and ignored (#1307). + const vercelHeaders = new Headers({ + 'x-vercel-forwarded-for': '198.51.100.7', + 'x-forwarded-for': '203.0.113.10, 10.0.0.1', + }); + const previousVercel = process.env.VERCEL; + process.env.VERCEL = '1'; + assert.equal(extractClientRateLimitKey(vercelHeaders), '198.51.100.7'); + delete process.env.VERCEL; + assert.equal(extractClientRateLimitKey(vercelHeaders), '203.0.113.10'); + if (previousVercel !== undefined) process.env.VERCEL = previousVercel; const plausibleBoolMaster = withPlausibleMinPrice( { min_price: true as unknown as number }, diff --git a/apps/web/src/server/rate/api-handler.ts b/apps/web/src/server/rate/api-handler.ts index fbf16503a..2a68e2acf 100644 --- a/apps/web/src/server/rate/api-handler.ts +++ b/apps/web/src/server/rate/api-handler.ts @@ -710,6 +710,15 @@ export function extractClientRateLimitKey( .replace(/\s+/g, '') .slice(0, 128); }; + // On Vercel, the platform-set client IP first (a client cannot set it + // there); anywhere else that header is ordinary client input and ignored + // (DOS/Rate#1307). + if (process.env.VERCEL === '1') { + const platformIp = sanitizeIpCandidate( + headers.get('x-vercel-forwarded-for')?.split(',')[0] + ); + if (platformIp) return platformIp; + } const forwardedFor = headers.get('x-forwarded-for'); if (forwardedFor) { const firstIp = sanitizeIpCandidate(forwardedFor.split(',')[0]); @@ -720,6 +729,30 @@ export function extractClientRateLimitKey( return 'anon'; } +/** + * Per-IP limit of the uncached inspection routes (`/inspect`, `/lookup`), + * per server instance (DOS/Rate#1307). A durable shared limit is #1247. + */ +export const inspectRateLimiter = new BoundedSlidingWindowRateLimiter({ + windowMs: 60_000, + maxRequests: 30, + maxBuckets: 5_000, +}); + +/** The 429 the inspection routes answer past `inspectRateLimiter`. */ +export function inspectRateLimitResponse( + headers: Record +): NextResponse { + return NextResponse.json( + { + ok: false, + error: 'Rate limit exceeded. Maximum 30 inspections per minute.', + code: 'RATE_LIMIT_EXCEEDED', + }, + { status: 429, headers: { ...headers, 'Retry-After': '60' } } + ); +} + export type ReviewVoteAction = 'up' | 'down' | 'report' | 'clear'; export type ReviewReportReason = 'spam' | 'fake' | 'offensive' | 'off_topic' | 'other'; diff --git a/apps/web/src/server/rate/index.ts b/apps/web/src/server/rate/index.ts index 1a9bff1b0..a67133b4e 100644 --- a/apps/web/src/server/rate/index.ts +++ b/apps/web/src/server/rate/index.ts @@ -1,4 +1,5 @@ import 'server-only'; +import { createHash } from 'node:crypto'; import { cache } from 'react'; import { sitemapRowsByItem } from '@/lib/item-directory'; import { @@ -585,9 +586,41 @@ export const getCachedFilteredReviewCountsByMaster = cache(() => )() ); -export const getCachedInspectProductOrShopUrl = cache( - inspectProductOrShopUrlWithRadar -); +// One Data Cache entry per normalized query for 2 minutes, so repeating a +// query does not rerun its reads (DOS/Rate#1307). The key is a hash: the +// query is up to 2 KB of caller text. A result too large for an entry (Next +// skips entries over 2 MB) is cached as a marker and read uncached. +const INSPECT_CACHE_MAX_BYTES = 1_500_000; +const _getCachedInspectionByKey = cache(async (query: string) => { + const cached = await withRateDataCache( + async (q: string) => { + const result = await inspectProductOrShopUrlWithRadar(q); + return JSON.stringify(result).length > INSPECT_CACHE_MAX_BYTES + ? { tooLarge: true as const } + : result; + }, + [ + 'rate-inspect', + createHash('sha256').update(query).digest('hex').slice(0, 32), + ], + { + revalidate: 120, + tags: [ + RATE_CACHE_TAGS.PRODUCTS, + RATE_CACHE_TAGS.OFFERS, + RATE_CACHE_TAGS.REVIEWS, + RATE_CACHE_TAGS.MERCHANTS, + ], + } + )(query); + return 'tooLarge' in cached + ? inspectProductOrShopUrlWithRadar(query) + : cached; +}); + +export function getCachedInspectProductOrShopUrl(rawQuery: string) { + return _getCachedInspectionByKey(sanitizeCachedInspectQuery(rawQuery)); +} // A product URL only needs its category, so only category_slug is kept from // metadata and specifications: whole spec sheets would push the entry past From 8c1cca0dab65aa421143fc4c8e8727841381f672 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:58:47 +0700 Subject: [PATCH 65/69] test(e2e): print the item size-panel payload numbers to the ci log (#1313) * test(e2e): print the item size-panel payload numbers to the ci log Refs #1285 * test(e2e): log the payload numbers with console.warn Refs #1285 Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/item-model/item-size-panels.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/e2e/item-model/item-size-panels.spec.ts b/apps/web/e2e/item-model/item-size-panels.spec.ts index fee1f7984..a5b51b67c 100644 --- a/apps/web/e2e/item-model/item-size-panels.spec.ts +++ b/apps/web/e2e/item-model/item-size-panels.spec.ts @@ -40,6 +40,11 @@ test('a six-size Item ships only its default size and loads the others on demand type: 'size-route json bytes, five other sizes', description: String(others), }); + // Printed too, so the CI log carries the numbers (the line reporter does not + // print annotations). + console.warn( + `[#1285] RSC bytes: six-size Item (lazy) ${big}, two-size Item (eager) ${small}; size-route JSON bytes for the five other sizes ${others}` + ); const html = await (await request.get(BIG)).text(); expect(html).toContain(listing(64, 1)); From dcfc88afcfa26229c0555f45fcc742d3bdd1edde Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:59:04 +0700 Subject: [PATCH 66/69] fix(web): public api errors answer a fixed message unless the error is a public one (#1315) * fix(web): public api errors answer a fixed message unless the error is a public one Co-Authored-By: Claude Opus 5.5 --- .../src/app/api/reviews/[id]/vote/route.ts | 2 +- .../__tests__/core-rate-architecture.test.ts | 50 +++++++++++++++++-- apps/web/src/server/rate/api-handler.ts | 25 ++++++++-- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/api/reviews/[id]/vote/route.ts b/apps/web/src/app/api/reviews/[id]/vote/route.ts index 97a152ce3..a843f0c56 100644 --- a/apps/web/src/app/api/reviews/[id]/vote/route.ts +++ b/apps/web/src/app/api/reviews/[id]/vote/route.ts @@ -358,7 +358,7 @@ export async function POST( return NextResponse.json( { ok: false, - error: sanitizePublicApiError(error, 'Failed to vote on review'), + error: 'Failed to vote on review', }, { status: 500, headers: REVIEW_VOTE_CORS_HEADERS } ); diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 668f82ada..cda67a000 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -2440,6 +2440,7 @@ async function runCoreArchitectureSuite() { // 48. Unified API v1 Error Sanitizer & Bounded Query Parsers (T9) + Pure Domain Module rate-domain.ts (T12) (#170) const { + PublicApiError, sanitizePublicApiError, jsonApiError, parseBoundedIntParam, @@ -2455,11 +2456,54 @@ async function runCoreArchitectureSuite() { ); assert.equal( sanitizePublicApiError( - new Error('Missing required slug parameter'), + new PublicApiError('Missing required slug parameter'), 'Fallback error' ), 'Missing required slug parameter', - 'Must preserve short, non-DB validation messages' + 'Must preserve short validation messages written for clients' + ); + // Allow-list, not deny-list: any other error, even a harmless-looking one, + // answers with the fallback (DOS/Rate#1310). + assert.equal( + sanitizePublicApiError( + new Error('Missing required slug parameter'), + 'Fallback error' + ), + 'Fallback error' + ); + assert.equal( + sanitizePublicApiError( + new TypeError("Cannot read properties of undefined (reading 'id')"), + 'Fallback error' + ), + 'Fallback error' + ); + assert.equal( + sanitizePublicApiError( + new PublicApiError('PGRST116: relation "rate.items" does not exist'), + 'Fallback error' + ), + 'Fallback error', + 'a public error that looks internal is still masked' + ); + // A 5xx never carries the error's message, even a public one. + const err5xx = jsonApiError( + 503, + 'Service unavailable', + new PublicApiError('Upstream shop timed out') + ); + assert.equal( + ((await err5xx.json()) as { error: string }).error, + 'Service unavailable' + ); + const err4xx = jsonApiError( + 400, + 'Bad request', + new PublicApiError('Invalid slug') + ); + assert.equal( + ((await err4xx.json()) as { error: string }).error, + 'Invalid slug' ); assert.equal(parseBoundedIntParam('999', 24, 1, 100), 100); assert.equal(parseBoundedIntParam('-5', 24, 1, 100), 1); @@ -9292,7 +9336,7 @@ async function runCoreArchitectureSuite() { ); assert.equal( apiHandlerMod.sanitizePublicApiError( - new Error('Invalid\u0000\r\nquery param') + new apiHandlerMod.PublicApiError('Invalid\u0000\r\nquery param') ), 'Invalid query param' ); diff --git a/apps/web/src/server/rate/api-handler.ts b/apps/web/src/server/rate/api-handler.ts index 2a68e2acf..13b170b3d 100644 --- a/apps/web/src/server/rate/api-handler.ts +++ b/apps/web/src/server/rate/api-handler.ts @@ -45,15 +45,28 @@ export const PUBLIC_API_CORS_HEADERS: Record = { const DB_INTERNAL_ERROR_REGEX = /PGRST\d+|relation\s+"[^"]+"|column\s+"[^"]+"|permission denied|syntax error at|duplicate key value|violates foreign key|violates check constraint|schema\s+"rate"|rate-api:\s*failed to/i; +/** + * An error whose message is written for API clients (a validation message). Only + * these reach a response body; any other error answers with the route's fixed + * message (DOS/Rate#1310). + */ +export class PublicApiError extends Error { + constructor(message: string) { + super(message); + this.name = 'PublicApiError'; + } +} + /** * Audit T9: Prevent leaking internal PostgreSQL / PostgREST / Supabase schema or query errors - * to public API consumers while preserving safe validation messages. + * to public API consumers. Allow-list: only a `PublicApiError` message passes, and even + * then not one that looks internal or is too long (DOS/Rate#1310). */ export function sanitizePublicApiError( err: unknown, fallbackMessage = 'Internal server error' ): string { - if (!(err instanceof Error) || typeof err.message !== 'string') { + if (!(err instanceof PublicApiError) || typeof err.message !== 'string') { return fallbackMessage; } const msg = err.message @@ -72,9 +85,11 @@ export function jsonApiError( err?: unknown, extraHeaders?: Record ) { - const safeError = err - ? sanitizePublicApiError(err, fallbackMessage) - : fallbackMessage; + // A 5xx always answers with the fixed message: its cause is never the client's. + const safeError = + err && status < 500 + ? sanitizePublicApiError(err, fallbackMessage) + : fallbackMessage; return NextResponse.json( { ok: false, From 67938aa7a2739d102196d9ba1b9c77aedf6c6c08 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:07:37 +0700 Subject: [PATCH 67/69] feat(web): report-only content security policy with a per-request script nonce (#1318) * feat(web): report-only content security policy with a per-request script nonce * fix(web): csp report endpoint caps streamed bodies, validates and dedupes before logging Co-Authored-By: Claude Opus 5.5 --- apps/web/package.json | 2 +- apps/web/src/app/api/csp-report/route.ts | 112 +++++++++++++++ apps/web/src/lib/__tests__/csp.test.ts | 174 +++++++++++++++++++++++ apps/web/src/lib/csp.ts | 68 +++++++++ apps/web/src/proxy.ts | 16 ++- 5 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/api/csp-report/route.ts create mode 100644 apps/web/src/lib/__tests__/csp.test.ts create mode 100644 apps/web/src/lib/csp.ts diff --git a/apps/web/package.json b/apps/web/package.json index 7aa1380c1..6613417f2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/csp.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/api/csp-report/route.ts b/apps/web/src/app/api/csp-report/route.ts new file mode 100644 index 000000000..b7cc7566a --- /dev/null +++ b/apps/web/src/app/api/csp-report/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server'; +import { createLogger } from '@/lib/logger'; + +const log = createLogger('csp'); + +// Browsers post a few hundred bytes; anything larger is not a report. +const MAX_REPORT_BYTES = 8 * 1024; +// One log line per distinct violation per window and instance, at most this many +// distinct ones: an endpoint anyone can post to must not flood the logs. +const DEDUPE_WINDOW_MS = 10 * 60 * 1000; +const MAX_DISTINCT = 200; +const seen = new Map(); + +/** The path of a URL, or the scheme/keyword a CSP report gives ("inline", "eval"). */ +function shortUrl(value: unknown): string | undefined { + if (typeof value !== 'string' || !value) return undefined; + try { + const url = new URL(value); + return `${url.origin}${url.pathname}`.slice(0, 200); + } catch { + return value.slice(0, 40); + } +} + +/** The body as text, or null once it passes `limit` bytes (stops reading there). */ +async function readCapped( + request: Request, + limit: number +): Promise { + const declared = Number(request.headers.get('content-length') ?? 0); + if (declared > limit) return null; + if (!request.body) return ''; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + const all = new Uint8Array(size); + let at = 0; + for (const c of chunks) { + all.set(c, at); + at += c.byteLength; + } + return new TextDecoder().decode(all); +} + +/** True the first time a key is seen in the window, while the table has room. */ +function firstInWindow(key: string, now: number): boolean { + const last = seen.get(key); + if (last !== undefined && now - last < DEDUPE_WINDOW_MS) return false; + if (last === undefined && seen.size >= MAX_DISTINCT) { + for (const [k, t] of seen) { + if (now - t >= DEDUPE_WINDOW_MS) seen.delete(k); + } + if (seen.size >= MAX_DISTINCT) return false; + } + seen.set(key, now); + return true; +} + +/** + * POST /api/csp-report: violation reports of the report-only Content Security + * Policy (DOS/Rate#1311). A report needs a directive and a page on this site's + * origin; each distinct one is logged once per window, without query strings, so + * the report window can be read from the logs before the policy is enforced. + * Always 204. + */ +export async function POST(request: Request) { + try { + const text = await readCapped(request, MAX_REPORT_BYTES); + if (!text) return new NextResponse(null, { status: 204 }); + const parsed = JSON.parse(text) as { + 'csp-report'?: Record; + }; + const report = parsed?.['csp-report']; + if (!report || typeof report !== 'object') { + return new NextResponse(null, { status: 204 }); + } + const directive = String( + report['effective-directive'] ?? report['violated-directive'] ?? '' + ).slice(0, 60); + const page = shortUrl(report['document-uri']); + const origin = new URL(request.url).origin; + if (!/^[a-z-]+$/.test(directive) || !page?.startsWith(`${origin}/`)) { + return new NextResponse(null, { status: 204 }); + } + const blocked = shortUrl(report['blocked-uri']); + const source = shortUrl(report['source-file']); + if ( + firstInWindow(`${directive}|${blocked}|${page}|${source}`, Date.now()) + ) { + log.warn('csp violation', { + directive, + blocked, + page, + source, + line: Number(report['line-number']) || undefined, + }); + } + } catch { + // Not a report: nothing to log. + } + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/src/lib/__tests__/csp.test.ts b/apps/web/src/lib/__tests__/csp.test.ts new file mode 100644 index 000000000..7cb3e4991 --- /dev/null +++ b/apps/web/src/lib/__tests__/csp.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; + +// The report-only Content Security Policy (DOS/Rate#1311): a nonce per request on +// both the request (for Next.js) and the response, a script-src with no inline +// allowance, and a report endpoint that only logs. + +process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://abc.supabase.co'; + +async function main() { + const { buildContentSecurityPolicy, createCspNonce, CSP_HEADER } = + await import('../csp'); + + const a = createCspNonce(); + const b = createCspNonce(); + assert.notEqual(a, b, 'a fresh nonce per request'); + assert.match(a, /^[A-Za-z0-9+/]{22}==$/, '128 random bits, base64'); + + const policy = buildContentSecurityPolicy('n0nce', { + supabaseUrl: 'https://abc.supabase.co/rest/v1', + }); + const directive = (name: string) => + policy + .split('; ') + .find((d) => d.startsWith(`${name} `)) + ?.slice(name.length + 1); + assert.equal( + directive('script-src'), + "'self' 'nonce-n0nce' 'strict-dynamic'", + 'scripts: the nonce, never unsafe-inline or unsafe-eval in production' + ); + assert.equal(directive('default-src'), "'self'"); + assert.equal( + directive('connect-src'), + "'self' https://abc.supabase.co wss://abc.supabase.co" + ); + assert.equal(directive('object-src'), "'none'"); + assert.equal(directive('frame-ancestors'), "'self'"); + assert.equal(directive('report-uri'), '/api/csp-report'); + assert.match( + buildContentSecurityPolicy('x', { dev: true }), + /script-src 'self' 'nonce-x' 'strict-dynamic' 'unsafe-eval'/ + ); + assert.doesNotMatch( + buildContentSecurityPolicy('x', { supabaseUrl: 'javascript:alert(1)' }), + /javascript/, + 'only an http(s) Supabase URL is added' + ); + + // The proxy: the same policy on the response and on the forwarded request. + const { NextRequest } = await import('next/server'); + const { localeProxy } = await import('../../proxy'); + const res = localeProxy( + new NextRequest('https://rate.vn/smartphones', { + headers: { host: 'rate.vn', 'x-forwarded-proto': 'https' }, + }) + ); + const sent = res.headers.get(CSP_HEADER); + assert.ok(sent, 'pages carry the report-only policy'); + assert.match( + sent, + /script-src 'self' 'nonce-[A-Za-z0-9+/=]+' 'strict-dynamic'/ + ); + assert.equal( + res.headers.get(`x-middleware-request-${CSP_HEADER.toLowerCase()}`), + sent, + 'Next.js reads the same nonce from the request' + ); + assert.equal( + res.headers.get('content-security-policy'), + null, + 'nothing is enforced yet' + ); + const res2 = localeProxy( + new NextRequest('https://rate.vn/smartphones', { + headers: { host: 'rate.vn', 'x-forwarded-proto': 'https' }, + }) + ); + assert.notEqual(res2.headers.get(CSP_HEADER), sent, 'one nonce per request'); + + // The report endpoint logs a report and answers 204 whatever it gets. + const { POST } = await import('../../app/api/csp-report/route'); + const lines: string[] = []; + // The logger writes warnings to stderr. + const original = console.error; + console.error = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + try { + const ok = await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + headers: { 'content-type': 'application/csp-report' }, + body: JSON.stringify({ + 'csp-report': { + 'document-uri': 'https://rate.vn/smartphones?email=a@b.c', + 'effective-directive': 'script-src-elem', + 'blocked-uri': 'inline', + 'line-number': 3, + }, + }), + }) + ); + assert.equal(ok.status, 204); + const bad = await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + body: 'not json', + }) + ); + assert.equal(bad.status, 204); + const before = lines.length; + // The same violation again is not logged twice. + await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + body: JSON.stringify({ + 'csp-report': { + 'document-uri': 'https://rate.vn/smartphones', + 'effective-directive': 'script-src-elem', + 'blocked-uri': 'inline', + }, + }), + }) + ); + // A report without a directive, or for another site's page, is not logged. + for (const report of [ + {}, + { + 'document-uri': 'https://evil.example/x', + 'effective-directive': 'script-src-elem', + }, + ]) { + await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + body: JSON.stringify({ 'csp-report': report }), + }) + ); + } + // A streamed body with no Content-Length stops being read past 8 KB. + let pulled = 0; + const big = new ReadableStream({ + pull(controller) { + pulled += 1024; + if (pulled > 256 * 1024) controller.close(); + else controller.enqueue(new Uint8Array(1024).fill(32)); + }, + }); + const huge = await POST( + new Request('https://rate.vn/api/csp-report', { + method: 'POST', + body: big, + duplex: 'half', + } as RequestInit) + ); + assert.equal(huge.status, 204); + assert.ok(pulled <= 16 * 1024, `read ${pulled} bytes of an oversized body`); + assert.equal(lines.length, before, 'no further log line'); + } finally { + console.error = original; + } + const logged = lines.join('\n'); + assert.match(logged, /csp violation/); + assert.match(logged, /script-src-elem/); + assert.match(logged, /https:\/\/rate\.vn\/smartphones/); + assert.doesNotMatch(logged, /email=/, 'no query string is logged'); + + console.warn(' ✅ report-only CSP with a per-request nonce (#1311)'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/web/src/lib/csp.ts b/apps/web/src/lib/csp.ts new file mode 100644 index 000000000..e3358b558 --- /dev/null +++ b/apps/web/src/lib/csp.ts @@ -0,0 +1,68 @@ +/** + * Content Security Policy for the pages (DOS/Rate#1311). The proxy sends it as + * `Content-Security-Policy-Report-Only` with a fresh nonce per request, and puts + * the same header on the request so Next.js adds the nonce to its own scripts. + * Enforce it (rename the header) only after a clean report window. + * + * - `script-src`: the nonce plus `'strict-dynamic'`, so a script the nonced + * Next.js runtime loads (chunks, `/_vercel/*` analytics if added) is allowed + * and no other inline or injected script is. JSON-LD blocks + * (`type="application/ld+json"`) are data, not scripts, and need nothing. + * - Styles stay `'unsafe-inline'`: React `style` attributes and Next.js inline + * styles have no nonce. + * - Images come from any HTTPS shop CDN (as `images.remotePatterns`). + * - `connect-src` adds the Supabase project for the browser auth client. + */ +export const CSP_HEADER = 'Content-Security-Policy-Report-Only'; +export const CSP_REPORT_PATH = '/api/csp-report'; + +/** A base64 nonce from 128 random bits. */ +export function createCspNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +/** The origin of an http(s) URL, or null. */ +function originOf(url: string | undefined): string | null { + if (!url) return null; + try { + const { origin, protocol } = new URL(url); + return protocol === 'https:' || protocol === 'http:' ? origin : null; + } catch { + return null; + } +} + +export function buildContentSecurityPolicy( + nonce: string, + options: { dev?: boolean; supabaseUrl?: string } = {} +): string { + const supabase = originOf(options.supabaseUrl); + const connect = ["'self'"]; + if (supabase) { + connect.push(supabase, supabase.replace(/^http/, 'ws')); + } + if (options.dev) connect.push('ws:'); + const script = ["'self'", `'nonce-${nonce}'`, "'strict-dynamic'"]; + // React Refresh evaluates code in development. + if (options.dev) script.push("'unsafe-eval'"); + return [ + "default-src 'self'", + `script-src ${script.join(' ')}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + `connect-src ${connect.join(' ')}`, + "frame-src 'self'", + "worker-src 'self' blob:", + "manifest-src 'self'", + "frame-ancestors 'self'", + "base-uri 'self'", + "form-action 'self'", + "object-src 'none'", + `report-uri ${CSP_REPORT_PATH}`, + ].join('; '); +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index f1333d3f7..72a697774 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import createMiddleware from 'next-intl/middleware'; +import { + CSP_HEADER, + buildContentSecurityPolicy, + createCspNonce, +} from './lib/csp'; import { isDevelopment } from './lib/general-helpers'; import { routing } from './lib/navigation'; import { @@ -89,7 +94,16 @@ export function localeProxy(req: NextRequest): NextResponse { req.cookies.set('NEXT_LOCALE', routing.defaultLocale); } - return intlMiddleware(req); + // A nonce per request: on the request so Next.js puts it on its scripts (it + // reads the report-only header too), and on the response (DOS/Rate#1311). + const csp = buildContentSecurityPolicy(createCspNonce(), { + dev: isDevelopment(), + supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, + }); + req.headers.set(CSP_HEADER, csp); + const response = intlMiddleware(req); + response.headers.set(CSP_HEADER, csp); + return response; } export async function proxy(req: NextRequest): Promise { From e02bd31beaf0de6b781c984de289e56f63951f12 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:14:04 +0700 Subject: [PATCH 68/69] feat(extension): vi, en and cs through chrome.i18n (#1325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(extension): vi, en and cs through chrome.i18n Side panel, trust pill, toolbar badge and manifest text now come from src/_locales (default vi, copied to dist by the build) through a typed t() helper that falls back to the vi catalogue without chrome.i18n. Tests pin identical keys and placeholders and fail on Vietnamese left in src. Server text stays as the API returns it (DOS/Rate#1304). * fix(extension): vietnamese description within chrome's 132 characters The Vietnamese manifest description was 145 characters, over Chrome's limit; it is shortened with the same meaning, the store listing's short summary uses the same text, and the drift test checks name and description lengths in every locale and the summary against the vi catalogue. * fix(extension): count wording that reads right for any number English and Czech messages with a count no longer need a plural form, and the Czech empty-review text says recenzí as the other labels do. * test(extension): expect the count wording for reviews Co-Authored-By: Claude Opus 5.5 --- apps/extension/CHROMEWEBSTORE.md | 2 +- apps/extension/manifest.json | 7 +- apps/extension/scripts/build.mjs | 8 +- apps/extension/src/_locales/cs/messages.json | 515 ++++++++++++++++++ apps/extension/src/_locales/en/messages.json | 515 ++++++++++++++++++ apps/extension/src/_locales/vi/messages.json | 515 ++++++++++++++++++ .../src/background/service-worker.ts | 3 +- apps/extension/src/content/content-script.ts | 31 +- apps/extension/src/i18n.ts | 95 ++++ apps/extension/src/security-guards.ts | 31 +- apps/extension/src/sidepanel/panel-view.ts | 102 ++-- apps/extension/src/sidepanel/sidepanel.html | 26 +- apps/extension/src/sidepanel/sidepanel.ts | 133 +++-- apps/extension/src/test-extension-pipeline.ts | 265 ++++++++- apps/extension/src/types.ts | 4 + apps/extension/src/verdict-display.ts | 11 +- apps/extension/tsconfig.json | 1 + 17 files changed, 2097 insertions(+), 167 deletions(-) create mode 100644 apps/extension/src/_locales/cs/messages.json create mode 100644 apps/extension/src/_locales/en/messages.json create mode 100644 apps/extension/src/_locales/vi/messages.json create mode 100644 apps/extension/src/i18n.ts diff --git a/apps/extension/CHROMEWEBSTORE.md b/apps/extension/CHROMEWEBSTORE.md index da1b3c0e1..9b6df0a42 100644 --- a/apps/extension/CHROMEWEBSTORE.md +++ b/apps/extension/CHROMEWEBSTORE.md @@ -14,7 +14,7 @@ Rate.vn - So Sánh Giá, Lịch Sử Giá & Lọc Đánh Giá Ảo AI ### Short Summary (<= 132 characters) -Kiểm tra lịch sử giá 90 ngày, phát hiện giảm giá ảo, tìm nơi bán rẻ hơn và lọc đánh giá seeding bằng AI trên Shopee, Lazada, Tiki. +Xem lịch sử giá 90 ngày, phát hiện giảm giá ảo, tìm nơi bán rẻ hơn, lọc đánh giá ảo bằng AI trên 10 sàn và chuỗi bán lẻ Việt Nam. ### Detailed Description diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json index affc91f48..47399927f 100644 --- a/apps/extension/manifest.json +++ b/apps/extension/manifest.json @@ -1,16 +1,17 @@ { "manifest_version": 3, - "name": "Rate.vn - So Sánh Giá, Lịch Sử Giá & Lọc Đánh Giá Ảo AI", + "name": "__MSG_extName__", "short_name": "Rate.vn", "version": "2.0.0", - "description": "Kiểm tra lịch sử giá 90 ngày, phát hiện giảm giá ảo, tìm nơi bán rẻ hơn và lọc đánh giá seeding bằng AI trên 10 sàn TMĐT & chuỗi bán lẻ Việt Nam.", + "description": "__MSG_extDescription__", + "default_locale": "vi", "icons": { "16": "icons/icon-16.png", "48": "icons/icon-48.png", "128": "icons/icon-128.png" }, "action": { - "default_title": "Mở bảng phân tích giá & độ tin cậy Rate.vn", + "default_title": "__MSG_actionTitle__", "default_icon": { "16": "icons/icon-16.png", "48": "icons/icon-48.png", diff --git a/apps/extension/scripts/build.mjs b/apps/extension/scripts/build.mjs index bd62303b0..f8b0892fd 100644 --- a/apps/extension/scripts/build.mjs +++ b/apps/extension/scripts/build.mjs @@ -31,7 +31,13 @@ async function main() { ); } - // 3. Bundle each entry with esbuild so every output is self-contained. + // 3. Copy the chrome.i18n catalogues (manifest default_locale "vi", + // DOS/Rate#1304); Chrome reads them from _locales next to manifest.json. + fs.cpSync(path.join(srcDir, '_locales'), path.join(distDir, '_locales'), { + recursive: true, + }); + + // 4. Bundle each entry with esbuild so every output is self-contained. // The service worker and side panel load as ES modules (manifest // "type": "module",