Skip to content

feat(geo,admin): Qatar soft fallback + country picker + suppression UX#273

Merged
kashkoool merged 2 commits into
mainfrom
chore/geo-qatar-default-and-picker
May 15, 2026
Merged

feat(geo,admin): Qatar soft fallback + country picker + suppression UX#273
kashkoool merged 2 commits into
mainfrom
chore/geo-qatar-default-and-picker

Conversation

@kashkoool

@kashkoool kashkoool commented May 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • Geo: Backend now returns Qatar as a visible soft fallback (source=`unsupported-default`) when geo detects an unsupported country. Pairs with a new navbar country picker so visitors can override the fallback — silent misattribution is gone, but the storefront always has something to render.
  • Admin /email-suppressions: per-reason filter chips with live counts (Bounces / Complaints / Manual / All). Single `Prisma.groupBy` in the same request — no extra round-trip.
  • No DB migrations.

Why

Per user feedback today:

  1. Germany VPN visitors still saw Qatar activities because the frontend asked the catalog with no `countryId` filter → backend returned global (Qatar-heavy) data.
  2. User wanted Qatar as the explicit default for unsupported countries (with a way to switch).
  3. Admin /email-suppressions needed quick filtering + counts.

Changes

  • `apps/api/src/geo/geo.service.ts` — soft Qatar fallback. Distinct sources: `unsupported-default` (real geo, country not in DB) vs existing `fallback` (private IP dev path) vs new `unsupported` (Qatar row also missing — extreme edge case).
  • `apps/web/src/components/country-picker.tsx` (new) — desktop pill + mobile menu row variants. Flag emoji from ISO alpha-2.
  • `apps/web/src/components/navbar.tsx` — wires picker into both desktop bar and hamburger menu.
  • `apps/web/src/context/geo-context.tsx` — when API returns no country at all, clear stale cache. Manual picks preserved.
  • `apps/api/src/admin/admin.service.ts` + `admin.controller.ts` + new `email-suppressions-query.dto.ts` — accept `?reason=` filter, return `counts` map alongside items.
  • `apps/web/src/app/admin/email-suppressions/page.tsx` — `` subcomponent for the filter strip.

Test plan

  • Type-check API + web (clean)
  • Lint API + web changed files (clean)
  • Unit tests `admin.service.spec.ts` + `email-suppression.service.spec.ts` — 45/45 pass
  • Manual: Germany VPN → should still see Qatar content, picker shows current country, switching country updates listings
  • Manual: /admin/email-suppressions → counts strip, click each chip filters list, "All" clears

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added filtering for email suppressions by reason (Bounce, Complaint, Manual) with per-reason count display on the suppressions page.
    • Added country picker component to navbar for convenient country selection, supporting both desktop dropdown and mobile menu variants.
  • Bug Fixes

    • Fixed stale geolocation data persisting in the UI by clearing cached location data when detection fails.
  • Improvements

    • Geo detection now provides improved fallback behavior for unsupported countries.

Review Change Stack

Geo:
- Restore Qatar as a soft visual fallback when geo detects an unsupported
  country (Germany, India, etc.). Backend returns the real Qatar row with
  source='unsupported-default' so the storefront has something to render,
  and the navbar country picker (new) lets visitors switch explicitly.
  This is NOT the silent-misattribution bug from before — the picker
  affordance makes the fallback user-fixable rather than invisible.
- New CountryPicker component (desktop pill + mobile menu row) calls
  geo.setCountry() which already routes to source='manual' (never auto
  revalidated, never clobbered by IP geo).
- geo-context: when API returns no country at all (Qatar row missing or
  source='unknown'), clear any stale cached country so the picker is the
  only forward path. Manual picks still survive (early-return preserved).

Email Suppressions admin UX (no schema changes):
- Add per-reason count chips (Bounces / Complaints / Manual / All) that
  double as filter chips. Counts come from a single Prisma groupBy in the
  same response as the page items.
- Accept new ?reason=BOUNCE|COMPLAINT|MANUAL query param via a small
  EmailSuppressionsQueryDto extending PaginationDto.

No DB migrations. Per-reason counts and filter live entirely off the
existing schema.
@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@kashkoool has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 52 minutes and 19 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7dd40d54-1999-4187-9f45-da16ef56a0ec

📥 Commits

Reviewing files that changed from the base of the PR and between ea2cae7 and 06f7724.

📒 Files selected for processing (1)
  • apps/web/src/components/country-picker.tsx
📝 Walkthrough

Walkthrough

This PR adds email suppression list filtering by reason with per-reason counts, and introduces a country picker component for geographic selection. The backend geo service implements a Qatar fallback for unsupported countries. The frontend integrates the new country picker into the navbar and adds filtering controls to the suppressions page.

Changes

Email Suppressions Filtering

Layer / File(s) Summary
Email Suppressions Query Contract
apps/api/src/admin/dto/email-suppressions-query.dto.ts, apps/api/src/admin/admin.controller.ts
New EmailSuppressionsQueryDto extends pagination and validates optional reason field against BOUNCE, COMPLAINT, MANUAL. Controller endpoint parameter type updated to use the new DTO.
Admin Service Reason Filtering
apps/api/src/admin/admin.service.ts
Service method accepts inline query with optional reason, builds conditional where filter, and expands Prisma queries to compute groupBy(['reason']) aggregates. Response now includes per-reason counts object.
Frontend Suppression List Filtering UI
apps/web/src/app/admin/email-suppressions/page.tsx
Response type updated with counts map. Page introduces reason state and includes it in query key. UI replaces total badge with clickable counts-strip chips (All/Bounces/Complaints/Manual) that filter the list and reset pagination.

Geo Detection and Country Picker

Layer / File(s) Summary
Geo Detection Fallback Strategy
apps/api/src/geo/geo.service.ts, apps/web/src/context/geo-context.tsx
Geo service implements Qatar fallback when country detection returns null, querying for active Qatar record and its first city. Geo context now clears persisted cache and explicitly sets country/city to null when /geo/detect yields no result.
CountryPicker Component Implementation
apps/web/src/components/country-picker.tsx
New CountryPicker component with desktop (dropdown pill) and mobile (full-width row) variants. Fetches countries via React Query, generates flag emojis from ISO codes, manages dropdown open/close with outside-click and Escape detection, and wires selection to useGeo context.
Navbar Country Picker Integration
apps/web/src/components/navbar.tsx
CountryPicker added to desktop header area (variant="desktop") and mobile hamburger menu (variant="mobile" with menu-closing onSelect handler).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • kashkoool/Jadwal#220: Overlaps in apps/web/src/context/geo-context.tsx localStorage caching behavior for geo detection and manual selection.
  • kashkoool/Jadwal#201: Introduces PaginationDto that EmailSuppressionsQueryDto extends in this PR.
  • kashkoool/Jadwal#226: Related changes to geo context cache handling and /geo/detect result processing.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main changes: Qatar soft fallback in geo service, new country picker component, and email suppressions UX enhancement with reason filtering.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/geo-qatar-default-and-picker

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
apps/api/src/geo/geo.service.ts (1)

76-80: 💤 Low value

Consider a more intentional city ordering strategy.

The fallback city is selected as the first alphabetically by English name (orderBy: { nameEn: 'asc' }). While deterministic, this may not be the most user-friendly default. Consider ordering by population (if available), a designated isCapital flag, or an explicit displayOrder field to ensure the most relevant city (e.g., Doha) appears as the fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/geo/geo.service.ts` around lines 76 - 80, The current fallback
city selection uses a simple alphabetical order in the prisma query (see
prisma.client.city.findFirst and const firstCity) which may not pick the most
relevant city; update the selection logic to prefer a more meaningful ordering
such as isCapital, population, or an explicit displayOrder: modify the findFirst
call to orderBy an ordered list (e.g., isCapital desc, displayOrder asc,
population desc, nameEn asc) or implement a two-step fallback that first
searches for city where isCapital = true (or displayOrder is set) for the
fallbackCountry and only falls back to alphabetical if none found, ensuring the
most relevant city (e.g., Doha) is chosen as the default.
apps/web/src/components/country-picker.tsx (1)

59-63: ⚡ Quick win

Consider adding error handling for the countries query.

The component doesn't handle query errors. If /catalog/countries fails, users will see an indefinite "Loading..." state with no explanation. Consider using the error property from useQuery to display a user-friendly error message or retry button.

📡 Proposed enhancement with error state
-  const { data: countries = [] } = useQuery<Country[]>({
+  const { data: countries = [], error, isError } = useQuery<Country[]>({
     queryKey: ['public-countries'],
     queryFn: () => api.get('/catalog/countries').then((r) => r.data),
     staleTime: 60 * 60 * 1000,
   });

Then in the desktop dropdown (and similarly for mobile):

           {countries.length === 0 ? (
-            <div className="px-4 py-3 text-sm text-gray-400 dark:text-slate-500">{t('common.loading')}</div>
+            <div className="px-4 py-3 text-sm text-gray-400 dark:text-slate-500">
+              {isError ? t('common.error', { defaultValue: 'Failed to load countries' }) : t('common.loading')}
+            </div>
           ) : (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/country-picker.tsx` around lines 59 - 63, The
countries query currently only destructures data from useQuery (const { data:
countries = [] } = useQuery(...)) so failures leave the component stuck on
"Loading..."; update the useQuery call to also destructure error and isLoading
(e.g., const { data: countries = [], error, isLoading, refetch } =
useQuery(...)) and then add UI branches in country-picker.tsx (where the
dropdowns are rendered) to show a user-friendly error message and a retry
control (or fallback list) when error is present, and keep the existing loading
state for isLoading; ensure you reference the same queryKey ['public-countries']
and the queryFn so behavior and caching remain unchanged.
apps/api/src/admin/admin.service.ts (1)

2813-2833: ⚡ Quick win

Consider adding EmailSuppression to the Prisma schema for type safety.

The repeated cast to (this.prisma.client as any).emailSuppression bypasses TypeScript's type checking. If the EmailSuppression table structure changes (e.g., column rename, type change), these queries would fail at runtime without any compile-time warning.

🔧 Suggested improvement

Add the EmailSuppression model to your schema.prisma file. If the table already exists in the database:

# Run Prisma introspection to detect the existing table
npx prisma db pull

# Then regenerate the Prisma client
npx prisma generate

Or manually add the model to schema.prisma if introspection isn't suitable. This eliminates the unsafe casts and provides full IntelliSense + compile-time checking for all three queries (lines 2813, 2829, 2830).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/admin/admin.service.ts` around lines 2813 - 2833, The code
repeatedly uses unsafe casts like (this.prisma.client as any).emailSuppression
for queries (emailSuppression.findMany, count, groupBy) which bypass TypeScript
checks; add an EmailSuppression model to your Prisma schema (or run prisma db
pull if the table already exists) and regenerate the Prisma client so you can
remove the casts and use this.prisma.client.emailSuppression with proper types,
allowing compile-time validation and IntelliSense for the findMany, count, and
groupBy calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/api/src/admin/admin.service.ts`:
- Around line 2813-2833: The code repeatedly uses unsafe casts like
(this.prisma.client as any).emailSuppression for queries
(emailSuppression.findMany, count, groupBy) which bypass TypeScript checks; add
an EmailSuppression model to your Prisma schema (or run prisma db pull if the
table already exists) and regenerate the Prisma client so you can remove the
casts and use this.prisma.client.emailSuppression with proper types, allowing
compile-time validation and IntelliSense for the findMany, count, and groupBy
calls.

In `@apps/api/src/geo/geo.service.ts`:
- Around line 76-80: The current fallback city selection uses a simple
alphabetical order in the prisma query (see prisma.client.city.findFirst and
const firstCity) which may not pick the most relevant city; update the selection
logic to prefer a more meaningful ordering such as isCapital, population, or an
explicit displayOrder: modify the findFirst call to orderBy an ordered list
(e.g., isCapital desc, displayOrder asc, population desc, nameEn asc) or
implement a two-step fallback that first searches for city where isCapital =
true (or displayOrder is set) for the fallbackCountry and only falls back to
alphabetical if none found, ensuring the most relevant city (e.g., Doha) is
chosen as the default.

In `@apps/web/src/components/country-picker.tsx`:
- Around line 59-63: The countries query currently only destructures data from
useQuery (const { data: countries = [] } = useQuery(...)) so failures leave the
component stuck on "Loading..."; update the useQuery call to also destructure
error and isLoading (e.g., const { data: countries = [], error, isLoading,
refetch } = useQuery(...)) and then add UI branches in country-picker.tsx (where
the dropdowns are rendered) to show a user-friendly error message and a retry
control (or fallback list) when error is present, and keep the existing loading
state for isLoading; ensure you reference the same queryKey ['public-countries']
and the queryFn so behavior and caching remain unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f95901d7-fdd1-4937-8334-560df3a61d45

📥 Commits

Reviewing files that changed from the base of the PR and between 5b1c8ec and ea2cae7.

📒 Files selected for processing (8)
  • apps/api/src/admin/admin.controller.ts
  • apps/api/src/admin/admin.service.ts
  • apps/api/src/admin/dto/email-suppressions-query.dto.ts
  • apps/api/src/geo/geo.service.ts
  • apps/web/src/app/admin/email-suppressions/page.tsx
  • apps/web/src/components/country-picker.tsx
  • apps/web/src/components/navbar.tsx
  • apps/web/src/context/geo-context.tsx

…t test

CountryPicker imported @/lib/localize, which transitively imports @/lib/i18n.
i18n.ts runs i18next.use(initReactI18next) at module load. The navbar unit
test mocks react-i18next without exporting initReactI18next, so the mocked
value is undefined and i18n.use() crashes the whole suite at import time.

Replace localized() with a 3-line pickName() helper that takes the current
language as an argument (read from useTranslation().i18n.language inside
the component). No more module-load init in the navbar's graph.
@kashkoool kashkoool merged commit 48e6bb4 into main May 15, 2026
20 checks passed
@kashkoool kashkoool deleted the chore/geo-qatar-default-and-picker branch May 15, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant