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
5 changes: 4 additions & 1 deletion docs/API_CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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)
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +57 to +63

-- 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
Comment on lines +81 to +83
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;

Comment on lines +92 to +103
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.';
100 changes: 95 additions & 5 deletions supabase/tests/scanner_functions.test.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
-- ─────────────────────────────────────────────────────────────────────────────

BEGIN;
SELECT plan(72);
SELECT plan(80);

-- ─── Fixtures ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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');
Expand All @@ -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',
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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)'
);
Comment on lines +634 to +639

-- 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;
Loading