diff --git a/backend/src/db/graphql/groups.js b/backend/src/db/graphql/groups.js index bd6f5e721..27b57de3a 100644 --- a/backend/src/db/graphql/groups.js +++ b/backend/src/db/graphql/groups.js @@ -12,7 +12,7 @@ export const createGroupMutation = gql` $groupType: GroupType! $actionRadius: GroupActionRadius! $categoryIds: [ID] - $locationName: String + $locationName: String # empty string '' sets it to null ) { CreateGroup( id: $id @@ -63,7 +63,7 @@ export const updateGroupMutation = gql` $actionRadius: GroupActionRadius $categoryIds: [ID] $avatar: ImageInput - $locationName: String + $locationName: String # empty string '' sets it to null ) { UpdateGroup( id: $id diff --git a/backend/src/schema/resolvers/groups.js b/backend/src/schema/resolvers/groups.js index 887d84ef8..4b3d645eb 100644 --- a/backend/src/schema/resolvers/groups.js +++ b/backend/src/schema/resolvers/groups.js @@ -149,9 +149,11 @@ export default { }, UpdateGroup: async (_parent, params, context, _resolveInfo) => { const { categoryIds } = params - const { id: groupId, avatar: avatarInput } = params delete params.categoryIds + const { id: groupId, avatar: avatarInput } = params delete params.avatar + params.locationName = params.locationName === '' ? null : params.locationName + if (CONFIG.CATEGORIES_ACTIVE && categoryIds) { if (categoryIds.length < CATEGORIES_MIN) { throw new UserInputError('Too view categories!') @@ -210,7 +212,10 @@ export default { }) try { const group = await writeTxResultPromise - await createOrUpdateLocations('Group', params.id, params.locationName, session) + // TODO: put in a middleware, see "UpdateUser" + if (params.locationName !== undefined) { + await createOrUpdateLocations('Group', params.id, params.locationName, session) + } return group } catch (error) { if (error.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') diff --git a/backend/src/schema/resolvers/groups.spec.js b/backend/src/schema/resolvers/groups.spec.js index 3d94ffd93..2911a8b06 100644 --- a/backend/src/schema/resolvers/groups.spec.js +++ b/backend/src/schema/resolvers/groups.spec.js @@ -553,7 +553,7 @@ describe('in mode', () => { describe('query groups', () => { describe('in general finds only listed groups – no hidden groups where user is none or pending member', () => { describe('without any filters', () => { - it('finds all listed groups', async () => { + it('finds all listed groups – including the set locations', async () => { const result = await query({ query: groupQuery, variables: {} }) expect(result).toMatchObject({ data: { @@ -572,12 +572,16 @@ describe('in mode', () => { expect.objectContaining({ id: 'others-group', slug: 'uninteresting-group', + locationName: null, + location: null, myRole: null, }), expect.objectContaining({ id: 'third-hidden-group', slug: 'third-investigative-journalism-group', myRole: 'usual', + locationName: null, + location: null, }), ]), }, @@ -2617,21 +2621,32 @@ describe('in mode', () => { }) describe('authenticated', () => { - let otherUser + let noMemberUser beforeAll(async () => { - otherUser = await Factory.build( + noMemberUser = await Factory.build( 'user', { - id: 'other-user', - name: 'Other TestUser', + id: 'none-member-user', + name: 'None Member TestUser', }, { - email: 'test2@example.org', + email: 'none-member-user@example.org', password: '1234', }, ) - authenticatedUser = await otherUser.toJson() + usualMemberUser = await Factory.build( + 'user', + { + id: 'usual-member-user', + name: 'Usual Member TestUser', + }, + { + email: 'usual-member-user@example.org', + password: '1234', + }, + ) + authenticatedUser = await noMemberUser.toJson() await mutate({ mutation: createGroupMutation, variables: { @@ -2655,7 +2670,15 @@ describe('in mode', () => { groupType: 'public', actionRadius: 'regional', categoryIds, - locationName: 'Hamburg, Germany', + locationName: 'Berlin, Germany', + }, + }) + await mutate({ + mutation: changeGroupMemberRoleMutation, + variables: { + groupId: 'my-group', + userId: 'usual-member-user', + roleInGroup: 'usual', }, }) }) @@ -2666,40 +2689,190 @@ describe('in mode', () => { authenticatedUser = await user.toJson() }) - it('has set the settings', async () => { - await expect( - mutate({ - mutation: updateGroupMutation, - variables: { - id: 'my-group', - name: 'The New Group For Our Country', - about: 'We will change the land!', - description: 'Some country relevant description' + descriptionAdditional100, - actionRadius: 'national', - // avatar, // test this as result - locationName: 'Berlin, Germany', + describe('all standard settings – excluding location', () => { + it('has updated the settings', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + name: 'The New Group For Our Country', + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + // avatar, // test this as result + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + name: 'The New Group For Our Country', + slug: 'the-new-group-for-our-country', // changing the slug is tested in the slugifyMiddleware + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + // avatar, // test this as result + myRole: 'owner', + }, }, - }), - ).resolves.toMatchObject({ - data: { - UpdateGroup: { - id: 'my-group', - name: 'The New Group For Our Country', - slug: 'the-new-group-for-our-country', // changing the slug is tested in the slugifyMiddleware - about: 'We will change the land!', - description: 'Some country relevant description' + descriptionAdditional100, - actionRadius: 'national', - // avatar, // test this as result - locationName: 'Berlin, Germany', - location: expect.objectContaining({ - name: 'Berlin', - nameDE: 'Berlin', - nameEN: 'Berlin', + errors: undefined, + }) + }) + }) + + describe('location', () => { + describe('"locationName" is undefined – shall not change location', () => { + it('has left locaton unchanged as "Berlin"', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + }, }), - myRole: 'owner', - }, - }, - errors: undefined, + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + locationName: 'Berlin, Germany', + location: expect.objectContaining({ + name: 'Berlin', + nameDE: 'Berlin', + nameEN: 'Berlin', + }), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('"locationName" is null – shall change location "Berlin" to unset location', () => { + it('has updated the location to unset location', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + // avatar, // test this as result + locationName: null, + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + locationName: null, + location: null, + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('change unset location to "Paris"', () => { + it('has updated the location to "Paris"', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + // avatar, // test this as result + locationName: 'Paris, France', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + name: 'The New Group For Our Country', + slug: 'the-new-group-for-our-country', // changing the slug is tested in the slugifyMiddleware + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + // avatar, // test this as result + locationName: 'Paris, France', + location: expect.objectContaining({ + name: 'Paris', + nameDE: 'Paris', + nameEN: 'Paris', + }), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('change location "Paris" to "Hamburg"', () => { + it('has updated the location to "Hamburg"', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + // avatar, // test this as result + locationName: 'Hamburg, Germany', + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + name: 'The New Group For Our Country', + slug: 'the-new-group-for-our-country', // changing the slug is tested in the slugifyMiddleware + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + // avatar, // test this as result + locationName: 'Hamburg, Germany', + location: expect.objectContaining({ + name: 'Hamburg', + nameDE: 'Hamburg', + nameEN: 'Hamburg', + }), + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) + }) + + describe('"locationName" is empty string – shall change location "Hamburg" to unset location ', () => { + it('has updated the location to unset', async () => { + await expect( + mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + // avatar, // test this as result + locationName: '', // empty string '' sets it to null + }, + }), + ).resolves.toMatchObject({ + data: { + UpdateGroup: { + id: 'my-group', + name: 'The New Group For Our Country', + slug: 'the-new-group-for-our-country', // changing the slug is tested in the slugifyMiddleware + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + // avatar, // test this as result + locationName: null, + location: null, + myRole: 'owner', + }, + }, + errors: undefined, + }) + }) }) }) @@ -2783,9 +2956,9 @@ describe('in mode', () => { }) }) - describe('as no(!) owner', () => { + describe('as "usual-member-user" member, no(!) owner', () => { it('throws authorization error', async () => { - authenticatedUser = await otherUser.toJson() + authenticatedUser = await usualMemberUser.toJson() const { errors } = await mutate({ mutation: updateGroupMutation, variables: { @@ -2794,9 +2967,25 @@ describe('in mode', () => { about: 'We will change the land!', description: 'Some country relevant description' + descriptionAdditional100, actionRadius: 'national', - categoryIds: ['cat4', 'cat27'], // test this as result - // avatar, // test this as result - // locationName, // test this as result + categoryIds: ['cat4', 'cat27'], + }, + }) + expect(errors[0]).toHaveProperty('message', 'Not Authorized!') + }) + }) + + describe('as "none-member-user"', () => { + it('throws authorization error', async () => { + authenticatedUser = await noMemberUser.toJson() + const { errors } = await mutate({ + mutation: updateGroupMutation, + variables: { + id: 'my-group', + name: 'The New Group For Our Country', + about: 'We will change the land!', + description: 'Some country relevant description' + descriptionAdditional100, + actionRadius: 'national', + categoryIds: ['cat4', 'cat27'], }, }) expect(errors[0]).toHaveProperty('message', 'Not Authorized!') diff --git a/backend/src/schema/resolvers/users.js b/backend/src/schema/resolvers/users.js index 52889a1c3..f03f0a7ca 100644 --- a/backend/src/schema/resolvers/users.js +++ b/backend/src/schema/resolvers/users.js @@ -139,9 +139,10 @@ export default { return blockedUser.toJson() }, UpdateUser: async (_parent, params, context, _resolveInfo) => { - const { termsAndConditionsAgreedVersion } = params const { avatar: avatarInput } = params delete params.avatar + params.locationName = params.locationName === '' ? null : params.locationName + const { termsAndConditionsAgreedVersion } = params if (termsAndConditionsAgreedVersion) { const regEx = new RegExp(/^[0-9]+\.[0-9]+\.[0-9]+$/g) if (!regEx.test(termsAndConditionsAgreedVersion)) { @@ -169,7 +170,10 @@ export default { }) try { const user = await writeTxResultPromise - await createOrUpdateLocations('User', params.id, params.locationName, session) + // TODO: put in a middleware, see "CreateGroup, UpdateGroup" + if (params.locationName !== undefined) { + await createOrUpdateLocations('User', params.id, params.locationName, session) + } return user } catch (error) { throw new UserInputError(error.message) diff --git a/backend/src/schema/resolvers/users.spec.js b/backend/src/schema/resolvers/users.spec.js index d8fce3b29..b31477664 100644 --- a/backend/src/schema/resolvers/users.spec.js +++ b/backend/src/schema/resolvers/users.spec.js @@ -161,7 +161,7 @@ describe('UpdateUser', () => { $id: ID! $name: String $termsAndConditionsAgreedVersion: String - $locationName: String + $locationName: String # empty string '' sets it to null ) { UpdateUser( id: $id diff --git a/backend/src/schema/resolvers/users/location.js b/backend/src/schema/resolvers/users/location.js index cd042b964..25b813f53 100644 --- a/backend/src/schema/resolvers/users/location.js +++ b/backend/src/schema/resolvers/users/location.js @@ -1,6 +1,5 @@ import request from 'request' import { UserInputError } from 'apollo-server' -import isEmpty from 'lodash/isEmpty' import Debug from 'debug' import asyncForEach from '../../../helpers/asyncForEach' import CONFIG from '../../../config' @@ -63,64 +62,70 @@ const createLocation = async (session, mapboxData) => { } export const createOrUpdateLocations = async (nodeLabel, nodeId, locationName, session) => { - if (isEmpty(locationName)) { - return - } - const res = await fetch( - `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent( - locationName, - )}.json?access_token=${CONFIG.MAPBOX_TOKEN}&types=region,place,country&language=${locales.join( - ',', - )}`, - ) + let locationId - debug(res) + if (locationName !== null) { + const res = await fetch( + `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent( + locationName, + )}.json?access_token=${ + CONFIG.MAPBOX_TOKEN + }&types=region,place,country&language=${locales.join(',')}`, + ) - if (!res || !res.features || !res.features[0]) { - throw new UserInputError('locationName is invalid') - } + debug(res) - let data - - res.features.forEach((item) => { - if (item.matching_place_name === locationName) { - data = item + if (!res || !res.features || !res.features[0]) { + throw new UserInputError('locationName is invalid') } - }) - if (!data) { - data = res.features[0] - } - if (!data || !data.place_type || !data.place_type.length) { - throw new UserInputError('locationName is invalid') - } + let data - if (data.place_type.length > 1) { - data.id = 'region.' + data.id.split('.')[1] - } - await createLocation(session, data) - - let parent = data - - if (data.context) { - await asyncForEach(data.context, async (ctx) => { - await createLocation(session, ctx) - await session.writeTransaction((transaction) => { - return transaction.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 + 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') + } + + if (data.place_type.length > 1) { + data.id = 'region.' + data.id.split('.')[1] + } + await createLocation(session, data) + + let parent = data + + if (data.context) { + await asyncForEach(data.context, async (ctx) => { + await createLocation(session, ctx) + await session.writeTransaction((transaction) => { + return transaction.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 + }) + } + + locationId = data.id + } else { + locationId = 'non-existent-id' } + // delete all current locations from node and add new location await session.writeTransaction((transaction) => { return transaction.run( @@ -133,7 +138,7 @@ export const createOrUpdateLocations = async (nodeLabel, nodeId, locationName, s MERGE (node)-[:IS_IN]->(location) RETURN location.id, node.id `, - { nodeId, locationId: data.id }, + { nodeId, locationId }, ) }) } diff --git a/backend/src/schema/types/type/Group.gql b/backend/src/schema/types/type/Group.gql index 084145d1c..9f9b18da5 100644 --- a/backend/src/schema/types/type/Group.gql +++ b/backend/src/schema/types/type/Group.gql @@ -33,8 +33,8 @@ type Group { groupType: GroupType! actionRadius: GroupActionRadius! - location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") locationName: String + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") categories: [Category] @relation(name: "CATEGORIZED", direction: "OUT") @@ -95,7 +95,7 @@ type Mutation { actionRadius: GroupActionRadius! categoryIds: [ID] # avatar: ImageInput # a group can not be created with an avatar - locationName: String # test this as result + locationName: String # empty string '' sets it to null ): Group UpdateGroup( @@ -108,7 +108,7 @@ type Mutation { actionRadius: GroupActionRadius categoryIds: [ID] avatar: ImageInput # test this as result - locationName: String # test this as result + locationName: String # empty string '' sets it to null ): Group # DeleteGroup(id: ID!): Group diff --git a/backend/src/schema/types/type/User.gql b/backend/src/schema/types/type/User.gql index 2861b0fda..fdab73d17 100644 --- a/backend/src/schema/types/type/User.gql +++ b/backend/src/schema/types/type/User.gql @@ -33,8 +33,8 @@ type User { invitedBy: User @relation(name: "INVITED", direction: "IN") invited: [User] @relation(name: "INVITED", direction: "OUT") - location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") locationName: String + location: Location @cypher(statement: "MATCH (this)-[:IS_IN]->(l:Location) RETURN l") about: String socialMedia: [SocialMedia]! @relation(name: "OWNED_BY", direction: "IN") @@ -212,7 +212,7 @@ type Mutation { email: String slug: String avatar: ImageInput - locationName: String + locationName: String # empty string '' sets it to null about: String termsAndConditionsAgreedVersion: String termsAndConditionsAgreedAt: String diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index 053cb022f..5c29bc0b4 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -221,25 +221,25 @@ export const updateUserMutation = () => { $id: ID! $slug: String $name: String - $locationName: String $about: String $allowEmbedIframes: Boolean $showShoutsPublicly: Boolean $sendNotificationEmails: Boolean $termsAndConditionsAgreedVersion: String $avatar: ImageInput + $locationName: String # empty string '' sets it to null ) { UpdateUser( id: $id slug: $slug name: $name - locationName: $locationName about: $about allowEmbedIframes: $allowEmbedIframes showShoutsPublicly: $showShoutsPublicly sendNotificationEmails: $sendNotificationEmails termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion avatar: $avatar + locationName: $locationName ) { id slug diff --git a/webapp/graphql/groups.js b/webapp/graphql/groups.js index bc9855560..89e0f9f02 100644 --- a/webapp/graphql/groups.js +++ b/webapp/graphql/groups.js @@ -12,7 +12,7 @@ export const createGroupMutation = gql` $groupType: GroupType! $actionRadius: GroupActionRadius! $categoryIds: [ID] - $locationName: String + $locationName: String # empty string '' sets it to null ) { CreateGroup( id: $id @@ -58,7 +58,7 @@ export const updateGroupMutation = gql` $actionRadius: GroupActionRadius $categoryIds: [ID] $avatar: ImageInput - $locationName: String + $locationName: String # empty string '' sets it to null ) { UpdateGroup( id: $id