Files
motovaultpro/backend/src/features/stations/api/stations.controller.ts
Eric Gullickson d8dec64538 feat: add station matching from receipt merchant name (refs #132)
Add Google Places Text Search to match receipt merchant names (e.g.
"Shell", "COSTCO #123") to real gas stations. Backend exposes
POST /api/stations/match endpoint. Frontend calls it after OCR
extraction and pre-fills locationData with matched station's placeId,
name, and address. Users can clear the match in the review modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:45:13 -06:00

227 lines
6.6 KiB
TypeScript

/**
* @ai-summary Fastify route handlers for stations API
* @ai-context HTTP request/response handling with Fastify reply methods
*/
import { FastifyRequest, FastifyReply } from 'fastify';
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,
StationMatchBody,
SaveStationBody,
StationParams,
UpdateSavedStationBody
} from '../domain/stations.types';
import { googleMapsClient } from '../external/google-maps/google-maps.client';
export class StationsController {
private stationsService: StationsService;
constructor() {
const repository = new StationsRepository(pool);
this.stationsService = new StationsService(repository);
}
async searchStations(request: FastifyRequest<{ Body: StationSearchBody }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const { latitude, longitude, radius, fuelType } = request.body;
if (!latitude || !longitude) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Latitude and longitude are required'
});
}
const result = await this.stationsService.searchNearbyStations({
latitude,
longitude,
radius,
fuelType
}, userId);
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error searching stations', { error, userId: (request as any).user?.sub });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to search stations'
});
}
}
async matchStation(request: FastifyRequest<{ Body: StationMatchBody }>, reply: FastifyReply) {
try {
const { merchantName } = request.body;
if (!merchantName || !merchantName.trim()) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Merchant name is required',
});
}
const result = await this.stationsService.matchStationFromReceipt(merchantName);
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error matching station from receipt', { error, merchantName: request.body?.merchantName });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to match station',
});
}
}
async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const {
placeId,
nickname,
notes,
isFavorite,
has93Octane,
has93OctaneEthanolFree
} = request.body;
if (!placeId) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Place ID is required'
});
}
const result = await this.stationsService.saveStation(placeId, userId, {
nickname,
notes,
isFavorite,
has93Octane,
has93OctaneEthanolFree
});
return reply.code(201).send(result);
} catch (error: any) {
logger.error('Error saving station', { error, 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 save station'
});
}
}
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 {
const userId = (request as any).user.sub;
const result = await this.stationsService.getUserSavedStations(userId);
return reply.code(200).send(result);
} catch (error: any) {
logger.error('Error getting saved stations', { error, userId: (request as any).user?.sub });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get saved stations'
});
}
}
async removeSavedStation(request: FastifyRequest<{ Params: StationParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const { placeId } = request.params;
await this.stationsService.removeSavedStation(placeId, userId);
return reply.code(204).send();
} catch (error: any) {
logger.error('Error removing 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 remove saved station'
});
}
}
async getStationPhoto(request: FastifyRequest<{ Params: { reference: string } }>, reply: FastifyReply) {
try {
const { reference } = request.params;
if (!reference) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Photo reference is required'
});
}
const photoBuffer = await googleMapsClient.fetchPhoto(reference);
return reply
.code(200)
.header('Content-Type', 'image/jpeg')
.header('Cache-Control', 'public, max-age=86400')
.send(photoBuffer);
} catch (error: any) {
logger.error('Error fetching station photo', {
error,
reference: request.params.reference
});
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to fetch station photo'
});
}
}
}