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:
@@ -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(),
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class AutocompleteResultDto {
|
||||
@Field()
|
||||
text: string;
|
||||
|
||||
@Field()
|
||||
placeId: string;
|
||||
}
|
||||
+28
@@ -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 {};
|
||||
}
|
||||
}
|
||||
+20
@@ -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,
|
||||
}));
|
||||
};
|
||||
+78
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user