From 2cd07950c68c9e397f1d894bfb88a86c43e3afa0 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Wed, 1 Apr 2026 19:44:24 +0200 Subject: [PATCH] refactor(backend): improve map performance (#9476) --- backend/jest.config.ts | 2 +- .../groups/GroupWithLocationFilter.gql | 8 + .../queries/posts/PostWithLocationFilter.gql | 8 + .../queries/users/UserWithLocationFilter.gql | 8 + backend/src/graphql/resolvers/groups.ts | 5 +- .../resolvers/helpers/Resolver.spec.ts | 55 +++++ .../helpers/filterHasLocation.spec.ts | 176 +++++++++++++++ .../resolvers/helpers/filterHasLocation.ts | 56 +++++ .../resolvers/helpers/filterHelpers.spec.ts | 207 ++++++++++++++++++ backend/src/graphql/resolvers/posts.ts | 3 + backend/src/graphql/resolvers/users.ts | 2 + backend/src/graphql/types/type/Group.gql | 1 + backend/src/graphql/types/type/Post.gql | 1 + backend/src/graphql/types/type/User.gql | 1 + webapp/graphql/MapQuery.js | 51 +++++ webapp/pages/map.vue | 116 ++++------ 16 files changed, 627 insertions(+), 73 deletions(-) create mode 100644 backend/src/graphql/queries/groups/GroupWithLocationFilter.gql create mode 100644 backend/src/graphql/queries/posts/PostWithLocationFilter.gql create mode 100644 backend/src/graphql/queries/users/UserWithLocationFilter.gql create mode 100644 backend/src/graphql/resolvers/helpers/Resolver.spec.ts create mode 100644 backend/src/graphql/resolvers/helpers/filterHasLocation.spec.ts create mode 100644 backend/src/graphql/resolvers/helpers/filterHasLocation.ts create mode 100644 backend/src/graphql/resolvers/helpers/filterHelpers.spec.ts create mode 100644 webapp/graphql/MapQuery.js diff --git a/backend/jest.config.ts b/backend/jest.config.ts index c761bc2dc..5174bf589 100644 --- a/backend/jest.config.ts +++ b/backend/jest.config.ts @@ -27,7 +27,7 @@ export default { ], coverageThreshold: { global: { - lines: 92, + lines: 93, }, }, testMatch: ['**/src/**/?(*.)+(spec|test).ts?(x)'], diff --git a/backend/src/graphql/queries/groups/GroupWithLocationFilter.gql b/backend/src/graphql/queries/groups/GroupWithLocationFilter.gql new file mode 100644 index 000000000..ecaa04580 --- /dev/null +++ b/backend/src/graphql/queries/groups/GroupWithLocationFilter.gql @@ -0,0 +1,8 @@ +query GroupWithLocationFilter($hasLocation: Boolean) { + Group(hasLocation: $hasLocation) { + id + location { + id + } + } +} diff --git a/backend/src/graphql/queries/posts/PostWithLocationFilter.gql b/backend/src/graphql/queries/posts/PostWithLocationFilter.gql new file mode 100644 index 000000000..b3d4bccb9 --- /dev/null +++ b/backend/src/graphql/queries/posts/PostWithLocationFilter.gql @@ -0,0 +1,8 @@ +query PostWithLocationFilter($filter: _PostFilter) { + Post(filter: $filter) { + id + eventLocation { + id + } + } +} diff --git a/backend/src/graphql/queries/users/UserWithLocationFilter.gql b/backend/src/graphql/queries/users/UserWithLocationFilter.gql new file mode 100644 index 000000000..94c7c7b58 --- /dev/null +++ b/backend/src/graphql/queries/users/UserWithLocationFilter.gql @@ -0,0 +1,8 @@ +query UserWithLocationFilter($filter: _UserFilter) { + User(filter: $filter) { + id + location { + id + } + } +} diff --git a/backend/src/graphql/resolvers/groups.ts b/backend/src/graphql/resolvers/groups.ts index 344ce52d8..0eafe5142 100644 --- a/backend/src/graphql/resolvers/groups.ts +++ b/backend/src/graphql/resolvers/groups.ts @@ -23,7 +23,7 @@ import type { Context } from '@src/context' export default { Query: { Group: async (_object, params, context: Context, _resolveInfo) => { - const { isMember, id, slug, first, offset } = params + const { isMember, hasLocation, id, slug, first, offset } = params const session = context.driver.session() try { return await session.readTransaction(async (txc) => { @@ -35,10 +35,13 @@ export default { if (slug !== undefined) matchFilters.push('group.slug = $slug') const matchWhere = matchFilters.length ? `WHERE ${matchFilters.join(' AND ')}` : '' + const locationMatch = hasLocation === true ? 'MATCH (group)-[:IS_IN]->(:Location)' : '' + const transactionResponse = await txc.run( ` MATCH (group:Group) ${matchWhere} + ${locationMatch} OPTIONAL MATCH (:User {id: $userId})-[membership:MEMBER_OF]->(group) WITH group, membership ${(isMember === true && "WHERE membership IS NOT NULL AND (group.groupType IN ['public', 'closed']) OR (group.groupType = 'hidden' AND membership.role IN ['usual', 'admin', 'owner'])") || ''} diff --git a/backend/src/graphql/resolvers/helpers/Resolver.spec.ts b/backend/src/graphql/resolvers/helpers/Resolver.spec.ts new file mode 100644 index 000000000..d49826460 --- /dev/null +++ b/backend/src/graphql/resolvers/helpers/Resolver.spec.ts @@ -0,0 +1,55 @@ +import { removeUndefinedNullValuesFromObject, convertObjectToCypherMapLiteral } from './Resolver' + +describe('removeUndefinedNullValuesFromObject', () => { + it('removes undefined values', () => { + const obj = { a: 1, b: undefined, c: 'hello' } + removeUndefinedNullValuesFromObject(obj) + expect(obj).toEqual({ a: 1, c: 'hello' }) + }) + + it('removes null values', () => { + const obj = { a: 1, b: null, c: 'hello' } + removeUndefinedNullValuesFromObject(obj) + expect(obj).toEqual({ a: 1, c: 'hello' }) + }) + + it('keeps falsy but defined values', () => { + const obj = { a: 0, b: false, c: '' } + removeUndefinedNullValuesFromObject(obj) + expect(obj).toEqual({ a: 0, b: false, c: '' }) + }) + + it('handles empty object', () => { + const obj = {} + removeUndefinedNullValuesFromObject(obj) + expect(obj).toEqual({}) + }) +}) + +describe('convertObjectToCypherMapLiteral', () => { + it('converts single entry', () => { + expect(convertObjectToCypherMapLiteral({ id: 'g0' })).toBe('{id: "g0"}') + }) + + it('converts multiple entries', () => { + expect(convertObjectToCypherMapLiteral({ id: 'g0', slug: 'yoga' })).toBe( + '{id: "g0", slug: "yoga"}', + ) + }) + + it('returns empty string for empty object', () => { + expect(convertObjectToCypherMapLiteral({})).toBe('') + }) + + it('adds space in front when addSpaceInfrontIfMapIsNotEmpty is true and map is not empty', () => { + expect(convertObjectToCypherMapLiteral({ id: 'g0' }, true)).toBe(' {id: "g0"}') + }) + + it('does not add space when addSpaceInfrontIfMapIsNotEmpty is true but map is empty', () => { + expect(convertObjectToCypherMapLiteral({}, true)).toBe('') + }) + + it('does not add space when addSpaceInfrontIfMapIsNotEmpty is false', () => { + expect(convertObjectToCypherMapLiteral({ id: 'g0' }, false)).toBe('{id: "g0"}') + }) +}) diff --git a/backend/src/graphql/resolvers/helpers/filterHasLocation.spec.ts b/backend/src/graphql/resolvers/helpers/filterHasLocation.spec.ts new file mode 100644 index 000000000..3d9ab5376 --- /dev/null +++ b/backend/src/graphql/resolvers/helpers/filterHasLocation.spec.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Factory, { cleanDatabase } from '@db/factories' +import GroupWithLocationFilter from '@graphql/queries/groups/GroupWithLocationFilter.gql' +import PostWithLocationFilter from '@graphql/queries/posts/PostWithLocationFilter.gql' +import UserWithLocationFilter from '@graphql/queries/users/UserWithLocationFilter.gql' +import { createApolloTestSetup } from '@root/test/helpers' + +import type { ApolloTestSetup } from '@root/test/helpers' +import type { Context } from '@src/context' + +let authenticatedUser: Context['user'] +const context = () => ({ authenticatedUser }) +let query: ApolloTestSetup['query'] +let database: ApolloTestSetup['database'] +let server: ApolloTestSetup['server'] + +beforeAll(async () => { + await cleanDatabase() + const apolloSetup = await createApolloTestSetup({ context }) + query = apolloSetup.query + database = apolloSetup.database + server = apolloSetup.server +}) + +afterAll(async () => { + await cleanDatabase() + void server.stop() + void database.driver.close() + database.neode.close() +}) + +afterEach(async () => { + await cleanDatabase() +}) + +describe('hasLocation filter', () => { + describe('User', () => { + beforeEach(async () => { + const location = await Factory.build('location', { + id: 'loc-hamburg', + name: 'Hamburg', + type: 'region', + lng: 10.0, + lat: 53.55, + }) + const userWithLocation = await Factory.build('user', { + id: 'u-with-loc', + name: 'User With Location', + }) + await userWithLocation.relateTo(location, 'isIn') + await Factory.build('user', { + id: 'u-without-loc', + name: 'User Without Location', + }) + authenticatedUser = await userWithLocation.toJson() + }) + + it('returns all users without filter', async () => { + const result = await query({ query: UserWithLocationFilter }) + expect(result.data?.User.length).toBeGreaterThanOrEqual(2) + }) + + it('returns only users with location when hasLocation is true', async () => { + const result = await query({ + query: UserWithLocationFilter, + variables: { filter: { hasLocation: true } }, + }) + const ids = result.data?.User.map((u: { id: string }) => u.id) + expect(ids).toContain('u-with-loc') + expect(ids).not.toContain('u-without-loc') + }) + }) + + describe('Group', () => { + beforeEach(async () => { + const location = await Factory.build('location', { + id: 'loc-berlin', + name: 'Berlin', + type: 'region', + lng: 13.4, + lat: 52.52, + }) + const owner = await Factory.build('user', { id: 'group-owner', name: 'Owner' }) + authenticatedUser = await owner.toJson() + + const groupWithLocation = await Factory.build('group', { + id: 'g-with-loc', + name: 'Group With Location', + groupType: 'public', + ownerId: 'group-owner', + }) + await groupWithLocation.relateTo(location, 'isIn') + + await Factory.build('group', { + id: 'g-without-loc', + name: 'Group Without Location', + groupType: 'public', + ownerId: 'group-owner', + }) + }) + + it('returns all groups without filter', async () => { + const result = await query({ query: GroupWithLocationFilter }) + expect(result.data?.Group.length).toBeGreaterThanOrEqual(2) + }) + + it('returns only groups with location when hasLocation is true', async () => { + const result = await query({ + query: GroupWithLocationFilter, + variables: { hasLocation: true }, + }) + const ids = result.data?.Group.map((g: { id: string }) => g.id) + expect(ids).toContain('g-with-loc') + expect(ids).not.toContain('g-without-loc') + }) + }) + + describe('Post', () => { + beforeEach(async () => { + const author = await Factory.build('user', { id: 'post-author', name: 'Author' }) + authenticatedUser = await author.toJson() + + await Factory.build('location', { + id: 'loc-munich', + name: 'Munich', + type: 'region', + lng: 11.58, + lat: 48.14, + }) + await Factory.build('post', { + id: 'p-with-loc', + title: 'Event With Location', + postType: 'Event', + authorId: 'post-author', + }) + // Post model has no isIn relationship defined in Neode, use Cypher directly + const session = database.driver.session() + try { + await session.writeTransaction((txc) => + txc.run(` + MATCH (p:Post {id: 'p-with-loc'}), (l:Location {id: 'loc-munich'}) + MERGE (p)-[:IS_IN]->(l) + `), + ) + } finally { + await session.close() + } + + await Factory.build('post', { + id: 'p-without-loc', + title: 'Event Without Location', + postType: 'Event', + authorId: 'post-author', + }) + }) + + it('returns all posts without hasLocation filter', async () => { + const result = await query({ + query: PostWithLocationFilter, + }) + expect(result.data?.Post.length).toBeGreaterThanOrEqual(2) + }) + + it('returns only posts with location when hasLocation is true', async () => { + const result = await query({ + query: PostWithLocationFilter, + variables: { filter: { hasLocation: true } }, + }) + const ids = result.data?.Post.map((p: { id: string }) => p.id) + expect(ids).toContain('p-with-loc') + expect(ids).not.toContain('p-without-loc') + }) + }) +}) diff --git a/backend/src/graphql/resolvers/helpers/filterHasLocation.ts b/backend/src/graphql/resolvers/helpers/filterHasLocation.ts new file mode 100644 index 000000000..3e5535390 --- /dev/null +++ b/backend/src/graphql/resolvers/helpers/filterHasLocation.ts @@ -0,0 +1,56 @@ +import type { Context } from '@src/context' + +interface FilterParams { + filter?: { + hasLocation?: boolean + id_in?: string[] + [key: string]: unknown + } + [key: string]: unknown +} + +type AllowedLabel = 'User' | 'Post' + +const getIdsWithLocation = async (context: Context, label: AllowedLabel): Promise => { + const session = context.driver.session() + try { + const result = await session.readTransaction(async (transaction) => { + const cypher = ` + MATCH (n:${label})-[:IS_IN]->(l:Location) + RETURN collect(n.id) AS ids` + const response = await transaction.run(cypher) + return response.records.map((record) => record.get('ids') as string[]) + }) + const [ids] = result + return ids + } finally { + await session.close() + } +} + +const mergeIdIn = (existing: string[] | undefined, incoming: string[]): string[] => { + if (!existing) return incoming + return existing.filter((id) => incoming.includes(id)) +} + +export const filterUsersHasLocation = async ( + params: FilterParams, + context: Context, +): Promise => { + if (!params.filter?.hasLocation) return params + delete params.filter.hasLocation + const userIds = await getIdsWithLocation(context, 'User') + params.filter.id_in = mergeIdIn(params.filter.id_in, userIds) + return params +} + +export const filterPostsHasLocation = async ( + params: FilterParams, + context: Context, +): Promise => { + if (!params.filter?.hasLocation) return params + delete params.filter.hasLocation + const postIds = await getIdsWithLocation(context, 'Post') + params.filter.id_in = mergeIdIn(params.filter.id_in, postIds) + return params +} diff --git a/backend/src/graphql/resolvers/helpers/filterHelpers.spec.ts b/backend/src/graphql/resolvers/helpers/filterHelpers.spec.ts new file mode 100644 index 000000000..6780f1172 --- /dev/null +++ b/backend/src/graphql/resolvers/helpers/filterHelpers.spec.ts @@ -0,0 +1,207 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import Factory, { cleanDatabase } from '@db/factories' +import CreatePost from '@graphql/queries/posts/CreatePost.gql' +import Post from '@graphql/queries/posts/Post.gql' +import { createApolloTestSetup } from '@root/test/helpers' + +import type { ApolloTestSetup } from '@root/test/helpers' +import type { Context } from '@src/context' + +let authenticatedUser: Context['user'] +const context = () => ({ authenticatedUser }) +let query: ApolloTestSetup['query'] +let mutate: ApolloTestSetup['mutate'] +let database: ApolloTestSetup['database'] +let server: ApolloTestSetup['server'] + +beforeAll(async () => { + await cleanDatabase() + const apolloSetup = await createApolloTestSetup({ context }) + query = apolloSetup.query + mutate = apolloSetup.mutate + database = apolloSetup.database + server = apolloSetup.server +}) + +afterAll(async () => { + await cleanDatabase() + void server.stop() + void database.driver.close() + database.neode.close() +}) + +afterEach(async () => { + await cleanDatabase() +}) + +describe('filterForMutedUsers', () => { + describe('when looking up a single post by id', () => { + it('does not filter muted users', async () => { + const author = await Factory.build('user', { id: 'muted-author', name: 'Muted Author' }) + const viewer = await Factory.build('user', { id: 'viewer', name: 'Viewer' }) + + // Viewer mutes author + const session = database.driver.session() + try { + await session.writeTransaction((txc) => + txc.run(` + MATCH (viewer:User {id: 'viewer'}), (author:User {id: 'muted-author'}) + MERGE (viewer)-[:MUTED]->(author) + `), + ) + } finally { + await session.close() + } + + // Author creates a post + authenticatedUser = await author.toJson() + await mutate({ + mutation: CreatePost, + variables: { + id: 'muted-post', + title: 'Post by muted user', + content: 'Some content here for the post', + postType: 'Article', + }, + }) + + // Viewer queries for the specific post by id — should still see it + authenticatedUser = await viewer.toJson() + const result = await query({ + query: Post, + variables: { id: 'muted-post' }, + }) + expect(result.data?.Post).toHaveLength(1) + expect(result.data?.Post[0].id).toBe('muted-post') + }) + }) + + describe('when listing all posts', () => { + it('filters posts from muted users', async () => { + const author = await Factory.build('user', { id: 'muted-author', name: 'Muted Author' }) + const otherAuthor = await Factory.build('user', { + id: 'other-author', + name: 'Other Author', + }) + const viewer = await Factory.build('user', { id: 'viewer', name: 'Viewer' }) + + // Viewer mutes author + const session = database.driver.session() + try { + await session.writeTransaction((txc) => + txc.run(` + MATCH (viewer:User {id: 'viewer'}), (author:User {id: 'muted-author'}) + MERGE (viewer)-[:MUTED]->(author) + `), + ) + } finally { + await session.close() + } + + // Both authors create posts + authenticatedUser = await author.toJson() + await mutate({ + mutation: CreatePost, + variables: { + id: 'muted-post', + title: 'Post by muted user', + content: 'Some content here for the muted post', + postType: 'Article', + }, + }) + + authenticatedUser = await otherAuthor.toJson() + await mutate({ + mutation: CreatePost, + variables: { + id: 'visible-post', + title: 'Post by other user', + content: 'Some content here for the visible post', + postType: 'Article', + }, + }) + + // Viewer lists all posts — should not see muted author's post + authenticatedUser = await viewer.toJson() + const result = await query({ query: Post }) + const ids = result.data?.Post.map((p: { id: string }) => p.id) + expect(ids).toContain('visible-post') + expect(ids).not.toContain('muted-post') + }) + }) +}) + +describe('filterPostsOfMyGroups', () => { + describe('when user has no group memberships', () => { + it('returns empty for postsInMyGroups filter', async () => { + const author = await Factory.build('user', { id: 'author', name: 'Author' }) + authenticatedUser = await author.toJson() + + await mutate({ + mutation: CreatePost, + variables: { + id: 'some-post', + title: 'A regular post', + content: 'Some content here for the post', + postType: 'Article', + }, + }) + + // Query with postsInMyGroups but user has no groups + const result = await query({ + query: Post, + variables: { filter: { postsInMyGroups: true } }, + }) + expect(result.data?.Post).toHaveLength(0) + }) + }) +}) + +describe('filterInvisiblePosts', () => { + describe('with closed group posts', () => { + beforeEach(async () => { + const owner = await Factory.build('user', { id: 'group-owner', name: 'Group Owner' }) + await Factory.build('user', { id: 'outsider', name: 'Outsider' }) + + authenticatedUser = await owner.toJson() + await Factory.build('group', { + id: 'closed-group', + name: 'Closed Group', + groupType: 'closed', + ownerId: 'group-owner', + }) + + await mutate({ + mutation: CreatePost, + variables: { + id: 'closed-group-post', + title: 'Secret Post', + content: 'Some content here for the secret post', + postType: 'Article', + groupId: 'closed-group', + }, + }) + + await mutate({ + mutation: CreatePost, + variables: { + id: 'public-post', + title: 'Public Post', + content: 'Some content here for the public post', + postType: 'Article', + }, + }) + }) + + it('filters posts in non-public groups for non-members', async () => { + const outsider = await database.neode.find('User', 'outsider') + authenticatedUser = (await outsider.toJson()) as Context['user'] + const result = await query({ query: Post }) + const ids = result.data?.Post.map((p: { id: string }) => p.id) + expect(ids).toContain('public-post') + expect(ids).not.toContain('closed-group-post') + }) + }) +}) diff --git a/backend/src/graphql/resolvers/posts.ts b/backend/src/graphql/resolvers/posts.ts index f0634d3ed..0834dbd0b 100644 --- a/backend/src/graphql/resolvers/posts.ts +++ b/backend/src/graphql/resolvers/posts.ts @@ -13,6 +13,7 @@ import { UserInputError } from '@graphql/errors' import { validateEventParams } from './helpers/events' import { filterForMutedUsers } from './helpers/filterForMutedUsers' +import { filterPostsHasLocation } from './helpers/filterHasLocation' import { filterInvisiblePosts } from './helpers/filterInvisiblePosts' import { filterPostsOfMyGroups } from './helpers/filterPostsOfMyGroups' import Resolver from './helpers/Resolver' @@ -61,6 +62,7 @@ export default { params = await filterInvisiblePosts(params, context) params = await filterForMutedUsers(params, context) params = filterEventDates(params) + params = await filterPostsHasLocation(params, context) params = await maintainPinnedPosts(params) return neo4jgraphql(object, params, context, resolveInfo) }, @@ -68,6 +70,7 @@ export default { params = await filterPostsOfMyGroups(params, context) params = await filterInvisiblePosts(params, context) params = await filterForMutedUsers(params, context) + params = await filterPostsHasLocation(params, context) params = await maintainGroupPinnedPosts(params) return neo4jgraphql(object, params, context, resolveInfo) }, diff --git a/backend/src/graphql/resolvers/users.ts b/backend/src/graphql/resolvers/users.ts index fc009da6b..b5818f8fa 100644 --- a/backend/src/graphql/resolvers/users.ts +++ b/backend/src/graphql/resolvers/users.ts @@ -12,6 +12,7 @@ import { getNeode } from '@db/neo4j' import { UserInputError, ForbiddenError } from '@graphql/errors' import { defaultTrophyBadge, defaultVerificationBadge } from './badges' +import { filterUsersHasLocation } from './helpers/filterHasLocation' import normalizeEmail from './helpers/normalizeEmail' import Resolver from './helpers/Resolver' import { images } from './images/images' @@ -66,6 +67,7 @@ export default { await session.close() } } + args = await filterUsersHasLocation(args, context) return neo4jgraphql(object, args, context, resolveInfo) }, }, diff --git a/backend/src/graphql/types/type/Group.gql b/backend/src/graphql/types/type/Group.gql index 5037c83a0..07eaa73b0 100644 --- a/backend/src/graphql/types/type/Group.gql +++ b/backend/src/graphql/types/type/Group.gql @@ -79,6 +79,7 @@ input _GroupFilter { type Query { Group( isMember: Boolean # if 'undefined' or 'null' then get all groups + hasLocation: Boolean # if 'true' then only groups with a location id: ID slug: String first: Int diff --git a/backend/src/graphql/types/type/Post.gql b/backend/src/graphql/types/type/Post.gql index e61b013cf..680feb2ff 100644 --- a/backend/src/graphql/types/type/Post.gql +++ b/backend/src/graphql/types/type/Post.gql @@ -92,6 +92,7 @@ input _PostFilter { postType_in: [PostType] eventStart_gte: String eventEnd_gte: String + hasLocation: Boolean } enum _PostOrdering { diff --git a/backend/src/graphql/types/type/User.gql b/backend/src/graphql/types/type/User.gql index ae02ce454..8a30317f2 100644 --- a/backend/src/graphql/types/type/User.gql +++ b/backend/src/graphql/types/type/User.gql @@ -158,6 +158,7 @@ type User { input _UserFilter { AND: [_UserFilter!] OR: [_UserFilter!] + hasLocation: Boolean name_contains: String about_contains: String slug_contains: String diff --git a/webapp/graphql/MapQuery.js b/webapp/graphql/MapQuery.js new file mode 100644 index 000000000..363ba2729 --- /dev/null +++ b/webapp/graphql/MapQuery.js @@ -0,0 +1,51 @@ +import gql from 'graphql-tag' + +export const mapQuery = (i18n) => { + const lang = i18n.locale().toUpperCase() + return gql` + query MapData($userFilter: _UserFilter, $groupHasLocation: Boolean, $postFilter: _PostFilter) { + User(filter: $userFilter) { + id + slug + name + about + location { + id + name(lang: "${lang}") + lng + lat + } + } + Group(hasLocation: $groupHasLocation) { + id + slug + name + about + location { + id + name(lang: "${lang}") + lng + lat + } + } + Post(filter: $postFilter) { + id + slug + title + content + postType + eventStart + eventEnd + eventVenue + eventLocationName + eventIsOnline + eventLocation { + id + name(lang: "${lang}") + lng + lat + } + } + } + ` +} diff --git a/webapp/pages/map.vue b/webapp/pages/map.vue index 9af0aa37b..421974518 100644 --- a/webapp/pages/map.vue +++ b/webapp/pages/map.vue @@ -64,9 +64,8 @@ import mapboxgl from 'mapbox-gl' import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder' import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css' import { mapGetters } from 'vuex' -import { profileUserQuery, mapUserQuery } from '~/graphql/User' -import { groupQuery } from '~/graphql/groups' -import { filterMapPosts } from '~/graphql/PostQuery.js' +import { profileUserQuery } from '~/graphql/User' +import { mapQuery } from '~/graphql/MapQuery' import mobile from '~/mixins/mobile' import Empty from '~/components/Empty/Empty' import MapStylesButtons from '~/components/Map/MapStylesButtons' @@ -363,7 +362,7 @@ export default { if (this.isPreparedForMarkers) { // add markers for "users" this.users.forEach((user) => { - if (user.id !== this.currentUser.id && user.location) { + if (user.id !== this.currentUser.id) { this.markers.geoJSON.push({ type: 'Feature', properties: { @@ -403,45 +402,41 @@ export default { } // add markers for "groups" this.groups.forEach((group) => { - if (group.location) { - this.markers.geoJSON.push({ - type: 'Feature', - properties: { - type: 'group', - iconName: 'marker-red', - iconRotate: 0.0, - id: group.id, - slug: group.slug, - name: group.name, - description: group.about ? group.about : undefined, - }, - geometry: { - type: 'Point', - coordinates: this.getCoordinates(group.location), - }, - }) - } + this.markers.geoJSON.push({ + type: 'Feature', + properties: { + type: 'group', + iconName: 'marker-red', + iconRotate: 0.0, + id: group.id, + slug: group.slug, + name: group.name, + description: group.about ? group.about : undefined, + }, + geometry: { + type: 'Point', + coordinates: this.getCoordinates(group.location), + }, + }) }) // add markers for "posts", post type "Event" with location coordinates this.posts.forEach((post) => { - if (post.postType.includes('Event') && post.eventLocation) { - this.markers.geoJSON.push({ - type: 'Feature', - properties: { - type: 'event', - iconName: 'marker-purple', - iconRotate: 0.0, - id: post.id, - slug: post.slug, - name: post.title, - description: this.$filters.removeHtml(post.content), - }, - geometry: { - type: 'Point', - coordinates: this.getCoordinates(post.eventLocation), - }, - }) - } + this.markers.geoJSON.push({ + type: 'Feature', + properties: { + type: 'event', + iconName: 'marker-purple', + iconRotate: 0.0, + id: post.id, + slug: post.slug, + name: post.title, + description: this.$filters.removeHtml(post.content), + }, + geometry: { + type: 'Point', + coordinates: this.getCoordinates(post.eventLocation), + }, + }) }) this.markers.isGeoJSON = true @@ -514,47 +509,26 @@ export default { }, }, apollo: { - User: { + mapData: { query() { - return mapUserQuery(this.$i18n) - }, - variables() { - return {} - }, - update({ User }) { - this.users = User - this.addMarkersOnCheckPrepared() - }, - fetchPolicy: 'cache-and-network', - }, - Group: { - query() { - return groupQuery(this.$i18n) - }, - variables() { - return {} - }, - update({ Group }) { - this.groups = Group - this.addMarkersOnCheckPrepared() - }, - fetchPolicy: 'cache-and-network', - }, - Post: { - query() { - return filterMapPosts(this.$i18n) + return mapQuery(this.$i18n) }, variables() { return { - filter: { + userFilter: { hasLocation: true }, + groupHasLocation: true, + postFilter: { postType_in: ['Event'], eventStart_gte: new Date(), - // would be good to just query for events with defined "eventLocation". couldn't get it working + hasLocation: true, }, } }, - update({ Post }) { + update({ User, Group, Post }) { + this.users = User + this.groups = Group this.posts = Post + this.addMarkersOnCheckPrepared() }, fetchPolicy: 'cache-and-network', },