diff --git a/backend/.env.example b/backend/.env.example index fb6ae87..d4ad23a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,3 +5,5 @@ DB_DATABASE=auth_db DB_HOST=localhost JWT_SECRET=«generate_strong_secret_here» JWT_EXPIRES_IN=604800 + +GOOGLE_API_KEY= diff --git a/backend/config/index.js b/backend/config/index.js index eeb848e..7747667 100644 --- a/backend/config/index.js +++ b/backend/config/index.js @@ -11,4 +11,7 @@ module.exports = { secret: process.env.JWT_SECRET, expiresIn: process.env.JWT_EXPIRES_IN, }, + apiKeys: { + google: process.env.GOOGLE_API_KEY, + }, }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 7db4a3f..44b01d0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -4,6 +4,26 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@googlemaps/google-maps-services-js": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@googlemaps/google-maps-services-js/-/google-maps-services-js-3.3.4.tgz", + "integrity": "sha512-EhICEyVklA87+lqOjlAwGkkVpwXWKgYX93wGWtWUFHizKcAALKLwx2pDb/0bJy/t/6QCFbOokEuStkwljf2xQA==", + "requires": { + "@googlemaps/url-signature": "^1.0.4", + "agentkeepalive": "^4.1.0", + "axios": "^0.24.0", + "query-string": "^7.0.1", + "retry-axios": "^2.2.1" + } + }, + "@googlemaps/url-signature": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@googlemaps/url-signature/-/url-signature-1.0.8.tgz", + "integrity": "sha512-DJL0o1voIx+VntZC9PFZuho3M0PDy44tjpGxFjf2BuOyvd8FKknyN8OGuHf5GQp8rKZzUU0Ng+V8/m5h9wYW/A==", + "requires": { + "crypto-js": "^4.1.1" + } + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", @@ -44,6 +64,31 @@ "negotiator": "0.6.2" } }, + "agentkeepalive": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.1.4.tgz", + "integrity": "sha512-+V/rGa3EuU74H6wR04plBb7Ks10FbtUQgRj/FQOG7uUIEuaINI+AiqJR1k6t3SVNs7o7ZjIdus6706qqzVq8jQ==", + "requires": { + "debug": "^4.1.0", + "depd": "^1.1.2", + "humanize-ms": "^1.2.1" + }, + "dependencies": { + "debug": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", + "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -126,6 +171,14 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, + "axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "requires": { + "follow-redirects": "^1.14.4" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -576,6 +629,11 @@ "which": "^2.0.1" } }, + "crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -644,6 +702,11 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" + }, "decompress-response": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", @@ -921,6 +984,11 @@ "to-regex-range": "^5.0.1" } }, + "filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs=" + }, "finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -943,6 +1011,11 @@ "locate-path": "^3.0.0" } }, + "follow-redirects": { + "version": "1.14.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.6.tgz", + "integrity": "sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==" + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1112,6 +1185,14 @@ } } }, + "humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=", + "requires": { + "ms": "^2.0.0" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1879,6 +1960,17 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, + "query-string": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.1.tgz", + "integrity": "sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==", + "requires": { + "decode-uri-component": "^0.2.0", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } + }, "random-bytes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", @@ -2004,6 +2096,11 @@ "any-promise": "^1.3.0" } }, + "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==" + }, "rndm": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", @@ -2199,6 +2296,11 @@ "integrity": "sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==", "dev": true }, + "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==" + }, "split2": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", @@ -2217,6 +2319,11 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" }, + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" + }, "string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index ed7123f..abb8b5b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "author": "", "license": "ISC", "dependencies": { + "@googlemaps/google-maps-services-js": "^3.3.4", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", "cors": "^2.8.5", diff --git a/backend/routes/api/business.js b/backend/routes/api/business.js index 09832fe..77692da 100644 --- a/backend/routes/api/business.js +++ b/backend/routes/api/business.js @@ -1,10 +1,14 @@ const asyncHandler = require("express-async-handler"); const createHttpError = require("http-errors"); const express = require("express"); -const { check, query } = require("express-validator"); +const { Client } = require("@googlemaps/google-maps-services-js"); +const { check, query, validationResult } = require("express-validator"); const { Op } = require("sequelize"); const { Business, Review, User } = require("../../db/models"); +const { + apiKeys: { google: googleApiKey }, +} = require("../../config"); const { handleValidationErrors, sanitizePaginationQuery, @@ -83,9 +87,44 @@ router.get( }) ); +const client = new Client(); +const addressToLatLong = asyncHandler(async (req) => { + // If the lat and long are passed in the body, skip requesting the information from Google + if (req.body.lat && req.body.long) { + return { lat: req.body.lat, long: req.body.long }; + } + + const validationErrors = validationResult(req); + + if (!validationErrors.isEmpty()) { + return; + } + + const { address, city, state, zipCode } = req.body; + + const geocodeResult = await client.geocode({ + params: { + address: `${address}, ${city}, ${state} ${zipCode}`, + key: googleApiKey, + }, + }); + + if ( + !geocodeResult.data || + geocodeResult.data.status !== "OK" || + !geocodeResult.data.results || + geocodeResult.data.results.length < 1 + ) { + return; + } + + return { + lat: geocodeResult.data.results[0].geometry.location.lat, + long: geocodeResult.data.results[0].geometry.location.lng, + }; +}); + const validateBusiness = [ - check("name").trim().exists({ checkFalsy: true }).withMessage("Enter a name"), - check("description").trim().optional({ checkFalsy: true }), check("address") .trim() .exists({ checkFalsy: true }) @@ -99,6 +138,29 @@ const validateBusiness = [ .trim() .exists({ checkFalsy: true }) .withMessage("Enter a zip code"), + check("address").custom(async (_, { req }) => { + try { + const latLong = await addressToLatLong(req); + + if (!latLong) { + throw new Error("Failed to find this address. Try again."); + } + + req.body.lat = latLong.lat; + req.body.long = latLong.long; + return true; + } catch { + throw new Error("Failed to find this address. Try again."); + } + }), + + check("name").trim().exists({ checkFalsy: true }).withMessage("Enter a name"), + check("description").trim().optional({ checkFalsy: true }), + check("displayImage") + .trim() + .optional({ checkFalsy: true }) + .isURL() + .withMessage("Enter a valid image url"), check("lat") .exists({ checkFalsy: true }) .withMessage("Enter a latitude") @@ -109,11 +171,6 @@ const validateBusiness = [ .withMessage("Enter a longitude") .isDecimal() .withMessage("Enter a valid longitude"), - check("displayImage") - .trim() - .optional({ checkFalsy: true }) - .isURL() - .withMessage("Enter a valid image url"), handleValidationErrors, ]; diff --git a/frontend/src/components/business/BusinessEditor.js b/frontend/src/components/business/BusinessEditor.js index 6480095..84ad55a 100644 --- a/frontend/src/components/business/BusinessEditor.js +++ b/frontend/src/components/business/BusinessEditor.js @@ -45,8 +45,6 @@ const BusinessEditor = ({ addNew }) => { const [city, setCity] = useState(businessData.city || ""); const [state, setState] = useState(businessData.state || ""); const [zipCode, setZipCode] = useState(businessData.zipCode || ""); - const [lat, setLat] = useState(businessData.lat || ""); - const [long, setLong] = useState(businessData.long || ""); const [displayImage, setDisplayImage] = useState( businessData.displayImage || "" ); @@ -73,8 +71,6 @@ const BusinessEditor = ({ addNew }) => { city, state, zipCode, - lat, - long, displayImage, }; @@ -217,38 +213,6 @@ const BusinessEditor = ({ addNew }) => { required /> - - setLat(e.target.value)} - inputProps={{ - type: "number", - step: "any", - }} - error={!!errors.lat} - helperText={errors.lat} - required - /> - - - setLong(e.target.value)} - inputProps={{ - type: "number", - step: "any", - }} - error={!!errors.long} - helperText={errors.long} - required - /> -