diff --git a/admin/src/components/globals/index.js b/admin/src/components/globals/index.js index 81aef7ae22..b4d054bc65 100644 --- a/admin/src/components/globals/index.js +++ b/admin/src/components/globals/index.js @@ -437,7 +437,7 @@ export const Tooltip = props => css` &:hover:after, &:hover:before { opacity: 1; - transition: all 0.1s ease-in 0.1s; + transition: opacity 0.1s ease-in 0.1s; } `; @@ -487,7 +487,7 @@ export const Onboarding = props => css` &:after, &:before { opacity: 1; - transition: all 0.1s ease-in 0.1s; + transition: opacity 0.1s ease-in 0.1s; } `; diff --git a/analytics/utils/transformations.js b/analytics/utils/transformations.js index 7cd12cbe7e..dfc6a0a377 100644 --- a/analytics/utils/transformations.js +++ b/analytics/utils/transformations.js @@ -24,6 +24,7 @@ type AnalyticsCommunity = { id: ?string, name: ?string, slug: ?string, + isPrivate: boolean, }; type AnalyticsChannelPermissions = { @@ -134,12 +135,14 @@ export const analyticsCommunity = ( id: null, name: null, slug: null, + isPrivate: false, }; return { id: community.id, name: community.name, slug: community.slug, + isPrivate: community.isPrivate, }; }; diff --git a/api/migrations/20180517180716-enable-private-communities.js b/api/migrations/20180517180716-enable-private-communities.js new file mode 100644 index 0000000000..d0ad106774 --- /dev/null +++ b/api/migrations/20180517180716-enable-private-communities.js @@ -0,0 +1,17 @@ +exports.up = async (r, conn) => { + return r + .table('communities') + .update({ + isPrivate: false, + }) + .run(conn); +}; + +exports.down = function(r, conn) { + return r + .table('communities') + .update({ + isPrivate: r.literal(), + }) + .run(conn); +}; diff --git a/api/migrations/20180517215503-add-ispending-to-userscommunities.js b/api/migrations/20180517215503-add-ispending-to-userscommunities.js new file mode 100644 index 0000000000..aed213d768 --- /dev/null +++ b/api/migrations/20180517215503-add-ispending-to-userscommunities.js @@ -0,0 +1,17 @@ +exports.up = async (r, conn) => { + return r + .table('usersCommunities') + .update({ + isPending: false, + }) + .run(conn); +}; + +exports.down = function(r, conn) { + return r + .table('usersCommunities') + .update({ + isPending: r.literal(), + }) + .run(conn); +}; diff --git a/api/migrations/20180518135040-add-join-settings-to-community-settings.js b/api/migrations/20180518135040-add-join-settings-to-community-settings.js new file mode 100644 index 0000000000..4cd7aedfb2 --- /dev/null +++ b/api/migrations/20180518135040-add-join-settings-to-community-settings.js @@ -0,0 +1,20 @@ +exports.up = async (r, conn) => { + return r + .table('communitySettings') + .update({ + joinSettings: { + tokenJoinEnabled: false, + token: null, + }, + }) + .run(conn); +}; + +exports.down = function(r, conn) { + return r + .table('communitySettings') + .update({ + joinSettings: r.literal(), + }) + .run(conn); +}; diff --git a/api/migrations/seed/default/channels.js b/api/migrations/seed/default/channels.js index 3a31897411..79e2adf746 100644 --- a/api/migrations/seed/default/channels.js +++ b/api/migrations/seed/default/channels.js @@ -5,6 +5,7 @@ const { SPECTRUM_COMMUNITY_ID, PAYMENTS_COMMUNITY_ID, DELETED_COMMUNITY_ID, + PRIVATE_COMMUNITY_ID, SPECTRUM_GENERAL_CHANNEL_ID, SPECTRUM_PRIVATE_CHANNEL_ID, PAYMENTS_GENERAL_CHANNEL_ID, @@ -13,6 +14,7 @@ const { SPECTRUM_DELETED_CHANNEL_ID, DELETED_COMMUNITY_DELETED_CHANNEL_ID, MODERATOR_CREATED_CHANNEL_ID, + PRIVATE_GENERAL_CHANNEL_ID, } = constants; module.exports = [ @@ -106,4 +108,15 @@ module.exports = [ isPrivate: false, isDefault: false, }, + + { + id: PRIVATE_GENERAL_CHANNEL_ID, + communityId: PRIVATE_COMMUNITY_ID, + createdAt: new Date(DATE), + name: 'General', + description: 'General', + slug: 'private-general', + isPrivate: false, + isDefault: false, + }, ]; diff --git a/api/migrations/seed/default/communities.js b/api/migrations/seed/default/communities.js index ec2e32fd70..0fcc589df5 100644 --- a/api/migrations/seed/default/communities.js +++ b/api/migrations/seed/default/communities.js @@ -5,12 +5,14 @@ const { SPECTRUM_COMMUNITY_ID, PAYMENTS_COMMUNITY_ID, DELETED_COMMUNITY_ID, + PRIVATE_COMMUNITY_ID, } = constants; module.exports = [ { id: SPECTRUM_COMMUNITY_ID, createdAt: new Date(DATE), + isPrivate: false, name: 'Spectrum', description: 'The future of communities', website: 'https://spectrum.chat', @@ -23,6 +25,7 @@ module.exports = [ { id: PAYMENTS_COMMUNITY_ID, createdAt: new Date(DATE), + isPrivate: false, name: 'Payments', description: 'Where payments are tested', website: 'https://spectrum.chat', @@ -36,6 +39,7 @@ module.exports = [ id: DELETED_COMMUNITY_ID, createdAt: new Date(DATE), deletedAt: new Date(DATE), + isPrivate: false, name: 'Deleted', description: 'Things didnt work out', website: 'https://spectrum.chat', @@ -45,4 +49,17 @@ module.exports = [ 'https://spectrum.imgix.net/communities/-Kh6RfPYjmSaIWbkck8i/Twitter Header.png.0.3303118636071434', slug: 'deleted', }, + { + id: PRIVATE_COMMUNITY_ID, + createdAt: new Date(DATE), + isPrivate: true, + name: 'Private community', + description: 'Private community', + website: 'https://spectrum.chat', + profilePhoto: + 'https://spectrum.imgix.net/communities/-Kh6RfPYjmSaIWbkck8i/Twitter Profile.png.0.6225566835336693', + coverPhoto: + 'https://spectrum.imgix.net/communities/-Kh6RfPYjmSaIWbkck8i/Twitter Header.png.0.3303118636071434', + slug: 'private', + }, ]; diff --git a/api/migrations/seed/default/constants.js b/api/migrations/seed/default/constants.js index 9f0156c5de..eff3218ab3 100644 --- a/api/migrations/seed/default/constants.js +++ b/api/migrations/seed/default/constants.js @@ -22,6 +22,7 @@ const COMMUNITY_MODERATOR_USER_ID = '9'; const SPECTRUM_COMMUNITY_ID = '1'; const PAYMENTS_COMMUNITY_ID = '2'; const DELETED_COMMUNITY_ID = '3'; +const PRIVATE_COMMUNITY_ID = '4'; // channels const SPECTRUM_GENERAL_CHANNEL_ID = '1'; @@ -32,6 +33,7 @@ const SPECTRUM_ARCHIVED_CHANNEL_ID = '5'; const SPECTRUM_DELETED_CHANNEL_ID = '6'; const DELETED_COMMUNITY_DELETED_CHANNEL_ID = '7'; const MODERATOR_CREATED_CHANNEL_ID = '8'; +const PRIVATE_GENERAL_CHANNEL_ID = '9'; module.exports = { DATE, @@ -47,6 +49,7 @@ module.exports = { SPECTRUM_COMMUNITY_ID, PAYMENTS_COMMUNITY_ID, DELETED_COMMUNITY_ID, + PRIVATE_COMMUNITY_ID, SPECTRUM_GENERAL_CHANNEL_ID, SPECTRUM_PRIVATE_CHANNEL_ID, PAYMENTS_GENERAL_CHANNEL_ID, @@ -55,4 +58,5 @@ module.exports = { SPECTRUM_DELETED_CHANNEL_ID, DELETED_COMMUNITY_DELETED_CHANNEL_ID, MODERATOR_CREATED_CHANNEL_ID, + PRIVATE_GENERAL_CHANNEL_ID, }; diff --git a/api/migrations/seed/default/threads.js b/api/migrations/seed/default/threads.js index b07a8a60c4..90c29cf268 100644 --- a/api/migrations/seed/default/threads.js +++ b/api/migrations/seed/default/threads.js @@ -6,17 +6,14 @@ const { BRIAN_ID, MAX_ID, BRYN_ID, - CHANNEL_MODERATOR_USER_ID, SPECTRUM_GENERAL_CHANNEL_ID, + PRIVATE_GENERAL_CHANNEL_ID, SPECTRUM_PRIVATE_CHANNEL_ID, - PAYMENTS_GENERAL_CHANNEL_ID, - PAYMENTS_PRIVATE_CHANNEL_ID, - SPECTRUM_DELETED_CHANNEL_ID, DELETED_COMMUNITY_DELETED_CHANNEL_ID, MODERATOR_CREATED_CHANNEL_ID, DELETED_COMMUNITY_ID, SPECTRUM_COMMUNITY_ID, - PAYMENTS_COMMUNITY_ID, + PRIVATE_COMMUNITY_ID, SPECTRUM_ARCHIVED_CHANNEL_ID, } = constants; @@ -385,4 +382,35 @@ module.exports = [ lastActive: new Date(DATE + 2), // deletedAt is missing intentionally }, + + { + id: 'thread-13', + createdAt: new Date(DATE + 2), + creatorId: MAX_ID, + channelId: PRIVATE_GENERAL_CHANNEL_ID, + communityId: PRIVATE_COMMUNITY_ID, + isPublished: true, + isLocked: false, + type: 'DRAFTJS', + content: { + title: 'Yet another thread', + body: JSON.stringify( + toJSON(fromPlainText('This is just another thread')) + ), + }, + attachments: [], + edits: [ + { + timestamp: new Date(DATE + 2), + content: { + title: 'Yet another thread', + body: JSON.stringify( + toJSON(fromPlainText('This is just another thread')) + ), + }, + }, + ], + modifiedAt: new Date(DATE + 2), + lastActive: new Date(DATE + 2), + }, ]; diff --git a/api/migrations/seed/default/usersChannels.js b/api/migrations/seed/default/usersChannels.js index 16a8bc673d..14aaee961c 100644 --- a/api/migrations/seed/default/usersChannels.js +++ b/api/migrations/seed/default/usersChannels.js @@ -6,11 +6,11 @@ const { MAX_ID, BRYN_ID, BLOCKED_USER_ID, - PENDING_USER_ID, PREVIOUS_MEMBER_USER_ID, CHANNEL_MODERATOR_USER_ID, COMMUNITY_MODERATOR_USER_ID, SPECTRUM_GENERAL_CHANNEL_ID, + PRIVATE_GENERAL_CHANNEL_ID, SPECTRUM_ARCHIVED_CHANNEL_ID, SPECTRUM_PRIVATE_CHANNEL_ID, DELETED_COMMUNITY_DELETED_CHANNEL_ID, @@ -384,4 +384,16 @@ module.exports = [ isPending: false, receiveNotifications: false, }, + { + id: '31', + createdAt: new Date(DATE), + userId: MAX_ID, + channelId: PRIVATE_GENERAL_CHANNEL_ID, + isOwner: true, + isModerator: false, + isMember: true, + isBlocked: false, + isPending: false, + receiveNotifications: false, + }, ]; diff --git a/api/migrations/seed/default/usersCommunities.js b/api/migrations/seed/default/usersCommunities.js index 2599911242..63f9993373 100644 --- a/api/migrations/seed/default/usersCommunities.js +++ b/api/migrations/seed/default/usersCommunities.js @@ -11,6 +11,7 @@ const { COMMUNITY_MODERATOR_USER_ID, SPECTRUM_COMMUNITY_ID, PAYMENTS_COMMUNITY_ID, + PRIVATE_COMMUNITY_ID, } = constants; module.exports = [ @@ -23,6 +24,7 @@ module.exports = [ isModerator: false, isMember: true, isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -35,6 +37,7 @@ module.exports = [ isModerator: false, isMember: true, isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -47,6 +50,7 @@ module.exports = [ isModerator: false, isMember: true, isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -59,6 +63,7 @@ module.exports = [ isModerator: false, isMember: false, isBlocked: true, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -71,6 +76,7 @@ module.exports = [ isModerator: false, isMember: false, isBlocked: false, + isPending: false, receiveNotifications: false, reputation: 100, }, @@ -84,6 +90,7 @@ module.exports = [ isModerator: false, isMember: true, isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -96,6 +103,7 @@ module.exports = [ isModerator: false, isMember: true, isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -108,6 +116,7 @@ module.exports = [ isModerator: false, isMember: true, isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -120,6 +129,7 @@ module.exports = [ isModerator: false, isMember: true, isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -132,6 +142,7 @@ module.exports = [ isModerator: false, isMember: true, isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -144,6 +155,7 @@ module.exports = [ isModerator: true, isMember: false, isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, @@ -156,6 +168,34 @@ module.exports = [ isModerator: true, isMember: true, isBlocked: false, + isPending: false, + receiveNotifications: true, + reputation: 100, + }, + { + id: '14', + createdAt: new Date(DATE), + userId: MAX_ID, + communityId: PRIVATE_COMMUNITY_ID, + isOwner: true, + isModerator: false, + isMember: true, + isBlocked: false, + isPending: false, + receiveNotifications: true, + reputation: 100, + }, + + { + id: '15', + createdAt: new Date(DATE), + userId: BRIAN_ID, + communityId: PRIVATE_COMMUNITY_ID, + isOwner: false, + isModerator: false, + isMember: false, + isBlocked: false, + isPending: false, receiveNotifications: true, reputation: 100, }, diff --git a/api/models/community.js b/api/models/community.js index 1f6110dd6e..430afab18c 100644 --- a/api/models/community.js +++ b/api/models/community.js @@ -41,6 +41,18 @@ export const getCommunitiesBySlug = (slugs: Array): Promise => { + return db + .table('communities') + .getAll(slug, { index: 'slug' }) + .filter(community => db.not(community.hasFields('deletedAt'))) + .run() + .then(results => { + if (!results || results.length === 0) return null; + return results[0]; + }); +}; + // prettier-ignore export const getCommunitiesByUser = (userId: string): Promise> => { return ( @@ -118,6 +130,7 @@ export type CreateCommunityInput = { website: string, file: Object, coverFile: Object, + isPrivate: boolean, }, }; @@ -135,7 +148,7 @@ export type EditCommunityInput = { // prettier-ignore export const createCommunity = ({ input }: CreateCommunityInput, user: DBUser): Promise => { - const { name, slug, description, website, file, coverFile } = input + const { name, slug, description, website, file, coverFile, isPrivate } = input return db .table('communities') @@ -152,6 +165,7 @@ export const createCommunity = ({ input }: CreateCommunityInput, user: DBUser): creatorId: user.id, administratorEmail: user.email, stripeCustomerId: null, + isPrivate }, { returnChanges: true } ) diff --git a/api/models/communitySettings.js b/api/models/communitySettings.js index 43c94c9964..aadad9f696 100644 --- a/api/models/communitySettings.js +++ b/api/models/communitySettings.js @@ -2,6 +2,7 @@ const { db } = require('./db'); import type { DBCommunitySettings, DBCommunity } from 'shared/types'; import { getCommunityById } from './community'; +import shortid from 'shortid'; import axios from 'axios'; import { decryptString } from 'shared/encryption'; import { trackQueue } from 'shared/bull/queues'; @@ -23,6 +24,34 @@ const defaultSettings = { invitesMemberCount: null, invitesCustomMessage: null, }, + joinSettings: { + tokenJoinEnabled: false, + token: null, + }, +}; + +// prettier-ignore +export const getOrCreateCommunitySettings = async (communityId: string): Promise => { + const settings = await db + .table('communitySettings') + .getAll(communityId, { index: 'communityId' }) + .run(); + + if (!settings || settings.length === 0) { + return await db + .table('communitySettings') + .insert( + { + ...defaultSettings, + communityId, + }, + { returnChanges: true } + ) + .run() + .then(results => results.changes[0].new_val); + } + + return settings[0]; }; // prettier-ignore @@ -90,29 +119,15 @@ export const getCommunitiesSettings = (communityIds: Array): Promise => { +export const createCommunitySettings = (communityId: string): Promise => { return db .table('communitySettings') .insert({ - communityId: id, - brandedLogin: { - isEnabled: false, - message: null, - }, - slackSettings: { - connectedAt: null, - connectedBy: null, - invitesSentAt: null, - teamName: null, - teamId: null, - invitesMemberCount: null, - invitesCustomMessage: null, - scope: null, - token: null, - }, + communityId, + ...defaultSettings }) .run() - .then(async () => await getCommunityById(id)); + .then(async () => await getCommunityById(communityId)); }; // prettier-ignore @@ -364,3 +379,77 @@ const handleSlackChannelResponse = async (data: Object, communityId: string) => return []; }; + +export const enableCommunityTokenJoin = ( + communityId: string, + userId: string +) => { + return db + .table('communitySettings') + .getAll(communityId, { index: 'communityId' }) + .update({ + joinSettings: { + tokenJoinEnabled: true, + token: shortid.generate(), + }, + }) + .run() + .then(async () => { + trackQueue.add({ + userId, + event: events.COMMUNITY_JOIN_TOKEN_ENABLED, + context: { communityId }, + }); + + return await getCommunityById(communityId); + }); +}; + +export const disableCommunityTokenJoin = ( + communityId: string, + userId: string +) => { + return db + .table('communitySettings') + .getAll(communityId, { index: 'communityId' }) + .update({ + joinSettings: { + tokenJoinEnabled: false, + token: null, + }, + }) + .run() + .then(async () => { + trackQueue.add({ + userId, + event: events.COMMUNITY_JOIN_TOKEN_DISABLED, + context: { communityId }, + }); + + return await getCommunityById(communityId); + }); +}; + +export const resetCommunityJoinToken = ( + communityId: string, + userId: string +) => { + return db + .table('communitySettings') + .getAll(communityId, { index: 'communityId' }) + .update({ + joinSettings: { + token: shortid.generate(), + }, + }) + .run() + .then(async () => { + trackQueue.add({ + userId, + event: events.COMMUNITY_JOIN_TOKEN_RESET, + context: { communityId }, + }); + + return await getCommunityById(communityId); + }); +}; diff --git a/api/models/search.js b/api/models/search.js index 8001764316..f1af7e29ba 100644 --- a/api/models/search.js +++ b/api/models/search.js @@ -44,6 +44,20 @@ export const getPublicChannelIdsForUsersThreads = (userId: string): Promise> => { + return db + .table('threads') + .getAll(userId, { index: 'creatorId' }) + .filter(row => row.hasFields('deletedAt').not()) + .eqJoin('communityId', db.table('communities')) + .filter(row => row('right')('isPrivate').eq(false)) + .zip() + .map(row => row('communityId')) + .run(); +}; + // prettier-ignore export const getPrivateChannelIdsForUsersThreads = (userId: string): Promise> => { return db @@ -57,6 +71,20 @@ export const getPrivateChannelIdsForUsersThreads = (userId: string): Promise> => { + return db + .table('threads') + .getAll(userId, { index: 'creatorId' }) + .filter(row => row.hasFields('deletedAt').not()) + .eqJoin('communityId', db.table('communities')) + .filter(row => row('right')('isPrivate').eq(true)) + .zip() + .map(row => row('communityId')) + .run(); +}; + // prettier-ignore export const getUsersJoinedChannels = (userId: string): Promise> => { return db @@ -67,6 +95,16 @@ export const getUsersJoinedChannels = (userId: string): Promise> = .run(); }; +// prettier-ignore +export const getUsersJoinedCommunities = (userId: string): Promise> => { + return db + .table('usersCommunities') + .getAll(userId, { index: 'userId' }) + .filter({ isMember: true }) + .map(row => row('communityId')) + .run(); +}; + // prettier-ignore export const getUsersJoinedPrivateChannelIds = (userId: string): Promise> => { return db @@ -80,3 +118,17 @@ export const getUsersJoinedPrivateChannelIds = (userId: string): Promise row('id')) .run(); }; + +// prettier-ignore +export const getUsersJoinedPrivateCommunityIds = (userId: string): Promise> => { + return db + .table('usersCommunities') + .getAll(userId, { index: 'userId' }) + .filter({ isMember: true }) + .eqJoin('communityId', db.table('communities')) + .filter(row => row('right')('isPrivate').eq(true)) + .without({ left: ['id'] }) + .zip() + .map(row => row('id')) + .run(); +}; diff --git a/api/models/usersCommunities.js b/api/models/usersCommunities.js index 9191939a46..2bd2a455f2 100644 --- a/api/models/usersCommunities.js +++ b/api/models/usersCommunities.js @@ -1,7 +1,7 @@ // @flow const { db } = require('./db'); import { sendCommunityNotificationQueue } from 'shared/bull/queues'; -import type { DBUsersCommunities } from 'shared/types'; +import type { DBUsersCommunities, DBCommunity } from 'shared/types'; import { events } from 'shared/analytics'; import { trackQueue } from 'shared/bull/queues'; @@ -16,7 +16,7 @@ import { trackQueue } from 'shared/bull/queues'; // invoked only when a new community is being created. the user who is doing // the creation is automatically an owner and a member // prettier-ignore -const createOwnerInCommunity = (communityId: string, userId: string): Promise => { +export const createOwnerInCommunity = (communityId: string, userId: string): Promise => { return db .table('usersCommunities') .insert( @@ -28,6 +28,7 @@ const createOwnerInCommunity = (communityId: string, userId: string): Promise => { +export const createMemberInCommunity = (communityId: string, userId: string): Promise => { trackQueue.add({ userId, @@ -57,8 +58,7 @@ const createMemberInCommunity = (communityId: string, userId: string): Promise { if (result && result.length > 0) { @@ -69,8 +69,7 @@ const createMemberInCommunity = (communityId: string, userId: string): Promise => { +export const removeMemberInCommunity = (communityId: string, userId: string): Promise => { trackQueue.add({ userId, @@ -119,8 +119,7 @@ const removeMemberInCommunity = (communityId: string, userId: string): Promise => { +export const removeMembersInCommunity = async (communityId: string): Promise => { const usersCommunities = await db .table('usersCommunities') @@ -175,11 +174,10 @@ const removeMembersInCommunity = async (communityId: string): Promise = // owner when managing a private community. sets pending to false to handle // private communitys modifying pending users to be blocked // prettier-ignore -const blockUserInCommunity = (communityId: string, userId: string): Promise => { +export const blockUserInCommunity = (communityId: string, userId: string): Promise => { return db .table('usersCommunities') - .getAll(communityId, { index: 'communityId' }) - .filter({ userId }) + .getAll([userId, communityId], { index: 'userIdAndCommunityId'}) .update( { isMember: false, @@ -205,16 +203,17 @@ const blockUserInCommunity = (communityId: string, userId: string): Promise => { +export const unblockUserInCommunity = (communityId: string, userId: string): Promise => { return db .table('usersCommunities') - .getAll(communityId, { index: 'communityId' }) - .filter({ userId, isBlocked: true }) + .getAll([userId, communityId], { index: 'userIdAndCommunityId' }) + .filter({ isBlocked: true }) .update( { isModerator: false, isMember: true, isBlocked: false, + isPending: false, receiveNotifications: true, }, { returnChanges: true } @@ -234,11 +233,10 @@ const unblockUserInCommunity = (communityId: string, userId: string): Promise => { +export const makeMemberModeratorInCommunity = (communityId: string, userId: string): Promise => { return db .table('usersCommunities') - .getAll(communityId, { index: 'communityId' }) - .filter({ userId }) + .getAll([userId, communityId], { index: 'userIdAndCommunityId'}) .update( { isBlocked: false, @@ -263,11 +261,10 @@ const makeMemberModeratorInCommunity = (communityId: string, userId: string): Pr // moves a moderator to be only a member in a community. does not remove them from the community // prettier-ignore -const removeModeratorInCommunity = (communityId: string, userId: string): Promise => { +export const removeModeratorInCommunity = (communityId: string, userId: string): Promise => { return db .table('usersCommunities') - .getAll(communityId, { index: 'communityId' }) - .filter({ userId }) + .getAll([userId, communityId], { index: 'userIdAndCommunityId'}) .update( { isModerator: false, @@ -289,7 +286,7 @@ const removeModeratorInCommunity = (communityId: string, userId: string): Promis // changes all moderators in a community to members // prettier-ignore -const removeModeratorsInCommunity = async (communityId: string): Promise => { +export const removeModeratorsInCommunity = async (communityId: string): Promise => { const moderators = await db .table('usersCommunities') .getAll(communityId, { index: 'communityId' }) @@ -319,7 +316,8 @@ const removeModeratorsInCommunity = async (communityId: string): Promise { +// invoked when a user is deleting their account +export const removeUsersCommunityMemberships = async (userId: string) => { const memberships = await db .table('usersCommunities') .getAll(userId, { index: 'userId' }) @@ -342,6 +340,7 @@ const removeUsersCommunityMemberships = async (userId: string) => { isOwner: false, isModerator: false, isMember: false, + isPending: false, receiveNotifications: false, }) .run(); @@ -349,6 +348,135 @@ const removeUsersCommunityMemberships = async (userId: string) => { return Promise.all([...trackingPromises, removeMembershipsPromise]); }; +// prettier-ignore +export const createPendingMemberInCommunity = async (communityId: string, userId: string): Promise => { + return db + .table('usersCommunities') + .getAll([userId, communityId], { index: 'userIdAndCommunityId' }) + .run() + .then(result => { + if (result && result.length > 0) { + // if the result exists, it means the user has a previous relationship + // with this community - we handle blocked logic upstream in the mutation, + // so in this case we can just update the record to be pending + + return db + .table('usersCommunities') + .getAll([userId, communityId], { index: 'userIdAndCommunityId' }) + .update( + { + createdAt: new Date(), + isPending: true + }, + { returnChanges: 'always' } + ) + .run(); + } else { + // if no relationship exists, we can create a new one from scratch + return db + .table('usersCommunities') + .insert( + { + communityId, + userId, + createdAt: new Date(), + isMember: false, + isOwner: false, + isModerator: false, + isBlocked: false, + isPending: true, + receiveNotifications: true, + reputation: 0, + }, + { returnChanges: true } + ) + .run(); + } + }) + .then(result => { + trackQueue.add({ + userId, + event: events.USER_REQUESTED_TO_JOIN_COMMUNITY, + context: { communityId } + }) + + // TODO@PRIVATE_COMMUNITIES + // add queue for sending notification to community owner + + return result.changes[0].new_val; + }); +} + +// prettier-ignore +export const removePendingMemberInCommunity = async (communityId: string, userId: string): Promise => { + trackQueue.add({ + userId, + event: events.USER_CANCELED_REQUEST_TO_JOIN_COMMUNITY, + context: { communityId } + }) + + return db + .table('usersCommunities') + .getAll([userId, communityId], { index: 'userIdAndCommunityId'}) + .update({ + isPending: false, + }) + .run() +} + +export const approvePendingMemberInCommunity = async ( + communityId: string, + userId: string +): Promise => { + return db + .table('usersCommunities') + .getAll([userId, communityId], { index: 'userIdAndCommunityId' }) + .update( + { + isMember: true, + isPending: false, + receiveNotifications: true, + }, + { returnChanges: 'always' } + ) + .run() + .then(result => { + trackQueue.add({ + userId, + event: events.USER_WAS_APPROVED_IN_COMMUNITY, + context: { communityId }, + }); + + return result.changes[0].new_val; + }); +}; + +export const blockPendingMemberInCommunity = async ( + communityId: string, + userId: string +): Promise => { + return db + .table('usersCommunities') + .getAll([userId, communityId], { index: 'userIdAndCommunityId' }) + .update( + { + isPending: false, + isBlocked: true, + }, + { returnChanges: 'always' } + ) + .run() + .then(result => { + trackQueue.add({ + userId, + event: events.USER_WAS_BLOCKED_IN_COMMUNITY, + context: { communityId }, + }); + + return result.changes[0].new_val; + }); +}; + /* =========================================================== @@ -360,7 +488,7 @@ const removeUsersCommunityMemberships = async (userId: string) => { type Options = { first: number, after: number }; // prettier-ignore -const getMembersInCommunity = (communityId: string, options: Options, filter: Object): Promise> => { +export const getMembersInCommunity = (communityId: string, options: Options, filter: Object): Promise> => { const { first, after } = options return db .table('usersCommunities') @@ -375,7 +503,7 @@ const getMembersInCommunity = (communityId: string, options: Options, filter: Ob }; // prettier-ignore -const getBlockedUsersInCommunity = (communityId: string): Promise> => { +export const getBlockedUsersInCommunity = (communityId: string): Promise> => { return ( db .table('usersCommunities') @@ -388,7 +516,20 @@ const getBlockedUsersInCommunity = (communityId: string): Promise> }; // prettier-ignore -const getModeratorsInCommunity = (communityId: string): Promise> => { +export const getPendingUsersInCommunity = (communityId: string): Promise> => { + return ( + db + .table('usersCommunities') + .getAll(communityId, { index: 'communityId' }) + .filter({ isPending: true }) + // return an array of the userIds to be loaded by gql + .map(userCommunity => userCommunity('userId')) + .run() + ); +}; + +// prettier-ignore +export const getModeratorsInCommunity = (communityId: string): Promise> => { return ( db .table('usersCommunities') @@ -400,7 +541,9 @@ const getModeratorsInCommunity = (communityId: string): Promise> = ); }; -const getOwnersInCommunity = (communityId: string): Promise> => { +export const getOwnersInCommunity = ( + communityId: string +): Promise> => { return ( db .table('usersCommunities') @@ -412,18 +555,19 @@ const getOwnersInCommunity = (communityId: string): Promise> => { ); }; -const DEFAULT_USER_COMMUNITY_PERMISSIONS = { +export const DEFAULT_USER_COMMUNITY_PERMISSIONS = { isOwner: false, isMember: false, isModerator: false, isBlocked: false, + isPending: false, receiveNotifications: false, reputation: 0, }; // NOTE @BRIAN: DEPRECATED - DONT USE IN THE FUTURE // prettier-ignore -const getUserPermissionsInCommunity = (communityId: string, userId: string): Promise => { +export const getUserPermissionsInCommunity = (communityId: string, userId: string): Promise => { return db .table('usersCommunities') .getAll([userId, communityId], { @@ -447,7 +591,7 @@ const getUserPermissionsInCommunity = (communityId: string, userId: string): Pro }; // prettier-ignore -const checkUserPermissionsInCommunity = (communityId: string, userId: string): Promise => { +export const checkUserPermissionsInCommunity = (communityId: string, userId: string): Promise => { return db .table('usersCommunities') .getAll([userId, communityId], { index: 'userIdAndCommunityId' }) @@ -457,7 +601,7 @@ const checkUserPermissionsInCommunity = (communityId: string, userId: string): P type UserIdAndCommunityId = [string, string]; // prettier-ignore -const getUsersPermissionsInCommunities = (input: Array) => { +export const getUsersPermissionsInCommunities = (input: Array) => { return db .table('usersCommunities') .getAll(...input, { index: 'userIdAndCommunityId' }) @@ -483,7 +627,7 @@ const getUsersPermissionsInCommunities = (input: Array) => }); }; -const getReputationByUser = (userId: string): Promise => { +export const getReputationByUser = (userId: string): Promise => { return db .table('usersCommunities') .getAll(userId, { index: 'userId' }) @@ -495,7 +639,7 @@ const getReputationByUser = (userId: string): Promise => { }; // prettier-ignore -const getUsersTotalReputation = (userIds: Array): Promise> => { +export const getUsersTotalReputation = (userIds: Array): Promise> => { return db .table('usersCommunities') .getAll(...userIds, { index: 'userId' }) @@ -515,28 +659,3 @@ const getUsersTotalReputation = (userIds: Array): Promise> ) ); }; - -module.exports = { - // modify and create - createOwnerInCommunity, - createMemberInCommunity, - removeMemberInCommunity, - removeMembersInCommunity, - blockUserInCommunity, - unblockUserInCommunity, - makeMemberModeratorInCommunity, - removeModeratorInCommunity, - removeModeratorsInCommunity, - removeUsersCommunityMemberships, - // get - DEFAULT_USER_COMMUNITY_PERMISSIONS, - getMembersInCommunity, - getBlockedUsersInCommunity, - getModeratorsInCommunity, - getOwnersInCommunity, - getUserPermissionsInCommunity, - checkUserPermissionsInCommunity, - getReputationByUser, - getUsersTotalReputation, - getUsersPermissionsInCommunities, -}; diff --git a/api/mutations/community/disableCommunityTokenJoin.js b/api/mutations/community/disableCommunityTokenJoin.js new file mode 100644 index 0000000000..e979c76e30 --- /dev/null +++ b/api/mutations/community/disableCommunityTokenJoin.js @@ -0,0 +1,41 @@ +// @flow +import type { GraphQLContext } from '../../'; +import UserError from '../../utils/UserError'; +import { + getOrCreateCommunitySettings, + disableCommunityTokenJoin, +} from '../../models/communitySettings'; +import { + isAuthedResolver as requireAuth, + canModerateCommunity, +} from '../../utils/permissions'; +import { events } from 'shared/analytics'; +import { trackQueue } from 'shared/bull/queues'; + +type Input = { + input: { + id: string, + }, +}; + +export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { + const { id: communityId } = args.input; + const { user, loaders } = ctx; + + if (!await canModerateCommunity(user.id, communityId, loaders)) { + trackQueue.add({ + userId: user.id, + event: events.COMMUNITY_JOIN_TOKEN_DISABLED_FAILED, + context: { communityId }, + properties: { + reason: 'no permission', + }, + }); + + return new UserError('You don’t have permission to manage this community'); + } + + return await getOrCreateCommunitySettings(communityId).then( + async () => await disableCommunityTokenJoin(communityId, user.id) + ); +}); diff --git a/api/mutations/community/enableCommunityTokenJoin.js b/api/mutations/community/enableCommunityTokenJoin.js new file mode 100644 index 0000000000..b3655da06a --- /dev/null +++ b/api/mutations/community/enableCommunityTokenJoin.js @@ -0,0 +1,41 @@ +// @flow +import type { GraphQLContext } from '../../'; +import UserError from '../../utils/UserError'; +import { + getOrCreateCommunitySettings, + enableCommunityTokenJoin, +} from '../../models/communitySettings'; +import { + isAuthedResolver as requireAuth, + canModerateCommunity, +} from '../../utils/permissions'; +import { events } from 'shared/analytics'; +import { trackQueue } from 'shared/bull/queues'; + +type Input = { + input: { + id: string, + }, +}; + +export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { + const { id: communityId } = args.input; + const { user, loaders } = ctx; + + if (!await canModerateCommunity(user.id, communityId, loaders)) { + trackQueue.add({ + userId: user.id, + event: events.COMMUNITY_JOIN_TOKEN_ENABLED_FAILED, + context: { communityId }, + properties: { + reason: 'no permission', + }, + }); + + return new UserError('You don’t have permission to manage this Community'); + } + + return await getOrCreateCommunitySettings(communityId).then( + async () => await enableCommunityTokenJoin(communityId, user.id) + ); +}); diff --git a/api/mutations/community/index.js b/api/mutations/community/index.js index ffd2b807bc..3d6347ec41 100644 --- a/api/mutations/community/index.js +++ b/api/mutations/community/index.js @@ -17,6 +17,9 @@ import enableBrandedLogin from './enableBrandedLogin'; import disableBrandedLogin from './disableBrandedLogin'; import saveBrandedLoginSettings from './saveBrandedLoginSettings'; import importSlackMembers from './importSlackMembers'; +import enableCommunityTokenJoin from './enableCommunityTokenJoin'; +import disableCommunityTokenJoin from './disableCommunityTokenJoin'; +import resetCommunityJoinToken from './resetCommunityJoinToken'; module.exports = { Mutation: { @@ -38,5 +41,8 @@ module.exports = { disableBrandedLogin, saveBrandedLoginSettings, importSlackMembers, + enableCommunityTokenJoin, + disableCommunityTokenJoin, + resetCommunityJoinToken, }, }; diff --git a/api/mutations/community/resetCommunityJoinToken.js b/api/mutations/community/resetCommunityJoinToken.js new file mode 100644 index 0000000000..2402ce33a1 --- /dev/null +++ b/api/mutations/community/resetCommunityJoinToken.js @@ -0,0 +1,41 @@ +// @flow +import type { GraphQLContext } from '../../'; +import UserError from '../../utils/UserError'; +import { + getOrCreateCommunitySettings, + resetCommunityJoinToken, +} from '../../models/communitySettings'; +import { + isAuthedResolver as requireAuth, + canModerateCommunity, +} from '../../utils/permissions'; +import { events } from 'shared/analytics'; +import { trackQueue } from 'shared/bull/queues'; + +type Input = { + input: { + id: string, + }, +}; + +export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { + const { id: communityId } = args.input; + const { user, loaders } = ctx; + + if (!await canModerateCommunity(user.id, communityId, loaders)) { + trackQueue.add({ + userId: user.id, + event: events.COMMUNITY_JOIN_TOKEN_RESET_FAILED, + context: { communityId }, + properties: { + reason: 'no permission', + }, + }); + + return new UserError('You don’t have permission to manage this community'); + } + + return await getOrCreateCommunitySettings(communityId).then( + async () => await resetCommunityJoinToken(communityId, user.id) + ); +}); diff --git a/api/mutations/communityMember/addCommunityMemberWithToken.js b/api/mutations/communityMember/addCommunityMemberWithToken.js new file mode 100644 index 0000000000..4896407a92 --- /dev/null +++ b/api/mutations/communityMember/addCommunityMemberWithToken.js @@ -0,0 +1,115 @@ +// @flow +import type { GraphQLContext } from '../../'; +import UserError from '../../utils/UserError'; +import { createMemberInDefaultChannels } from '../../models/usersChannels'; +import { + getUserPermissionsInCommunity, + createMemberInCommunity, + approvePendingMemberInCommunity, +} from '../../models/usersCommunities'; +import { getCommunityBySlug } from '../../models/community'; +import { getOrCreateCommunitySettings } from '../../models/communitySettings'; +import { isAuthedResolver as requireAuth } from '../../utils/permissions'; +import { trackQueue } from 'shared/bull/queues'; +import { events } from 'shared/analytics'; + +type Input = { + input: { + communitySlug: string, + communitySlug: string, + token: string, + }, +}; + +export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { + const { user } = ctx; + const { communitySlug, token } = args.input; + + const community = await getCommunityBySlug(communitySlug); + + if (!community) { + trackQueue.add({ + userId: user.id, + event: events.USER_JOINED_COMMUNITY_WITH_TOKEN_FAILED, + properties: { + reason: 'no community', + }, + }); + + return new UserError('No community found in this community'); + } + + if (!community.isPrivate) { + return community; + } + + const [communityPermissions, settings] = await Promise.all([ + getUserPermissionsInCommunity(community.id, user.id), + getOrCreateCommunitySettings(community.id), + ]); + + if ( + communityPermissions.isOwner || + communityPermissions.isModerator || + communityPermissions.isMember + ) { + return community; + } + + if (communityPermissions.isBlocked) { + trackQueue.add({ + userId: user.id, + event: events.USER_JOINED_COMMUNITY_WITH_TOKEN_FAILED, + context: { communityId: community.id }, + properties: { + reason: 'no permission', + }, + }); + + return new UserError("You don't have permission to view this community"); + } + + if (!settings.joinSettings || !settings.joinSettings.tokenJoinEnabled) { + trackQueue.add({ + userId: user.id, + event: events.USER_JOINED_COMMUNITY_WITH_TOKEN_FAILED, + context: { communityId: community.id }, + properties: { + reason: 'no token or changed token', + }, + }); + + return new UserError( + "You can't join at this time, the token may have changed" + ); + } + if ( + settings.joinSettings.tokenJoinEnabled && + token !== settings.joinSettings.token + ) { + trackQueue.add({ + userId: user.id, + event: events.USER_JOINED_COMMUNITY_WITH_TOKEN_FAILED, + context: { communityId: community.id }, + properties: { + reason: 'no token or changed token', + }, + }); + + return new UserError( + "You can't join at this time, the token may have changed" + ); + } + + if (communityPermissions.isPending) { + return await Promise.all([ + approvePendingMemberInCommunity(community.id, user.id), + createMemberInDefaultChannels(community.id, user.id), + ]).then(() => community); + } + + return await Promise.all([ + createMemberInCommunity(community.id, user.id), + createMemberInDefaultChannels(community.id, user.id), + ]).then(() => community); +}); diff --git a/api/mutations/communityMember/addPendingCommunityMember.js b/api/mutations/communityMember/addPendingCommunityMember.js new file mode 100644 index 0000000000..8115c0c397 --- /dev/null +++ b/api/mutations/communityMember/addPendingCommunityMember.js @@ -0,0 +1,145 @@ +// @flow +import type { GraphQLContext } from '../../'; +import UserError from '../../utils/UserError'; +import { getCommunityById } from '../../models/community'; +import { + createMemberInCommunity, + createPendingMemberInCommunity, + checkUserPermissionsInCommunity, +} from '../../models/usersCommunities'; +import { createMemberInDefaultChannels } from '../../models/usersChannels'; +import { isAuthedResolver as requireAuth } from '../../utils/permissions'; +import { events } from 'shared/analytics'; +import { + trackQueue, + sendPrivateCommunityRequestQueue, +} from 'shared/bull/queues'; + +type Input = { + input: { + communityId: string, + }, +}; + +export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { + const { user } = ctx; + const { communityId } = args.input; + + const [permissions, community] = await Promise.all([ + checkUserPermissionsInCommunity(communityId, user.id), + getCommunityById(communityId), + ]); + + if (!community) { + trackQueue.add({ + userId: user.id, + event: events.USER_REQUESTED_TO_JOIN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'no community', + }, + }); + + return new UserError("We couldn't find that community."); + } + + // shouldn't happen, but handle this case anyways + if (!community.isPrivate) { + if (!permissions || permissions.length === 0) { + return await Promise.all([ + createMemberInCommunity(communityId, user.id), + createMemberInDefaultChannels(communityId, user.id), + ]) + // return the community to fulfill the resolver + .then(() => community); + } + + const permission = permissions[0]; + + if (permission.isBlocked) { + trackQueue.add({ + userId: user.id, + event: events.USER_REQUESTED_TO_JOIN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'user blocked', + }, + }); + + return new UserError("You aren't able to join this community."); + } + + if (permission.isOwner || permission.isModerator || permission.isMember) { + trackQueue.add({ + userId: user.id, + event: events.USER_REQUESTED_TO_JOIN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'already member', + }, + }); + + return new UserError("You're already a member of this community."); + } + + return await Promise.all([ + createMemberInCommunity(communityId, user.id), + createMemberInDefaultChannels(communityId, user.id), + ]) + // return the community to fulfill the resolver + .then(() => community); + } + + const permission = permissions[0]; + + if (permission && permission.isBlocked) { + trackQueue.add({ + userId: user.id, + event: events.USER_REQUESTED_TO_JOIN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'user blocked', + }, + }); + + return new UserError("You aren't able to join this community."); + } + + if ( + permission && + (permission.isOwner || permission.isModerator || permissions.isMember) + ) { + trackQueue.add({ + userId: user.id, + event: events.USER_REQUESTED_TO_JOIN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'already member', + }, + }); + + return new UserError("You're already a member of this community."); + } + + if (permission && permission.isPending) { + trackQueue.add({ + userId: user.id, + event: events.USER_REQUESTED_TO_JOIN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'already pending', + }, + }); + + return new UserError('You have already requested to join this community.'); + } + + sendPrivateCommunityRequestQueue.add({ + userId: user.id, + communityId, + }); + + return await createPendingMemberInCommunity(communityId, user.id).then( + () => community + ); +}); diff --git a/api/mutations/communityMember/approvePendingCommunityMember.js b/api/mutations/communityMember/approvePendingCommunityMember.js new file mode 100644 index 0000000000..d3ef72f18d --- /dev/null +++ b/api/mutations/communityMember/approvePendingCommunityMember.js @@ -0,0 +1,141 @@ +// @flow +import type { GraphQLContext } from '../../'; +import UserError from '../../utils/UserError'; +import { getCommunityById } from '../../models/community'; +import { createMemberInDefaultChannels } from '../../models/usersChannels'; +import { + approvePendingMemberInCommunity, + checkUserPermissionsInCommunity, +} from '../../models/usersCommunities'; +import { + isAuthedResolver as requireAuth, + canModerateCommunity, +} from '../../utils/permissions'; +import { events } from 'shared/analytics'; +import { + trackQueue, + sendPrivateCommunityRequestApprovedQueue, +} from 'shared/bull/queues'; + +type Input = { + input: { + userId: string, + communityId: string, + }, +}; + +export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { + const { user, loaders } = ctx; + const { communityId, userId: userToEvaluateId } = args.input; + + if (!await canModerateCommunity(user.id, communityId, loaders)) { + trackQueue.add({ + userId: user.id, + event: events.USER_APPROVED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'no permission', + }, + }); + + return new UserError( + 'You must own or moderate this community to manage members.' + ); + } + + const [userToEvaluatePermissions, community] = await Promise.all([ + checkUserPermissionsInCommunity(communityId, userToEvaluateId), + getCommunityById(communityId), + ]); + + if (!community) { + trackQueue.add({ + userId: user.id, + event: events.USER_APPROVED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'no community', + }, + }); + + return new UserError("We couldn't find that community."); + } + + if (!userToEvaluatePermissions || userToEvaluatePermissions === 0) { + trackQueue.add({ + userId: user.id, + event: events.USER_APPROVED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'not pending', + }, + }); + + return new UserError( + 'This person is not requesting to join your community.' + ); + } + + const userToEvaluatePermission = userToEvaluatePermissions[0]; + + if (!userToEvaluatePermission.isPending) { + trackQueue.add({ + userId: user.id, + event: events.USER_APPROVED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'not pending', + }, + }); + + return new UserError( + 'This person is not requesting to join your community.' + ); + } + + if (userToEvaluatePermission.isBlocked) { + trackQueue.add({ + userId: user.id, + event: events.USER_APPROVED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'blocked', + }, + }); + + return new UserError('This person is already blocked in your community.'); + } + + return await Promise.all([ + approvePendingMemberInCommunity(communityId, userToEvaluateId), + createMemberInDefaultChannels(communityId, userToEvaluateId), + ]) + .then(([newPermissions]) => { + sendPrivateCommunityRequestApprovedQueue.add({ + userId: userToEvaluateId, + communityId, + moderatorId: user.id, + }); + + trackQueue.add({ + userId: user.id, + event: events.USER_APPROVED_PENDING_MEMBER_IN_COMMUNITY, + context: { communityId }, + }); + + return newPermissions; + }) + .catch(err => { + trackQueue.add({ + userId: user.id, + event: events.USER_APPROVED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'unknown error', + error: err.message, + }, + }); + + return new UserError(err); + }); +}); diff --git a/api/mutations/communityMember/blockPendingCommunityMember.js b/api/mutations/communityMember/blockPendingCommunityMember.js new file mode 100644 index 0000000000..885865919f --- /dev/null +++ b/api/mutations/communityMember/blockPendingCommunityMember.js @@ -0,0 +1,144 @@ +// @flow +import type { GraphQLContext } from '../../'; +import UserError from '../../utils/UserError'; +import { getCommunityById } from '../../models/community'; +import { blockUserInChannel } from '../../models/usersChannels'; +import { getChannelsByCommunity } from '../../models/channel'; +import { + blockPendingMemberInCommunity, + checkUserPermissionsInCommunity, +} from '../../models/usersCommunities'; +import { + isAuthedResolver as requireAuth, + canModerateCommunity, +} from '../../utils/permissions'; +import { events } from 'shared/analytics'; +import { trackQueue } from 'shared/bull/queues'; + +type Input = { + input: { + userId: string, + communityId: string, + }, +}; + +export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { + const { user, loaders } = ctx; + const { communityId, userId: userToEvaluateId } = args.input; + + if (!await canModerateCommunity(user.id, communityId, loaders)) { + trackQueue.add({ + userId: user.id, + event: events.USER_BLOCKED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'no permission', + }, + }); + + return new UserError( + 'You must own or moderate this community to manage members.' + ); + } + + const [userToEvaluatePermissions, community] = await Promise.all([ + checkUserPermissionsInCommunity(communityId, userToEvaluateId), + getCommunityById(communityId), + ]); + + if (!community) { + trackQueue.add({ + userId: user.id, + event: events.USER_BLOCKED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'no community', + }, + }); + + return new UserError("We couldn't find that community."); + } + + if (!userToEvaluatePermissions || userToEvaluatePermissions === 0) { + trackQueue.add({ + userId: user.id, + event: events.USER_BLOCKED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'not member', + }, + }); + + return new UserError('This person is not a member of your community.'); + } + + const userToEvaluatePermission = userToEvaluatePermissions[0]; + + if ( + !userToEvaluatePermission.isMember && + !userToEvaluatePermission.isPending + ) { + trackQueue.add({ + userId: user.id, + event: events.USER_BLOCKED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'not member', + }, + }); + + return new UserError('This person is not a member of your community.'); + } + + if (userToEvaluatePermission.isBlocked) { + trackQueue.add({ + userId: user.id, + event: events.USER_BLOCKED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'blocked', + }, + }); + + return new UserError('This person is already blocked in your community.'); + } + + const channels = await getChannelsByCommunity(community.id); + const channelIds = channels.map(c => c.id); + const blockInChannelPromises = channelIds.map(async channelId => { + trackQueue.add({ + userId: user.id, + event: events.USER_BLOCKED_MEMBER_IN_CHANNEL, + context: { channelId }, + }); + + return await blockUserInChannel(channelId, userToEvaluateId); + }); + + return await Promise.all([ + blockPendingMemberInCommunity(communityId, userToEvaluateId), + ...blockInChannelPromises, + ]) + .then(([newPermissions]) => { + trackQueue.add({ + userId: user.id, + event: events.USER_BLOCKED_PENDING_MEMBER_IN_COMMUNITY, + context: { communityId }, + }); + + return newPermissions; + }) + .catch(err => { + trackQueue.add({ + userId: user.id, + event: events.USER_BLOCKED_PENDING_MEMBER_IN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'unknown error', + error: err.message, + }, + }); + + return new UserError(err); + }); +}); diff --git a/api/mutations/communityMember/index.js b/api/mutations/communityMember/index.js index 7b31ee081b..49ad126308 100644 --- a/api/mutations/communityMember/index.js +++ b/api/mutations/communityMember/index.js @@ -1,18 +1,28 @@ // @flow import addCommunityMember from './addCommunityMember'; +import addCommunityMemberWithToken from './addCommunityMemberWithToken'; +import addPendingCommunityMember from './addPendingCommunityMember'; import removeCommunityMember from './removeCommunityMember'; +import removePendingCommunityMember from './removePendingCommunityMember'; import addCommunityModerator from './addCommunityModerator'; +import approvePendingCommunityMember from './approvePendingCommunityMember'; import removeCommunityModerator from './removeCommunityModerator'; import blockCommunityMember from './blockCommunityMember'; +import blockPendingCommunityMember from './blockPendingCommunityMember'; import unblockCommunityMember from './unblockCommunityMember'; module.exports = { Mutation: { addCommunityMember, + addCommunityMemberWithToken, + addPendingCommunityMember, removeCommunityMember, + removePendingCommunityMember, addCommunityModerator, + approvePendingCommunityMember, removeCommunityModerator, blockCommunityMember, + blockPendingCommunityMember, unblockCommunityMember, }, }; diff --git a/api/mutations/communityMember/removePendingCommunityMember.js b/api/mutations/communityMember/removePendingCommunityMember.js new file mode 100644 index 0000000000..bc26406438 --- /dev/null +++ b/api/mutations/communityMember/removePendingCommunityMember.js @@ -0,0 +1,58 @@ +// @flow +import type { GraphQLContext } from '../../'; +import UserError from '../../utils/UserError'; +import { getCommunityById } from '../../models/community'; +import { + removePendingMemberInCommunity, + checkUserPermissionsInCommunity, +} from '../../models/usersCommunities'; +import { isAuthedResolver as requireAuth } from '../../utils/permissions'; +import { events } from 'shared/analytics'; +import { trackQueue } from 'shared/bull/queues'; + +type Input = { + input: { + communityId: string, + }, +}; + +export default requireAuth(async (_: any, args: Input, ctx: GraphQLContext) => { + const { user } = ctx; + const { communityId } = args.input; + + const [permissions, community] = await Promise.all([ + checkUserPermissionsInCommunity(communityId, user.id), + getCommunityById(communityId), + ]); + + // if no permissions exist, the user wasn't pending to begin with + if (!permissions || permissions.length === 0) { + trackQueue.add({ + userId: user.id, + event: events.USER_CANCELED_REQUEST_TO_JOIN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'not pending', + }, + }); + + return community; + } + + if (!community) { + trackQueue.add({ + userId: user.id, + event: events.USER_CANCELED_REQUEST_TO_JOIN_COMMUNITY_FAILED, + context: { communityId }, + properties: { + reason: 'no community', + }, + }); + + return new UserError("We couldn't find that community."); + } + + return await removePendingMemberInCommunity(communityId, user.id).then( + () => community + ); +}); diff --git a/api/queries/community/channelConnection.js b/api/queries/community/channelConnection.js index b2401579d0..43903355c6 100644 --- a/api/queries/community/channelConnection.js +++ b/api/queries/community/channelConnection.js @@ -1,14 +1,29 @@ // @flow +import type { GraphQLContext } from '../../'; import type { DBCommunity } from 'shared/types'; import { getChannelsByCommunity } from '../../models/channel'; +import { canViewCommunity } from '../../utils/permissions'; -export default ({ id }: DBCommunity) => ({ - pageInfo: { - hasNextPage: false, - }, - edges: getChannelsByCommunity(id).then(channels => - channels.map(channel => ({ - node: channel, - })) - ), -}); +export default async ({ id }: DBCommunity, _: any, ctx: GraphQLContext) => { + const { user, loaders } = ctx; + + if (!await canViewCommunity(user, id, loaders)) { + return { + pageInfo: { + hasNextPage: false, + }, + edges: [], + }; + } + + return { + pageInfo: { + hasNextPage: false, + }, + edges: getChannelsByCommunity(id).then(channels => + channels.map(channel => ({ + node: channel, + })) + ), + }; +}; diff --git a/api/queries/community/hasFeatures.js b/api/queries/community/hasFeatures.js index 8ca9e4cbe4..a1e44670f6 100644 --- a/api/queries/community/hasFeatures.js +++ b/api/queries/community/hasFeatures.js @@ -2,7 +2,6 @@ const debug = require('debug')('api:queries:community:has-features'); import type { GraphQLContext } from '../..'; import type { DBCommunity } from 'shared/types'; -import UserError from '../../utils/UserError'; import { StripeUtil } from 'shared/stripe/utils'; import { COMMUNITY_ANALYTICS } from 'pluto/queues/constants'; diff --git a/api/queries/community/index.js b/api/queries/community/index.js index 39323a3b6b..233351f92f 100644 --- a/api/queries/community/index.js +++ b/api/queries/community/index.js @@ -29,6 +29,7 @@ import hasChargeableSource from './hasChargeableSource'; import hasFeatures from './hasFeatures'; import brandedLogin from './brandedLogin'; import slackSettings from './slackSettings'; +import joinSettings from './joinSettings'; module.exports = { Query: { @@ -62,5 +63,6 @@ module.exports = { hasFeatures, brandedLogin, slackSettings, + joinSettings, }, }; diff --git a/api/queries/community/joinSettings.js b/api/queries/community/joinSettings.js new file mode 100644 index 0000000000..b194ce24b8 --- /dev/null +++ b/api/queries/community/joinSettings.js @@ -0,0 +1,13 @@ +// @flow +import type { DBCommunity } from 'shared/types'; +import type { GraphQLContext } from '../../'; + +export default async ( + { id }: DBCommunity, + _: any, + { loaders }: GraphQLContext +) => { + return loaders.communitySettings.load(id).then(settings => { + return settings.joinSettings; + }); +}; diff --git a/api/queries/community/memberConnection.js b/api/queries/community/memberConnection.js index dd3b40e000..01ca75f99e 100644 --- a/api/queries/community/memberConnection.js +++ b/api/queries/community/memberConnection.js @@ -9,6 +9,7 @@ import type { GraphQLContext } from '../../'; import type { PaginationOptions } from '../../utils/paginate-arrays'; import { encode, decode } from '../../utils/base64'; const { getMembersInCommunity } = require('../../models/usersCommunities'); +import { canViewCommunity } from '../../utils/permissions'; type MemberConnectionFilterType = { isMember?: boolean, @@ -18,15 +19,26 @@ type MemberConnectionFilterType = { isBlocked?: boolean, }; -export default ( - { id }: DBCommunity, - { - first = 10, - after, - filter, - }: { ...$Exact, filter: MemberConnectionFilterType }, - { loaders }: GraphQLContext -) => { +type Args = { + ...$Exact, + filter: MemberConnectionFilterType, +}; + +export default async (root: DBCommunity, args: Args, ctx: GraphQLContext) => { + const { user, loaders } = ctx; + const { id } = root; + + if (!await canViewCommunity(user, id, loaders)) { + return { + pageInfo: { + hasNextPage: false, + }, + edges: [], + }; + } + + const { first = 10, after, filter } = args; + const cursor = decode(after); // Get the index from the encoded cursor, asdf234gsdf-2 => ["-2", "2"] const lastDigits = cursor.match(/-(\d+)$/); diff --git a/api/queries/community/members.js b/api/queries/community/members.js index faa1861e57..79256d1679 100644 --- a/api/queries/community/members.js +++ b/api/queries/community/members.js @@ -3,6 +3,7 @@ import type { DBCommunity } from 'shared/types'; import type { GraphQLContext } from '../../'; import type { PaginationOptions } from '../../utils/paginate-arrays'; import { encode, decode } from '../../utils/base64'; +import { canViewCommunity } from '../../utils/permissions'; const { getMembersInCommunity } = require('../../models/usersCommunities'); type MembersFilterType = { @@ -13,15 +14,25 @@ type MembersFilterType = { isBlocked?: boolean, }; -export default ( - { id }: DBCommunity, - { - first = 10, - after, - filter, - }: { ...$Exact, filter: MembersFilterType }, - { loaders }: GraphQLContext -) => { +type Args = { + ...$Exact, + filter: MembersFilterType, +}; + +export default async (root: DBCommunity, args: Args, ctx: GraphQLContext) => { + const { id } = root; + const { user, loaders } = ctx; + + if (!await canViewCommunity(user, id, loaders)) { + return { + pageInfo: { + hasNextPage: false, + }, + edges: [], + }; + } + + const { first = 10, after, filter } = args; const cursor = decode(after); // Get the index from the encoded cursor, asdf234gsdf-2 => ["-2", "2"] const lastDigits = cursor.match(/-(\d+)$/); diff --git a/api/queries/community/metaData.js b/api/queries/community/metaData.js index 4116433f4c..89721e247d 100644 --- a/api/queries/community/metaData.js +++ b/api/queries/community/metaData.js @@ -1,23 +1,26 @@ // @flow import type { DBCommunity } from 'shared/types'; import type { GraphQLContext } from '../../'; +import { canViewCommunity } from '../../utils/permissions'; -type MemberOrChannelCount = { - reduction?: number, -}; +export default async (root: DBCommunity, _: any, ctx: GraphQLContext) => { + const { user, loaders } = ctx; + const { id } = root; + + if (!await canViewCommunity(user, id, loaders)) { + return { + channels: 0, + members: 0, + }; + } -export default ({ id }: DBCommunity, _: any, { loaders }: GraphQLContext) => { - // $FlowIssue - return Promise.all([ + const [channelCount, memberCount] = await Promise.all([ loaders.communityChannelCount.load(id), loaders.communityMemberCount.load(id), - ]).then( - ([channelCount, memberCount]: [ - MemberOrChannelCount, - MemberOrChannelCount, - ]) => ({ - channels: channelCount ? channelCount.reduction : 0, - members: memberCount ? memberCount.reduction : 0, - }) - ); + ]); + + return { + channels: channelCount ? channelCount.reduction : 0, + members: memberCount ? memberCount.reduction : 0, + }; }; diff --git a/api/queries/community/pinnedThread.js b/api/queries/community/pinnedThread.js index 537b0eac9b..3cdf3c2a9f 100644 --- a/api/queries/community/pinnedThread.js +++ b/api/queries/community/pinnedThread.js @@ -1,14 +1,18 @@ // @flow +import type { GraphQLContext } from '../../'; import type { DBCommunity } from 'shared/types'; -import { getThreads } from '../../models/thread'; +import { getThreadById } from '../../models/thread'; +import { canViewCommunity } from '../../utils/permissions'; -export default async ({ pinnedThreadId }: DBCommunity) => { - let pinnedThread; - if (pinnedThreadId) { - pinnedThread = await getThreads([pinnedThreadId]); +export default async (root: DBCommunity, _: any, ctx: GraphQLContext) => { + const { user, loaders } = ctx; + const { pinnedThreadId, id } = root; + + if (!pinnedThreadId) return null; + + if (!await canViewCommunity(user, id, loaders)) { + return null; } - if (pinnedThread && Array.isArray(pinnedThread) && pinnedThread.length > 0) - return pinnedThread[0]; - return null; + return await getThreadById(pinnedThreadId); }; diff --git a/api/queries/community/threadConnection.js b/api/queries/community/threadConnection.js index 3577458618..c6134944cd 100644 --- a/api/queries/community/threadConnection.js +++ b/api/queries/community/threadConnection.js @@ -8,12 +8,23 @@ import { getPublicChannelsByCommunity, } from '../../models/channel'; import { getThreadsByChannels } from '../../models/thread'; +import { canViewCommunity } from '../../utils/permissions'; + +// prettier-ignore +export default async (root: DBCommunity, args: PaginationOptions, ctx: GraphQLContext) => { + const { first = 10, after } = args + const { user, loaders } = ctx + const { id } = root + + if (!await canViewCommunity(user, id, loaders)) { + return { + pageInfo: { + hasNextPage: false, + }, + edges: [] + } + } -export default async ( - { id }: DBCommunity, - { first = 10, after }: PaginationOptions, - { user }: GraphQLContext -) => { const cursor = decode(after); // Get the index from the encoded cursor, asdf234gsdf-2 => ["-2", "2"] const lastDigits = cursor.match(/-(\d+)$/); diff --git a/api/queries/community/watercooler.js b/api/queries/community/watercooler.js index 0ef929d218..50a4ef73c4 100644 --- a/api/queries/community/watercooler.js +++ b/api/queries/community/watercooler.js @@ -1,9 +1,18 @@ // @flow +import type { GraphQLContext } from '../../'; import type { DBCommunity } from 'shared/types'; import { getThreads } from '../../models/thread'; +import { canViewCommunity } from '../../utils/permissions'; -export default async ({ watercoolerId }: DBCommunity) => { +export default async (root: DBCommunity, _: any, ctx: GraphQLContext) => { + const { watercoolerId, id } = root; + const { user, loaders } = ctx; if (!watercoolerId) return null; + + if (!await canViewCommunity(user, id, loaders)) { + return null; + } + return await getThreads([watercoolerId]).then( res => (res && res.length > 0 ? res[0] : null) ); diff --git a/api/queries/communityMember/roles.js b/api/queries/communityMember/roles.js index 23124d9636..909c74e6d9 100644 --- a/api/queries/communityMember/roles.js +++ b/api/queries/communityMember/roles.js @@ -1,10 +1,16 @@ // @flow import type { DBUsersCommunities } from 'shared/types'; -export default ({ isModerator, isOwner, isBlocked }: DBUsersCommunities) => { +export default ({ + isModerator, + isOwner, + isBlocked, + isPending, +}: DBUsersCommunities) => { const roles = []; if (isModerator) roles.push('moderator'); if (isOwner) roles.push('admin'); if (isBlocked) roles.push('blocked'); + if (isPending) roles.push('pending'); return roles; }; diff --git a/api/queries/search/searchCommunities.js b/api/queries/search/searchCommunities.js index 988f113d16..6b52db268e 100644 --- a/api/queries/search/searchCommunities.js +++ b/api/queries/search/searchCommunities.js @@ -26,6 +26,7 @@ export default (args: Args, { loaders, user }: GraphQLContext) => { return loaders.community.loadMany(communityIds); }) .then(data => data.filter(Boolean)) + .then(data => data.filter(community => !community.isPrivate)) .catch(err => { console.error('err', err); }); diff --git a/api/queries/search/searchThreads.js b/api/queries/search/searchThreads.js index 1abab6ea3f..333f752235 100644 --- a/api/queries/search/searchThreads.js +++ b/api/queries/search/searchThreads.js @@ -12,14 +12,18 @@ import { DEFAULT_USER_CHANNEL_PERMISSIONS, } from '../../models/usersChannels'; import { getChannelById, getChannels } from '../../models/channel'; -import { getCommunityById } from '../../models/community'; +import { getCommunityById, getCommunities } from '../../models/community'; import { getPublicChannelIdsInCommunity, + getPublicCommunityIdsForUsersThreads, getPrivateChannelIdsInCommunity, + getPrivateCommunityIdsForUsersThreads, getUsersJoinedPrivateChannelIds, + getUsersJoinedPrivateCommunityIds, getPublicChannelIdsForUsersThreads, getPrivateChannelIdsForUsersThreads, getUsersJoinedChannels, + getUsersJoinedCommunities, } from '../../models/search'; import { trackQueue } from 'shared/bull/queues'; import { events } from 'shared/analytics'; @@ -67,24 +71,36 @@ export default async (args: Args, { loaders, user }: GraphQLContext) => { // if no threads exist, send an empty array to the client if (!searchResultThreads || searchResultThreads.length === 0) return []; - const getChannel = getChannelById(channelId); + const channel = await getChannelById(channelId); + + // channel doesn't exist + if (!channel) return []; + + const usersPermissionsInCommunity = IS_AUTHED_USER + ? getUserPermissionsInCommunity(channel.communityId, user.id) + : DEFAULT_USER_COMMUNITY_PERMISSIONS; + const usersPermissionsInChannel = IS_AUTHED_USER ? getUserPermissionsInChannel(channelId, user.id) : DEFAULT_USER_CHANNEL_PERMISSIONS; - const [channel, permissions] = await Promise.all([ - getChannel, + const [ + community, + communityPermissions, + channelPermissions, + ] = await Promise.all([ + getCommunityById(channel.communityId), + usersPermissionsInCommunity, usersPermissionsInChannel, ]); - // channel doesn't exist - if (!channel) return []; - if (permissions.isBlocked) return []; + if (!community) return []; - // if the channel is private and the user isn't a member - if (channel.isPrivate && !permissions.isMember) { - return []; - } + if (community.isPrivate && !communityPermissions.isMember) return []; + + if (channelPermissions.isBlocked) return []; + + if (channel.isPrivate && !channelPermissions.isMember) return []; searchResultThreads = searchResultThreads.filter( t => t.channelId === channel.id @@ -130,14 +146,16 @@ export default async (args: Args, { loaders, user }: GraphQLContext) => { getCurrentUsersPermissionInCommunity, ]); - // community is deleted or not found if (!community) return []; + if (community.isPrivate && !currentUserPermissionInCommunity.isMember) + return []; if (currentUserPermissionInCommunity.isBlocked) return []; const privateChannelsWhereUserIsMember = intersection( privateChannels, currentUsersPrivateChannels ); + const availableChannelsForSearch = [ ...publicChannels, ...privateChannelsWhereUserIsMember, @@ -161,6 +179,15 @@ export default async (args: Args, { loaders, user }: GraphQLContext) => { if (!searchResultThreads || searchResultThreads.length === 0) return []; const getPublicChannelIds = getPublicChannelIdsForUsersThreads(creatorId); + const getPublicCommunityIds = getPublicCommunityIdsForUsersThreads( + creatorId + ); + const getPrivateCommunityIds = IS_AUTHED_USER + ? getPrivateCommunityIdsForUsersThreads(creatorId) + : []; + const getCurrentUsersCommunityIds = IS_AUTHED_USER + ? getUsersJoinedPrivateCommunityIds(user.id) + : []; const getPrivateChannelIds = IS_AUTHED_USER ? getPrivateChannelIdsForUsersThreads(creatorId) : []; @@ -169,19 +196,36 @@ export default async (args: Args, { loaders, user }: GraphQLContext) => { : []; const [ + publicCommunities, + privateCommunities, publicChannels, privateChannels, currentUsersPrivateChannels, + currentUsersPrivateCommunities, ] = await Promise.all([ + getPublicCommunityIds, + getPrivateCommunityIds, getPublicChannelIds, getPrivateChannelIds, getCurrentUsersChannelIds, + getCurrentUsersCommunityIds, ]); + const privateCommunitiesWhereUserIsMember = intersection( + privateCommunities, + currentUsersPrivateCommunities + ); + + const availableCommunitiesForSearch = [ + ...publicCommunities, + ...privateCommunitiesWhereUserIsMember, + ]; + const privateChannelsWhereUserIsMember = intersection( privateChannels, currentUsersPrivateChannels ); + const availableChannelsForSearch = [ ...publicChannels, ...privateChannelsWhereUserIsMember, @@ -189,6 +233,7 @@ export default async (args: Args, { loaders, user }: GraphQLContext) => { searchResultThreads = searchResultThreads .filter(t => availableChannelsForSearch.indexOf(t.channelId) >= 0) + .filter(t => availableCommunitiesForSearch.indexOf(t.communityId) >= 0) .filter(t => t.creatorId === searchFilter.creatorId); return loaders.thread @@ -204,17 +249,22 @@ export default async (args: Args, { loaders, user }: GraphQLContext) => { // if no threads exist, send an empty array to the client if (!searchResultThreads || searchResultThreads.length === 0) return []; + const getAvailableCommunityids = IS_AUTHED_USER + ? getUsersJoinedCommunities(user.id) + : []; + const getAvailableChannelIds = IS_AUTHED_USER ? getUsersJoinedChannels(user.id) : []; - const [availableChannelsForSearch] = await Promise.all([ - getAvailableChannelIds, - ]); + const [ + availableCommunitiesForSearch, + availableChannelsForSearch, + ] = await Promise.all([getAvailableCommunityids, getAvailableChannelIds]); - searchResultThreads = searchResultThreads.filter( - t => availableChannelsForSearch.indexOf(t.channelId) >= 0 - ); + searchResultThreads = searchResultThreads + .filter(t => availableChannelsForSearch.indexOf(t.channelId) >= 0) + .filter(t => availableCommunitiesForSearch.indexOf(t.communityId) >= 0); return loaders.thread .loadMany(searchResultThreads.map(t => t.threadId)) @@ -233,14 +283,25 @@ export default async (args: Args, { loaders, user }: GraphQLContext) => { searchResultThreads.map(t => t.channelId) ); + const communitiesOfThreads = await getCommunities( + searchResultThreads.map(t => t.communityId) + ); + // see if any channels where thread results were found are private const privateChannelIds = channelsOfThreads .filter(c => c.isPrivate) .map(c => c.id); + const privateCommunityIds = communitiesOfThreads + .filter(c => c.isPrivate) + .map(c => c.id); + // if the search results contain threads that aren't in any private channels, // send down the results - if (!privateChannelIds || privateChannelIds.length === 0) { + if ( + (!privateChannelIds || privateChannelIds.length === 0) && + (!privateCommunityIds || privateCommunityIds.length === 0) + ) { return loaders.thread .loadMany(searchResultThreads.map(t => t.threadId)) .then(data => data.filter(thread => thread && !thread.deletedAt)); @@ -251,18 +312,32 @@ export default async (args: Args, { loaders, user }: GraphQLContext) => { ? await getUsersJoinedPrivateChannelIds(user.id) : []; + const currentUsersPrivateCommunityIds = IS_AUTHED_USER + ? await getUsersJoinedPrivateCommunityIds(user.id) + : []; + // find which private channels the user is a member of const availablePrivateChannels = currentUsersPrivateChannelIds.length > 0 ? intersection(privateChannelIds, currentUsersPrivateChannelIds) : []; + const availablePrivateCommunities = + currentUsersPrivateCommunityIds.length > 0 + ? intersection(privateCommunityIds, currentUsersPrivateCommunityIds) + : []; + // for each thread in the search results, determine if it was posted in // a private channel. if yes, is the current user a member? searchResultThreads = searchResultThreads.filter(thread => { if (privateChannelIds.indexOf(thread.channelId) >= 0) { return availablePrivateChannels.indexOf(thread.channelId) >= 0; } + + if (privateCommunityIds.indexOf(thread.communityId) >= 0) { + return availablePrivateCommunities.indexOf(thread.communityId) >= 0; + } + return thread; }); diff --git a/api/queries/thread/rootThread.js b/api/queries/thread/rootThread.js index 383913366f..39727209f8 100644 --- a/api/queries/thread/rootThread.js +++ b/api/queries/thread/rootThread.js @@ -15,20 +15,31 @@ export default async ( If no user exists, we need to make sure the thread being fetched is not in a private channel */ if (!user) { - const channel = await loaders.channel.load(thread.channelId); + const [channel, community] = await Promise.all([ + loaders.channel.load(thread.channelId), + loaders.community.load(thread.communityId), + ]); // if the channel is private, don't return any thread data - if (channel.isPrivate) return null; + if (channel.isPrivate || community.isPrivate) return null; return thread; } else { // if the user is signed in, we need to check if the channel is private as well as the user's permission in that channel - const [permissions, channel] = await Promise.all([ + const [ + channelPermissions, + channel, + communityPermissions, + community, + ] = await Promise.all([ loaders.userPermissionsInChannel.load([user.id, thread.channelId]), loaders.channel.load(thread.channelId), + loaders.userPermissionsInCommunity.load([user.id, thread.communityId]), + loaders.community.load(thread.communityId), ]); // if the thread is in a private channel where the user is not a member, don't return any thread data - if (channel.isPrivate && !permissions.isMember) return null; + if (channel.isPrivate && !channelPermissions.isMember) return null; + if (community.isPrivate && !communityPermissions.isMember) return null; return thread; } }; diff --git a/api/test/__snapshots__/user.test.js.snap b/api/test/__snapshots__/user.test.js.snap index d8b2b4915e..c52367d861 100644 --- a/api/test/__snapshots__/user.test.js.snap +++ b/api/test/__snapshots__/user.test.js.snap @@ -23,6 +23,11 @@ Object { "user": Object { "communityConnection": Object { "edges": Array [ + Object { + "node": Object { + "name": "Private community", + }, + }, Object { "node": Object { "name": "Payments", diff --git a/api/types/Channel.js b/api/types/Channel.js index 119ce91f9a..b12eb67db8 100644 --- a/api/types/Channel.js +++ b/api/types/Channel.js @@ -58,11 +58,6 @@ const Channel = /* GraphQL */ ` userId: ID! } - type JoinSettings { - tokenJoinEnabled: Boolean - token: String - } - type Channel { id: ID! createdAt: Date! diff --git a/api/types/Community.js b/api/types/Community.js index 6e37bb6614..714465e941 100644 --- a/api/types/Community.js +++ b/api/types/Community.js @@ -129,20 +129,21 @@ const Community = /* GraphQL */ ` type Community { id: ID! - createdAt: Date! + createdAt: Date name: String! slug: LowercaseString! - description: String! + description: String website: String profilePhoto: String coverPhoto: String reputation: Int pinnedThreadId: String pinnedThread: Thread + isPrivate: Boolean communityPermissions: CommunityPermissions @cost(complexity: 1) - channelConnection: CommunityChannelsConnection! @cost(complexity: 1) - members(first: Int = 10, after: String, filter: MembersFilter): CommunityMembers! @cost(complexity: 5, multiplier: "first") - threadConnection(first: Int = 10, after: String): CommunityThreadsConnection! @cost(complexity: 2, multiplier: "first") + channelConnection: CommunityChannelsConnection @cost(complexity: 1) + members(first: Int = 10, after: String, filter: MembersFilter): CommunityMembers @cost(complexity: 5, multiplier: "first") + threadConnection(first: Int = 10, after: String): CommunityThreadsConnection @cost(complexity: 2, multiplier: "first") metaData: CommunityMetaData @cost(complexity: 10) invoices: [Invoice] @cost(complexity: 1) recurringPayments: [RecurringPayment] @@ -153,6 +154,7 @@ const Community = /* GraphQL */ ` topAndNewThreads: TopAndNewThreads @cost(complexity: 4) watercooler: Thread brandedLogin: BrandedLogin + joinSettings: JoinSettings slackSettings: CommunitySlackSettings @cost(complexity: 2) hasFeatures: Features @@ -198,6 +200,7 @@ const Community = /* GraphQL */ ` website: String file: Upload coverFile: Upload + isPrivate: Boolean } input EditCommunityInput { @@ -273,6 +276,18 @@ const Community = /* GraphQL */ ` customMessage: String } + input EnableCommunityTokenJoinInput { + id: ID! + } + + input DisableCommunityTokenJoinInput { + id: ID! + } + + input ResetCommunityJoinTokenInput { + id: ID! + } + extend type Mutation { createCommunity(input: CreateCommunityInput!): Community editCommunity(input: EditCommunityInput!): Community @@ -294,6 +309,9 @@ const Community = /* GraphQL */ ` enableBrandedLogin(input: EnableBrandedLoginInput!): Community disableBrandedLogin(input: DisableBrandedLoginInput!): Community saveBrandedLoginSettings(input: SaveBrandedLoginSettingsInput!): Community + enableCommunityTokenJoin(input: EnableCommunityTokenJoinInput!): Community + disableCommunityTokenJoin(input: DisableCommunityTokenJoinInput!): Community + resetCommunityJoinToken(input: ResetCommunityJoinTokenInput!): Community } `; diff --git a/api/types/CommunityMember.js b/api/types/CommunityMember.js index 4b305a77ea..bc2609e23c 100644 --- a/api/types/CommunityMember.js +++ b/api/types/CommunityMember.js @@ -8,6 +8,7 @@ const CommunityMember = /* GraphQL */ ` isModerator: Boolean isOwner: Boolean isBlocked: Boolean + isPending: Boolean reputation: Int } @@ -43,8 +44,36 @@ const CommunityMember = /* GraphQL */ ` communityId: ID! } + input AddPendingCommunityMemberInput { + communityId: ID! + } + + input RemovePendingCommunityMemberInput { + communityId: ID! + } + + input ApprovePendingCommunityMemberInput { + userId: ID! + communityId: ID! + } + + input BlockPendingCommunityMemberInput { + userId: ID! + communityId: ID! + } + + input AddCommunityMemberWithTokenInput { + communitySlug: LowercaseString! + token: String! + } + extend type Mutation { addCommunityMember(input: AddCommunityMemberInput!): Community + addCommunityMemberWithToken(input: AddCommunityMemberWithTokenInput!): Community + addPendingCommunityMember(input: AddPendingCommunityMemberInput!): Community + removePendingCommunityMember(input: RemovePendingCommunityMemberInput!): Community + approvePendingCommunityMember(input: ApprovePendingCommunityMemberInput!): CommunityMember + blockPendingCommunityMember(input: BlockPendingCommunityMemberInput!): CommunityMember removeCommunityMember(input: RemoveCommunityMemberInput!): Community addCommunityModerator(input: AddCommunityModeratorInput!): CommunityMember removeCommunityModerator(input: RemoveCommunityModeratorInput!): CommunityMember diff --git a/api/types/Notification.js b/api/types/Notification.js index 0950ea9774..c13db93a74 100644 --- a/api/types/Notification.js +++ b/api/types/Notification.js @@ -16,6 +16,8 @@ const Notification = /* GraphQL */ ` MENTION_MESSAGE PRIVATE_CHANNEL_REQUEST_SENT PRIVATE_CHANNEL_REQUEST_APPROVED + PRIVATE_COMMUNITY_REQUEST_SENT + PRIVATE_COMMUNITY_REQUEST_APPROVED } enum EntityType { diff --git a/api/types/general.js b/api/types/general.js index 0bd0236035..f48ce5ea88 100644 --- a/api/types/general.js +++ b/api/types/general.js @@ -23,6 +23,7 @@ const general = /* GraphQL */ ` isBlocked: Boolean isOwner: Boolean isModerator: Boolean + isPending: Boolean receiveNotifications: Boolean reputation: Int } @@ -74,6 +75,11 @@ const general = /* GraphQL */ ` contacts: [ EmailInviteContactInput ] customMessage: String } + + type JoinSettings { + tokenJoinEnabled: Boolean + token: String + } `; module.exports = general; diff --git a/api/utils/permissions.js b/api/utils/permissions.js index b1fd816f52..60d5a28fc3 100644 --- a/api/utils/permissions.js +++ b/api/utils/permissions.js @@ -1,7 +1,7 @@ // @flow import UserError from './UserError'; import type { GraphQLContext } from '../'; -import type { DBChannel, DBCommunity } from 'shared/types'; +import type { DBChannel, DBCommunity, DBUser } from 'shared/types'; import { COMMUNITY_SLUG_BLACKLIST, CHANNEL_SLUG_BLACKLIST, @@ -47,11 +47,8 @@ const communityExists = async (communityId: string, loaders: any): Promise { +// prettier-ignore +export const canAdministerChannel = async (userId: string, channelId: string, loaders: any) => { if (!userId || !channelId) return false; const channel = await channelExists(channelId, loaders); @@ -71,11 +68,8 @@ export const canAdministerChannel = async ( return false; }; -export const canModerateChannel = async ( - userId: string, - channelId: string, - loaders: any -) => { +// prettier-ignore +export const canModerateChannel = async (userId: string, channelId: string, loaders: any) => { if (!userId || !channelId) return false; const channel = await channelExists(channelId, loaders); @@ -95,11 +89,8 @@ export const canModerateChannel = async ( return false; }; -export const canAdministerCommunity = async ( - userId: string, - communityId: string, - loaders: any -) => { +// prettier-ignore +export const canAdministerCommunity = async (userId: string, communityId: string, loaders: any) => { if (!userId || !communityId) return false; const community = await communityExists(communityId, loaders); @@ -114,6 +105,7 @@ export const canAdministerCommunity = async ( return false; }; +// prettier-igore export const canModerateCommunity = async ( userId: string, communityId: string, @@ -134,3 +126,25 @@ export const canModerateCommunity = async ( return true; return false; }; + +// prettier-ignore +export const canViewCommunity = async (user: DBUser, communityId: string, loaders: any) => { + if (!communityId) return false; + + const community = await communityExists(communityId, loaders); + if (!community) return false; + + if (!community.isPrivate) return true + + if (!user) return false + + const communityPermissions = await loaders.userPermissionsInCommunity.load([ + user.id, + communityId, + ]); + + if (!communityPermissions) return false; + if (!communityPermissions.isMember) return false + + return true; +} diff --git a/athena/index.js b/athena/index.js index 979075e685..510db14695 100644 --- a/athena/index.js +++ b/athena/index.js @@ -17,6 +17,8 @@ import processAdminMessageModeration from './queues/moderationEvents/message'; import processAdminThreadModeration from './queues/moderationEvents/thread'; import processUserRequestedJoinPrivateChannel from './queues/private-channel-request-sent'; import processUserRequestPrivateChannelApproved from './queues/private-channel-request-approved'; +import processUserRequestedJoinPrivateCommunity from './queues/private-community-request-sent'; +import processUserRequestPrivateCommunityApproved from './queues/private-community-request-approved'; import processPushNotifications from './queues/send-push-notifications'; import startNotificationsListener from './listeners/notifications'; import processSendSlackInvitations from './queues/send-slack-invitations'; @@ -35,6 +37,8 @@ import { PROCESS_ADMIN_TOXIC_THREAD, PRIVATE_CHANNEL_REQUEST_SENT, PRIVATE_CHANNEL_REQUEST_APPROVED, + PRIVATE_COMMUNITY_REQUEST_SENT, + PRIVATE_COMMUNITY_REQUEST_APPROVED, SEND_PUSH_NOTIFICATIONS, TRACK_USER_LAST_SEEN, SEND_SLACK_INVITIATIONS, @@ -63,6 +67,8 @@ const server = createWorker({ [PROCESS_ADMIN_TOXIC_THREAD]: processAdminThreadModeration, [PRIVATE_CHANNEL_REQUEST_SENT]: processUserRequestedJoinPrivateChannel, [PRIVATE_CHANNEL_REQUEST_APPROVED]: processUserRequestPrivateChannelApproved, + [PRIVATE_COMMUNITY_REQUEST_SENT]: processUserRequestedJoinPrivateCommunity, + [PRIVATE_COMMUNITY_REQUEST_APPROVED]: processUserRequestPrivateCommunityApproved, [SEND_PUSH_NOTIFICATIONS]: processPushNotifications, }); diff --git a/athena/queues/community-invite.js b/athena/queues/community-invite.js index e33ea291a3..2445704d4d 100644 --- a/athena/queues/community-invite.js +++ b/athena/queues/community-invite.js @@ -7,6 +7,7 @@ import { storeNotification } from '../models/notification'; import { getUserByEmail } from '../models/user'; import createQueue from '../../shared/bull/create-queue'; import { storeUsersNotifications } from '../models/usersNotifications'; +import { getCommunitySettings } from '../models/communitySettings'; import { SEND_COMMUNITY_INVITE_EMAIL } from './constants'; const sendCommunityInviteEmailQueue = createQueue(SEND_COMMUNITY_INVITE_EMAIL); import type { @@ -17,6 +18,7 @@ import type { const addToSendCommunityInviteEmailQueue = ( recipient, community, + communitySettings, sender, customMessage ) => { @@ -31,6 +33,7 @@ const addToSendCommunityInviteEmailQueue = ( recipient, sender, community, + communitySettings, customMessage, }, { @@ -80,6 +83,7 @@ export default async (job: Job) => { const context = await fetchPayload('COMMUNITY', communityId); const communityToInvite = JSON.parse(context.payload); + const communitySettings = await getCommunitySettings(communityId); const sender = JSON.parse(actor.payload); // if the recipient of the email is not a member of spectrum, pass their information along to the email queue @@ -88,6 +92,7 @@ export default async (job: Job) => { return addToSendCommunityInviteEmailQueue( inboundRecipient, communityToInvite, + communitySettings, sender, customMessage ).catch(err => { @@ -129,6 +134,7 @@ export default async (job: Job) => { userId: existingUser.id, }, communityToInvite, + communitySettings, sender, customMessage ); diff --git a/athena/queues/constants.js b/athena/queues/constants.js index 456edd74ce..cff2750b1d 100644 --- a/athena/queues/constants.js +++ b/athena/queues/constants.js @@ -21,6 +21,9 @@ export const PROCESS_ADMIN_TOXIC_THREAD = 'process admin toxic thread'; export const PRIVATE_CHANNEL_REQUEST_SENT = 'private channel request sent'; export const PRIVATE_CHANNEL_REQUEST_APPROVED = 'private channel request approved'; +export const PRIVATE_COMMUNITY_REQUEST_SENT = 'private community request sent'; +export const PRIVATE_COMMUNITY_REQUEST_APPROVED = + 'private community request approved'; export const SEND_PUSH_NOTIFICATIONS = 'push notifications'; export const SEND_SLACK_INVITIATIONS = 'send slack invitations'; diff --git a/athena/queues/private-community-request-approved.js b/athena/queues/private-community-request-approved.js new file mode 100644 index 0000000000..f2f05da072 --- /dev/null +++ b/athena/queues/private-community-request-approved.js @@ -0,0 +1,66 @@ +// @flow +const debug = require('debug')( + 'athena:queue:user-request-private-community-approved' +); +import Raven from 'shared/raven'; +import { getCommunityById } from '../models/community'; +import { storeNotification } from '../models/notification'; +import { storeUsersNotifications } from '../models/usersNotifications'; +import { getUsers } from '../models/user'; +import { fetchPayload } from '../utils/payloads'; +import isEmail from 'validator/lib/isEmail'; +import { sendPrivateCommunityRequestApprovedEmailQueue } from 'shared/bull/queues'; +import type { + Job, + PrivateCommunityRequestApprovedJobData, +} from 'shared/bull/types'; + +export default async (job: Job) => { + const { userId, communityId, moderatorId } = job.data; + debug(`user request to join community ${communityId} approved`); + + const [actor, context] = await Promise.all([ + fetchPayload('USER', moderatorId), + fetchPayload('COMMUNITY', communityId), + ]); + + const eventType = 'PRIVATE_COMMUNITY_REQUEST_APPROVED'; + + // construct a new notification record to either be updated or stored in the db + const nextNotificationRecord = Object.assign( + {}, + { + event: eventType, + actors: [actor], + context, + entities: [context], + } + ); + + // update or store a record in the notifications table, returns a notification + const updatedNotification = await storeNotification(nextNotificationRecord); + + const community = await getCommunityById(communityId); + const recipients = await getUsers([userId]); + const filteredRecipients = recipients.filter(user => isEmail(user.email)); + const usersNotificationPromises = filteredRecipients.map(recipient => + storeUsersNotifications(updatedNotification.id, recipient.id) + ); + + const usersEmailPromises = filteredRecipients.map(recipient => + sendPrivateCommunityRequestApprovedEmailQueue.add({ + // $FlowIssue + recipient, + community, + }) + ); + + return await Promise.all([ + ...usersEmailPromises, // handle emails separately + ...usersNotificationPromises, // update or store usersNotifications in-app + ]).catch(err => { + debug('❌ Error in job:\n'); + debug(err); + Raven.captureException(err); + }); +}; diff --git a/athena/queues/private-community-request-sent.js b/athena/queues/private-community-request-sent.js new file mode 100644 index 0000000000..65b78cdea5 --- /dev/null +++ b/athena/queues/private-community-request-sent.js @@ -0,0 +1,90 @@ +// @flow +const debug = require('debug')( + 'athena:queue:user-requested-join-private-community' +); +import Raven from 'shared/raven'; +import { getCommunityById } from '../models/community'; +import { storeNotification } from '../models/notification'; +import { storeUsersNotifications } from '../models/usersNotifications'; +import { + getOwnersInCommunity, + getModeratorsInCommunity, +} from '../models/usersCommunities'; +import { getUsers } from '../models/user'; +import { fetchPayload } from '../utils/payloads'; +import isEmail from 'validator/lib/isEmail'; +import { sendPrivateCommunityRequestEmailQueue } from 'shared/bull/queues'; +import type { Job, PrivateCommunityRequestJobData } from 'shared/bull/types'; + +export default async (job: Job) => { + const { userId, communityId } = job.data; + debug( + `new request to join a private community from user ${userId} in community ${communityId}` + ); + + const [actor, context] = await Promise.all([ + fetchPayload('USER', userId), + fetchPayload('COMMUNITY', communityId), + ]); + + const eventType = 'PRIVATE_COMMUNITY_REQUEST_SENT'; + + // construct a new notification record to either be updated or stored in the db + const nextNotificationRecord = Object.assign( + {}, + { + event: eventType, + actors: [actor], + context, + entities: [context], + } + ); + + // update or store a record in the notifications table, returns a notification + const updatedNotification = await storeNotification(nextNotificationRecord); + + // get the owners of the community + const [ownersInCommunity, moderatorsInCommunity] = await Promise.all([ + getOwnersInCommunity(communityId), + getModeratorsInCommunity(communityId), + ]); + + const uniqueRecipientIds = [ + ...ownersInCommunity, + ...moderatorsInCommunity, + ].filter((item, i, ar) => ar.indexOf(item) === i); + + // get all the user data for the owners + const recipientsWithUserData = await getUsers([...uniqueRecipientIds]); + + // only get owners + moderators with emails + const filteredRecipients = recipientsWithUserData.filter( + owner => owner.email && isEmail(owner.email) + ); + + // for each owner, create a notification for the app + const usersNotificationPromises = filteredRecipients.map(recipient => + storeUsersNotifications(updatedNotification.id, recipient.id) + ); + + // for each owner,send an email + const userPayload = JSON.parse(actor.payload); + const community = await getCommunityById(communityId); + const usersEmailPromises = filteredRecipients.map(recipient => + sendPrivateCommunityRequestEmailQueue.add({ + user: userPayload, + // $FlowFixMe + recipient, + community, + }) + ); + + return Promise.all([ + usersEmailPromises, // handle emails separately + usersNotificationPromises, // update or store usersNotifications in-app + ]).catch(err => { + debug('❌ Error in job:\n'); + debug(err); + Raven.captureException(err); + }); +}; diff --git a/cypress/integration/channel/view/profile_spec.js b/cypress/integration/channel/view/profile_spec.js index bcd054b613..bd14d3bcab 100644 --- a/cypress/integration/channel/view/profile_spec.js +++ b/cypress/integration/channel/view/profile_spec.js @@ -4,11 +4,20 @@ const publicChannel = data.channels[0]; const privateChannel = data.channels[1]; const archivedChannel = data.channels.find(c => c.slug === 'archived'); const deletedChannel = data.channels.find(c => c.slug === 'deleted'); +const publicChannelInPrivateCommunity = data.channels.find( + c => c.slug === 'private-general' +); + +const privateCommunity = data.communities.find( + community => community.id === publicChannelInPrivateCommunity.communityId +); const community = data.communities.find( community => community.id === publicChannel.communityId ); +const bryn = data.users.find(user => user.username === 'bryn'); + const { userId: blockedInChannelId } = data.usersChannels.find( ({ channelId, isBlocked }) => channelId === publicChannel.id && isBlocked ); @@ -17,6 +26,11 @@ const { userId: memberInPrivateChannelId } = data.usersChannels.find( ({ channelId, isMember }) => channelId === privateChannel.id && isMember ); +const { userId: memberInPrivateCommunityId } = data.usersChannels.find( + ({ channelId, isMember }) => + channelId === publicChannelInPrivateCommunity.id && isMember +); + describe('public channel', () => { beforeEach(() => { cy.visit(`/${community.slug}/${publicChannel.slug}`); @@ -34,6 +48,44 @@ describe('public channel', () => { }); }); +describe('public channel in private community signed out', () => { + beforeEach(() => { + cy.visit( + `/${privateCommunity.slug}/${publicChannelInPrivateCommunity.slug}` + ); + }); + + it('should render error view', () => { + cy.get('[data-cy="channel-view-blocked"]').should('be.visible'); + }); +}); + +describe('public channel in private community with permission', () => { + beforeEach(() => { + cy.auth(memberInPrivateCommunityId); + cy.visit( + `/${privateCommunity.slug}/${publicChannelInPrivateCommunity.slug}` + ); + }); + + it('should render if user is member of community', () => { + cy.get('[data-cy="channel-view"]').should('be.visible'); + }); +}); + +describe('public channel in private community without permission', () => { + beforeEach(() => { + cy.auth(bryn.id); + cy.visit( + `/${privateCommunity.slug}/${publicChannelInPrivateCommunity.slug}` + ); + }); + + it('should render error view', () => { + cy.get('[data-cy="channel-view-blocked"]').should('be.visible'); + }); +}); + describe('archived channel', () => { beforeEach(() => { cy.visit(`/${community.slug}/${archivedChannel.slug}`); diff --git a/cypress/integration/community/settings/create_spec.js b/cypress/integration/community/settings/create_spec.js new file mode 100644 index 0000000000..6a2bc2e0f3 --- /dev/null +++ b/cypress/integration/community/settings/create_spec.js @@ -0,0 +1,145 @@ +import data from '../../../../shared/testing/data'; + +const user = data.users[0]; + +describe('creating a public community', () => { + beforeEach(() => { + cy.auth(user.id); + cy.visit(`/new/community`); + }); + + it('should create a public community', () => { + cy + .get('[data-cy="create-community-form"]') + .scrollIntoView() + .should('be.visible'); + + cy + .get('[data-cy="community-name-input"]') + .scrollIntoView() + .should('be.visible') + .click() + .type('my public community'); + + cy + .get('[data-cy="community-description-input"]') + .scrollIntoView() + .should('be.visible') + .click() + .type('my public description'); + + cy + .get('[data-cy="community-website-input"]') + .scrollIntoView() + .should('be.visible') + .click() + .type('spectrum.chat'); + + cy + .get('[data-cy="community-website-input"]') + .scrollIntoView() + .should('be.visible') + .click() + .type('spectrum.chat'); + + cy + .get('[data-cy="community-public-selector-input"]') + .scrollIntoView() + .should('be.visible') + .should('be.checked'); + + cy + .get('[data-cy="community-coc-input-unchecked"]') + .scrollIntoView() + .should('be.visible') + .click(); + + cy + .get('[data-cy="community-create-button"]') + .scrollIntoView() + .should('be.visible') + .should('not.be.disabled') + .click(); + + cy + .get('[data-cy="community-creation-invitation-step"]') + .should('be.visible'); + }); +}); + +describe.only('creating a private community', () => { + beforeEach(() => { + cy.auth(user.id); + cy.visit(`/new/community`); + }); + + it('should create a private community', () => { + cy + .get('[data-cy="create-community-form"]') + .scrollIntoView() + .should('be.visible'); + + cy + .get('[data-cy="community-name-input"]') + .scrollIntoView() + .should('be.visible') + .click() + .type('private community'); + + cy + .get('[data-cy="community-description-input"]') + .scrollIntoView() + .should('be.visible') + .click() + .type('my private description'); + + cy + .get('[data-cy="community-website-input"]') + .scrollIntoView() + .should('be.visible') + .click() + .type('spectrum.chat'); + + cy + .get('[data-cy="community-website-input"]') + .scrollIntoView() + .should('be.visible') + .click() + .type('spectrum.chat'); + + cy + .get('[data-cy="community-public-selector-input"]') + .scrollIntoView() + .should('be.visible') + .should('be.checked'); + + cy + .get('[data-cy="community-private-selector-input"]') + .should('be.visible') + .should('not.be.checked') + .click(); + + cy.get('[data-cy="community-private-selector-input"]').should('be.checked'); + + cy + .get('[data-cy="community-public-selector-input"]') + .should('not.be.checked'); + + cy + .get('[data-cy="community-coc-input-unchecked"]') + .scrollIntoView() + .should('be.visible') + .click(); + + cy + .get('[data-cy="community-create-button"]') + .scrollIntoView() + .should('be.visible') + .should('not.be.disabled') + .click(); + + cy + .get('[data-cy="community-creation-invitation-step"]') + .should('be.visible'); + }); +}); diff --git a/cypress/integration/community/settings/private_invite_link_spec.js b/cypress/integration/community/settings/private_invite_link_spec.js new file mode 100644 index 0000000000..220ee75f56 --- /dev/null +++ b/cypress/integration/community/settings/private_invite_link_spec.js @@ -0,0 +1,65 @@ +import data from '../../../../shared/testing/data'; + +const community = data.communities.find(c => c.slug === 'private'); +const { userId: ownerInCommunityId } = data.usersCommunities.find( + ({ communityId, isOwner }) => communityId === community.id && isOwner +); + +const enable = () => { + cy.get('[data-cy="community-settings"]').should('be.visible'); + + cy.get('[data-cy="login-with-token-settings"]').scrollIntoView(); + + cy + .get('[data-cy="toggle-token-link-invites-unchecked"]') + .should('be.visible') + .click(); + + cy.get('[data-cy="join-link-input"]').should('be.visible'); +}; + +describe('private community invite link settings', () => { + beforeEach(() => { + cy.auth(ownerInCommunityId); + cy.visit(`/${community.slug}/settings/members`); + }); + + it('should handle enable, reset, and disable', () => { + // enable + enable(); + + // reset token + cy.get('[data-cy="login-with-token-settings"]').scrollIntoView(); + cy + .get('[data-cy="join-link-input"]') + .invoke('val') + .then(val1 => { + // do more work here + + // click the button which changes the input's value + cy.get('[data-cy="refresh-join-link-token"]').should('be.visible'); + + cy.get('[data-cy="refresh-join-link-token"]').click(); + + cy.get('[data-cy="refresh-join-link-token"]').should('be.disabled'); + cy.get('[data-cy="refresh-join-link-token"]').should('not.be.disabled'); + + // grab the input again and compare its previous value + // to the current value + cy + .get('[data-cy="join-link-input"]') + .invoke('val') + .should(val2 => { + expect(val1).not.to.eq(val2); + }); + }); + + // disable + cy + .get('[data-cy="toggle-token-link-invites-checked"]') + .should('be.visible') + .click(); + + cy.get('[data-cy="join-link-input"]').should('not.be.visible'); + }); +}); diff --git a/cypress/integration/community/view/profile_spec.js b/cypress/integration/community/view/profile_spec.js new file mode 100644 index 0000000000..086f404306 --- /dev/null +++ b/cypress/integration/community/view/profile_spec.js @@ -0,0 +1,310 @@ +import data from '../../../../shared/testing/data'; + +const publicCommunity = data.communities.find(c => c.slug === 'spectrum'); +const privateCommunity = data.communities.find(c => c.slug === 'private'); + +const publicUsersCommunities = data.usersCommunities + .filter( + uc => + uc.communityId === publicCommunity.id && + uc.isMember && + (uc.isOwner || uc.isModerator) + ) + .map(uc => uc.userId); +const privateUsersCommunities = data.usersCommunities + .filter( + uc => + uc.communityId === privateCommunity.id && + uc.isMember && + (uc.isOwner || uc.isModerator) + ) + .map(uc => uc.userId); +const publicTeamMembers = data.users.filter( + u => publicUsersCommunities.indexOf(u.id) >= 0 +); +const privateTeamMembers = data.users.filter( + u => privateUsersCommunities.indexOf(u.id) >= 0 +); + +const { userId: memberInPublicCommunityId } = data.usersCommunities.find( + ({ communityId, isMember }) => communityId === publicCommunity.id && isMember +); + +const { userId: nonMemberInPublicCommunityId } = data.usersCommunities.find( + ({ communityId, isMember, isBlocked }) => + communityId === publicCommunity.id && !isMember && !isBlocked +); + +const { userId: memberInPrivateCommunityId } = data.usersCommunities.find( + ({ communityId, isMember }) => communityId === privateCommunity.id && isMember +); + +const { userId: nonMemberInPrivateCommunityId } = data.usersCommunities.find( + ({ communityId, isMember, isBlocked }) => + communityId === privateCommunity.id && !isMember && !isBlocked +); + +describe('public community signed out', () => { + beforeEach(() => { + cy.visit(`/${publicCommunity.slug}`); + }); + + it('should render profile', () => { + cy.get('[data-cy="community-view"]').should('be.visible'); + cy.contains(publicCommunity.description); + cy.contains(publicCommunity.name); + cy.contains(publicCommunity.website); + cy.get(`[src*="${publicCommunity.profilePhoto}"]`).should('be.visible'); + }); + + it('should render threads', () => { + cy + .get('[data-cy="community-view-content"]') + .scrollIntoView() + .should('be.visible'); + + data.threads + .filter( + thread => !thread.deletedAt && thread.communityId === publicCommunity.id + ) + .forEach(thread => + cy.contains(thread.content.title).should('be.visible') + ); + }); + + it('should render channels', () => { + data.channels + .filter(channel => channel.communityId === publicCommunity.id) + .filter(channel => !channel.isPrivate) + .filter(channel => !channel.deletedAt) + .forEach(channel => { + cy + .contains(channel.name) + .scrollIntoView() + .should('be.visible'); + }); + }); + + it('should render team', () => { + publicTeamMembers.forEach(user => { + cy + .contains(user.name) + .scrollIntoView() + .should('be.visible'); + }); + }); + + it('should prompt user to login when joining', () => { + cy + .get('[data-cy="join-community-button-login"]') + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('[data-cy="login-page"]').should('be.visible'); + }); +}); + +describe('public community signed in without permission', () => { + beforeEach(() => { + cy.auth(nonMemberInPublicCommunityId); + cy.visit(`/${publicCommunity.slug}`); + }); + + it('should render profile', () => { + cy.get('[data-cy="community-view"]').should('be.visible'); + cy.contains(publicCommunity.description); + cy.contains(publicCommunity.name); + cy.contains(publicCommunity.website); + cy.get(`[src*="${publicCommunity.profilePhoto}"]`).should('be.visible'); + }); + + it('should render threads', () => { + cy + .get('[data-cy="community-view-content"]') + .scrollIntoView() + .should('be.visible'); + + data.threads + .filter( + thread => !thread.deletedAt && thread.communityId === publicCommunity.id + ) + .forEach(thread => + cy.contains(thread.content.title).should('be.visible') + ); + }); + + it('should render channels', () => { + data.channels + .filter(channel => channel.communityId === publicCommunity.id) + .filter(channel => !channel.isPrivate) + .filter(channel => !channel.deletedAt) + .forEach(channel => { + cy + .contains(channel.name) + .scrollIntoView() + .should('be.visible'); + }); + }); + + it('should render team', () => { + publicTeamMembers.forEach(user => { + cy + .contains(user.name) + .scrollIntoView() + .should('be.visible'); + }); + }); + + it('should join the community', () => { + cy + .get('[data-cy="join-community-button"]') + .scrollIntoView() + .should('be.visible'); + + cy + .get('[data-cy="join-community-button"]') + .contains(`Join ${publicCommunity.name}`) + .click(); + + cy.get('[data-cy="join-community-button"]').should('be.disabled'); + cy.get('[data-cy="join-community-button"]').should('not.be.disabled'); + + cy + .get('[data-cy="join-community-button"]') + .contains(`Member`) + .click(); + + cy.get('[data-cy="join-community-button"]').should('be.disabled'); + cy.get('[data-cy="join-community-button"]').should('not.be.disabled'); + + cy + .get('[data-cy="join-community-button"]') + .contains(`Join ${publicCommunity.name}`); + }); +}); + +describe('public community signed in with permission', () => { + beforeEach(() => { + cy.auth(memberInPublicCommunityId); + cy.visit(`/${publicCommunity.slug}`); + }); + + it('should render profile', () => { + cy.get('[data-cy="community-view"]').should('be.visible'); + cy.contains(publicCommunity.description); + cy.contains(publicCommunity.name); + cy.contains(publicCommunity.website); + cy.get(`[src*="${publicCommunity.profilePhoto}"]`).should('be.visible'); + }); +}); + +describe('private community signed out', () => { + beforeEach(() => { + cy.visit(`/${privateCommunity.slug}`); + }); + + it('should prompt a login', () => { + cy.get('[data-cy="login-page"]').should('be.visible'); + }); +}); + +describe('private community signed in without permission', () => { + beforeEach(() => { + cy.auth(nonMemberInPrivateCommunityId); + cy.visit(`/${privateCommunity.slug}`); + }); + + it('should render the blocked page', () => { + cy.get('[data-cy="community-view-blocked"]').should('be.visible'); + cy.contains('This community is private'); + }); + + it('should request to join the private community', () => { + cy + .get('[data-cy="request-to-join-private-community-button"]') + .should('be.visible') + .contains(`Request to join ${privateCommunity.name}`) + .click(); + + cy + .get('[data-cy="request-to-join-private-community-button"]') + .should('be.disabled'); + + cy + .get('[data-cy="request-to-join-private-community-button"]') + .should('not.be.disabled'); + + cy + .get('[data-cy="cancel-request-to-join-private-community-button"]') + .should('be.visible') + .contains('Cancel request') + .click(); + + cy + .get('[data-cy="cancel-request-to-join-private-community-button"]') + .should('be.disabled'); + + cy + .get('[data-cy="cancel-request-to-join-private-community-button"]') + .should('not.be.disabled'); + + cy + .get('[data-cy="request-to-join-private-community-button"]') + .should('be.visible') + .contains(`Request to join ${privateCommunity.name}`); + }); +}); + +describe('private community signed in with permissions', () => { + beforeEach(() => { + cy.auth(memberInPrivateCommunityId); + cy.visit(`/${privateCommunity.slug}`); + }); + + it('should render profile', () => { + cy.get('[data-cy="community-view"]').should('be.visible'); + cy.contains(privateCommunity.description); + cy.contains(privateCommunity.name); + cy.contains(privateCommunity.website); + cy.get(`[src*="${privateCommunity.profilePhoto}"]`).should('be.visible'); + }); + + it('should render threads', () => { + cy + .get('[data-cy="community-view-content"]') + .scrollIntoView() + .should('be.visible'); + + data.threads + .filter( + thread => + !thread.deletedAt && thread.communityId === privateCommunity.id + ) + .forEach(thread => + cy.contains(thread.content.title).should('be.visible') + ); + }); + + it('should render channels', () => { + data.channels + .filter(channel => channel.communityId === privateCommunity.id) + .filter(channel => !channel.isPrivate) + .filter(channel => !channel.deletedAt) + .forEach(channel => { + cy + .contains(channel.name) + .scrollIntoView() + .should('be.visible'); + }); + }); + + it('should render team', () => { + privateTeamMembers.forEach(user => { + cy + .contains(user.name) + .scrollIntoView() + .should('be.visible'); + }); + }); +}); diff --git a/cypress/integration/community_spec.js b/cypress/integration/community_spec.js deleted file mode 100644 index 56180d7bfd..0000000000 --- a/cypress/integration/community_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -import data from '../../shared/testing/data'; - -const community = data.communities[0]; - -describe('Community View', () => { - beforeEach(() => { - cy.visit(`/${community.slug}`); - }); - - it('should render all the communities data, and show a list of channels and threads', () => { - cy.get('[data-cy="community-view"]').should('be.visible'); - cy.contains(community.description); - cy.contains(community.name); - cy.contains(community.website); - cy.get(`[src*="${community.profilePhoto}"]`).should('be.visible'); - // TODO: Actually use a Cypress API for this instead of this hacky shit - cy.document().then(document => { - expect(document.body.toString().indexOf(community.coverPhoto) > -1); - }); - - cy - .get('[data-cy="community-view-content"]') - .scrollIntoView() - .should('be.visible'); - - data.threads - .filter( - thread => !thread.deletedAt && thread.communityId === community.id - ) - .forEach(thread => { - cy.contains(thread.content.title).should('be.visible'); - }); - - data.channels - .filter(channel => channel.communityId === community.id) - .filter(channel => !channel.isPrivate) - .filter(channel => !channel.deletedAt) - .forEach(channel => { - cy.contains(channel.name).should('be.visible'); - }); - }); -}); diff --git a/email-templates/communityInvite.html b/email-templates/communityInvite.html index 586986f4df..a43213806f 100644 --- a/email-templates/communityInvite.html +++ b/email-templates/communityInvite.html @@ -3,7 +3,7 @@ - You've been invited to join {{community.name}} on Spectrum + {{subject}} @@ -402,7 +402,7 @@ - Join {{community.name}} + Join {{community.name}} diff --git a/email-templates/privateChannelRequestApproved.html b/email-templates/privateChannelRequestApproved.html index 7cdd0bc3c9..4d722c2a44 100644 --- a/email-templates/privateChannelRequestApproved.html +++ b/email-templates/privateChannelRequestApproved.html @@ -389,7 +389,7 @@

- You are receiving this email because you joined Spectrum. If you no longer want to receive emails like this, you can: + You are receiving this email because you joined Spectrum.