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
2 changes: 2 additions & 0 deletions packages/shared-data/src/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,11 +1459,13 @@ export type Database = {
cluster_official_min: number;
history_avg: number;
history_count: number;
history_first_at: string;
history_max: number;
history_median: number;
history_min: number;
merchant_id: string;
merchant_is_official: boolean;
peer_reference_max: number;
peer_reference_min: number;
platform: string;
price_current: number;
Expand Down
11 changes: 10 additions & 1 deletion scripts/lib/rate-db.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,19 @@ export const repoRoot = path.resolve(
'..'
);

// Migrations the throwaway database cannot apply, and the test that covers each
// instead (the same list DOS/DOS.Me keeps in scripts/migration/test-rate-schema-replay.mjs).
export const NOT_REPLAYED = new Map([
[
'20261025090000_rate_product_images_bucket.sql',
'needs the Supabase storage schema; covered by DOS/DOS.Me scripts/ci/rate-product-images-bucket.integration.test.mjs',
],
]);

export function readMigrations() {
const migDir = path.join(repoRoot, 'supabase', 'migrations');
return readdirSync(migDir)
.filter((f) => f.endsWith('.sql'))
.filter((f) => f.endsWith('.sql') && !NOT_REPLAYED.has(f))
.sort()
.map((f) => ({ name: f, sql: readFileSync(path.join(migDir, f), 'utf8') }));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
-- ==============================================================================
-- Migration: JReviews-Style Dual Rating (Expert vs Weighted User),
-- Review Upvote/Downvote Weighting, 5-Category Anti-Scam Flags & Multi-Entity Support
-- ==============================================================================

-- 1. Extend rate.review_flags with 5 distinct Vietnamese e-commerce & service flag categories
ALTER TABLE rate.review_flags
ADD COLUMN IF NOT EXISTS flag_category TEXT DEFAULT 'authentic'
Expand Down Expand Up @@ -54,7 +49,6 @@ DECLARE
v_net INTEGER := COALESCE(p_upvotes, 0) - COALESCE(p_downvotes, 0);
v_weight NUMERIC;
BEGIN
-- Skip/Ignore coin spam, seeding fake, competitor sabotage, and review bombing completely
IF p_flag_category IN ('low_value_coin_spam', 'seeding_fake', 'competitor_sabotage', 'review_bombing')
OR p_verdict = 'fake' THEN
RETURN 0.00;
Expand All @@ -72,18 +66,27 @@ BEGIN
v_weight := v_base;
END IF;

-- Penalty for user abuse reports
v_weight := v_weight - (COALESCE(p_reports, 0) * 0.35);

RETURN ROUND(GREATEST(0.00, LEAST(3.00, v_weight)), 2);
END;
$$;

-- Public read access for review_flags so UI can display transparency badges
ALTER TABLE rate.review_flags ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Public can view review_flags" ON rate.review_flags;
CREATE POLICY "Public can view review_flags" ON rate.review_flags FOR SELECT USING (true);
DROP POLICY IF EXISTS "Service role write review_flags" ON rate.review_flags;
CREATE POLICY "Service role write review_flags" ON rate.review_flags FOR ALL TO service_role USING (true) WITH CHECK (true);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE schemaname = 'rate' AND tablename = 'review_flags' AND policyname = 'Public can view review_flags'
) THEN
CREATE POLICY "Public can view review_flags" ON rate.review_flags FOR SELECT USING (true);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE schemaname = 'rate' AND tablename = 'review_flags' AND policyname = 'Service role write review_flags'
) THEN
CREATE POLICY "Service role write review_flags" ON rate.review_flags FOR ALL TO service_role USING (true) WITH CHECK (true);
END IF;
END $$;

GRANT SELECT ON rate.review_flags TO anon, authenticated;
GRANT ALL ON rate.review_flags TO service_role;
117 changes: 53 additions & 64 deletions supabase/migrations/20261014011054_review_votes.sql
Original file line number Diff line number Diff line change
@@ -1,20 +1,3 @@
-- ==============================================================================
-- Migration: per-user review votes / reports (rate.review_votes)
--
-- Design notes
-- * One row per (review, voter): a user has ONE up/down vote per review and may
-- additionally REPORT it. Changing a vote is an UPDATE, not a second row.
-- * Authenticated users only (auth.users, shared Supabase Auth). Anonymous voting is
-- the easiest brigading vector (review_bombing / competitor_sabotage), so it is
-- deliberately not supported in v1.
-- * Counters on rate.reviews (upvotes/downvotes/report_count/helpfulness_weight) are
-- ALWAYS recomputed from rate.review_votes (set-based, idempotent -> no drift), and
-- helpfulness_weight reuses rate.compute_review_weight so SQL and TS agree.
-- * rate.master_products.user_rating_weighted is recomputed across ALL listings of a
-- master product.
-- * Flood guard: max 60 new votes / hour / user.
-- ==============================================================================

CREATE TABLE IF NOT EXISTS rate.review_votes (
review_id UUID NOT NULL REFERENCES rate.reviews(id) ON DELETE CASCADE,
voter_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
Expand All @@ -34,10 +17,6 @@ CREATE TABLE IF NOT EXISTS rate.review_votes (
CREATE INDEX IF NOT EXISTS idx_rate_review_votes_voter_time
ON rate.review_votes (voter_id, created_at DESC);

-- ------------------------------------------------------------------------------
-- Aggregate: weighted user rating of a master product across ALL its listings.
-- Only analyzed reviews (a review_flags row exists) with weight > 0 count.
-- ------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rate.refresh_master_product_rating(p_master_id UUID)
RETURNS void
LANGUAGE sql
Expand All @@ -64,9 +43,6 @@ AS $$
WHERE m.id = p_master_id;
$$;

-- ------------------------------------------------------------------------------
-- Recompute counters + weight of one review from review_votes, then its master rating.
-- ------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rate.refresh_review_vote_counts(p_review_id UUID)
RETURNS void
LANGUAGE plpgsql
Expand Down Expand Up @@ -115,9 +91,6 @@ BEGIN
END;
$$;

-- ------------------------------------------------------------------------------
-- Triggers
-- ------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rate.review_votes_guard()
RETURNS trigger
LANGUAGE plpgsql
Expand Down Expand Up @@ -161,52 +134,68 @@ BEGIN
END;
$$;

DROP TRIGGER IF EXISTS trg_review_votes_guard ON rate.review_votes;
CREATE TRIGGER trg_review_votes_guard
BEFORE INSERT OR UPDATE ON rate.review_votes
FOR EACH ROW EXECUTE FUNCTION rate.review_votes_guard();
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_review_votes_guard'
) THEN
CREATE TRIGGER trg_review_votes_guard
BEFORE INSERT OR UPDATE ON rate.review_votes
FOR EACH ROW EXECUTE FUNCTION rate.review_votes_guard();
END IF;

DROP TRIGGER IF EXISTS trg_review_votes_after_change ON rate.review_votes;
CREATE TRIGGER trg_review_votes_after_change
AFTER INSERT OR UPDATE OR DELETE ON rate.review_votes
FOR EACH ROW EXECUTE FUNCTION rate.review_votes_after_change();
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_review_votes_after_change'
) THEN
CREATE TRIGGER trg_review_votes_after_change
AFTER INSERT OR UPDATE OR DELETE ON rate.review_votes
FOR EACH ROW EXECUTE FUNCTION rate.review_votes_after_change();
END IF;
END $$;

-- ------------------------------------------------------------------------------
-- RLS: users read/write ONLY their own votes. Public sees only the counters that
-- live on rate.reviews (never who voted).
-- ------------------------------------------------------------------------------
ALTER TABLE rate.review_votes ENABLE ROW LEVEL SECURITY;

DROP POLICY IF EXISTS "Users read own review votes" ON rate.review_votes;
CREATE POLICY "Users read own review votes" ON rate.review_votes
FOR SELECT TO authenticated USING (voter_id = (SELECT auth.uid()));
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE schemaname = 'rate' AND tablename = 'review_votes' AND policyname = 'Users read own review votes'
) THEN
CREATE POLICY "Users read own review votes" ON rate.review_votes
FOR SELECT TO authenticated USING (voter_id = (SELECT auth.uid()));
END IF;

DROP POLICY IF EXISTS "Users insert own review votes" ON rate.review_votes;
CREATE POLICY "Users insert own review votes" ON rate.review_votes
FOR INSERT TO authenticated WITH CHECK (voter_id = (SELECT auth.uid()));
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE schemaname = 'rate' AND tablename = 'review_votes' AND policyname = 'Users insert own review votes'
) THEN
CREATE POLICY "Users insert own review votes" ON rate.review_votes
FOR INSERT TO authenticated WITH CHECK (voter_id = (SELECT auth.uid()));
END IF;

DROP POLICY IF EXISTS "Users update own review votes" ON rate.review_votes;
CREATE POLICY "Users update own review votes" ON rate.review_votes
FOR UPDATE TO authenticated
USING (voter_id = (SELECT auth.uid()))
WITH CHECK (voter_id = (SELECT auth.uid()));
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE schemaname = 'rate' AND tablename = 'review_votes' AND policyname = 'Users update own review votes'
) THEN
CREATE POLICY "Users update own review votes" ON rate.review_votes
FOR UPDATE TO authenticated
USING (voter_id = (SELECT auth.uid()))
WITH CHECK (voter_id = (SELECT auth.uid()));
END IF;

DROP POLICY IF EXISTS "Users delete own review votes" ON rate.review_votes;
CREATE POLICY "Users delete own review votes" ON rate.review_votes
FOR DELETE TO authenticated USING (voter_id = (SELECT auth.uid()));
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE schemaname = 'rate' AND tablename = 'review_votes' AND policyname = 'Users delete own review votes'
) THEN
CREATE POLICY "Users delete own review votes" ON rate.review_votes
FOR DELETE TO authenticated USING (voter_id = (SELECT auth.uid()));
END IF;

DROP POLICY IF EXISTS "Service role full access review_votes" ON rate.review_votes;
CREATE POLICY "Service role full access review_votes" ON rate.review_votes
FOR ALL TO service_role USING (true) WITH CHECK (true);
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE schemaname = 'rate' AND tablename = 'review_votes' AND policyname = 'Service role full access review_votes'
) THEN
CREATE POLICY "Service role full access review_votes" ON rate.review_votes
FOR ALL TO service_role USING (true) WITH CHECK (true);
END IF;
END $$;

-- ------------------------------------------------------------------------------
-- Grants. Internal functions are NOT callable through the public API.
-- ------------------------------------------------------------------------------
REVOKE ALL ON rate.review_votes FROM anon;
GRANT SELECT, INSERT, UPDATE, DELETE ON rate.review_votes TO authenticated;
GRANT ALL ON rate.review_votes TO service_role;

REVOKE EXECUTE ON FUNCTION rate.refresh_master_product_rating(UUID) FROM PUBLIC, anon, authenticated;
REVOKE EXECUTE ON FUNCTION rate.refresh_review_vote_counts(UUID) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION rate.refresh_master_product_rating(UUID) TO service_role;
GRANT EXECUTE ON FUNCTION rate.refresh_review_vote_counts(UUID) TO service_role;
GRANT EXECUTE ON FUNCTION rate.refresh_review_vote_counts(UUID) TO service_role;
63 changes: 63 additions & 0 deletions supabase/migrations/20261025090000_rate_product_images_bucket.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
-- Rate.vn product image mirror (DOS/Rate#887).
--
-- Some marketplace CDNs refuse images to pages on rate.vn (hotlink
-- protection), and listings lose their pictures when a marketplace removes
-- them. Rate-Services copies a listing's first images into this bucket at
-- crawl time and stores the public URLs on rate.products.images.
--
-- Public bucket: rate.vn serves the objects through the Storage public URL,
-- which does not consult RLS. Only service_role (Rate-Services) writes. The
-- restrictive fences keep anon and authenticated from listing, writing or
-- deleting objects here even when a broad Storage policy exists.
-- Idempotent: replays are no-ops, and a bucket or fence that differs from this
-- definition fails the deployment instead of being silently widened.
-- CREATE POLICY locks storage.objects, which every product on the shared
-- project uses: give up after 5s rather than queue Storage behind a blocker.
BEGIN;
SET LOCAL lock_timeout = '5s';

INSERT INTO storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
VALUES ('rate-product-images', 'rate-product-images', true, 2097152,
ARRAY['image/jpeg', 'image/png', 'image/webp']) ON CONFLICT (id) DO NOTHING;

DO $bucket$
BEGIN
IF NOT EXISTS (SELECT 1 FROM storage.buckets WHERE id = 'rate-product-images' AND name = 'rate-product-images'
AND public = true AND file_size_limit = 2097152
AND allowed_mime_types = ARRAY['image/jpeg', 'image/png', 'image/webp']) THEN
RAISE EXCEPTION 'RATE_IMAGE_BUCKET_CONFIGURATION_MISMATCH';
END IF;
END;
$bucket$;

DO $storage_policies$
DECLARE v_expected record; v_policy record; v_relation regclass; v_roles oid[];
BEGIN
SELECT array_agg(oid ORDER BY oid) INTO v_roles FROM pg_catalog.pg_roles WHERE rolname IN ('anon', 'authenticated');
FOR v_expected IN SELECT * FROM (VALUES
('objects', 'rate_product_images_server_only', 'bucket_id'),
('buckets', 'rate_product_image_bucket_server_only', 'id')) AS x(table_name, policy_name, column_name)
LOOP
v_relation := to_regclass('storage.' || v_expected.table_name);
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_class WHERE oid = v_relation AND relrowsecurity) THEN
RAISE EXCEPTION 'RATE_IMAGE_STORAGE_RLS_REQUIRED';
END IF;
SELECT * INTO v_policy FROM pg_catalog.pg_policy WHERE polrelid = v_relation AND polname = v_expected.policy_name;
IF NOT FOUND THEN
EXECUTE format('CREATE POLICY %I ON storage.%I AS RESTRICTIVE FOR ALL TO anon, authenticated
USING (%I <> ''rate-product-images'') WITH CHECK (%I <> ''rate-product-images'')',
v_expected.policy_name, v_expected.table_name, v_expected.column_name, v_expected.column_name);
ELSE
IF v_policy.polpermissive OR v_policy.polcmd <> '*' OR cardinality(v_policy.polroles) <> 2
OR NOT v_policy.polroles @> v_roles
OR regexp_replace(pg_catalog.pg_get_expr(v_policy.polqual, v_relation), '[[:space:]()]', '', 'g')
IS DISTINCT FROM v_expected.column_name || '<>''rate-product-images''::text'
OR regexp_replace(pg_catalog.pg_get_expr(v_policy.polwithcheck, v_relation), '[[:space:]()]', '', 'g')
IS DISTINCT FROM v_expected.column_name || '<>''rate-product-images''::text' THEN
RAISE EXCEPTION 'RATE_IMAGE_STORAGE_POLICY_MISMATCH';
END IF;
END IF;
END LOOP;
END;
$storage_policies$;
COMMIT;
Loading
Loading