Add country mapping support for imports and geographic map
Build and Push Docker Image / build (push) Successful in 9m7s Details

- Add normalizeCountryToCode utility to convert country names to ISO-2 codes
- Support English, French and common alternate spellings
- Update Typeform import to support country field mapping
- Update Notion import to support country field mapping
- Allow project.update to set/update country with automatic normalization
- Fix geographic distribution map showing empty when country data exists

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-04 16:13:40 +01:00
parent 6d2537ec04
commit c0f318a867
4 changed files with 233 additions and 1 deletions

View File

@ -221,3 +221,201 @@ export function getCountryCoordinates(code: string): [number, number] | null {
if (!country) return null if (!country) return null
return [country.lat, country.lng] return [country.lat, country.lng]
} }
/**
* Country name to ISO-2 code mappings.
* Includes English, French, and common alternate spellings.
*/
const COUNTRY_NAME_TO_CODE: Record<string, string> = {
// Build reverse lookup from COUNTRIES
...Object.fromEntries(
Object.entries(COUNTRIES).flatMap(([code, info]) => [
[info.name.toLowerCase(), code],
])
),
// French names and alternate spellings
'tunisie': 'TN',
'royaume-uni': 'GB',
'uk': 'GB',
'angleterre': 'GB',
'england': 'GB',
'espagne': 'ES',
'inde': 'IN',
'états-unis': 'US',
'etats-unis': 'US',
'usa': 'US',
'allemagne': 'DE',
'italie': 'IT',
'suisse': 'CH',
'belgique': 'BE',
'pays-bas': 'NL',
'australie': 'AU',
'japon': 'JP',
'chine': 'CN',
'brésil': 'BR',
'bresil': 'BR',
'mexique': 'MX',
'maroc': 'MA',
'egypte': 'EG',
'afrique du sud': 'ZA',
'sénégal': 'SN',
'senegal': 'SN',
"côte d'ivoire": 'CI',
'cote d\'ivoire': 'CI',
'indonésie': 'ID',
'indonesie': 'ID',
'thaïlande': 'TH',
'thailande': 'TH',
'malaisie': 'MY',
'singapour': 'SG',
'grèce': 'GR',
'grece': 'GR',
'turquie': 'TR',
'pologne': 'PL',
'norvège': 'NO',
'norvege': 'NO',
'suède': 'SE',
'suede': 'SE',
'danemark': 'DK',
'finlande': 'FI',
'irlande': 'IE',
'autriche': 'AT',
'nigéria': 'NG',
'nigeria': 'NG',
'tanzanie': 'TZ',
'ouganda': 'UG',
'zambie': 'ZM',
'somalie': 'SO',
'jordanie': 'JO',
'algérie': 'DZ',
'algerie': 'DZ',
'cameroun': 'CM',
'maurice': 'MU',
'malte': 'MT',
'croatie': 'HR',
'roumanie': 'RO',
'hongrie': 'HU',
'tchéquie': 'CZ',
'tcheque': 'CZ',
'slovaquie': 'SK',
'slovénie': 'SI',
'estonie': 'EE',
'lettonie': 'LV',
'lituanie': 'LT',
'chypre': 'CY',
'malawi': 'MW',
'mozambique': 'MZ',
'namibie': 'NA',
'botswana': 'BW',
'zimbabwe': 'ZW',
'éthiopie': 'ET',
'ethiopie': 'ET',
'soudan': 'SD',
'libye': 'LY',
'arabie saoudite': 'SA',
'émirats arabes unis': 'AE',
'emirats arabes unis': 'AE',
'uae': 'AE',
'qatar': 'QA',
'koweït': 'KW',
'koweit': 'KW',
'bahreïn': 'BH',
'bahrein': 'BH',
'oman': 'OM',
'yémen': 'YE',
'yemen': 'YE',
'irak': 'IQ',
'iran': 'IR',
'afghanistan': 'AF',
'pakistan': 'PK',
'bangladesh': 'BD',
'sri lanka': 'LK',
'népal': 'NP',
'nepal': 'NP',
'birmanie': 'MM',
'myanmar': 'MM',
'cambodge': 'KH',
'laos': 'LA',
'corée du sud': 'KR',
'coree du sud': 'KR',
'south korea': 'KR',
'corée du nord': 'KP',
'coree du nord': 'KP',
'north korea': 'KP',
'nouvelle-zélande': 'NZ',
'nouvelle zelande': 'NZ',
'fidji': 'FJ',
'fiji': 'FJ',
'papouasie-nouvelle-guinée': 'PG',
'argentine': 'AR',
'chili': 'CL',
'colombie': 'CO',
'pérou': 'PE',
'perou': 'PE',
'venezuela': 'VE',
'équateur': 'EC',
'equateur': 'EC',
'bolivie': 'BO',
'paraguay': 'PY',
'uruguay': 'UY',
'costa rica': 'CR',
'panama': 'PA',
'guatemala': 'GT',
'honduras': 'HN',
'salvador': 'SV',
'nicaragua': 'NI',
'cuba': 'CU',
'haïti': 'HT',
'haiti': 'HT',
'jamaïque': 'JM',
'jamaique': 'JM',
'trinidad': 'TT',
'trinité-et-tobago': 'TT',
'république dominicaine': 'DO',
'republique dominicaine': 'DO',
'dominican republic': 'DO',
'puerto rico': 'PR',
'porto rico': 'PR',
}
/**
* Convert a country name or code to ISO-2 code.
* Handles:
* - Already valid ISO-2 codes (returns as-is)
* - Full country names (English or French)
* - Common alternate spellings
*
* @param input Country name or code
* @returns ISO-2 code or null if not recognized
*/
export function normalizeCountryToCode(input: string | null | undefined): string | null {
if (!input) return null
const trimmed = input.trim()
if (!trimmed) return null
// If already a valid 2-letter ISO code
if (/^[A-Z]{2}$/.test(trimmed) && COUNTRIES[trimmed]) {
return trimmed
}
// Check uppercase version
const upper = trimmed.toUpperCase()
if (/^[A-Z]{2}$/.test(upper) && COUNTRIES[upper]) {
return upper
}
// Try to find in name mappings
const lower = trimmed.toLowerCase()
const code = COUNTRY_NAME_TO_CODE[lower]
if (code) return code
// Try partial matching for country names
for (const [name, countryCode] of Object.entries(COUNTRY_NAME_TO_CODE)) {
if (lower.includes(name) || name.includes(lower)) {
return countryCode
}
}
return null
}

View File

@ -7,6 +7,7 @@ import {
getNotionDatabaseSchema, getNotionDatabaseSchema,
queryNotionDatabase, queryNotionDatabase,
} from '@/lib/notion' } from '@/lib/notion'
import { normalizeCountryToCode } from '@/lib/countries'
export const notionImportRouter = router({ export const notionImportRouter = router({
/** /**
@ -91,6 +92,7 @@ export const notionImportRouter = router({
teamName: z.string().optional(), teamName: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
tags: z.string().optional(), // Multi-select property tags: z.string().optional(), // Multi-select property
country: z.string().optional(), // Country name or ISO code
}), }),
// Store unmapped columns in metadataJson // Store unmapped columns in metadataJson
includeUnmappedInMetadata: z.boolean().default(true), includeUnmappedInMetadata: z.boolean().default(true),
@ -148,6 +150,15 @@ export const notionImportRouter = router({
} }
} }
// Get country and normalize to ISO code
let country: string | null = null
if (input.mappings.country) {
const countryValue = getPropertyValue(record.properties, input.mappings.country)
if (typeof countryValue === 'string') {
country = normalizeCountryToCode(countryValue)
}
}
// Build metadata from unmapped columns // Build metadata from unmapped columns
let metadataJson: Record<string, unknown> | null = null let metadataJson: Record<string, unknown> | null = null
if (input.includeUnmappedInMetadata) { if (input.includeUnmappedInMetadata) {
@ -156,6 +167,7 @@ export const notionImportRouter = router({
input.mappings.teamName, input.mappings.teamName,
input.mappings.description, input.mappings.description,
input.mappings.tags, input.mappings.tags,
input.mappings.country,
].filter(Boolean)) ].filter(Boolean))
metadataJson = {} metadataJson = {}
@ -179,6 +191,7 @@ export const notionImportRouter = router({
teamName: typeof teamName === 'string' ? teamName.trim() : null, teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null, description: typeof description === 'string' ? description : null,
tags, tags,
country,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
externalIdsJson: { externalIdsJson: {
notionPageId: record.id, notionPageId: record.id,

View File

@ -7,6 +7,7 @@ import {
notifyProjectTeam, notifyProjectTeam,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
export const projectRouter = router({ export const projectRouter = router({
/** /**
@ -324,6 +325,7 @@ export const projectRouter = router({
title: z.string().min(1).max(500).optional(), title: z.string().min(1).max(500).optional(),
teamName: z.string().optional().nullable(), teamName: z.string().optional().nullable(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
country: z.string().optional().nullable(), // ISO-2 code or country name (will be normalized)
// Status update requires roundId // Status update requires roundId
roundId: z.string().optional(), roundId: z.string().optional(),
status: z status: z
@ -341,13 +343,19 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, metadataJson, status, roundId, ...data } = input const { id, metadataJson, status, roundId, country, ...data } = input
// Normalize country to ISO-2 code if provided
const normalizedCountry = country !== undefined
? (country === null ? null : normalizeCountryToCode(country))
: undefined
const project = await ctx.prisma.project.update({ const project = await ctx.prisma.project.update({
where: { id }, where: { id },
data: { data: {
...data, ...data,
...(status && { status }), ...(status && { status }),
...(normalizedCountry !== undefined && { country: normalizedCountry }),
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}, },
}) })

View File

@ -8,6 +8,7 @@ import {
getAllTypeformResponses, getAllTypeformResponses,
responseToObject, responseToObject,
} from '@/lib/typeform' } from '@/lib/typeform'
import { normalizeCountryToCode } from '@/lib/countries'
export const typeformImportRouter = router({ export const typeformImportRouter = router({
/** /**
@ -102,6 +103,7 @@ export const typeformImportRouter = router({
description: z.string().optional(), description: z.string().optional(),
tags: z.string().optional(), // Multi-select or text field tags: z.string().optional(), // Multi-select or text field
email: z.string().optional(), // For tracking submission email email: z.string().optional(), // For tracking submission email
country: z.string().optional(), // Country name or ISO code
}), }),
// Store unmapped columns in metadataJson // Store unmapped columns in metadataJson
includeUnmappedInMetadata: z.boolean().default(true), includeUnmappedInMetadata: z.boolean().default(true),
@ -162,6 +164,15 @@ export const typeformImportRouter = router({
} }
} }
// Get country and normalize to ISO code
let country: string | null = null
if (input.mappings.country) {
const countryValue = record[input.mappings.country]
if (typeof countryValue === 'string') {
country = normalizeCountryToCode(countryValue)
}
}
// Build metadata from unmapped columns // Build metadata from unmapped columns
let metadataJson: Record<string, unknown> | null = null let metadataJson: Record<string, unknown> | null = null
if (input.includeUnmappedInMetadata) { if (input.includeUnmappedInMetadata) {
@ -171,6 +182,7 @@ export const typeformImportRouter = router({
input.mappings.description, input.mappings.description,
input.mappings.tags, input.mappings.tags,
input.mappings.email, input.mappings.email,
input.mappings.country,
'_response_id', '_response_id',
'_submitted_at', '_submitted_at',
].filter(Boolean)) ].filter(Boolean))
@ -207,6 +219,7 @@ export const typeformImportRouter = router({
teamName: typeof teamName === 'string' ? teamName.trim() : null, teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null, description: typeof description === 'string' ? description : null,
tags, tags,
country,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
externalIdsJson: { externalIdsJson: {
typeformResponseId: response.response_id, typeformResponseId: response.response_id,