diff --git a/.env.template b/.env.template index 3ec0cbce9..42211b184 100644 --- a/.env.template +++ b/.env.template @@ -6,4 +6,5 @@ GRAPHQL_URI=http://localhost:4000 CLIENT_URI=http://localhost:3000 MOCK=false -JWT_SECRET=b/&&7b78BF&fv/Vd +JWT_SECRET="b/&&7b78BF&fv/Vd" +MAPBOX_TOKEN="pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ" diff --git a/Dockerfile b/Dockerfile index ed6c274ce..750d284dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ CMD ["yarn", "run", "start"] FROM base as builder RUN yarn install --frozen-lockfile --non-interactive COPY . . +RUN cp .env.template .env RUN yarn run build # reduce image size with a multistage build diff --git a/docker-compose.yml b/docker-compose.yml index 459496173..5a7650aa1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,8 +17,9 @@ services: - GRAPHQL_PORT=4000 - GRAPHQL_URI=http://localhost:4000 - CLIENT_URI=http://localhost:3000 - - JWT_SECRET=b/&&7b78BF&fv/Vd + - JWT_SECRET="b/&&7b78BF&fv/Vd" - MOCK=false + - MAPBOX_TOKEN=pk.eyJ1IjoiaHVtYW4tY29ubmVjdGlvbiIsImEiOiJjajl0cnBubGoweTVlM3VwZ2lzNTNud3ZtIn0.KZ8KK9l70omjXbEkkbHGsQ neo4j: image: humanconnection/neo4j:latest diff --git a/src/graphql-schema.js b/src/graphql-schema.js index 95a62cb99..cbeb6bb55 100644 --- a/src/graphql-schema.js +++ b/src/graphql-schema.js @@ -98,7 +98,9 @@ export const resolvers = { const session = driver.session() return session.run( 'MATCH (user:User {email: $userEmail}) ' + - 'RETURN user {.id, .slug, .name, .avatar, .email, .password, .role} as user LIMIT 1', { userEmail: email }) + 'RETURN user {.id, .slug, .name, .avatar, .locationName, .about, .email, .password, .role} as user LIMIT 1', { + userEmail: email + }) .then(async (result) => { session.close() const [currentUser] = await result.records.map(function (record) { diff --git a/src/helpers/asyncForEach.js b/src/helpers/asyncForEach.js new file mode 100644 index 000000000..1f05ea915 --- /dev/null +++ b/src/helpers/asyncForEach.js @@ -0,0 +1,14 @@ +/** + * Provide a way to iterate for each element in an array while waiting for async functions to finish + * + * @param array + * @param callback + * @returns {Promise} + */ +async function asyncForEach (array, callback) { + for (let index = 0; index < array.length; index++) { + await callback(array[index], index, array) + } +} + +export default asyncForEach diff --git a/src/middleware/helpers/walkRecursive.js b/src/helpers/walkRecursive.js similarity index 100% rename from src/middleware/helpers/walkRecursive.js rename to src/helpers/walkRecursive.js diff --git a/src/middleware/dateTimeMiddleware.js b/src/middleware/dateTimeMiddleware.js index b0ab1e539..97e6e2767 100644 --- a/src/middleware/dateTimeMiddleware.js +++ b/src/middleware/dateTimeMiddleware.js @@ -1,5 +1,3 @@ -import format from 'date-fns/format' - export default { Mutation: { CreateUser: async (resolve, root, args, context, info) => { @@ -31,22 +29,22 @@ export default { return result }, UpdateUser: async (resolve, root, args, context, info) => { - args.updatedAt = format(new Date()) + args.updatedAt = (new Date()).toISOString() const result = await resolve(root, args, context, info) return result }, UpdatePost: async (resolve, root, args, context, info) => { - args.updatedAt = format(new Date()) + args.updatedAt = (new Date()).toISOString() const result = await resolve(root, args, context, info) return result }, UpdateComment: async (resolve, root, args, context, info) => { - args.updatedAt = format(new Date()) + args.updatedAt = (new Date()).toISOString() const result = await resolve(root, args, context, info) return result }, UpdateOrganization: async (resolve, root, args, context, info) => { - args.updatedAt = format(new Date()) + args.updatedAt = (new Date()).toISOString() const result = await resolve(root, args, context, info) return result } diff --git a/src/middleware/index.js b/src/middleware/index.js index 56a0b5673..bd95f0e93 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -6,6 +6,7 @@ import excerptMiddleware from './excerptMiddleware' import dateTimeMiddleware from './dateTimeMiddleware' import xssMiddleware from './xssMiddleware' import permissionsMiddleware from './permissionsMiddleware' +import userMiddleware from './userMiddleware' export default schema => { let middleware = [ @@ -15,7 +16,8 @@ export default schema => { excerptMiddleware, xssMiddleware, fixImageUrlsMiddleware, - softDeleteMiddleware + softDeleteMiddleware, + userMiddleware ] // add permisions middleware at the first position (unless we're seeding) diff --git a/src/middleware/nodes/locations.js b/src/middleware/nodes/locations.js new file mode 100644 index 000000000..735b047dd --- /dev/null +++ b/src/middleware/nodes/locations.js @@ -0,0 +1,124 @@ + +import request from 'request' +import { UserInputError } from 'apollo-server' +import isEmpty from 'lodash/isEmpty' +import asyncForEach from '../../helpers/asyncForEach' + +const fetch = url => { + return new Promise((resolve, reject) => { + request(url, function (error, response, body) { + if (error) { + reject(error) + } else { + resolve(JSON.parse(body)) + } + }) + }) +} + +const locales = [ + 'en', + 'de', + 'fr', + 'nl', + 'it', + 'es', + 'pt', + 'pl' +] + +const createLocation = async (session, mapboxData) => { + const data = { + id: mapboxData.id, + nameEN: mapboxData.text_en, + nameDE: mapboxData.text_de, + nameFR: mapboxData.text_fr, + nameNL: mapboxData.text_nl, + nameIT: mapboxData.text_it, + nameES: mapboxData.text_es, + namePT: mapboxData.text_pt, + namePL: mapboxData.text_pl, + type: mapboxData.id.split('.')[0].toLowerCase(), + lat: (mapboxData.center && mapboxData.center.length) ? mapboxData.center[0] : null, + lng: (mapboxData.center && mapboxData.center.length) ? mapboxData.center[1] : null + } + + let query = 'MERGE (l:Location {id: $id}) ' + + 'SET l.name = $nameEN, ' + + 'l.nameEN = $nameEN, ' + + 'l.nameDE = $nameDE, ' + + 'l.nameFR = $nameFR, ' + + 'l.nameNL = $nameNL, ' + + 'l.nameIT = $nameIT, ' + + 'l.nameES = $nameES, ' + + 'l.namePT = $namePT, ' + + 'l.namePL = $namePL, ' + + 'l.type = $type' + + if (data.lat && data.lng) { + query += ', l.lat = $lat, l.lng = $lng' + } + query += ' RETURN l.id' + + await session.run(query, data) +} + +const createOrUpdateLocations = async (userId, locationName, driver) => { + if (isEmpty(locationName)) { + return + } + const mapboxToken = process.env.MAPBOX_TOKEN + const res = await fetch(`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(locationName)}.json?access_token=${mapboxToken}&types=region,place,country&language=${locales.join(',')}`) + + if (!res || !res.features || !res.features[0]) { + throw new UserInputError('locationName is invalid') + } + + let data + + res.features.forEach(item => { + if (item.matching_place_name === locationName) { + data = item + } + }) + if (!data) { + data = res.features[0] + } + + if (!data || !data.place_type || !data.place_type.length) { + throw new UserInputError('locationName is invalid') + } + + const session = driver.session() + await createLocation(session, data) + + let parent = data + + if (data.context) { + await asyncForEach(data.context, async ctx => { + await createLocation(session, ctx) + + await session.run( + 'MATCH (parent:Location {id: $parentId}), (child:Location {id: $childId}) ' + + 'MERGE (child)<-[:IS_IN]-(parent) ' + + 'RETURN child.id, parent.id', { + parentId: parent.id, + childId: ctx.id + }) + + parent = ctx + }) + } + // delete all current locations from user + await session.run('MATCH (u:User {id: $userId})-[r:IS_IN]->(l:Location) DETACH DELETE r', { + userId: userId + }) + // connect user with location + await session.run('MATCH (u:User {id: $userId}), (l:Location {id: $locationId}) MERGE (u)-[:IS_IN]->(l) RETURN l.id, u.id', { + userId: userId, + locationId: data.id + }) + session.close() +} + +export default createOrUpdateLocations diff --git a/src/middleware/passwordMiddleware.js b/src/middleware/passwordMiddleware.js index 4f551fe5d..16480b126 100644 --- a/src/middleware/passwordMiddleware.js +++ b/src/middleware/passwordMiddleware.js @@ -1,5 +1,5 @@ import bcrypt from 'bcryptjs' -import walkRecursive from './helpers/walkRecursive' +import walkRecursive from '../helpers/walkRecursive' export default { Mutation: { diff --git a/src/middleware/permissionsMiddleware.js b/src/middleware/permissionsMiddleware.js index 5b0cb87d2..6f786583e 100644 --- a/src/middleware/permissionsMiddleware.js +++ b/src/middleware/permissionsMiddleware.js @@ -14,7 +14,7 @@ const isModerator = rule()(async (parent, args, ctx, info) => { }) */ -const isOwner = rule()(async (parent, args, ctx, info) => { +const isOwner = rule({ cache: 'no_cache' })(async (parent, args, ctx, info) => { return ctx.user.id === parent.id }) diff --git a/src/middleware/userMiddleware.js b/src/middleware/userMiddleware.js new file mode 100644 index 000000000..55b181bc9 --- /dev/null +++ b/src/middleware/userMiddleware.js @@ -0,0 +1,16 @@ +import createOrUpdateLocations from './nodes/locations' + +export default { + Mutation: { + CreateUser: async (resolve, root, args, context, info) => { + const result = await resolve(root, args, context, info) + await createOrUpdateLocations(args.id, args.locationName, context.driver) + return result + }, + UpdateUser: async (resolve, root, args, context, info) => { + const result = await resolve(root, args, context, info) + await createOrUpdateLocations(args.id, args.locationName, context.driver) + return result + } + } +} diff --git a/src/middleware/xssMiddleware.js b/src/middleware/xssMiddleware.js index ac71f3421..59ce8800f 100644 --- a/src/middleware/xssMiddleware.js +++ b/src/middleware/xssMiddleware.js @@ -1,4 +1,4 @@ -import walkRecursive from './helpers/walkRecursive' +import walkRecursive from '../helpers/walkRecursive' // import { getByDot, setByDot, getItems, replaceItems } from 'feathers-hooks-common' import sanitizeHtml from 'sanitize-html' // import { isEmpty, intersection } from 'lodash' diff --git a/src/nodes/users/userMiddleware.js b/src/nodes/users/userMiddleware.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/schema.graphql b/src/schema.graphql index bc6a3829c..3031ac0fe 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -13,6 +13,8 @@ type LoggedInUser { avatar:String! email: String! role: String! + locationName: String + about: String token: String! } @@ -44,6 +46,23 @@ enum UserGroupEnum { user } +type Location { + id: ID! + name: String! + nameEN: String + nameDE: String + nameFR: String + nameNL: String + nameIT: String + nameES: String + namePT: String + namePL: String + type: String! + lat: Float + lng: Float + parent: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") +} + type User { id: ID! name: String @@ -54,6 +73,11 @@ type User { deleted: Boolean disabled: Boolean role: UserGroupEnum + + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") + locationName: String + about: String + createdAt: String updatedAt: String diff --git a/src/seed/data/index.js b/src/seed/data/index.js index e50fececc..33bc56f36 100644 --- a/src/seed/data/index.js +++ b/src/seed/data/index.js @@ -1,5 +1,5 @@ import gql from 'graphql-tag' -import helper from '../seed-helpers' +import asyncForEach from '../../helpers/asyncForEach' const seed = { Badge: require('./badges.js').default, @@ -22,7 +22,7 @@ let data = {} export default async function (client) { // iterate through seeds - await helper.asyncForEach(Object.keys(seed), async key => { + await asyncForEach(Object.keys(seed), async key => { const mutations = seed[key] try { const res = await client diff --git a/src/seed/data/users.js b/src/seed/data/users.js index 2c148bc0c..8106a75f4 100644 --- a/src/seed/data/users.js +++ b/src/seed/data/users.js @@ -9,6 +9,7 @@ export default function (data) { password: "1234", email: "admin@example.org", avatar: "${faker.internet.avatar()}", + locationName: "Hamburg, Germany", role: admin, disabled: false, deleted: false) { @@ -24,6 +25,7 @@ export default function (data) { password: "1234", email: "moderator@example.org", avatar: "${faker.internet.avatar()}", + locationName: "USA", role: moderator, disabled: false, deleted: false) { diff --git a/src/seed/seed-helpers.js b/src/seed/seed-helpers.js index 48b9ba01d..23bde40ae 100644 --- a/src/seed/seed-helpers.js +++ b/src/seed/seed-helpers.js @@ -46,7 +46,7 @@ export default { let randomIds = _.shuffle(ids) return items[randomIds.pop()] }, - randomItems: (items, key = '_id', min = 1, max = 1) => { + randomItems: (items, key = 'id', min = 1, max = 1) => { let randomIds = _.shuffle(_.keys(items)) let res = [] @@ -54,7 +54,7 @@ export default { for (let i = 0; i < count; i++) { let r = items[randomIds.pop()][key] - if (key === '_id') { + if (key === 'id') { r = r.toString() } res.push(r) @@ -117,22 +117,10 @@ export default { mapIdsByKey: (items, values, key) => { let res = [] values.forEach(value => { - res.push(_.find(items, [key, value])._id.toString()) + res.push(_.find(items, [key, value]).id.toString()) }) return res }, - /** - * Provide a way to iterate for each element in an array while waiting for async functions to finish - * - * @param array - * @param callback - * @returns {Promise} - */ - asyncForEach: async (array, callback) => { - for (let index = 0; index < array.length; index++) { - await callback(array[index], index, array) - } - }, genInviteCode: () => { const chars = '23456789abcdefghkmnpqrstuvwxyzABCDEFGHJKLMNPRSTUVWXYZ' let code = '' diff --git a/src/server.js b/src/server.js index f10696f16..76e8419b7 100644 --- a/src/server.js +++ b/src/server.js @@ -14,6 +14,13 @@ import jwtStrategy from './jwt/strategy' import jwt from 'jsonwebtoken' dotenv.config() +// check env and warn +const requiredEnvVars = ['MAPBOX_TOKEN', 'JWT_SECRET'] +requiredEnvVars.forEach(env => { + if (!process.env[env]) { + throw new Error(`ERROR: "${env}" env variable is missing.`) + } +}) let schema = makeExecutableSchema({ typeDefs, @@ -52,6 +59,7 @@ const createServer = (options) => { return payload }, schema: schema, + debug: debug, tracing: debug, middlewares: middleware(schema), mocks: (process.env.MOCK === 'true') ? mocks : false