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
4 changes: 3 additions & 1 deletion frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,9 @@
"submittedLabel": "Submitted:",
"reviewedLabel": "Reviewed:",
"approve": "Approve",
"reject": "Reject"
"reject": "Reject",
"countryLabel": "Country:",
"allCountries": "All countries"
},
"monitoring": {
"title": "System Monitoring",
Expand Down
4 changes: 3 additions & 1 deletion frontend/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,9 @@
"submittedLabel": "Zgłoszono:",
"reviewedLabel": "Zweryfikowano:",
"approve": "✅ Zatwierdź",
"reject": "❌ Odrzuć"
"reject": "❌ Odrzuć",
"countryLabel": "Kraj:",
"allCountries": "Wszystkie kraje"
},
"monitoring": {
"title": "Monitoring systemu",
Expand Down
104 changes: 104 additions & 0 deletions frontend/src/app/app/admin/submissions/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ vi.mock("@/components/common/skeletons", () => ({
SubmissionsSkeleton: () => <div data-testid="skeleton" role="status" aria-label="Loading submissions" />,
}));

vi.mock("@/components/common/CountryChip", () => ({
CountryChip: ({ country }: { country: string | null }) =>
country ? <span data-testid="country-chip">{country}</span> : null,
}));

// ─── Helpers ────────────────────────────────────────────────────────────────

function Wrapper({ children }: Readonly<{ children: React.ReactNode }>) {
Expand Down Expand Up @@ -61,6 +66,8 @@ const makeSubmission = (overrides: Record<string, unknown> = {}) => ({
user_flagged: false,
review_notes: null,
existing_product_match: null,
scan_country: "PL",
suggested_country: null,
...overrides,
});

Expand Down Expand Up @@ -529,4 +536,101 @@ describe("AdminSubmissionsPage", () => {
);
});
});

// ─── Country Context Tests (#925) ──────────────────────────────────────

it("shows country chip when scan_country is present", async () => {
render(<AdminSubmissionsPage />, { 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(<AdminSubmissionsPage />, { 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(<AdminSubmissionsPage />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText("Unknown Origin")).toBeInTheDocument();
});
expect(screen.queryByTestId("country-chip")).not.toBeInTheDocument();
});

it("renders country filter dropdown", async () => {
render(<AdminSubmissionsPage />, { 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(<AdminSubmissionsPage />, { 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" }),
);
});
});
});
37 changes: 35 additions & 2 deletions frontend/src/app/app/admin/submissions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -56,11 +58,12 @@ export default function AdminSubmissionsPage() {
const supabase = createClient();
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState("pending");
const [countryFilter, setCountryFilter] = useState<string | null>(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({
Expand All @@ -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);
Expand Down Expand Up @@ -260,6 +264,29 @@ export default function AdminSubmissionsPage() {
))}
</div>

{/* Country filter */}
<div className="flex items-center gap-2">
<span className="text-sm text-foreground-secondary">
{t("admin.countryLabel")}
</span>
<select
value={countryFilter ?? ""}
onChange={(e) => {
setCountryFilter(e.target.value || null);
setPage(1);
}}
className="rounded-lg border bg-surface px-2 py-1 text-sm text-foreground"
data-testid="country-filter"
Comment on lines +267 to +279
>
<option value="">{t("admin.allCountries")}</option>
{COUNTRIES.map((c) => (
<option key={c.code} value={c.code}>
{c.code}
</option>
))}
</select>
</div>

{/* Loading */}
{isLoading && <SubmissionsSkeleton />}

Expand Down Expand Up @@ -404,6 +431,12 @@ function AdminSubmissionCard({
</p>
</div>
<div className="flex items-center gap-2">
<CountryChip
country={
submission.suggested_country ?? submission.scan_country
}
size="sm"
/>
{submission.user_trust_score !== null &&
submission.user_trust_score !== undefined && (
<span
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,9 @@ export interface AdminSubmission extends Submission {
notes: string | null;
user_id: string;
reviewed_at: string | null;
// Country context (#925)
scan_country: string | null;
suggested_country: string | null;
// Trust enrichment (#474)
user_trust_score: number;
user_total_submissions: number;
Expand All @@ -1174,6 +1177,7 @@ export interface AdminSubmissionsResponse {
pages: number;
page_size: number;
status_filter: string;
country_filter: string | null;
submissions: AdminSubmission[];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Migration: 20260321000100_admin_submissions_country_context.sql
-- Ticket: #925 — Show country context in admin submission review UI
-- ═══════════════════════════════════════════════════════════════════════════
-- Adds scan_country and suggested_country fields to the
-- api_admin_get_submissions response. Adds optional p_country filter param.
--
-- Backward compatible: new keys are additive, new param has DEFAULT NULL.
-- ═══════════════════════════════════════════════════════════════════════════
-- To roll back: redeploy api_admin_get_submissions from 20260315000600
-- ═══════════════════════════════════════════════════════════════════════════


CREATE OR REPLACE FUNCTION public.api_admin_get_submissions(
p_status text DEFAULT 'pending',
p_page integer DEFAULT 1,
p_page_size integer DEFAULT 20,
p_country text DEFAULT NULL
Comment on lines +14 to +18

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drop old api_admin_get_submissions overload

This migration creates a new 4-parameter api_admin_get_submissions but never removes the existing 3-parameter version from 20260315000600_admin_submission_dashboard.sql, so 3-arg callers keep executing the old implementation and won’t receive country_filter/country fields or country filtering behavior. That breaks backward-compatibility expectations for existing callers that still pass only (p_status, p_page, p_page_size) and is already visible in this commit’s own SQL test usage of the 3-arg call.

Useful? React with 👍 / 👎.

)
Comment on lines +14 to +19
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_offset integer;
v_total bigint;
v_items jsonb;
BEGIN
v_offset := (GREATEST(p_page, 1) - 1) * LEAST(p_page_size, 50);

Comment on lines +31 to +32
SELECT COUNT(*) INTO v_total
FROM public.product_submissions
WHERE (p_status = 'all' OR status = p_status)
AND (p_country IS NULL
OR scan_country = p_country
OR suggested_country = p_country);

SELECT COALESCE(jsonb_agg(row_obj ORDER BY rn), '[]'::jsonb)
INTO v_items
FROM (
SELECT
ROW_NUMBER() OVER (ORDER BY ps.created_at ASC) AS rn,
jsonb_build_object(
'id', ps.id,
'ean', ps.ean,
'product_name', ps.product_name,
'brand', ps.brand,
'category', ps.category,
'photo_url', ps.photo_url,
'notes', ps.notes,
'status', ps.status,
'user_id', ps.user_id,
'merged_product_id', ps.merged_product_id,
'created_at', ps.created_at,
'updated_at', ps.updated_at,
'reviewed_at', ps.reviewed_at,
-- ── Country context (#925) ─────────────────────────
'scan_country', ps.scan_country,
'suggested_country', ps.suggested_country,
-- ── Trust & quality enrichment (#474) ──────────────
'user_trust_score', COALESCE(uts.trust_score, 50),
'user_total_submissions', COALESCE(uts.total_submissions, 0),
'user_approved_pct', CASE
WHEN COALESCE(uts.total_submissions, 0) > 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,
Comment on lines +95 to +99
'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)';
Comment on lines +110 to +116
14 changes: 13 additions & 1 deletion 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(70);
SELECT plan(72);

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

Expand Down Expand Up @@ -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'],
Expand Down
Loading