diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81ceff8a7..7df3dfdf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,9 @@ jobs: - name: Replay the DOS-Me criteria ratings proposal (Docker) run: node scripts/test-criteria-ratings-proposal.mjs + - 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: Check generated rate schema types match the migrations run: pnpm check:db-types diff --git a/.gitignore b/.gitignore index eb9fd6efb..9a949f4f1 100644 --- a/.gitignore +++ b/.gitignore @@ -152,6 +152,9 @@ playwright-report-item-model/ !Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql !Docs/Proposals/2026-10-31-dos-me-criteria-ratings-rollback.sql !Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql +!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 * - Copy* * - Bản sao* *.dump diff --git a/.gitleaksignore b/.gitleaksignore index d4273ba73..a74366ea9 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -4,3 +4,10 @@ # Fake 'sk-123...' key in the logger redaction test. The line carries gitleaks:allow # today; this commit (#260) added it before the inline allow comment existed. 02a0187ca6b96e11476e6bf90921aabf53b1f7a5:apps/importer/src/shopee/__tests__/entity-matcher.test.ts:generic-api-key:453 + +# The message key 'v2ErrDuplicate' in `key: '...'` literals of the review form v2 +# rules (#1544), read as a generic API key. Later commits use a named constant. +2f624a1c620f68ecb658eed02aba62b05f05b04b:apps/web/src/lib/review-form-v2.ts:generic-api-key:114 +2f624a1c620f68ecb658eed02aba62b05f05b04b:apps/web/src/lib/review-form-v2.ts:generic-api-key:193 +4c0506053bbbc6668df016cdebfd3ee37fc9cea8:apps/web/src/lib/review-form-v2.ts:generic-api-key:75 +4c0506053bbbc6668df016cdebfd3ee37fc9cea8:apps/web/src/lib/review-form-v2.ts:generic-api-key:76 diff --git a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql index 9fe8ff517..24a1ba7a0 100644 --- a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql +++ b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql @@ -44,7 +44,8 @@ WHERE n.nspname = 'rate' AND NOT t.tgisinternal AND t.tgenabled <> 'D' 'trg_review_flags_rating_extras_upd', 'trg_review_flags_rating_extras_del', 'trg_master_products_rating_extras'); --- V5 Every criterion score meets the display thresholds of D9. expect 0 +-- V5 Every criterion score has 3 writers and a stored weight sum of 1.5. expect 0 +-- (D9 also caps each review at 1 toward that sum; V6 checks it.) SELECT count(*) AS criteria_below_threshold FROM rate.master_products m, jsonb_each(m.criteria_scores) c(key, v) WHERE (c.v ->> 'writers')::int < 3 diff --git a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.md b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.md index b8c8bbe12..280b920db 100644 --- a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.md +++ b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.md @@ -38,7 +38,7 @@ Design: [`2026-10-31-criteria-stars-pros-cons.html`](./2026-10-31-criteria-stars - `criteria_scores`: `{key: {score, weight_sum, writers}}`. A criterion appears only when all of these hold (D9): - 3 or more reviews rated it; - each rating is a number from 1 to 5, under a key shaped like the shared-data keys; - - the reviews' weight sum is 1.5 or more. + - the reviews' weight sum is 1.5 or more, each review counting at most 1 toward it, so one heavy review plus near-zero ones never passes. The stored `weight_sum` is the plain sum. Readers apply the ranking threshold of 5 from `writers`. - **Which reviews count:** the same reviews `refresh_master_product_rating` counts. diff --git a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql index c783187c9..7f1fe3207 100644 --- a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql +++ b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql @@ -161,7 +161,9 @@ END $$; -- criteria = the same estimate per criterion of criteria_ratings, over the reviews that -- rated it with a number from 1 to 5 under a key shaped like the shared-data -- keys (^[a-z_]{2,32}$); kept only with 3 or more reviews and a weight sum of --- 1.5 or more (D9). "writers" lets a reader apply the ranking threshold (5). A +-- 1.5 or more (D9), each review counting at most 1 toward that sum, so one heavy +-- review plus near-zero ones never passes. The stored weight_sum is the plain +-- sum. "writers" lets a reader apply the ranking threshold (5). A -- Rate user writes one review per product, so a review is a writer. The prior -- mean is 4.2 for every criterion until per-category means exist. @@ -221,6 +223,7 @@ BEGIN SELECT c.key, count(*) AS writers, round(sum(e.w), 2) AS weight_sum, + sum(LEAST(1.0, e.w)) AS shown_sum, round((3 * 4.2 + sum(e.w * (c.value #>> '{}')::numeric)) / (3 + sum(e.w)), 3) AS score FROM eligible e CROSS JOIN LATERAL jsonb_each( @@ -238,7 +241,7 @@ BEGIN (SELECT jsonb_object_agg(pc.key, jsonb_build_object( 'score', pc.score, 'weight_sum', pc.weight_sum, 'writers', pc.writers)) FROM per_criterion pc - WHERE pc.writers >= 3 AND pc.weight_sum >= 1.5), + WHERE pc.writers >= 3 AND pc.shown_sum >= 1.5), '{}'::jsonb) FROM overall o $q$, CASE WHEN v_item IS NULL THEN 'p.cluster_id' ELSE 'p.item_id' END) @@ -462,8 +465,13 @@ ALTER TABLE rate.reviews VALIDATE CONSTRAINT reviews_criteria_version_positive; ALTER TABLE rate.reviews VALIDATE CONSTRAINT reviews_value_score_range; ALTER TABLE rate.master_products VALIDATE CONSTRAINT master_products_rating_score_range; ALTER TABLE rate.master_products VALIDATE CONSTRAINT master_products_rating_weight_sum_nonneg; +-- A master with a reviewed listing, or one the live refresh rated (an Item variant whose +-- reviews sit on listings with no variant). The first test does not trust the cached +-- authentic_review_count: a weight written straight to rate.reviews leaves it stale. SELECT rate.refresh_master_rating_extras(m.id) FROM rate.master_products m -WHERE m.authentic_review_count > 0 OR m.user_rating_weighted > 0 +WHERE EXISTS (SELECT 1 FROM rate.products p JOIN rate.reviews r ON r.product_id = p.id + WHERE p.cluster_id = m.id) + OR m.authentic_review_count > 0 OR m.user_rating_weighted > 0 ORDER BY m.id; COMMIT; 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 new file mode 100644 index 000000000..61906277b --- /dev/null +++ b/Docs/Proposals/2026-11-01-dos-me-review-submission-v2-rollback.sql @@ -0,0 +1,18 @@ +-- PROPOSAL, NOT APPLIED. Rollback of 2026-11-01-dos-me-review-submission-v2.sql (DOS/Rate#1544). +-- Drops the v2 RPC, the line helpers, the criteria table and the position key. Reviews written +-- through v2 stay, with their rating_exact, verdict, criteria_version and review_points rows +-- (P2 columns and table). Run it only while no web build calls v2 (the form falls back to v1). +-- One transaction, re-runnable. + +BEGIN; + +DROP FUNCTION IF EXISTS rate.submit_community_review_v2(uuid, jsonb, text, jsonb, jsonb, jsonb, text, text, text, text); +DROP FUNCTION IF EXISTS rate.review_point_list(jsonb, text, integer, integer, text); +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_point_compare(text); +DROP INDEX IF EXISTS rate.review_points_position_key; +DROP TABLE IF EXISTS rate.rating_criteria; + +COMMIT; diff --git a/Docs/Proposals/2026-11-01-dos-me-review-submission-v2-verify.sql b/Docs/Proposals/2026-11-01-dos-me-review-submission-v2-verify.sql new file mode 100644 index 000000000..87e6f5423 --- /dev/null +++ b/Docs/Proposals/2026-11-01-dos-me-review-submission-v2-verify.sql @@ -0,0 +1,69 @@ +-- PROPOSAL, NOT APPLIED. Read-only checks for 2026-11-01-dos-me-review-submission-v2.sql +-- (DOS/Rate#1544). Run after the migration on preview, beta and prod. V1 to V7 must return 0. + +-- V1 The criteria lists are complete. expect 0 +-- Version 1 has 36 rows; each category weighs 100 with one required criterion. +SELECT count(*) AS rating_criteria_incomplete +FROM (SELECT 1) one +WHERE (SELECT count(*) FROM rate.rating_criteria WHERE criteria_version = 1) <> 36 + OR EXISTS (SELECT 1 FROM rate.rating_criteria GROUP BY criteria_version, category_slug + HAVING sum(weight) <> 100 OR count(*) FILTER (WHERE required) <> 1); + +-- V2 The functions exist, the RPC is SECURITY DEFINER with an empty search_path. expect 0 +SELECT 7 - count(DISTINCT p.proname) AS functions_missing +FROM pg_proc p +WHERE p.pronamespace = 'rate'::regnamespace + AND (p.proname IN ('review_text_stored', 'review_text_length', 'review_point_compare', + 'review_point_has_content', 'review_point_is_evasive_con', 'review_point_list') + OR (p.proname = 'submit_community_review_v2' AND p.prosecdef + AND p.proconfig @> ARRAY['search_path=""'])); + +-- V3 anon cannot run the RPC or the helpers; authenticated and service_role can. expect 0 +SELECT count(*) AS grants_wrong +FROM (VALUES ('anon', false), ('authenticated', true), ('service_role', true)) AS g(role, allowed) +CROSS JOIN (VALUES + ('rate.submit_community_review_v2(uuid, jsonb, text, jsonb, jsonb, jsonb, text, text, text, text)'), + ('rate.review_point_list(jsonb, text, integer, integer, text)'), + ('rate.review_text_stored(text)'), ('rate.review_text_length(text)'), + ('rate.review_point_compare(text)'), ('rate.review_point_has_content(text)'), + ('rate.review_point_is_evasive_con(text)')) AS f(fn) +WHERE has_function_privilege(g.role, f.fn, 'EXECUTE') <> g.allowed; + +-- V4 Every v2 review (criteria_version set) has a verdict, a pro and a con. expect 0 +SELECT count(*) AS v2_reviews_incomplete +FROM rate.reviews r +WHERE r.criteria_version IS NOT NULL + AND (r.verdict IS NULL OR r.rating_exact IS NULL + OR NOT EXISTS (SELECT 1 FROM rate.review_points p + WHERE p.review_id = r.id AND p.criterion_key IS NULL AND p.kind = 'pro') + OR NOT EXISTS (SELECT 1 FROM rate.review_points p + WHERE p.review_id = r.id AND p.criterion_key IS NULL AND p.kind = 'con')); + +-- V5 Every v2 review's star is its 0.1 star rounded. expect 0 +SELECT count(*) AS v2_star_mismatch +FROM rate.reviews +WHERE criteria_version IS NOT NULL AND rating_star <> round(rating_exact); + +-- V6 The position key exists (one row per review, criterion, kind and position). expect 0 +SELECT 1 - count(*) AS position_key_missing +FROM pg_indexes +WHERE schemaname = 'rate' AND indexname = 'review_points_position_key'; + +-- V7 The line helpers read Vietnamese the same as shared-data on this database. expect 0 +-- Upper case folds, diacritics stay, a dodge is caught, emoji marks are invisible. +SELECT count(*) AS line_helpers_wrong +FROM (VALUES + (rate.review_point_compare('KHÔNG CÓ'), 'không có'), + (rate.review_point_compare('Sạc NHANH!!'), 'sạc nhanh'), + (rate.review_point_compare('Pin tốt ❤️'), 'pin tốt'), + (rate.review_point_is_evasive_con('Không có gì ☺️')::text, 'true'), + (rate.review_point_is_evasive_con('Giá hơi cao')::text, 'false'), + (rate.review_point_has_content('ưu điểm')::text, 'true'), + (rate.review_text_length('Ổn ' || chr(8203) || '😀')::text, '4')) AS t(got, want) +WHERE got IS DISTINCT FROM want; + +-- V8 For information: v2 reviews and their lines so far. +SELECT count(*) AS v2_reviews, + (SELECT count(*) FROM rate.review_points p JOIN rate.reviews r ON r.id = p.review_id + WHERE r.criteria_version IS NOT NULL) AS v2_lines +FROM rate.reviews WHERE criteria_version IS NOT NULL; diff --git a/Docs/Proposals/2026-11-01-dos-me-review-submission-v2.md b/Docs/Proposals/2026-11-01-dos-me-review-submission-v2.md new file mode 100644 index 000000000..725aebc79 --- /dev/null +++ b/Docs/Proposals/2026-11-01-dos-me-review-submission-v2.md @@ -0,0 +1,90 @@ +# DOS-Me migration proposal: review submission v2 (P3) + +**Not applied.** DOS/Rate#1544, phase P3 of the criteria ratings design (`2026-10-31-criteria-stars-pros-cons.html`, sections 2 and 4.2; D1 and D5, approved by JOY on 2026-10-31). JOY reviews it and applies it through DOS-Me: preview, then beta, then production. DOS/Rate runs no DDL against production. + +- **Needs:** P2 (`2026-10-31-dos-me-criteria-ratings.sql`, DOS/DOS.Me#1489) applied first. P3 writes P2's columns (`rating_exact`, `verdict`, `criteria_version`) and P2's `rate.review_points` table. +- **Files:** + - `2026-11-01-dos-me-review-submission-v2.sql`: the migration, one transaction, re-runnable. + - `-verify.sql`: read-only checks; V1 to V7 must return 0. + - `-rollback.sql`: drops what the migration adds, one transaction, re-runnable. +- **Replay test:** `scripts/test-review-submission-v2-proposal.mjs`, on Docker, in DOS/Rate CI. + +## What it adds + +- **`rate.rating_criteria`:** the criteria of each category, with weight, the required flag and order, versioned. Version 1 is `packages/shared-data/src/rules/rating-criteria.ts`, 36 rows. A version never changes; a new list is a new version. RLS is on, with public read. +- **Line helpers**, the SQL side of `review-form.ts`. They read text under the built-in `pg_c_utf8` collation (PostgreSQL 17), so they behave the same whatever the database's locale is: + - `rate.review_text_stored` (`storedPoint`): a line as stored, composed (NFC), without invisible characters, trimmed; + - `rate.review_point_compare` (`comparablePoint`): lower case, diacritics kept, anything but letters, digits and spaces as a space; + - `rate.review_point_has_content` (`hasContent`); + - `rate.review_point_is_evasive_con` (`isEvasiveCon`); its pattern is `EVASIVE_CON_PATTERN`, character for character; + - `rate.review_text_length`: code points of the stored form, so a stored line always fits the `review_points` length check; + - `rate.review_point_list`, which checks one list of pros or cons. +- **`rate.submit_community_review_v2(master, criteria, verdict, pros, cons, criterion_points, comment, author_name, variation, reviewer_type)`**, SECURITY DEFINER, callable by `authenticated` and `service_role`. It: + - checks the criterion stars: keys of the master's category only, 1 to 5 in half steps, the required criterion given, at most 2 left out. It stores each star at 0.1, whatever scale the caller sent; + - stores `rating_exact` as their weighted average at 0.1 (the same as `weightedReviewStar`), and `rating_star` as that value rounded; + - checks the verdict: 20 to 300 characters, with words in it; + - checks pros and cons: 1 to 5 each, 5 to 150 characters each, no line twice, no line in both lists, and no con that dodges; + - checks the lines of one criterion: up to 3 pros and 3 cons, under the same rules. As in `review-form.ts`, nothing compares a criterion's lines with the overall lists or with another criterion's, and a criterion the writer left unrated may still have lines; + - writes the review, one `rate.review_points` row per line, the author row and the excluded flag, all in one transaction. A refused call writes nothing. + - Without free text, the verdict becomes the review's `comment`, so the analyzer and v1 readers still have text. +- **`review_points_position_key`:** one row per review, criterion, kind and position. + +## Same as v1 + +- Only a signed-in, non-anonymous user or the service role can submit. +- The review goes on a crawled listing of the master. +- One review per user and master, and at most 5 reviews per user in 24 hours. v1 and v2 share the per-user lock. +- The keyword rules apply, now over the verdict and the lines too. They only exclude. +- A new review starts excluded until the analyzer decides. + +v1 stays unchanged and granted, so the current form works until the web calls v2. A later migration revokes v1. + +## Errors + +- Messages are in Vietnamese, with SQLSTATE 22023. +- The field is in `DETAIL`, so the API can mark it: + - `criteria`, or `criteria.`; + - `verdict`; + - `pros`, or `pros.`; + - `cons`, or `cons.`; + - `criterion_points...`; + - `comment`. +- The gates keep v1's codes: + - 42501: not signed in, or an anonymous account; + - 23505: the user already reviewed this master; + - 54000: more than 5 reviews in 24 hours; + - P0002: no product, or no listing. + +## Differences from the TypeScript rules + +- **Invisible characters:** both sides strip the same set: every format character (`\p{Cf}`, including the tags of flag emoji), the emoji variation selectors and the keycap mark. + - The replay test checks every such code point. + - With this PR, `review-form.ts` also strips them. Before, "Không có gì ☺️" passed as a con, because the variation selector survived. +- **Same rules on both sides:** comparison turns anything but letters, digits and spaces into a space, and lengths count code points of the stored form. +- **Known differences:** both are rare in Vietnamese reviews, and V7 checks the cases that matter. + - Case folding follows Unicode simple mapping in SQL; JavaScript gives a final sigma ("ς"). + - Some scripts' combining marks are kept by JavaScript composition but turned into spaces by the SQL class. +- **Corpus:** the replay test compares the compared form, the dodge check, the stored form and the length on a corpus of real and tricky lines: + - diacritics, dodges, upper case and fullwidth letters; + - zero-width and tag characters, variation-selector, keycap and flag emoji; + - decomposed input, and astral private-use characters. +- **Unknown category:** the RPC refuses a master whose category has no list (or no category). `getRatingCriteria` falls back to the smartphone list, so the API must check `hasRatingCriteria` before it shows the form. + +## Rollout + +1. Preview: Supabase Preview applies it on the DOS-Me PR. Then run the verify file there, read-only. +2. Beta: the native GitHub Integration applies it on merge to `dev`. Then run the verify file. +3. Production: a selective promote PR into `main`, after JOY's approval, as for P2. +4. Web (P3, #1544): the form and `POST /api/v1/rate/reviews` call v2, and map `DETAIL` to the field. +5. Rollback: `-rollback.sql`, only while no web build calls v2. Reviews written through v2 keep their columns and lines. + +## Verification + +- **V1:** 36 version 1 rows; each category weighs 100, with one required criterion. +- **V2:** the seven functions exist; the RPC is SECURITY DEFINER with an empty `search_path`. +- **V3:** `anon` cannot run the RPC or the helpers; `authenticated` and `service_role` can. +- **V4:** every v2 review has a verdict, a 0.1 star, a pro and a con. +- **V5:** every v2 review's star equals its 0.1 star rounded. +- **V6:** the position key exists. +- **V7:** the helpers read Vietnamese as shared-data does on this database: upper case, diacritics, a dodge, emoji marks, length. +- **V8** (for information): the number of v2 reviews and lines. diff --git a/Docs/Proposals/2026-11-01-dos-me-review-submission-v2.sql b/Docs/Proposals/2026-11-01-dos-me-review-submission-v2.sql new file mode 100644 index 000000000..79ae84334 --- /dev/null +++ b/Docs/Proposals/2026-11-01-dos-me-review-submission-v2.sql @@ -0,0 +1,573 @@ +-- PROPOSAL, NOT APPLIED. DOS-Me migration for phase P3 of the criteria ratings design +-- (DOS/Rate#1544; design Docs/Proposals/2026-10-31-criteria-stars-pros-cons.html, sections 2 +-- and 4.2; decisions D1, D5 approved by JOY on 2026-10-31). It needs P2 (DOS/Rate#1543, +-- DOS/DOS.Me#1489) applied first. JOY reviews and applies it through DOS-Me (preview, then beta, +-- then prod). Zero DDL on prod from DOS/Rate. +-- Rollback: 2026-11-01-dos-me-review-submission-v2-rollback.sql. +-- Verification: 2026-11-01-dos-me-review-submission-v2-verify.sql. +-- Replay test: scripts/test-review-submission-v2-proposal.mjs (Docker, never a real database). +-- +-- 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; diff --git a/apps/importer/package.json b/apps/importer/package.json index 7318a951b..a37c606d3 100644 --- a/apps/importer/package.json +++ b/apps/importer/package.json @@ -37,9 +37,10 @@ "lint": "eslint . --max-warnings 0", "format": "prettier --write \"src/**/*.ts\"", "backfill:specs": "tsx src/shopee/jobs/backfill-specs.ts", - "test": "tsx src/shopee/__tests__/open-core-intelligence.test.ts && tsx src/shopee/__tests__/sourced-seeds.test.ts && tsx src/shopee/__tests__/entity-matcher.test.ts && tsx src/shopee/__tests__/item-matcher.test.ts && tsx src/shopee/__tests__/shopee-to-supabase.test.ts && tsx src/shopee/__tests__/paging.test.ts && tsx src/shopee/__tests__/backfill-review-risk.test.ts && tsx src/shopee/__tests__/item-inventory-report.test.ts && tsx src/shopee/__tests__/arena-audit.test.ts && tsx src/shopee/__tests__/item-aware-jobs.test.ts && tsx src/shopee/test-ai-enricher.ts && tsx src/shopee/test-review-analyzer.ts", + "test": "tsx src/shopee/__tests__/open-core-intelligence.test.ts && tsx src/shopee/__tests__/sourced-seeds.test.ts && tsx src/shopee/__tests__/entity-matcher.test.ts && tsx src/shopee/__tests__/item-matcher.test.ts && tsx src/shopee/__tests__/shopee-to-supabase.test.ts && tsx src/shopee/__tests__/paging.test.ts && tsx src/shopee/__tests__/backfill-review-risk.test.ts && tsx src/shopee/__tests__/item-inventory-report.test.ts && tsx src/shopee/__tests__/arena-audit.test.ts && tsx src/shopee/__tests__/item-aware-jobs.test.ts && tsx src/shopee/__tests__/product-points.test.ts && tsx src/shopee/test-ai-enricher.ts && tsx src/shopee/test-review-analyzer.ts", "test:dist": "node src/shopee/__tests__/dist-exports.smoke.mjs", - "backfill:review-risk": "tsx src/shopee/jobs/backfill-review-risk.ts" + "backfill:review-risk": "tsx src/shopee/jobs/backfill-review-risk.ts", + "product-points": "tsx src/shopee/jobs/product-points.ts" }, "keywords": [ "product-normalizer", diff --git a/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs b/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs index a7a985ba2..ab05ac842 100644 --- a/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs +++ b/apps/importer/src/shopee/__tests__/dist-exports.smoke.mjs @@ -27,6 +27,7 @@ for (const name of [ 'computeRetryDelayMs', 'resolveItemModelMode', 'UnkeyableProductTitleError', + 'NonNewListingError', ]) { assert.equal( typeof crawlers[name], diff --git a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts index 7b38c2616..e6cdb7c5b 100644 --- a/apps/importer/src/shopee/__tests__/entity-matcher.test.ts +++ b/apps/importer/src/shopee/__tests__/entity-matcher.test.ts @@ -261,6 +261,73 @@ async function runEntityMatcherSuite() { 'new', 'Trade-in promotion ("thu cũ đổi mới") must not misclassify a new phone as used' ); + for (const description of [ + 'Nhận thu máy cũ giá cao', + 'Hỗ trợ thu mua máy cũ, trả góp 0%', + 'Thu cũ lên đời, bảo hành 12 tháng', + 'Đổi máy cũ lấy máy mới tại cửa hàng', + 'Thu máy cũ đổi máy mới, trợ giá', + 'Thu mua máy cũ giá cao', + 'Mới 100% - Thu mua điện thoại cũ giá cao', + ]) { + assert.equal( + detectProductCondition('Điện thoại Samsung Galaxy S24 Ultra 256GB', description), + 'new', + `A trade-in offer (${description}) does not make a new listing used (DOS/Rate#1430)` + ); + } + assert.equal( + detectProductCondition('Điện thoại Samsung Galaxy S24 Ultra 256GB', 'Máy cũ, pin 90%'), + 'used' + ); + // A new device's title offering a trade-in in any order stays new (DOS/Rate#1430). + for (const title of [ + 'Samsung Galaxy S24 Ultra 256GB - Trợ giá lên đời máy cũ', + 'Samsung Galaxy S24 Ultra 256GB Lên đời máy cũ trợ giá 2 triệu', + 'Samsung Galaxy S24 Ultra 256GB Hỗ trợ đổi máy cũ', + 'iPhone 16 Pro Max 256GB Chính hãng VN/A - Đổi máy cũ', + 'iPhone 16 Pro Max 256GB Trợ giá máy cũ 1 triệu', + 'iPhone 16 Pro Max 256GB Thu cũ lên đời', + 'iPhone 16 Pro Max 256GB Máy cũ lên đời trợ giá', + 'iPhone 16 Pro Max 256GB Lên đời từ điện thoại cũ', + 'iPhone 16 Pro Max 256GB Thu đt cũ đổi mới', + 'iPhone 16 Pro Max 256GB Thu mua giá cao máy cũ', + 'iPhone 16 Pro Max 256GB Thu mua lại máy cũ', + 'iPhone 16 Pro Max 256GB Trợ giá 2 triệu cho máy cũ', + 'iPhone 16 Pro Max 256GB Lên đời - máy cũ trợ giá', + 'iPhone 16 Pro Max 256GB Lên-đời máy cũ', + ]) { + assert.equal(detectProductCondition(title), 'new', `${title} is a new device`); + } + // A real used listing stays used. + for (const title of [ + 'Điện thoại iPhone 12 Pro 128GB máy cũ đẹp', + 'iPhone 11 64GB Máy cũ giá rẻ bảo hành 3 tháng', + 'Samsung Galaxy S21 điện thoại cũ đã qua sử dụng', + 'Thanh lý máy cũ iPhone XS Max', + 'iPhone 13 cũ - máy cũ zin, trợ giá ship', + // A warranty swap is not a trade-in, and a bare "hỗ trợ" is not a trade-in verb. + 'iPhone 11 64GB máy cũ đổi mới trong 30 ngày', + 'iPhone 12 Pro hỗ trợ máy cũ', + ]) { + assert.equal(detectProductCondition(title), 'used', `${title} is a used device`); + } + assert.equal(detectProductCondition('Điện thoại cũ đổi mới 99%'), 'like_new_99'); + for (const title of [ + 'Tai nghe Apple AirPods Pro 2nd Gen USB-C Chính hãng VN/A', + 'Apple iPhone SE 2nd generation 64GB', + ]) { + assert.equal(detectProductCondition(title), 'new', `${title} is a new device`); + } + assert.equal(detectProductCondition('iPhone 13 128GB 2nd hand'), 'used'); + // A bare percentage is a spec or an ad, not a condition; one about the device is. + assert.equal( + detectProductCondition( + 'Tai nghe Anker Soundcore Liberty 4 NC Mới 100% Chống ồn 98% Chính hãng' + ), + 'new' + ); + assert.equal(detectProductCondition('iPhone 13 Pro 128GB máy 98% pin 99%'), 'like_new_99'); assert.equal( detectProductCondition('Điện thoại Apple iPhone 15 Pro Max 256GB Likenew 99% Zin Nguyên Bản'), 'like_new_99' diff --git a/apps/importer/src/shopee/__tests__/product-points.test.ts b/apps/importer/src/shopee/__tests__/product-points.test.ts new file mode 100644 index 000000000..300628437 --- /dev/null +++ b/apps/importer/src/shopee/__tests__/product-points.test.ts @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict'; +import { + buildProductPoints, + reviewPointWeight, + runProductPointsJob, + type PointReviewRow, + type ReviewPointRow, +} from '../jobs/product-points.js'; +import type { RateSupabaseClient } from '../types.js'; + +const NOW = new Date('2026-11-01T00:00:00Z'); +const PHONES = 'c0000000-0000-4000-8000-000000000001'; +const BUDS = 'c0000000-0000-4000-8000-000000000002'; +const PHONE = 'a0000000-0000-4000-8000-000000000001'; +const EARBUD = 'a0000000-0000-4000-8000-000000000002'; +const QUIET = 'a0000000-0000-4000-8000-000000000003'; + +const review = ( + id: string, + product_id: string, + extra: Partial = {} +): PointReviewRow => ({ + id, + product_id, + origin: 'crawl', + reviewer_type: 'user', + helpfulness_weight: 1, + created_at: '2026-11-01T00:00:00Z', + platform_created_at: null, + review_flags: { include_in_rating: true, verdict: 'authentic' }, + ...extra, +}); +let lineNo = 0; +const point = ( + review_id: string, + kind: string, + body: string, + criterion_key: string | null = null +): ReviewPointRow => { + lineNo += 1; + return { id: `p${String(lineNo).padStart(3, '0')}`, review_id, kind, body, criterion_key }; +}; + +const categories = [ + { id: PHONES, slug: 'smartphones' }, + { id: BUDS, slug: 'earbuds' }, +]; +const masters = [ + { id: PHONE, category_id: PHONES }, + { id: EARBUD, category_id: BUDS }, + { id: QUIET, category_id: PHONES }, +]; +const listings = [ + { id: 'l-phone', cluster_id: PHONE, origin: 'crawl' }, + { id: 'l-buds', cluster_id: EARBUD, origin: 'crawl' }, + { id: 'l-quiet', cluster_id: QUIET, origin: 'crawl' }, + // A fabricated listing of the phone: its reviews never count. + { id: 'l-seed', cluster_id: PHONE, origin: 'seed' }, +]; +const reviews = [ + review('r1', 'l-phone'), + review('r2', 'l-phone', { helpfulness_weight: '0.8' }), + review('r3', 'l-phone', { helpfulness_weight: 3 }), + // Excluded by the analyzer: its lines do not count. + review('r4', 'l-phone', { review_flags: { include_in_rating: false, verdict: 'suspicious' } }), + review('r5', 'l-buds'), + review('r6', 'l-buds'), + // An expert review Rate published, not analyzed yet. + review('r7', 'l-buds', { + reviewer_type: 'expert', + helpfulness_weight: 0, + review_flags: [{ include_in_rating: false, verdict: 'unanalyzed' }], + }), + // A hostile writer on the quiet master: one review, many cons. + review('r8', 'l-quiet', { helpfulness_weight: 3 }), + // Seed data: a seed review on a crawled listing, a crawled review on a seed listing. + review('r9', 'l-phone', { origin: 'seed' }), + review('r10', 'l-seed'), +]; +const points = [ + point('r1', 'pro', 'Pin trâu, dùng cả ngày'), + point('r2', 'pro', 'pin rất trâu dùng cả ngày'), + point('r1', 'con', 'Giá hơi cao so với đối thủ'), + point('r3', 'con', 'Máy hơi nóng khi chơi game', 'performance'), + point('r4', 'con', 'Máy hơi nóng khi chơi game', 'performance'), + point('r4', 'pro', 'Pin trâu dùng cả ngày luôn'), + point('r5', 'pro', 'Chống ồn tốt khi đi tàu'), + point('r6', 'pro', 'chống ồn rất tốt khi đi tàu'), + point('r7', 'con', 'Mic thu tiếng gió khá nhiều', 'calls'), + point('r8', 'con', 'Pin tụt nhanh kinh khủng'), + point('r8', 'con', 'Màn hình ám vàng rõ'), + // Without the origin filter these two would make the price con show. + point('r9', 'con', 'Giá hơi cao so với đối thủ'), + point('r10', 'con', 'giá hơi cao so với đối thủ'), +]; + +async function run() { + console.log('🧪 Product points job suite (#1546)'); + + // Weights: included user reviews keep theirs, excluded or pending ones are 0, + // an expert counts at least 1 unless the analyzer excluded it. + assert.equal(reviewPointWeight(review('a', 'l')), 1); + assert.equal(reviewPointWeight(review('a', 'l', { helpfulness_weight: '2.5' })), 2.5); + assert.equal( + reviewPointWeight( + review('a', 'l', { review_flags: { include_in_rating: false, verdict: 'unanalyzed' } }) + ), + 0 + ); + assert.equal(reviewPointWeight(review('a', 'l', { review_flags: null })), 0); + // A NULL include counts, as in the P2 rating (COALESCE(include_in_rating, true)). + assert.equal( + reviewPointWeight( + review('a', 'l', { review_flags: { include_in_rating: null, verdict: null } }) + ), + 1 + ); + assert.equal(reviewPointWeight(review('a', 'l', { helpfulness_weight: 'NaN' })), 0); + assert.equal(reviewPointWeight(reviews[6]!), 1); + assert.equal( + reviewPointWeight( + review('a', 'l', { + reviewer_type: 'expert', + review_flags: { include_in_rating: false, verdict: 'suspicious' }, + }) + ), + 0 + ); + + const built = buildProductPoints({ points, reviews, listings, masters, categories }, NOW); + const phone = built.get(PHONE)!; + // Two writers (1 + 0.8) agree on the battery; review 4 is excluded, so the + // heat con has one writer (r3) and does not show; the price con has one too. + assert.deepEqual( + phone.map(p => [p.criterion_key, p.kind, p.label, p.support, p.reviewer_count]), + [['battery', 'pro', 'Pin trâu, dùng cả ngày', 1.8, 2]] + ); + assert.equal(phone[0]!.method, 'code'); + assert.equal(phone[0]!.updated_at, NOW.toISOString()); + // Earbuds use their own criteria: "chống ồn" is ANC; the expert's mic con + // shows on its own. + const buds = built.get(EARBUD)!; + assert.deepEqual(buds.map(p => [p.criterion_key, p.kind, p.has_expert]).sort(), [ + ['anc', 'pro', false], + ['calls', 'con', true], + ]); + // One hostile writer shows nothing; the master is listed so old rows clear. + assert.deepEqual(built.get(QUIET), []); + + // The job against a fake client: a dry run writes nothing; --apply inserts + // the new rows before deleting the old 'code' rows, and clears a master whose + // lines no longer show anything. + const fake = fakeSupabase({ + review_points: points, + reviews, + products: listings, + master_products: masters, + categories, + product_points: [ + { id: 'old-1', master_id: PHONE, method: 'code' }, + { id: 'old-2', master_id: QUIET, method: 'code' }, + { id: 'ai-1', master_id: PHONE, method: 'ai' }, + ], + }); + const dry = await runProductPointsJob(fake.client, { now: NOW }); + assert.deepEqual(dry, { apply: false, masters: 3, points: 3, inserted: 0, deleted: 0 }); + assert.ok(fake.filters.includes('review_points:method=writer'), "only the writers' lines"); + assert.equal(fake.writes.length, 0, 'a dry run writes nothing'); + + const applied = await runProductPointsJob(fake.client, { apply: true, now: NOW }); + assert.deepEqual(applied, { apply: true, masters: 3, points: 3, inserted: 3, deleted: 2 }); + assert.deepEqual( + fake.writes.map(w => `${w.op}:${w.detail}`), + [`insert:${PHONE}x1`, 'delete:old-1', `insert:${EARBUD}x2`, 'delete:old-2'] + ); + assert.ok(!fake.writes.some(w => w.detail.includes('ai-1')), "'ai' rows stay"); + + // One master only. + const one = fakeSupabase({ + review_points: points, + reviews, + products: listings, + master_products: masters, + categories, + product_points: [], + }); + const single = await runProductPointsJob(one.client, { apply: true, masterId: EARBUD, now: NOW }); + assert.equal(single.masters, 1); + assert.deepEqual( + one.writes.map(w => `${w.op}:${w.detail}`), + [`insert:${EARBUD}x2`] + ); + await assert.rejects(runProductPointsJob(one.client, { masterId: 'not-a-uuid' }), /UUID/); + + console.log(' ✅ weights, aggregation per master and category, dry run and apply verified'); +} + +type Row = Record; + +/** + * A tiny stand-in for the Supabase query builder: select with order/range, + * in, eq, insert and delete().in(). It records writes. + */ +function fakeSupabase(tables: Record) { + const writes: { op: string; detail: string }[] = []; + const filters: string[] = []; + const query = (table: string) => { + let rows = [...(tables[table] ?? [])] as Row[]; + const builder = { + select: () => builder, + order: () => builder, + eq: (column: string, value: unknown) => { + filters.push(`${table}:${column}=${String(value)}`); + // Fixture rows without the column (review lines have no method) pass. + rows = rows.filter(r => r[column] === undefined || r[column] === value); + return builder; + }, + in: (column: string, values: unknown[]) => { + rows = rows.filter(r => values.includes(r[column])); + return builder; + }, + range: (from: number, to: number) => + Promise.resolve({ data: rows.slice(from, to + 1), error: null }), + then: (resolve: (value: { data: Row[]; error: null }) => unknown) => + Promise.resolve({ data: rows, error: null }).then(resolve), + }; + return builder; + }; + const client = { + from: (table: string) => ({ + ...query(table), + insert: (payload: Row[]) => { + writes.push({ op: 'insert', detail: `${String(payload[0]?.master_id)}x${payload.length}` }); + return Promise.resolve({ error: null }); + }, + delete: () => ({ + in: (_column: string, ids: string[]) => { + writes.push({ op: 'delete', detail: ids.join(',') }); + return Promise.resolve({ error: null }); + }, + }), + }), + }; + return { client: client as unknown as RateSupabaseClient, writes, filters }; +} + +run().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts index c5e3aee96..68826178d 100644 --- a/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts +++ b/apps/importer/src/shopee/__tests__/shopee-to-supabase.test.ts @@ -17,7 +17,7 @@ import { normalizeScrapedProductPrices, platformReviewIdFor, } from '../shopee-to-supabase.js'; -import { UnkeyableProductTitleError } from '../product-normalizer.js'; +import { NonNewListingError, UnkeyableProductTitleError } from '../product-normalizer.js'; import type { RateSupabaseClient, ShopeeProduct, ShopeeReview } from '../types.js'; function createBaseProduct(overrides: Partial = {}): ShopeeProduct { @@ -532,6 +532,48 @@ async function runTests() { UnkeyableProductTitleError ); assert.equal(accessoryEvents.productUpserts.length, 0); + // A used, like-new or refurbished listing is rejected before anything is written, with + // or without a hint: its price would become the new device's min_price (DOS/Rate#1430). + for (const [title, hint, condition] of [ + ['Like new 99% iPhone 15 128GB', undefined, 'like_new_99'], + ['Điện thoại iPhone 14 Pro Max 128GB Máy cũ đã qua sử dụng', 'master-hint-verified', 'used'], + ['Apple iPhone 15 Pro 256GB Hàng Tân Trang Apple CPO', 'master-hint-verified', 'refurbished'], + // A control character inside the phrase is stripped before the check. + ['Apple iPhone 14 Pro Max 128GB Máy\u0000 cũ', 'master-hint-verified', 'used'], + ] as const) { + const { stub: nonNewStub, events: nonNewEvents } = createPipelineStubSupabase({ + hintRow: { id: 'master-hint-verified' }, + }); + await assert.rejects( + new ShopeeIngestionPipeline({ supabaseClient: nonNewStub, apiKey: '' }).ingestProduct( + createBaseProduct({ title }), + undefined, + hint ? { masterIdHint: hint } : {} + ), + (err: unknown) => + err instanceof NonNewListingError && + err.code === 'NON_NEW_LISTING' && + err.condition === condition + ); + assert.equal(nonNewEvents.productUpserts.length, 0, `${title} writes no listing`); + } + // Only the title rejects: description boilerplate that reads as non-new (a negation, + // "99% khách hàng") keeps a new listing (DOS/Rate#1430). + for (const description of [ + 'Nhận thu máy cũ, hỗ trợ trả góp 0%', + 'Cam kết hàng chính hãng, không bán hàng cũ, hàng tân trang', + '99% khách hàng hài lòng', + ]) { + const { stub: descStub, events: descEvents } = createPipelineStubSupabase({ + hintRow: { id: 'master-hint-verified' }, + }); + await new ShopeeIngestionPipeline({ supabaseClient: descStub, apiKey: '' }).ingestProduct( + createBaseProduct({ description }), + undefined, + { masterIdHint: 'master-hint-verified' } + ); + assert.equal(descEvents.productUpserts.length, 1, `${description} keeps the listing`); + } assert.equal(hintOkEvents.productUpserts[0]?.cluster_id, 'master-hint-verified'); assert.equal(hintOkEvents.productUpserts[0]?.category_id, 'cat-smartphones'); assert.equal( diff --git a/apps/importer/src/shopee/jobs/product-points.ts b/apps/importer/src/shopee/jobs/product-points.ts new file mode 100644 index 000000000..b02a7f172 --- /dev/null +++ b/apps/importer/src/shopee/jobs/product-points.ts @@ -0,0 +1,397 @@ +/** + * Product pros and cons writer (DOS/Rate#1546, design #1531 section 5, D9) + * + * Usage: + * pnpm --filter @repo/crawlers exec tsx src/shopee/jobs/product-points.ts [--apply] [--master ] + * + * Reads every review line (rate.review_points) with its review, flag, listing + * and category, clusters the lines of each master with + * aggregateProductPoints (@repo/shared-data/rules), and writes the points it + * shows to rate.product_points (method 'code'). Dry-run by default: it prints + * what it would write. `--apply` writes, as the rate_worker role: the job + * signs in as the worker's Supabase Auth user (RATE_WORKER_EMAIL and + * RATE_WORKER_PASSWORD, with the project's publishable key), whose access + * token carries that role. It may read the review tables and write only + * product_points. + * + * Which lines count, as the P2 rating counts reviews: lines the writer wrote + * (method 'writer'), on crawled listings (origin 'crawl'), of crawled or + * community reviews; seed, synthetic, fixture and backfill rows never show. + * A review is one writer: a Rate user writes one review per master. Two + * reviews of one person on one master (after the matcher moves a listing) or + * several marketplace reviews of one buyer count as several writers; the + * analyzer's weights and D9's weight sum limit what that can show. + * + * Run it from one place at a time (the importer's schedule). Two runs at once + * can leave both runs' rows until the next run, which replaces them. + * + * Per master, new rows are inserted before the old 'code' rows are deleted, so + * a reader never sees a master with no points while the job runs. A master + * whose lines no longer show anything has its 'code' rows removed. Rows of + * another method ('ai', the rewording step of design 5.2) are left alone. + * + * Not here yet: review_points.cluster_id (rate_worker has no UPDATE on + * review_points) and lines extracted by AI from marketplace reviews. + */ +import { pathToFileURL } from 'node:url'; +import dotenv from 'dotenv'; +import { aggregateProductPoints, type PointCluster, type PointLine } from '@repo/shared-data/rules'; +import { fetchAllPages, resolveBackfillSupabaseTarget } from '../paging.js'; +import { createImporterLogger } from '../logger.js'; +import { scopeSupabaseToRate, type RateSupabaseClient } from '../types.js'; + +const logger = createImporterLogger('ProductPointsJob'); + +/** IDs per `in` filter, so a request URL stays short. */ +const IN_CHUNK = 150; + +export interface ReviewPointRow { + id: string; + review_id: string; + criterion_key: string | null; + kind: string; + body: string; +} + +export interface PointReviewRow { + id: string; + product_id: string; + origin: string | null; + reviewer_type: string | null; + helpfulness_weight: number | string | null; + created_at: string | null; + platform_created_at: string | null; + review_flags: + | { include_in_rating: boolean | null; verdict: string | null } + | { include_in_rating: boolean | null; verdict: string | null }[] + | null; +} + +export interface PointListingRow { + id: string; + cluster_id: string | null; + origin: string | null; +} + +export interface PointMasterRow { + id: string; + category_id: string | null; +} + +export interface CategoryRow { + id: string; + slug: string; +} + +/** One rate.product_points row to insert. */ +export interface ProductPointInsert { + master_id: string; + criterion_key: string | null; + kind: 'pro' | 'con'; + label: string; + support: number; + reviewer_count: number; + has_expert: boolean; + first_seen: string; + last_seen: string; + method: 'code'; + updated_at: string; +} + +const flagOf = ( + review: PointReviewRow +): { include_in_rating: boolean | null; verdict: string | null } | null => + Array.isArray(review.review_flags) ? (review.review_flags[0] ?? null) : review.review_flags; + +/** Reviews whose lines may count: crawled or community, never seed or test data. */ +const COUNTED_REVIEW_ORIGINS = new Set(['crawl', 'community']); + +/** + * The weight a review's lines carry. A user review counts as the P2 rating + * counts it: it has a flag that does not exclude it (a NULL include counts), + * with its stored weight; weight 0 is the gate of design 5.1. An expert review + * Rate published counts with weight 1 at least, unless the analyzer excluded + * it after looking at it (D9: one expert shows a point). + */ +export function reviewPointWeight(review: PointReviewRow): number { + const flag = flagOf(review); + const stored = Number(review.helpfulness_weight ?? 0); + const weight = Number.isFinite(stored) && stored > 0 ? stored : 0; + if (review.reviewer_type === 'expert') { + const excluded = + flag !== null && + flag.include_in_rating === false && + (flag.verdict ?? 'unanalyzed') !== 'unanalyzed'; + return excluded ? 0 : Math.max(1, weight); + } + return flag !== null && flag.include_in_rating !== false ? weight : 0; +} + +/** + * The points to write per master. A Rate user writes one review per master, + * so a review is a writer (as in the P2 rating). Masters with lines but + * nothing to show map to an empty list, so their old rows are cleared. + */ +export function buildProductPoints( + input: { + points: readonly ReviewPointRow[]; + reviews: readonly PointReviewRow[]; + listings: readonly PointListingRow[]; + masters: readonly PointMasterRow[]; + categories: readonly CategoryRow[]; + }, + now: Date +): Map { + const reviewById = new Map(input.reviews.map(r => [r.id, r])); + const masterByListing = new Map( + input.listings + .filter(l => typeof l.cluster_id === 'string' && l.cluster_id && l.origin === 'crawl') + .map(l => [l.id, l.cluster_id as string]) + ); + const slugByCategory = new Map(input.categories.map(c => [c.id, c.slug])); + const categoryByMaster = new Map( + input.masters.map(m => [ + m.id, + m.category_id ? (slugByCategory.get(m.category_id) ?? null) : null, + ]) + ); + + const linesByMaster = new Map(); + for (const point of input.points) { + const review = reviewById.get(point.review_id); + if (!review || !COUNTED_REVIEW_ORIGINS.has(review.origin ?? '')) continue; + const masterId = masterByListing.get(review.product_id); + if (!masterId || !categoryByMaster.has(masterId)) continue; + if (point.kind !== 'pro' && point.kind !== 'con') continue; + const lines = linesByMaster.get(masterId) ?? []; + lines.push({ + id: point.id, + reviewId: review.id, + writerId: review.id, + kind: point.kind, + criterionKey: point.criterion_key, + body: point.body, + weight: reviewPointWeight(review), + isExpert: review.reviewer_type === 'expert', + writtenAt: review.platform_created_at ?? review.created_at ?? '', + }); + linesByMaster.set(masterId, lines); + } + + const out = new Map(); + const stamp = now.toISOString(); + for (const [masterId, lines] of linesByMaster) { + const result = aggregateProductPoints(categoryByMaster.get(masterId), lines, now); + // What a page shows: the top points of each criterion and the summary. + const shown = new Map(); + for (const sides of [...Object.values(result.byCriterion), result.summary]) + for (const cluster of [...sides.pros, ...sides.cons]) shown.set(cluster.key, cluster); + out.set( + masterId, + [...shown.values()].map(c => ({ + master_id: masterId, + criterion_key: c.criterionKey, + kind: c.kind, + label: c.label, + support: c.support, + reviewer_count: c.reviewerCount, + has_expert: c.hasExpert, + first_seen: c.firstSeen.toISOString(), + last_seen: c.lastSeen.toISOString(), + method: 'code' as const, + updated_at: stamp, + })) + ); + } + return out; +} + +function chunks(items: readonly T[], size = IN_CHUNK): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +async function selectIn( + supabase: RateSupabaseClient, + table: string, + columns: string, + column: string, + ids: readonly string[] +): Promise { + const rows: T[] = []; + for (const part of chunks([...new Set(ids)])) { + const { data, error } = await supabase.from(table).select(columns).in(column, part); + if (error) throw new Error(`${table}: ${error.message}`); + rows.push(...((data ?? []) as T[])); + } + return rows; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function runProductPointsJob( + supabase: RateSupabaseClient, + options: { apply?: boolean; masterId?: string | null; now?: Date } = {} +): Promise<{ + apply: boolean; + masters: number; + points: number; + inserted: number; + deleted: number; +}> { + const apply = options.apply === true; + const now = options.now ?? new Date(); + const only = options.masterId ?? null; + if (only !== null && !UUID.test(only)) throw new Error('--master must be a UUID.'); + + const points = await fetchAllPages('review_points', (from, to) => + supabase + .from('review_points') + .select('id, review_id, criterion_key, kind, body') + .eq('method', 'writer') + .order('id') + .range(from, to) + ); + const reviews = await selectIn( + supabase, + 'reviews', + 'id, product_id, origin, reviewer_type, helpfulness_weight, created_at, platform_created_at, review_flags(include_in_rating, verdict)', + 'id', + points.map(p => p.review_id) + ); + const listings = await selectIn( + supabase, + 'products', + 'id, cluster_id, origin', + 'id', + reviews.map(r => r.product_id) + ); + // Masters that have 'code' rows now: they are rewritten or cleared too. + const existing = await fetchAllPages<{ id: string; master_id: string }>( + 'product_points', + (from, to) => + supabase + .from('product_points') + .select('id, master_id') + .eq('method', 'code') + .order('id') + .range(from, to) + ); + const masterIds = [ + ...new Set([ + ...listings.map(l => l.cluster_id).filter((id): id is string => !!id), + ...existing.map(row => row.master_id), + ]), + ].filter(id => only === null || id === only); + const masters = await selectIn( + supabase, + 'master_products', + 'id, category_id', + 'id', + masterIds + ); + const categories = await fetchAllPages('categories', (from, to) => + supabase.from('categories').select('id, slug').order('id').range(from, to) + ); + + const built = buildProductPoints({ points, reviews, listings, masters, categories }, now); + for (const id of masterIds) if (!built.has(id)) built.set(id, []); + const oldIdsByMaster = new Map(); + for (const row of existing) { + const ids = oldIdsByMaster.get(row.master_id) ?? []; + ids.push(row.id); + oldIdsByMaster.set(row.master_id, ids); + } + + let inserted = 0; + let deleted = 0; + let total = 0; + for (const id of masterIds) { + const rows = built.get(id) ?? []; + const oldIds = oldIdsByMaster.get(id) ?? []; + total += rows.length; + logger.info( + 'product_points.master', + `${apply ? 'Writing' : 'Would write'} ${rows.length} points for ${id} (replacing ${oldIds.length}).`, + { master_id: id, points: rows.length, replaced: oldIds.length } + ); + if (!apply) continue; + if (rows.length > 0) { + const { error } = await supabase.from('product_points').insert(rows); + if (error) throw new Error(`product_points insert for ${id}: ${error.message}`); + inserted += rows.length; + } + for (const part of chunks(oldIds)) { + const { error } = await supabase.from('product_points').delete().in('id', part); + if (error) throw new Error(`product_points delete for ${id}: ${error.message}`); + deleted += part.length; + } + } + logger.info( + 'product_points.done', + `${apply ? 'Wrote' : 'Dry run:'} ${total} points for ${masterIds.length} masters from ${points.length} review lines.`, + { apply, masters: masterIds.length, points: total, inserted, deleted } + ); + return { apply, masters: masterIds.length, points: total, inserted, deleted }; +} + +const isDirectExecution = + typeof process !== 'undefined' && + Array.isArray(process.argv) && + Boolean(process.argv[1]) && + import.meta.url === pathToFileURL(process.argv[1]!).href; + +if (isDirectExecution) { + dotenv.config({ quiet: true }); + dotenv.config({ path: '../../.env', quiet: true }); + (async () => { + const { createClient } = await import('@supabase/supabase-js'); + const rawUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || ''; + // The publishable key plus the worker user's sign-in: its access token + // carries the rate_worker role, which can read the review tables and write + // only product_points, so a mistake here cannot touch anything else. + const key = ( + process.env.SUPABASE_PUBLISHABLE_KEY || + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || + '' + ).trim(); + const email = (process.env.RATE_WORKER_EMAIL || '').trim(); + const password = process.env.RATE_WORKER_PASSWORD || ''; + if (!rawUrl.trim() || !key || !email || !password) { + throw new Error( + 'Missing SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, RATE_WORKER_EMAIL or RATE_WORKER_PASSWORD in environment.' + ); + } + const { url, host } = resolveBackfillSupabaseTarget(rawUrl); + const client = createClient(url, key, { auth: { persistSession: false } }); + const { data: auth, error: authError } = await client.auth.signInWithPassword({ + email, + password, + }); + if (authError || !auth.session) { + throw new Error( + `Sign-in as the rate_worker user failed: ${authError?.message ?? 'no session'}` + ); + } + const role = (auth.user?.role ?? '').trim(); + if (role !== 'rate_worker') { + throw new Error(`The signed-in user has role '${role}', not rate_worker; refusing to run.`); + } + const supabase = scopeSupabaseToRate(client); + const apply = process.argv.includes('--apply'); + const at = process.argv.indexOf('--master'); + const masterId = at >= 0 ? (process.argv[at + 1] ?? '') : null; + // The host is not secret; it shows which project the job reads. + logger.info('product_points.target', `Target: ${host} (${apply ? 'apply' : 'dry run'})`, { + host, + }); + await runProductPointsJob(supabase, { apply, masterId }); + })().catch((err: unknown) => { + logger.error( + 'product_points.fatal', + `Product points job failed: ${err instanceof Error ? err.message : String(err)}` + ); + process.exit(1); + }); +} diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts index d5392bda9..5321efafe 100644 --- a/apps/importer/src/shopee/product-normalizer.ts +++ b/apps/importer/src/shopee/product-normalizer.ts @@ -96,6 +96,8 @@ export interface NormalizedProductCandidate { storage_gb: number | null; // null = unparseable from the title; identity carries ':unknown' needs_review: boolean; // true when identity could not be confidently derived or listing is non-new (plan v7 §5.2, Audit C6) condition?: ProductCondition; + /** The condition the sanitized title alone states; the ingest check reads it (DOS/Rate#1430). */ + title_condition?: ProductCondition; origin?: ProductOrigin; ram_gb: number | null; screen_size_inch: number | null; @@ -134,16 +136,61 @@ export class UnkeyableProductTitleError extends Error { } } +/** + * A used, like-new or refurbished listing. `rate.products` stores no condition, so + * linked to a master its lower price would become the new device's `min_price` and + * feed the deal radar. The listing is rejected before anything is written until + * condition is stored (DOS/Rate#1430). + */ +export class NonNewListingError extends Error { + readonly code = 'NON_NEW_LISTING'; + readonly condition: Exclude; + + constructor(title: unknown, condition: Exclude) { + super( + `Cannot ingest a non-new listing (condition=${condition}): ${JSON.stringify(String(title ?? '').slice(0, 200))}` + ); + this.name = 'NonNewListingError'; + this.condition = condition; + } +} + +/** + * A trade-in offer for the buyer's old device, which says nothing about the listing's own: + * a trade-in verb right before "(máy|điện thoại) cũ" ("thu mua máy cũ", "lên đời máy cũ", + * "trợ giá máy cũ", "hỗ trợ đổi máy cũ"), or "cũ" right before "lên đời" or "đổi/lấy mới" + * ("thu cũ đổi mới", "máy cũ lên đời"). A few filler words or an amount ("2 triệu") may sit + * between verb and device, and a hyphen, colon or comma may separate the words. "Mới" is not + * a trade-in when a number or a warranty term follows it ("đổi mới 99%", "đổi mới trong 30 + * ngày"), and a bare "hỗ trợ" is not a trade-in verb ("hỗ trợ máy cũ"). + */ +const SEP = String.raw`[\s\-:,]*`; +const OLD_DEVICE = String.raw`(?:(?:máy|điện\s*thoại|đt)${SEP})?cũ`; +const TRADE_IN_FILLER = String.raw`(?:(?:từ|cho|mọi|các|bằng|khi|lại|giá\s*cao|\d+(?:[.,]\d+)?\s*(?:triệu|tr|k|đ))${SEP})*`; +const TRADE_IN_TO_NEW = String.raw`(?:lên${SEP}đời|(?:lấy|đổi|sang)${SEP}(?:(?:máy|điện\s*thoại)${SEP})?mới(?!\s*(?:\d|trong|sau|nếu|khi)))`; +const TRADE_IN_PHRASE = new RegExp( + String.raw`(?:(?:hỗ\s*trợ${SEP})?(?:thu(?:\s*mua)?|đổi|lên${SEP}đời)|trợ\s*giá)${SEP}` + + TRADE_IN_FILLER + + OLD_DEVICE + + String.raw`(?:${SEP}${TRADE_IN_TO_NEW})?` + + '|' + + OLD_DEVICE + + SEP + + TRADE_IN_TO_NEW, + 'gi' +); + /** * Detect listing physical condition from product title and optional description (Audit C6). - * Strips promotional trade-in phrases ("thu cũ đổi mới") before matching so new retail listings - * offering trade-in programs are not misclassified as used devices. + * Strips promotional trade-in phrases ("thu cũ đổi mới", "thu máy cũ lên đời") before matching + * so new retail listings offering trade-in programs are not misclassified as used devices; a + * non-new listing is rejected at ingest (DOS/Rate#1430). */ export function detectProductCondition(title: string, description?: string): ProductCondition { const safeTitle = typeof title === 'string' ? title : ''; const safeDesc = typeof description === 'string' ? description : ''; - const cleanTitle = safeTitle.replace(/thu\s*cũ\s*đổi\s*mới/gi, ' '); - const cleanDesc = safeDesc.replace(/thu\s*cũ\s*đổi\s*mới/gi, ' '); + const cleanTitle = safeTitle.replace(TRADE_IN_PHRASE, ' '); + const cleanDesc = safeDesc.replace(TRADE_IN_PHRASE, ' '); const checkSource = (text: string): ProductCondition | null => { if ( @@ -154,14 +201,14 @@ export function detectProductCondition(title: string, description?: string): Pro return 'refurbished'; } if ( - /(?:\blike\s*new\b|\blikenew\b|mới\s*9[5-9]%|đẹp\s*9[5-9]%|zin\s*9[5-9]%|\b9[89]%|siêu\s*lướt|hàng\s*lướt|máy\s*lướt|hàng\s*trưng\s*bày|máy\s*trưng\s*bày|\bopen\s*box\b|đã\s*kích\s*hoạt\s*bảo\s*hành)/i.test( + /(?:\blike\s*new\b|\blikenew\b|mới\s*9[5-9]%|đẹp\s*9[5-9]%|zin\s*9[5-9]%|(?:máy|ngoại\s*hình|hình\s*thức|keng|pin)\s*9[5-9]%|siêu\s*lướt|hàng\s*lướt|máy\s*lướt|hàng\s*trưng\s*bày|máy\s*trưng\s*bày|\bopen\s*box\b|đã\s*kích\s*hoạt\s*bảo\s*hành)/i.test( text ) ) { return 'like_new_99'; } if ( - /(?:máy\s*cũ|hàng\s*cũ|điện\s*thoại\s*cũ|đã\s*qua\s*sử\s*dụng|\bsecond\s*hand\b|\b2nd\b|thanh\s*lý\s*máy\s*cũ|máy\s*trần\s*cũ)/i.test( + /(?:máy\s*cũ|hàng\s*cũ|điện\s*thoại\s*cũ|đã\s*qua\s*sử\s*dụng|\bsecond\s*hand\b|\b2nd\s*hand\b|thanh\s*lý\s*máy\s*cũ|máy\s*trần\s*cũ)/i.test( text ) ) { @@ -3218,6 +3265,7 @@ export class ProductNormalizer { storage_gb: storageKnown ? storageGb : null, needs_review: !keyable || !storageConfident || condition !== 'new', condition, + title_condition: detectProductCondition(title), origin, ram_gb: ramGb, screen_size_inch: screenSize, diff --git a/apps/importer/src/shopee/shopee-to-supabase.ts b/apps/importer/src/shopee/shopee-to-supabase.ts index f2730ced5..7f4533247 100644 --- a/apps/importer/src/shopee/shopee-to-supabase.ts +++ b/apps/importer/src/shopee/shopee-to-supabase.ts @@ -18,6 +18,7 @@ import { } from './types.js'; import { isAccessoryTitle, + NonNewListingError, ProductNormalizer, UnkeyableProductTitleError, } from './product-normalizer.js'; @@ -538,6 +539,14 @@ export class ShopeeIngestionPipeline { if (candidate.keyable === false && (!options.masterIdHint || isAccessoryTitle(product.title))) { throw new UnkeyableProductTitleError(product.title); } + // A used, like-new or refurbished listing is rejected even with a hint: with no + // stored condition, its price would become the new device's min_price (DOS/Rate#1430). + // Only the title decides: description boilerplate ("không bán hàng cũ", "99% khách + // hàng hài lòng") would drop new listings, so a description hit still only sets needs_review. + const condition = candidate.title_condition ?? 'new'; + if (condition !== 'new') { + throw new NonNewListingError(product.title, condition); + } // 1. Upsert merchant const entityHash = hashEntity(`${platform}:${product.shop.shopId}`); diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json index c791a0c65..32119b2cb 100644 --- a/apps/web/locales/cs.json +++ b/apps/web/locales/cs.json @@ -124,7 +124,7 @@ } }, "compare": { - "criteriaSourceExpert": "Hodnocení kritérií od expertů", + "criteriaSourceExpert": "Hodnocení kritérií od recenzentů nebo expertů", "criterionNoData": "Nedostatek dat", "versusMetaTitle": "{nameA} vs {nameB}: 10 skupin parametrů a hodnocení | Rate.vn", "versusMetaTitleShort": "{nameA} vs {nameB} | Rate.vn", @@ -346,7 +346,7 @@ "quickToggleShortLimit": "Max. 4" }, "leaderboard": { - "criteriaSourceExpert": "Hodnocení kritérií od expertů", + "criteriaSourceExpert": "Hodnocení kritérií od recenzentů nebo expertů", "criterionNoData": "Nedostatek dat", "metaTitle": "Žebříček technologií | Rate.vn", "metaDescription": "Nezávislý žebříček podle kategorií: hodnocení expertů a ověřených kupujících v 5 kritériích, bez reklam a affiliate odkazů.", @@ -579,7 +579,34 @@ "reportTitle": "Nahlásit falešnou, spamovou nebo sabotážní recenzi (každé nahlášení sníží váhu o 0,35)", "reportBtnLabel": "Nahlásit ({count})", "filterGroupAria": "Filtrovat recenze", - "exportMenu": "Exportovat data" + "exportMenu": "Exportovat data", + "v2FormHeading": "Recenze {productName}", + "v2CriteriaLabel": "Ohodnoťte každé kritérium", + "v2Skip": "Zatím nepoužito", + "v2StarAria": "{criterion}: {star} hvězdiček", + "v2Overall": "Celkem", + "v2Verdict": "Verdikt", + "v2VerdictHint": "Kdo by si ho měl koupit a kdo ne", + "v2Pros": "Klady", + "v2Cons": "Zápory", + "v2ConsHint": "I drobnost: cena, příslušenství, hmotnost", + "v2AddLine": "Přidat řádek", + "v2RemoveLine": "Odebrat řádek", + "v2CriterionPoints": "Vlastní klady a zápory", + "v2Comment": "Podrobnosti (nepovinné)", + "v2Submit": "Odeslat recenzi", + "v2Submitting": "Odesílání...", + "v2ErrInvalid": "Recenze zatím není platná: zkontrolujte označená pole.", + "v2ErrCriterion": "Neplatné kritérium.", + "v2ErrRequired": "Toto kritérium je nutné ohodnotit.", + "v2ErrSkipped": "Vynechat lze nejvýše {max} kritéria.", + "v2ErrVerdict": "Verdikt musí mít {min} až {max} znaků.", + "v2ErrCount": "Je třeba 1 až {max} řádků.", + "v2ErrLength": "Každý řádek musí mít {min} až {max} znaků.", + "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." }, "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 16f127784..a2f202d03 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -124,7 +124,7 @@ } }, "compare": { - "criteriaSourceExpert": "Criterion scores from experts", + "criteriaSourceExpert": "Criterion scores from reviewers or experts", "criterionNoData": "Not enough data", "versusMetaTitle": "{nameA} vs {nameB}: 10 spec groups and review scores | Rate.vn", "versusMetaTitleShort": "{nameA} vs {nameB} | Rate.vn", @@ -346,7 +346,7 @@ "quickToggleShortLimit": "Max 4" }, "leaderboard": { - "criteriaSourceExpert": "Criterion scores from experts", + "criteriaSourceExpert": "Criterion scores from reviewers or experts", "criterionNoData": "Not enough data", "metaTitle": "Tech device leaderboard | Rate.vn", "metaDescription": "Independent rankings by category: expert and verified buyer scores across 5 criteria, with no ads or affiliate bias.", @@ -579,7 +579,34 @@ "reportTitle": "Report a fake, spam or sabotage review (each report lowers its weight by 0.35)", "reportBtnLabel": "Report ({count})", "filterGroupAria": "Filter reviews", - "exportMenu": "Export data" + "exportMenu": "Export data", + "v2FormHeading": "Review {productName}", + "v2CriteriaLabel": "Rate each criterion", + "v2Skip": "Not used yet", + "v2StarAria": "{criterion}: {star} stars", + "v2Overall": "Overall", + "v2Verdict": "Verdict", + "v2VerdictHint": "Who should buy it, who should not", + "v2Pros": "Pros", + "v2Cons": "Cons", + "v2ConsHint": "Even a small one: price, accessories, weight", + "v2AddLine": "Add line", + "v2RemoveLine": "Remove line", + "v2CriterionPoints": "Own pros and cons", + "v2Comment": "Details (optional)", + "v2Submit": "Submit review", + "v2Submitting": "Submitting...", + "v2ErrInvalid": "The review is not valid yet: check the marked fields.", + "v2ErrCriterion": "Not a valid criterion.", + "v2ErrRequired": "This criterion must be rated.", + "v2ErrSkipped": "Skip at most {max} criteria.", + "v2ErrVerdict": "The verdict needs {min} to {max} characters.", + "v2ErrCount": "Needs 1 to {max} lines.", + "v2ErrLength": "Each line needs {min} to {max} characters.", + "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." }, "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 f9b347687..f631d6abe 100644 --- a/apps/web/locales/vi.json +++ b/apps/web/locales/vi.json @@ -124,7 +124,7 @@ } }, "compare": { - "criteriaSourceExpert": "Điểm tiêu chí do chuyên gia chấm", + "criteriaSourceExpert": "Điểm tiêu chí từ người review hoặc chuyên gia", "criterionNoData": "Chưa đủ dữ liệu", "versusMetaTitle": "So sánh {nameA} và {nameB}: 10 nhóm thông số và điểm đánh giá | Rate.vn", "versusMetaTitleShort": "{nameA} vs {nameB} | Rate.vn", @@ -346,7 +346,7 @@ "quickToggleShortLimit": "Tối đa 4" }, "leaderboard": { - "criteriaSourceExpert": "Điểm tiêu chí do chuyên gia chấm", + "criteriaSourceExpert": "Điểm tiêu chí từ người review hoặc chuyên gia", "criterionNoData": "Chưa đủ dữ liệu", "metaTitle": "Bảng xếp hạng thiết bị công nghệ | Rate.vn", "metaDescription": "Bảng xếp hạng độc lập theo danh mục: điểm chuyên gia và điểm người mua thật theo 5 tiêu chí, không bị chi phối bởi quảng cáo hay link affiliate.", @@ -579,7 +579,34 @@ "reportTitle": "Báo cáo đánh giá ảo, spam hoặc chơi xấu (mỗi báo cáo giảm trọng số 0.35)", "reportBtnLabel": "Báo cáo ({count})", "filterGroupAria": "Lọc đánh giá", - "exportMenu": "Xuất dữ liệu" + "exportMenu": "Xuất dữ liệu", + "v2FormHeading": "Đánh giá {productName}", + "v2CriteriaLabel": "Chấm từng tiêu chí", + "v2Skip": "Chưa dùng tới", + "v2StarAria": "{criterion}: {star} sao", + "v2Overall": "Điểm tổng", + "v2Verdict": "Kết luận", + "v2VerdictHint": "Ai nên mua, ai không nên", + "v2Pros": "Ưu điểm", + "v2Cons": "Nhược điểm", + "v2ConsHint": "Điều nhỏ nhất cũng được: giá, phụ kiện, cân nặng", + "v2AddLine": "Thêm dòng", + "v2RemoveLine": "Xoá dòng", + "v2CriterionPoints": "Ưu nhược riêng", + "v2Comment": "Chi tiết (không bắt buộc)", + "v2Submit": "Gửi đánh giá", + "v2Submitting": "Đang gửi...", + "v2ErrInvalid": "Đánh giá chưa hợp lệ, xem lại các ô được đánh dấu.", + "v2ErrCriterion": "Tiêu chí không hợp lệ.", + "v2ErrRequired": "Cần chấm tiêu chí này.", + "v2ErrSkipped": "Bỏ qua tối đa {max} tiêu chí.", + "v2ErrVerdict": "Kết luận dài {min} đến {max} ký tự.", + "v2ErrCount": "Cần 1 đến {max} dòng.", + "v2ErrLength": "Mỗi dòng {min} đến {max} ký tự.", + "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." }, "watchlist": { "metaTitle": "Theo dõi giá thật và cảnh báo giảm giá | Rate.vn", diff --git a/apps/web/package.json b/apps/web/package.json index 4921f9508..f6cb17c3c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings 0", "preview": "next build && next start", "start": "next start", - "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-variant-links.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-criteria.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/rating-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/csp.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/device-images.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/render-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/model-match.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/developers-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/plural-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/locale-format.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/paged-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/server-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/hero-products.test.ts", + "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-form-v2.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-variant-links.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-criteria.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/rating-score.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/rating-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/csp.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/device-images.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/render-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/model-match.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/developers-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/plural-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/locale-format.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/paged-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/server-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/hero-products.test.ts", "typecheck": "tsc --noEmit" }, "browserslist": [ diff --git a/apps/web/src/app/[locale]/leaderboard/page.tsx b/apps/web/src/app/[locale]/leaderboard/page.tsx index cd2cf2a4c..3d6eecd39 100644 --- a/apps/web/src/app/[locale]/leaderboard/page.tsx +++ b/apps/web/src/app/[locale]/leaderboard/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { resolveCompareEffectiveUserRating } from '@/lib/rate-domain'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { BsArrowLeftRight, @@ -128,10 +129,10 @@ export default async function LeaderboardPage({ params }: PageProps) { () => ({}) as Record ), ]); - // Back in one best-rated-first list (same key as search_master_products' - // rating_desc), so the top 15 in the ItemList below stay platform-wide. + // Back in one best-rated-first list, by the one rating (D3), so the top + // 15 in the ItemList below stay platform-wide. const ratingOf = (p: (typeof productsByCategory)[number][number]) => - Number(p.user_rating_weighted || 0) || Number(p.average_rating || 0); + resolveCompareEffectiveUserRating(p); const products = productsByCategory .flat() .sort((a, b) => ratingOf(b) - ratingOf(a)); diff --git a/apps/web/src/app/[locale]/page.tsx b/apps/web/src/app/[locale]/page.tsx index ce5e903d2..f7b30c026 100644 --- a/apps/web/src/app/[locale]/page.tsx +++ b/apps/web/src/app/[locale]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { resolveCompareEffectiveUserRating } from '@/lib/rate-domain'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '@/lib/navigation'; import PageShell from './components/page-shell'; @@ -531,9 +532,7 @@ export default async function Home({ { ...item, slug: catalogRowSlug(item) }, catSlugById ); - const rawUser = Number( - item.user_rating_weighted || item.average_rating || 0 - ); + const rawUser = resolveCompareEffectiveUserRating(item); const expertScore = item.expert_rating != null && Number(item.expert_rating) > 0 ? Number(item.expert_rating) @@ -638,12 +637,12 @@ export default async function Home({ #{battle.productA.rank} vs #{battle.productB.rank} - {battle.scoreDelta5 > 0.005 + {battle.scoreDelta5 > 0.05 ? tHome('scoreDeltaLabel', { delta: formatDecimal( battle.scoreDelta5, locale, - 2 + 1 ), }) : tHome('scoreTieLabel')} @@ -657,8 +656,8 @@ export default async function Home({
@@ -672,8 +671,8 @@ export default async function Home({
diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx index 9addaf3f7..dae88e149 100644 --- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx @@ -40,6 +40,7 @@ import { computeBayesianRatingSummary } from '@/lib/rate-api'; import { normalizeCompareFiveStarRating, resolveCompareEffectiveUserRating, + resolveStoredRatingScore, resolveCompareExpertRating5, resolveCompareExpertRating10, } from '@/lib/rate-domain'; @@ -523,10 +524,15 @@ export async function ProductDetailView({ const rawUserRating = resolveCompareEffectiveUserRating(product); const expertRating10 = resolveCompareExpertRating10(product); const bayesianSummary = computeBayesianRatingSummary(reviews, rawUserRating); + // The headline is the stored rating every other page shows (D3); the + // review list only gives the confidence tag and the counts. + const storedRating = resolveStoredRatingScore(product); const displayUserRating = - bayesianSummary.effectiveWeightSum > 0 - ? bayesianSummary.bayesianRating - : rawUserRating; + storedRating > 0 + ? storedRating + : bayesianSummary.effectiveWeightSum > 0 + ? bayesianSummary.bayesianRating + : rawUserRating; const keySpecLine = buildKeySpecLine(product, categorySlug); const mainImage = firstImageUrl(product.images); @@ -561,7 +567,7 @@ export async function ProductDetailView({ displayUserRating > 0 && authenticCount > 0 ? { '@type': 'AggregateRating', - ratingValue: Number(displayUserRating.toFixed(2)), + ratingValue: Math.round(displayUserRating * 10) / 10, reviewCount: authenticCount, bestRating: 5, worstRating: 1, @@ -781,7 +787,7 @@ export async function ProductDetailView({ name={v.product.name} brand={v.product.brand} modelKey={v.product.model_key} - userScore5={Number(displayUserRating.toFixed(2))} + userScore5={displayUserRating} expertScore10={expertRating10} authenticReviews={authenticCount} minPriceVnd={v.stats.minPrice} @@ -835,7 +841,7 @@ export async function ProductDetailView({ ); const sizeStripContext = { categoryPath: categoryBasePath, - userScore5: Number(displayUserRating.toFixed(2)), + userScore5: displayUserRating, expertScore10: expertRating10, authenticReviews: authenticCount, keySpecLine, @@ -1443,10 +1449,8 @@ export async function ProductDetailView({ averageRating={normalizeCompareFiveStarRating( product.average_rating )} - userRatingWeighted={ - normalizeCompareFiveStarRating(product.user_rating_weighted) || - null - } + userRatingWeighted={displayUserRating || null} + ratingScore={storedRating || null} expertRating={resolveCompareExpertRating5(product)} expertReviewSummary={product.expert_review_summary || null} authenticReviewCount={authenticCount} diff --git a/apps/web/src/app/[locale]/smartphones/page.tsx b/apps/web/src/app/[locale]/smartphones/page.tsx index 1a86fa80f..94592ecf0 100644 --- a/apps/web/src/app/[locale]/smartphones/page.tsx +++ b/apps/web/src/app/[locale]/smartphones/page.tsx @@ -1,4 +1,5 @@ import { Suspense } from 'react'; +import { resolveCompareEffectiveUserRating } from '@/lib/rate-domain'; import type { Metadata } from 'next'; import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '@/lib/navigation'; @@ -781,13 +782,7 @@ export async function CategoryCatalogContent({ const thumb = productImageUrl(item); const effectiveRating = - item.user_rating_weighted && - Number(item.user_rating_weighted) > 0 - ? Number(item.user_rating_weighted) - : item.average_rating && - Number(item.average_rating) > 0 - ? Number(item.average_rating) - : null; + resolveCompareEffectiveUserRating(item) || null; const expertScore = item.expert_rating != null && Number(item.expert_rating) > 0 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 493a2aa5d..445a48b56 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 @@ -360,6 +360,154 @@ async function testReviews() { ); } +// Review form v2 (DOS/Rate#1544): behind NEXT_PUBLIC_RATE_REVIEW_FORM_V2, +// checked against the product's category, and never stored until the DOS-Me +// v2 RPC exists. +async function testReviewsV2() { + const { POST } = await import('../reviews/route'); + type Req = Parameters[0]; + const v2Review = { + masterProductId: MASTER_ID, + formVersion: 2, + authorName: 'Tester', + ratingStar: 5, + criteriaRatings: { + performance: 4.5, + camera: 4, + battery: 5, + display: 4, + value: 3.5, + }, + verdict: 'Hợp người chụp ảnh nhiều, không hợp người chơi game nặng.', + pros: ['Pin dùng trọn ngày'], + cons: ['Giá hơi cao so với cấu hình'], + }; + rpcResponder = () => ({ status: 200, json: { id: 'stored' } }); + + // Flag off: the body takes the current path, which needs a comment. + delete process.env.NEXT_PUBLIC_RATE_REVIEW_FORM_V2; + calls = []; + let res = await POST( + post('/api/v1/rate/reviews', v2Review, GOOD_TOKEN) as Req + ); + assert.equal(res.status, 400); + assert.equal((await res.json()).code, 'INVALID_BODY'); + assert.equal(rpcCalls('submit_community_review').length, 0); + + process.env.NEXT_PUBLIC_RATE_REVIEW_FORM_V2 = '1'; + try { + // With v2 on, a v1 body is refused: no verdict, pros or cons. + calls = []; + res = await POST( + post('/api/v1/rate/reviews', validReview, GOOD_TOKEN) as Req + ); + assert.equal(res.status, 400); + assert.equal((await res.json()).code, 'INVALID_BODY'); + calls = []; + res = await POST( + post( + '/api/v1/rate/reviews', + { ...v2Review, formVersion: '2' }, + GOOD_TOKEN + ) as Req + ); + assert.equal(res.status, 400); + assert.equal(rpcCalls('submit_community_review').length, 0); + + // Still signed-in only. + calls = []; + res = await POST(post('/api/v1/rate/reviews', v2Review) as Req); + assert.equal(res.status, 401); + + // No verdict, no pro, no con: rejected per field. + calls = []; + res = await POST( + post( + '/api/v1/rate/reviews', + { ...v2Review, verdict: '', pros: [], cons: ['không có'] }, + GOOD_TOKEN + ) as Req + ); + assert.equal(res.status, 400); + const invalid = await res.json(); + assert.equal(invalid.code, 'INVALID_REVIEW'); + assert.deepEqual( + invalid.fieldErrors.map((e: { field: string }) => e.field), + ['verdict', 'pros', 'cons.0'] + ); + assert.equal(rpcCalls('submit_community_review').length, 0); + + // A criterion of another category is rejected, not dropped. + calls = []; + res = await POST( + post( + '/api/v1/rate/reviews', + { + ...v2Review, + criteriaRatings: { ...v2Review.criteriaRatings, anc: 5 }, + }, + GOOD_TOKEN + ) as Req + ); + assert.equal(res.status, 400); + assert.deepEqual((await res.json()).fieldErrors, [ + { field: 'criterion.anc', key: 'v2ErrCriterion' }, + ]); + + // An oversized body is refused before any check. + calls = []; + res = await POST( + post( + '/api/v1/rate/reviews', + { ...v2Review, pros: Array.from({ length: 21 }, () => 'Pin trâu') }, + GOOD_TOKEN + ) as Req + ); + assert.equal(res.status, 400); + assert.equal((await res.json()).code, 'INVALID_BODY'); + + // A draft product is not found. + calls = []; + res = await POST( + post( + '/api/v1/rate/reviews', + { ...v2Review, masterProductId: DRAFT_MASTER_ID }, + GOOD_TOKEN + ) as Req + ); + assert.equal(res.status, 404); + + // A valid review waits for the v2 RPC: 503, and nothing is written. + 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'); + assert.equal( + calls.filter((c) => c.url.includes('/rest/v1/rpc/')).length, + 0 + ); + assert.ok(calls.every((c) => c.method === 'GET')); + } 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 built = buildReviewV2Submission('smartphones', v2Review); + assert.ok(built.ok); + // (4.5*20 + 4*20 + 5*20 + 4*15 + 3.5*15) / 90 = 4.25 + assert.equal(built.submission.ratingStar, 4.3); + assert.equal(built.submission.criteriaVersion, 1); + assert.equal( + buildReviewV2Submission('smartphones', { ...v2Review, verdict: '' }).ok, + false + ); + console.warn( + ' ✅ POST review form v2 is flagged, checked per field and never stored yet (#1544)' + ); +} + // GET reviews by id serves published products only, like the slug path (DOS/Rate#1309). async function testReviewsByIdPublishedOnly() { const { GET } = await import('../reviews/route'); @@ -1688,6 +1836,7 @@ async function testMerchantCacheRevalidateAndDomainBounds() { async function main() { console.log('🧪 Public write-path security suite (#155)'); await testReviews(); + await testReviewsV2(); await testReviewsByIdPublishedOnly(); await testWatchlist(); await testCookieSessions(); diff --git a/apps/web/src/app/api/v1/rate/compare/route.ts b/apps/web/src/app/api/v1/rate/compare/route.ts index 9069d9722..eb68b6fa3 100644 --- a/apps/web/src/app/api/v1/rate/compare/route.ts +++ b/apps/web/src/app/api/v1/rate/compare/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { productRatingScore5 } from '@/lib/rate-domain'; import { getCachedCompareMasterProducts, getCachedMasterProductBySlug, @@ -85,6 +86,7 @@ export async function GET(request: NextRequest) { // The category's spec view, as /compare shows it (DOS/Rate#1166). ...applyCategorySpecTemplate(lockedCategory, p), canonical_href: getMasterProductHref(p), + rating_score_5: productRatingScore5(p), deal_radar: radarExportFields(computeProductRadarSignal(p, offers)), sale_state: saleStateOfMaster(p), }; diff --git a/apps/web/src/app/api/v1/rate/leaderboard/route.ts b/apps/web/src/app/api/v1/rate/leaderboard/route.ts index 42fb90e2f..4647de9aa 100644 --- a/apps/web/src/app/api/v1/rate/leaderboard/route.ts +++ b/apps/web/src/app/api/v1/rate/leaderboard/route.ts @@ -7,6 +7,7 @@ import { } from '@/server/rate'; import { radarExportFields } from '@/lib/deal-radar'; import { + displayedScore5, normalizeLeaderboardCategory, normalizeLeaderboardCriterion, normalizeLeaderboardPriceSegment, @@ -99,6 +100,7 @@ export async function GET(req: NextRequest) { active_score_10: r.activeScore10, combined_score_5: r.combinedScore5, user_score_5: r.userScore5, + rating_score_5: displayedScore5(r.userScore5) || null, expert_score_10: r.expertScore10, authentic_review_count: r.authenticReviewCount, filtered_noise_review_count: r.filteredNoiseReviewCount, diff --git a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts index 0f808b940..fc3df85d5 100644 --- a/apps/web/src/app/api/v1/rate/products/[slug]/route.ts +++ b/apps/web/src/app/api/v1/rate/products/[slug]/route.ts @@ -8,7 +8,10 @@ import { } from '@/server/rate'; import { computeProductRadarSignal } from '@/server/rate/deals'; import { isOfferUnavailable } from '@/lib/product-market-stats'; -import { getMasterProductCategorySlug } from '@/lib/rate-domain'; +import { + getMasterProductCategorySlug, + productRatingScore5, +} from '@/lib/rate-domain'; import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { listingCountForSaleState, saleStateOfMaster } from '@/lib/sale-state'; import { @@ -110,6 +113,7 @@ export async function GET(request: NextRequest, context: RouteContext) { // Why there is no price, when there is none (DOS/Rate#1188). product: { ...product, + rating_score_5: productRatingScore5(product), sale_state: saleStateOfMaster( product, listingCountForSaleState( diff --git a/apps/web/src/app/api/v1/rate/products/route.ts b/apps/web/src/app/api/v1/rate/products/route.ts index 71bbf8fa5..5e8dd5835 100644 --- a/apps/web/src/app/api/v1/rate/products/route.ts +++ b/apps/web/src/app/api/v1/rate/products/route.ts @@ -7,7 +7,10 @@ import { } from '@/server/rate'; import { computeProductRadarSignal } from '@/server/rate/deals'; import { radarExportFields } from '@/lib/deal-radar'; -import { getMasterProductCategorySlug } from '@/lib/rate-domain'; +import { + getMasterProductCategorySlug, + productRatingScore5, +} from '@/lib/rate-domain'; import { applyCategorySpecTemplate } from '@repo/shared-data/rules'; import { saleStateOfMaster } from '@/lib/sale-state'; import { @@ -323,6 +326,7 @@ export async function GET(request: NextRequest) { const offers = offersByMasterId.get(p.id) ?? []; return { ...applyCategorySpecTemplate(getMasterProductCategorySlug(p), p), + rating_score_5: productRatingScore5(p), deal_radar: radarExportFields(computeProductRadarSignal(p, offers)), sale_state: saleStateOfMaster(p), }; 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 02cb85779..18a7cf044 100644 --- a/apps/web/src/app/api/v1/rate/reviews/route.ts +++ b/apps/web/src/app/api/v1/rate/reviews/route.ts @@ -7,6 +7,7 @@ import { getCachedMasterProductBySlug, getCachedMasterProductReviews, getCachedMasterProductReviewSummary, + getCachedPublishedMasterProductCategory, isCachedPublishedMasterProduct, } from '@/server/rate'; import { @@ -24,6 +25,14 @@ import { type RpcRejection, } from '@/server/rate/request-auth'; import { createLogger, serializeError } from '@/lib/logger'; +import { + REVIEW_FORM_VERSION, + isReviewFormV2Enabled, +} from '@/lib/review-form-v2'; +import { + buildReviewV2Submission, + submitCommunityReviewV2, +} from '@/server/rate/review-v2'; const log = createLogger('api.reviews'); @@ -167,6 +176,51 @@ const SubmitReviewSchema = z.object({ .optional(), }); +const stripControls = (s: string) => + s + .replace(/\r\n?/g, '\n') + .replace(/[^\P{Cc}\n]+/gu, '') + .trim(); +// One line: control characters and line breaks become spaces. +const oneLine = (s: string) => + s + .replace(/\p{Cc}+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +// Generous caps against an oversized body; the real limits are the D5 rules +// in reviewV2Errors, which name the field that breaks them. +const V2Line = z.string().max(600).transform(oneLine); +const V2Lines = z.array(V2Line).max(20); + +/** The v2 body (DOS/Rate#1544): no star, the server computes it. */ +const SubmitReviewV2Schema = z.object({ + masterProductId: z + .string() + .trim() + .uuid('Mã sản phẩm chuẩn hóa (masterProductId) không hợp lệ.'), + formVersion: z.literal(REVIEW_FORM_VERSION), + authorName: z.string().max(120).optional(), + variation: z.string().max(200).optional(), + comment: z + .string() + .transform(stripControls) + .pipe(z.string().max(3000, 'Nội dung đánh giá dài tối đa 3.000 ký tự.')) + .optional(), + criteriaRatings: z + .record(z.string().max(40), z.number().finite().nullable()) + .refine((r) => Object.keys(r).length <= 20), + verdict: z.string().max(1200).transform(oneLine), + pros: V2Lines, + cons: V2Lines, + criterionPoints: z + .record( + z.string().max(40), + z.object({ pros: V2Lines.optional(), cons: V2Lines.optional() }) + ) + .refine((r) => Object.keys(r).length <= 20) + .optional(), +}); + const FLAG_CATEGORIES = new Set([ 'authentic', 'low_value_coin_spam', @@ -376,6 +430,12 @@ export async function POST(request: NextRequest) { ); if (!auth.ok) return auth.response; + // With v2 on, every review is a v2 review: a v1 body (no verdict, pros + // or cons, a star from the browser) is refused as INVALID_BODY. + if (isReviewFormV2Enabled()) { + return await submitReviewV2(auth.rateClient, rawBody); + } + const parsed = SubmitReviewSchema.safeParse(rawBody ?? {}); if (!parsed.success) { return NextResponse.json( @@ -506,3 +566,71 @@ export async function POST(request: NextRequest) { ); } } + +/** + * The v2 path, behind NEXT_PUBLIC_RATE_REVIEW_FORM_V2 (DOS/Rate#1544): the + * review is checked against its product's category with the form's own + * rules, and its star is the weighted average of its criteria. Storing it + * waits for the DOS-Me v2 RPC, so a valid review gets a 503 for now. + */ +async function submitReviewV2( + rateClient: unknown, + rawBody: unknown +): Promise { + const parsed = SubmitReviewV2Schema.safeParse(rawBody); + if (!parsed.success) { + return NextResponse.json( + { + ok: false, + error: + parsed.error.issues[0]?.message || 'Dữ liệu đánh giá không hợp lệ.', + code: 'INVALID_BODY', + }, + { status: 400, headers: REVIEWS_CORS_HEADERS } + ); + } + const body = parsed.data; + + const categorySlug = await getCachedPublishedMasterProductCategory( + body.masterProductId + ); + if (!categorySlug) { + return NextResponse.json( + { ok: false, error: 'Product not found', code: 'NOT_FOUND' }, + { status: 404, headers: REVIEWS_CORS_HEADERS } + ); + } + + const checked = buildReviewV2Submission(categorySlug, body); + if (!checked.ok) { + 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: checked.fieldErrors, + }, + { status: 400, headers: REVIEWS_CORS_HEADERS } + ); + } + + const result = await submitCommunityReviewV2(rateClient, checked.submission); + if (!result.ok) { + 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, + }, + { status: 503, headers: REVIEWS_CORS_HEADERS } + ); + } + return NextResponse.json( + { + ok: true, + mode: 'supabase_rpc', + review: toReviewItem(result.record, {}), + }, + { headers: REVIEWS_CORS_HEADERS } + ); +} diff --git a/apps/web/src/app/api/v1/rate/versus/route.ts b/apps/web/src/app/api/v1/rate/versus/route.ts index c11b8df90..7182bbe63 100644 --- a/apps/web/src/app/api/v1/rate/versus/route.ts +++ b/apps/web/src/app/api/v1/rate/versus/route.ts @@ -10,6 +10,7 @@ import { radarExportFields } from '@/lib/deal-radar'; import { versusApiWinner } from '@/lib/versus-verdict'; import { comparePositiveMinPrices, + displayedScore5, leaderboardCriteriaForCategory, scoreMasterProductForLeaderboard, sharedScoredCriteria, @@ -267,28 +268,36 @@ export async function GET(req: NextRequest) { value: 20, }; - // The weights apply to the criteria both products have a real score - // for; with none in common, both sides use their main score, and no - // criterion counts as won or lost (DOS/Rate#1143). + // The weights apply to the criteria both products have a score for + // past the ranking threshold (D9); with none in common, both sides use + // their rating as every page shows it, and no criterion counts as won + // or lost (DOS/Rate#1143, D3). const comparedCriteria = sharedScoredCriteria( - scoreA.criteria, - scoreB.criteria + scoreA.rankCriteria, + scoreB.rankCriteria ); const weightedA5 = weightedCriteriaScore5( - scoreA.criteria, + scoreA.rankCriteria, normalizedWeights, comparedCriteria ); const weightedB5 = weightedCriteriaScore5( - scoreB.criteria, + scoreB.rankCriteria, normalizedWeights, comparedCriteria ); const byCriteria = weightedA5 != null && weightedB5 != null; - const customScoreA5 = byCriteria ? weightedA5 : scoreA.combinedScore5; - const customScoreB5 = byCriteria ? weightedB5 : scoreB.combinedScore5; - const customScoreA10 = Number((customScoreA5 * 2).toFixed(1)); - const customScoreB10 = Number((customScoreB5 * 2).toFixed(1)); + const baseScore5 = (s: typeof scoreA) => + s.userScore5 > 0 ? s.userScore5 : s.combinedScore5; + const customScoreA5 = byCriteria ? weightedA5 : baseScore5(scoreA); + const customScoreB5 = byCriteria ? weightedB5 : baseScore5(scoreB); + // Rounded once, as the pages round (D3). + const customScoreA10 = Number( + (Math.round(customScoreA5 * 10) / 5).toFixed(1) + ); + const customScoreB10 = Number( + (Math.round(customScoreB5 * 10) / 5).toFixed(1) + ); const priceSide = comparePositiveMinPrices( scoreA.minPrice, scoreB.minPrice @@ -440,6 +449,7 @@ export async function GET(req: NextRequest) { custom_weighted_score_5: customScoreA5, custom_weighted_score_10: customScoreA10, user_score_5: scoreA.userScore5, + rating_score_5: displayedScore5(scoreA.userScore5) || null, expert_score_10: scoreA.expertScore10, authentic_review_count: scoreA.authenticReviewCount, filtered_noise_review_count: scoreA.filteredNoiseReviewCount, @@ -465,6 +475,7 @@ export async function GET(req: NextRequest) { custom_weighted_score_5: customScoreB5, custom_weighted_score_10: customScoreB10, user_score_5: scoreB.userScore5, + rating_score_5: displayedScore5(scoreB.userScore5) || null, expert_score_10: scoreB.expertScore10, authentic_review_count: scoreB.authenticReviewCount, filtered_noise_review_count: scoreB.filteredNoiseReviewCount, diff --git a/apps/web/src/components/compare/CompareWorkspace.tsx b/apps/web/src/components/compare/CompareWorkspace.tsx index b933db5ca..38cf000b9 100644 --- a/apps/web/src/components/compare/CompareWorkspace.tsx +++ b/apps/web/src/components/compare/CompareWorkspace.tsx @@ -2035,12 +2035,12 @@ export default function CompareWorkspace({
{/* A 0.00 gap is a tie, as on the home page (DOS/Rate#1209). */} - {battle.scoreDelta5 > 0.005 + {battle.scoreDelta5 > 0.05 ? t('scoreDeltaBadge', { delta: formatDecimal( battle.scoreDelta5, locale, - 2 + 1 ), }) : t('mdRowTie')} @@ -2066,8 +2066,8 @@ export default function CompareWorkspace({ {battle.productA.name} @@ -2083,8 +2083,8 @@ export default function CompareWorkspace({ {battle.productB.name} diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx index e4d3dbaec..3cb5efd34 100644 --- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx +++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx @@ -86,7 +86,8 @@ export const PERSONA_WEIGHTS: Record< export function sanitizeVersusScore5(value: unknown): number { const num = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(num) || num <= 0) return 0; - return Number(Math.min(5, num).toFixed(2)); + // Unrounded: the page rounds once, to 1 decimal (D3). + return Math.min(5, num); } /** A criterion score out of 5, or null when there is no real one. */ @@ -156,7 +157,8 @@ export function computeVersusWeightedResult( const wVal = sanitizeWeight(weights.value); const rawSumW = wScreen + wPerf + wCam + wBat + wVal; - const shared = sharedScoredCriteria(scoreA.criteria, scoreB.criteria); + // Weights read the scores past the ranking threshold (D9). + const shared = sharedScoredCriteria(scoreA.rankCriteria, scoreB.rankCriteria); const calcWeighted = (item: ScoredVersusItem): number => { // The product's rating as every page shows it (DOS/Rate#1470); a model // with no buyer rating yet keeps its composite (expert or criteria). @@ -169,7 +171,7 @@ export function computeVersusWeightedResult( // Only the criteria both models have a real score for are weighed; with // none in common the weights change nothing (DOS/Rate#1143). const wAvg = weightedCriteriaScore5( - item.criteria, + item.rankCriteria, rawSumW > 0 ? { screen: wScreen, @@ -266,8 +268,8 @@ export default function VersusInteractiveScoreboard({ // otherwise they would change nothing, so they are not offered // (DOS/Rate#1556). const sharedCriteria = useMemo( - () => sharedScoredCriteria(scoreA.criteria, scoreB.criteria), - [scoreA.criteria, scoreB.criteria] + () => sharedScoredCriteria(scoreA.rankCriteria, scoreB.rankCriteria), + [scoreA.rankCriteria, scoreB.rankCriteria] ); const availablePersonas = versusAvailablePersonas(sharedCriteria); const canWeigh = availablePersonas.length > 0; diff --git a/apps/web/src/components/deals/DealFinderWorkspace.tsx b/apps/web/src/components/deals/DealFinderWorkspace.tsx index 5107832d1..b8ff01d8c 100644 --- a/apps/web/src/components/deals/DealFinderWorkspace.tsx +++ b/apps/web/src/components/deals/DealFinderWorkspace.tsx @@ -16,6 +16,7 @@ import { MAX_VALID_VND_PRICE, } from '@/lib/rate-domain'; import { + displayedScore5, scoreMasterProductForLeaderboard, type ScoredLeaderboardItem, } from '@/lib/leaderboard-engine'; @@ -110,9 +111,10 @@ export function availableDealPersonas( ) { continue; } - const { criteria } = scoreMasterProductForLeaderboard(p); + // A persona ranks, so it needs a score past the ranking threshold. + const { rankCriteria } = scoreMasterProductForLeaderboard(p); for (const c of Object.values(PERSONA_LEAN)) { - if ((criteria[c] ?? 0) > 0) scored.add(c); + if ((rankCriteria[c] ?? 0) > 0) scored.add(c); } } } @@ -167,14 +169,15 @@ export function rankProductsForDealFinder( } const item = scoreMasterProductForLeaderboard(p); - const c = item.criteria; - // A persona leans on one criterion only where the product has a real - // score for it; otherwise the combined score stands (DOS/Rate#1143). + const c = item.rankCriteria; + // The default view shows the one rating (D3). A persona leans on one + // criterion only where the product has a score past the ranking + // threshold; otherwise the rating stands (DOS/Rate#1143, D9). const lean = (score: number | null, share: number) => - score != null && score > 0 - ? Number((item.combinedScore5 * (1 - share) + score * share).toFixed(2)) - : item.combinedScore5; - let personaScore5 = item.combinedScore5; + score != null && score > 0 && item.userScore5 > 0 + ? item.userScore5 * (1 - share) + score * share + : item.userScore5; + let personaScore5 = item.userScore5; if (persona === 'value_hunter') { personaScore5 = lean(c.value, 0.55); } else if (persona === 'photography') { @@ -193,9 +196,10 @@ export function rankProductsForDealFinder( ? Math.round(rawMaxP) : minP; const spreadSavingsVnd = Math.max(0, maxP - minP); - const combinedScore10 = item.combinedScore5 * 2; + // Per point of the rating the card shows (D3). + const ratingScore10 = displayedScore5(item.userScore5) * 2; const costPerPointVnd = - combinedScore10 > 0 ? Math.round(minP / combinedScore10) : 0; + ratingScore10 > 0 ? Math.round(minP / ratingScore10) : 0; scored.push({ ...item, @@ -206,9 +210,16 @@ export function rankProductsForDealFinder( }); } + // The default view ranks by the rating as shown, the composite breaking + // a tie (D3); a persona ranks by its own score. + const shown = (score: number) => + persona === 'default' ? displayedScore5(score) : score; scored.sort((a, b) => { - if (b.personaScore5 !== a.personaScore5) { - return b.personaScore5 - a.personaScore5; + if (shown(b.personaScore5) !== shown(a.personaScore5)) { + return shown(b.personaScore5) - shown(a.personaScore5); + } + if (b.combinedScore5 !== a.combinedScore5) { + return b.combinedScore5 - a.combinedScore5; } return a.minPrice - b.minPrice; }); @@ -477,7 +488,7 @@ export default function DealFinderWorkspace({ .slice(0, 20) .map( (d) => - `| #${d.rank} | ${sanitizeDealFinderMarkdownCell(d.name)} | ${sanitizeDealFinderMarkdownCell(d.brand)} | **${d.personaScore5 > 0 ? `${d.personaScore5.toFixed(2)}/5.0` : t('noScore')}** | ★ ${d.userScore5 > 0 ? d.userScore5.toFixed(1) : t('noScore')} (${d.expertScore10 != null ? `Lab ${d.expertScore10.toFixed(1)}/10` : t('labNoScore')}) | **${d.minPrice.toLocaleString('vi-VN')} ₫** | ${d.spreadSavingsVnd > 0 ? `-${d.spreadSavingsVnd.toLocaleString('vi-VN')} ₫` : t('samePrice')} | ${radarMarkdownCell(getRadarSignal(radarIndex, d.slug))} |` + `| #${d.rank} | ${sanitizeDealFinderMarkdownCell(d.name)} | ${sanitizeDealFinderMarkdownCell(d.brand)} | **${d.personaScore5 > 0 ? `${d.personaScore5.toFixed(2)}/5.0` : t('noScore')}** | ★ ${d.userScore5 > 0 ? displayedScore5(d.userScore5).toFixed(1) : t('noScore')} (${d.expertScore10 != null ? `Lab ${d.expertScore10.toFixed(1)}/10` : t('labNoScore')}) | **${d.minPrice.toLocaleString('vi-VN')} ₫** | ${d.spreadSavingsVnd > 0 ? `-${d.spreadSavingsVnd.toLocaleString('vi-VN')} ₫` : t('samePrice')} | ${radarMarkdownCell(getRadarSignal(radarIndex, d.slug))} |` ), ]; await navigator.clipboard.writeText(lines.join('\n')); @@ -511,7 +522,7 @@ export default function DealFinderWorkspace({ d.brand, d.categorySlug, d.personaScore5 > 0 ? d.personaScore5.toFixed(2) : '', - d.userScore5 > 0 ? d.userScore5.toFixed(2) : '', + d.userScore5 > 0 ? displayedScore5(d.userScore5).toFixed(1) : '', d.expertScore10 != null ? d.expertScore10.toFixed(1) : '', d.minPrice, d.spreadSavingsVnd, @@ -949,7 +960,7 @@ export default function DealFinderWorkspace({ {deal.brand} diff --git a/apps/web/src/components/home/HeroSearchInspector.tsx b/apps/web/src/components/home/HeroSearchInspector.tsx index 73f26c62f..705759fac 100644 --- a/apps/web/src/components/home/HeroSearchInspector.tsx +++ b/apps/web/src/components/home/HeroSearchInspector.tsx @@ -449,7 +449,7 @@ export default function HeroSearchInspector({ ); const minPrice = formatNumber(safeMinPrice, locale); const rating = sanitizeHeroInspectorRating( - item.user_rating_weighted, + item.rating_score || item.user_rating_weighted, item.average_rating ); diff --git a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx index 851b44f84..7c1da9439 100644 --- a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx +++ b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx @@ -27,6 +27,7 @@ import { } from '@/lib/rate-domain'; import { LEADERBOARD_SCORE_CRITERIA, + displayedScore5, leaderboardCriteriaForCategory, weightedCriteriaScore5, type LeaderboardCriteriaBreakdown, @@ -148,6 +149,7 @@ interface RankedProductEntry { authenticCount: number; ignoredCount: number; criteria: LeaderboardCriteriaBreakdown; + rankCriteria: LeaderboardCriteriaBreakdown; activeScore5: number; } @@ -222,6 +224,7 @@ function computeProductScores( authenticCount: scored.authenticReviewCount, ignoredCount: scored.filteredNoiseReviewCount, criteria: scored.criteria, + rankCriteria: scored.rankCriteria, activeScore5: scored.activeScore5, }; } @@ -445,24 +448,37 @@ export default function LeaderboardExplorer({ // The category's criteria that at least one product has a real score // for. A criterion with none gets no tab, no weight and no row cell, not - // an empty list (DOS/Rate#1553). - const scoredCriteria = useMemo(() => { - const scored = new Set(); + // an empty list (DOS/Rate#1553). A tab and a weight need a score past the + // ranking threshold; a cell needs one past the display threshold (D9). + const { scoredCriteria, shownCriteria } = useMemo(() => { + const ranked = new Set(); + const shown = new Set(); const own = leaderboardCriteriaForCategory(activeCategory); for (const item of categoryProducts) { - const { criteria } = computeProductScores( + const { criteria, rankCriteria } = computeProductScores( item, 'combined', filteredReviewCounts ); for (const c of own) { - if ((criteria[c] ?? 0) > 0) scored.add(c); + if ((criteria[c] ?? 0) > 0) shown.add(c); + if ((rankCriteria[c] ?? 0) > 0) ranked.add(c); } - if (scored.size === own.length) break; + if (ranked.size === own.length) break; } - return own.filter((c) => scored.has(c)); + return { + scoredCriteria: own.filter((c) => ranked.has(c)), + shownCriteria: own.filter((c) => shown.has(c)), + }; }, [activeCategory, categoryProducts, filteredReviewCounts]); const hasScoredCriteria = scoredCriteria.length > 0; + const hasShownCriteria = shownCriteria.length > 0; + // The one rating shows at 1 decimal (D3); a persona's own weighted score + // keeps 2, under the persona's name. + const ratingView = + activePersona === 'none' && + (activeCriterion === 'combined' || activeCriterion === 'user'); + const scoreDigits: 1 | 2 = ratingView ? 1 : 2; const availableBrands = useMemo(() => { const brands = new Set(); @@ -534,10 +550,12 @@ export default function LeaderboardExplorer({ if ( activePersona !== 'none' && totalWeight > 0 && - base.activeScore5 > 0 + (activeCriterion === 'combined' + ? base.combinedScore5 > 0 + : base.activeScore5 > 0) ) { const weighted5 = weightedCriteriaScore5( - base.criteria, + base.rankCriteria, customWeights, weightedCriteria ); @@ -555,12 +573,24 @@ export default function LeaderboardExplorer({ if (activeCriterion === 'user' && entry.userScore5 <= 0) { return false; } + // The overall view keeps a product with an expert score but no + // rating yet, after every rated one (D3). + if (activeCriterion === 'combined' && activePersona === 'none') { + return entry.combinedScore5 > 0; + } return entry.activeScore5 > 0; }); + // Ranked by the score as shown, so equal-looking scores never rank by + // a hidden decimal; the composite breaks the tie (D3). + const shown = (score: number) => + activePersona === 'none' && + (activeCriterion === 'combined' || activeCriterion === 'user') + ? displayedScore5(score) + : score; return scored.sort((a, b) => { - if (b.activeScore5 !== a.activeScore5) { - return b.activeScore5 - a.activeScore5; + if (shown(b.activeScore5) !== shown(a.activeScore5)) { + return shown(b.activeScore5) - shown(a.activeScore5); } if (b.combinedScore5 !== a.combinedScore5) { return b.combinedScore5 - a.combinedScore5; @@ -647,7 +677,9 @@ export default function LeaderboardExplorer({ sanitizeLeaderboardMarkdownCell(entry.product.name), sanitizeLeaderboardMarkdownCell(entry.product.brand), entry.activeScore5.toFixed(2), - entry.userScore5 > 0 ? entry.userScore5.toFixed(1) : t('unratedShort'), + entry.userScore5 > 0 + ? displayedScore5(entry.userScore5).toFixed(1) + : t('unratedShort'), entry.expertScore10 != null ? entry.expertScore10.toFixed(1) : t('unratedShort'), @@ -701,7 +733,9 @@ export default function LeaderboardExplorer({ entry.product.brand || '', entry.categorySlug, entry.activeScore5.toFixed(2), - entry.userScore5 > 0 ? entry.userScore5.toFixed(1) : '', + entry.userScore5 > 0 + ? displayedScore5(entry.userScore5).toFixed(1) + : '', entry.expertScore10 != null ? entry.expertScore10.toFixed(1) : '', ...LEADERBOARD_SCORE_CRITERIA.map((c) => { const score = entry.criteria[c]; @@ -1223,7 +1257,7 @@ export default function LeaderboardExplorer({ {rankedEntries.map((entry, idx) => ( ))} @@ -1247,7 +1281,12 @@ export default function LeaderboardExplorer({ value={entry.product.slug} > #{rankIdx + 1} {entry.product.name} ( - {formatDecimal(entry.activeScore5, locale, 2)}★) + {formatDecimal( + entry.activeScore5, + locale, + scoreDigits + )} + ★) ); })} @@ -1345,7 +1384,7 @@ export default function LeaderboardExplorer({
@@ -1410,7 +1449,7 @@ export default function LeaderboardExplorer({
{/* Mini 5-Criteria Bars */} - {hasScoredCriteria && ( + {hasShownCriteria && (
scoredCriteria.includes(c.key)) + .filter((c) => shownCriteria.includes(c.key)) .map((c) => { const val = entry.criteria[c.key]; const pct = sanitizeLeaderboardBarPercentage(val); @@ -1551,9 +1590,14 @@ export default function LeaderboardExplorer({ const b = rankedEntries[iB]!; const vHref = versusHref(a.product.slug, b.product.slug); const delta = formatDecimal( - Math.abs(a.activeScore5 - b.activeScore5), + Math.abs( + scoreDigits === 1 + ? displayedScore5(a.activeScore5) - + displayedScore5(b.activeScore5) + : a.activeScore5 - b.activeScore5 + ), locale, - 2 + scoreDigits ); return (
@@ -1574,7 +1618,7 @@ export default function LeaderboardExplorer({
@@ -1588,7 +1632,7 @@ export default function LeaderboardExplorer({
@@ -1770,7 +1814,7 @@ export default function LeaderboardExplorer({ { key: 'value', short: t('shortValue') }, ] as const ) - .filter((c) => scoredCriteria.includes(c.key)) + .filter((c) => shownCriteria.includes(c.key)) .map((c) => (
diff --git a/apps/web/src/components/leaderboard/ProductLeaderboardBadges.tsx b/apps/web/src/components/leaderboard/ProductLeaderboardBadges.tsx index 44435b1dc..0d9aa49b9 100644 --- a/apps/web/src/components/leaderboard/ProductLeaderboardBadges.tsx +++ b/apps/web/src/components/leaderboard/ProductLeaderboardBadges.tsx @@ -55,7 +55,7 @@ export function sanitizeLeaderboardBadgeEntry( : safeRank; const rawScore5 = Number(candidate.score5); if (!Number.isFinite(rawScore5) || rawScore5 <= 0) return null; - const safeScore5 = Math.min(5, Math.max(0, Number(rawScore5.toFixed(2)))); + const safeScore5 = Math.min(5, Math.max(0, rawScore5)); const rawScore10 = Number(candidate.score10); const safeScore10 = Number.isFinite(rawScore10) && rawScore10 > 0 @@ -177,7 +177,13 @@ export default async function ProductLeaderboardBadges({ fontSize: '0.8125rem', }} > - ({formatDecimal(b.score5, lang, 2)}) ↗ + ( + {formatDecimal( + b.score5, + lang, + b.criterion === 'combined' ? 1 : 2 + )} + ) ↗ ); diff --git a/apps/web/src/components/reviews/JReviewsSection.tsx b/apps/web/src/components/reviews/JReviewsSection.tsx index 0b6f21f40..25e77a8e5 100644 --- a/apps/web/src/components/reviews/JReviewsSection.tsx +++ b/apps/web/src/components/reviews/JReviewsSection.tsx @@ -52,6 +52,8 @@ import { type ReviewBadgeKind, } from '@/lib/review-display'; import AnalyzerText, { analyzerText } from './AnalyzerText'; +import ReviewFormV2 from './ReviewFormV2'; +import { isReviewFormV2Enabled } from '@/lib/review-form-v2'; import { formatDecimal, formatPercent, @@ -70,6 +72,8 @@ export interface JReviewsSectionProps { productName: string; averageRating: number; userRatingWeighted: number | null; + /** The stored rating the page headline shows (D3), when there is one. */ + ratingScore?: number | null; expertRating: number | null; expertReviewSummary?: string | null; authenticReviewCount: number; @@ -140,6 +144,7 @@ export default function JReviewsSection({ productName, averageRating, userRatingWeighted, + ratingScore, expertRating, expertReviewSummary, authenticReviewCount, @@ -296,6 +301,10 @@ export default function JReviewsSection({ bayesianConfidence, userWeightSum, } = computeJReviewsUserScores(reviews, userRatingWeighted, averageRating); + // The buyer score is the stored rating every page shows (D3); the list + // only gives the weight and the confidence. + const shownUserScore = + ratingScore != null && ratingScore > 0 ? ratingScore : bayesianUserScore; const confidenceTier: 'insufficient_data' | 'low' | 'medium' | 'high' = bayesianConfidence < 0.3 ? 'insufficient_data' @@ -331,7 +340,7 @@ export default function JReviewsSection({ // The buyer score the header shows, so a 100% buyer composite equals it // (DOS/Rate#1144). const dualScore = computeCombinedDualScore({ - userScore: bayesianUserScore, + userScore: shownUserScore, expertScore: computedExpert, userConfidence: bayesianConfidence, fallbackAverage: averageRating, @@ -366,7 +375,7 @@ export default function JReviewsSection({ blendMode: dualScore.blendMode, starAnomaly, user: computedUser, - bayesianUser: bayesianUserScore, + bayesianUser: shownUserScore, bayesianConfidence, confidenceTier, confidenceLabel, @@ -378,6 +387,7 @@ export default function JReviewsSection({ reviews, averageRating, userRatingWeighted, + ratingScore, expertRating, shownCriteriaMeta, t, @@ -628,38 +638,8 @@ export default function JReviewsSection({ return; } - const newRev = payload.review as ReviewItem; - setReviews((prev) => [newRev, ...prev]); - - try { - const cacheKey = `${LOCAL_REVIEWS_PREFIX}${masterProductId || productSlug || productName}`; - const existingLocal = JSON.parse( - window.localStorage.getItem(cacheKey) || '[]' - ); - const nextLocal = [ - newRev, - ...(Array.isArray(existingLocal) ? existingLocal : []), - ].slice(0, 20); - window.localStorage.setItem(cacheKey, JSON.stringify(nextLocal)); - } catch { - // Ignore storage quota errors - } - setComment(''); - setShowWriteForm(false); - setSubmitSuccess( - newRev.verdict === 'unanalyzed' - ? t('successPendingModeration') - : newRev.include_in_rating - ? t('successPublishedAuthentic', { - weight: formatDecimal(newRev.helpfulness_weight, locale, 2), - }) - : t('successFlaggedFiltered', { - flag: - flagMetaMap[newRev.flag_category]?.label ?? - newRev.flag_category, - }) - ); + handleReviewStored(payload.review as ReviewItem); } catch { setSubmitError(t('errNetworkFailed')); } finally { @@ -667,6 +647,40 @@ export default function JReviewsSection({ } } + /** Shows a stored review at the top and says how it was classified. */ + function handleReviewStored(newRev: ReviewItem) { + setReviews((prev) => [newRev, ...prev]); + + try { + const cacheKey = `${LOCAL_REVIEWS_PREFIX}${masterProductId || productSlug || productName}`; + const existingLocal = JSON.parse( + window.localStorage.getItem(cacheKey) || '[]' + ); + const nextLocal = [ + newRev, + ...(Array.isArray(existingLocal) ? existingLocal : []), + ].slice(0, 20); + window.localStorage.setItem(cacheKey, JSON.stringify(nextLocal)); + } catch { + // Ignore storage quota errors + } + + setShowWriteForm(false); + setSubmitSuccess( + newRev.verdict === 'unanalyzed' + ? t('successPendingModeration') + : newRev.include_in_rating + ? t('successPublishedAuthentic', { + weight: formatDecimal(newRev.helpfulness_weight, locale, 2), + }) + : t('successFlaggedFiltered', { + flag: + flagMetaMap[newRev.flag_category]?.label ?? + newRev.flag_category, + }) + ); + } + const [copiedReviewsMd, setCopiedReviewsMd] = useState(false); const handleCopyReviewsMarkdown = async () => { @@ -879,8 +893,20 @@ export default function JReviewsSection({
)} + {/* Review form v2 behind its flag (DOS/Rate#1544) */} + {showWriteForm && isReviewFormV2Enabled() && ( + + )} + {/* Interactive 5-Criteria Write Review Form */} - {showWriteForm && ( + {showWriteForm && !isReviewFormV2Enabled() && (
void; +} + +const MESSAGE_KEYS = new Set([ + 'v2ErrCriterion', + 'v2ErrRequired', + 'v2ErrSkipped', + 'v2ErrVerdict', + 'v2ErrCount', + 'v2ErrLength', + 'v2ErrNoContent', + 'v2ErrEvasive', + 'v2ErrDuplicate', + 'v2ErrBoth', +]); + +/** Field errors from the API, kept only when they have the known shape. */ +function apiFieldErrors(value: unknown): ReviewV2Error[] { + if (!Array.isArray(value)) return []; + return value + .filter( + (e): e is ReviewV2Error => + Boolean(e) && + typeof e.field === 'string' && + e.field.length <= 80 && + MESSAGE_KEYS.has(e.key) + ) + .slice(0, 40) + .map((e) => ({ + field: e.field, + key: e.key, + values: + e.values && typeof e.values === 'object' + ? Object.fromEntries( + Object.entries(e.values).filter( + ([, v]) => typeof v === 'number' && Number.isFinite(v) + ) + ) + : undefined, + })); +} + +/** + * Stars from 1 to 5 in half steps, as a radio group: one tab stop, arrows + * move by half a star (design 4.1, no default value). + */ +function HalfStarPicker({ + value, + label, + invalid, + required, + describedBy, + onChange, +}: { + value: number | null; + label: string; + invalid: boolean; + required: boolean; + describedBy?: string; + onChange: (star: number) => void; +}) { + const t = useTranslations('reviews'); + const locale = useLocale(); + const refs = useRef>({}); + const focusable = value ?? 1; + + function move(next: number) { + const star = Math.max(1, Math.min(5, next)); + onChange(star); + refs.current[String(star)]?.focus(); + } + + function handleKey(e: React.KeyboardEvent) { + const step = + e.key === 'ArrowRight' || e.key === 'ArrowUp' + ? 0.5 + : e.key === 'ArrowLeft' || e.key === 'ArrowDown' + ? -0.5 + : 0; + if (step) { + e.preventDefault(); + move((value ?? (step > 0 ? 0.5 : 1.5)) + step); + } else if (e.key === 'Home' || e.key === 'End') { + e.preventDefault(); + move(e.key === 'Home' ? 1 : 5); + } + } + + return ( +
+ {[1, 2, 3, 4, 5].map((n) => { + const fill = + value != null && value >= n + ? 100 + : value != null && value >= n - 0.5 + ? 50 + : 0; + return ( + +
+ ); +} + +function LineList({ + label, + hint, + field, + lines, + max, + errorFor, + onChange, +}: { + label: string; + hint?: string; + field: string; + lines: string[]; + max: number; + errorFor: (field: string) => string | null; + onChange: (lines: string[]) => void; +}) { + const t = useTranslations('reviews'); + const baseId = useId(); + const listError = errorFor(field); + const listErrorId = `${baseId}-err`; + const rows = lines.length > 0 ? lines : ['']; + // Blank lines are dropped before the check, so error N is the Nth line + // with text in it, not the Nth row. + let checked = -1; + const checkedIndex = rows.map((line) => + line.trim() ? (checked += 1) : null + ); + return ( +
+ {label} + {rows.map((line, index) => { + const at = checkedIndex[index]; + const error = at == null ? null : errorFor(`${field}.${at}`); + const errorId = `${baseId}-${index}-err`; + return ( +
+
+ + onChange( + rows.map((l, i) => (i === index ? e.target.value : l)) + ) + } + className={`form-control form-control-sm rounded-3 ${error ? 'is-invalid' : ''}`} + /> + {rows.length > 1 && ( + + )} +
+ {error && ( +
+ {error} +
+ )} +
+ ); + })} + {listError && ( +
+ {listError} +
+ )} + {rows.length < max && ( + + )} +
+ ); +} + +/** + * The v2 review form (DOS/Rate#1544, design #1531 section 4.1): per-category + * criteria in half stars with the weighted star beside them, a verdict, 1-5 + * pros and cons, and optional pros and cons per rated criterion. Errors show + * on the field they belong to, and the draft stays in the browser until the + * review is stored. + */ +export default function ReviewFormV2({ + masterProductId, + productName, + categorySlug, + defaultAuthorName, + onStored, +}: ReviewFormV2Props) { + const t = useTranslations('reviews'); + const locale = useLocale(); + const fieldId = useId(); + const criteria: readonly RatingCriterion[] = useMemo( + () => getRatingCriteria(categorySlug ?? null), + [categorySlug] + ); + const labelOf = (c: RatingCriterion) => + locale === 'vi' ? c.label.vi : c.label.en; + const draftKey = masterProductId + ? `${REVIEW_V2_DRAFT_PREFIX}${masterProductId}` + : null; + + const [draft, setDraft] = useState(emptyReviewV2Draft); + const [draftLoaded, setDraftLoaded] = useState(false); + const [authorName, setAuthorName] = useState(''); + const [variation, setVariation] = useState(''); + const [openPoints, setOpenPoints] = useState(null); + const [attempted, setAttempted] = useState(false); + const [serverErrors, setServerErrors] = useState([]); + const [submitError, setSubmitError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!draftKey) return; + try { + const saved = parseReviewV2Draft(window.localStorage.getItem(draftKey)); + if (saved) setDraft(saved); + } catch { + // Storage blocked: the form starts empty. + } + setDraftLoaded(true); + }, [draftKey]); + + useEffect(() => { + if (!draftLoaded || !draftKey) return; + try { + window.localStorage.setItem(draftKey, JSON.stringify(draft)); + } catch { + // Storage full or blocked: the draft lives only in the page. + } + }, [draft, draftKey, draftLoaded]); + + const input = useMemo(() => reviewV2InputFromDraft(draft), [draft]); + const overall = reviewV2Star(categorySlug ?? null, input.criteriaRatings); + const liveErrors = useMemo( + () => (attempted ? reviewV2Errors(categorySlug ?? null, input) : []), + [attempted, categorySlug, input] + ); + const errors = liveErrors.length > 0 ? liveErrors : serverErrors; + const errorFor = (field: string): string | null => { + const error = errors.find((e) => e.field === field); + return error ? t(error.key, error.values) : null; + }; + + function update(next: (prev: ReviewV2Draft) => ReviewV2Draft) { + setServerErrors([]); + setDraft(next); + } + + function setRating(key: string, star: number | null) { + update((prev) => ({ ...prev, ratings: { ...prev.ratings, [key]: star } })); + if (star == null && openPoints === key) setOpenPoints(null); + } + + function setPoints(key: string, list: 'pros' | 'cons', lines: string[]) { + update((prev) => { + const entry = prev.points[key] ?? { pros: [], cons: [] }; + return { + ...prev, + points: { ...prev.points, [key]: { ...entry, [list]: lines } }, + }; + }); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSubmitError(null); + setAttempted(true); + if (!masterProductId) { + setSubmitError(t('errMissingMasterId')); + return; + } + if (reviewV2Errors(categorySlug ?? null, input).length > 0) { + setSubmitError(t('v2ErrInvalid')); + return; + } + setSubmitting(true); + try { + const res = await fetch('/api/v1/rate/reviews', { + method: 'POST', + headers: await jsonWriteHeaders(), + body: JSON.stringify({ + masterProductId, + formVersion: REVIEW_FORM_VERSION, + authorName: authorName.trim() || defaultAuthorName, + variation: variation.trim() || undefined, + comment: draft.comment.trim() || undefined, + ...input, + }), + }); + if (res.status === 401) { + setSubmitError(t('errLoginRequired')); + return; + } + const payload = await res.json().catch(() => null); + if (!res.ok || !payload?.ok || !payload?.review) { + setServerErrors(apiFieldErrors(payload?.fieldErrors)); + setSubmitError(t(submitErrorKey(payload?.code))); + return; + } + try { + if (draftKey) window.localStorage.removeItem(draftKey); + } catch { + // Nothing to clear when storage is blocked. + } + setDraft(emptyReviewV2Draft()); + setAttempted(false); + onStored(payload.review as ReviewItem); + } catch { + setSubmitError(t('errNetworkFailed')); + } finally { + setSubmitting(false); + } + } + + const skippedError = errorFor('criteria'); + const verdictError = errorFor('verdict'); + const verdictLength = [...draft.verdict.trim()].length; + + return ( + +

+ {t('v2FormHeading', { productName })} +

+ + {submitError && ( +
+
+ )} + +
+
+ + setAuthorName(e.target.value)} + placeholder={t('authorNamePlaceholder')} + className="form-control form-control-sm rounded-3" + maxLength={80} + /> +
+
+ + setVariation(e.target.value)} + placeholder={t('variationPlaceholder')} + className="form-control form-control-sm rounded-3" + maxLength={100} + /> +
+
+ +
+
+ {t('v2CriteriaLabel')} + + {t('v2Overall')}{' '} + + {overall == null ? '-' : formatDecimal(overall, locale, 1)} ★ + + +
+ {criteria.map((c) => { + const value = draft.ratings[c.key] ?? null; + // Skipped on purpose: null; not rated yet: no key at all. + const skipped = + Object.prototype.hasOwnProperty.call(draft.ratings, c.key) && + value == null; + const label = labelOf(c); + const error = errorFor(`criterion.${c.key}`); + const errorId = `${fieldId}-${c.key}-err`; + const pointsOpen = openPoints === c.key && value != null; + const points = draft.points[c.key] ?? { pros: [], cons: [] }; + return ( +
+
+ + {label} + {c.required && ( + + )} + + setRating(c.key, star)} + /> + + {value == null ? '' : formatDecimal(value, locale, 1)} + + {!c.required && ( + + )} + {value != null && ( + + )} +
+ {error && ( +
+ {error} +
+ )} + {pointsOpen && ( +
+
+ setPoints(c.key, 'pros', lines)} + /> +
+
+ setPoints(c.key, 'cons', lines)} + /> +
+
+ )} +
+ ); + })} + {skippedError && ( +
+ {skippedError} +
+ )} +
+ +
+ +