Google Maps Bug

This commit is contained in:
Eric Gullickson
2025-11-08 12:17:29 -06:00
parent efbe9ba3c0
commit bb4a356b9e
39 changed files with 1175 additions and 449 deletions

View File

@@ -51,6 +51,34 @@ export class CacheService {
logger.error('Cache delete error', { key, error });
}
}
/**
* Delete all keys matching the provided pattern (without namespace prefix).
* Uses SCAN to avoid blocking Redis for large keyspaces.
*/
async deletePattern(pattern: string): Promise<void> {
try {
const namespacedPattern = this.prefix + pattern;
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(
cursor,
'MATCH',
namespacedPattern,
'COUNT',
100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
} catch (error) {
logger.error('Cache delete pattern error', { pattern, error });
}
}
}
export const cacheService = new CacheService();

View File

@@ -315,6 +315,7 @@ describe('Admin Catalog Integration Tests', () => {
it('should invalidate cache after create operation', async () => {
// Set a cache value
await redis.set('mvp:platform:vehicle-data:makes:2024', JSON.stringify([]), 3600);
await redis.set('mvp:platform:years', JSON.stringify([2024]), 3600);
// Create make (should invalidate cache)
await app.inject({
@@ -324,12 +325,11 @@ describe('Admin Catalog Integration Tests', () => {
payload: { name: 'Honda' }
});
// Check if cache was invalidated (implementation depends on invalidateVehicleData)
// Note: Current implementation logs warning but doesn't actually invalidate
// This test documents expected behavior
const cacheValue = await redis.get('mvp:platform:vehicle-data:makes:2024');
// Cache should be invalidated or remain (depending on implementation)
expect(cacheValue).toBeDefined();
const yearsCacheValue = await redis.get('mvp:platform:years');
expect(cacheValue).toBeNull();
expect(yearsCacheValue).toBeNull();
});
});

View File

@@ -114,6 +114,15 @@ export class PlatformCacheService {
* Invalidate all vehicle data cache (for admin operations)
*/
async invalidateVehicleData(): Promise<void> {
logger.warn('Vehicle data cache invalidation not implemented (requires pattern deletion)');
try {
const yearsKey = this.prefix + 'years';
await Promise.all([
this.cacheService.del(yearsKey),
this.cacheService.deletePattern(this.prefix + 'vehicle-data:*')
]);
logger.debug('Vehicle data cache invalidated');
} catch (error) {
logger.error('Vehicle data cache invalidation failed', { error });
}
}
}

View File

@@ -8,7 +8,12 @@ import { StationsService } from '../domain/stations.service';
import { StationsRepository } from '../data/stations.repository';
import { pool } from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import { StationSearchBody, SaveStationBody, StationParams } from '../domain/stations.types';
import {
StationSearchBody,
SaveStationBody,
StationParams,
UpdateSavedStationBody
} from '../domain/stations.types';
export class StationsController {
private stationsService: StationsService;
@@ -50,7 +55,14 @@ export class StationsController {
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const { placeId, nickname, notes, isFavorite } = request.body;
const {
placeId,
nickname,
notes,
isFavorite,
has93Octane,
has93OctaneEthanolFree
} = request.body;
if (!placeId) {
return reply.code(400).send({
@@ -62,7 +74,9 @@ export class StationsController {
const result = await this.stationsService.saveStation(placeId, userId, {
nickname,
notes,
isFavorite
isFavorite,
has93Octane,
has93OctaneEthanolFree
});
return reply.code(201).send(result);
@@ -82,6 +96,38 @@ export class StationsController {
});
}
}
async updateSavedStation(
request: FastifyRequest<{ Params: StationParams; Body: UpdateSavedStationBody }>,
reply: FastifyReply
) {
try {
const userId = (request as any).user.sub;
const { placeId } = request.params;
const result = await this.stationsService.updateSavedStation(placeId, userId, request.body);
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error updating saved station', {
error,
placeId: request.params.placeId,
userId: (request as any).user?.sub
});
if (error.message.includes('not found')) {
return reply.code(404).send({
error: 'Not Found',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to update saved station'
});
}
}
async getSavedStations(request: FastifyRequest, reply: FastifyReply) {
try {
@@ -122,4 +168,4 @@ export class StationsController {
});
}
}
}
}

View File

@@ -8,7 +8,8 @@ import { FastifyPluginAsync } from 'fastify';
import {
StationSearchBody,
SaveStationBody,
StationParams
StationParams,
UpdateSavedStationBody
} from '../domain/stations.types';
import { StationsController } from './stations.controller';
@@ -30,6 +31,15 @@ export const stationsRoutes: FastifyPluginAsync = async (
handler: stationsController.saveStation.bind(stationsController)
});
// PATCH /api/stations/saved/:placeId - Update saved station metadata
fastify.patch<{ Params: StationParams; Body: UpdateSavedStationBody }>(
'/stations/saved/:placeId',
{
preHandler: [fastify.authenticate],
handler: stationsController.updateSavedStation.bind(stationsController)
}
);
// GET /api/stations/saved - Get user's saved stations
fastify.get('/stations/saved', {
preHandler: [fastify.authenticate],

View File

@@ -45,14 +45,37 @@ export class StationsRepository {
return this.mapCacheRow(result.rows[0]);
}
async saveStation(userId: string, placeId: string, data?: { nickname?: string; notes?: string; isFavorite?: boolean }): Promise<SavedStation> {
async saveStation(
userId: string,
placeId: string,
data?: {
nickname?: string;
notes?: string;
isFavorite?: boolean;
has93Octane?: boolean;
has93OctaneEthanolFree?: boolean;
}
): Promise<SavedStation> {
const query = `
INSERT INTO saved_stations (user_id, place_id, nickname, notes, is_favorite)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO saved_stations (
user_id,
place_id,
nickname,
notes,
is_favorite,
has_93_octane,
has_93_octane_ethanol_free
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, place_id) DO UPDATE
SET nickname = COALESCE($3, saved_stations.nickname),
notes = COALESCE($4, saved_stations.notes),
is_favorite = COALESCE($5, saved_stations.is_favorite),
has_93_octane = COALESCE($6, saved_stations.has_93_octane),
has_93_octane_ethanol_free = CASE
WHEN $6 IS NOT NULL AND $6 = false THEN false
ELSE COALESCE($7, saved_stations.has_93_octane_ethanol_free)
END,
updated_at = NOW()
RETURNING *
`;
@@ -62,12 +85,58 @@ export class StationsRepository {
placeId,
data?.nickname,
data?.notes,
data?.isFavorite || false
data?.isFavorite ?? false,
data?.has93Octane ?? false,
data?.has93OctaneEthanolFree ?? false
]);
return this.mapSavedRow(result.rows[0]);
}
async updateSavedStation(
userId: string,
placeId: string,
data: {
nickname?: string;
notes?: string;
isFavorite?: boolean;
has93Octane?: boolean;
has93OctaneEthanolFree?: boolean;
}
): Promise<SavedStation | null> {
const query = `
UPDATE saved_stations
SET
nickname = COALESCE($3, nickname),
notes = COALESCE($4, notes),
is_favorite = COALESCE($5, is_favorite),
has_93_octane = COALESCE($6, has_93_octane),
has_93_octane_ethanol_free = CASE
WHEN $6 IS NOT NULL AND $6 = false THEN false
ELSE COALESCE($7, has_93_octane_ethanol_free)
END,
updated_at = NOW()
WHERE user_id = $1 AND place_id = $2
RETURNING *
`;
const result = await this.pool.query(query, [
userId,
placeId,
data.nickname,
data.notes,
data.isFavorite,
data.has93Octane,
data.has93OctaneEthanolFree
]);
if (result.rows.length === 0) {
return null;
}
return this.mapSavedRow(result.rows[0]);
}
async getUserSavedStations(userId: string): Promise<SavedStation[]> {
const query = `
SELECT * FROM saved_stations
@@ -107,11 +176,14 @@ export class StationsRepository {
id: row.id,
userId: row.user_id,
stationId: row.place_id,
placeId: row.place_id,
nickname: row.nickname,
notes: row.notes,
isFavorite: row.is_favorite,
has93Octane: row.has_93_octane ?? false,
has93OctaneEthanolFree: row.has_93_octane_ethanol_free ?? false,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
}
}

View File

@@ -4,7 +4,13 @@
import { StationsRepository } from '../data/stations.repository';
import { googleMapsClient } from '../external/google-maps/google-maps.client';
import { StationSearchRequest, StationSearchResponse, SavedStation } from './stations.types';
import {
StationSearchRequest,
StationSearchResponse,
SavedStation,
StationSavedMetadata,
UpdateSavedStationBody
} from './stations.types';
import { logger } from '../../../core/logging/logger';
export class StationsService {
@@ -22,14 +28,43 @@ export class StationsService {
request.longitude,
request.radius || 5000
);
// Fetch saved stations for prioritization and metadata
const savedStations = await this.repository.getUserSavedStations(userId);
const savedMap = new Map(savedStations.map(saved => [saved.placeId, saved]));
// Cache stations for future reference
for (const station of stations) {
await this.repository.cacheStation(station);
// Enrich station with saved metadata
const saved = savedMap.get(station.placeId);
if (saved) {
station.isSaved = true;
station.savedMetadata = this.mapSavedMetadata(saved);
}
}
// Sort by distance
stations.sort((a, b) => (a.distance || 0) - (b.distance || 0));
// Sort saved stations first, favorites next, then distance
stations.sort((a, b) => {
const aSaved = a.savedMetadata ? 1 : 0;
const bSaved = b.savedMetadata ? 1 : 0;
if (aSaved !== bSaved) {
return bSaved - aSaved;
}
const aFavorite = a.savedMetadata?.isFavorite ? 1 : 0;
const bFavorite = b.savedMetadata?.isFavorite ? 1 : 0;
if (aFavorite !== bFavorite) {
return bFavorite - aFavorite;
}
const aDistance = a.distance ?? Number.POSITIVE_INFINITY;
const bDistance = b.distance ?? Number.POSITIVE_INFINITY;
return aDistance - bDistance;
});
return {
stations,
@@ -45,7 +80,13 @@ export class StationsService {
async saveStation(
placeId: string,
userId: string,
data?: { nickname?: string; notes?: string; isFavorite?: boolean }
data?: {
nickname?: string;
notes?: string;
isFavorite?: boolean;
has93Octane?: boolean;
has93OctaneEthanolFree?: boolean;
}
) {
// Get station details from cache
const station = await this.repository.getCachedStation(placeId);
@@ -62,6 +103,25 @@ export class StationsService {
station
};
}
async updateSavedStation(
placeId: string,
userId: string,
data: UpdateSavedStationBody
) {
const updated = await this.repository.updateSavedStation(userId, placeId, data);
if (!updated) {
throw new Error('Saved station not found');
}
const station = await this.repository.getCachedStation(placeId);
return {
...updated,
station
};
}
async getUserSavedStations(userId: string) {
const savedStations = await this.repository.getUserSavedStations(userId);
@@ -87,4 +147,14 @@ export class StationsService {
throw new Error('Saved station not found');
}
}
}
private mapSavedMetadata(saved: SavedStation): StationSavedMetadata {
return {
nickname: saved.nickname,
notes: saved.notes,
isFavorite: saved.isFavorite,
has93Octane: saved.has93Octane,
has93OctaneEthanolFree: saved.has93OctaneEthanolFree
};
}
}

View File

@@ -18,6 +18,8 @@ export interface Station {
isOpen?: boolean;
rating?: number;
photoUrl?: string;
isSaved?: boolean;
savedMetadata?: StationSavedMetadata;
}
export interface StationSearchRequest {
@@ -41,9 +43,12 @@ export interface SavedStation {
id: string;
userId: string;
stationId: string;
placeId: string;
nickname?: string;
notes?: string;
isFavorite: boolean;
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
createdAt: Date;
updatedAt: Date;
}
@@ -61,8 +66,26 @@ export interface SaveStationBody {
nickname?: string;
notes?: string;
isFavorite?: boolean;
has93Octane?: boolean;
has93OctaneEthanolFree?: boolean;
}
export interface StationParams {
placeId: string;
}
}
export interface UpdateSavedStationBody {
nickname?: string;
notes?: string;
isFavorite?: boolean;
has93Octane?: boolean;
has93OctaneEthanolFree?: boolean;
}
export interface StationSavedMetadata {
nickname?: string;
notes?: string;
isFavorite: boolean;
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
}

View File

@@ -0,0 +1,11 @@
-- Add 93 octane metadata to saved stations
ALTER TABLE saved_stations
ADD COLUMN IF NOT EXISTS has_93_octane BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE saved_stations
ADD COLUMN IF NOT EXISTS has_93_octane_ethanol_free BOOLEAN NOT NULL DEFAULT false;
-- Backfill existing rows with defaults
UPDATE saved_stations
SET has_93_octane = COALESCE(has_93_octane, false),
has_93_octane_ethanol_free = COALESCE(has_93_octane_ethanol_free, false);

View File

@@ -52,9 +52,12 @@ export const mockSavedStations: SavedStation[] = [
id: '550e8400-e29b-41d4-a716-446655440000',
userId: 'user123',
stationId: mockStations[0].placeId,
placeId: mockStations[0].placeId,
nickname: 'Work Gas Station',
notes: 'Usually has good prices, rewards program available',
isFavorite: true,
has93Octane: true,
has93OctaneEthanolFree: true,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-15')
},
@@ -62,9 +65,12 @@ export const mockSavedStations: SavedStation[] = [
id: '550e8400-e29b-41d4-a716-446655440001',
userId: 'user123',
stationId: mockStations[1].placeId,
placeId: mockStations[1].placeId,
nickname: 'Home Station',
notes: 'Closest to apartment',
isFavorite: true,
has93Octane: true,
has93OctaneEthanolFree: false,
createdAt: new Date('2024-01-05'),
updatedAt: new Date('2024-01-10')
}

View File

@@ -26,7 +26,8 @@ describe('StationsService', () => {
cacheStation: jest.fn().mockResolvedValue(undefined),
getCachedStation: jest.fn(),
saveStation: jest.fn(),
getUserSavedStations: jest.fn(),
getUserSavedStations: jest.fn().mockResolvedValue([]),
updateSavedStation: jest.fn(),
deleteSavedStation: jest.fn()
} as unknown as jest.Mocked<StationsRepository>;
@@ -51,6 +52,7 @@ describe('StationsService', () => {
expect(result.stations).toHaveLength(3);
expect(result.stations[0]?.name).toBe('Shell Gas Station - Downtown');
expect(mockRepository.cacheStation).toHaveBeenCalledTimes(3);
expect(mockRepository.getUserSavedStations).toHaveBeenCalledWith(mockUserId);
});
it('should sort stations by distance', async () => {
@@ -108,6 +110,32 @@ describe('StationsService', () => {
expect(mockRepository.cacheStation).toHaveBeenCalled();
});
it('should prioritize saved stations and include saved metadata', async () => {
const savedStation = mockSavedStations[0];
if (!savedStation) throw new Error('Mock saved station not found');
mockRepository.getUserSavedStations.mockResolvedValue([savedStation]);
const stationsWithDistance = [
{ ...mockStations[0], distance: 900 },
{ ...mockStations[1], distance: 100 }
];
(googleMapsClient.searchNearbyStations as jest.Mock).mockResolvedValue(
stationsWithDistance
);
const result = await service.searchNearbyStations(
{
latitude: searchCoordinates.sanFrancisco.latitude,
longitude: searchCoordinates.sanFrancisco.longitude
},
mockUserId
);
expect(result.stations[0]?.placeId).toBe(savedStation.stationId);
expect(result.stations[0]?.savedMetadata?.has93Octane).toBe(true);
});
});
describe('saveStation', () => {
@@ -153,13 +181,17 @@ describe('StationsService', () => {
await service.saveStation(station.placeId, mockUserId, {
nickname: 'Favorite Station',
notes: 'Best prices in area',
isFavorite: true
isFavorite: true,
has93Octane: true,
has93OctaneEthanolFree: false
});
expect(mockRepository.saveStation).toHaveBeenCalledWith(mockUserId, station.placeId, {
nickname: 'Favorite Station',
notes: 'Best prices in area',
isFavorite: true
isFavorite: true,
has93Octane: true,
has93OctaneEthanolFree: false
});
});
});
@@ -188,6 +220,40 @@ describe('StationsService', () => {
});
});
describe('updateSavedStation', () => {
it('should update saved station metadata', async () => {
const savedStation = mockSavedStations[0];
const station = mockStations[0];
if (!savedStation || !station) throw new Error('Mock data not found');
mockRepository.updateSavedStation.mockResolvedValue(savedStation);
mockRepository.getCachedStation.mockResolvedValue(station);
const result = await service.updateSavedStation(savedStation.stationId, mockUserId, {
has93Octane: true,
has93OctaneEthanolFree: true
});
expect(mockRepository.updateSavedStation).toHaveBeenCalledWith(
mockUserId,
savedStation.stationId,
{
has93Octane: true,
has93OctaneEthanolFree: true
}
);
expect(result.station).toEqual(station);
});
it('should throw if saved station not found', async () => {
mockRepository.updateSavedStation.mockResolvedValue(null);
await expect(
service.updateSavedStation('unknown', mockUserId, { nickname: 'Update' })
).rejects.toThrow('Saved station not found');
});
});
describe('removeSavedStation', () => {
it('should delete a saved station', async () => {
const savedStation = mockSavedStations[0];