From 8dcb0c1f3b99aff1f59fa7eb0aa8e43f51c6463b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:03:30 +0700 Subject: [PATCH 1/5] fix(proposals): review v2 rollback drops review_text_stored (#1573) * fix(proposals): review v2 rollback drops review_text_stored The review submission v2 rollback left rate.review_text_stored(text) behind. gemini found it on the DOS-Me PR (DOS/DOS.Me#1499), which carries the same fix. The replay now checks that all seven functions are gone after the rollback. Refs #1544 Co-Authored-By: Claude Opus 5.5 --- .../2026-11-01-dos-me-review-submission-v2-rollback.sql | 1 + scripts/test-review-submission-v2-proposal.mjs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Docs/Proposals/2026-11-01-dos-me-review-submission-v2-rollback.sql b/Docs/Proposals/2026-11-01-dos-me-review-submission-v2-rollback.sql index 61906277..4be9960e 100644 --- a/Docs/Proposals/2026-11-01-dos-me-review-submission-v2-rollback.sql +++ b/Docs/Proposals/2026-11-01-dos-me-review-submission-v2-rollback.sql @@ -11,6 +11,7 @@ DROP FUNCTION IF EXISTS rate.review_point_list(jsonb, text, integer, integer, te DROP FUNCTION IF EXISTS rate.review_point_is_evasive_con(text); DROP FUNCTION IF EXISTS rate.review_point_has_content(text); DROP FUNCTION IF EXISTS rate.review_text_length(text); +DROP FUNCTION IF EXISTS rate.review_text_stored(text); DROP FUNCTION IF EXISTS rate.review_point_compare(text); DROP INDEX IF EXISTS rate.review_points_position_key; DROP TABLE IF EXISTS rate.rating_criteria; diff --git a/scripts/test-review-submission-v2-proposal.mjs b/scripts/test-review-submission-v2-proposal.mjs index 391318a4..35816831 100644 --- a/scripts/test-review-submission-v2-proposal.mjs +++ b/scripts/test-review-submission-v2-proposal.mjs @@ -497,7 +497,8 @@ try { sql(rollback); assert.equal( sql(`SELECT count(*) FROM pg_proc WHERE pronamespace = 'rate'::regnamespace - AND proname IN ('submit_community_review_v2', 'review_point_list', 'review_point_compare')`), + AND proname IN ('submit_community_review_v2', 'review_point_list', 'review_point_compare', + 'review_point_has_content', 'review_point_is_evasive_con', 'review_text_length', 'review_text_stored')`), '0' ); assert.equal(sql(`SELECT to_regclass('rate.rating_criteria') IS NULL`), 't'); From a736affdcf91ae34077ef65c7c841e0414a91b84 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:05:49 +0700 Subject: [PATCH 2/5] docs(proposals): dos-me search sorts and filters on the shown rating (#1574) * docs(proposals): dos-me search sorts and filters on the shown rating Draft DOS-Me migration (not applied): rate.search_master_products orders rating_desc and filters p_min_rating on rating_score, then the weighted user rating, then the average, as the web shows them. Built on DOS/DOS.Me#1497's function; verify, rollback and a Docker replay in CI. Refs #1545 * docs(proposals): the search rating filter compares at one decimal (#1545 review) A stored 4.46 shows 4.5, so it now passes a 4.5 minimum; V5 and the replay follow. The proposal lists the web follow-ups the review found. Refs #1545 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + .gitignore | 3 + ...02-dos-me-search-rating-score-rollback.sql | 131 +++++++++++++ ...1-02-dos-me-search-rating-score-verify.sql | 79 ++++++++ .../2026-11-02-dos-me-search-rating-score.md | 100 ++++++++++ .../2026-11-02-dos-me-search-rating-score.sql | 167 +++++++++++++++++ scripts/test-search-rating-score-proposal.mjs | 172 ++++++++++++++++++ 7 files changed, 655 insertions(+) create mode 100644 Docs/Proposals/2026-11-02-dos-me-search-rating-score-rollback.sql create mode 100644 Docs/Proposals/2026-11-02-dos-me-search-rating-score-verify.sql create mode 100644 Docs/Proposals/2026-11-02-dos-me-search-rating-score.md create mode 100644 Docs/Proposals/2026-11-02-dos-me-search-rating-score.sql create mode 100644 scripts/test-search-rating-score-proposal.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7df3dfdf..87f05e54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,9 @@ jobs: - name: Replay the DOS-Me review submission v2 proposal (Docker) run: pnpm --filter @repo/shared-data build && node scripts/test-review-submission-v2-proposal.mjs + - name: Replay the DOS-Me search rating proposal (Docker) + run: node scripts/test-search-rating-score-proposal.mjs + - name: Check generated rate schema types match the migrations run: pnpm check:db-types diff --git a/.gitignore b/.gitignore index 9a949f4f..1fc7202c 100644 --- a/.gitignore +++ b/.gitignore @@ -155,6 +155,9 @@ playwright-report-item-model/ !Docs/Proposals/2026-11-01-dos-me-review-submission-v2.sql !Docs/Proposals/2026-11-01-dos-me-review-submission-v2-rollback.sql !Docs/Proposals/2026-11-01-dos-me-review-submission-v2-verify.sql +!Docs/Proposals/2026-11-02-dos-me-search-rating-score.sql +!Docs/Proposals/2026-11-02-dos-me-search-rating-score-rollback.sql +!Docs/Proposals/2026-11-02-dos-me-search-rating-score-verify.sql * - Copy* * - Bản sao* *.dump diff --git a/Docs/Proposals/2026-11-02-dos-me-search-rating-score-rollback.sql b/Docs/Proposals/2026-11-02-dos-me-search-rating-score-rollback.sql new file mode 100644 index 00000000..1d92c69c --- /dev/null +++ b/Docs/Proposals/2026-11-02-dos-me-search-rating-score-rollback.sql @@ -0,0 +1,131 @@ +-- ROLLBACK of the PROPOSAL 2026-11-02-dos-me-search-rating-score.sql (DOS/Rate#1545). NOT APPLIED. +-- +-- Puts back rate.search_master_products as DOS/DOS.Me#1497 (head 8ccfbf2e) defines it: the +-- "highest rated" order reads user_rating_weighted then average_rating, and p_min_rating falls +-- back to expert_rating. The function body below is #1497's, byte for byte. No table, index or +-- data change, so nothing is lost. Idempotent: CREATE OR REPLACE FUNCTION. + +CREATE OR REPLACE FUNCTION rate.search_master_products( + 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_ram_gb INTEGER[] DEFAULT NULL, + p_storage_gb INTEGER[] DEFAULT NULL, + p_min_battery_mah INTEGER DEFAULT NULL, + p_min_refresh_rate_hz INTEGER DEFAULT NULL, + p_min_fast_charging_w INTEGER DEFAULT NULL, + p_has_5g BOOLEAN DEFAULT NULL, + p_has_nfc BOOLEAN DEFAULT NULL, + p_max_screen_size_inch NUMERIC DEFAULT NULL, + p_sort TEXT DEFAULT 'default', + p_limit INTEGER DEFAULT 200, + p_offset INTEGER DEFAULT 0 +) +RETURNS SETOF rate.master_products +LANGUAGE plpgsql +STABLE +SECURITY INVOKER +SET search_path = '' +SET plan_cache_mode = 'force_custom_plan' +AS $$ +BEGIN + RETURN QUERY + SELECT m.* + FROM rate.master_products m + CROSS JOIN LATERAL ( + SELECT + pg_catalog.regexp_replace(COALESCE(m.specifications #>> '{display,refresh_rate_hz}', ''), '[^0-9.]', '', 'g') AS refresh_rate_hz, + pg_catalog.regexp_replace(COALESCE(m.specifications #>> '{battery,fast_charging_w}', ''), '[^0-9.]', '', 'g') AS fast_charging_w, + pg_catalog.regexp_replace(COALESCE(m.specifications #>> '{display,size_inch}', ''), '[^0-9.]', '', 'g') AS size_inch + ) spec + WHERE m.status = 'published' + AND (p_category_id IS NULL OR m.category_id = p_category_id) + AND ( + p_brands IS NULL + OR cardinality(p_brands) = 0 + OR pg_catalog.lower(pg_catalog.btrim(m.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 ( + -- Same expression as idx_master_products_search_full_trgm. + ( + COALESCE(m.brand, '') || ' ' || + COALESCE(m.name, '') || ' ' || + COALESCE(m.model, '') || ' ' || + COALESCE(m.model_key, '') || ' ' || + COALESCE(m.variant_name, '') || ' ' || + COALESCE(m.key_specs ->> 'chip', '') || ' ' || + COALESCE(m.specifications #>> '{hardware,chipset}', '') + ) 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 m.min_price >= p_min_price) + AND (p_max_price IS NULL OR p_max_price <= 0 OR m.min_price <= p_max_price) + AND ( + p_min_rating IS NULL + OR p_min_rating <= 0 + OR COALESCE( + NULLIF(m.user_rating_weighted, 0), + NULLIF(m.expert_rating, 0), + m.average_rating, + 0 + ) >= p_min_rating + ) + AND (p_ram_gb IS NULL OR cardinality(p_ram_gb) = 0 OR m.ram_gb = ANY (p_ram_gb)) + AND (p_storage_gb IS NULL OR cardinality(p_storage_gb) = 0 OR m.storage_gb = ANY (p_storage_gb)) + AND (p_min_battery_mah IS NULL OR p_min_battery_mah <= 0 OR m.battery_mah >= p_min_battery_mah) + AND ( + p_min_refresh_rate_hz IS NULL + OR p_min_refresh_rate_hz <= 0 + OR COALESCE( + CASE WHEN spec.refresh_rate_hz ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$' THEN spec.refresh_rate_hz::NUMERIC END, + 0 + ) >= p_min_refresh_rate_hz + OR COALESCE(m.key_specs ->> 'screen', '') ILIKE '%120hz%' + OR COALESCE(m.key_specs ->> 'screen', '') ILIKE '%144hz%' + ) + AND ( + p_min_fast_charging_w IS NULL + OR p_min_fast_charging_w <= 0 + OR COALESCE( + CASE WHEN spec.fast_charging_w ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$' THEN spec.fast_charging_w::NUMERIC END, + 0 + ) >= p_min_fast_charging_w + ) + AND (p_has_5g IS NULL OR p_has_5g = false OR m.has_5g = true) + AND ( + p_has_nfc IS NULL + OR p_has_nfc = false + OR (m.specifications -> 'connectivity' ->> 'nfc') = 'true' + ) + AND ( + p_max_screen_size_inch IS NULL + OR p_max_screen_size_inch <= 0 + OR COALESCE( + m.screen_size_inch, + CASE WHEN spec.size_inch ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$' THEN spec.size_inch::NUMERIC END + ) <= p_max_screen_size_inch + ) + ORDER BY + CASE WHEN p_sort = 'price_asc' THEN m.min_price END ASC NULLS LAST, + CASE WHEN p_sort = 'price_desc' THEN m.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, 200), 500)) + OFFSET GREATEST(0, COALESCE(p_offset, 0)); +END; +$$; + +GRANT EXECUTE ON FUNCTION rate.search_master_products( + UUID, TEXT[], TEXT, NUMERIC, NUMERIC, NUMERIC, INTEGER[], INTEGER[], + INTEGER, INTEGER, INTEGER, BOOLEAN, BOOLEAN, NUMERIC, TEXT, INTEGER, INTEGER +) TO anon, authenticated, service_role; diff --git a/Docs/Proposals/2026-11-02-dos-me-search-rating-score-verify.sql b/Docs/Proposals/2026-11-02-dos-me-search-rating-score-verify.sql new file mode 100644 index 00000000..533a126e --- /dev/null +++ b/Docs/Proposals/2026-11-02-dos-me-search-rating-score-verify.sql @@ -0,0 +1,79 @@ +-- VERIFY the PROPOSAL 2026-11-02-dos-me-search-rating-score.sql (DOS/Rate#1545). Read-only. +-- Run after the migration on preview, beta and production. Every check returns 0. +-- The shown rating below is the migration's expression (resolveCompareEffectiveUserRating). + +-- V1 the function is #1497's plpgsql, invoker-rights, search_path '' and custom plans; expect 0 +SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace +WHERE n.nspname = 'rate' AND p.proname = 'search_master_products' + AND NOT ( + p.prolang = (SELECT oid FROM pg_language WHERE lanname = 'plpgsql') + AND NOT p.prosecdef + AND p.provolatile = 's' + AND p.proconfig @> ARRAY['search_path=""', 'plan_cache_mode=force_custom_plan'] + ); + +-- V2 the order and the filter read rating_score and no longer read expert_rating; expect 0 +SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace +WHERE n.nspname = 'rate' AND p.proname = 'search_master_products' + AND (p.prosrc NOT LIKE '%m.rating_score%' OR p.prosrc LIKE '%expert_rating%'); + +-- V3 anon, authenticated and service_role can still call it; expect 0 +SELECT count(*) FROM unnest(ARRAY['anon', 'authenticated', 'service_role']) AS r(role) +WHERE NOT has_function_privilege( + r.role, + 'rate.search_master_products(uuid, text[], text, numeric, numeric, numeric, integer[], integer[], integer, integer, integer, boolean, boolean, numeric, text, integer, integer)', + 'EXECUTE' +); + +-- V4 "highest rated" never puts a product above one with a higher shown rating; expect 0 +SELECT count(*) FROM ( + SELECT lag(r.rating_5) OVER (ORDER BY s.ordinality) AS above, r.rating_5 AS here + FROM rate.search_master_products(p_sort => 'rating_desc', p_limit => 500) + WITH ORDINALITY AS s + CROSS JOIN LATERAL ( + SELECT COALESCE(CASE + WHEN s.rating_score > 0 AND s.rating_score <= 5 THEN s.rating_score + ELSE COALESCE( + CASE WHEN s.user_rating_weighted > 0 AND s.user_rating_weighted <= 100 THEN round(CASE + WHEN s.user_rating_weighted > 10 THEN s.user_rating_weighted / 20 + WHEN s.user_rating_weighted > 5 THEN s.user_rating_weighted / 2 + ELSE s.user_rating_weighted END, 2) END, + CASE WHEN s.average_rating > 0 AND s.average_rating <= 100 THEN round(CASE + WHEN s.average_rating > 10 THEN s.average_rating / 20 + WHEN s.average_rating > 5 THEN s.average_rating / 2 + ELSE s.average_rating END, 2) END) + END, 0) AS rating_5 + ) r +) o +WHERE o.here > o.above; + +-- V5 a minimum rating returns exactly the products whose shown rating reaches it; expect 0 +-- (the shown rating at one decimal, as the pages show it; +-- per threshold: products returned below it, plus products at or above it that are missing). +WITH shown AS ( + SELECT m.id, COALESCE(CASE + WHEN m.rating_score > 0 AND m.rating_score <= 5 THEN m.rating_score + ELSE COALESCE( + CASE WHEN m.user_rating_weighted > 0 AND m.user_rating_weighted <= 100 THEN round(CASE + WHEN m.user_rating_weighted > 10 THEN m.user_rating_weighted / 20 + WHEN m.user_rating_weighted > 5 THEN m.user_rating_weighted / 2 + ELSE m.user_rating_weighted END, 2) END, + CASE WHEN m.average_rating > 0 AND m.average_rating <= 100 THEN round(CASE + WHEN m.average_rating > 10 THEN m.average_rating / 20 + WHEN m.average_rating > 5 THEN m.average_rating / 2 + ELSE m.average_rating END, 2) END) + END, 0) AS rating_5 + FROM rate.master_products m + WHERE m.status = 'published' +), +thresholds AS (SELECT unnest(ARRAY[3.5, 4.0, 4.5, 4.8]::numeric[]) AS t), +returned AS ( + SELECT t.t, s.id + FROM thresholds t + CROSS JOIN LATERAL rate.search_master_products(p_min_rating => t.t, p_limit => 500) s +) +SELECT + (SELECT count(*) FROM returned r JOIN shown s ON s.id = r.id WHERE round(s.rating_5, 1) < r.t) + + (SELECT count(*) FROM thresholds t + WHERE (SELECT count(*) FROM returned r WHERE r.t = t.t) + < LEAST(500, (SELECT count(*) FROM shown s WHERE round(s.rating_5, 1) >= t.t))); diff --git a/Docs/Proposals/2026-11-02-dos-me-search-rating-score.md b/Docs/Proposals/2026-11-02-dos-me-search-rating-score.md new file mode 100644 index 00000000..e2b76d35 --- /dev/null +++ b/Docs/Proposals/2026-11-02-dos-me-search-rating-score.md @@ -0,0 +1,100 @@ +# DOS-Me migration proposal: catalog search sorts and filters on the shown rating + +**Not applied.** This is a follow-up of DOS/Rate#1545, which is phase P4 of the criteria ratings design (`2026-10-31-criteria-stars-pros-cons.html`, D3: one rating everywhere). JOY reviews it and applies it through DOS-Me: preview, then beta, then production. DOS/Rate runs no DDL against production. + +## Problem + +Since PR #1571, every page shows the same rating. It is `master_products.rating_score` when the product has one, and otherwise the weighted user rating, then the marketplace average (`resolveCompareEffectiveUserRating`, `apps/web/src/lib/rate-domain.ts`). + +The catalog RPC `rate.search_master_products` still uses older values for two things: + +- **The order `p_sort = 'rating_desc'`** reads `user_rating_weighted`, then `average_rating`. It ignores `rating_score`, and it does not bring a 0-10 or 0-100 average to 0-5. +- **The filter `p_min_rating`** reads `user_rating_weighted`, then `expert_rating`, then `average_rating`. No page shows `expert_rating` as the product's rating. + +So three things can disagree with the rating on screen: + +- the catalog's "Điểm đánh giá cao nhất" order; +- `?rating=` on the catalog and in `GET /api/v1/rate/products`; +- the leaderboard's top-100 load, which calls the RPC with `rating_desc`. + +## Change + +The migration is one `CREATE OR REPLACE FUNCTION`. The order and the filter read a new `shown.rating_5`, worked out exactly as the web does: + +- `rating_score`, when it is above 0 and at most 5; +- otherwise `user_rating_weighted`, then `average_rating`. Each one counts only when it is above 0 and at most 100. A value above 10 is divided by 20, and a value above 5 is divided by 2. The result is rounded to 2 decimals (`normalizeCompareFiveStarRating`); +- otherwise nothing. Such a product sorts last and never passes a minimum rating, as before. + +The order uses this rating unrounded. The filter compares it at one decimal, as every page shows it (`productRatingScore5`): a stored 4.46 shows 4.5, so it passes a 4.5 minimum. + +**Nothing else changes.** The rest is DOS/DOS.Me#1497's function (head `8ccfbf2e`), byte for byte: the signature, the return type, `SECURITY INVOKER`, `search_path = ''`, `plan_cache_mode = force_custom_plan`, the other filters, the trigram search expression, the `created_at` and `id` tiebreak, the paging and the grants. There is no table, index or data change. + +**Diff against #1497's function:** + +```diff + ) spec ++ CROSS JOIN LATERAL ( ++ SELECT CASE ++ WHEN m.rating_score > 0 AND m.rating_score <= 5 THEN m.rating_score ++ ELSE COALESCE(, ) ++ END AS rating_5 ++ ) shown + ... +- OR COALESCE( +- NULLIF(m.user_rating_weighted, 0), +- NULLIF(m.expert_rating, 0), +- m.average_rating, +- 0 +- ) >= p_min_rating ++ OR pg_catalog.round(COALESCE(shown.rating_5, 0), 1) >= p_min_rating + ... +- CASE WHEN p_sort = 'rating_desc' THEN COALESCE(NULLIF(m.user_rating_weighted, 0), m.average_rating, 0) END DESC NULLS LAST, ++ CASE WHEN p_sort = 'rating_desc' THEN COALESCE(shown.rating_5, 0) END DESC NULLS LAST, +``` + +### What changes for shoppers + +- **The rating order follows the shown rating.** A product with a stored score of 4.6 no longer ranks above one shown at 4.8 because of a higher weighted rating. +- **`?rating=4.5` returns exactly the products shown at 4.5 or more**, counting the rating at one decimal as the pages do. A product with only an expert score, and no rating on screen, no longer passes. +- **A 0-10 average no longer ranks first.** Before, an average stored as 9.0 sorted above every 0-5 rating. + +## Order (needs, in this order) + +1. **P2** (`2026-10-31-dos-me-criteria-ratings.sql`, live on production) provides `rating_score`. +2. **DOS/DOS.Me#1497** (open; `20261101170000_rate_search_master_products_trgm_index_and_numeric_guard`) rewrites the function in plpgsql and adds the trigram index. +3. **This proposal**, as a DOS-Me migration with a timestamp after `20261101170000`. + +This proposal must land **after #1497**. If it is applied first, #1497 would later put the old rating expressions back, and verification check V2 below would catch that. If #1497 changes its function before it merges, rebase this function on its final body: only the three hunks above are this proposal's. + +## Files + +- `2026-11-02-dos-me-search-rating-score.sql`: the migration. It is re-runnable. +- `-verify.sql`: read-only checks. V1 to V5 must each return 0: + - **V1:** the function is #1497's: plpgsql, invoker rights, stable, `search_path = ''`, custom plans. + - **V2:** the function reads `m.rating_score` and no longer reads `expert_rating`. + - **V3:** `anon`, `authenticated` and `service_role` can still call it. + - **V4:** in the `rating_desc` order, no product sits above one with a higher shown rating. + - **V5:** at minimum ratings of 3.5, 4.0, 4.5 and 4.8, the RPC returns exactly the published products whose shown rating, at one decimal, reaches the threshold. +- `-rollback.sql`: #1497's function, byte for byte. There is no data to restore. +- **Replay test:** `scripts/test-search-rating-score-proposal.mjs`, on Docker, in DOS/Rate CI. It runs these steps: + 1. Apply P2, then #1497's function (from the rollback file). + 2. Check the old order and filter on a small catalog: a stored score, weighted only, a 0-10 average, expert only, a zero stored score, and a draft. + 3. Check that V2, V4 and V5 flag #1497's behaviour, so the checks have teeth. + 4. Apply the proposal twice, then check the new order, the filter thresholds, the three roles, search, the draft, and V1 to V5 at 0. + 5. Roll back twice, then apply the proposal again. + +## Known limits + +- **Rounding:** the fallback rounds with PostgreSQL `round(numeric, 2)` (half away from zero), and the web uses `toFixed(2)`. They can differ by 0.01 only on a binary halfway value of a fallback rating. `rating_score` itself is not rounded before that; both sides round the result once more to one decimal for the filter, half up. +- **No index on the rating:** the shown rating is computed per row, so the rating order and filter have no index. `rate.master_products` has under 40 rows on production. If the catalog grows past a few thousand published rows, a generated column or an expression index is a later step. +- **Web follow-ups (separate web PR, not this proposal):** + - **The JS fallback path:** `getEffectiveRating` in `apps/web/src/lib/rate-api.ts` runs only when the RPC is unavailable. It still tries `expert_rating` before `average_rating`, does not bring a 0-10 value to 0-5, and accepts a `rating_score` above 5. It should use `resolveCompareEffectiveUserRating`, with the one-decimal filter. A test (`core-rate-architecture.test.ts`, "rating_desc sort must use expert_rating") pins the old behaviour. + - **The OpenAPI text** for `min_rating` and `sort` (`server/rate/openapi.ts`) could say they use `rating_score_5`, the shown rating. + - **The leaderboard engine's `userScore5`** fallback reads a value above 10 as 0, where `rate-domain.ts` divides it by 20. It only matters for edge rows, which the leaderboard re-ranks itself. + +## Rollout + +1. **Preview:** a DOS-Me PR with this migration, after #1497 has merged. Supabase Preview applies it; then run the verify file there, read-only. +2. **Beta:** the GitHub Integration applies it on merge to `dev`. Run the verify file again. +3. **Production:** a selective promote PR into `main`, after JOY's approval. Run the verify file. +4. **Rollback:** `-rollback.sql`, at any time. No data depends on it. diff --git a/Docs/Proposals/2026-11-02-dos-me-search-rating-score.sql b/Docs/Proposals/2026-11-02-dos-me-search-rating-score.sql new file mode 100644 index 00000000..d3a8888a --- /dev/null +++ b/Docs/Proposals/2026-11-02-dos-me-search-rating-score.sql @@ -0,0 +1,167 @@ +-- PROPOSAL, NOT APPLIED. DOS-Me migration for DOS/Rate#1545 (phase P4 of #1531). +-- JOY applies it through DOS-Me (preview, beta, production); DOS/Rate runs no DDL on production. +-- +-- rate.search_master_products: the catalog's "highest rated" order (p_sort = 'rating_desc') and +-- its minimum rating filter (p_min_rating) read the one rating every page shows (design D3): +-- 1. master_products.rating_score (P2, the stored Bayesian score), when it is above 0 and at +-- most 5; +-- 2. else user_rating_weighted, then average_rating, each read as the web reads them +-- (normalizeCompareFiveStarRating: a value above 0 and at most 100, a 0-10 or 0-100 scale +-- brought to 0-5, rounded to 2 decimals). +-- This is resolveCompareEffectiveUserRating in apps/web/src/lib/rate-domain.ts. Before, the order +-- read user_rating_weighted then average_rating, and the filter also fell back to expert_rating, +-- which no page shows as the product's rating; so the catalog, `?rating=` and the leaderboard's +-- top-100 load could disagree with the rating on screen. The filter compares the rating at one +-- decimal, as the pages show it (productRatingScore5); the order uses it unrounded. A product with none of the three sorts +-- last and never passes a minimum rating, as before. +-- +-- Needs, in this order: +-- * P2 (2026-10-31-dos-me-criteria-ratings.sql): the rating_score column; +-- * DOS/DOS.Me#1497 (20261101170000_rate_search_master_products_trgm_index_and_numeric_guard): +-- this function body is #1497's, head 8ccfbf2e, with only the two rating expressions changed. +-- Applied before #1497, #1497 would put the old rating expressions back. +-- Same signature, return type, SECURITY INVOKER, search_path, plan_cache_mode, filters, paging and +-- grants as #1497. No table, index or data change. Idempotent: CREATE OR REPLACE FUNCTION. + +CREATE OR REPLACE FUNCTION rate.search_master_products( + 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_ram_gb INTEGER[] DEFAULT NULL, + p_storage_gb INTEGER[] DEFAULT NULL, + p_min_battery_mah INTEGER DEFAULT NULL, + p_min_refresh_rate_hz INTEGER DEFAULT NULL, + p_min_fast_charging_w INTEGER DEFAULT NULL, + p_has_5g BOOLEAN DEFAULT NULL, + p_has_nfc BOOLEAN DEFAULT NULL, + p_max_screen_size_inch NUMERIC DEFAULT NULL, + p_sort TEXT DEFAULT 'default', + p_limit INTEGER DEFAULT 200, + p_offset INTEGER DEFAULT 0 +) +RETURNS SETOF rate.master_products +LANGUAGE plpgsql +STABLE +SECURITY INVOKER +SET search_path = '' +SET plan_cache_mode = 'force_custom_plan' +AS $$ +BEGIN + RETURN QUERY + SELECT m.* + FROM rate.master_products m + CROSS JOIN LATERAL ( + SELECT + pg_catalog.regexp_replace(COALESCE(m.specifications #>> '{display,refresh_rate_hz}', ''), '[^0-9.]', '', 'g') AS refresh_rate_hz, + pg_catalog.regexp_replace(COALESCE(m.specifications #>> '{battery,fast_charging_w}', ''), '[^0-9.]', '', 'g') AS fast_charging_w, + pg_catalog.regexp_replace(COALESCE(m.specifications #>> '{display,size_inch}', ''), '[^0-9.]', '', 'g') AS size_inch + ) spec + -- The rating the pages show (DOS/Rate#1545): rating_score, else the weighted user rating, else + -- the marketplace average, each on 0-5 as resolveCompareEffectiveUserRating reads it. NULL when + -- the product has none. + CROSS JOIN LATERAL ( + SELECT CASE + WHEN m.rating_score > 0 AND m.rating_score <= 5 THEN m.rating_score + ELSE COALESCE( + CASE WHEN m.user_rating_weighted > 0 AND m.user_rating_weighted <= 100 THEN pg_catalog.round( + CASE + WHEN m.user_rating_weighted > 10 THEN m.user_rating_weighted / 20 + WHEN m.user_rating_weighted > 5 THEN m.user_rating_weighted / 2 + ELSE m.user_rating_weighted + END, 2) END, + CASE WHEN m.average_rating > 0 AND m.average_rating <= 100 THEN pg_catalog.round( + CASE + WHEN m.average_rating > 10 THEN m.average_rating / 20 + WHEN m.average_rating > 5 THEN m.average_rating / 2 + ELSE m.average_rating + END, 2) END + ) + END AS rating_5 + ) shown + WHERE m.status = 'published' + AND (p_category_id IS NULL OR m.category_id = p_category_id) + AND ( + p_brands IS NULL + OR cardinality(p_brands) = 0 + OR pg_catalog.lower(pg_catalog.btrim(m.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 ( + -- Same expression as idx_master_products_search_full_trgm. + ( + COALESCE(m.brand, '') || ' ' || + COALESCE(m.name, '') || ' ' || + COALESCE(m.model, '') || ' ' || + COALESCE(m.model_key, '') || ' ' || + COALESCE(m.variant_name, '') || ' ' || + COALESCE(m.key_specs ->> 'chip', '') || ' ' || + COALESCE(m.specifications #>> '{hardware,chipset}', '') + ) 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 m.min_price >= p_min_price) + AND (p_max_price IS NULL OR p_max_price <= 0 OR m.min_price <= p_max_price) + AND ( + p_min_rating IS NULL + OR p_min_rating <= 0 + -- At one decimal, as the pages show it: 4.46 shows 4.5 and passes 4.5. + OR pg_catalog.round(COALESCE(shown.rating_5, 0), 1) >= p_min_rating + ) + AND (p_ram_gb IS NULL OR cardinality(p_ram_gb) = 0 OR m.ram_gb = ANY (p_ram_gb)) + AND (p_storage_gb IS NULL OR cardinality(p_storage_gb) = 0 OR m.storage_gb = ANY (p_storage_gb)) + AND (p_min_battery_mah IS NULL OR p_min_battery_mah <= 0 OR m.battery_mah >= p_min_battery_mah) + AND ( + p_min_refresh_rate_hz IS NULL + OR p_min_refresh_rate_hz <= 0 + OR COALESCE( + CASE WHEN spec.refresh_rate_hz ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$' THEN spec.refresh_rate_hz::NUMERIC END, + 0 + ) >= p_min_refresh_rate_hz + OR COALESCE(m.key_specs ->> 'screen', '') ILIKE '%120hz%' + OR COALESCE(m.key_specs ->> 'screen', '') ILIKE '%144hz%' + ) + AND ( + p_min_fast_charging_w IS NULL + OR p_min_fast_charging_w <= 0 + OR COALESCE( + CASE WHEN spec.fast_charging_w ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$' THEN spec.fast_charging_w::NUMERIC END, + 0 + ) >= p_min_fast_charging_w + ) + AND (p_has_5g IS NULL OR p_has_5g = false OR m.has_5g = true) + AND ( + p_has_nfc IS NULL + OR p_has_nfc = false + OR (m.specifications -> 'connectivity' ->> 'nfc') = 'true' + ) + AND ( + p_max_screen_size_inch IS NULL + OR p_max_screen_size_inch <= 0 + OR COALESCE( + m.screen_size_inch, + CASE WHEN spec.size_inch ~ '^([0-9]+\.?[0-9]*|\.[0-9]+)$' THEN spec.size_inch::NUMERIC END + ) <= p_max_screen_size_inch + ) + ORDER BY + CASE WHEN p_sort = 'price_asc' THEN m.min_price END ASC NULLS LAST, + CASE WHEN p_sort = 'price_desc' THEN m.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(shown.rating_5, 0) END DESC NULLS LAST, + m.created_at DESC, + m.id ASC + LIMIT GREATEST(1, LEAST(COALESCE(p_limit, 200), 500)) + OFFSET GREATEST(0, COALESCE(p_offset, 0)); +END; +$$; + +GRANT EXECUTE ON FUNCTION rate.search_master_products( + UUID, TEXT[], TEXT, NUMERIC, NUMERIC, NUMERIC, INTEGER[], INTEGER[], + INTEGER, INTEGER, INTEGER, BOOLEAN, BOOLEAN, NUMERIC, TEXT, INTEGER, INTEGER +) TO anon, authenticated, service_role; diff --git a/scripts/test-search-rating-score-proposal.mjs b/scripts/test-search-rating-score-proposal.mjs new file mode 100644 index 00000000..4cafa6a2 --- /dev/null +++ b/scripts/test-search-rating-score-proposal.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// Replays the DOS-Me search rating PROPOSAL (DOS/Rate#1545) on the migration replay stack: P2 +// (rating_score), then DOS/DOS.Me#1497's function (the proposal's rollback file is #1497's body), +// the verification showing the old order and filter disagree with the shown rating, the proposal +// twice, the new order, filter and grants, every verification query, the rollback twice and the +// proposal again. Nothing touches a real database. +// +// Usage: node scripts/test-search-rating-score-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 p2 = read('2026-10-31-dos-me-criteria-ratings.sql'); +const proposal = read('2026-11-02-dos-me-search-rating-score.sql'); +const rollback = read('2026-11-02-dos-me-search-rating-score-rollback.sql'); +const verify = read('2026-11-02-dos-me-search-rating-score-verify.sql'); + +const container = `rate-search-rating-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 checks = [ + ...verify.matchAll( + /^-- (V\d+) [^\n]*expect 0\n(?:--[^\n]*\n)*((?:SELECT|WITH)[\s\S]*?;)/gm + ), +].map(([, id, query]) => ({ id, query })); +assert.deepEqual( + checks.map((c) => c.id), + ['V1', 'V2', 'V3', 'V4', 'V5'], + 'the verification lists its expect-0 queries' +); + +const PHONES = 'c0000000-0000-4000-8000-000000000001'; +// name: [rating_score, user_rating_weighted, average_rating, expert_rating, status] +const PRODUCTS = { + a: ['4.814', '4.2', null, null, 'published'], // shown 4.814 (the stored score wins) + b: [null, '4.9', '4.1', null, 'published'], // shown 4.9 (weighted) + c: [null, '0', '9.0', '4.9', 'published'], // shown 4.5 (a 0-10 average) + d: [null, null, null, '4.95', 'published'], // no shown rating: expert only + e: ['4.6', '4.95', null, null, 'published'], // shown 4.6; the old order put it first + f: ['0', '4.0', null, null, 'published'], // a zero score falls back: shown 4.0 + g: ['5', '5', '5', '5', 'draft'], // never listed + h: ['4.46', null, null, null, 'published'], // shows 4.5: passes a 4.5 minimum + // user_rating_weighted has a 0-5 CHECK, so only average_rating can need scaling. +}; +const id = (name) => + `a0000000-0000-4000-8000-0000000000${name.charCodeAt(0).toString(16)}`; +const nameOf = Object.fromEntries( + Object.keys(PRODUCTS).map((name) => [id(name), name]) +); + +try { + const { sql, asRole } = startRateDb({ name: container }); + const verifyResults = () => + Object.fromEntries(checks.map(({ id: v, query }) => [v, sql(query)])); + const checkAll = (when) => { + for (const [v, result] of Object.entries(verifyResults())) + assert.equal(result, '0', `${v} ${when}`); + }; + const listed = (args, role = 'anon') => + asRole( + role, + `SELECT string_agg(s.id::text, ',' ORDER BY s.ordinality) + FROM rate.search_master_products(${args}) WITH ORDINALITY AS s` + ) + .split('\n') + .filter(Boolean) + .join('') + .split(',') + .filter(Boolean) + .map((x) => nameOf[x]) + .join(''); + + sql(p2); + sql(` + INSERT INTO rate.categories (id, name, slug) VALUES ('${PHONES}', 'Điện thoại', 'smartphones'); + INSERT INTO rate.master_products (id, brand, model, model_key, name, slug, category_id, status, + rating_score, user_rating_weighted, average_rating, expert_rating) + VALUES ${Object.entries(PRODUCTS) + .map( + ([name, [score, weighted, average, expert, status]]) => + `('${id(name)}', 'Acme', 'P${name}', 'acme:p${name}', 'Acme P${name}', 'acme-p${name}', + '${PHONES}', '${status}', ${score ?? 'NULL'}, ${weighted ?? 'NULL'}, + ${average ?? 'NULL'}, ${expert ?? 'NULL'})` + ) + .join(',\n')}; + `); + + // DOS/DOS.Me#1497 first: the rollback file is its function, byte for byte. + sql(rollback); + const rowsOf = (sqlArgs) => + `p_sort => 'rating_desc', p_limit => 50${sqlArgs}`; + assert.equal( + listed(rowsOf('')), + 'cebafdh', + "#1497 orders by user_rating_weighted, then average_rating, unscaled (c's 9/10 first)" + ); + assert.equal( + listed(rowsOf(', p_min_rating => 4.9')), + 'cebd', + "#1497's filter lets an expert-only product through" + ); + const before = verifyResults(); + assert.equal(before.V1, '0', 'V1 holds for #1497'); + assert.equal(before.V3, '0', 'V3 holds for #1497'); + for (const v of ['V2', 'V4', 'V5']) + assert.notEqual(before[v], '0', `${v} catches #1497's rating`); + + const checkProposal = (when) => { + // 4.9, 4.814, 4.6, 4.5, 4.0, then the product with no shown rating. + assert.equal(listed(rowsOf('')), 'baechfd', `rating order ${when}`); + assert.equal( + listed(rowsOf(''), 'authenticated'), + 'baechfd', + `authenticated ${when}` + ); + assert.equal(listed(rowsOf(''), 'service_role'), 'baechfd'); + assert.equal( + listed(rowsOf(', p_min_rating => 4.6')), + 'bae', + `minimum 4.6 ${when}` + ); + assert.equal( + listed(rowsOf(', p_min_rating => 4.5')), + 'baech', + `minimum 4.5 keeps 4.5 and 4.46 (shown 4.5) ${when}` + ); + assert.equal( + listed(rowsOf(', p_min_rating => 4.95')), + '', + `an expert-only product has no shown rating ${when}` + ); + // Other sorts and filters are #1497's. + assert.equal( + listed(`p_sort => 'default', p_limit => 50`).length, + 7, + `the draft stays out ${when}` + ); + assert.equal(listed(`p_search => 'Acme Pc'`), 'c', `search ${when}`); + checkAll(when); + }; + + sql(proposal); + sql(proposal); + checkProposal('after the proposal (applied twice)'); + + sql(rollback); + sql(rollback); + assert.equal(listed(rowsOf('')), 'cebafdh', 'rollback restores #1497'); + assert.notEqual(verifyResults().V2, '0', 'V2 after the rollback'); + + sql(proposal); + checkProposal('after the proposal again'); + + console.log( + 'search rating proposal: #1497, apply, order, filter, grants, verify and rollback replayed' + ); +} finally { + removeContainer(); +} From 809d7174d298ea2163fbc0fe24096b628e8d8ee9 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:20:41 +0700 Subject: [PATCH 3/5] fix(web): rating fallbacks follow the shown rating (#1575) * fix(web): rating fallbacks follow the shown rating (#1545) - The catalog's non-RPC path sorts and filters on resolveCompareEffectiveUserRating, not expert_rating, and compares a minimum rating at one decimal as pages show it. - The leaderboard engine's user score uses the same helper, so a 0-100 average is 4.5, not 0, and a zero weighted rating falls back to the average. - The rating helpers move to rate-domain-core (re-exported from rate-domain), so the engine imports them without a cycle. - OpenAPI says minRating and rating_desc use rating_score_5. Refs #1545 * fix(web): keep the OpenAPI rating text until the search RPC migration (#1545 review) The live search_master_products still filters and sorts on the older ratings; the docs change when the DOS-Me migration from #1574 is applied. Refs #1545 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/leaderboard-criteria.test.ts | 25 ++++++++ apps/web/src/lib/leaderboard-engine.ts | 17 ++--- apps/web/src/lib/rate-api.ts | 24 ++++--- apps/web/src/lib/rate-domain-core.ts | 57 +++++++++++++++++ apps/web/src/lib/rate-domain.ts | 64 +++---------------- .../__tests__/core-rate-architecture.test.ts | 21 ++++-- 6 files changed, 123 insertions(+), 85 deletions(-) diff --git a/apps/web/src/lib/__tests__/leaderboard-criteria.test.ts b/apps/web/src/lib/__tests__/leaderboard-criteria.test.ts index 6b8dbe34..e9987a70 100644 --- a/apps/web/src/lib/__tests__/leaderboard-criteria.test.ts +++ b/apps/web/src/lib/__tests__/leaderboard-criteria.test.ts @@ -128,4 +128,29 @@ assert.deepEqual( ['battery'] ); +// The user score is the rating the pages show (resolveCompareEffectiveUserRating, +// DOS/Rate#1545): the stored score first, else the weighted rating, else the +// average, each brought to 0-5 the same way. +const withRatings = (ratings: Record) => + scoreMasterProductForLeaderboard({ + ...phone('ratings', 0), + ...ratings, + } as unknown as MasterProduct).userScore5; +assert.equal( + withRatings({ rating_score: 4.814, user_rating_weighted: 4.2 }), + 4.814 +); +assert.equal(withRatings({ rating_score: 0, user_rating_weighted: 4.3 }), 4.3); +// A zero weighted rating falls back to the average, then scaled: 9/10 is 4.5. +assert.equal(withRatings({ user_rating_weighted: 0, average_rating: 9 }), 4.5); +// A 0-100 average is 4.5 too; the engine used to read anything above 10 as 0. +assert.equal( + withRatings({ user_rating_weighted: null, average_rating: 90 }), + 4.5 +); +assert.equal( + withRatings({ user_rating_weighted: null, average_rating: null }), + 0 +); + console.log('leaderboard-criteria: criterion scores are real or null'); diff --git a/apps/web/src/lib/leaderboard-engine.ts b/apps/web/src/lib/leaderboard-engine.ts index a97ef4dd..90f812be 100644 --- a/apps/web/src/lib/leaderboard-engine.ts +++ b/apps/web/src/lib/leaderboard-engine.ts @@ -2,6 +2,7 @@ import { getCategoryRouteHref, getMasterProductCategorySlug, getMasterProductHref, + resolveCompareEffectiveUserRating, resolveProductImageUrl, } from './rate-domain-core'; import { catalogRowSlug, type ItemCatalogRow } from './item-directory'; @@ -410,18 +411,10 @@ export function scoreMasterProductForLeaderboard( images: safeItem.images, }) || '/img/devices/apple-iphone-16-pro-max-256gb.jpg'; - // The stored Bayesian rating, kept unrounded so a page rounds once (D3). - // A product without one falls back to the older ratings. - const storedRating = toSafePositiveNumber(safeItem.rating_score); - const rawUser = - toSafePositiveNumber(safeItem.user_rating_weighted) || - toSafePositiveNumber(safeItem.average_rating); - const userScore5 = - storedRating > 0 && storedRating <= 5 - ? storedRating - : rawUser > 0 && rawUser <= 10 - ? clampScore5(rawUser > 5 ? rawUser / 2 : rawUser) - : 0; + // The rating every page shows (D3): the stored Bayesian score, kept + // unrounded so a page rounds once, else the weighted user rating, then the + // marketplace average, each read as the pages read them (DOS/Rate#1545). + const userScore5 = resolveCompareEffectiveUserRating(safeItem); const rawExpert = toSafePositiveNumber(safeItem.expert_rating); const expertScore10: number | null = diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index 16ac5ea9..aceebf60 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -37,6 +37,7 @@ import { parseEcommerceUrlOrQuery, extractProductKeywordFromUrl, firstImageUrl, + resolveCompareEffectiveUserRating, } from './rate-domain-core'; import { isOfferUnavailable, @@ -669,22 +670,19 @@ export async function getMasterProducts( }); } - // The stored rating first (D3), then the weighted user rating; an - // unrated product sorts by its expert score, then the marketplace - // average (DOS/Rate#510). + // The rating every page shows (D3), as search_master_products reads it + // (DOS/Rate#1545): the stored score, else the weighted user rating, else + // the marketplace average. The expert score is shown apart, never as it. const getEffectiveRating = (item: MasterProduct): number => - Number(item.rating_score) > 0 - ? Number(item.rating_score) - : Number(item.user_rating_weighted) > 0 - ? Number(item.user_rating_weighted) - : Number(item.expert_rating) > 0 - ? Number(item.expert_rating) - : Number(item.average_rating || 0); - - // Minimum effective rating filter + resolveCompareEffectiveUserRating(item); + + // Minimum rating, compared at one decimal as the pages show it: a stored + // 4.46 shows 4.5 and passes 4.5. if (typeof params?.minRating === 'number' && params.minRating > 0) { const minRating = params.minRating; - items = items.filter((item) => getEffectiveRating(item) >= minRating); + items = items.filter( + (item) => Math.round(getEffectiveRating(item) * 10) / 10 >= minRating + ); } // Battery: capacity_mah >= minBatteryMah (top-level -> specifications.battery.capacity_mah -> key_specs.battery " mAh") diff --git a/apps/web/src/lib/rate-domain-core.ts b/apps/web/src/lib/rate-domain-core.ts index 9c25dedc..a22847c3 100644 --- a/apps/web/src/lib/rate-domain-core.ts +++ b/apps/web/src/lib/rate-domain-core.ts @@ -6,6 +6,63 @@ */ import type { MasterProduct } from '@repo/shared-data'; +/** A finite number from a number or a numeric string, else NaN. */ +export function parseFiniteNumericSpec(raw: unknown): number { + if (typeof raw === 'number') { + return Number.isFinite(raw) ? raw : NaN; + } + if (typeof raw === 'string') { + const cleaned = raw.replace(/\p{Cc}+/gu, '').trim(); + if (!cleaned || cleaned.length > 32 || /^[+-]?0[xbo]/i.test(cleaned)) { + return NaN; + } + return Number(cleaned.replace(',', '.')); + } + return NaN; +} + +export function normalizeCompareFiveStarRating( + raw: number | string | null | undefined +): number { + const num = parseFiniteNumericSpec(raw); + if (!Number.isFinite(num) || num <= 0 || num > 100) { + return 0; + } + const scaled = num > 10 ? num / 20 : num > 5 ? num / 2 : num; + if (scaled <= 0 || scaled > 5) return 0; + return Number(scaled.toFixed(2)); +} + +/** + * The stored Bayesian rating (`rating_score`, DOS/Rate#1545), out of 5 and + * unrounded: it is stored at 3 decimals so a page rounds once, to 1. Zero + * when there is none. + */ +export function resolveStoredRatingScore( + p: Partial> | null | undefined +): number { + if (!p || typeof p !== 'object' || Array.isArray(p)) return 0; + const num = parseFiniteNumericSpec(p.rating_score); + return Number.isFinite(num) && num > 0 && num <= 5 ? num : 0; +} + +/** + * The one rating every page shows (design #1531 D3): the stored Bayesian + * `rating_score`. A product without one falls back to the weighted user + * rating, then the marketplace average, as before the column existed. + */ +export function resolveCompareEffectiveUserRating( + p: Pick & + Partial> +): number { + if (!p || typeof p !== 'object' || Array.isArray(p)) return 0; + const stored = resolveStoredRatingScore(p); + if (stored > 0) return stored; + const weighted = normalizeCompareFiveStarRating(p.user_rating_weighted); + if (weighted > 0) return weighted; + return normalizeCompareFiveStarRating(p.average_rating); +} + export type ReviewFlagCategory = | 'authentic' | 'low_value_coin_spam' diff --git a/apps/web/src/lib/rate-domain.ts b/apps/web/src/lib/rate-domain.ts index cd934212..70342826 100644 --- a/apps/web/src/lib/rate-domain.ts +++ b/apps/web/src/lib/rate-domain.ts @@ -25,6 +25,14 @@ export { resolveProductImageUrl, sanitizePostgrestFilterToken, stripEcommerceNoiseTokens, + normalizeCompareFiveStarRating, + resolveCompareEffectiveUserRating, + resolveStoredRatingScore, +} from './rate-domain-core'; +import { + normalizeCompareFiveStarRating, + parseFiniteNumericSpec, + resolveCompareEffectiveUserRating, } from './rate-domain-core'; export type { BayesianConfidenceTier, @@ -86,20 +94,6 @@ import type { ProductSpecifications, } from '@repo/shared-data'; -function parseFiniteNumericSpec(raw: unknown): number { - if (typeof raw === 'number') { - return Number.isFinite(raw) ? raw : NaN; - } - if (typeof raw === 'string') { - const cleaned = raw.replace(/\p{Cc}+/gu, '').trim(); - if (!cleaned || cleaned.length > 32 || /^[+-]?0[xbo]/i.test(cleaned)) { - return NaN; - } - return Number(cleaned.replace(',', '.')); - } - return NaN; -} - function toBoundedKeySpecText(value: unknown, maxLen = 256): string { if (typeof value === 'number') { return Number.isFinite(value) ? String(value) : ''; @@ -112,48 +106,6 @@ function toBoundedKeySpecText(value: unknown, maxLen = 256): string { .slice(0, maxLen); } -export function normalizeCompareFiveStarRating( - raw: number | string | null | undefined -): number { - const num = parseFiniteNumericSpec(raw); - if (!Number.isFinite(num) || num <= 0 || num > 100) { - return 0; - } - const scaled = num > 10 ? num / 20 : num > 5 ? num / 2 : num; - if (scaled <= 0 || scaled > 5) return 0; - return Number(scaled.toFixed(2)); -} - -/** - * The stored Bayesian rating (`rating_score`, DOS/Rate#1545), out of 5 and - * unrounded: it is stored at 3 decimals so a page rounds once, to 1. Zero - * when there is none. - */ -export function resolveStoredRatingScore( - p: Partial> | null | undefined -): number { - if (!p || typeof p !== 'object' || Array.isArray(p)) return 0; - const num = parseFiniteNumericSpec(p.rating_score); - return Number.isFinite(num) && num > 0 && num <= 5 ? num : 0; -} - -/** - * The one rating every page shows (design #1531 D3): the stored Bayesian - * `rating_score`. A product without one falls back to the weighted user - * rating, then the marketplace average, as before the column existed. - */ -export function resolveCompareEffectiveUserRating( - p: Pick & - Partial> -): number { - if (!p || typeof p !== 'object' || Array.isArray(p)) return 0; - const stored = resolveStoredRatingScore(p); - if (stored > 0) return stored; - const weighted = normalizeCompareFiveStarRating(p.user_rating_weighted); - if (weighted > 0) return weighted; - return normalizeCompareFiveStarRating(p.average_rating); -} - /** * The one rating as every page shows it, for the API's `rating_score_5`: * one decimal, rounded once, or null when the product has none (D3). 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 0aba2cf2..e31221d6 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 @@ -6106,10 +6106,23 @@ async function runCoreArchitectureSuite() { sort: 'rating_desc', }); assert.equal(sorted510.length, 2); - assert.equal( - sorted510[0]?.id, - 'mp-510-1', - 'rating_desc sort must use expert_rating (4.9) when user_rating_weighted is null (#510)' + // The shown rating orders the list: 4.6 (weighted) above 4.2 (the + // average); the expert score (4.9) is shown apart (DOS/Rate#1545). + assert.deepEqual( + sorted510.map((p) => p.id), + ['mp-510-2', 'mp-510-1'], + 'rating_desc sorts by the shown rating, not by expert_rating (#1545)' + ); + masterPageFetched = false; + const rated510 = await rateApi510.getMasterProducts({ + categorySlug: 'laptops', + brands: ['asus'], + minRating: 4.5, + }); + assert.deepEqual( + rated510.map((p) => p.id), + ['mp-510-2'], + 'minRating compares the shown rating; an expert 4.9 does not pass (#1545)' ); } finally { globalThis.fetch = origFetch510; From a6c095187992936fb1cd231ef8a87185556166ad Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:36:50 +0700 Subject: [PATCH 4/5] chore(db): mirror the p3 review submission v2 migration from dos-me (#1576) * chore(db): mirror the p3 review submission v2 migration from dos-me DOS/DOS.Me#1499 is applied to production (promote DOS/DOS.Me#1500, 96237029). Mirror its migration byte for byte into supabase/migrations and regenerate packages/shared-data/src/database.types.ts, so the web gets the types of rate.submit_community_review_v2, rate.rating_criteria and the line helpers. Nothing is applied by this change. Refs #1544 Co-Authored-By: Claude Opus 5.5 --- packages/shared-data/src/database.types.ts | 60 ++ ...261102090000_rate_review_submission_v2.sql | 576 ++++++++++++++++++ 2 files changed, 636 insertions(+) create mode 100644 supabase/migrations/20261102090000_rate_review_submission_v2.sql diff --git a/packages/shared-data/src/database.types.ts b/packages/shared-data/src/database.types.ts index 20d5e5c6..32c0907d 100644 --- a/packages/shared-data/src/database.types.ts +++ b/packages/shared-data/src/database.types.ts @@ -750,6 +750,33 @@ export type Database = { }, ]; }; + rating_criteria: { + Row: { + category_slug: string; + criteria_version: number; + key: string; + position: number; + required: boolean; + weight: number; + }; + Insert: { + category_slug: string; + criteria_version: number; + key: string; + position: number; + required?: boolean; + weight: number; + }; + Update: { + category_slug?: string; + criteria_version?: number; + key?: string; + position?: number; + required?: boolean; + weight?: number; + }; + Relationships: []; + }; review_analysis: { Row: { ai_summary: string | null; @@ -1348,6 +1375,24 @@ export type Database = { Args: { p_author: string; p_comment: string; p_rating: number }; Returns: string; }; + review_point_compare: { Args: { p_text: string }; Returns: string }; + review_point_has_content: { Args: { p_text: string }; Returns: boolean }; + review_point_is_evasive_con: { + Args: { p_text: string }; + Returns: boolean; + }; + review_point_list: { + Args: { + p_field: string; + p_kind: string; + p_lines: Json; + p_max: number; + p_min: number; + }; + Returns: string[]; + }; + review_text_length: { Args: { p_text: string }; Returns: number }; + review_text_stored: { Args: { p_text: string }; Returns: string }; search_catalog_autocomplete: { Args: { p_limit?: number; p_query: string }; Returns: { @@ -1454,6 +1499,21 @@ export type Database = { }; Returns: Json; }; + submit_community_review_v2: { + Args: { + p_author_name?: string; + p_comment?: string; + p_cons: Json; + p_criteria_ratings: Json; + p_criterion_points?: Json; + p_master_product_id: string; + p_pros: Json; + p_reviewer_type?: string; + p_variation?: string; + p_verdict: string; + }; + Returns: Json; + }; upsert_price_alert: { Args: { p_client_fingerprint: string; diff --git a/supabase/migrations/20261102090000_rate_review_submission_v2.sql b/supabase/migrations/20261102090000_rate_review_submission_v2.sql new file mode 100644 index 00000000..3fcc32f8 --- /dev/null +++ b/supabase/migrations/20261102090000_rate_review_submission_v2.sql @@ -0,0 +1,576 @@ +-- ============================================================================= +-- Migration: 20261102090000_rate_review_submission_v2.sql +-- Purpose (DOS/Rate#1544): phase P3 of the DOS/Rate criteria ratings design +-- (DOS/Rate#1531, Docs/Proposals/2026-10-31-criteria-stars-pros-cons.html, sections 2 and +-- 4.2; decisions D1 and D5 approved by JOY on 2026-10-31). It needs P2 +-- (20261101120000_rate_criteria_ratings.sql). Copied unchanged from the reviewed DOS/Rate +-- proposal Docs/Proposals/2026-11-01-dos-me-review-submission-v2.sql (DOS/Rate#1568) apart +-- from this header. One transaction. +-- Verify: scripts/ops/rate-review-submission-v2-verify.sql (V1 to V7 return 0). +-- Rollback: scripts/ops/rate-review-submission-v2-rollback.sql. +-- ============================================================================= +-- +-- What it adds: +-- rate.rating_criteria the criteria of each category with weights, versioned: +-- version 1 equals packages/shared-data/src/rules/ +-- rating-criteria.ts (the replay test keeps them equal) +-- rate.review_point_compare(text) the compared form of a line (shared-data comparablePoint) +-- rate.review_point_has_content(text) at least three letters or digits, two of them different +-- rate.review_point_is_evasive_con(text) a con that only says there is none (shared-data +-- isEvasiveCon, the same pattern text) +-- rate.review_text_stored(text) a line as stored (shared-data storedPoint) +-- rate.review_text_length(text) its length in characters as the writer sees them +-- rate.submit_community_review_v2(...) a Rate review under D5: criterion stars (half steps, +-- the required criterion, at most 2 left out), the review +-- star as their weighted average at 0.1, a verdict of 20 to +-- 300 characters, 1 to 5 pros and cons of 5 to 150 +-- characters, up to 3 pros and 3 cons per criterion. It +-- stores rating_exact, verdict, criteria_version and one +-- rate.review_points row per line, in one transaction. +-- review_points_position_key one row per review, criterion, kind and position +-- +-- rate.submit_community_review (v1) is kept unchanged, so the current web form keeps working +-- until P3's web side calls v2; a later migration revokes v1. v2 has its own name rather than an +-- overload: PostgREST cannot choose between two functions of one name by their arguments. +-- +-- Same gates as v1 (20261018153000_rate_row_provenance.sql): a signed-in, non-anonymous user or +-- the service role; a crawled listing of the master; one review per user and master; 5 reviews +-- per user in 24 hours; keyword rules that exclude, never include; the review starts excluded +-- from the rating until the analyzer decides. Errors are in Vietnamese with SQLSTATE 22023 and +-- the field in DETAIL ('verdict', 'pros', 'pros.2', 'criteria.battery', +-- 'criterion_points.battery.cons.0'), so the API can point at the field. +-- +-- Idempotent: IF NOT EXISTS, ON CONFLICT DO NOTHING for the version 1 rows, CREATE OR REPLACE for +-- functions. One transaction. + +BEGIN; + +-- 1. rate.rating_criteria ----------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS rate.rating_criteria ( + criteria_version integer NOT NULL CHECK (criteria_version >= 1), + category_slug text NOT NULL CHECK (category_slug ~ '^[a-z_-]{2,64}$'), + key text NOT NULL CHECK (key ~ '^[a-z_]{2,32}$'), + weight smallint NOT NULL CHECK (weight BETWEEN 1 AND 100), + required boolean NOT NULL DEFAULT false, + position smallint NOT NULL CHECK (position BETWEEN 0 AND 19), + PRIMARY KEY (criteria_version, category_slug, key) +); + +ALTER TABLE rate.rating_criteria ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "Public can view rating_criteria" ON rate.rating_criteria; +CREATE POLICY "Public can view rating_criteria" ON rate.rating_criteria FOR SELECT USING (true); +GRANT SELECT ON rate.rating_criteria TO anon, authenticated, rate_worker; +GRANT ALL ON rate.rating_criteria TO service_role; + +-- A version never changes once written: a new list is a new version (RATING_CRITERIA_VERSION). +INSERT INTO rate.rating_criteria (criteria_version, category_slug, key, weight, required, position) +VALUES + (1, 'smartphones', 'performance', 20, false, 0), + (1, 'smartphones', 'camera', 20, false, 1), + (1, 'smartphones', 'battery', 20, false, 2), + (1, 'smartphones', 'display', 15, false, 3), + (1, 'smartphones', 'design', 10, false, 4), + (1, 'smartphones', 'value', 15, true, 5), + (1, 'tablets', 'display', 20, false, 0), + (1, 'tablets', 'performance', 20, false, 1), + (1, 'tablets', 'battery', 15, false, 2), + (1, 'tablets', 'sound', 10, false, 3), + (1, 'tablets', 'software', 10, false, 4), + (1, 'tablets', 'design', 10, false, 5), + (1, 'tablets', 'value', 15, true, 6), + (1, 'laptops', 'performance', 25, false, 0), + (1, 'laptops', 'display', 15, false, 1), + (1, 'laptops', 'battery', 15, false, 2), + (1, 'laptops', 'keyboard', 10, false, 3), + (1, 'laptops', 'thermals', 10, false, 4), + (1, 'laptops', 'design', 10, false, 5), + (1, 'laptops', 'value', 15, true, 6), + (1, 'smartwatches', 'health', 25, false, 0), + (1, 'smartwatches', 'battery', 20, false, 1), + (1, 'smartwatches', 'design', 15, false, 2), + (1, 'smartwatches', 'apps', 15, false, 3), + (1, 'smartwatches', 'display', 10, false, 4), + (1, 'smartwatches', 'value', 15, true, 5), + (1, 'earbuds', 'sound', 30, false, 0), + (1, 'earbuds', 'anc', 15, false, 1), + (1, 'earbuds', 'battery', 15, false, 2), + (1, 'earbuds', 'comfort', 15, false, 3), + (1, 'earbuds', 'calls', 10, false, 4), + (1, 'earbuds', 'value', 15, true, 5), + (1, 'services', 'service_quality', 35, false, 0), + (1, 'services', 'price', 25, true, 1), + (1, 'services', 'attitude', 20, false, 2), + (1, 'services', 'speed', 20, false, 3) +ON CONFLICT (criteria_version, category_slug, key) DO NOTHING; + +-- 2. One row per review, criterion, kind and position ------------------------------------------ + +CREATE UNIQUE INDEX IF NOT EXISTS review_points_position_key + ON rate.review_points (review_id, COALESCE(criterion_key, ''), kind, position); + +-- 3. Line helpers (packages/shared-data/src/rules/review-form.ts) ------------------------------ + +-- Every helper reads text under the built-in pg_c_utf8 collation (PostgreSQL 17), so lower case +-- and letter classes follow Unicode whatever the database's own locale is. The invisible set is +-- review-form.ts INVISIBLE: format characters (zero-width, bidi marks, flag tags), emoji +-- variation selectors and the keycap mark; the replay test checks every such code point. + +-- storedPoint: canonical composition, no invisible characters, trimmed. A line is stored in this +-- form and its length counted on it, so it always fits the review_points length check. +CREATE OR REPLACE FUNCTION rate.review_text_stored(p_text text) +RETURNS text +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +SET search_path = '' +AS $$ + SELECT pg_catalog.regexp_replace( + pg_catalog.regexp_replace(normalize(COALESCE(p_text, ''), NFC), + '[­؀-؅؜۝܏࢐-࢑࣢᠎​-‏‪-‮⁠-⁤⁦-⃣︀-️-\U000110BD\U000110CD\U00013430-\U0001343F\U0001BCA0-\U0001BCA3\U0001D173-\U0001D17A\U000E0001\U000E0020-\U000E007F\U000E0100-\U000E01EF]', + '', 'g') COLLATE pg_catalog.pg_c_utf8, + '^[[:space:]]+|[[:space:]]+$', '', 'g') +$$; + +-- Length in characters as the writer sees them: code points of the stored form. +CREATE OR REPLACE FUNCTION rate.review_text_length(p_text text) +RETURNS integer +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +SET search_path = '' +AS $$ + SELECT char_length(rate.review_text_stored(p_text)) +$$; + +-- comparablePoint: compatibility composition, no invisible characters, lower case, anything but +-- letters, digits and spaces as a space, spaces collapsed. Diacritics stay: "sạc" and "sắc" +-- differ. +CREATE OR REPLACE FUNCTION rate.review_point_compare(p_text text) +RETURNS text +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +SET search_path = '' +AS $$ + SELECT pg_catalog.btrim(pg_catalog.regexp_replace( + pg_catalog.regexp_replace( + pg_catalog.lower(pg_catalog.regexp_replace(normalize(COALESCE(p_text, ''), NFKC), + '[­؀-؅؜۝܏࢐-࢑࣢᠎​-‏‪-‮⁠-⁤⁦-⃣︀-️-\U000110BD\U000110CD\U00013430-\U0001343F\U0001BCA0-\U0001BCA3\U0001D173-\U0001D17A\U000E0001\U000E0020-\U000E007F\U000E0100-\U000E01EF]', + '', 'g') COLLATE pg_catalog.pg_c_utf8), + '[^[:alnum:][:space:]]+', ' ', 'g'), + '[[:space:]]+', ' ', 'g')) +$$; + +-- hasContent: at least three letters or digits, and not one character repeated. +CREATE OR REPLACE FUNCTION rate.review_point_has_content(p_text text) +RETURNS boolean +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +SET search_path = '' +AS $$ + SELECT pg_catalog.char_length(c) >= 3 + AND (SELECT count(DISTINCT ch) FROM pg_catalog.regexp_split_to_table(c, '') AS ch) >= 2 + FROM (SELECT pg_catalog.regexp_replace(rate.review_point_compare(p_text) COLLATE pg_catalog.pg_c_utf8, + '[^[:alnum:]]', '', 'g') AS c) s +$$; + +-- isEvasiveCon: no words, or the whole compared line is a dodge ("không có", "chưa thấy gì để +-- chê", "mọi thứ đều ổn", "none", "10/10"). The pattern text equals EVASIVE_CON_PATTERN. +CREATE OR REPLACE FUNCTION rate.review_point_is_evasive_con(p_text text) +RETURNS boolean +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +SET search_path = '' +AS $$ + SELECT NOT rate.review_point_has_content(p_text) + OR rate.review_point_compare(p_text) ~ '^(?:(?:hiện tại|hien tai|tạm thời|tam thoi|đến giờ|den gio|vẫn|van|thì|thi|thực sự|thuc su|thật sự|that su|mình|minh|em|tôi|toi|tui|anh|chị|chi|e|m) )*(?:không|khong|ko|k|kh|khum|chưa|chua|chẳng|chang|chả|cha|hok|hông|hong|hem)(?: (?:có|co|thấy|thay|gặp|gap|phát hiện|phat hien|phát hiện ra|phat hien ra|chê được|che duoc|chê|che))*(?: (?:gì|gi|j|nhược điểm|nhuoc diem|điểm trừ|diem tru|khuyết điểm|khuyet diem|điểm yếu|diem yeu|vấn đề|van de|lỗi|loi|điểm|diem|ý kiến|y kien))*(?:(?: (?:để|de|mà|ma|đáng|dang|phải|phai|cần|can))? (?:chê|che|phàn nàn|phan nan|nói|noi|kể|ke))?(?: (?:cả|ca|hết|het|nào|nao|luôn|luon|ạ|a|nhé|nhe|nha|nhen|đâu|dau))*$|^(?:(?:mọi thứ|moi thu|tất cả|tat ca|all) (?:(?:đều|deu) )?(?:ổn|on|tốt|tot|ok|good|fine)|(?:kcj|no|none|nothing|nothing to complain|nothing to complain about|no con|no cons|no complaint|no complaints|everything is fine|n a|0|ok|ổn|on|tốt|tot|rất tốt|rat tot|tuyệt vời|tuyet voi|hoàn hảo|hoan hao|perfect|10 10|5 sao))$' +$$; + +REVOKE ALL ON FUNCTION rate.review_text_stored(text) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION rate.review_text_length(text) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION rate.review_point_compare(text) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION rate.review_point_has_content(text) FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION rate.review_point_is_evasive_con(text) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION rate.review_text_stored(text) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION rate.review_text_length(text) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION rate.review_point_compare(text) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION rate.review_point_has_content(text) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION rate.review_point_is_evasive_con(text) TO authenticated, service_role; + +-- 4. The lines of one list, checked and normalised ---------------------------------------------- + +-- A list of pros or cons: a JSON array of strings, each 5 to 150 characters with words in it, +-- no line twice; a con must not dodge. Returns the lines as stored (review_text_stored), in +-- order. p_field names the +-- list in errors ('pros', 'criterion_points.battery.cons'). +CREATE OR REPLACE FUNCTION rate.review_point_list( + p_lines jsonb, p_kind text, p_min integer, p_max integer, p_field text +) +RETURNS text[] +LANGUAGE plpgsql +IMMUTABLE +SET search_path = '' +AS $$ +DECLARE + v_lines text[] := '{}'; + v_seen text[] := '{}'; + v_line text; + v_compared text; + v_len integer; + v_i integer := 0; +BEGIN + IF p_lines IS NULL OR jsonb_typeof(p_lines) = 'null' THEN + p_lines := '[]'::jsonb; + END IF; + IF jsonb_typeof(p_lines) <> 'array' THEN + RAISE EXCEPTION 'Danh sách % không hợp lệ', CASE p_kind WHEN 'pro' THEN 'ưu điểm' ELSE 'nhược điểm' END + USING ERRCODE = '22023', DETAIL = p_field; + END IF; + IF jsonb_array_length(p_lines) < p_min OR jsonb_array_length(p_lines) > p_max THEN + RAISE EXCEPTION 'Cần từ % đến % %', p_min, p_max, + CASE p_kind WHEN 'pro' THEN 'ưu điểm' ELSE 'nhược điểm' END + USING ERRCODE = '22023', DETAIL = p_field; + END IF; + FOR v_i IN 0 .. jsonb_array_length(p_lines) - 1 LOOP + IF jsonb_typeof(p_lines -> v_i) <> 'string' THEN + RAISE EXCEPTION 'Mỗi dòng phải là chữ' USING ERRCODE = '22023', DETAIL = p_field || '.' || v_i; + END IF; + v_line := rate.review_text_stored(p_lines ->> v_i); + v_len := rate.review_text_length(v_line); + IF v_len < 5 OR v_len > 150 THEN + RAISE EXCEPTION 'Mỗi dòng từ 5 đến 150 ký tự' USING ERRCODE = '22023', DETAIL = p_field || '.' || v_i; + END IF; + IF p_kind = 'pro' AND NOT rate.review_point_has_content(v_line) THEN + RAISE EXCEPTION 'Dòng này chưa có nội dung' USING ERRCODE = '22023', DETAIL = p_field || '.' || v_i; + END IF; + IF p_kind = 'con' AND rate.review_point_is_evasive_con(v_line) THEN + RAISE EXCEPTION 'Hãy ghi một điểm chưa tốt cụ thể, kể cả điểm nhỏ' + USING ERRCODE = '22023', DETAIL = p_field || '.' || v_i; + END IF; + v_compared := rate.review_point_compare(v_line); + IF v_compared = ANY (v_seen) THEN + RAISE EXCEPTION 'Dòng này bị trùng' USING ERRCODE = '22023', DETAIL = p_field || '.' || v_i; + END IF; + v_seen := v_seen || v_compared; + v_lines := v_lines || v_line; + END LOOP; + RETURN v_lines; +END; +$$; + +REVOKE ALL ON FUNCTION rate.review_point_list(jsonb, text, integer, integer, text) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION rate.review_point_list(jsonb, text, integer, integer, text) + TO authenticated, service_role; + +-- 5. rate.submit_community_review_v2 ----------------------------------------------------------- + +CREATE OR REPLACE FUNCTION rate.submit_community_review_v2( + p_master_product_id uuid, + p_criteria_ratings jsonb, + p_verdict text, + p_pros jsonb, + p_cons jsonb, + p_criterion_points jsonb DEFAULT '{}'::jsonb, + p_comment text DEFAULT NULL, + p_author_name text DEFAULT NULL, + p_variation text DEFAULT NULL, + p_reviewer_type text DEFAULT 'user' +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_uid uuid := auth.uid(); + v_claims jsonb := COALESCE(NULLIF(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb); + v_is_service boolean; + v_category text; + v_version integer; + v_criterion record; + v_value jsonb; + v_star numeric; + v_clean_criteria jsonb := '{}'::jsonb; + v_weighted numeric := 0; + v_weights integer := 0; + v_skipped integer := 0; + v_rating_exact numeric(2, 1); + v_rating_star smallint; + v_verdict text; + v_verdict_len integer; + v_pros text[]; + v_cons text[]; + v_key text; + v_points jsonb; + v_cr_pros text[]; + v_cr_cons text[]; + v_criterion_lines jsonb := '[]'::jsonb; + v_comment text; + v_comment_len integer; + v_all_text text; + v_clean_author text; + v_clean_variation text; + v_clean_type text; + v_product_id uuid; + v_recent integer; + v_review_id uuid; + v_created_at timestamptz; + v_line_count integer := 0; + v_i integer; + v_flag_verdict text := 'unanalyzed'; + v_flag_category text := NULL; + v_reason text := 'Đánh giá cộng đồng đang chờ phân tích, chưa được tính vào điểm trung bình.'; +BEGIN + v_is_service := COALESCE(v_claims ->> 'role', '') = 'service_role'; + + -- The gates of v1: a signed-in, non-anonymous user, or the service role. + IF v_uid IS NULL AND NOT v_is_service THEN + RAISE EXCEPTION 'Bạn cần đăng nhập để gửi đánh giá' USING ERRCODE = '42501'; + END IF; + IF NOT v_is_service AND COALESCE(v_claims ->> 'is_anonymous', 'false') = 'true' THEN + RAISE EXCEPTION 'Bạn cần đăng nhập bằng tài khoản thật để gửi đánh giá' USING ERRCODE = '42501'; + END IF; + IF p_master_product_id IS NULL THEN + RAISE EXCEPTION 'master_product_id is required' USING ERRCODE = '22023', DETAIL = 'master_product_id'; + END IF; + + -- The master's category and its newest criteria list. A category with no list is refused + -- rather than rated as a phone (shared-data hasRatingCriteria). + SELECT c.slug INTO v_category + FROM rate.master_products m + LEFT JOIN rate.categories c ON c.id = m.category_id + WHERE m.id = p_master_product_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'Không tìm thấy sản phẩm' USING ERRCODE = 'P0002', DETAIL = 'master_product_id'; + END IF; + SELECT max(criteria_version) INTO v_version + FROM rate.rating_criteria WHERE category_slug = v_category; + IF v_version IS NULL THEN + RAISE EXCEPTION 'Danh mục này chưa có tiêu chí chấm điểm' USING ERRCODE = '22023', DETAIL = 'criteria'; + END IF; + + -- Criterion stars (D5): keys of the list only, 1 to 5 in half steps, the required criterion + -- given, at most 2 left out. The review star is their weighted average at 0.1. + IF p_criteria_ratings IS NULL OR jsonb_typeof(p_criteria_ratings) <> 'object' THEN + RAISE EXCEPTION 'Hãy chấm điểm các tiêu chí' USING ERRCODE = '22023', DETAIL = 'criteria'; + END IF; + FOR v_key IN SELECT jsonb_object_keys(p_criteria_ratings) LOOP + IF NOT EXISTS (SELECT 1 FROM rate.rating_criteria + WHERE criteria_version = v_version AND category_slug = v_category AND key = v_key) THEN + RAISE EXCEPTION 'Tiêu chí không thuộc danh mục này' USING ERRCODE = '22023', DETAIL = 'criteria.' || left(v_key, 40); + END IF; + END LOOP; + FOR v_criterion IN + SELECT key, weight, required FROM rate.rating_criteria + WHERE criteria_version = v_version AND category_slug = v_category + ORDER BY position + LOOP + v_value := p_criteria_ratings -> v_criterion.key; + IF v_value IS NULL OR jsonb_typeof(v_value) = 'null' THEN + IF v_criterion.required THEN + RAISE EXCEPTION 'Tiêu chí này là bắt buộc' USING ERRCODE = '22023', DETAIL = 'criteria.' || v_criterion.key; + END IF; + v_skipped := v_skipped + 1; + CONTINUE; + END IF; + IF jsonb_typeof(v_value) <> 'number' THEN + RAISE EXCEPTION 'Số sao phải từ 1 đến 5, bước 0,5' USING ERRCODE = '22023', DETAIL = 'criteria.' || v_criterion.key; + END IF; + v_star := (v_value #>> '{}')::numeric; + IF v_star < 1 OR v_star > 5 OR v_star * 2 <> trunc(v_star * 2) THEN + RAISE EXCEPTION 'Số sao phải từ 1 đến 5, bước 0,5' USING ERRCODE = '22023', DETAIL = 'criteria.' || v_criterion.key; + END IF; + v_star := round(v_star, 1); + v_clean_criteria := v_clean_criteria || jsonb_build_object(v_criterion.key, v_star); + v_weighted := v_weighted + v_star * v_criterion.weight; + v_weights := v_weights + v_criterion.weight; + END LOOP; + IF v_skipped > 2 THEN + RAISE EXCEPTION 'Chỉ được bỏ qua tối đa 2 tiêu chí' USING ERRCODE = '22023', DETAIL = 'criteria'; + END IF; + v_rating_exact := round(v_weighted / v_weights, 1); + v_rating_star := round(v_rating_exact)::smallint; + + -- Verdict, 20 to 300 characters with words in it. + v_verdict := rate.review_text_stored(p_verdict); + v_verdict_len := rate.review_text_length(v_verdict); + IF v_verdict_len < 20 OR v_verdict_len > 300 THEN + RAISE EXCEPTION 'Phần kết luận cần từ 20 đến 300 ký tự' USING ERRCODE = '22023', DETAIL = 'verdict'; + END IF; + IF NOT rate.review_point_has_content(v_verdict) THEN + RAISE EXCEPTION 'Phần kết luận chưa có nội dung' USING ERRCODE = '22023', DETAIL = 'verdict'; + END IF; + + -- Pros and cons of the whole review: 1 to 5 each; no line in both lists. + v_pros := rate.review_point_list(p_pros, 'pro', 1, 5, 'pros'); + v_cons := rate.review_point_list(p_cons, 'con', 1, 5, 'cons'); + FOR v_i IN 1 .. array_length(v_cons, 1) LOOP + IF rate.review_point_compare(v_cons[v_i]) IN (SELECT rate.review_point_compare(x) FROM unnest(v_pros) x) THEN + RAISE EXCEPTION 'Một dòng không thể vừa là ưu điểm vừa là nhược điểm' + USING ERRCODE = '22023', DETAIL = 'cons.' || (v_i - 1); + END IF; + END LOOP; + + -- Pros and cons of one criterion: optional, up to 3 each, criteria of the list only. + IF p_criterion_points IS NOT NULL AND jsonb_typeof(p_criterion_points) NOT IN ('object', 'null') THEN + RAISE EXCEPTION 'Ưu nhược theo tiêu chí không hợp lệ' USING ERRCODE = '22023', DETAIL = 'criterion_points'; + END IF; + IF p_criterion_points IS NOT NULL AND jsonb_typeof(p_criterion_points) = 'object' THEN + FOR v_key, v_points IN SELECT * FROM jsonb_each(p_criterion_points) LOOP + IF NOT EXISTS (SELECT 1 FROM rate.rating_criteria + WHERE criteria_version = v_version AND category_slug = v_category AND key = v_key) THEN + RAISE EXCEPTION 'Tiêu chí không thuộc danh mục này' + USING ERRCODE = '22023', DETAIL = 'criterion_points.' || left(v_key, 40); + END IF; + IF jsonb_typeof(v_points) <> 'object' THEN + RAISE EXCEPTION 'Ưu nhược theo tiêu chí không hợp lệ' + USING ERRCODE = '22023', DETAIL = 'criterion_points.' || v_key; + END IF; + v_cr_pros := rate.review_point_list(v_points -> 'pros', 'pro', 0, 3, 'criterion_points.' || v_key || '.pros'); + v_cr_cons := rate.review_point_list(v_points -> 'cons', 'con', 0, 3, 'criterion_points.' || v_key || '.cons'); + FOR v_i IN 1 .. COALESCE(array_length(v_cr_cons, 1), 0) LOOP + IF rate.review_point_compare(v_cr_cons[v_i]) IN (SELECT rate.review_point_compare(x) FROM unnest(v_cr_pros) x) THEN + RAISE EXCEPTION 'Một dòng không thể vừa là ưu điểm vừa là nhược điểm' + USING ERRCODE = '22023', DETAIL = 'criterion_points.' || v_key || '.cons.' || (v_i - 1); + END IF; + END LOOP; + v_criterion_lines := v_criterion_lines || jsonb_build_object('key', v_key, 'pros', to_jsonb(v_cr_pros), 'cons', to_jsonb(v_cr_cons)); + END LOOP; + END IF; + + -- The free text is optional; without it the verdict is the review's comment, so readers of v1 + -- reviews and the analyzer always have text. + v_comment := rate.review_text_stored(p_comment); + v_comment_len := char_length(v_comment); + IF v_comment_len > 3000 THEN + RAISE EXCEPTION 'Nội dung đánh giá tối đa 3000 ký tự' USING ERRCODE = '22023', DETAIL = 'comment'; + END IF; + IF v_comment_len = 0 THEN + v_comment := v_verdict; + END IF; + + v_clean_author := left(btrim(COALESCE(p_author_name, '')), 80); + IF char_length(v_clean_author) < 2 THEN + v_clean_author := 'Người dùng Rate.vn'; + END IF; + v_clean_variation := NULLIF(left(btrim(COALESCE(p_variation, '')), 120), ''); + v_clean_type := CASE WHEN p_reviewer_type = 'expert' AND v_is_service THEN 'expert' ELSE 'user' END; + + -- Only a crawled listing of the master, as v1. + SELECT id INTO v_product_id + FROM rate.products + WHERE cluster_id = p_master_product_id AND origin = 'crawl' + ORDER BY price_current ASC NULLS LAST, created_at ASC + LIMIT 1; + IF v_product_id IS NULL THEN + RAISE EXCEPTION 'Không tìm thấy listing liên kết cho sản phẩm chuẩn hóa này' USING ERRCODE = 'P0002'; + END IF; + + IF v_uid IS NOT NULL THEN + -- The same lock key as v1, so v1 and v2 submissions of one user are serialized together. + PERFORM pg_advisory_xact_lock(hashtextextended('rate.submit_community_review:' || v_uid::text, 0)); + IF EXISTS ( + SELECT 1 FROM rate.review_authors a + JOIN rate.reviews r ON r.id = a.review_id + JOIN rate.products p ON p.id = r.product_id + WHERE a.author_user_id = v_uid AND p.cluster_id = p_master_product_id + ) THEN + RAISE EXCEPTION 'Bạn đã gửi đánh giá cho sản phẩm này' USING ERRCODE = '23505'; + END IF; + SELECT count(*) INTO v_recent FROM rate.review_authors + WHERE author_user_id = v_uid AND created_at > now() - interval '24 hours'; + IF v_recent >= 5 THEN + RAISE EXCEPTION 'Bạn đã gửi quá 5 đánh giá trong 24 giờ, vui lòng thử lại sau' USING ERRCODE = '54000'; + END IF; + END IF; + + -- v1's keyword rules, over every line the writer wrote. They only exclude. + v_all_text := concat_ws(E'\n', v_comment, v_verdict, array_to_string(v_pros, E'\n'), array_to_string(v_cons, E'\n'), + (SELECT string_agg(line, E'\n') FROM jsonb_array_elements(v_criterion_lines) e, + LATERAL (SELECT jsonb_array_elements_text(e -> 'pros') UNION ALL SELECT jsonb_array_elements_text(e -> 'cons')) AS t(line))); + IF v_all_text ~* '(nhận xu|kiếm xu|lấy xu|comment lấy xu|hình ảnh chỉ mang tính chất|video chỉ mang tính chất)' THEN + v_flag_verdict := 'suspicious'; + v_flag_category := 'low_value_coin_spam'; + v_reason := 'Phát hiện cụm từ bình luận cày xu mẫu, loại khỏi điểm trung bình.'; + ELSIF v_all_text ~* '(inbox zalo|liên hệ zalo|nhắn zalo|http://|https://|shopee\.ee|s\.shopee)' THEN + v_flag_verdict := 'suspicious'; + v_flag_category := 'seeding_fake'; + v_reason := '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.'; + ELSIF v_rating_star <= 2 AND v_all_text ~* '(shop lừa đảo sang shop|mua bên shop khác tốt hơn|đừng mua shop này qua shop)' THEN + v_flag_verdict := 'suspicious'; + v_flag_category := 'competitor_sabotage'; + v_reason := 'Nghi vấn điều hướng sang đối thủ, loại khỏi điểm trung bình.'; + END IF; + + INSERT INTO rate.reviews ( + product_id, platform_review_id, author_name, rating_star, rating_exact, comment, verdict, + variation, is_verified_buyer, reviewer_type, criteria_ratings, criteria_version, + upvotes, downvotes, report_count, helpfulness_weight + ) + VALUES ( + v_product_id, 'rate_comm_' || replace(gen_random_uuid()::text, '-', ''), v_clean_author, + v_rating_star, v_rating_exact, v_comment, v_verdict, v_clean_variation, NULL, v_clean_type, + v_clean_criteria, v_version, 0, 0, 0, 0.00 + ) + RETURNING id, created_at INTO v_review_id, v_created_at; + + INSERT INTO rate.review_points (review_id, criterion_key, kind, body, position, method) + SELECT v_review_id, NULL, 'pro', line, ord - 1, 'writer' FROM unnest(v_pros) WITH ORDINALITY AS t(line, ord) + UNION ALL + SELECT v_review_id, NULL, 'con', line, ord - 1, 'writer' FROM unnest(v_cons) WITH ORDINALITY AS t(line, ord) + UNION ALL + SELECT v_review_id, e ->> 'key', 'pro', line, ord - 1, 'writer' + FROM jsonb_array_elements(v_criterion_lines) e, + jsonb_array_elements_text(e -> 'pros') WITH ORDINALITY AS t(line, ord) + UNION ALL + SELECT v_review_id, e ->> 'key', 'con', line, ord - 1, 'writer' + FROM jsonb_array_elements(v_criterion_lines) e, + jsonb_array_elements_text(e -> 'cons') WITH ORDINALITY AS t(line, ord); + GET DIAGNOSTICS v_line_count = ROW_COUNT; + + IF v_uid IS NOT NULL THEN + INSERT INTO rate.review_authors (review_id, author_user_id) VALUES (v_review_id, v_uid); + END IF; + + INSERT INTO rate.review_flags ( + review_id, verdict, fake_probability, flag_category, include_in_rating, reason, signals, + detector, detector_version, input_hash + ) + VALUES ( + v_review_id, v_flag_verdict, NULL, v_flag_category, false, v_reason, '{}'::jsonb, + 'rate-community-guard', 'v3', + rate.review_input_hash(v_clean_author, v_rating_star, v_comment) + ); + + -- No rating refresh: an excluded review cannot change the rating. + RETURN jsonb_build_object( + 'id', v_review_id, + 'product_id', v_product_id, + 'author_name', v_clean_author, + 'rating_star', v_rating_star, + 'rating_exact', v_rating_exact, + 'criteria_ratings', v_clean_criteria, + 'criteria_version', v_version, + 'verdict', v_verdict, + 'comment', v_comment, + 'variation', v_clean_variation, + 'reviewer_type', v_clean_type, + 'points', v_line_count, + 'flag_verdict', v_flag_verdict, + 'flag_category', v_flag_category, + 'include_in_rating', false, + 'reason', v_reason, + 'created_at', v_created_at + ); +END; +$$; + +REVOKE ALL ON FUNCTION rate.submit_community_review_v2(uuid, jsonb, text, jsonb, jsonb, jsonb, text, text, text, text) + FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION rate.submit_community_review_v2(uuid, jsonb, text, jsonb, jsonb, jsonb, text, text, text, text) + TO authenticated, service_role; + +COMMIT; From 92d86baadcd49e610c94d83b8a19ec67d93e8d64 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:58:13 +0700 Subject: [PATCH 5/5] feat(web): store review form v2 through submit_community_review_v2 (#1577) * feat(web): store review form v2 through submit_community_review_v2 Refs #1544. P3 is live (DOS/DOS.Me#1500, mirrored in #1576), so the stub becomes the real call: rate.submit_community_review_v2 as the signed-in user, with blank criteria left out and no star or reviewer type sent. A 22023 rejection whose DETAIL names a field is shown on that field of the form (criteria.x as criterion.x, criterion_points as points); 42501, 23505, 54000, P0002 and a bare 22023 keep v1's answers, a missing RPC is a 503. The review list gets the analysis verdict (flag_verdict), not the writer's verdict text. The write button is hidden for a category without a criteria list, and the API refuses one. The flag stays off by default. * fix(web): review findings on the v2 rpc wiring Refs #1544. The review category is the product's category row, as the RPC reads it, never a guess from metadata or the name: the API and the write button use it, so no one fills a form the RPC refuses. The stored item keeps the category's own criteria keys. Criterion lines are counted on storedPoint (NFC, no invisible characters) like the RPC. A whole criterion entry the RPC refuses is marked on the criteria box, and P0002 on the product is a 404. Co-Authored-By: Claude Opus 5.5 --- apps/web/locales/cs.json | 3 +- apps/web/locales/en.json | 3 +- apps/web/locales/vi.json | 3 +- .../app/[locale]/smartphones/[slug]/page.tsx | 9 + .../api/v1/rate/__tests__/write-paths.test.ts | 198 +++++++++++++++++- apps/web/src/app/api/v1/rate/reviews/route.ts | 127 ++++++++++- .../components/reviews/JReviewsSection.tsx | 37 ++-- .../src/components/reviews/ReviewFormV2.tsx | 15 +- .../src/lib/__tests__/review-form-v2.test.ts | 10 + apps/web/src/lib/rate-api.ts | 25 ++- apps/web/src/lib/review-form-v2.ts | 9 +- apps/web/src/server/rate/index.ts | 11 +- apps/web/src/server/rate/openapi.ts | 2 +- apps/web/src/server/rate/review-v2.ts | 87 +++++++- 14 files changed, 475 insertions(+), 64 deletions(-) diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index 32119b2c..ef76bf54 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -606,7 +606,8 @@ "v2ErrNoContent": "Napište to konkrétněji.", "v2ErrEvasive": "Uveďte jednu věc, která vám vadí, i drobnou.", "v2ErrDuplicate": "Tento řádek se opakuje.", - "v2ErrBoth": "Tento řádek už je mezi klady." + "v2ErrBoth": "Tento řádek už je mezi klady.", + "v2ErrRejected": "Toto pole není platné." }, "watchlist": { "metaTitle": "Seznam hlídaných cen a upozornění na slevy | Rate.vn", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index a2f202d0..170588f0 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -606,7 +606,8 @@ "v2ErrNoContent": "Say a bit more.", "v2ErrEvasive": "Name one thing you dislike, however small.", "v2ErrDuplicate": "This line is a duplicate.", - "v2ErrBoth": "This line is already a pro." + "v2ErrBoth": "This line is already a pro.", + "v2ErrRejected": "This field is not valid." }, "watchlist": { "metaTitle": "True price watchlist and drop alerts | Rate.vn", diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json index f631d6ab..8882389d 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -606,7 +606,8 @@ "v2ErrNoContent": "Viết rõ ý hơn.", "v2ErrEvasive": "Ghi một điểm chưa ưng, dù nhỏ.", "v2ErrDuplicate": "Dòng này bị trùng.", - "v2ErrBoth": "Dòng này đã có ở ưu điểm." + "v2ErrBoth": "Dòng này đã có ở ưu điểm.", + "v2ErrRejected": "Ô này chưa hợp lệ." }, "watchlist": { "metaTitle": "Theo dõi giá thật và cảnh báo giảm giá | Rate.vn", diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index dae88e14..2a532f4c 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { isReviewFormV2Enabled } from '@/lib/review-form-v2'; import { Fragment } from 'react'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '@/lib/navigation'; @@ -28,6 +29,7 @@ import ItemLazySizePanel, { import { shouldLazyLoadSizes } from '@/lib/item-size-panel'; import { getCachedMasterProductBySlug, + getCachedPublishedMasterProductCategory, getCachedMasterProductOffers, getCachedMasterProductPriceHistory, getCachedMasterProductReviews, @@ -473,6 +475,12 @@ export async function ProductDetailView({ siblingsRead, ]); timing.done(); + // With review form v2 on, the category the RPC rates (DOS/Rate#1544). + const reviewCategory = isReviewFormV2Enabled() + ? await getCachedPublishedMasterProductCategory(product.id) + .then((r) => r.category) + .catch(() => null) + : null; const variantLinks = itemRoute ? null : buildProductVariantLinks(product, siblings); @@ -1449,6 +1457,7 @@ export async function ProductDetailView({ averageRating={normalizeCompareFiveStarRating( product.average_rating )} + reviewCategorySlug={reviewCategory} userRatingWeighted={displayUserRating || null} ratingScore={storedRating || null} expertRating={resolveCompareExpertRating5(product)} 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 445a48b5..972362dc 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 @@ -41,6 +41,9 @@ const USER = { }; const MASTER_ID = '99999999-8888-4777-8666-555555555555'; const DRAFT_MASTER_ID = '99999999-8888-4777-8666-444444444444'; +// A published product with no category row (DOS/Rate#1544). +const NO_CATEGORY_MASTER_ID = '99999999-8888-4777-8666-333333333333'; +const PHONES_CATEGORY_ID = '77777777-8888-4777-8666-555555555555'; process.env.NEXT_PUBLIC_SUPABASE_URL = SUPABASE_URL; process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = ANON_KEY; @@ -151,10 +154,22 @@ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { : { id: DRAFT_MASTER_ID, status: 'draft' } ); } + if (pathname === '/rest/v1/categories') { + return jsonResponse(200, [{ id: PHONES_CATEGORY_ID, slug: 'smartphones' }]); + } + if ( + pathname === '/rest/v1/master_products' && + new URL(url).searchParams.get('id') === `eq.${NO_CATEGORY_MASTER_ID}` + ) { + return jsonResponse(200, [ + { id: NO_CATEGORY_MASTER_ID, category_id: null, status: 'published' }, + ]); + } if (pathname === '/rest/v1/master_products') { return jsonResponse(200, [ { id: MASTER_ID, + category_id: PHONES_CATEGORY_ID, slug: 'apple-iphone-16-128gb', name: 'Apple iPhone 16 128GB', brand: 'Apple', @@ -477,23 +492,192 @@ async function testReviewsV2() { ); assert.equal(res.status, 404); - // A valid review waits for the v2 RPC: 503, and nothing is written. + // A product with no category row has no criteria list: refused here, + // as the RPC would refuse it. calls = []; - res = await POST(post('/api/v1/rate/reviews', v2Review, GOOD_TOKEN) as Req); - assert.equal(res.status, 503); - assert.equal((await res.json()).code, 'REVIEWS_UNAVAILABLE'); + res = await POST( + post( + '/api/v1/rate/reviews', + { ...v2Review, masterProductId: NO_CATEGORY_MASTER_ID }, + GOOD_TOKEN + ) as Req + ); + assert.equal(res.status, 400); + assert.deepEqual((await res.json()).fieldErrors, [ + { field: 'criteria', key: 'v2ErrCriterion' }, + ]); assert.equal( calls.filter((c) => c.url.includes('/rest/v1/rpc/')).length, 0 ); - assert.ok(calls.every((c) => c.method === 'GET')); + + // A valid review goes to rate.submit_community_review_v2 as the user, + // with blank criteria left out; the stored analysis verdict, not the + // writer's verdict text, reaches the review list. + calls = []; + rpcResponder = (name, rpcBody) => + name === 'submit_community_review_v2' + ? { + status: 200, + json: { + id: 'bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee', + author_name: 'Tester', + rating_star: 4, + rating_exact: 4.3, + criteria_ratings: (rpcBody as { p_criteria_ratings: unknown }) + .p_criteria_ratings, + verdict: v2Review.verdict, + comment: null, + reviewer_type: 'user', + points: 2, + flag_verdict: 'unanalyzed', + flag_category: null, + include_in_rating: false, + created_at: '2026-11-02T00:00:00Z', + }, + } + : { status: 500, json: { message: 'unexpected rpc' } }; + res = await POST( + post( + '/api/v1/rate/reviews', + { + ...v2Review, + criteriaRatings: { ...v2Review.criteriaRatings, design: null }, + }, + GOOD_TOKEN + ) as Req + ); + assert.equal(res.status, 200); + const stored = await res.json(); + assert.equal(stored.ok, true); + assert.equal(stored.review.verdict, 'unanalyzed'); + assert.equal(stored.review.include_in_rating, false); + assert.deepEqual( + stored.review.criteria_ratings, + v2Review.criteriaRatings, + 'the new criteria keys survive on the stored item' + ); + assert.equal( + stored.review.signals.criteria_count, + Object.keys(v2Review.criteriaRatings).length + ); + const [v2Rpc] = rpcCalls('submit_community_review_v2'); + assert.ok(v2Rpc, 'the v2 RPC must be called'); + // v1 is never called on the v2 path. + assert.equal( + calls.filter((c) => c.url.endsWith('/rpc/submit_community_review')) + .length, + 0 + ); + assert.equal(v2Rpc.headers.authorization, `Bearer ${GOOD_TOKEN}`); + const v2Args = v2Rpc.body as Record; + assert.equal(v2Args.p_master_product_id, MASTER_ID); + assert.deepEqual(v2Args.p_criteria_ratings, v2Review.criteriaRatings); + assert.equal(v2Args.p_verdict, v2Review.verdict); + assert.deepEqual(v2Args.p_pros, v2Review.pros); + assert.deepEqual(v2Args.p_cons, v2Review.cons); + assert.equal(v2Args.p_reviewer_type, undefined); + assert.equal(v2Args.p_rating_star, undefined); + assert.ok( + calls.every((c) => !JSON.stringify(c.headers).includes(SERVICE_KEY)), + 'no request may carry the service-role key' + ); + + // A field the database refuses is marked on that field of the form. + for (const [details, field] of [ + ['pros.0', 'pros.0'], + ['criteria.battery', 'criterion.battery'], + ['criterion_points.battery.cons.0', 'points.battery.cons.0'], + ]) { + calls = []; + rpcResponder = () => ({ + status: 400, + json: { code: '22023', message: 'Dòng này bị trùng', details }, + }); + res = await POST( + post('/api/v1/rate/reviews', v2Review, GOOD_TOKEN) as Req + ); + assert.equal(res.status, 400); + const refused = await res.json(); + assert.equal(refused.code, 'INVALID_REVIEW'); + assert.deepEqual(refused.fieldErrors, [{ field, key: 'v2ErrRejected' }]); + } + + // The other rejections keep v1's meaning. + for (const [sqlstate, status, code] of [ + ['42501', 403, 'FORBIDDEN'], + ['23505', 409, 'DUPLICATE_REVIEW'], + ['54000', 429, 'RATE_LIMIT_EXCEEDED'], + ['P0002', 400, 'REJECTED'], + ['22023', 400, 'REJECTED'], + ] as const) { + calls = []; + rpcResponder = () => ({ + status: 400, + json: { code: sqlstate, message: 'rejected by rpc' }, + }); + res = await POST( + post('/api/v1/rate/reviews', v2Review, GOOD_TOKEN) as Req + ); + assert.equal(res.status, status, sqlstate); + assert.equal((await res.json()).code, code, sqlstate); + } + + // No product behind the id: a 404, not "no listing". + calls = []; + rpcResponder = () => ({ + status: 400, + json: { + code: 'P0002', + message: 'Không tìm thấy sản phẩm', + details: 'master_product_id', + }, + }); + res = await POST(post('/api/v1/rate/reviews', v2Review, GOOD_TOKEN) as Req); + assert.equal(res.status, 404); + + // An environment without the v2 RPC says so. + calls = []; + rpcResponder = () => ({ + status: 404, + json: { code: 'PGRST202', message: 'Could not find the function' }, + }); + res = await POST(post('/api/v1/rate/reviews', v2Review, GOOD_TOKEN) as Req); + assert.equal(res.status, 503); + assert.equal((await res.json()).code, 'REVIEWS_UNAVAILABLE'); + + // Any other failure is reported, never faked. + calls = []; + rpcResponder = () => ({ + status: 500, + json: { code: 'XX000', message: 'boom' }, + }); + res = await POST(post('/api/v1/rate/reviews', v2Review, GOOD_TOKEN) as Req); + assert.equal(res.status, 502); + assert.equal((await res.json()).code, 'PERSIST_FAILED'); } finally { delete process.env.NEXT_PUBLIC_RATE_REVIEW_FORM_V2; } // The stored star is computed from the criteria (the test product is a // phone): the body's ratingStar 5 is never read. Lines are single lines. - const { buildReviewV2Submission } = await import('@/server/rate/review-v2'); + const { buildReviewV2Submission, reviewV2FieldFromRpcDetail } = + await import('@/server/rate/review-v2'); + // DETAIL names the database field; the form's names differ for criteria. + assert.equal(reviewV2FieldFromRpcDetail('verdict'), 'verdict'); + assert.equal(reviewV2FieldFromRpcDetail('cons.4'), 'cons.4'); + assert.equal(reviewV2FieldFromRpcDetail('criteria'), 'criteria'); + assert.equal(reviewV2FieldFromRpcDetail('criteria.value'), 'criterion.value'); + assert.equal(reviewV2FieldFromRpcDetail('criterion_points'), 'criteria'); + // A whole entry has no field of its own in the form. + assert.equal(reviewV2FieldFromRpcDetail('criterion_points.anc'), 'criteria'); + assert.equal( + reviewV2FieldFromRpcDetail('criterion_points.anc.pros'), + 'points.anc.pros' + ); + assert.equal(reviewV2FieldFromRpcDetail('master_product_id'), null); + assert.equal(reviewV2FieldFromRpcDetail('criteria.__proto__x'), null); + assert.equal(reviewV2FieldFromRpcDetail(null), null); const built = buildReviewV2Submission('smartphones', v2Review); assert.ok(built.ok); // (4.5*20 + 4*20 + 5*20 + 4*15 + 3.5*15) / 90 = 4.25 @@ -504,7 +688,7 @@ async function testReviewsV2() { false ); console.warn( - ' ✅ POST review form v2 is flagged, checked per field and never stored yet (#1544)' + ' ✅ POST review form v2 is flagged, checked per field and stored through the v2 RPC (#1544)' ); } 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 18a7cf04..3eab0e3b 100644 --- a/apps/web/src/app/api/v1/rate/reviews/route.ts +++ b/apps/web/src/app/api/v1/rate/reviews/route.ts @@ -31,8 +31,11 @@ import { } from '@/lib/review-form-v2'; import { buildReviewV2Submission, + reviewV2FieldFromRpcDetail, submitCommunityReviewV2, } from '@/server/rate/review-v2'; +import { hasRatingCriteria } from '@repo/shared-data/rules'; +import type { AuthenticatedRateRequest } from '@/server/rate/request-auth'; const log = createLogger('api.reviews'); @@ -574,7 +577,7 @@ export async function POST(request: NextRequest) { * waits for the DOS-Me v2 RPC, so a valid review gets a 503 for now. */ async function submitReviewV2( - rateClient: unknown, + rateClient: AuthenticatedRateRequest['rateClient'], rawBody: unknown ): Promise { const parsed = SubmitReviewV2Schema.safeParse(rawBody); @@ -591,16 +594,29 @@ async function submitReviewV2( } const body = parsed.data; - const categorySlug = await getCachedPublishedMasterProductCategory( - body.masterProductId - ); - if (!categorySlug) { + const { published, category: categorySlug } = + await getCachedPublishedMasterProductCategory(body.masterProductId); + if (!published) { return NextResponse.json( { ok: false, error: 'Product not found', code: 'NOT_FOUND' }, { status: 404, headers: REVIEWS_CORS_HEADERS } ); } + // The RPC refuses a category with no criteria list, or a product with + // no category; say so here first. + if (!categorySlug || !hasRatingCriteria(categorySlug)) { + return NextResponse.json( + { + ok: false, + error: 'Danh mục này chưa có tiêu chí chấm điểm.', + code: 'INVALID_REVIEW', + fieldErrors: [{ field: 'criteria', key: 'v2ErrCriterion' }], + }, + { status: 400, headers: REVIEWS_CORS_HEADERS } + ); + } + const checked = buildReviewV2Submission(categorySlug, body); if (!checked.ok) { return NextResponse.json( @@ -616,21 +632,114 @@ async function submitReviewV2( const result = await submitCommunityReviewV2(rateClient, checked.submission); if (!result.ok) { + if (result.missing) { + return NextResponse.json( + { + ok: false, + error: 'Chức năng gửi đánh giá chưa sẵn sàng trên môi trường này.', + code: 'REVIEWS_UNAVAILABLE', + }, + { status: 503, headers: REVIEWS_CORS_HEADERS } + ); + } + // A field the database refused: shown on that field of the form. + const field = + result.error?.code === '22023' + ? reviewV2FieldFromRpcDetail(result.error.details) + : null; + if (field) { + log.warn('submit_community_review_v2 rejected a field', { field }); + return NextResponse.json( + { + ok: false, + error: 'Đánh giá chưa hợp lệ, xem lại các ô được đánh dấu.', + code: 'INVALID_REVIEW', + fieldErrors: [{ field, key: 'v2ErrRejected' }], + }, + { status: 400, headers: REVIEWS_CORS_HEADERS } + ); + } + if ( + result.error?.code === 'P0002' && + result.error.details === 'master_product_id' + ) { + return NextResponse.json( + { ok: false, error: 'Product not found', code: 'NOT_FOUND' }, + { status: 404, headers: REVIEWS_CORS_HEADERS } + ); + } + const rejection = result.error + ? mapRpcRejection(result.error, RPC_REJECTIONS) + : null; + if (rejection?.status === 429) { + return rateLimitExceededResponse( + rejection.error, + REVIEWS_CORS_HEADERS, + 86_400 + ); + } + if (rejection) { + return NextResponse.json( + { ok: false, error: rejection.error, code: rejection.code }, + { status: rejection.status, headers: REVIEWS_CORS_HEADERS } + ); + } + log.error('submit_community_review_v2 failed', { + code: result.error?.code, + message: result.error?.message, + }); return NextResponse.json( { ok: false, - error: 'Chức năng gửi đánh giá chưa sẵn sàng trên môi trường này.', - code: result.code, + error: 'Không thể lưu đánh giá lúc này.', + code: 'PERSIST_FAILED', }, - { status: 503, headers: REVIEWS_CORS_HEADERS } + { status: 502, headers: REVIEWS_CORS_HEADERS } ); } + + try { + revalidateTag(RATE_CACHE_TAGS.REVIEWS, 'max'); + revalidateTag(RATE_CACHE_TAGS.PRODUCTS, 'max'); + revalidateTag(RATE_CACHE_TAGS.STATS, 'max'); + } catch { + // Ignore revalidateTag errors outside Next.js request context + } + + // The RPC's `verdict` is the writer's verdict text; the analysis verdict + // the review list reads is `flag_verdict`. Its criteria use the + // category's own keys, which v1's sanitizer would drop. + const record = result.record; + const review = toReviewItem( + { ...record, verdict: record.flag_verdict ?? 'unanalyzed' }, + {} + ); + const criteria = v2StoredCriteria(record.criteria_ratings); return NextResponse.json( { ok: true, mode: 'supabase_rpc', - review: toReviewItem(result.record, {}), + review: { + ...review, + criteria_ratings: criteria, + signals: { + ...review.signals, + criteria_count: Object.keys(criteria).length, + }, + }, }, { headers: REVIEWS_CORS_HEADERS } ); } + +/** Stored v2 criterion stars: the category's keys, 1 to 5. */ +function v2StoredCriteria(raw: unknown): Record { + const out: Record = {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + for (const [key, value] of Object.entries(raw).slice(0, 20)) { + if (!/^[a-z][a-z_]{1,31}$/.test(key)) continue; + const star = asNumber(value, NaN); + if (Number.isFinite(star) && star >= 1 && star <= 5) out[key] = star; + } + return out; +} diff --git a/apps/web/src/components/reviews/JReviewsSection.tsx b/apps/web/src/components/reviews/JReviewsSection.tsx index 25e77a8e..dc59f44a 100644 --- a/apps/web/src/components/reviews/JReviewsSection.tsx +++ b/apps/web/src/components/reviews/JReviewsSection.tsx @@ -54,6 +54,7 @@ import { import AnalyzerText, { analyzerText } from './AnalyzerText'; import ReviewFormV2 from './ReviewFormV2'; import { isReviewFormV2Enabled } from '@/lib/review-form-v2'; +import { hasRatingCriteria } from '@repo/shared-data/rules'; import { formatDecimal, formatPercent, @@ -68,6 +69,11 @@ export interface JReviewsSectionProps { masterProductId?: string; /** Shows only this category's criteria: no screen for earbuds. */ categorySlug?: CanonicalCategorySlug; + /** + * The category review form v2 rates (the product's category row, as the + * v2 RPC reads it); null when it has none. Only read with v2 on. + */ + reviewCategorySlug?: string | null; productSlug?: string; productName: string; averageRating: number; @@ -153,6 +159,7 @@ export default function JReviewsSection({ summary, radarSignal, categorySlug, + reviewCategorySlug, }: JReviewsSectionProps) { const t = useTranslations('reviews'); const tAnalyzer = useTranslations('analyzerCopy'); @@ -860,18 +867,22 @@ export default function JReviewsSection({ )} - {masterProductId && ( - - )} + {/* v2 rates a category's own criteria: a category without a + list has no form, as the RPC refuses it (DOS/Rate#1544). */} + {masterProductId && + (!isReviewFormV2Enabled() || + hasRatingCriteria(reviewCategorySlug)) && ( + + )} {t('dualRatingStandardBadge')} @@ -899,7 +910,7 @@ export default function JReviewsSection({ key={masterProductId ?? productSlug ?? productName} masterProductId={masterProductId} productName={productName} - categorySlug={categorySlug} + categorySlug={reviewCategorySlug ?? undefined} defaultAuthorName={t('defaultAuthorName')} onStored={handleReviewStored} /> diff --git a/apps/web/src/components/reviews/ReviewFormV2.tsx b/apps/web/src/components/reviews/ReviewFormV2.tsx index 93a75a63..9bed9413 100644 --- a/apps/web/src/components/reviews/ReviewFormV2.tsx +++ b/apps/web/src/components/reviews/ReviewFormV2.tsx @@ -11,7 +11,6 @@ import { type RatingCriterion, } from '@repo/shared-data/rules'; import type { ReviewItem } from '@/lib/rate-domain'; -import type { CanonicalCategorySlug } from '@/lib/rate-domain-core'; import { jsonWriteHeaders } from '@/lib/auth/access-token'; import { submitErrorKey } from '@/lib/review-display'; import { formatDecimal } from '@/lib/locale-format'; @@ -31,7 +30,8 @@ import { export interface ReviewFormV2Props { masterProductId?: string; productName: string; - categorySlug?: CanonicalCategorySlug; + /** The category row the v2 RPC rates against (never guessed). */ + categorySlug?: string; defaultAuthorName: string; onStored: (review: ReviewItem) => void; } @@ -47,6 +47,7 @@ const MESSAGE_KEYS = new Set([ 'v2ErrEvasive', 'v2ErrDuplicate', 'v2ErrBoth', + 'v2ErrRejected', ]); /** Field errors from the API, kept only when they have the known shape. */ @@ -429,6 +430,7 @@ export default function ReviewFormV2({ const skippedError = errorFor('criteria'); const verdictError = errorFor('verdict'); + const commentError = errorFor('comment'); const verdictLength = [...draft.verdict.trim()].length; return ( @@ -659,11 +661,18 @@ export default function ReviewFormV2({ rows={3} value={draft.comment} maxLength={3000} + aria-invalid={commentError ? true : undefined} + aria-describedby={commentError ? `${fieldId}-comment-err` : undefined} onChange={(e) => update((prev) => ({ ...prev, comment: e.target.value })) } - className="form-control form-control-sm rounded-3" + className={`form-control form-control-sm rounded-3 ${commentError ? 'is-invalid' : ''}`} /> + {commentError && ( +
+ {commentError} +
+ )}
diff --git a/apps/web/src/lib/__tests__/review-form-v2.test.ts b/apps/web/src/lib/__tests__/review-form-v2.test.ts index 51a823f5..5079bed4 100644 --- a/apps/web/src/lib/__tests__/review-form-v2.test.ts +++ b/apps/web/src/lib/__tests__/review-form-v2.test.ts @@ -138,6 +138,16 @@ function main() { ['points.battery.cons.0:v2ErrBoth', 'points.camera:v2ErrCount'] ); + // Criterion lines count like the RPC: NFC, invisible characters out. + // "Đẹp❤️" is 4 there (the variation selector is not counted), too short. + assert.deepEqual( + fields({ + ...VALID, + criterionPoints: { design: { pros: ['Đẹp❤️'] } }, + }), + ['points.design.pros.0:v2ErrLength'] + ); + // A malformed body gives errors and never throws. assert.ok( reviewV2Errors('smartphones', null as unknown as ReviewV2Input).length > 0 diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index aceebf60..113cb708 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -1772,17 +1772,19 @@ export async function isPublishedMasterProduct( } /** - * The category of a published master product, or null when the id names no - * published product. The review API checks criteria against it rather than a - * category the browser sends (DOS/Rate#1544). + * The review category of a published master product: the slug of its + * `category_id`, as rate.submit_community_review_v2 reads it, with no guess + * from metadata or the name (DOS/Rate#1544). `published` is false when the + * id names no published product; `category` is null when it has no + * category row. */ export async function getPublishedMasterProductCategorySlug( masterId: string -): Promise { +): Promise<{ published: boolean; category: string | null }> { const supabase = getRateClient(); const { data, error } = await supabase .from('master_products') - .select('id, slug, name, model, category_id, metadata, specifications') + .select('id, category_id') .eq('id', masterId) .eq('status', 'published') .maybeSingle(); @@ -1793,12 +1795,13 @@ export async function getPublishedMasterProductCategorySlug( }); throw new RateDataUnavailableError('master product', error); } - if (!data) return null; - const categorySlugById = await getCategorySlugMap(); - return getMasterProductCategorySlug( - data as Parameters[0], - categorySlugById ?? undefined - ); + if (!data) return { published: false, category: null }; + const categoryId = (data as { category_id?: string | null }).category_id; + const categorySlugById = categoryId ? await getCategorySlugMap() : null; + return { + published: true, + category: (categoryId && categorySlugById?.get(categoryId)) || null, + }; } export async function getMasterProductReviews( diff --git a/apps/web/src/lib/review-form-v2.ts b/apps/web/src/lib/review-form-v2.ts index 4cece403..0bdbd9a0 100644 --- a/apps/web/src/lib/review-form-v2.ts +++ b/apps/web/src/lib/review-form-v2.ts @@ -20,6 +20,7 @@ import { getRatingCriteria, hasContent, isEvasiveCon, + storedPoint, validateCriteriaRatings, validateReviewText, weightedReviewStar, @@ -58,7 +59,8 @@ export type ReviewV2MessageKey = | 'v2ErrNoContent' | 'v2ErrEvasive' | 'v2ErrDuplicate' - | 'v2ErrBoth'; + | 'v2ErrBoth' + | 'v2ErrRejected'; /** * One error on one field. Fields: `criteria`, `criterion.`, `verdict`, @@ -77,8 +79,9 @@ const DUPLICATE_LINE = 'v2ErrDuplicate' as const; /** Length as the writer sees it, like review-form.ts and char_length. */ function lineLength(text: string): number { - const composed = String(text ?? '').normalize('NFKC'); - return [...composed.replace(/\p{Cf}+/gu, '').trim()].length; + // Counted on the stored form (NFC, no invisible characters), as the v2 + // RPC and the main lists count it. + return [...storedPoint(text)].length; } function hasOwn(record: object, key: string): boolean { diff --git a/apps/web/src/server/rate/index.ts b/apps/web/src/server/rate/index.ts index bded58e5..018fa4d3 100644 --- a/apps/web/src/server/rate/index.ts +++ b/apps/web/src/server/rate/index.ts @@ -394,14 +394,19 @@ const _getCachedPublishedCategoryByKey = cache((cleanId: string) => ); /** - * The category of a published master product, null for any other id: what - * the review API checks criteria against (DOS/Rate#1544). + * The review category of a published master product (its category_id's + * slug, as the v2 review RPC reads it): what the review API and the write + * button check criteria against (DOS/Rate#1544). */ export function getCachedPublishedMasterProductCategory( masterProductId: string ) { const cleanId = sanitizeCachedEntityId(masterProductId); - if (!cleanId) return Promise.resolve(null); + if (!cleanId) + return Promise.resolve({ + published: false, + category: null as string | null, + }); return _getCachedPublishedCategoryByKey(cleanId); } diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index b3a0d732..6d7235c2 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -1583,7 +1583,7 @@ function buildBaseRateOpenApiSpec( summary: 'Submit a 5-criteria JReviews community review with automated anti-seeding check', description: - 'Submits a community review for the signed-in user via `rate.submit_community_review`. The review is stored unanalyzed and does not change product scores until it is analyzed; keyword spam is flagged at once. Requires a Supabase access token. The reviewer type is decided by the database; the request cannot claim `expert`. Any failure to store the review is returned as an error.', + "Submits a community review for the signed-in user via `rate.submit_community_review`. The review is stored unanalyzed and does not change product scores until it is analyzed; keyword spam is flagged at once. Requires a Supabase access token. The reviewer type is decided by the database; the request cannot claim `expert`. Any failure to store the review is returned as an error. Where review form v2 is enabled, the body must carry `formVersion: 2`, `criteriaRatings` (the category's criteria in half stars), `verdict`, `pros` and `cons` (and optionally `criterionPoints`); it is stored through `rate.submit_community_review_v2`, the star is computed from the criteria, and a field the review breaks comes back as `code: INVALID_REVIEW` with `fieldErrors`.", tags: ['Community Reviews & Voting'], security: [{ bearerAuth: [] }], requestBody: { diff --git a/apps/web/src/server/rate/review-v2.ts b/apps/web/src/server/rate/review-v2.ts index 3df6ba6e..33c82dbf 100644 --- a/apps/web/src/server/rate/review-v2.ts +++ b/apps/web/src/server/rate/review-v2.ts @@ -6,8 +6,12 @@ import { type CriterionPoints, type ReviewV2Error, } from '@/lib/review-form-v2'; +import { + isMissingRpcError, + type AuthenticatedRateRequest, +} from './request-auth'; -/** A checked v2 review, as `rate.submit_community_review` v2 will take it. */ +/** A checked v2 review, as `rate.submit_community_review_v2` takes it. */ export interface ReviewV2Submission { masterProductId: string; authorName: string; @@ -78,22 +82,83 @@ export function buildReviewV2Submission( }; } +export interface ReviewV2RpcError { + code?: string; + message?: string; + details?: string | null; +} + export type ReviewV2SubmitResult = | { ok: true; record: Record } - | { ok: false; code: 'REVIEWS_UNAVAILABLE' }; + | { ok: false; missing: boolean; error: ReviewV2RpcError | null }; /** - * Stores a v2 review (DOS/Rate#1544). A stub until DOS-Me ships the v2 RPC - * with its own checks (verdict, pros and cons tables, the stored star): it - * never touches the database and always answers that reviews are not - * available yet, which the route turns into a 503. The real call runs as - * the signed-in user through `rateClient`, like the v1 RPC. + * Stores a v2 review through `rate.submit_community_review_v2` (DOS-Me + * 20261102090000), as the signed-in user: the database takes the author + * from auth.uid(), checks the same D5 rules again, refuses anonymous + * accounts, a second review of the same product and more than 5 a day, and + * stores the review excluded until it is analysed. */ export async function submitCommunityReviewV2( - rateClient: unknown, + rateClient: AuthenticatedRateRequest['rateClient'], submission: ReviewV2Submission ): Promise { - void rateClient; - void submission; - return { ok: false, code: 'REVIEWS_UNAVAILABLE' }; + const criteria: Record = {}; + for (const [key, star] of Object.entries(submission.criteriaRatings)) { + if (typeof star === 'number') criteria[key] = star; + } + const points = submission.criterionPoints; + const { data, error } = await rateClient.rpc('submit_community_review_v2', { + p_master_product_id: submission.masterProductId, + p_criteria_ratings: criteria, + p_verdict: submission.verdict, + p_pros: [...submission.pros], + p_cons: [...submission.cons], + ...(Object.keys(points).length > 0 + ? { p_criterion_points: points as Record } + : {}), + ...(submission.comment ? { p_comment: submission.comment } : {}), + p_author_name: submission.authorName, + ...(submission.variation ? { p_variation: submission.variation } : {}), + }); + if (error) { + return { ok: false, missing: isMissingRpcError(error), error }; + } + if ( + !data || + typeof data !== 'object' || + Array.isArray(data) || + !('id' in data) + ) { + return { ok: false, missing: false, error: null }; + } + return { ok: true, record: data as Record }; +} + +/** + * The form field a 22023 rejection names in its DETAIL ('pros.2', + * 'criteria.battery', 'criterion_points.anc.cons.0'), in the form's own + * field names (`criterion.`, `points....`). Null for a detail the + * form has no field for. + */ +export function reviewV2FieldFromRpcDetail(detail: unknown): string | null { + if (typeof detail !== 'string') return null; + const d = detail.trim(); + if (/^(verdict|pros|cons|criteria|comment)$/.test(d)) return d; + if (/^(pros|cons)\.\d{1,2}$/.test(d)) return d; + const criterion = /^criteria\.([a-z][a-z_]{1,31})$/.exec(d); + if (criterion) return `criterion.${criterion[1]}`; + const points = + /^criterion_points(?:\.([a-z][a-z_]{1,31})(?:\.(pros|cons)(?:\.(\d{1,2}))?)?)?$/.exec( + d + ); + if (points) { + // The form shows pros and cons lists only, so a whole entry (an unknown + // key, a value that is no object) is marked on the criteria box. + if (!points[1] || !points[2]) return 'criteria'; + return ['points', points[1], points[2], points[3]] + .filter((part): part is string => Boolean(part)) + .join('.'); + } + return null; }