Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Docs/Proposals/2026-10-31-dos-me-criteria-ratings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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;
18 changes: 18 additions & 0 deletions Docs/Proposals/2026-11-01-dos-me-review-submission-v2-rollback.sql
Original file line number Diff line number Diff line change
@@ -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;
69 changes: 69 additions & 0 deletions Docs/Proposals/2026-11-01-dos-me-review-submission-v2-verify.sql
Original file line number Diff line number Diff line change
@@ -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;
90 changes: 90 additions & 0 deletions Docs/Proposals/2026-11-01-dos-me-review-submission-v2.md
Original file line number Diff line number Diff line change
@@ -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.<key>`;
- `verdict`;
- `pros`, or `pros.<i>`;
- `cons`, or `cons.<i>`;
- `criterion_points.<key>.<pros|cons>.<i>`;
- `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.
Loading
Loading