diff --git a/src/lib/countries.ts b/src/lib/countries.ts index 1884474..6de6f79 100644 --- a/src/lib/countries.ts +++ b/src/lib/countries.ts @@ -221,3 +221,201 @@ export function getCountryCoordinates(code: string): [number, number] | null { if (!country) return null 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 = { + // 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 +} diff --git a/src/server/routers/notion-import.ts b/src/server/routers/notion-import.ts index a5f942e..e86e4a1 100644 --- a/src/server/routers/notion-import.ts +++ b/src/server/routers/notion-import.ts @@ -7,6 +7,7 @@ import { getNotionDatabaseSchema, queryNotionDatabase, } from '@/lib/notion' +import { normalizeCountryToCode } from '@/lib/countries' export const notionImportRouter = router({ /** @@ -91,6 +92,7 @@ export const notionImportRouter = router({ teamName: z.string().optional(), description: z.string().optional(), tags: z.string().optional(), // Multi-select property + country: z.string().optional(), // Country name or ISO code }), // Store unmapped columns in metadataJson 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 let metadataJson: Record | null = null if (input.includeUnmappedInMetadata) { @@ -156,6 +167,7 @@ export const notionImportRouter = router({ input.mappings.teamName, input.mappings.description, input.mappings.tags, + input.mappings.country, ].filter(Boolean)) metadataJson = {} @@ -179,6 +191,7 @@ export const notionImportRouter = router({ teamName: typeof teamName === 'string' ? teamName.trim() : null, description: typeof description === 'string' ? description : null, tags, + country, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, externalIdsJson: { notionPageId: record.id, diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 141581d..1976988 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -7,6 +7,7 @@ import { notifyProjectTeam, NotificationTypes, } from '../services/in-app-notification' +import { normalizeCountryToCode } from '@/lib/countries' export const projectRouter = router({ /** @@ -324,6 +325,7 @@ export const projectRouter = router({ title: z.string().min(1).max(500).optional(), teamName: 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 roundId: z.string().optional(), status: z @@ -341,13 +343,19 @@ export const projectRouter = router({ }) ) .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({ where: { id }, data: { ...data, ...(status && { status }), + ...(normalizedCountry !== undefined && { country: normalizedCountry }), metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, }, }) diff --git a/src/server/routers/typeform-import.ts b/src/server/routers/typeform-import.ts index dee645d..6babad1 100644 --- a/src/server/routers/typeform-import.ts +++ b/src/server/routers/typeform-import.ts @@ -8,6 +8,7 @@ import { getAllTypeformResponses, responseToObject, } from '@/lib/typeform' +import { normalizeCountryToCode } from '@/lib/countries' export const typeformImportRouter = router({ /** @@ -102,6 +103,7 @@ export const typeformImportRouter = router({ description: z.string().optional(), tags: z.string().optional(), // Multi-select or text field email: z.string().optional(), // For tracking submission email + country: z.string().optional(), // Country name or ISO code }), // Store unmapped columns in metadataJson 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 let metadataJson: Record | null = null if (input.includeUnmappedInMetadata) { @@ -171,6 +182,7 @@ export const typeformImportRouter = router({ input.mappings.description, input.mappings.tags, input.mappings.email, + input.mappings.country, '_response_id', '_submitted_at', ].filter(Boolean)) @@ -207,6 +219,7 @@ export const typeformImportRouter = router({ teamName: typeof teamName === 'string' ? teamName.trim() : null, description: typeof description === 'string' ? description : null, tags, + country, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, externalIdsJson: { typeformResponseId: response.response_id,