export const GOOGLE_PLACES_FIELD_MASK = "places.id,places.displayName,places.formattedAddress,places.websiteUri,places.nationalPhoneNumber,places.internationalPhoneNumber,places.rating,places.userRatingCount,places.businessStatus,places.types,places.primaryType,places.googleMapsUri"; type CampaignLike = { categoryMode?: "preset" | "custom"; category?: string | null; customSearchTerm?: string | null; postalCode: string; radiusKm: number; latitude?: number; longitude?: number; }; type PlacesNearbyBody = { includedTypes: string[]; maxResultCount: number; locationRestriction: { circle: { center: { latitude: number; longitude: number; }; radius: number; }; }; }; type PlacesTextBody = { textQuery: string; maxResultCount: number; locationBias?: { circle: { center: { latitude: number; longitude: number; }; radius: number; }; }; }; type PlacesSearchBody = PlacesNearbyBody | PlacesTextBody; export type PlacesSearchSpec = { searchType: "nearby" | "text"; endpoint: "searchNearby" | "searchText"; body: PlacesSearchBody; }; const GOOGLE_PLACES_MAX_RESULTS = 20; const PRESET_CATEGORY_TYPES: Record = { Anwalt: "lawyer", Restaurant: "restaurant", Café: "cafe", Cafe: "cafe", Friseur: "hair_salon", Zahnarzt: "dentist", Physiotherapie: "physiotherapist", }; function ensureRadiusMeters(radiusKm: number) { if (typeof radiusKm !== "number" || !Number.isFinite(radiusKm)) { throw new Error("Radius must be a finite number."); } return Math.round(radiusKm * 1000); } function normalizeCustomSearchTerm(value?: string | null) { return (value ?? "").trim(); } export function getPlacesSearchSpec(campaignLike: CampaignLike): PlacesSearchSpec { const category = normalizeCustomSearchTerm(campaignLike.category); const isCustomSearch = campaignLike.categoryMode === "custom" || category === "Anderes"; const isNearbyPreset = campaignLike.categoryMode !== "custom" && category in PRESET_CATEGORY_TYPES; if (isNearbyPreset) { const latitude = campaignLike.latitude; const longitude = campaignLike.longitude; if (typeof latitude !== "number" || typeof longitude !== "number") { throw new Error("Nearby places search requires latitude and longitude."); } return { searchType: "nearby", endpoint: "searchNearby", body: { includedTypes: [PRESET_CATEGORY_TYPES[category]!], maxResultCount: GOOGLE_PLACES_MAX_RESULTS, locationRestriction: { circle: { center: { latitude, longitude, }, radius: ensureRadiusMeters(campaignLike.radiusKm), }, }, }, }; } const baseTerm = isCustomSearch ? normalizeCustomSearchTerm(campaignLike.customSearchTerm) : category; const locationQuerySuffix = campaignLike.postalCode ? `${campaignLike.postalCode} Deutschland` : "Deutschland"; const fallbackTerm = baseTerm ? `${baseTerm} in ${locationQuerySuffix}` : `Unternehmen in ${locationQuerySuffix}`; const textBody: PlacesTextBody = { textQuery: fallbackTerm, maxResultCount: GOOGLE_PLACES_MAX_RESULTS, }; if ( typeof campaignLike.latitude === "number" && typeof campaignLike.longitude === "number" ) { textBody.locationBias = { circle: { center: { latitude: campaignLike.latitude, longitude: campaignLike.longitude, }, radius: ensureRadiusMeters(campaignLike.radiusKm), }, }; } return { searchType: "text", endpoint: "searchText", body: textBody, }; } type LegacyGeocodingResponse = { status: string; results?: Array<{ geometry?: { location?: { lat?: unknown; lng?: unknown; }; }; formatted_address?: string; place_id?: string; }>; }; export type GeocodingCoordinates = { latitude: number; longitude: number; formattedAddress: string; placeId: string; fetchedAt: number; }; export function buildGeocodingUrl({ postalCode, apiKey, }: { postalCode: string; apiKey: string; }): string { const url = new URL("https://maps.googleapis.com/maps/api/geocode/json"); url.searchParams.set("address", `${postalCode}, Deutschland`); url.searchParams.set("components", `country:DE|postal_code:${postalCode}`); url.searchParams.set("language", "de"); url.searchParams.set("region", "de"); url.searchParams.set("key", apiKey); return url.toString(); } export function parseGeocodingResponse( response: LegacyGeocodingResponse, fetchedAt: number, ) { if (!response || response.status !== "OK") { throw new Error(`Geocoding failed with status "${response?.status ?? "unknown"}".`); } const firstResult = response.results?.[0]; if (!firstResult) { throw new Error("Geocoding returned no results."); } const latitude = firstResult.geometry?.location?.lat; const longitude = firstResult.geometry?.location?.lng; const formattedAddress = firstResult.formatted_address; const placeId = firstResult.place_id; if (typeof latitude !== "number" || !Number.isFinite(latitude)) { throw new Error("Geocoding result is missing latitude."); } if (typeof longitude !== "number" || !Number.isFinite(longitude)) { throw new Error("Geocoding result is missing longitude."); } if (!formattedAddress) { throw new Error("Geocoding result is missing formatted address."); } if (!placeId) { throw new Error("Geocoding result is missing place id."); } return { latitude, longitude, formattedAddress, placeId, fetchedAt, }; } type GooglePlaceDisplayName = | string | { text?: string; }; type GooglePlaceApiPlace = { id?: string; displayName?: GooglePlaceDisplayName; formattedAddress?: string; websiteUri?: string; nationalPhoneNumber?: string; internationalPhoneNumber?: string; rating?: number; userRatingCount?: number; businessStatus?: string; types?: string[]; primaryType?: string; googleMapsUri?: string; }; export type GooglePlacesApiResponse = { places?: GooglePlaceApiPlace[] | null; }; export type GooglePlaceCandidate = { placeId: string; businessName: string; address: string; websiteUrl: string | null; websiteDomain: string | null; phone: string | null; rating: number | null; userRatingCount: number | null; businessStatus: string | null; googleTypes: string[]; googlePrimaryType: string | null; googleMapsUrl: string | null; sourceProvider: "google_places"; sourceFetchedAt: number; }; function normalizeDisplayName(value?: GooglePlaceDisplayName) { if (typeof value === "string") { return value.trim(); } return value?.text?.trim() ?? ""; } function normalizeNumber(value: unknown) { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string" && value.trim().length > 0) { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function normalizeWebsiteDomain(input?: string | null) { if (!input) { return null; } try { const url = new URL(input); const host = url.hostname.toLowerCase(); return host.replace(/^www\./, ""); } catch { return null; } } export function normalizePlacesResponse( response: GooglePlacesApiResponse, fetchedAt: number, ): GooglePlaceCandidate[] { const places = Array.isArray(response?.places) ? response.places : []; return places.flatMap((place) => { if (!place || !place.id) { return []; } const websiteUrl = place.websiteUri?.trim() ?? null; const candidate: GooglePlaceCandidate = { placeId: place.id, businessName: normalizeDisplayName(place.displayName), address: place.formattedAddress?.trim() ?? "", websiteUrl, websiteDomain: normalizeWebsiteDomain(websiteUrl), phone: place.nationalPhoneNumber ?? place.internationalPhoneNumber ?? null, rating: normalizeNumber(place.rating), userRatingCount: normalizeNumber(place.userRatingCount), businessStatus: place.businessStatus?.trim() ?? null, googleTypes: Array.isArray(place.types) ? place.types : [], googlePrimaryType: place.primaryType?.trim() ?? null, googleMapsUrl: place.googleMapsUri?.trim() ?? null, sourceProvider: "google_places", sourceFetchedAt: fetchedAt, }; return [candidate]; }); } export type ExistingLeadLike = { googlePlaceId?: string | null; websiteDomain?: string | null; }; export type BlacklistRow = { type: "domain" | "email" | "phone" | "company" | "google_place_id"; value: string; normalizedValue: string; }; export type BlacklistLookupValue = { type: "domain" | "phone" | "company" | "google_place_id"; normalizedValue: string; }; function normalizeDomain(value?: string | null) { return value?.trim().toLowerCase().replace(/^www\./, "") ?? ""; } function normalizePhone(value?: string | null) { if (!value) { return ""; } return value.replace(/\D+/g, ""); } function uniqueLookupValues(values: BlacklistLookupValue[]) { const seen = new Set(); return values.filter((value) => { const key = `${value.type}:${value.normalizedValue}`; if (!value.normalizedValue || seen.has(key)) { return false; } seen.add(key); return true; }); } export function getBlacklistLookupValues( candidate: GooglePlaceCandidate, ): BlacklistLookupValue[] { return uniqueLookupValues([ { type: "google_place_id", normalizedValue: normalizeDomain(candidate.placeId), }, { type: "domain", normalizedValue: normalizeDomain(candidate.websiteDomain), }, { type: "company", normalizedValue: normalizeDomain(candidate.businessName), }, { type: "phone", normalizedValue: normalizePhone(candidate.phone), }, { type: "phone", normalizedValue: normalizeDomain(candidate.phone), }, ]); } export function isDuplicateCandidate( candidate: GooglePlaceCandidate, existing: ExistingLeadLike[], ): boolean { const candidatePlaceId = normalizeDomain(candidate.placeId); const candidateDomain = normalizeDomain(candidate.websiteDomain); return existing.some((entry) => { const entryPlaceId = normalizeDomain(entry.googlePlaceId); const entryDomain = normalizeDomain(entry.websiteDomain); return ( (candidatePlaceId && entryPlaceId === candidatePlaceId) || (candidateDomain && entryDomain === candidateDomain) ); }); } export function getBlacklistMatches( candidate: GooglePlaceCandidate, blacklistRows: BlacklistRow[], ) { const candidatePlaceId = normalizeDomain(candidate.placeId); const candidateDomain = normalizeDomain(candidate.websiteDomain); const candidateCompany = normalizeDomain(candidate.businessName); const candidatePhone = normalizePhone(candidate.phone); return blacklistRows.filter((row) => { if (!row.normalizedValue) { return false; } switch (row.type) { case "google_place_id": return candidatePlaceId !== "" && row.normalizedValue === candidatePlaceId; case "domain": return candidateDomain !== "" && row.normalizedValue === candidateDomain; case "company": return ( candidateCompany !== "" && row.normalizedValue === candidateCompany ); case "phone": return ( candidatePhone !== "" && (row.normalizedValue === candidatePhone || normalizePhone(row.value) === candidatePhone) ); default: return false; } }); }