Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented travelTime Data Functionality #376

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions backend/scripts/add_buildings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@ const formatBuilding = ({
area: getAreaType(area),
latitude,
longitude,
walkTime: 0,
driveTime: 0,
});

const makeBuilding = async (apartmentWithId: ApartmentWithId) => {
Expand Down
269 changes: 269 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import {
ApartmentWithId,
CantFindApartmentForm,
QuestionForm,
LocationTravelTimes,
} from '@common/types/db-types';
// Import Firebase configuration and types
import { auth } from 'firebase-admin';
import { Timestamp } from '@firebase/firestore-types';
import nodemailer from 'nodemailer';
import axios from 'axios';
import { db, FieldValue, FieldPath } from './firebase-config';
import { Faq } from './firebase-config/types';
import authenticate from './auth';
Expand All @@ -39,6 +41,8 @@ const usersCollection = db.collection('users');
const pendingBuildingsCollection = db.collection('pendingBuildings');
const contactQuestionsCollection = db.collection('contactQuestions');

const travelTimesCollection = db.collection('travelTimes');

// Middleware setup
const app: Express = express();

Expand Down Expand Up @@ -960,4 +964,269 @@ app.post('/api/add-contact-question', authenticate, async (req, res) => {
}
});

interface TravelTimes {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you already imported LocationTravelTimes, you can delete TravelTimes and use the imported type.

agQuadWalking: number;
agQuadDriving: number;
engQuadWalking: number;
engQuadDriving: number;
hoPlazaWalking: number;
hoPlazaDriving: number;
}

const { REACT_APP_MAPS_API_KEY } = process.env;
const LANDMARKS = {
eng_quad: '42.4445,-76.4836', // Duffield Hall
ag_quad: '42.4489,-76.4780', // Mann Library
ho_plaza: '42.4468,-76.4851', // Ho Plaza
};

interface TravelTimeInput {
origin: string; // Can be either address or "latitude,longitude"
}

/**
* getTravelTimes – Calculates travel times between an origin and multiple destinations using Google Distance Matrix API.
*
* @remarks
* Makes an HTTP request to the Google Distance Matrix API and processes the response to extract duration values.
* Times are converted from seconds to minutes before being returned.
*
* @param {string} origin - Starting location as either an address or coordinates in "lat,lng" format
* @param {string[]} destinations - Array of destination addresses to calculate times to
* @param {'walking' | 'driving'} mode - Mode of transportation to use for calculations
* @return {Promise<number[]>} - Array of travel times in minutes to each destination
*/
async function getTravelTimes(
origin: string,
destinations: string[],
mode: 'walking' | 'driving'
): Promise<number[]> {
const response = await axios.get(
`https://maps.googleapis.com/maps/api/distancematrix/json?origins=${encodeURIComponent(
origin
)}&destinations=${destinations
.map((dest) => encodeURIComponent(dest))
.join('|')}&mode=${mode}&key=${REACT_APP_MAPS_API_KEY}`
);

return response.data.rows[0].elements.map(
(element: { duration: { value: number } }) => element.duration.value / 60
);
}

/**
* Travel Times Calculator - Calculates walking and driving times from a given origin to Cornell landmarks.
*
* @remarks
* Uses Google Maps Distance Matrix API to calculate travel times to three landmarks: Engineering Quad,
* Agriculture Quad, and Ho Plaza. Returns both walking and driving durations in minutes.
* Origin can be either an address or coordinates in "latitude,longitude" format.
*
* @route POST /api/travel-times
*
* @input {string} req.body.origin - Starting point, either as address or "latitude,longitude"
*
* @status
* - 200: Successfully retrieved travel times
* - 400: Missing or invalid origin
* - 500: Server error or Google Maps API failure
*/
app.post('/api/calculate-travel-times', async (req, res) => {
try {
const { origin } = req.body as TravelTimeInput;
console.log('Origin:', origin);

if (!origin) {
return res.status(400).json({ error: 'Origin is required' });
}

const destinations = Object.values(LANDMARKS);
console.log('Destinations array:', destinations);

// Get walking and driving times using the helper function
const [walkingTimes, drivingTimes] = await Promise.all([
getTravelTimes(origin, destinations, 'walking'),
getTravelTimes(origin, destinations, 'driving'),
]);

console.log('Raw walking times:', walkingTimes);
console.log('Raw driving times:', drivingTimes);

const travelTimes: TravelTimes = {
engQuadWalking: walkingTimes[0],
engQuadDriving: drivingTimes[0],
agQuadWalking: walkingTimes[1],
agQuadDriving: drivingTimes[1],
hoPlazaWalking: walkingTimes[2],
hoPlazaDriving: drivingTimes[2],
};

console.log('Final travel times:', travelTimes);
return res.status(200).json(travelTimes);
} catch (error) {
console.error('Error calculating travel times:', error);
return res.status(500).json({ error: 'Internal server error' });
}
});

/**
* Test Travel Times Endpoint - Creates a travel times document for a specific building.
*
* @remarks
* Retrieves building coordinates from the buildings collection, calculates travel times to Cornell landmarks,
* and stores the results in the travelTimes collection. This endpoint is used for testing and populating
* travel time data for existing buildings.
*
* @param {string} buildingId - The ID of the building to calculate and store travel times for
*
* @return {Object} - Object containing success message, building ID, and calculated travel times
*/
app.post('/api/test-travel-times/:buildingId', async (req, res) => {
try {
const { buildingId } = req.params;

// Get building data
const buildingDoc = await buildingsCollection.doc(buildingId).get();
if (!buildingDoc.exists) {
return res.status(404).json({ error: 'Building not found' });
}

const buildingData = buildingDoc.data();
if (!buildingData?.latitude || !buildingData?.longitude) {
return res.status(400).json({ error: 'Building missing coordinate data' });
}

// Calculate travel times using the main endpoint
const response = await axios.post(`http://localhost:3000/api/calculate-travel-times`, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can delete http://localhost:3000 from the axios post request. Keeping this part in the request means it will only work if you run the backend locally, but not on prod. I believe replacing it with axios.post('/api/calculate-travel-times') may do the trick, but I didn't test this modification.

origin: `${buildingData.latitude},${buildingData.longitude}`,
});

// Store in Firebase
await travelTimesCollection.doc(buildingId).set(response.data);

return res.status(200).json({
message: 'Travel times calculated and stored successfully',
buildingId,
travelTimes: response.data,
});
} catch (error) {
console.error('Error in test endpoint:', error);
return res.status(500).json({ error: 'Internal server error' });
}
});
/**
* Batch Travel Times Endpoint - Calculates and stores travel times for a batch of buildings.
*
* @remarks
* Processes a batch of buildings from the buildings collection, calculating travel times to Cornell landmarks
* for each one and storing the results in the travelTimes collection. This endpoint handles buildings in batches
* with rate limiting between requests. Supports pagination through the startAfter parameter.
*
* @param {number} batchSize - Number of buildings to process in this batch (defaults to 50)
* @param {string} [startAfter] - Optional ID of last processed building for pagination
* @return {Object} - Summary object containing:
* - Message indicating batch completion
* - Total number of buildings processed in this batch
* - Count of successful calculations
* - Arrays of successful building IDs and failed buildings with error details
*/

// Helper function for rate limiting API requests
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

app.post('/api/batch-create-travel-times/:batchSize/:startAfter?', async (req, res) => {
try {
const batchSize = parseInt(req.params.batchSize, 10) || 50;
const { startAfter } = req.params;

let query = buildingsCollection.limit(batchSize);
if (startAfter) {
const lastDoc = await buildingsCollection.doc(startAfter).get();
query = query.startAfter(lastDoc);
}

const buildingDocs = (await query.get()).docs;
const results = {
success: [] as string[],
failed: [] as { id: string; error: string }[],
};

const processPromises = buildingDocs.map(async (doc) => {
try {
await delay(100); // 100ms delay between requests
const buildingData = doc.data();
if (!buildingData?.latitude || !buildingData?.longitude) {
results.failed.push({
id: doc.id,
error: 'Missing coordinates',
});
return;
}

const response = await axios.post(`http://localhost:3000/api/calculate-travel-times`, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, you can delete http://localhost:3000 from the axios post request.

origin: `${buildingData.latitude},${buildingData.longitude}`,
});

await travelTimesCollection.doc(doc.id).set(response.data);
results.success.push(doc.id);
} catch (error) {
results.failed.push({
id: doc.id,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
});

await Promise.all(processPromises);

res.status(200).json({
message: 'Batch processing completed',
totalProcessed: buildingDocs.length,
successCount: results.success.length,
failureCount: results.failed.length,
hasMore: buildingDocs.length === batchSize,
lastProcessedId: buildingDocs[buildingDocs.length - 1]?.id,
results,
});
} catch (error) {
console.error('Batch processing error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});

/**
* Get Travel Times By Building ID - Retrieves pre-calculated travel times from the travel times collection.
*
* @remarks
* Looks up the travel times document for the given building ID and returns the stored walking and driving
* times to Cornell landmarks: Engineering Quad, Agriculture Quad, and Ho Plaza.
*
* @route GET /api/travel-times/:buildingId
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adjust to /api/travel-times-by-id/:buildingId

*
* @input {string} req.params.buildingId - ID of the building to get travel times for
*
* @status
* - 200: Successfully retrieved travel times
* - 404: Building travel times not found
* - 500: Server error
*/
app.get('/api/travel-times-by-id/:buildingId', async (req, res) => {
try {
const { buildingId } = req.params;

const travelTimeDoc = await travelTimesCollection.doc(buildingId).get();

if (!travelTimeDoc.exists) {
return res.status(404).json({ error: 'Travel times not found for this building' });
}

const travelTimes = travelTimeDoc.data() as LocationTravelTimes;

return res.status(200).json(travelTimes);
} catch (error) {
console.error('Error retrieving travel times:', error);
return res.status(500).json({ error: 'Internal server error' });
}
});

export default app;
11 changes: 9 additions & 2 deletions common/types/db-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,15 @@ export type Apartment = {
readonly area: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER';
readonly latitude: number;
readonly longitude: number;
readonly walkTime: number;
readonly driveTime: number;
};

export type LocationTravelTimes = {
agQuadDriving: number;
agQuadWalking: number;
engQuadDriving: number;
engQuadWalking: number;
hoPlazaDriving: number;
hoPlazaWalking: number;
};

export type ApartmentWithId = Apartment & Id;
Expand Down
Loading
Loading