Add country mapping support for imports and geographic map
Build and Push Docker Image / build (push) Successful in 9m7s
Details
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:
parent
6d2537ec04
commit
c0f318a867
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue