feat: add Google Place Autocomplete for address fields (#13450)

resolve #13253
This PR enables the use of Google Place Autocomplete and Place Details
APIs in the backend. It allows users to automatically fill in address
fields by typing into the address1 input. The input is debounced, then
the Autocomplete API is called. Once the user selects an address, the
Place Details API is used to parse and fill in the individual address
fields.


https://github.com/user-attachments/assets/e04b8474-25b8-48f5-83d0-2074f8d5fc94

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Naifer
2025-07-29 08:56:08 +01:00
committed by GitHub
parent c186b78f67
commit 4eba13e9fb
28 changed files with 1993 additions and 131 deletions
@@ -49,6 +49,7 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { GeoMapModule } from 'src/engine/core-modules/geo-map/geo-map-module';
import { AuditModule } from './audit/audit.module';
import { ClientConfigModule } from './client-config/client-config.module';
@@ -84,6 +85,7 @@ import { FileModule } from './file/file.module';
RoleModule,
RedisClientModule,
WorkspaceQueryRunnerModule,
GeoMapModule,
SubscriptionsModule,
ImapSmtpCaldavModule,
FileStorageModule.forRoot(),
@@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class AutocompleteResultDto {
@Field()
text: string;
@Field()
placeId: string;
}
@@ -0,0 +1,28 @@
import { Field, Float, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class LocationDto {
@Field(() => Float, { nullable: true })
lat?: number;
@Field(() => Float, { nullable: true })
lng?: number;
}
@ObjectType()
export class PlaceDetailsResultDto {
@Field({ nullable: true })
state?: string;
@Field({ nullable: true })
postcode?: string;
@Field({ nullable: true })
city?: string;
@Field({ nullable: true })
country?: string;
@Field(() => LocationDto, { nullable: true })
location?: LocationDto;
}
@@ -0,0 +1,14 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { GeoMapResolver } from 'src/engine/core-modules/geo-map/resolver/geo-map.resolver';
import { GeoMapService } from 'src/engine/core-modules/geo-map/services/geo-map.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [HttpModule, WorkspaceCacheStorageModule, TokenModule],
providers: [GeoMapService, GeoMapResolver],
exports: [],
})
export class GeoMapModule {}
@@ -0,0 +1,36 @@
import { UseGuards } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import { AutocompleteResultDto } from 'src/engine/core-modules/geo-map/dtos/autocomplete-result.dto';
import { PlaceDetailsResultDto } from 'src/engine/core-modules/geo-map/dtos/place-details-result.dto';
import { GeoMapService } from 'src/engine/core-modules/geo-map/services/geo-map.service';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Resolver()
@UseGuards(WorkspaceAuthGuard)
export class GeoMapResolver {
constructor(private readonly geoMapService: GeoMapService) {}
@Query(() => [AutocompleteResultDto])
async getAutoCompleteAddress(
@Args('address') address: string,
@Args('token') token: string,
@Args('country', { nullable: true }) country?: string,
@Args('isFieldCity', { nullable: true }) isFieldCity?: boolean,
) {
return this.geoMapService.getAutoCompleteAddress(
address,
token,
country,
isFieldCity,
);
}
@Query(() => PlaceDetailsResultDto)
async getAddressDetails(
@Args('placeId') placeId: string,
@Args('token') token: string,
) {
return this.geoMapService.getAddressDetails(placeId, token);
}
}
@@ -0,0 +1,78 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
AutocompleteSanitizedResult,
sanitizeAutocompleteResults,
} from 'src/engine/core-modules/geo-map/utils/sanitize-autocomplete-results.util';
import {
AddressFields,
sanitizePlaceDetailsResults,
} from 'src/engine/core-modules/geo-map/utils/sanitize-place-details-results.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class GeoMapService {
private apiMapKey: string | undefined;
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly httpService: HttpService,
) {
if (
!this.twentyConfigService.get(
'IS_MAPS_AND_ADDRESS_AUTOCOMPLETE_ENABLED',
) ||
!this.twentyConfigService.get('GOOGLE_MAP_API_KEY')
) {
return;
}
this.apiMapKey = this.twentyConfigService.get('GOOGLE_MAP_API_KEY');
}
public async getAutoCompleteAddress(
address: string,
token: string,
country?: string,
isFieldCity?: boolean,
): Promise<AutocompleteSanitizedResult[] | undefined> {
if (!isDefined(address) || address.trim().length === 0) {
return [];
}
let url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(address)}&sessiontoken=${token}&key=${this.apiMapKey}`;
if (isDefined(country) && country !== '') {
url += `&components=country:${country}`;
}
if (isDefined(isFieldCity) && isFieldCity === true) {
url += `&types=(cities)`;
}
const result = await this.httpService.axiosRef.get(url);
if (result.data.status === 'OK') {
return sanitizeAutocompleteResults(result.data.predictions);
}
return [];
}
public async getAddressDetails(
placeId: string,
token: string,
): Promise<AddressFields | undefined> {
const result = await this.httpService.axiosRef.get(
`https://maps.googleapis.com/maps/api/place/details/json?place_id=${placeId}&sessiontoken=${token}&fields=address_components%2Cgeometry&key=${this.apiMapKey}`,
);
if (result.data.status === 'OK') {
return sanitizePlaceDetailsResults(
result.data.result?.address_components,
result.data.result?.geometry?.location,
);
}
return {};
}
}
@@ -0,0 +1,20 @@
export type AutocompleteSanitizedResult = {
text: string;
placeId: string;
};
type GooglePrediction = {
description: string;
place_id: string;
};
export const sanitizeAutocompleteResults = (
autocompleteResults: GooglePrediction[],
): AutocompleteSanitizedResult[] => {
if (!Array.isArray(autocompleteResults) || autocompleteResults.length === 0)
return [];
return autocompleteResults.map((result) => ({
text: result.description,
placeId: result.place_id,
}));
};
@@ -0,0 +1,78 @@
export type AddressComponent = {
long_name: string;
short_name: string;
types: string[];
};
export type AddressFields = {
state?: string;
postcode?: string;
city?: string;
country?: string;
location?: locationFields;
};
export type locationFields = {
lat?: number;
lng?: number;
};
export const sanitizePlaceDetailsResults = (
AddressComponents: AddressComponent[],
location?: locationFields,
): AddressFields => {
if (!AddressComponents || AddressComponents.length === 0) return {};
const address: AddressFields = {};
for (const AddressComponent of AddressComponents) {
for (const type of AddressComponent.types) {
switch (type) {
case 'postal_code': {
address.postcode =
AddressComponent.long_name + (address.postcode ?? '');
break;
}
case 'postal_code_suffix': {
address.postcode =
(address.postcode ?? '') + '-' + AddressComponent.long_name;
break;
}
case 'locality':
address.city = AddressComponent.long_name;
break;
case 'postal_town':
if (!address.city) {
address.city = AddressComponent.long_name;
}
break;
case 'administrative_area_level_3': {
if (!address.city) {
address.city = AddressComponent.long_name;
}
break;
}
case 'administrative_area_level_1': {
address.state = AddressComponent.long_name;
break;
}
case 'administrative_area_level_2': {
if (!address.state) {
address.state = AddressComponent.long_name;
}
break;
}
case 'country':
address.country = AddressComponent.short_name;
break;
}
}
}
address.location = location;
return address;
};
@@ -9,7 +9,6 @@ import {
ValidationError,
validateSync,
} from 'class-validator';
import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { AwsRegion } from 'src/engine/core-modules/twenty-config/interfaces/aws-region.interface';
@@ -67,16 +66,6 @@ export class ConfigVariables {
@IsOptional()
IS_EMAIL_VERIFICATION_REQUIRED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TwoFactorAuthentication,
description:
'Select the two-factor authentication strategy (e.g., TOTP or HOTP) to be used for workspace logins.',
type: ConfigVariableType.ENUM,
options: Object.values(TwoFactorAuthenticationStrategy),
})
@IsOptional()
TWO_FACTOR_AUTHENTICATION_STRATEGY = TwoFactorAuthenticationStrategy.TOTP;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the email verification token is valid',
@@ -1170,6 +1159,23 @@ export class ConfigVariables {
@IsOptionalOrEmptyString()
@IsTwentySemVer()
APP_VERSION?: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'Enable or disable google map api usage',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_MAPS_AND_ADDRESS_AUTOCOMPLETE_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
isSensitive: true,
description: 'Google map api key for places and map',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.IS_MAPS_AND_ADDRESS_AUTOCOMPLETE_ENABLED)
GOOGLE_MAP_API_KEY: string;
}
export const validate = (config: Record<string, unknown>): ConfigVariables => {