TDD: Google Maps Integration for Project Address
📋 Parent Issue: Issue #227 - Project Metadata Management - Phase 1
Related PRD: Google Maps Integration
Overview
This document provides the detailed technical design and implementation guide for integrating Google Maps services into the Project Settings UI. This enhancement adds intelligent address autocomplete and visual location confirmation to the existing project metadata management feature.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Project Settings UI │
│ │
│ ┌────────────────────--──┐ ┌──────────────────────────┐ │
│ │ Project Metadata │ │ Project Location Map │ │
│ │ Card │ │ Widget │ │
│ │ │ │ │ │
│ │ [Edit Address] │ │ [Google Map Display] │ │
│ │ │ │ │ │
│ │ Street: [Input]─-─┐ │ │ • Center on address │ │
│ │ ↓ │ │ │ • Show marker │ │
│ │ [Autocomplete] │ │ │ • Interactive zoom │ │
│ │ ↓ │ │ │ │ │
│ │ [Select Place] │ │ └──────────────────────────┘ │
│ │ ↓ │ │ ↑ │
│ │ Auto-fill fields │ │ │ │
│ │ City, State, Zip │ │ │ │
│ │ ↓ │ │ │ │
│ │ [Save] ─────────┼─-─┼─────────────────┘ │
│ │ ↓ │ │ (triggers geocoding & map update) │
│ └──────────────────--────┘ │ │
└─────────────────────────────────────────────────────────────────┘
↓
[gRPC Update]
↓
┌─────────────────────────────────────────────────────────────────┐
│ Backend Service │
│ │
│ ProjectMetadataService.updateProjectMetadata() │
│ • Validates address fields │
│ • Saves to project-metadata.json │
│ • Stores lat/lng and google_place_id │
└─────────────────────────────────────────────────────────────────┘
Component Architecture
web-ng-m3/src/app/
├── shared/
│ └── google-maps.service.ts # Google Maps API wrapper
│
├── components/project/settings/
│ ├── project-settings/
│ │ ├── project-settings.component.ts # Main component (enhanced)
│ │ ├── project-settings.component.html # Layout (two-column grid)
│ │ └── project-settings.component.scss # Responsive styles
│ │
│ └── project-location-map/ # NEW: Map widget
│ ├── project-location-map.component.ts
│ ├── project-location-map.component.html
│ └── project-location-map.component.scss
│
└── environments/
├── environment.ts # Dev config with API key
└── environment.prod.ts # Prod config with API key
Proto Schema Updates
Enhanced ProjectAddress Message
File: src/main/proto/api.proto
// Message representing project address
// Updated to include Google Maps metadata
message ProjectAddress {
// Core address fields (existing)
string street = 1;
string city = 2;
string state = 3;
string postal_code = 4;
string country = 5; // Default: "USA"
// Google Maps metadata (NEW - optional)
// These fields are populated when user selects from Google Places Autocomplete
// or when the address is geocoded
// Google Places ID - unique identifier for validation and lookup
// Format: "ChIJrTLr-GyuEmsRBfy61i59si0"
// Can be used to fetch updated place details in the future
string google_place_id = 6;
// Geocoded coordinates (decimal degrees)
// Used for map display and future location-based features
// Example: latitude: 37.7749, longitude: -122.4194 (San Francisco)
double latitude = 7;
double longitude = 8;
// Google's canonical formatted address
// Example: "1600 Amphitheatre Parkway, Mountain View, CA 94043, USA"
// Provides consistency with Google's address database
string formatted_address = 9;
}
Backward Compatibility:
- All new fields are optional
- Existing
project-metadata.jsonfiles remain valid - Legacy addresses without geocoding continue to work
- Fields are populated incrementally as addresses are edited
Frontend Implementation Details
1. Google Maps Service
Purpose: Centralized wrapper for Google Maps API interactions
Key Responsibilities:
- Load Google Maps JavaScript API
- Create and configure Places Autocomplete instances
- Provide geocoding utilities
- Handle API errors gracefully
Implementation: web-ng-m3/src/app/shared/google-maps.service.ts
import { Injectable } from '@angular/core';
import { Loader } from '@googlemaps/js-api-loader';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class GoogleMapsService {
private loader: Loader;
private isLoaded = false;
constructor() {
this.loader = new Loader({
apiKey: environment.googleMapsApiKey,
version: 'weekly',
libraries: ['places'],
region: 'US', // Optimize for US addresses
language: 'en' // English language
});
}
/**
* Loads the Google Maps JavaScript API.
* Called automatically by other methods, but can be called explicitly for preloading.
*/
async loadGoogleMaps(): Promise<void> {
if (this.isLoaded) return;
try {
await this.loader.load();
this.isLoaded = true;
console.log('[GoogleMapsService] API loaded successfully');
} catch (error) {
console.error('[GoogleMapsService] Failed to load API:', error);
throw new Error('Failed to load Google Maps API');
}
}
/**
* Creates a Google Places Autocomplete instance on an input element.
*
* @param input - HTML input element to attach autocomplete to
* @param options - Autocomplete configuration options
* @returns Configured Autocomplete instance
*/
async createAutocomplete(
input: HTMLInputElement,
options?: google.maps.places.AutocompleteOptions
): Promise<google.maps.places.Autocomplete> {
await this.loadGoogleMaps();
// Default options optimized for project addresses
const defaultOptions: google.maps.places.AutocompleteOptions = {
types: ['address'], // Restrict to street addresses
componentRestrictions: { country: 'us' }, // USA only by default
fields: [
'address_components',
'formatted_address',
'geometry',
'place_id'
]
};
const mergedOptions = { ...defaultOptions, ...options };
return new google.maps.places.Autocomplete(input, mergedOptions);
}
/**
* Geocodes an address string to lat/lng coordinates.
*
* @param address - Full address string to geocode
* @returns Geocoding result with coordinates, or null if failed
*/
async geocodeAddress(address: string): Promise<google.maps.GeocoderResult | null> {
await this.loadGoogleMaps();
const geocoder = new google.maps.Geocoder();
return new Promise((resolve, reject) => {
geocoder.geocode(
{
address,
region: 'us' // Bias to US addresses
},
(results, status) => {
if (status === 'OK' && results && results.length > 0) {
console.log('[GoogleMapsService] Geocoding successful:', results[0]);
resolve(results[0]);
} else {
console.warn('[GoogleMapsService] Geocoding failed:', status);
resolve(null);
}
}
);
});
}
/**
* Reverse geocodes coordinates to an address.
* Useful for validating or updating existing lat/lng.
*
* @param lat - Latitude
* @param lng - Longitude
* @returns Reverse geocoding result, or null if failed
*/
async reverseGeocode(
lat: number,
lng: number
): Promise<google.maps.GeocoderResult | null> {
await this.loadGoogleMaps();
const geocoder = new google.maps.Geocoder();
return new Promise((resolve, reject) => {
geocoder.geocode(
{ location: { lat, lng } },
(results, status) => {
if (status === 'OK' && results && results.length > 0) {
resolve(results[0]);
} else {
console.warn('[GoogleMapsService] Reverse geocoding failed:', status);
resolve(null);
}
}
);
});
}
/**
* Checks if the API is loaded and functional.
*/
isApiLoaded(): boolean {
return this.isLoaded && typeof google !== 'undefined';
}
}
2. Enhanced Project Settings Component
Changes to Existing Component: web-ng-m3/src/app/components/project/settings/project-settings/project-settings.component.ts
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
// ... existing imports ...
import { GoogleMapsService } from '../../../../shared/google-maps.service';
export class ProjectSettingsComponent implements OnInit, OnDestroy, AfterViewInit {
// ... existing properties ...
// ========== GOOGLE MAPS INTEGRATION ==========
// ViewChild for street address input (for Places Autocomplete)
@ViewChild('streetAddressInput') streetAddressInput!: ElementRef<HTMLInputElement>;
// Google Places Autocomplete instance
private autocomplete: google.maps.places.Autocomplete | null = null;
// Map display properties
hasValidAddress = false;
mapCenter: { lat: number; lng: number } | null = null;
mapZoom = 16; // Street-level zoom
constructor(
// ... existing dependencies ...
private googleMapsService: GoogleMapsService
) {}
ngAfterViewInit(): void {
// Initialize autocomplete if already in edit mode
if (this.editingMetadata) {
setTimeout(() => this.initializeAutocomplete(), 100);
}
// Initialize map location on load
this.updateMapLocation();
}
/**
* Initializes Google Places Autocomplete on the street address input.
* Called when user enters edit mode.
*/
private async initializeAutocomplete(): Promise<void> {
// Guard: Ensure input element is available
if (!this.streetAddressInput?.nativeElement) {
console.warn('[ProjectSettings] Street address input not found');
return;
}
// Guard: Check if autocomplete already initialized
if (this.autocomplete) {
console.log('[ProjectSettings] Autocomplete already initialized');
return;
}
try {
console.log('[ProjectSettings] Initializing Places Autocomplete...');
// Create autocomplete instance with country restriction
const countryCode = this.editedAddress.country?.toLowerCase() || 'us';
this.autocomplete = await this.googleMapsService.createAutocomplete(
this.streetAddressInput.nativeElement,
{
types: ['address'],
componentRestrictions: { country: countryCode },
fields: [
'address_components',
'formatted_address',
'geometry',
'place_id'
]
}
);
// Listen for place selection
this.autocomplete.addListener('place_changed', () => {
this.handlePlaceSelected();
});
console.log('[ProjectSettings] Autocomplete initialized successfully');
} catch (error) {
console.error('[ProjectSettings] Failed to initialize autocomplete:', error);
// Graceful degradation - user can still enter address manually
this.snackBar.open(
'Address suggestions unavailable. You can still enter the address manually.',
'Close',
{ duration: 5000 }
);
}
}
/**
* Handles Google Places Autocomplete selection.
* Parses the selected place and populates address fields.
*/
private handlePlaceSelected(): void {
if (!this.autocomplete) {
console.warn('[ProjectSettings] Autocomplete not initialized');
return;
}
const place = this.autocomplete.getPlace();
// Validate place data
if (!place || !place.address_components) {
console.warn('[ProjectSettings] Invalid place selected:', place);
return;
}
console.log('[ProjectSettings] Place selected:', place);
// Parse address components
let streetNumber = '';
let route = '';
let city = '';
let state = '';
let postalCode = '';
let country = '';
for (const component of place.address_components) {
const type = component.types[0];
switch (type) {
case 'street_number':
streetNumber = component.long_name;
break;
case 'route':
route = component.long_name;
break;
case 'locality':
city = component.long_name;
break;
case 'administrative_area_level_1':
state = component.short_name; // Use short name for states (e.g., "CA")
break;
case 'postal_code':
postalCode = component.long_name;
break;
case 'country':
country = component.short_name; // Use short name (e.g., "US")
break;
}
}
// Update form fields
this.editedAddress.street = `${streetNumber} ${route}`.trim();
this.editedAddress.city = city;
this.editedAddress.state = state;
this.editedAddress.postalCode = postalCode;
this.editedAddress.country = country || 'USA';
// Store Google Maps metadata
if (place.geometry?.location) {
this.editedAddress.latitude = place.geometry.location.lat();
this.editedAddress.longitude = place.geometry.location.lng();
console.log('[ProjectSettings] Geocoded location:',
this.editedAddress.latitude, this.editedAddress.longitude);
}
if (place.place_id) {
this.editedAddress.googlePlaceId = place.place_id;
console.log('[ProjectSettings] Google Place ID:', place.place_id);
}
if (place.formatted_address) {
this.editedAddress.formattedAddress = place.formatted_address;
console.log('[ProjectSettings] Formatted address:', place.formatted_address);
}
// Show success feedback
this.snackBar.open('Address populated from Google Places', 'Close', { duration: 2000 });
}
/**
* Override: Enhanced to include geocoding before save.
*/
async saveMetadata(): Promise<void> {
if (!this.projectMetadata || this.savingMetadata) return;
const trimmedName = this.editedProjectName.trim();
if (!trimmedName) {
this.snackBar.open('Project name cannot be empty', 'Close', { duration: 3000 });
return;
}
this.savingMetadata = true;
try {
// Geocode address if not already geocoded
if (!this.editedAddress.latitude && this.isAddressComplete()) {
console.log('[ProjectSettings] Geocoding address before save...');
await this.geocodeAddress();
}
// Prepare address object with Google metadata
const address = {
street: this.editedAddress.street.trim(),
city: this.editedAddress.city.trim(),
state: this.editedAddress.state.trim(),
postalCode: this.editedAddress.postalCode.trim(),
country: this.editedAddress.country.trim(),
googlePlaceId: this.editedAddress.googlePlaceId || '',
latitude: this.editedAddress.latitude || 0,
longitude: this.editedAddress.longitude || 0,
formattedAddress: this.editedAddress.formattedAddress || ''
};
// Update metadata via gRPC
const response = await this.projectMetadataService.updateProjectMetadata(
this.projectId,
{
projectName: trimmedName,
projectDescription: this.editedDescription.trim(),
projectAddress: address
}
);
console.log('[ProjectSettings] Update response:', response.toObject());
this.snackBar.open('Project metadata updated successfully', 'Close', { duration: 3000 });
this.editingMetadata = false;
// Update local metadata
const updatedMetadata = response.getMetadata();
if (updatedMetadata && this.projectMetadata) {
this.projectMetadata.project_name = updatedMetadata.getProjectName();
this.projectMetadata.description = updatedMetadata.getProjectDescription();
const responseAddress = updatedMetadata.getProjectAddress();
if (responseAddress) {
this.projectMetadata.address = {
street: responseAddress.getStreet(),
city: responseAddress.getCity(),
state: responseAddress.getState(),
postalCode: responseAddress.getPostalCode(),
country: responseAddress.getCountry(),
googlePlaceId: responseAddress.getGooglePlaceId(),
latitude: responseAddress.getLatitude(),
longitude: responseAddress.getLongitude(),
formattedAddress: responseAddress.getFormattedAddress()
};
}
}
// Update map display
this.updateMapLocation();
} catch (error) {
console.error('[ProjectSettings] Error updating metadata:', error);
this.snackBar.open('Failed to update project metadata', 'Close', { duration: 5000 });
} finally {
this.savingMetadata = false;
}
}
/**
* Checks if all required address fields are filled.
*/
private isAddressComplete(): boolean {
return !!(
this.editedAddress.street &&
this.editedAddress.city &&
this.editedAddress.state &&
this.editedAddress.postalCode
);
}
/**
* Geocodes the current edited address.
* Updates latitude, longitude, and formatted address.
*/
private async geocodeAddress(): Promise<void> {
const fullAddress = this.formatAddressForGeocoding();
try {
const result = await this.googleMapsService.geocodeAddress(fullAddress);
if (result) {
this.editedAddress.latitude = result.geometry.location.lat();
this.editedAddress.longitude = result.geometry.location.lng();
this.editedAddress.formattedAddress = result.formatted_address;
// Also store place_id if available
if (result.place_id) {
this.editedAddress.googlePlaceId = result.place_id;
}
console.log('[ProjectSettings] Geocoding successful:', result);
} else {
console.warn('[ProjectSettings] Geocoding returned no results');
// Non-blocking - proceed with save even if geocoding fails
}
} catch (error) {
console.error('[ProjectSettings] Geocoding error:', error);
// Non-blocking - proceed with save even if geocoding fails
}
}
/**
* Formats the address for geocoding API call.
*/
private formatAddressForGeocoding(): string {
return [
this.editedAddress.street,
this.editedAddress.city,
this.editedAddress.state,
this.editedAddress.postalCode,
this.editedAddress.country
]
.filter(Boolean)
.join(', ');
}
/**
* Updates the map center and visibility based on current address.
* Called after metadata load and after address save.
*/
private updateMapLocation(): void {
const address = this.getProjectAddress();
if (address?.latitude && address?.longitude) {
this.hasValidAddress = true;
this.mapCenter = {
lat: address.latitude,
lng: address.longitude
};
console.log('[ProjectSettings] Map center updated:', this.mapCenter);
} else {
this.hasValidAddress = false;
this.mapCenter = null;
console.log('[ProjectSettings] No valid address for map display');
}
}
/**
* Override: Enhanced to initialize autocomplete.
*/
startEditMetadata(): void {
if (!this.projectMetadata) return;
// ... existing field initialization ...
this.editingMetadata = true;
// Initialize autocomplete after view updates
setTimeout(() => this.initializeAutocomplete(), 100);
}
ngOnDestroy(): void {
// ... existing cleanup ...
// Clean up Google Maps listeners
if (this.autocomplete) {
google.maps.event.clearInstanceListeners(this.autocomplete);
this.autocomplete = null;
}
}
}
3. Project Location Map Component
New Component: web-ng-m3/src/app/components/project/settings/project-location-map/project-location-map.component.ts
import {
Component,
Input,
OnInit,
OnChanges,
SimpleChanges,
OnDestroy
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GoogleMapsService } from '../../../../shared/google-maps.service';
@Component({
selector: 'app-project-location-map',
standalone: true,
imports: [
CommonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule
],
templateUrl: './project-location-map.component.html',
styleUrls: ['./project-location-map.component.scss']
})
export class ProjectLocationMapComponent implements OnInit, OnChanges, OnDestroy {
/**
* Map center coordinates.
* If null, map shows "No address" state.
*/
@Input() center: { lat: number; lng: number } | null = null;
/**
* Map zoom level (default: 16 for street-level view).
*/
@Input() zoom: number = 16;
/**
* Formatted address to display in marker tooltip.
*/
@Input() address: string = '';
// Component state
map: google.maps.Map | null = null;
marker: google.maps.Marker | null = null;
loading = true;
error = false;
constructor(private googleMapsService: GoogleMapsService) {}
async ngOnInit(): Promise<void> {
await this.initializeMap();
}
async ngOnChanges(changes: SimpleChanges): Promise<void> {
// Update map when center changes
if (changes['center'] && this.map) {
if (this.center) {
this.updateMapLocation();
} else {
this.clearMap();
}
}
}
/**
* Initializes the Google Map.
*/
private async initializeMap(): Promise<void> {
try {
console.log('[ProjectLocationMap] Initializing map...');
// Load Google Maps API
await this.googleMapsService.loadGoogleMaps();
// Get map container element
const mapElement = document.getElementById('project-map');
if (!mapElement) {
throw new Error('Map container element not found');
}
// Default center (San Francisco) if no address provided
const defaultCenter = { lat: 37.7749, lng: -122.4194 };
const initialCenter = this.center || defaultCenter;
const initialZoom = this.center ? this.zoom : 12;
// Create map
this.map = new google.maps.Map(mapElement, {
center: initialCenter,
zoom: initialZoom,
mapTypeControl: false, // Hide map type control
streetViewControl: false, // Hide street view control
fullscreenControl: true, // Keep fullscreen control
zoomControl: true, // Keep zoom control
gestureHandling: 'greedy' // Allow one-finger pan on mobile
});
// Add marker if center is provided
if (this.center) {
this.addMarker();
}
this.loading = false;
console.log('[ProjectLocationMap] Map initialized successfully');
} catch (error) {
console.error('[ProjectLocationMap] Failed to initialize map:', error);
this.error = true;
this.loading = false;
}
}
/**
* Updates the map center and marker when address changes.
*/
private updateMapLocation(): void {
if (!this.map || !this.center) {
console.warn('[ProjectLocationMap] Cannot update location - map or center missing');
return;
}
console.log('[ProjectLocationMap] Updating map location:', this.center);
// Animate to new center
this.map.panTo(this.center);
this.map.setZoom(this.zoom);
// Update or add marker
this.addMarker();
}
/**
* Adds or updates the location marker.
*/
private addMarker(): void {
if (!this.map || !this.center) return;
// Remove existing marker
if (this.marker) {
this.marker.setMap(null);
}
// Create new marker
this.marker = new google.maps.Marker({
position: this.center,
map: this.map,
title: this.address || 'Project Location',
animation: google.maps.Animation.DROP // Animate marker drop
});
console.log('[ProjectLocationMap] Marker added at:', this.center);
}
/**
* Clears the marker from the map.
*/
private clearMap(): void {
if (this.marker) {
this.marker.setMap(null);
this.marker = null;
}
}
ngOnDestroy(): void {
// Clean up marker
this.clearMap();
// Map will be garbage collected
this.map = null;
}
}
Template: project-location-map.component.html
<mat-card class="map-card">
<mat-card-header>
<mat-card-title>
<mat-icon>location_on</mat-icon>
Project Location
</mat-card-title>
</mat-card-header>
<mat-card-content>
<!-- Loading State -->
<div *ngIf="loading" class="map-state-container">
<mat-spinner diameter="40"></mat-spinner>
<p class="state-message">Loading map...</p>
</div>
<!-- Error State -->
<div *ngIf="error && !loading" class="map-state-container">
<mat-icon class="state-icon error-icon">error_outline</mat-icon>
<p class="state-message">Unable to load map</p>
<span class="state-hint">Please check your internet connection</span>
</div>
<!-- No Address State -->
<div *ngIf="!center && !loading && !error" class="map-state-container">
<mat-icon class="state-icon">location_off</mat-icon>
<p class="state-message">No address available</p>
<span class="state-hint">Add a project address to see the location</span>
</div>
<!-- Map Display -->
<div
*ngIf="center && !loading && !error"
id="project-map"
class="map-container">
</div>
</mat-card-content>
</mat-card>
Styles: project-location-map.component.scss
.map-card {
height: 100%;
display: flex;
flex-direction: column;
mat-card-header {
flex-shrink: 0;
mat-card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 20px;
font-weight: 500;
mat-icon {
color: #1976d2; // Primary color
}
}
}
mat-card-content {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
min-height: 400px; // Ensure minimum height
}
}
.map-container {
width: 100%;
height: 100%;
min-height: 400px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.map-state-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
color: rgba(0, 0, 0, 0.54);
.state-icon {
font-size: 64px;
width: 64px;
height: 64px;
margin-bottom: 16px;
color: rgba(0, 0, 0, 0.26);
&.error-icon {
color: #f44336; // Error color
}
}
.state-message {
font-size: 18px;
font-weight: 500;
margin: 0 0 8px 0;
}
.state-hint {
font-size: 14px;
color: rgba(0, 0, 0, 0.38);
text-align: center;
max-width: 300px;
}
}
// Loading spinner styling
mat-spinner {
margin-bottom: 24px;
}
4. Layout Updates
Update: project-settings.component.html
Add the map widget and create a two-column grid layout:
<div class="project-settings-container">
<div class="project-settings-header">
<h2>Project Settings</h2>
<p class="project-settings-subtitle">Manage project configuration, files, and content ingestion</p>
</div>
<!-- Legacy Project Banner -->
<app-legacy-project-banner
[show]="!loadingMetadata && !hasProjectMetadata"
(setupClick)="openMetadataSetupDialog()"
(dismiss)="onBannerDismiss()">
</app-legacy-project-banner>
<!-- Two-Column Grid: Project Metadata + Location Map -->
<div class="metadata-map-container">
<!-- Left Column: Project Metadata Card -->
<mat-card class="settings-section metadata-card">
<mat-card-header>
<div class="header-with-actions">
<div class="header-content">
<mat-card-title>
<mat-icon>info</mat-icon>
Project Metadata
</mat-card-title>
<mat-card-subtitle>Project information</mat-card-subtitle>
</div>
<div class="header-actions" *ngIf="hasProjectMetadata && !loadingMetadata">
<button mat-button *ngIf="!editingMetadata" (click)="startEditMetadata()">
<mat-icon>edit</mat-icon>
Edit
</button>
<button mat-raised-button color="primary" *ngIf="editingMetadata"
(click)="saveMetadata()" [disabled]="savingMetadata">
<mat-icon>check</mat-icon>
{{ savingMetadata ? 'Saving...' : 'Save' }}
</button>
<button mat-button *ngIf="editingMetadata"
(click)="cancelEditMetadata()" [disabled]="savingMetadata">
<mat-icon>close</mat-icon>
Cancel
</button>
</div>
</div>
</mat-card-header>
<mat-card-content>
<!-- ... existing metadata fields ... -->
<!-- Enhanced Address Section with Autocomplete -->
<div class="metadata-item full-width-item" *ngIf="hasAddress() || editingMetadata">
<label>Address</label>
<div *ngIf="!editingMetadata" class="metadata-value">
<div *ngIf="hasAddress()">
{{ formatAddress(getProjectAddress()) }}
</div>
<div *ngIf="!hasAddress()" class="empty-value">(No address)</div>
</div>
<!-- Address Edit Form with Google Places Autocomplete -->
<div *ngIf="editingMetadata" class="address-fields">
<mat-form-field appearance="outline" class="full-width-field">
<mat-label>Street</mat-label>
<input
matInput
#streetAddressInput
[(ngModel)]="editedAddress.street"
placeholder="Start typing to see suggestions..."
autocomplete="street-address"
name="street-address">
<mat-hint>Google Places suggestions will appear as you type</mat-hint>
</mat-form-field>
<div class="address-row">
<mat-form-field appearance="outline" class="half-width-field">
<mat-label>City</mat-label>
<input matInput [(ngModel)]="editedAddress.city"
placeholder="San Francisco" autocomplete="address-level2">
</mat-form-field>
<mat-form-field appearance="outline" class="quarter-width-field">
<mat-label>State</mat-label>
<input matInput [(ngModel)]="editedAddress.state"
placeholder="CA" autocomplete="address-level1">
</mat-form-field>
<mat-form-field appearance="outline" class="quarter-width-field">
<mat-label>Zip</mat-label>
<input matInput [(ngModel)]="editedAddress.postalCode"
placeholder="94102" autocomplete="postal-code">
</mat-form-field>
</div>
</div>
</div>
<!-- ... rest of existing metadata fields ... -->
</mat-card-content>
</mat-card>
<!-- Right Column: Project Location Map -->
<app-project-location-map
*ngIf="!loadingMetadata"
[center]="mapCenter"
[zoom]="mapZoom"
[address]="formatAddress(getProjectAddress())">
</app-project-location-map>
</div>
<!-- Uploaded Files Section (existing) -->
<mat-card class="settings-section">
<!-- ... existing file table ... -->
</mat-card>
</div>
Update Styles: project-settings.component.scss
// Two-column grid for metadata and map
.metadata-map-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 24px;
// Responsive: stack on smaller screens
@media (max-width: 1200px) {
grid-template-columns: 1fr;
}
@media (max-width: 768px) {
gap: 16px;
margin-bottom: 16px;
}
}
.metadata-card {
min-height: 400px;
display: flex;
flex-direction: column;
}
// Address field enhancements
.address-fields {
display: flex;
flex-direction: column;
gap: 16px;
.address-row {
display: flex;
gap: 16px;
@media (max-width: 600px) {
flex-direction: column;
}
}
.full-width-field {
width: 100%;
}
.half-width-field {
flex: 1;
}
.quarter-width-field {
flex: 0.5;
}
}
// Google Places Autocomplete styling override
.pac-container {
z-index: 10000 !important; // Ensure above Material dialogs
font-family: Roboto, 'Helvetica Neue', sans-serif;
border-radius: 4px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
.pac-item {
padding: 8px 12px;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
}
.pac-icon {
margin-top: 4px;
}
.pac-item-query {
font-weight: 500;
}
}
}
Environment Configuration
Development Environment
File: web-ng-m3/src/environments/environment.ts
export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
// Google Maps API Key (Development)
// DO NOT COMMIT REAL KEYS - Use environment variables
googleMapsApiKey: process.env['GOOGLE_MAPS_API_KEY'] || 'REPLACE_WITH_DEV_KEY',
// ... other environment variables
};
Production Environment
File: web-ng-m3/src/environments/environment.prod.ts
export const environment = {
production: true,
apiUrl: 'https://construction-code-expert-prod.run.app',
// Google Maps API Key (Production)
// Loaded from Secret Manager via Cloud Run environment variables
googleMapsApiKey: process.env['GOOGLE_MAPS_API_KEY'] || '',
// ... other environment variables
};
Environment Variable Setup
For Local Development:
# .env (not committed to git)
GOOGLE_MAPS_API_KEY=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
For Cloud Run (Production):
# Store in Google Secret Manager
gcloud secrets create google-maps-api-key \
--data-file=- <<< "AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
# Grant access to Cloud Run service account
gcloud secrets add-iam-policy-binding google-maps-api-key \
--member=serviceAccount:SERVICE_ACCOUNT_EMAIL \
--role=roles/secretmanager.secretAccessor
# Configure Cloud Run to use secret as environment variable
gcloud run services update construction-code-expert-prod \
--update-secrets=GOOGLE_MAPS_API_KEY=google-maps-api-key:latest
Backend Changes
Proto Compilation
After updating api.proto, regenerate Java and TypeScript classes:
# Java
cd /workspaces/construction-code-expert
export JAVA_HOME=/usr/lib/jvm/temurin-23-jdk-arm64
mvn clean compile
# TypeScript
cd web-ng-m3
npm run generate-proto
Validation (Optional Enhancement)
Add server-side address validation:
// ProjectMetadataService.java
private void validateAddress(ProjectAddress address) {
// Validate required fields
if (address.getStreet().isEmpty()) {
throw new IllegalArgumentException("Street address is required");
}
// Validate lat/lng if provided
if (address.getLatitude() != 0 || address.getLongitude() != 0) {
if (address.getLatitude() < -90 || address.getLatitude() > 90) {
throw new IllegalArgumentException("Invalid latitude: " + address.getLatitude());
}
if (address.getLongitude() < -180 || address.getLongitude() > 180) {
throw new IllegalArgumentException("Invalid longitude: " + address.getLongitude());
}
}
}
Testing Strategy
Unit Tests
GoogleMapsService Tests
describe('GoogleMapsService', () => {
let service: GoogleMapsService;
let mockLoader: jasmine.SpyObj<Loader>;
beforeEach(() => {
mockLoader = jasmine.createSpyObj('Loader', ['load']);
service = new GoogleMapsService();
(service as any).loader = mockLoader;
});
it('should load Google Maps API once', async () => {
mockLoader.load.and.returnValue(Promise.resolve());
await service.loadGoogleMaps();
await service.loadGoogleMaps(); // Second call
expect(mockLoader.load).toHaveBeenCalledTimes(1);
});
it('should handle API load failure', async () => {
mockLoader.load.and.returnValue(Promise.reject('API Error'));
await expectAsync(service.loadGoogleMaps()).toBeRejected();
});
});
ProjectLocationMapComponent Tests
describe('ProjectLocationMapComponent', () => {
let component: ProjectLocationMapComponent;
let fixture: ComponentFixture<ProjectLocationMapComponent>;
let mockGoogleMapsService: jasmine.SpyObj<GoogleMapsService>;
beforeEach(() => {
mockGoogleMapsService = jasmine.createSpyObj('GoogleMapsService', [
'loadGoogleMaps'
]);
TestBed.configureTestingModule({
imports: [ProjectLocationMapComponent],
providers: [
{ provide: GoogleMapsService, useValue: mockGoogleMapsService }
]
});
fixture = TestBed.createComponent(ProjectLocationMapComponent);
component = fixture.componentInstance;
});
it('should show loading state initially', () => {
expect(component.loading).toBe(true);
});
it('should show error state if map fails to load', async () => {
mockGoogleMapsService.loadGoogleMaps.and.returnValue(
Promise.reject('Failed to load')
);
await component.ngOnInit();
expect(component.error).toBe(true);
expect(component.loading).toBe(false);
});
it('should show "no address" state when center is null', () => {
component.center = null;
component.loading = false;
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('No address available');
});
});
Integration Tests
End-to-End Address Flow
describe('Project Address with Google Maps', () => {
it('should autocomplete address and display on map', () => {
// Navigate to project settings
cy.visit('/projects/test-project/settings');
// Enter edit mode
cy.get('[data-testid="edit-metadata-button"]').click();
// Type in address field
cy.get('[data-testid="street-address-input"]')
.type('1600 Amphitheatre');
// Wait for Google Places suggestions
cy.get('.pac-container').should('be.visible');
// Select first suggestion
cy.get('.pac-item').first().click();
// Verify fields are populated
cy.get('[data-testid="city-input"]')
.should('have.value', 'Mountain View');
cy.get('[data-testid="state-input"]')
.should('have.value', 'CA');
// Save metadata
cy.get('[data-testid="save-metadata-button"]').click();
// Verify map displays
cy.get('#project-map').should('be.visible');
cy.get('.map-container').should('exist');
});
});
Performance Optimizations
Lazy Loading
Load Google Maps API only when needed:
// In GoogleMapsService
private apiPromise: Promise<void> | null = null;
async loadGoogleMaps(): Promise<void> {
if (this.isLoaded) return;
// Reuse pending promise to avoid duplicate loads
if (!this.apiPromise) {
this.apiPromise = this.loader.load();
}
await this.apiPromise;
this.isLoaded = true;
}
Caching Geocoding Results
Cache geocoded addresses to avoid redundant API calls:
// In ProjectMetadataService
private geocodingCache = new Map<string, GeocodingResult>();
private getCachedGeocoding(address: string): GeocodingResult | null {
const cacheKey = this.normalizeAddress(address);
return this.geocodingCache.get(cacheKey) || null;
}
private cacheGeocoding(address: string, result: GeocodingResult): void {
const cacheKey = this.normalizeAddress(address);
this.geocodingCache.set(cacheKey, result);
}
private normalizeAddress(address: string): string {
return address.toLowerCase().replace(/\s+/g, ' ').trim();
}
Security Best Practices
API Key Restrictions
-
HTTP Referrers (Web):
*.codeproof.app/*
*.construction-code-expert.run.app/*
localhost:4200/* (development only) -
API Restrictions:
- Enable only Maps JavaScript API
- Enable only Places API
-
Usage Quotas:
- Set daily quota limit (e.g., 10,000 requests/day)
- Enable billing alerts
Input Sanitization
private sanitizeAddressInput(input: string): string {
// Remove HTML tags
const withoutTags = input.replace(/<[^>]*>/g, '');
// Remove special characters (keep alphanumeric, spaces, commas, hyphens)
const sanitized = withoutTags.replace(/[^a-zA-Z0-9\s,.-]/g, '');
// Limit length
return sanitized.slice(0, 200);
}
Error Handling
Graceful Degradation
// In ProjectSettingsComponent
private async initializeAutocomplete(): Promise<void> {
try {
// Attempt to initialize Google Places
this.autocomplete = await this.googleMapsService.createAutocomplete(...);
} catch (error) {
// Log error
console.error('Places Autocomplete unavailable:', error);
// Show user-friendly message
this.snackBar.open(
'Address suggestions unavailable. You can still enter the address manually.',
'Close',
{ duration: 5000 }
);
// Continue with manual entry - no blocking error
// Chrome autocomplete still works
}
}
API Error States
| Error | User Message | Fallback Behavior |
|---|---|---|
| API Key Invalid | "Unable to load map services" | Manual address entry, no map |
| Network Error | "Connection issue. Please check your internet." | Retry button, manual entry |
| Geocoding Failed | "Address could not be verified" | Save without lat/lng |
| Quota Exceeded | "Map services temporarily unavailable" | Manual entry, try later |
Deployment Guide: Google Maps API Setup
Automated Setup (Recommended)
For new environment provisioning, use the automated setup script:
# Run from workspace root
cli/sdlc/new-environment-provisioning/setup-google-maps-api.sh <environment>
# Examples:
cli/sdlc/new-environment-provisioning/setup-google-maps-api.sh demo
cli/sdlc/new-environment-provisioning/setup-google-maps-api.sh prod
What the script does:
- ✅ Enables Google Maps JavaScript API and Places API
- ✅ Creates restricted API key
- ✅ Applies API and domain restrictions
- ✅ Stores API key in Secret Manager
- ✅ Grants service account access
- ✅ Tests API key with sample request
- ✅ Displays next steps and verification commands
Manual steps after running script:
- Update Cloud Run service to mount secret
- Update frontend environment configuration
- Rebuild and deploy frontend
- Set quotas and billing alerts in GCP Console
- Test end-to-end integration
Script Location: cli/sdlc/new-environment-provisioning/setup-google-maps-api.sh
Documentation: See New Environment Scripts
Manual Setup (Step-by-Step)
If you prefer manual setup or need to troubleshoot, follow these detailed steps:
Prerequisites
- GCP project already created (e.g.,
construction-code-expert-demo) - Billing account linked to the project
gcloudCLI installed and authenticated- Project environment variables loaded
# Load environment variables
ENV="demo" # or test, prod, etc.
source env/${ENV}/setvars.sh
Step 1: Enable Required Google Maps APIs
Enable the Google Maps JavaScript API and Places API for your GCP project.
# Enable Maps JavaScript API (includes base map, geocoding, and 3D features)
gcloud services enable maps-backend.googleapis.com \
--project=${GCP_PROJECT_ID}
# Enable Places API (includes autocomplete and place details)
gcloud services enable places-backend.googleapis.com \
--project=${GCP_PROJECT_ID}
# Verify APIs are enabled
gcloud services list --enabled \
--project=${GCP_PROJECT_ID} \
--filter="name:(maps-backend.googleapis.com OR places-backend.googleapis.com)"
Expected Output:
NAME TITLE
maps-backend.googleapis.com Maps JavaScript API
places-backend.googleapis.com Places API
Step 2: Create Restricted API Key
Create an API key with appropriate restrictions for security and cost control.
# Create the API key
# Note: Display name is for your reference only
gcloud alpha services api-keys create \
--display-name="Google Maps API Key - ${ENV} - Frontend" \
--project=${GCP_PROJECT_ID}
# Get the newly created API key's unique ID
API_KEY_ID=$(gcloud alpha services api-keys list \
--project=${GCP_PROJECT_ID} \
--filter="displayName:'Google Maps API Key - ${ENV} - Frontend'" \
--format="value(name)" \
--limit=1)
# Extract just the key ID from the full resource name
API_KEY_ID=$(basename ${API_KEY_ID})
echo "Created API Key ID: ${API_KEY_ID}"
Step 3: Apply API Restrictions
Restrict the API key to only Google Maps and Places APIs for security.
# Apply API restrictions
gcloud alpha services api-keys update ${API_KEY_ID} \
--project=${GCP_PROJECT_ID} \
--api-target=service=maps-backend.googleapis.com \
--api-target=service=places-backend.googleapis.com
# Verify API restrictions
gcloud alpha services api-keys describe ${API_KEY_ID} \
--project=${GCP_PROJECT_ID} \
--format="yaml(restrictions.apiTargets)"
Step 4: Apply Application Restrictions
Restrict the API key to specific domains (HTTP referrers) to prevent unauthorized usage.
# Determine Firebase hosting domain and custom domain based on environment
FIREBASE_DOMAIN="construction-code-expert-${ENV}-m3.web.app"
# Custom domain follows pattern, except prod which doesn't have env prefix
if [[ "${ENV}" == "prod" ]]; then
CUSTOM_DOMAIN="m3.codeproof.app"
else
CUSTOM_DOMAIN="${ENV}.m3.codeproof.app"
fi
# Apply browser key restrictions (HTTP referrers)
# Includes both Firebase hosting domain and custom domain
# IMPORTANT: Multiple referrers must be comma-separated in a single flag
gcloud alpha services api-keys update ${API_KEY_ID} \
--project=${GCP_PROJECT_ID} \
--allowed-referrers="${FIREBASE_DOMAIN}/*,*.${FIREBASE_DOMAIN}/*,${CUSTOM_DOMAIN}/*,*.${CUSTOM_DOMAIN}/*,localhost:4200/*,localhost:5173/*,127.0.0.1:4200/*"
# Verify application restrictions
gcloud alpha services api-keys describe ${API_KEY_ID} \
--project=${GCP_PROJECT_ID} \
--format="yaml(restrictions.browserKeyRestrictions)"
Note: The script configures both Firebase hosting domains (e.g., construction-code-expert-test-m3.web.app) and custom domains (e.g., test.m3.codeproof.app). The localhost restrictions are included for local development. Remove them for production environments if desired.
IMPORTANT: All referrers must be specified as comma-separated values in a single --allowed-referrers flag. Using multiple --allowed-referrers flags will only apply the last one.
Expected Output from Verification:
restrictions:
browserKeyRestrictions:
allowedReferrers:
- construction-code-expert-test-m3.web.app/*
- '*.construction-code-expert-test-m3.web.app/*'
- test.m3.codeproof.app/*
- '*.test.m3.codeproof.app/*'
- localhost:4200/*
- localhost:5173/*
- 127.0.0.1:4200/*
If you see only one referrer listed, the command syntax was incorrect.
Step 5: Get the API Key String
Retrieve the actual API key string to be stored in Secret Manager.
# Get the API key string
API_KEY_STRING=$(gcloud alpha services api-keys get-key-string ${API_KEY_ID} \
--project=${GCP_PROJECT_ID} \
--format="value(keyString)")
echo "API Key String: ${API_KEY_STRING}"
echo "⚠️ IMPORTANT: This is the only time you can retrieve this key string!"
echo " Store it securely in Secret Manager in the next step."
Step 6: Store API Key in Secret Manager
Store the API key securely in Google Secret Manager.
# Create secret in Secret Manager
echo -n "${API_KEY_STRING}" | gcloud secrets create google-maps-api-key \
--project=${GCP_PROJECT_ID} \
--replication-policy="automatic" \
--data-file=-
# Verify secret was created
gcloud secrets describe google-maps-api-key \
--project=${GCP_PROJECT_ID}
Step 7: Understanding Client-Side API Keys
Important Architecture Note: The Google Maps API key is a client-side browser key, not a server-side secret.
How Browser API Keys Work:
- API key is embedded in the JavaScript bundle at build time
- Bundle is deployed to Firebase Hosting (static files)
- Browser loads the JavaScript and uses the API key
- The key is visible in browser DevTools (this is expected and normal)
- Security is enforced by domain referrer restrictions, not by hiding the key
Why Secret Manager?
- Centralized key management across environments
- Can rotate keys without changing code
- CI/CD can retrieve keys at build time
- Best practice for credential management
No Server-Side Access Needed (for this feature):
- ❌ Cloud Run service account doesn't need access (for Phase 1)
- ❌ Backend doesn't serve the key to frontend
- ✅ Key is built into the static JavaScript bundle
- ✅ Firebase Hosting serves the pre-built files
Future Server-Side Considerations:
📝 TODO (Future Phases): If we add server-side Google Maps features, we'll need a separate server-side API key with different restrictions:
Potential Server-Side Use Cases:
- Batch geocoding of existing project addresses (Issue #XXX)
- Server-side address validation before saving
- Distance calculations between projects (nearby projects feature)
- Jurisdiction lookup via reverse geocoding
- Automatic address correction and normalization
Server-Side API Key Setup (when needed):
# Create server-side API key (different from browser key)
gcloud alpha services api-keys create \
--display-name="Google Maps API Key - $\{ENV\} - Backend" \
--project=$\{GCP_PROJECT_ID\}
# Apply IP address restrictions (not domain restrictions)
gcloud alpha services api-keys update $\{SERVER_API_KEY_ID\} \
--project=$\{GCP_PROJECT_ID\} \
--allowed-ips="0.0.0.0/0" # Or specific Cloud Run IP ranges
# Store in Secret Manager
gcloud secrets create google-maps-server-api-key ...
# Grant Cloud Run service account access
gcloud secrets add-iam-policy-binding google-maps-server-api-key \
--member="serviceAccount:$\{SERVICE_ACCOUNT_EMAIL\}" \
--role="roles/secretmanager.secretAccessor"
# Mount in Cloud Run
gcloud run services update construction-code-expert-$\{ENV\} \
--update-secrets=GOOGLE_MAPS_SERVER_API_KEY=google-maps-server-api-key:latestKey Differences:
- Browser Key (current): Domain referrer restrictions, embedded in frontend bundle
- Server Key (future): IP address restrictions, accessed from backend via Secret Manager
Why Two Keys?
- Different restriction types (domain vs IP)
- Different usage patterns (client vs server)
- Better security isolation
- Separate quota management
Step 8: Configure Frontend Environment Files
Update the Angular environment files with the API key.
For Development (web-ng-m3/src/environments/environment.ts):
export const environment = {
production: false,
// ... existing config
// Google Maps API Key (visible in browser - secured by domain restrictions)
googleMapsApiKey: 'YOUR_DEV_API_KEY_HERE'
};
For Production (web-ng-m3/src/environments/environment.prod.ts):
export const environment = {
production: true,
// ... existing config
// Google Maps API Key (visible in browser - secured by domain restrictions)
googleMapsApiKey: 'YOUR_PROD_API_KEY_HERE'
};
Build-Time Injection (Recommended for CI/CD):
# Retrieve key from Secret Manager during CI/CD build
API_KEY=$(gcloud secrets versions access latest \
--secret=google-maps-api-key \
--project=${GCP_PROJECT_ID})
# Inject into environment file before build
sed -i "s/YOUR_PROD_API_KEY_HERE/${API_KEY}/" \
web-ng-m3/src/environments/environment.prod.ts
# Build the app
npm run build:prod
# Deploy to Firebase
firebase deploy --project=${GCP_PROJECT_ID} --only hosting:prod
Manual Configuration (Simpler):
Since the key is already domain-restricted, it's safe to commit the key directly to environment.prod.ts. The domain restrictions prevent unauthorized usage even if the key is public.
Step 9: Set Usage Quotas and Billing Alerts
Configure quotas to prevent unexpected charges and set up billing alerts.
# Set daily quota for Maps JavaScript API (optional - for cost control)
# This is done via the GCP Console as gcloud doesn't support quota management
echo "📋 Manual Steps Required:"
echo ""
echo "1. Set API Quotas:"
echo " https://console.cloud.google.com/apis/api/maps-backend.googleapis.com/quotas?project=${GCP_PROJECT_ID}"
echo " Recommended: 10,000 requests/day for Maps JavaScript API"
echo ""
echo "2. Set Places API Quotas:"
echo " https://console.cloud.google.com/apis/api/places-backend.googleapis.com/quotas?project=${GCP_PROJECT_ID}"
echo " Recommended: 10,000 requests/day for Autocomplete"
echo ""
echo "3. Enable Billing Alerts:"
echo " https://console.cloud.google.com/billing/${BILLING_ACCOUNT_ID}/alerts?project=${GCP_PROJECT_ID}"
echo " Recommended: Alert at $50/month for Google Maps Platform"
Step 10: Verify API Key Setup
Test that the API key is correctly configured and accessible.
# Test Maps JavaScript API (via curl)
TEST_ADDRESS="1600+Amphitheatre+Parkway,+Mountain+View,+CA"
curl "https://maps.googleapis.com/maps/api/geocode/json?address=${TEST_ADDRESS}&key=${API_KEY_STRING}"
# Expected: JSON response with coordinates for Google's headquarters
Expected Output:
{
"results": [
{
"formatted_address": "1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA",
"geometry": {
"location": {
"lat": 37.4224764,
"lng": -122.0842499
}
}
}
],
"status": "OK"
}
Step 11: Configure Frontend with API Key
Update the Angular environment files with the API key.
Option A: Direct Configuration (Simpler, recommended):
# Edit the environment file directly
# File: web-ng-m3/src/environments/environment.${ENV}.ts
export const environment = {
production: true,
// ... existing config
// Google Maps API Key (domain-restricted, safe to commit)
googleMapsApiKey: '${API_KEY_STRING}'
};
Option B: Build-Time Injection (For CI/CD pipelines):
# Retrieve key from Secret Manager during build
API_KEY=$(gcloud secrets versions access latest \
--secret=google-maps-api-key \
--project=${GCP_PROJECT_ID})
# Inject into environment file
sed -i "s/YOUR_API_KEY_HERE/${API_KEY}/" \
web-ng-m3/src/environments/environment.${ENV}.ts
Note: Since browser API keys are secured by domain restrictions (not by hiding the key), it's actually safe to commit the key to your repository. However, using Secret Manager is still best practice for centralized management.
Step 12: Build and Deploy Frontend
Rebuild and deploy the frontend with the Google Maps integration.
# Navigate to frontend directory
cd web-ng-m3
# Load environment variables
source ../env/${ENV}/setvars.sh
source ../env/${ENV}/firebase/m3/setvars.sh
# Install dependencies (if needed)
npm install
# Rebuild frontend with Google Maps integration
npm run build:${ENV}
# Deploy to Firebase hosting
firebase deploy --project=${GCP_PROJECT_ID} --only hosting:${ENV}
Step 13: Test End-to-End
Perform end-to-end testing on the deployed application.
Manual Testing Checklist:
- Navigate to project settings page
- Click "Edit" on Project Metadata
- Type an address in the street field
- Verify Google Places autocomplete dropdown appears
- Select an address from the dropdown
- Verify city, state, zip auto-populate
- Click "Save"
- Verify map widget displays with correct location
- Open browser DevTools → Network tab
- Verify Maps API calls succeed (status 200)
- Verify no CORS or authentication errors
- Test on mobile device (responsive layout)
Verify API Usage:
# Check API usage in GCP Console
echo "Monitor API usage at:"
echo "https://console.cloud.google.com/apis/api/maps-backend.googleapis.com/metrics?project=${GCP_PROJECT_ID}"
echo "https://console.cloud.google.com/apis/api/places-backend.googleapis.com/metrics?project=${GCP_PROJECT_ID}"
Step 14: Monitor API Usage and Costs
Set up monitoring for the first week after deployment.
# View current month's usage
gcloud billing accounts list
# Get billing account ID
BILLING_ACCOUNT_ID=$(gcloud billing projects describe ${GCP_PROJECT_ID} \
--format="value(billingAccountName)" | sed 's|billingAccounts/||')
echo "Monitor billing at:"
echo "https://console.cloud.google.com/billing/${BILLING_ACCOUNT_ID}/reports?project=${GCP_PROJECT_ID}"
Monitoring Recommendations:
- Check API usage daily for the first week
- Verify costs remain within free tier ($200/month)
- Monitor for suspicious spikes (potential unauthorized usage)
- Adjust quotas if usage patterns differ from estimates
- Review domain restrictions if seeing unexpected traffic
Troubleshooting
API Key Not Working in Browser:
# Verify API key restrictions
gcloud alpha services api-keys describe ${API_KEY_ID} \
--project=${GCP_PROJECT_ID} \
--format="yaml(restrictions)"
# Check if APIs are enabled
gcloud services list --enabled --project=${GCP_PROJECT_ID} \
--filter="name:(maps-backend OR places-backend)"
# Test the API key directly
curl "https://maps.googleapis.com/maps/api/geocode/json?address=test&key=${API_KEY_STRING}"
Common Issues:
-
"RefererNotAllowedMapError" - Domain not in allowed referrers list
- Check browser console for the exact domain being used
- Add the domain to allowed referrers:
gcloud alpha services api-keys update ${API_KEY_ID} \
--project=${GCP_PROJECT_ID} \
--allowed-referrers="existing-referrers,new-domain/*"
-
"API key not found" - Key not embedded in environment file
- Check
web-ng-m3/dist-${ENV}/main.*.jsfor the API key string - If missing, verify environment file and rebuild
- Check
-
"This API project is not authorized" - API not enabled
- Enable the API:
gcloud services enable maps-backend.googleapis.com places-backend.googleapis.com \
--project=${GCP_PROJECT_ID}
- Enable the API:
-
"Quota exceeded" - Daily quota limit reached
- Check quota usage in GCP Console
- Increase quota or wait for reset (midnight Pacific Time)
Integration with New Environment Provisioning
These steps should be integrated into the new environment provisioning workflow after the following existing steps:
After:
- ✅ Step 3: Deploy gRPC services to Cloud Run
- ✅ Step 4: Deploy ESPv2 Envoy Proxy
- ✅ Step 5: Configure GCS Bucket
Before:
- Step 6: Deploy UI to Firebase
Why This Order?
- Google Maps API key is client-side (embedded in frontend bundle)
- Must be configured before frontend build
- No backend/Cloud Run dependencies
Automation Script:
cli/sdlc/new-environment-provisioning/setup-google-maps-api.sh
Deployment Checklist
- Enable Google Maps and Places APIs
- Create restricted API key
- Apply API restrictions (Maps + Places only)
- Apply application restrictions (domain referrers - both Firebase & custom domains)
- Store API key in Secret Manager (for centralized management)
- Update frontend environment files with API key
- Set usage quotas (10k req/day)
- Enable billing alerts ($50/month)
- Rebuild frontend bundle with API key
- Deploy to Firebase Hosting
- Test end-to-end functionality (autocomplete + map)
- Verify API key visible in browser DevTools (expected)
- Verify domain restrictions work (test from unauthorized domain)
- Monitor API usage for first week
- Document API key location in environment docs
Future Phase 2: 3D Flyover Widget
Technical Overview
The 3D flyover widget will provide a Google Earth-style aerial perspective using Google Maps JavaScript API's tilt and rotation capabilities.
Implementation Approach
Option 1: Google Maps JavaScript API with Vector Maps (Recommended)
// Create map with 3D tilt support
const map = new google.maps.Map(mapElement, {
center: { lat: 37.7749, lng: -122.4194 },
zoom: 18, // Closer zoom for 3D view
tilt: 45, // 45° aerial perspective
heading: 0, // North-facing by default
mapTypeId: 'satellite', // Satellite imagery for context
mapId: 'YOUR_MAP_ID' // Required for advanced features
});
// Add interactive controls
map.setOptions({
rotateControl: true, // Allow user to rotate view
tiltControl: true, // Allow user to adjust tilt
});
Option 2: Google Earth Web API (Alternative)
// Embed Google Earth Web (more immersive, but heavier)
// Requires separate Google Earth Engine API setup
const earthDiv = document.getElementById('earth-view');
const earth = new google.earth.Map(earthDiv);
earth.setCenter({ lat, lng, altitude: 1000, heading: 0, tilt: 45 });
Widget Layout (Phase 2)
<!-- 3D Flyover Card (full width below 2D map) -->
<mat-card class="flyover-card">
<mat-card-header>
<mat-card-title>
<mat-icon>terrain</mat-icon>
3D Aerial View
</mat-card-title>
<mat-card-subtitle>
Rotate and tilt to explore the site
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div id="flyover-map" class="flyover-container"></div>
<!-- View Controls -->
<div class="flyover-controls">
<button mat-icon-button (click)="resetView()" matTooltip="Reset view">
<mat-icon>refresh</mat-icon>
</button>
<button mat-icon-button (click)="rotate('left')" matTooltip="Rotate left">
<mat-icon>rotate_left</mat-icon>
</button>
<button mat-icon-button (click)="rotate('right')" matTooltip="Rotate right">
<mat-icon>rotate_right</mat-icon>
</button>
<mat-slider min="0" max="90" [(ngModel)]="tiltAngle"
(change)="updateTilt()"
matTooltip="Adjust tilt angle">
</mat-slider>
</div>
</mat-card-content>
</mat-card>
Component Skeleton (Phase 2)
@Component({
selector: 'app-project-flyover',
standalone: true,
templateUrl: './project-flyover.component.html',
styleUrls: ['./project-flyover.component.scss']
})
export class ProjectFlyoverComponent implements OnInit {
@Input() center: { lat: number; lng: number } | null = null;
map: google.maps.Map | null = null;
tiltAngle = 45;
heading = 0;
async ngOnInit(): Promise<void> {
await this.initialize3DMap();
}
private async initialize3DMap(): Promise<void> {
const map = new google.maps.Map(
document.getElementById('flyover-map')!,
{
center: this.center!,
zoom: 18,
tilt: this.tiltAngle,
heading: this.heading,
mapTypeId: 'satellite',
mapId: environment.googleMapsMapId,
rotateControl: true
}
);
this.map = map;
}
rotate(direction: 'left' | 'right'): void {
if (!this.map) return;
this.heading += (direction === 'right' ? 90 : -90);
this.map.setHeading(this.heading);
}
updateTilt(): void {
if (!this.map) return;
this.map.setTilt(this.tiltAngle);
}
resetView(): void {
this.heading = 0;
this.tiltAngle = 45;
if (this.map) {
this.map.setHeading(this.heading);
this.map.setTilt(this.tiltAngle);
}
}
}
API Requirements for 3D View
- Maps JavaScript API: Already enabled (no additional setup)
- Map ID: Create a custom Map ID in Cloud Console for advanced features
- Vector Maps: Automatically used for areas with 3D coverage
- No Additional Cost: Included in existing Maps JavaScript API pricing
3D Coverage Availability
- Urban Areas: Full 3D building models (major cities)
- Suburban Areas: Terrain elevation data
- Rural Areas: Basic terrain elevation
- Automatically falls back to 2D + terrain if 3D buildings unavailable
Future Phase 2: Street View Widget
Implementation
// Street View Panorama component
const panorama = new google.maps.StreetViewPanorama(
document.getElementById('street-view')!,
{
position: { lat, lng },
pov: { heading: 0, pitch: 0 },
zoom: 1,
addressControl: false,
linksControl: true,
panControl: true,
enableCloseButton: false
}
);
// Check if Street View is available
const streetViewService = new google.maps.StreetViewService();
streetViewService.getPanorama(
{ location: { lat, lng }, radius: 50 },
(data, status) => {
if (status === 'OK') {
panorama.setPano(data!.location!.pano);
} else {
// Show "Street View unavailable" message
}
}
);