diff --git a/server/src/creators/thread-creator.js b/server/src/creators/thread-creator.js index 1cf4a55dd..0dce1d00c 100644 --- a/server/src/creators/thread-creator.js +++ b/server/src/creators/thread-creator.js @@ -1,290 +1,351 @@ // @flow import invariant from 'invariant'; import { generateRandomColor } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import { messageTypes } from 'lib/types/message-types'; import { userRelationshipStatus } from 'lib/types/relationship-types'; import { type NewThreadRequest, type NewThreadResponse, threadTypes, threadPermissions, } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; import { dbQuery, SQL } from '../database/database'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; import { changeRole, recalculateAllPermissions, commitMembershipChangeset, setJoinsToUnread, getRelationshipRowsForUsers, getParentThreadRelationshipRowsForNewUsers, } from '../updaters/thread-permission-updaters'; import createIDs from './id-creator'; import createMessages from './message-creator'; import { createInitialRolesForNewThread } from './role-creator'; import type { UpdatesForCurrentSession } from './update-creator'; // If forceAddMembers is set, we will allow the viewer to add random users who // they aren't friends with. We will only fail if the viewer is trying to add // somebody who they have blocked or has blocked them. On the other hand, if // forceAddMembers is not set, we will fail if the viewer tries to add somebody // who they aren't friends with and doesn't have a membership row with a // nonnegative role for the parent thread. async function createThread( viewer: Viewer, request: NewThreadRequest, forceAddMembers?: boolean = false, updatesForCurrentSession?: UpdatesForCurrentSession = 'return', ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const threadType = request.type; const shouldCreateRelationships = forceAddMembers || threadType === threadTypes.PERSONAL; const parentThreadID = request.parentThreadID ? request.parentThreadID : null; const initialMemberIDs = request.initialMemberIDs && request.initialMemberIDs.length > 0 ? request.initialMemberIDs : null; if ( threadType !== threadTypes.CHAT_SECRET && threadType !== threadTypes.PERSONAL && !parentThreadID ) { throw new ServerError('invalid_parameters'); } if ( threadType === threadTypes.PERSONAL && (request.initialMemberIDs?.length !== 1 || parentThreadID) ) { throw new ServerError('invalid_parameters'); } const checkPromises = {}; if (parentThreadID) { checkPromises.parentThreadFetch = fetchThreadInfos( viewer, SQL`t.id = ${parentThreadID}`, ); checkPromises.hasParentPermission = checkThreadPermission( viewer, parentThreadID, threadType === threadTypes.SIDEBAR ? threadPermissions.CREATE_SIDEBARS : threadPermissions.CREATE_SUBTHREADS, ); } if (initialMemberIDs) { checkPromises.fetchInitialMembers = fetchKnownUserInfos( viewer, initialMemberIDs, ); } const { parentThreadFetch, hasParentPermission, fetchInitialMembers, } = await promiseAll(checkPromises); let parentThreadMembers; if (parentThreadID) { invariant(parentThreadFetch, 'parentThreadFetch should be set'); const parentThreadInfo = parentThreadFetch.threadInfos[parentThreadID]; if (!hasParentPermission) { throw new ServerError('invalid_credentials'); } parentThreadMembers = parentThreadInfo.members.map( (userInfo) => userInfo.id, ); } const viewerNeedsRelationshipsWith = []; if (fetchInitialMembers) { invariant(initialMemberIDs, 'should be set'); for (const initialMemberID of initialMemberIDs) { const initialMember = fetchInitialMembers[initialMemberID]; if (!initialMember && shouldCreateRelationships) { viewerNeedsRelationshipsWith.push(initialMemberID); continue; } else if (!initialMember) { throw new ServerError('invalid_credentials'); } const { relationshipStatus } = initialMember; if (relationshipStatus === userRelationshipStatus.FRIEND) { continue; } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER || relationshipStatus === userRelationshipStatus.BOTH_BLOCKED ) { throw new ServerError('invalid_credentials'); } else if ( parentThreadMembers && parentThreadMembers.includes(initialMemberID) ) { continue; } else if (!shouldCreateRelationships) { throw new ServerError('invalid_credentials'); } } } const [id] = await createIDs('threads', 1); const newRoles = await createInitialRolesForNewThread(id, threadType); const name = request.name ? request.name : null; const description = request.description ? request.description : null; const color = request.color ? request.color.toLowerCase() : generateRandomColor(); const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, newRoles.default.id, ]; - const query = SQL` - INSERT INTO threads(id, type, name, description, creator, - creation_time, color, parent_thread_id, default_role) - VALUES ${[row]} - `; - await dbQuery(query); + if (threadType === threadTypes.PERSONAL) { + const otherMemberID = initialMemberIDs?.[0]; + invariant( + otherMemberID, + 'Other member id should be set for a PERSONAL thread', + ); + const query = SQL` + INSERT INTO threads(id, type, name, description, creator, + creation_time, color, parent_thread_id, default_role) + SELECT ${row} + WHERE NOT EXISTS ( + SELECT * + FROM threads t + INNER JOIN memberships m1 + ON m1.thread = t.id AND m1.user = ${viewer.userID} + INNER JOIN memberships m2 + ON m2.thread = t.id AND m2.user = ${otherMemberID} + WHERE t.type = ${threadTypes.PERSONAL} + ) + `; + const [result] = await dbQuery(query); + + if (result.affectedRows === 0) { + const personalThreadQuery = SQL` + SELECT t.id + FROM threads t + INNER JOIN memberships m1 + ON m1.thread = t.id AND m1.user = ${viewer.userID} + INNER JOIN memberships m2 + ON m2.thread = t.id AND m2.user = ${otherMemberID} + WHERE t.type = ${threadTypes.PERSONAL} + `; + const deleteRoles = SQL` + DELETE FROM roles + WHERE id IN (${newRoles.default.id}, ${newRoles.creator.id}) + `; + const deleteIDs = SQL` + DELETE FROM ids + WHERE id IN (${id}, ${newRoles.default.id}, ${newRoles.creator.id}) + `; + const [[personalThreadResult]] = await Promise.all([ + dbQuery(personalThreadQuery), + dbQuery(deleteRoles), + dbQuery(deleteIDs), + ]); + invariant( + personalThreadResult.length > 0, + 'PERSONAL thread should exist', + ); + const personalThreadID = personalThreadResult[0].id.toString(); + + return { + newThreadID: personalThreadID, + updatesResult: { + newUpdates: [], + }, + newMessageInfos: [], + }; + } + } else { + const query = SQL` + INSERT INTO threads(id, type, name, description, creator, + creation_time, color, parent_thread_id, default_role) + VALUES ${[row]} + `; + await dbQuery(query); + } const [ creatorChangeset, initialMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberIDs ? changeRole(id, initialMemberIDs, null) : undefined, recalculateAllPermissions(id, threadType), ]); if (!creatorChangeset) { throw new ServerError('unknown_error'); } const { membershipRows: creatorMembershipRows, relationshipRows: creatorRelationshipRows, } = creatorChangeset; const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipRows = [ ...creatorRelationshipRows, ...recalculateRelationshipRows, ]; if (initialMemberIDs) { if (!initialMembersChangeset) { throw new ServerError('unknown_error'); } relationshipRows.push( ...getRelationshipRowsForUsers( viewer.userID, viewerNeedsRelationshipsWith, ), ); const { membershipRows: initialMembersMembershipRows, relationshipRows: initialMembersRelationshipRows, } = initialMembersChangeset; const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( id, recalculateMembershipRows, initialMemberAndCreatorIDs, ); membershipRows.push(...initialMembersMembershipRows); relationshipRows.push( ...initialMembersRelationshipRows, ...parentRelationshipRows, ); } setJoinsToUnread(membershipRows, viewer.userID, id); const changeset = { membershipRows, relationshipRows }; const { threadInfos, viewerUpdates } = await commitMembershipChangeset( viewer, changeset, { updatesForCurrentSession }, ); const messageDatas = [ { type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, ]; if (parentThreadID) { messageDatas.push({ type: messageTypes.CREATE_SUB_THREAD, threadID: parentThreadID, creatorID: viewer.userID, time, childThreadID: id, }); } const newMessageInfos = await createMessages( viewer, messageDatas, updatesForCurrentSession, ); if (hasMinCodeVersion(viewer.platformDetails, 62)) { return { newThreadID: id, updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } return { newThreadInfo: threadInfos[id], updatesResult: { newUpdates: viewerUpdates, }, newMessageInfos, }; } export default createThread; diff --git a/server/src/updaters/relationship-updaters.js b/server/src/updaters/relationship-updaters.js index e779568c3..be3021381 100644 --- a/server/src/updaters/relationship-updaters.js +++ b/server/src/updaters/relationship-updaters.js @@ -1,289 +1,314 @@ // @flow import invariant from 'invariant'; import { sortIDs } from 'lib/shared/relationship-utils'; import { messageTypes } from 'lib/types/message-types'; import { type RelationshipRequest, type RelationshipErrors, type UndirectedRelationshipRow, relationshipActions, undirectedStatus, directedStatus, } from 'lib/types/relationship-types'; import { threadTypes } from 'lib/types/thread-types'; import { updateTypes, type UpdateData } from 'lib/types/update-types'; import { cartesianProduct } from 'lib/utils/array'; import { ServerError } from 'lib/utils/errors'; import { promiseAll } from 'lib/utils/promises'; -import _mapValues from 'lodash/fp/mapValues'; import createMessages from '../creators/message-creator'; import createThread from '../creators/thread-creator'; import { createUpdates } from '../creators/update-creator'; import { dbQuery, SQL } from '../database/database'; import { fetchFriendRequestRelationshipOperations } from '../fetchers/relationship-fetchers'; import { fetchUserInfos } from '../fetchers/user-fetchers'; import type { Viewer } from '../session/viewer'; async function updateRelationships( viewer: Viewer, request: RelationshipRequest, ): Promise { const { action } = request; if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const uniqueUserIDs = [...new Set(request.userIDs)]; const users = await fetchUserInfos(uniqueUserIDs); let errors: RelationshipErrors = {}; const userIDs: string[] = []; for (let userID of uniqueUserIDs) { if (userID === viewer.userID || !users[userID].username) { const acc = errors.invalid_user || []; errors.invalid_user = [...acc, userID]; } else { userIDs.push(userID); } } if (!userIDs.length) { return errors; } const updateIDs = []; if (action === relationshipActions.FRIEND) { // We have to create personal threads before setting the relationship // status. By doing that we make sure that failed thread creation is // reported to the caller and can be repeated - there should be only // one PERSONAL thread per a pair of users and we can safely call it // repeatedly. const threadIDPerUser = await createPersonalThreads(viewer, request); const { userRelationshipOperations, errors: friendRequestErrors, } = await fetchFriendRequestRelationshipOperations(viewer, userIDs); errors = { ...errors, ...friendRequestErrors }; const undirectedInsertRows = []; const directedInsertRows = []; const directedDeleteIDs = []; const messageDatas = []; const now = Date.now(); for (const userID in userRelationshipOperations) { const operations = userRelationshipOperations[userID]; const ids = sortIDs(viewer.userID, userID); if (operations.length) { updateIDs.push(userID); } for (const operation of operations) { if (operation === 'delete_directed') { directedDeleteIDs.push(userID); } else if (operation === 'friend') { const [user1, user2] = ids; const status = undirectedStatus.FRIEND; undirectedInsertRows.push({ user1, user2, status }); messageDatas.push({ type: messageTypes.UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_accepted', }); } else if (operation === 'pending_friend') { const status = directedStatus.PENDING_FRIEND; directedInsertRows.push([viewer.userID, userID, status]); messageDatas.push({ type: messageTypes.UPDATE_RELATIONSHIP, threadID: threadIDPerUser[userID], creatorID: viewer.userID, targetID: userID, time: now, operation: 'request_sent', }); } else if (operation === 'know_of') { const [user1, user2] = ids; const status = undirectedStatus.KNOW_OF; undirectedInsertRows.push({ user1, user2, status }); } else { invariant(false, `unexpected relationship operation ${operation}`); } } } const promises = [updateUndirectedRelationships(undirectedInsertRows)]; if (directedInsertRows.length) { const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedInsertRows} ON DUPLICATE KEY UPDATE status = VALUES(status) `; promises.push(dbQuery(directedInsertQuery)); } if (directedDeleteIDs.length) { const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE (user1 = ${viewer.userID} AND user2 IN (${directedDeleteIDs})) OR (status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${directedDeleteIDs}) AND user2 = ${viewer.userID}) `; promises.push(dbQuery(directedDeleteQuery)); } if (messageDatas.length > 0) { promises.push(createMessages(viewer, messageDatas, 'broadcast')); } await Promise.all(promises); } else if (action === relationshipActions.UNFRIEND) { updateIDs.push(...userIDs); const updateRows = userIDs.map((userID) => { const [user1, user2] = sortIDs(viewer.userID, userID); return { user1, user2, status: undirectedStatus.KNOW_OF }; }); const deleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND (user1 = ${viewer.userID} AND user2 IN (${userIDs}) OR user1 IN (${userIDs}) AND user2 = ${viewer.userID}) `; await Promise.all([ updateUndirectedRelationships(updateRows, false), dbQuery(deleteQuery), ]); } else if (action === relationshipActions.BLOCK) { updateIDs.push(...userIDs); const directedRows = []; const undirectedRows = []; for (const userID of userIDs) { directedRows.push([viewer.userID, userID, directedStatus.BLOCKED]); const [user1, user2] = sortIDs(viewer.userID, userID); undirectedRows.push({ user1, user2, status: undirectedStatus.KNOW_OF }); } const directedInsertQuery = SQL` INSERT INTO relationships_directed (user1, user2, status) VALUES ${directedRows} ON DUPLICATE KEY UPDATE status = VALUES(status) `; const directedDeleteQuery = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.PENDING_FRIEND} AND user1 IN (${userIDs}) AND user2 = ${viewer.userID} `; await Promise.all([ dbQuery(directedInsertQuery), dbQuery(directedDeleteQuery), updateUndirectedRelationships(undirectedRows, false), ]); } else if (action === relationshipActions.UNBLOCK) { updateIDs.push(...userIDs); const query = SQL` DELETE FROM relationships_directed WHERE status = ${directedStatus.BLOCKED} AND user1 = ${viewer.userID} AND user2 IN (${userIDs}) `; await dbQuery(query); } else { invariant(false, `action ${action} is invalid or not supported currently`); } await createUpdates( updateDatasForUserPairs(cartesianProduct([viewer.userID], updateIDs)), ); return errors; } function updateDatasForUserPairs( userPairs: $ReadOnlyArray<[string, string]>, ): UpdateData[] { const time = Date.now(); const updateDatas = []; for (const [user1, user2] of userPairs) { updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user1, time, updatedUserID: user2, }); updateDatas.push({ type: updateTypes.UPDATE_USER, userID: user2, time, updatedUserID: user1, }); } return updateDatas; } async function updateUndirectedRelationships( changeset: UndirectedRelationshipRow[], greatest: boolean = true, ) { if (!changeset.length) { return; } const rows = changeset.map((row) => [row.user1, row.user2, row.status]); const query = SQL` INSERT INTO relationships_undirected (user1, user2, status) VALUES ${rows} `; if (greatest) { query.append( SQL`ON DUPLICATE KEY UPDATE status = GREATEST(status, VALUES(status))`, ); } else { query.append(SQL`ON DUPLICATE KEY UPDATE status = VALUES(status)`); } await dbQuery(query); } async function createPersonalThreads( viewer: Viewer, request: RelationshipRequest, ) { invariant( request.action === relationshipActions.FRIEND, 'We should only create a PERSONAL threads when sending a FRIEND request, ' + `but we tried to do that for ${request.action}`, ); + const threadIDPerUser = {}; + + const personalThreadsQuery = SQL` + SELECT t.id AS threadID, m2.user AS user2 + FROM threads t + INNER JOIN memberships m1 + ON m1.thread = t.id AND m1.user = ${viewer.userID} + INNER JOIN memberships m2 + ON m2.thread = t.id AND m2.user IN (${request.userIDs}) + WHERE t.type = ${threadTypes.PERSONAL} + `; + const [personalThreadsResult] = await dbQuery(personalThreadsQuery); + + for (const row of personalThreadsResult) { + const user2 = row.user2.toString(); + threadIDPerUser[user2] = row.threadID.toString(); + } + const threadCreationPromises = {}; for (const userID of request.userIDs) { + if (threadIDPerUser[userID]) { + continue; + } threadCreationPromises[userID] = createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [userID], }, true, 'broadcast', ); } const personalThreadPerUser = await promiseAll(threadCreationPromises); - return _mapValues((newThread) => newThread.newThreadID)( - personalThreadPerUser, - ); + + for (const userID in personalThreadPerUser) { + const newThread = personalThreadPerUser[userID]; + threadIDPerUser[userID] = + newThread.newThreadID ?? newThread.newThreadInfo.id; + } + + return threadIDPerUser; } export { updateRelationships, updateDatasForUserPairs, updateUndirectedRelationships, };