diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index ea335636..35e86e1a 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -702,6 +702,8 @@ Pre-computed confidence for all 1,025 products. Faster than calling `compute_dat **Country resolution order:** explicit `p_scan_country` → `user_preferences.country` → NULL. +**Matching behaviour:** When the same EAN exists in multiple countries, the function prefers the product whose `country` matches the resolved `scan_country`. Deprecated products (`is_deprecated = true`) are excluded. (#926) + **Found Response:** ```jsonc { @@ -718,7 +720,8 @@ Pre-computed confidence for all 1,025 products. Faster than calling `compute_dat "unhealthiness_score": 41, "nutri_score": "D", "scan_country": "PL", // resolved country (#923) - "product_country": "PL" // the product's stored country (#923) + "product_country": "PL", // the product's stored country (#923) + "is_cross_country": false // true when product_country ≠ scan_country (#926) } ``` diff --git a/supabase/migrations/20260321000100_admin_submissions_country_context.sql b/supabase/migrations/20260321000100_admin_submissions_country_context.sql index e3ab4dd4..a124157d 100644 --- a/supabase/migrations/20260321000100_admin_submissions_country_context.sql +++ b/supabase/migrations/20260321000100_admin_submissions_country_context.sql @@ -107,7 +107,7 @@ $$; GRANT EXECUTE ON FUNCTION public.api_admin_get_submissions(text, integer, integer, text) TO service_role, authenticated; -COMMENT ON FUNCTION public.api_admin_get_submissions IS +COMMENT ON FUNCTION public.api_admin_get_submissions(text, integer, integer, text) IS 'Purpose: List product submissions with trust enrichment and country context Auth: authenticated (SECURITY DEFINER) Params: p_status (default pending), p_page (default 1), p_page_size (default 20, max 50), p_country (optional country filter) diff --git a/supabase/migrations/20260321000200_region_preferred_scan_matching.sql b/supabase/migrations/20260321000200_region_preferred_scan_matching.sql new file mode 100644 index 00000000..059d7d13 --- /dev/null +++ b/supabase/migrations/20260321000200_region_preferred_scan_matching.sql @@ -0,0 +1,174 @@ +-- Migration: feat(scanner): region-preferred product matching in api_record_scan (#926) +-- Part of epic #920 — scanner country awareness (Phase 2: Smart Lookup) +-- Depends on: 20260320000300 (country-aware api_record_scan with p_scan_country) +-- +-- Changes: +-- api_record_scan: +-- 1. EAN lookup now prefers same-region matches via ORDER BY +-- 2. Excludes deprecated products from lookup +-- 3. Response includes is_cross_country boolean +-- Fix: drop stale 3-param overload of api_admin_get_submissions +-- and fix unqualified COMMENT from 20260321000100 +-- +-- Backward compatible: when v_scan_country is NULL, ORDER BY degrades to product_id only +-- Rollback: re-run previous version from 20260320000300_country_aware_scanner_rpcs.sql + +-- ════════════════════════════════════════════════════════════════════════════ +-- 0. Fix: drop stale 3-param overload of api_admin_get_submissions +-- The 4-param version (with p_country) supersedes it. +-- The unqualified COMMENT in 20260321000100 fails because both overloads exist. +-- ════════════════════════════════════════════════════════════════════════════ +DROP FUNCTION IF EXISTS public.api_admin_get_submissions(text, integer, integer); + +-- Re-apply the comment with fully-qualified signature +COMMENT ON FUNCTION public.api_admin_get_submissions(text, integer, integer, text) IS + 'Purpose: List product submissions with trust enrichment and country context + Auth: authenticated (SECURITY DEFINER) + Params: p_status (default pending), p_page (default 1), p_page_size (default 20, max 50), p_country (optional country filter) + Returns: JSONB {api_version, total, page, pages, page_size, status_filter, country_filter, submissions: [...]} + Country filter: matches scan_country OR suggested_country + Backward compatible: new p_country param defaults to NULL (no filter)'; + +-- ════════════════════════════════════════════════════════════════════════════ +-- 1. api_record_scan — region-preferred matching + is_cross_country (#926) +-- ════════════════════════════════════════════════════════════════════════════ + +CREATE OR REPLACE FUNCTION public.api_record_scan( + p_ean text, + p_scan_country text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user_id uuid := auth.uid(); + v_product record; + v_found boolean := false; + v_product_id bigint; + v_language text; + v_country_lang text; + v_cat_display text; + v_cat_icon text; + v_rate_check jsonb; + v_scan_country text; +BEGIN + -- Validate EAN format + IF p_ean IS NULL OR LENGTH(TRIM(p_ean)) NOT IN (8, 13) THEN + RETURN jsonb_build_object( + 'api_version', '1.0', + 'error', 'EAN must be 8 or 13 digits' + ); + END IF; + + -- Rate limit check (only for authenticated users who will write) + IF v_user_id IS NOT NULL THEN + v_rate_check := check_scan_rate_limit(v_user_id); + IF NOT (v_rate_check->>'allowed')::boolean THEN + RETURN jsonb_build_object( + 'api_version', '1.0', + 'error', 'rate_limit_exceeded', + 'message', 'Too many scans. Please try again later.', + 'retry_after_seconds', (v_rate_check->>'retry_after_seconds')::integer, + 'current_count', (v_rate_check->>'current_count')::integer, + 'max_allowed', (v_rate_check->>'max_allowed')::integer + ); + END IF; + END IF; + + -- Resolve scan_country: explicit param → user_preferences → NULL + v_scan_country := p_scan_country; + IF v_scan_country IS NULL AND v_user_id IS NOT NULL THEN + SELECT up.country INTO v_scan_country + FROM public.user_preferences up + WHERE up.user_id = v_user_id; + END IF; + + -- Resolve user language + v_language := resolve_language(NULL); + + -- Lookup product by EAN — prefer same-region match (#926) + -- When v_scan_country IS NULL, (p.country = NULL) evaluates to NULL (FALSE), + -- so ORDER BY degrades to product_id only — stable backward compat. + SELECT p.product_id, p.product_name, p.product_name_en, p.name_translations, + p.brand, p.category, p.country, p.unhealthiness_score, p.nutri_score_label + INTO v_product + FROM public.products p + WHERE p.ean = TRIM(p_ean) + AND p.is_deprecated IS NOT TRUE + ORDER BY (p.country = v_scan_country) DESC, + p.product_id + LIMIT 1; + + IF FOUND THEN + v_found := true; + v_product_id := v_product.product_id; + + -- Resolve country default language + SELECT cref.default_language INTO v_country_lang + FROM public.country_ref cref + WHERE cref.country_code = v_product.country; + v_country_lang := COALESCE(v_country_lang, LOWER(v_product.country)); + + -- Resolve category display + icon + SELECT COALESCE(ct.display_name, cr.display_name), + COALESCE(cr.icon_emoji, '📦') + INTO v_cat_display, v_cat_icon + FROM public.category_ref cr + LEFT JOIN public.category_translations ct + ON ct.category = cr.category AND ct.language_code = v_language + WHERE cr.category = v_product.category; + END IF; + + -- Record scan (only for authenticated users) + IF v_user_id IS NOT NULL THEN + INSERT INTO public.scan_history (user_id, ean, product_id, found, scan_country) + VALUES (v_user_id, TRIM(p_ean), v_product_id, v_found, v_scan_country); + END IF; + + -- Return result + IF v_found THEN + RETURN jsonb_build_object( + 'api_version', '1.0', + 'found', true, + 'product_id', v_product.product_id, + 'product_name', v_product.product_name, + 'product_name_en', v_product.product_name_en, + 'product_name_display', CASE + WHEN v_language = v_country_lang THEN v_product.product_name + WHEN v_language = 'en' THEN COALESCE(v_product.product_name_en, v_product.product_name) + ELSE COALESCE( + v_product.name_translations->>v_language, + v_product.product_name_en, + v_product.product_name + ) + END, + 'brand', v_product.brand, + 'category', v_product.category, + 'category_display', v_cat_display, + 'category_icon', v_cat_icon, + 'unhealthiness_score', v_product.unhealthiness_score, + 'nutri_score', v_product.nutri_score_label, + 'scan_country', v_scan_country, + 'product_country', v_product.country, + 'is_cross_country', (v_product.country IS DISTINCT FROM v_scan_country + AND v_scan_country IS NOT NULL) + ); + ELSE + RETURN jsonb_build_object( + 'api_version', '1.0', + 'found', false, + 'ean', TRIM(p_ean), + 'has_pending_submission', EXISTS ( + SELECT 1 FROM public.product_submissions + WHERE ean = TRIM(p_ean) AND status = 'pending' + ), + 'scan_country', v_scan_country + ); + END IF; +END; +$$; + +COMMENT ON FUNCTION public.api_record_scan(text, text) IS + 'Record a barcode scan and lookup product. Prefers same-region match when EAN exists in multiple countries. Returns is_cross_country when matched product differs from scan region. Enforces 100/24h rate limit per user.'; diff --git a/supabase/tests/scanner_functions.test.sql b/supabase/tests/scanner_functions.test.sql index 6bc4a773..38a8d1a9 100644 --- a/supabase/tests/scanner_functions.test.sql +++ b/supabase/tests/scanner_functions.test.sql @@ -7,7 +7,7 @@ -- ───────────────────────────────────────────────────────────────────────────── BEGIN; -SELECT plan(72); +SELECT plan(80); -- ─── Fixtures ─────────────────────────────────────────────────────────────── @@ -287,6 +287,10 @@ SELECT is( -- ─── 10. Trigger: auto-reject invalid EAN on product_submissions ──────────── +-- Temporarily disable trust trigger so submission INSERTs with NULL user_id work +-- (EAN validation + quality triage triggers remain active) +ALTER TABLE public.product_submissions DISABLE TRIGGER trg_trust_score_adjustment; + -- Valid EAN → stays pending INSERT INTO public.product_submissions (ean, product_name, status) VALUES ('4006381333931', 'pgTAP Trigger Valid EAN', 'pending'); @@ -301,6 +305,8 @@ SELECT is( INSERT INTO public.product_submissions (ean, product_name, status) VALUES ('4006381333932', 'pgTAP Trigger Invalid EAN', 'pending'); +ALTER TABLE public.product_submissions ENABLE TRIGGER trg_trust_score_adjustment; + SELECT is( (SELECT status FROM public.product_submissions WHERE product_name = 'pgTAP Trigger Invalid EAN'), 'rejected', @@ -503,7 +509,7 @@ SET LOCAL session_replication_role = 'replica'; INSERT INTO user_trust_scores (user_id, trust_score) VALUES ('00000000-0000-0000-0000-000000000099'::uuid, 85) ON CONFLICT (user_id) DO UPDATE SET trust_score = 85; -SET LOCAL session_replication_role = 'DEFAULT'; +SET LOCAL session_replication_role = 'origin'; -- High trust (85) gives +15 bonus → score = 65 SELECT is( @@ -519,7 +525,7 @@ SELECT is( SET LOCAL session_replication_role = 'replica'; UPDATE user_trust_scores SET trust_score = 15 WHERE user_id = '00000000-0000-0000-0000-000000000099'::uuid; -SET LOCAL session_replication_role = 'DEFAULT'; +SET LOCAL session_replication_role = 'origin'; SELECT is( ((_score_submission_quality( @@ -534,7 +540,7 @@ SELECT is( SET LOCAL session_replication_role = 'replica'; UPDATE user_trust_scores SET trust_score = 35 WHERE user_id = '00000000-0000-0000-0000-000000000099'::uuid; -SET LOCAL session_replication_role = 'DEFAULT'; +SET LOCAL session_replication_role = 'origin'; SELECT is( ((_score_submission_quality( @@ -549,7 +555,7 @@ SELECT is( SET LOCAL session_replication_role = 'replica'; UPDATE user_trust_scores SET trust_score = 10 WHERE user_id = '00000000-0000-0000-0000-000000000099'::uuid; -SET LOCAL session_replication_role = 'DEFAULT'; +SET LOCAL session_replication_role = 'origin'; SELECT ok( (_score_submission_quality( @@ -564,5 +570,89 @@ DELETE FROM user_trust_scores WHERE user_id = '00000000-0000-0000-0000-000000000099'::uuid; +-- ─── 15. Region-preferred matching + is_cross_country (#926) ──────────────── + +-- Response contains is_cross_country key +SELECT ok( + (public.api_record_scan('5901234123457', 'PL')) ? 'is_cross_country', + 'found response contains is_cross_country key (#926)' +); + +-- is_cross_country = true when scan_country differs from product_country +SELECT is( + (public.api_record_scan('5901234123457', 'PL'))->>'is_cross_country', + 'true', + 'is_cross_country=true when scan_country=PL but product_country=XX (#926)' +); + +-- is_cross_country = false when scan_country matches product_country +SELECT is( + (public.api_record_scan('5901234123457', 'XX'))->>'is_cross_country', + 'false', + 'is_cross_country=false when scan_country matches product_country (#926)' +); + +-- is_cross_country = false when no scan_country (NULL) +SELECT is( + (public.api_record_scan('5901234123457'))->>'is_cross_country', + 'false', + 'is_cross_country=false when scan_country is NULL (#926)' +); + +-- Region-preferred matching: insert PL + DE products with same EAN +-- EAN 4015000969604 is unused; PL product gets lower product_id +INSERT INTO public.products ( + product_id, ean, product_name, brand, category, country, + unhealthiness_score, nutri_score_label +) VALUES ( + 999990, '4015000969604', 'pgTAP Dual-EAN PL', 'Dual Brand', + 'pgtap-test-cat', 'PL', 35, 'C' +) ON CONFLICT (product_id) DO NOTHING; + +INSERT INTO public.products ( + product_id, ean, product_name, brand, category, country, + unhealthiness_score, nutri_score_label +) VALUES ( + 999991, '4015000969604', 'pgTAP Dual-EAN DE', 'Dual Brand', + 'pgtap-test-cat', 'DE', 30, 'B' +) ON CONFLICT (product_id) DO NOTHING; + +-- DE user gets DE product (region-preferred) +SELECT is( + ((public.api_record_scan('4015000969604', 'DE'))->>'product_id')::bigint, + 999991::bigint, + 'DE user gets DE product when same EAN exists in PL + DE (#926)' +); + +-- PL user gets PL product (region-preferred) +SELECT is( + ((public.api_record_scan('4015000969604', 'PL'))->>'product_id')::bigint, + 999990::bigint, + 'PL user gets PL product when same EAN exists in PL + DE (#926)' +); + +-- Cross-country fallback: user in XX scans PL-only EAN +SELECT is( + (public.api_record_scan('5901234123457', 'PL'))->>'found', + 'true', + 'cross-country fallback: PL user still finds XX-only product (#926)' +); + +-- Deprecated products excluded from scan lookup +INSERT INTO public.products ( + product_id, ean, product_name, brand, category, country, + unhealthiness_score, nutri_score_label, is_deprecated, deprecated_reason +) VALUES ( + 999989, '4015000969611', 'pgTAP Deprecated Product', 'Dead Brand', + 'pgtap-test-cat', 'XX', 50, 'D', true, 'test-deprecated' +) ON CONFLICT (product_id) DO NOTHING; + +SELECT is( + (public.api_record_scan('4015000969611'))->>'found', + 'false', + 'deprecated product excluded from scan lookup (#926)' +); + + SELECT * FROM finish(); ROLLBACK;