Google maps integration

This commit is contained in:
jackiettran
2025-09-09 22:49:55 -04:00
parent 69bf64fe70
commit 1d7db138df
25 changed files with 3711 additions and 577 deletions

View File

@@ -0,0 +1,119 @@
const rateLimit = require("express-rate-limit");
// General rate limiter for Maps API endpoints
const createMapsRateLimiter = (windowMs, max, message) => {
return rateLimit({
windowMs, // time window in milliseconds
max, // limit each IP/user to max requests per windowMs
message: {
error: message,
retryAfter: Math.ceil(windowMs / 1000), // seconds
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
// Use user ID if available, otherwise fall back to IPv6-safe IP handling
keyGenerator: (req) => {
if (req.user?.id) {
return `user:${req.user.id}`;
}
// Use the built-in IP key generator which properly handles IPv6
return rateLimit.defaultKeyGenerator(req);
},
});
};
// Specific rate limiters for different endpoints
const rateLimiters = {
// Places Autocomplete - allow more requests since users type frequently
placesAutocomplete: createMapsRateLimiter(
60 * 1000, // 1 minute window
30, // 30 requests per minute per user/IP
"Too many autocomplete requests. Please slow down."
),
// Place Details - moderate limit since each selection triggers this
placeDetails: createMapsRateLimiter(
60 * 1000, // 1 minute window
20, // 20 requests per minute per user/IP
"Too many place detail requests. Please slow down."
),
// Geocoding - lower limit since this is typically used less frequently
geocoding: createMapsRateLimiter(
60 * 1000, // 1 minute window
10, // 10 requests per minute per user/IP
"Too many geocoding requests. Please slow down."
),
};
// Enhanced rate limiter with user-specific limits
const createUserBasedRateLimiter = (windowMs, max, message) => {
const store = new Map(); // Simple in-memory store (use Redis in production)
return (req, res, next) => {
const key = req.user?.id
? `user:${req.user.id}`
: rateLimit.defaultKeyGenerator(req);
const now = Date.now();
const windowStart = now - windowMs;
// Clean up old entries
for (const [k, data] of store.entries()) {
if (data.windowStart < windowStart) {
store.delete(k);
}
}
// Get or create user's request data
let userData = store.get(key);
if (!userData || userData.windowStart < windowStart) {
userData = {
count: 0,
windowStart: now,
resetTime: now + windowMs,
};
}
// Check if limit exceeded
if (userData.count >= max) {
return res.status(429).json({
error: message,
retryAfter: Math.ceil((userData.resetTime - now) / 1000),
});
}
// Increment counter and store
userData.count++;
store.set(key, userData);
// Add headers
res.set({
"RateLimit-Limit": max,
"RateLimit-Remaining": Math.max(0, max - userData.count),
"RateLimit-Reset": new Date(userData.resetTime).toISOString(),
});
next();
};
};
// Burst protection for expensive operations
const burstProtection = createUserBasedRateLimiter(
10 * 1000, // 10 seconds
5, // 5 requests per 10 seconds
"Too many requests in a short period. Please slow down."
);
module.exports = {
// Individual rate limiters
placesAutocomplete: rateLimiters.placesAutocomplete,
placeDetails: rateLimiters.placeDetails,
geocoding: rateLimiters.geocoding,
// Burst protection
burstProtection,
// Utility functions
createMapsRateLimiter,
createUserBasedRateLimiter,
};

View File

@@ -9,11 +9,13 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@googlemaps/google-maps-services-js": "^3.4.2",
"bcryptjs": "^3.0.2",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"node-cron": "^3.0.3",
@@ -27,6 +29,28 @@
"nodemon": "^3.1.10"
}
},
"node_modules/@googlemaps/google-maps-services-js": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@googlemaps/google-maps-services-js/-/google-maps-services-js-3.4.2.tgz",
"integrity": "sha512-QjxiJSt8woyPaaQIUUDNL1nRfCEXTiv8KfJSNq/YzKEUVXABT75GLG+Zo267UwOcq70n8OsZbaro5VrY962mJg==",
"license": "Apache-2.0",
"dependencies": {
"@googlemaps/url-signature": "^1.0.4",
"agentkeepalive": "^4.1.0",
"axios": "^1.5.1",
"query-string": "<8.x",
"retry-axios": "<3.x"
}
},
"node_modules/@googlemaps/url-signature": {
"version": "1.0.40",
"resolved": "https://registry.npmjs.org/@googlemaps/url-signature/-/url-signature-1.0.40.tgz",
"integrity": "sha512-Gme3JxGZWQ4NVpATajSpS2/inQzhUxRvr/FK6IFpcC7AHOAmx8blI0y1/Qi2jqil+WoQ3TkEqq/MaKVtuV68RQ==",
"license": "Apache-2.0",
"dependencies": {
"crypto-js": "^4.2.0"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -103,6 +127,18 @@
"node": ">= 0.6"
}
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"license": "MIT",
"dependencies": {
"humanize-ms": "^1.2.1"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
@@ -144,6 +180,12 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
@@ -152,6 +194,17 @@
"node": ">= 4.0.0"
}
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -395,6 +448,18 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
@@ -493,6 +558,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -509,6 +580,24 @@
}
}
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -621,6 +710,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -683,6 +787,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz",
"integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -695,6 +817,15 @@
"node": ">=8"
}
},
"node_modules/filter-obj": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
@@ -711,6 +842,26 @@
"node": ">= 0.8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -726,6 +877,43 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -902,6 +1090,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -936,6 +1139,15 @@
"node": ">= 0.8"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -971,6 +1183,15 @@
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1704,6 +1925,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -1724,6 +1951,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.2.2",
"filter-obj": "^1.1.0",
"split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1804,6 +2049,18 @@
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
"integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw=="
},
"node_modules/retry-axios": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/retry-axios/-/retry-axios-2.6.0.tgz",
"integrity": "sha512-pOLi+Gdll3JekwuFjXO3fTq+L9lzMQGcSq7M5gIjExcl3Gu1hd4XXuf5o3+LuSBsaULQH7DiNbsqPd1chVpQGQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.7.0"
},
"peerDependencies": {
"axios": "*"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -2103,6 +2360,15 @@
"node": ">=10"
}
},
"node_modules/split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -2127,6 +2393,15 @@
"node": ">=10.0.0"
}
},
"node_modules/strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",

View File

@@ -16,11 +16,13 @@
"author": "",
"license": "ISC",
"dependencies": {
"@googlemaps/google-maps-services-js": "^3.4.2",
"bcryptjs": "^3.0.2",
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"node-cron": "^3.0.3",

View File

@@ -48,8 +48,20 @@ router.get("/", async (req, res) => {
order: [["createdAt", "DESC"]],
});
// Round coordinates to 2 decimal places for map display while keeping precise values in database
const itemsWithRoundedCoords = rows.map(item => {
const itemData = item.toJSON();
if (itemData.latitude !== null && itemData.latitude !== undefined) {
itemData.latitude = Math.round(parseFloat(itemData.latitude) * 100) / 100;
}
if (itemData.longitude !== null && itemData.longitude !== undefined) {
itemData.longitude = Math.round(parseFloat(itemData.longitude) * 100) / 100;
}
return itemData;
});
res.json({
items: rows,
items: itemsWithRoundedCoords,
totalPages: Math.ceil(count / limit),
currentPage: parseInt(page),
totalItems: count,
@@ -134,7 +146,16 @@ router.get("/:id", async (req, res) => {
return res.status(404).json({ error: "Item not found" });
}
res.json(item);
// Round coordinates to 2 decimal places for map display while keeping precise values in database
const itemResponse = item.toJSON();
if (itemResponse.latitude !== null && itemResponse.latitude !== undefined) {
itemResponse.latitude = Math.round(parseFloat(itemResponse.latitude) * 100) / 100;
}
if (itemResponse.longitude !== null && itemResponse.longitude !== undefined) {
itemResponse.longitude = Math.round(parseFloat(itemResponse.longitude) * 100) / 100;
}
res.json(itemResponse);
} catch (error) {
res.status(500).json({ error: error.message });
}

198
backend/routes/maps.js Normal file
View File

@@ -0,0 +1,198 @@
const express = require("express");
const router = express.Router();
const { authenticateToken } = require("../middleware/auth");
const rateLimiter = require("../middleware/rateLimiter");
const googleMapsService = require("../services/googleMapsService");
// Input validation middleware
const validateInput = (req, res, next) => {
// Basic input sanitization
if (req.body.input) {
req.body.input = req.body.input.toString().trim();
// Prevent extremely long inputs
if (req.body.input.length > 500) {
return res.status(400).json({ error: "Input too long" });
}
}
if (req.body.placeId) {
req.body.placeId = req.body.placeId.toString().trim();
// Basic place ID validation
if (!/^[A-Za-z0-9_-]+$/.test(req.body.placeId)) {
return res.status(400).json({ error: "Invalid place ID format" });
}
}
if (req.body.address) {
req.body.address = req.body.address.toString().trim();
if (req.body.address.length > 500) {
return res.status(400).json({ error: "Address too long" });
}
}
next();
};
// Error handling middleware
const handleServiceError = (error, res) => {
console.error("Maps service error:", error.message);
if (error.message.includes("API key not configured")) {
return res.status(503).json({
error: "Maps service temporarily unavailable",
details: "Configuration issue",
});
}
if (error.message.includes("quota exceeded")) {
return res.status(429).json({
error: "Service temporarily unavailable due to high demand",
details: "Please try again later",
});
}
return res.status(500).json({
error: "Failed to process request",
details: error.message,
});
};
/**
* POST /api/maps/places/autocomplete
* Proxy for Google Places Autocomplete API
*/
router.post(
"/places/autocomplete",
authenticateToken,
rateLimiter.burstProtection,
rateLimiter.placesAutocomplete,
validateInput,
async (req, res) => {
try {
const { input, types, componentRestrictions, sessionToken } = req.body;
if (!input || input.length < 2) {
return res.json({ predictions: [] });
}
const options = {
types: types || ["address"],
componentRestrictions,
sessionToken,
};
const result = await googleMapsService.getPlacesAutocomplete(
input,
options
);
// Log request for monitoring (without sensitive data)
console.log(
`Places Autocomplete: user=${
req.user?.id || "anonymous"
}, query_length=${input.length}, results=${
result.predictions?.length || 0
}`
);
res.json(result);
} catch (error) {
handleServiceError(error, res);
}
}
);
/**
* POST /api/maps/places/details
* Proxy for Google Places Details API
*/
router.post(
"/places/details",
authenticateToken,
rateLimiter.burstProtection,
rateLimiter.placeDetails,
validateInput,
async (req, res) => {
try {
const { placeId, sessionToken } = req.body;
if (!placeId) {
return res.status(400).json({ error: "Place ID is required" });
}
const options = {
sessionToken,
};
const result = await googleMapsService.getPlaceDetails(placeId, options);
// Log request for monitoring
console.log(
`Place Details: user=${
req.user?.id || "anonymous"
}, placeId=${placeId.substring(0, 10)}...`
);
res.json(result);
} catch (error) {
handleServiceError(error, res);
}
}
);
/**
* POST /api/maps/geocode
* Proxy for Google Geocoding API
*/
router.post(
"/geocode",
authenticateToken,
rateLimiter.burstProtection,
rateLimiter.geocoding,
validateInput,
async (req, res) => {
try {
const { address, componentRestrictions } = req.body;
if (!address) {
return res.status(400).json({ error: "Address is required" });
}
const options = {
componentRestrictions,
};
const result = await googleMapsService.geocodeAddress(address, options);
// Log request for monitoring
console.log(
`Geocoding: user=${req.user?.id || "anonymous"}, address_length=${
address.length
}`
);
res.json(result);
} catch (error) {
handleServiceError(error, res);
}
}
);
/**
* GET /api/maps/health
* Health check endpoint for Maps service
*/
router.get("/health", (req, res) => {
const isConfigured = googleMapsService.isConfigured();
res.status(isConfigured ? 200 : 503).json({
status: isConfigured ? "healthy" : "unavailable",
service: "Google Maps API Proxy",
timestamp: new Date().toISOString(),
configuration: {
apiKeyConfigured: isConfigured,
},
});
});
module.exports = router;

View File

@@ -239,7 +239,13 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
{
model: User,
as: "renter",
attributes: ["id", "username", "firstName", "lastName", "stripeCustomerId"],
attributes: [
"id",
"username",
"firstName",
"lastName",
"stripeCustomerId",
],
},
],
});
@@ -253,18 +259,26 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
}
// If owner is approving a pending rental, charge the stored payment method
if (status === "confirmed" && rental.status === "pending" && rental.ownerId === req.user.id) {
if (
status === "confirmed" &&
rental.status === "pending" &&
rental.ownerId === req.user.id
) {
if (!rental.stripePaymentMethodId) {
return res.status(400).json({ error: "No payment method found for this rental" });
return res
.status(400)
.json({ error: "No payment method found for this rental" });
}
try {
// Import StripeService to process the payment
const StripeService = require("../services/stripeService");
// Check if renter has a stripe customer ID
if (!rental.renter.stripeCustomerId) {
return res.status(400).json({ error: "Renter does not have a Stripe customer account" });
return res
.status(400)
.json({ error: "Renter does not have a Stripe customer account" });
}
// Create payment intent and charge the stored payment method
@@ -308,9 +322,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
} catch (paymentError) {
console.error("Payment failed during approval:", paymentError);
// Keep rental as pending, but inform of payment failure
return res.status(400).json({
error: "Payment failed during approval",
details: paymentError.message
return res.status(400).json({
error: "Payment failed during approval",
details: paymentError.message,
});
}
}
@@ -430,7 +444,6 @@ router.post("/:id/review-item", authenticateToken, async (req, res) => {
// Mark rental as completed (owner only)
router.post("/:id/mark-completed", authenticateToken, async (req, res) => {
try {
console.log("Mark completed endpoint hit for rental ID:", req.params.id);
const rental = await Rental.findByPk(req.params.id);
if (!rental) {

View File

@@ -20,6 +20,7 @@ const messageRoutes = require("./routes/messages");
const betaRoutes = require("./routes/beta");
const itemRequestRoutes = require("./routes/itemRequests");
const stripeRoutes = require("./routes/stripe");
const mapsRoutes = require("./routes/maps");
const PayoutProcessor = require("./jobs/payoutProcessor");
@@ -43,6 +44,7 @@ app.use("/api/rentals", rentalRoutes);
app.use("/api/messages", messageRoutes);
app.use("/api/item-requests", itemRequestRoutes);
app.use("/api/stripe", stripeRoutes);
app.use("/api/maps", mapsRoutes);
app.get("/", (req, res) => {
res.json({ message: "CommunityRentals.App API is running!" });

View File

@@ -0,0 +1,241 @@
const { Client } = require('@googlemaps/google-maps-services-js');
class GoogleMapsService {
constructor() {
this.client = new Client({});
this.apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!this.apiKey) {
console.error('❌ Google Maps API key not configured in environment variables');
} else {
console.log('✅ Google Maps service initialized');
}
}
/**
* Get autocomplete predictions for places
*/
async getPlacesAutocomplete(input, options = {}) {
if (!this.apiKey) {
throw new Error('Google Maps API key not configured');
}
if (!input || input.trim().length < 2) {
return { predictions: [] };
}
try {
const params = {
key: this.apiKey,
input: input.trim(),
types: options.types || 'address',
language: options.language || 'en',
...options
};
// Add session token if provided
if (options.sessionToken) {
params.sessiontoken = options.sessionToken;
}
// Add component restrictions (e.g., country)
if (options.componentRestrictions) {
params.components = Object.entries(options.componentRestrictions)
.map(([key, value]) => `${key}:${value}`)
.join('|');
}
const response = await this.client.placeAutocomplete({
params,
timeout: 5000
});
if (response.data.status === 'OK') {
return {
predictions: response.data.predictions.map(prediction => ({
placeId: prediction.place_id,
description: prediction.description,
types: prediction.types,
mainText: prediction.structured_formatting.main_text,
secondaryText: prediction.structured_formatting.secondary_text || ''
}))
};
} else {
console.error('Places Autocomplete API error:', response.data.status, response.data.error_message);
return {
predictions: [],
error: this.getErrorMessage(response.data.status),
status: response.data.status
};
}
} catch (error) {
console.error('Places Autocomplete service error:', error.message);
throw new Error('Failed to fetch place predictions');
}
}
/**
* Get detailed information about a place
*/
async getPlaceDetails(placeId, options = {}) {
if (!this.apiKey) {
throw new Error('Google Maps API key not configured');
}
if (!placeId) {
throw new Error('Place ID is required');
}
try {
const params = {
key: this.apiKey,
place_id: placeId,
fields: [
'address_components',
'formatted_address',
'geometry',
'place_id'
],
language: options.language || 'en'
};
// Add session token if provided
if (options.sessionToken) {
params.sessiontoken = options.sessionToken;
}
const response = await this.client.placeDetails({
params,
timeout: 5000
});
if (response.data.status === 'OK' && response.data.result) {
const place = response.data.result;
const addressComponents = {};
// Parse address components
if (place.address_components) {
place.address_components.forEach(component => {
const types = component.types;
if (types.includes('street_number')) {
addressComponents.streetNumber = component.long_name;
} else if (types.includes('route')) {
addressComponents.route = component.long_name;
} else if (types.includes('locality')) {
addressComponents.locality = component.long_name;
} else if (types.includes('administrative_area_level_1')) {
addressComponents.administrativeAreaLevel1 = component.short_name;
addressComponents.administrativeAreaLevel1Long = component.long_name;
} else if (types.includes('postal_code')) {
addressComponents.postalCode = component.long_name;
} else if (types.includes('country')) {
addressComponents.country = component.short_name;
}
});
}
return {
placeId: place.place_id,
formattedAddress: place.formatted_address,
addressComponents,
geometry: {
latitude: place.geometry?.location?.lat || 0,
longitude: place.geometry?.location?.lng || 0
}
};
} else {
console.error('Place Details API error:', response.data.status, response.data.error_message);
throw new Error(this.getErrorMessage(response.data.status));
}
} catch (error) {
console.error('Place Details service error:', error.message);
throw error;
}
}
/**
* Geocode an address to get coordinates
*/
async geocodeAddress(address, options = {}) {
if (!this.apiKey) {
throw new Error('Google Maps API key not configured');
}
if (!address || !address.trim()) {
throw new Error('Address is required for geocoding');
}
try {
const params = {
key: this.apiKey,
address: address.trim(),
language: options.language || 'en'
};
// Add component restrictions (e.g., country)
if (options.componentRestrictions) {
params.components = Object.entries(options.componentRestrictions)
.map(([key, value]) => `${key}:${value}`)
.join('|');
}
// Add bounds if provided
if (options.bounds) {
params.bounds = options.bounds;
}
const response = await this.client.geocode({
params,
timeout: 5000
});
if (response.data.status === 'OK' && response.data.results.length > 0) {
const result = response.data.results[0];
return {
latitude: result.geometry.location.lat,
longitude: result.geometry.location.lng,
formattedAddress: result.formatted_address,
placeId: result.place_id
};
} else {
console.error('Geocoding API error:', response.data.status, response.data.error_message);
return {
error: this.getErrorMessage(response.data.status),
status: response.data.status
};
}
} catch (error) {
console.error('Geocoding service error:', error.message);
throw new Error('Failed to geocode address');
}
}
/**
* Get human-readable error message for Google Maps API status codes
*/
getErrorMessage(status) {
const errorMessages = {
'ZERO_RESULTS': 'No results found for this query',
'OVER_QUERY_LIMIT': 'API quota exceeded. Please try again later',
'REQUEST_DENIED': 'API request denied. Check API key configuration',
'INVALID_REQUEST': 'Invalid request parameters',
'UNKNOWN_ERROR': 'Server error. Please try again',
'NOT_FOUND': 'The specified place was not found'
};
return errorMessages[status] || `Google Maps API error: ${status}`;
}
/**
* Check if the service is properly configured
*/
isConfigured() {
return !!this.apiKey;
}
}
// Export singleton instance
module.exports = new GoogleMapsService();