From 32225f2836fb50362efb0de2beab546aab3393fd Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:24:10 +0700 Subject: [PATCH] chore(db): mirror the four older drift migrations from dos-me DOS/DOS.Me is the only deployable ledger. Four rate migrations differed between it and supabase/migrations: - 20261013192302_jreviews_rating_and_review_flags.sql and 20261014011054_review_votes.sql: the DOS.Me version is the one on production (no explanatory comments; review_flags policies created only when missing). Replace the reference copies with it. - 20261025090000_rate_product_images_bucket.sql: only in DOS.Me. It needs the Supabase storage schema, so the replay skips it, as DOS.Me's own replay does (NOT_REPLAYED in scripts/lib/rate-db.mjs). - 20261106150000_rate_listing_price_context_peer_max_history_first.sql: only in DOS.Me (Rate-Services price context). Mirrored, and database.types.ts regenerated: history_first_at and peer_reference_max. Nothing is applied by this change. Refs #1606 Co-Authored-By: Claude Sonnet 5.5 --- packages/shared-data/src/database.types.ts | 2 + scripts/lib/rate-db.mjs | 11 +- ...92302_jreviews_rating_and_review_flags.sql | 27 +-- .../20261014011054_review_votes.sql | 117 +++++------ ...61025090000_rate_product_images_bucket.sql | 63 ++++++ ...g_price_context_peer_max_history_first.sql | 182 ++++++++++++++++++ 6 files changed, 325 insertions(+), 77 deletions(-) create mode 100644 supabase/migrations/20261025090000_rate_product_images_bucket.sql create mode 100644 supabase/migrations/20261106150000_rate_listing_price_context_peer_max_history_first.sql diff --git a/packages/shared-data/src/database.types.ts b/packages/shared-data/src/database.types.ts index f54114ed0..948d7fbce 100644 --- a/packages/shared-data/src/database.types.ts +++ b/packages/shared-data/src/database.types.ts @@ -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; diff --git a/scripts/lib/rate-db.mjs b/scripts/lib/rate-db.mjs index 01fd11c7f..57e1616b3 100644 --- a/scripts/lib/rate-db.mjs +++ b/scripts/lib/rate-db.mjs @@ -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') })); } diff --git a/supabase/migrations/20261013192302_jreviews_rating_and_review_flags.sql b/supabase/migrations/20261013192302_jreviews_rating_and_review_flags.sql index 0ad23c5f0..51501aa4a 100644 --- a/supabase/migrations/20261013192302_jreviews_rating_and_review_flags.sql +++ b/supabase/migrations/20261013192302_jreviews_rating_and_review_flags.sql @@ -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' @@ -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; @@ -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; diff --git a/supabase/migrations/20261014011054_review_votes.sql b/supabase/migrations/20261014011054_review_votes.sql index 6790db606..61855ea12 100644 --- a/supabase/migrations/20261014011054_review_votes.sql +++ b/supabase/migrations/20261014011054_review_votes.sql @@ -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, @@ -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 @@ -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 @@ -115,9 +91,6 @@ BEGIN END; $$; --- ------------------------------------------------------------------------------ --- Triggers --- ------------------------------------------------------------------------------ CREATE OR REPLACE FUNCTION rate.review_votes_guard() RETURNS trigger LANGUAGE plpgsql @@ -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; \ No newline at end of file diff --git a/supabase/migrations/20261025090000_rate_product_images_bucket.sql b/supabase/migrations/20261025090000_rate_product_images_bucket.sql new file mode 100644 index 000000000..375693f95 --- /dev/null +++ b/supabase/migrations/20261025090000_rate_product_images_bucket.sql @@ -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; diff --git a/supabase/migrations/20261106150000_rate_listing_price_context_peer_max_history_first.sql b/supabase/migrations/20261106150000_rate_listing_price_context_peer_max_history_first.sql new file mode 100644 index 000000000..fecf6b9f5 --- /dev/null +++ b/supabase/migrations/20261106150000_rate_listing_price_context_peer_max_history_first.sql @@ -0,0 +1,182 @@ +-- Rate: market reference and history age for the inflated_fake_sale signal +-- (DOS/Rate-Services#582, decided by JOY 2026-11-04). +-- +-- Purpose: the Rate-Services auditors flag `inflated_fake_sale` when a listing's +-- struck-through price is far above the median of its own 60-day price points. +-- A retailer that always sells below the manufacturer's list price (MSRP) and +-- strikes the list price through therefore trips the signal after one week at a +-- flat price, although the struck price is what the official stores sell at. +-- The auditors need two more facts per listing, both computed here because +-- PostgREST cuts max_rows (the reason 20261018160000 moved the aggregates to SQL): +-- +-- peer_reference_max the highest current price among the OTHER priced, +-- in-stock listings of the same cluster, official +-- merchants first, any merchant when the cluster has no +-- other official listing. The struck price is no inflated +-- original price when it does not exceed this by more than +-- the auditor's margin (3%). +-- history_first_at the oldest price point inside the history window. The +-- auditor only concludes once it is at least 14 days old. +-- +-- rate.listing_price_context(...) gains the two columns at the END of its +-- result. A function cannot change its result type with CREATE OR REPLACE, so +-- this drops and recreates it with the same signature and re-grants EXECUTE +-- (DROP removes the grants): service_role as in 20261018160000, rate_worker as +-- in 20261018183000. Both steps run in the migration's transaction, so no caller +-- sees the function missing. The body is that of 20261020230000 plus the two +-- columns, with the aggregates of the peers and median expressions schema-qualified +-- like those of the history expression (pg_catalog is searched anyway, so the +-- result is the same); nothing else changes. Nothing depends on the function (no view, +-- trigger or other function), so the DROP needs no CASCADE and fails loudly if +-- one appears. +-- +-- Idempotent: DROP FUNCTION IF EXISTS, then the full definition and the grants +-- again; a second run leaves the same function. Touches the `rate` schema only. + +DROP FUNCTION IF EXISTS rate.listing_price_context(UUID, INTEGER, INTEGER, UUID[]); + +CREATE FUNCTION rate.listing_price_context( + p_after UUID DEFAULT NULL, + p_limit INTEGER DEFAULT 500, + p_history_days INTEGER DEFAULT 60, + p_merchant_ids UUID[] DEFAULT NULL +) +RETURNS TABLE ( + product_id UUID, + cluster_id UUID, + merchant_id UUID, + merchant_is_official BOOLEAN, + platform TEXT, + title TEXT, + price_current NUMERIC, + price_original NUMERIC, + -- Minimum price among the OTHER priced, in-stock listings of the cluster, + -- official merchants first, any merchant when the cluster has no other + -- official listing (MerchantRiskAuditor.computePeerReferenceMinPrice). + peer_reference_min NUMERIC, + -- Minimum official-merchant price of the cluster's in-stock listings, this + -- listing included while it is in stock (PriceAnomalyAuditor.computeOfficialMinByCluster). + cluster_official_min NUMERIC, + history_count INTEGER, + history_avg NUMERIC, + history_median NUMERIC, + history_min NUMERIC, + history_max NUMERIC, + -- Maximum price among the OTHER priced, in-stock listings of the cluster, + -- chosen like peer_reference_min: official merchants first, any merchant when + -- the cluster has no other official listing. NULL when no other listing of + -- the cluster is in stock. + peer_reference_max NUMERIC, + -- recorded_at of the oldest crawled price point inside the history window + -- (the same points history_count counts); NULL when there is none. + history_first_at TIMESTAMPTZ +) +LANGUAGE plpgsql +STABLE +SET search_path = '' +AS $$ +DECLARE + -- Index-friendly predicates: no "param IS NULL OR ..." (which forces a + -- scan in a generic plan); the nil uuid sorts before every real id. + v_after UUID := COALESCE(p_after, '00000000-0000-0000-0000-000000000000'::uuid); + v_limit INTEGER := LEAST(GREATEST(COALESCE(p_limit, 500), 1), 1000); + v_since TIMESTAMPTZ := pg_catalog.now() - pg_catalog.make_interval(days => GREATEST(COALESCE(p_history_days, 60), 1)); + v_page UUID[]; +BEGIN + IF p_merchant_ids IS NULL THEN + SELECT pg_catalog.array_agg(x.id ORDER BY x.id) INTO v_page + FROM ( + SELECT p.id + FROM rate.products p + WHERE p.id > v_after + AND p.origin = 'crawl' + AND p.price_current > 0 + ORDER BY p.id + LIMIT v_limit + ) x; + ELSE + SELECT pg_catalog.array_agg(x.id ORDER BY x.id) INTO v_page + FROM ( + SELECT p.id + FROM rate.products p + WHERE p.merchant_id = ANY (p_merchant_ids) + AND p.id > v_after + AND p.origin = 'crawl' + AND p.price_current > 0 + ORDER BY p.id + LIMIT v_limit + ) x; + END IF; + + IF v_page IS NULL THEN + RETURN; + END IF; + + RETURN QUERY + SELECT + pg.id, + pg.cluster_id, + pg.merchant_id, + COALESCE(m.is_official, false), + pg.platform, + pg.title, + pg.price_current, + pg.price_original, + peers.peer_reference_min, + peers.cluster_official_min, + COALESCE(h.n, 0)::INTEGER, + h.avg_price, + h.median_price::NUMERIC, + h.min_price, + h.max_price, + peers.peer_reference_max, + h.first_at + FROM rate.products pg + JOIN rate.merchants m ON m.id = pg.merchant_id + LEFT JOIN LATERAL ( + SELECT + COALESCE( + pg_catalog.min(o.price_current) FILTER (WHERE o.id <> pg.id AND om.is_official), + pg_catalog.min(o.price_current) FILTER (WHERE o.id <> pg.id) + ) AS peer_reference_min, + pg_catalog.min(o.price_current) FILTER (WHERE om.is_official) AS cluster_official_min, + COALESCE( + pg_catalog.max(o.price_current) FILTER (WHERE o.id <> pg.id AND om.is_official), + pg_catalog.max(o.price_current) FILTER (WHERE o.id <> pg.id) + ) AS peer_reference_max + FROM rate.products o + JOIN rate.merchants om ON om.id = o.merchant_id + WHERE pg.cluster_id IS NOT NULL + AND o.cluster_id = pg.cluster_id + AND o.origin = 'crawl' + AND o.price_current > 0 + AND o.availability = 'in_stock' + ) peers ON true + LEFT JOIN LATERAL ( + SELECT + pg_catalog.count(*) AS n, + pg_catalog.avg(ph.price) AS avg_price, + pg_catalog.percentile_cont(0.5) WITHIN GROUP (ORDER BY ph.price) AS median_price, + pg_catalog.min(ph.price) AS min_price, + pg_catalog.max(ph.price) AS max_price, + pg_catalog.min(ph.recorded_at) AS first_at + FROM rate.price_history ph + WHERE ph.product_id = pg.id + AND ph.origin = 'crawl' + AND ph.price > 0 + AND ph.recorded_at >= v_since + ) h ON true + WHERE pg.id = ANY (v_page) + ORDER BY pg.id; +END; +$$; + +REVOKE ALL ON FUNCTION rate.listing_price_context(UUID, INTEGER, INTEGER, UUID[]) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION rate.listing_price_context(UUID, INTEGER, INTEGER, UUID[]) TO service_role; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rate_worker') THEN + GRANT EXECUTE ON FUNCTION rate.listing_price_context(UUID, INTEGER, INTEGER, UUID[]) TO rate_worker; + END IF; +END $$;