diff --git a/frontend/messages/de.json b/frontend/messages/de.json index a6cb5ba0..e1106906 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1090,7 +1090,9 @@ "submittedLabel": "Eingereicht:", "reviewedLabel": "Überprüft:", "approve": "Genehmigen", - "reject": "Ablehnen" + "reject": "Ablehnen", + "countryLabel": "Land:", + "allCountries": "Alle Länder" }, "monitoring": { "title": "Systemüberwachung", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0d11164d..b5bf7edd 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1090,7 +1090,9 @@ "submittedLabel": "Submitted:", "reviewedLabel": "Reviewed:", "approve": "Approve", - "reject": "Reject" + "reject": "Reject", + "countryLabel": "Country:", + "allCountries": "All countries" }, "monitoring": { "title": "System Monitoring", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index fc0f886a..ed784281 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -1090,7 +1090,9 @@ "submittedLabel": "Zgłoszono:", "reviewedLabel": "Zweryfikowano:", "approve": "✅ Zatwierdź", - "reject": "❌ Odrzuć" + "reject": "❌ Odrzuć", + "countryLabel": "Kraj:", + "allCountries": "Wszystkie kraje" }, "monitoring": { "title": "Monitoring systemu", diff --git a/frontend/src/app/app/admin/submissions/page.test.tsx b/frontend/src/app/app/admin/submissions/page.test.tsx index 5daf4a45..d8926b67 100644 --- a/frontend/src/app/app/admin/submissions/page.test.tsx +++ b/frontend/src/app/app/admin/submissions/page.test.tsx @@ -25,6 +25,11 @@ vi.mock("@/components/common/skeletons", () => ({ SubmissionsSkeleton: () =>
, })); +vi.mock("@/components/common/CountryChip", () => ({ + CountryChip: ({ country }: { country: string | null }) => + country ? {country} : null, +})); + // ─── Helpers ──────────────────────────────────────────────────────────────── function Wrapper({ children }: Readonly<{ children: React.ReactNode }>) { @@ -61,6 +66,8 @@ const makeSubmission = (overrides: Record = {}) => ({ user_flagged: false, review_notes: null, existing_product_match: null, + scan_country: "PL", + suggested_country: null, ...overrides, }); @@ -529,4 +536,101 @@ describe("AdminSubmissionsPage", () => { ); }); }); + + // ─── Country Context Tests (#925) ────────────────────────────────────── + + it("shows country chip when scan_country is present", async () => { + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("Test Chips")).toBeInTheDocument(); + }); + // All 3 subs have scan_country: "PL" + const chips = screen.getAllByTestId("country-chip"); + expect(chips.length).toBe(3); + expect(chips[0]).toHaveTextContent("PL"); + }); + + it("prefers suggested_country over scan_country", async () => { + mockCallRpc.mockImplementation((_client: unknown, fnName: string) => { + if (fnName === "api_admin_get_submissions") { + return Promise.resolve({ + ok: true, + data: { + submissions: [ + makeSubmission({ + scan_country: "PL", + suggested_country: "DE", + product_name: "German Chips", + }), + ], + page: 1, + pages: 1, + total: 1, + }, + }); + } + return Promise.resolve({ ok: true, data: {} }); + }); + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("German Chips")).toBeInTheDocument(); + }); + const chip = screen.getByTestId("country-chip"); + expect(chip).toHaveTextContent("DE"); + }); + + it("does not render country chip when both countries are null", async () => { + mockCallRpc.mockImplementation((_client: unknown, fnName: string) => { + if (fnName === "api_admin_get_submissions") { + return Promise.resolve({ + ok: true, + data: { + submissions: [ + makeSubmission({ + scan_country: null, + suggested_country: null, + product_name: "Unknown Origin", + }), + ], + page: 1, + pages: 1, + total: 1, + }, + }); + } + return Promise.resolve({ ok: true, data: {} }); + }); + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByText("Unknown Origin")).toBeInTheDocument(); + }); + expect(screen.queryByTestId("country-chip")).not.toBeInTheDocument(); + }); + + it("renders country filter dropdown", async () => { + render(, { wrapper: createWrapper() }); + await waitFor(() => { + expect(screen.getByTestId("country-filter")).toBeInTheDocument(); + }); + expect(screen.getByText("All countries")).toBeInTheDocument(); + }); + + it("sends p_country when country filter is selected", async () => { + render(, { wrapper: createWrapper() }); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByTestId("country-filter")).toBeInTheDocument(); + }); + + await user.selectOptions(screen.getByTestId("country-filter"), "PL"); + + await waitFor(() => { + expect(mockCallRpc).toHaveBeenCalledWith( + expect.anything(), + "api_admin_get_submissions", + expect.objectContaining({ p_country: "PL" }), + ); + }); + }); }); diff --git a/frontend/src/app/app/admin/submissions/page.tsx b/frontend/src/app/app/admin/submissions/page.tsx index e418162b..ff79e1ed 100644 --- a/frontend/src/app/app/admin/submissions/page.tsx +++ b/frontend/src/app/app/admin/submissions/page.tsx @@ -5,10 +5,12 @@ // that bypass RLS. In production, restrict route via middleware or auth check. import { Button } from "@/components/common/Button"; +import { CountryChip } from "@/components/common/CountryChip"; import { EmptyStateIllustration } from "@/components/common/EmptyStateIllustration"; import { SubmissionsSkeleton } from "@/components/common/skeletons"; import { Breadcrumbs } from "@/components/layout/Breadcrumbs"; import { useTranslation } from "@/lib/i18n"; +import { COUNTRIES } from "@/lib/constants"; import { callRpc } from "@/lib/rpc"; import { createClient } from "@/lib/supabase/client"; import { showToast } from "@/lib/toast"; @@ -56,11 +58,12 @@ export default function AdminSubmissionsPage() { const supabase = createClient(); const queryClient = useQueryClient(); const [statusFilter, setStatusFilter] = useState("pending"); + const [countryFilter, setCountryFilter] = useState(null); const [page, setPage] = useState(1); const queryKey = useMemo( - () => ["admin-submissions", statusFilter, page], - [statusFilter, page], + () => ["admin-submissions", statusFilter, countryFilter, page], + [statusFilter, countryFilter, page], ); const { data, isLoading, error } = useQuery({ @@ -74,6 +77,7 @@ export default function AdminSubmissionsPage() { p_status: statusFilter, p_page: page, p_page_size: 20, + p_country: countryFilter, }, ); if (!result.ok) throw new Error(result.error.message); @@ -260,6 +264,29 @@ export default function AdminSubmissionsPage() { ))}
+ {/* Country filter */} +
+ + {t("admin.countryLabel")} + + +
+ {/* Loading */} {isLoading && } @@ -404,6 +431,12 @@ function AdminSubmissionCard({

+ {submission.user_trust_score !== null && submission.user_trust_score !== undefined && ( 0 + THEN round(100.0 * uts.approved_submissions / uts.total_submissions) + ELSE NULL + END, + 'user_flagged', (uts.flagged_at IS NOT NULL), + 'review_notes', ps.review_notes, + 'existing_product_match', ( + SELECT jsonb_build_object( + 'product_id', p.product_id, + 'product_name', p.product_name + ) + FROM products p + WHERE p.ean = ps.ean AND p.is_deprecated IS NOT TRUE + LIMIT 1 + ) + ) AS row_obj + FROM public.product_submissions ps + LEFT JOIN public.user_trust_scores uts ON uts.user_id = ps.user_id + WHERE (p_status = 'all' OR ps.status = p_status) + AND (p_country IS NULL + OR ps.scan_country = p_country + OR ps.suggested_country = p_country) + ORDER BY ps.created_at ASC + OFFSET v_offset + LIMIT LEAST(p_page_size, 50) + ) sub; + + RETURN jsonb_build_object( + 'api_version', '1.0', + 'total', v_total, + 'page', GREATEST(p_page, 1), + 'pages', GREATEST(CEIL(v_total::numeric / LEAST(p_page_size, 50)), 1), + 'page_size', LEAST(p_page_size, 50), + 'status_filter', p_status, + 'country_filter', p_country, + 'submissions', v_items + ); +END; +$$; + +-- Updated grants for new signature (4 params) +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 + '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)'; diff --git a/supabase/tests/scanner_functions.test.sql b/supabase/tests/scanner_functions.test.sql index 148e4e99..6bc4a773 100644 --- a/supabase/tests/scanner_functions.test.sql +++ b/supabase/tests/scanner_functions.test.sql @@ -7,7 +7,7 @@ -- ───────────────────────────────────────────────────────────────────────────── BEGIN; -SELECT plan(70); +SELECT plan(72); -- ─── Fixtures ─────────────────────────────────────────────────────────────── @@ -457,6 +457,18 @@ SELECT lives_ok( 'api_admin_get_submissions lives_ok with trust enrichment' ); +-- api_admin_get_submissions response envelope contains country_filter key (#925) +SELECT ok( + public.api_admin_get_submissions('all', 1, 5) ? 'country_filter', + 'api_admin_get_submissions response has country_filter key' +); + +-- api_admin_get_submissions accepts p_country filter (#925) +SELECT lives_ok( + $$SELECT public.api_admin_get_submissions('all', 1, 5, 'PL')$$, + 'api_admin_get_submissions lives_ok with country filter' +); + -- api_admin_submission_velocity returns expected keys SELECT ok( public.api_admin_submission_velocity() ?& ARRAY['api_version', 'last_24h', 'last_7d', 'pending_count', 'status_breakdown', 'top_submitters'],