diff --git a/server/src/bots/squadbot.js b/server/src/bots/squadbot.js index ef1d8bf59..fd66a1958 100644 --- a/server/src/bots/squadbot.js +++ b/server/src/bots/squadbot.js @@ -1,30 +1,30 @@ // @flow import invariant from 'invariant'; import bots from 'lib/facts/bots'; import { threadTypes } from 'lib/types/thread-types'; -import createThread from '../creators/thread-creator'; +import { createThread } from '../creators/thread-creator'; import { createBotViewer } from '../session/bots'; const { squadbot } = bots; async function createSquadbotThread(userID: string): Promise { const squadbotViewer = createBotViewer(squadbot.userID); const newThreadRequest = { type: threadTypes.PERSONAL, initialMemberIDs: [userID], }; const result = await createThread(squadbotViewer, newThreadRequest, { forceAddMembers: true, }); const { newThreadID } = result; invariant( newThreadID, 'createThread should return newThreadID to bot viewer', ); return newThreadID; } export { createSquadbotThread }; diff --git a/server/src/creators/account-creator.js b/server/src/creators/account-creator.js index 3406d8506..486ebf2e7 100644 --- a/server/src/creators/account-creator.js +++ b/server/src/creators/account-creator.js @@ -1,167 +1,167 @@ // @flow import invariant from 'invariant'; import bcrypt from 'twin-bcrypt'; import ashoat from 'lib/facts/ashoat'; import { validUsernameRegex, oldValidUsernameRegex, validEmailRegex, } from 'lib/shared/account-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { RegisterResponse, RegisterRequest, } from 'lib/types/account-types'; import { messageTypes } from 'lib/types/message-types'; import { threadTypes } from 'lib/types/thread-types'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import { dbQuery, SQL } from '../database/database'; import { deleteCookie } from '../deleters/cookie-deleters'; import { sendEmailAddressVerificationEmail } from '../emails/verification'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers'; import { verifyCalendarQueryThreadIDs } from '../responders/entry-responders'; import { createNewUserCookie, setNewSession } from '../session/cookies'; import type { Viewer } from '../session/viewer'; import createIDs from './id-creator'; import createMessages from './message-creator'; -import createThread from './thread-creator'; +import { createThread } from './thread-creator'; const ashoatMessages = [ 'welcome to SquadCal! thanks for helping to test the alpha.', 'as you inevitably discover bugs, have feature requests, or design ' + 'suggestions, feel free to message them to me in the app.', ]; async function createAccount( viewer: Viewer, request: RegisterRequest, ): Promise { if (request.password.trim() === '') { throw new ServerError('empty_password'); } const usernameRegex = hasMinCodeVersion(viewer.platformDetails, 69) ? validUsernameRegex : oldValidUsernameRegex; if (request.username.search(usernameRegex) === -1) { throw new ServerError('invalid_username'); } if (request.email.search(validEmailRegex) === -1) { throw new ServerError('invalid_email'); } const usernameQuery = SQL` SELECT COUNT(id) AS count FROM users WHERE LCASE(username) = LCASE(${request.username}) `; const emailQuery = SQL` SELECT COUNT(id) AS count FROM users WHERE LCASE(email) = LCASE(${request.email}) `; const promises = [dbQuery(usernameQuery), dbQuery(emailQuery)]; const { calendarQuery } = request; if (calendarQuery) { promises.push(verifyCalendarQueryThreadIDs(calendarQuery)); } const [[usernameResult], [emailResult]] = await Promise.all(promises); if (usernameResult[0].count !== 0) { throw new ServerError('username_taken'); } if (emailResult[0].count !== 0) { throw new ServerError('email_taken'); } const hash = bcrypt.hashSync(request.password); const time = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const [id] = await createIDs('users', 1); const newUserRow = [id, request.username, hash, request.email, time]; const newUserQuery = SQL` INSERT INTO users(id, username, hash, email, creation_time) VALUES ${[newUserRow]} `; const [userViewerData] = await Promise.all([ createNewUserCookie(id, { platformDetails: request.platformDetails, deviceToken, }), deleteCookie(viewer.cookieID), dbQuery(newUserQuery), sendEmailAddressVerificationEmail( id, request.username, request.email, true, ), ]); viewer.setNewCookie(userViewerData); if (calendarQuery) { await setNewSession(viewer, calendarQuery, 0); } const [privateThreadResult, ashoatThreadResult] = await Promise.all([ createThread( viewer, { type: threadTypes.PRIVATE, name: request.username, description: 'This is your private thread, where you can set reminders and jot notes in private!', }, { forceAddMembers: true }, ), createThread( viewer, { type: threadTypes.PERSONAL, initialMemberIDs: [ashoat.id], }, { forceAddMembers: true }, ), ]); const ashoatThreadID = ashoatThreadResult.newThreadInfo ? ashoatThreadResult.newThreadInfo.id : ashoatThreadResult.newThreadID; invariant( ashoatThreadID, 'createThread should return either newThreadInfo or newThreadID', ); let messageTime = Date.now(); const ashoatMessageDatas = ashoatMessages.map((message) => ({ type: messageTypes.TEXT, threadID: ashoatThreadID, creatorID: ashoat.id, time: messageTime++, text: message, })); const [ashoatMessageInfos, threadsResult, userInfos] = await Promise.all([ createMessages(viewer, ashoatMessageDatas), fetchThreadInfos(viewer), fetchKnownUserInfos(viewer), ]); const rawMessageInfos = [ ...privateThreadResult.newMessageInfos, ...ashoatThreadResult.newMessageInfos, ...ashoatMessageInfos, ]; return { id, rawMessageInfos, cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: values(userInfos), }, }; } export default createAccount; diff --git a/server/src/creators/thread-creator.js b/server/src/creators/thread-creator.js index 252a10f77..3ab2f5fc0 100644 --- a/server/src/creators/thread-creator.js +++ b/server/src/creators/thread-creator.js @@ -1,459 +1,459 @@ // @flow import invariant from 'invariant'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils'; import { generatePendingThreadColor, generateRandomColor, } from 'lib/shared/thread-utils'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { Shape } from 'lib/types/core'; 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 { fetchMessageInfoByID } from '../fetchers/message-fetchers'; 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'; type CreateThreadOptions = Shape<{| +forceAddMembers: boolean, +updatesForCurrentSession: UpdatesForCurrentSession, |}>; // 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, options?: CreateThreadOptions, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const forceAddMembers = options?.forceAddMembers ?? false; const updatesForCurrentSession = options?.updatesForCurrentSession ?? 'return'; 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; const ghostMemberIDs = request.ghostMemberIDs && request.ghostMemberIDs.length > 0 ? request.ghostMemberIDs : null; if ( threadType !== threadTypes.CHAT_SECRET && threadType !== threadTypes.PERSONAL && threadType !== threadTypes.PRIVATE && !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, ); } const memberIDs = []; if (initialMemberIDs) { memberIDs.push(...initialMemberIDs); } if (ghostMemberIDs) { memberIDs.push(...ghostMemberIDs); } if (initialMemberIDs || ghostMemberIDs) { checkPromises.fetchMemberIDs = fetchKnownUserInfos(viewer, memberIDs); } const { parentThreadFetch, hasParentPermission, fetchMemberIDs, } = 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 (fetchMemberIDs) { invariant(initialMemberIDs || ghostMemberIDs, 'should be set'); for (const memberID of memberIDs) { const member = fetchMemberIDs[memberID]; if ( !member && shouldCreateRelationships && (threadType !== threadTypes.SIDEBAR || parentThreadMembers?.includes(memberID)) ) { viewerNeedsRelationshipsWith.push(memberID); continue; } else if (!member) { throw new ServerError('invalid_credentials'); } const { relationshipStatus } = member; if ( relationshipStatus === userRelationshipStatus.FRIEND && threadType !== threadTypes.SIDEBAR ) { continue; } else if ( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ) { throw new ServerError('invalid_credentials'); } else if ( parentThreadMembers && parentThreadMembers.includes(memberID) ) { 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; let color = request.color ? request.color.toLowerCase() : generateRandomColor(); if (threadType === threadTypes.PERSONAL) { color = generatePendingThreadColor( request.initialMemberIDs ?? [], viewer.id, ); } const sourceMessageID = request.sourceMessageID ? request.sourceMessageID : null; invariant( threadType !== threadTypes.SIDEBAR || sourceMessageID, 'sourceMessageID should be set for sidebar', ); const time = Date.now(); const row = [ id, threadType, name, description, viewer.userID, time, color, parentThreadID, newRoles.default.id, sourceMessageID, ]; 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, source_message) 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} AND m1.role != -1 AND m2.role != -1 ) `; 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} AND m1.role != -1 AND m2.role != -1 `; 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: [], }, userInfos: {}, newMessageInfos: [], }; } } else { const query = SQL` INSERT INTO threads(id, type, name, description, creator, creation_time, color, parent_thread_id, default_role, source_message) VALUES ${[row]} `; await dbQuery(query); } const [ creatorChangeset, initialMembersChangeset, ghostMembersChangeset, recalculatePermissionsChangeset, ] = await Promise.all([ changeRole(id, [viewer.userID], newRoles.creator.id), initialMemberIDs ? changeRole(id, initialMemberIDs, null) : undefined, ghostMemberIDs ? changeRole(id, ghostMemberIDs, -1) : undefined, recalculateAllPermissions(id, threadType), ]); if (!creatorChangeset) { throw new ServerError('unknown_error'); } const { membershipRows: creatorMembershipRows, relationshipRows: creatorRelationshipRows, } = creatorChangeset; const { membershipRows: recalculateMembershipRows, relationshipRows: recalculateRelationshipRows, } = recalculatePermissionsChangeset; const membershipRows = [ ...creatorMembershipRows, ...recalculateMembershipRows, ]; const relationshipRows = [ ...creatorRelationshipRows, ...recalculateRelationshipRows, ]; if (initialMemberIDs || ghostMemberIDs) { if (!initialMembersChangeset && !ghostMembersChangeset) { throw new ServerError('unknown_error'); } relationshipRows.push( ...getRelationshipRowsForUsers( viewer.userID, viewerNeedsRelationshipsWith, ), ); const membersMembershipRows = []; const membersRelationshipRows = []; if (initialMembersChangeset) { const { membershipRows: initialMembersMembershipRows, relationshipRows: initialMembersRelationshipRows, } = initialMembersChangeset; membersMembershipRows.push(...initialMembersMembershipRows); membersRelationshipRows.push(...initialMembersRelationshipRows); } if (ghostMembersChangeset) { const { membershipRows: ghostMembersMembershipRows, relationshipRows: ghostMembersRelationshipRows, } = ghostMembersChangeset; membersMembershipRows.push(...ghostMembersMembershipRows); membersRelationshipRows.push(...ghostMembersRelationshipRows); } const memberAndCreatorIDs = [...memberIDs, viewer.userID]; const parentRelationshipRows = getParentThreadRelationshipRowsForNewUsers( id, recalculateMembershipRows, memberAndCreatorIDs, ); membershipRows.push(...membersMembershipRows); relationshipRows.push( ...membersRelationshipRows, ...parentRelationshipRows, ); } setJoinsToUnread(membershipRows, viewer.userID, id); const changeset = { membershipRows, relationshipRows }; const { threadInfos, viewerUpdates, userInfos, } = await commitMembershipChangeset(viewer, changeset, { updatesForCurrentSession, }); const initialMemberAndCreatorIDs = initialMemberIDs ? [...initialMemberIDs, viewer.userID] : [viewer.userID]; const messageDatas = []; if (threadType !== threadTypes.SIDEBAR) { messageDatas.push({ type: messageTypes.CREATE_THREAD, threadID: id, creatorID: viewer.userID, time, initialThreadState: { type: threadType, name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }); } else { invariant(parentThreadID, 'parentThreadID should be set for sidebar'); invariant(sourceMessageID, 'sourceMessageID should be set for sidebar'); const sourceMessage = await fetchMessageInfoByID(viewer, sourceMessageID); if (!sourceMessage || sourceMessage.type === messageTypes.SIDEBAR_SOURCE) { throw new ServerError('invalid_parameters'); } messageDatas.push( { type: messageTypes.CREATE_SIDEBAR, threadID: id, creatorID: viewer.userID, time, sourceMessageAuthorID: sourceMessage.creatorID, initialThreadState: { name, parentThreadID, color, memberIDs: initialMemberAndCreatorIDs, }, }, { type: messageTypes.SIDEBAR_SOURCE, threadID: id, creatorID: viewer.userID, time, sourceMessage, }, ); } if (parentThreadID && threadType !== threadTypes.SIDEBAR) { 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, }, userInfos, newMessageInfos, }; } return { newThreadInfo: threadInfos[id], updatesResult: { newUpdates: viewerUpdates, }, userInfos, newMessageInfos, }; } -export default createThread; +export { createThread }; diff --git a/server/src/responders/thread-responders.js b/server/src/responders/thread-responders.js index 318f8acdf..f8fc3271a 100644 --- a/server/src/responders/thread-responders.js +++ b/server/src/responders/thread-responders.js @@ -1,182 +1,182 @@ // @flow import t from 'tcomb'; import { type ThreadDeletionRequest, type RoleChangeRequest, type ChangeThreadSettingsResult, type RemoveMembersRequest, type LeaveThreadRequest, type LeaveThreadResult, type UpdateThreadRequest, type NewThreadRequest, type NewThreadResponse, type ServerThreadJoinRequest, type ThreadJoinResult, threadTypes, } from 'lib/types/thread-types'; import { values } from 'lib/utils/objects'; -import createThread from '../creators/thread-creator'; +import { createThread } from '../creators/thread-creator'; import { deleteThread } from '../deleters/thread-deleters'; import type { Viewer } from '../session/viewer'; import { updateRole, removeMembers, leaveThread, updateThread, joinThread, } from '../updaters/thread-updaters'; import { validateInput, tShape, tNumEnum, tColor, tPassword, } from '../utils/validation-utils'; import { entryQueryInputValidator, verifyCalendarQueryThreadIDs, } from './entry-responders'; const threadDeletionRequestInputValidator = tShape({ threadID: t.String, accountPassword: tPassword, }); async function threadDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: ThreadDeletionRequest = input; await validateInput(viewer, threadDeletionRequestInputValidator, request); return await deleteThread(viewer, request); } const roleChangeRequestInputValidator = tShape({ threadID: t.String, memberIDs: t.list(t.String), role: t.refinement(t.String, (str) => { const int = parseInt(str, 10); return int == str && int > 0; }), }); async function roleUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: RoleChangeRequest = input; await validateInput(viewer, roleChangeRequestInputValidator, request); return await updateRole(viewer, request); } const removeMembersRequestInputValidator = tShape({ threadID: t.String, memberIDs: t.list(t.String), }); async function memberRemovalResponder( viewer: Viewer, input: any, ): Promise { const request: RemoveMembersRequest = input; await validateInput(viewer, removeMembersRequestInputValidator, request); return await removeMembers(viewer, request); } const leaveThreadRequestInputValidator = tShape({ threadID: t.String, }); async function threadLeaveResponder( viewer: Viewer, input: any, ): Promise { const request: LeaveThreadRequest = input; await validateInput(viewer, leaveThreadRequestInputValidator, request); return await leaveThread(viewer, request); } const updateThreadRequestInputValidator = tShape({ threadID: t.String, changes: tShape({ type: t.maybe(tNumEnum(values(threadTypes))), name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), parentThreadID: t.maybe(t.String), newMemberIDs: t.maybe(t.list(t.String)), }), accountPassword: t.maybe(tPassword), }); async function threadUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateThreadRequest = input; await validateInput(viewer, updateThreadRequestInputValidator, request); return await updateThread(viewer, request); } const threadRequestValidationShape = { name: t.maybe(t.String), description: t.maybe(t.String), color: t.maybe(tColor), parentThreadID: t.maybe(t.String), initialMemberIDs: t.maybe(t.list(t.String)), }; const newThreadRequestInputValidator = t.union([ tShape({ type: tNumEnum([threadTypes.SIDEBAR]), sourceMessageID: t.String, ...threadRequestValidationShape, }), tShape({ type: tNumEnum([ threadTypes.CHAT_NESTED_OPEN, threadTypes.CHAT_SECRET, threadTypes.PERSONAL, ]), ...threadRequestValidationShape, }), ]); async function threadCreationResponder( viewer: Viewer, input: any, ): Promise { const request: NewThreadRequest = input; await validateInput(viewer, newThreadRequestInputValidator, request); return await createThread(viewer, request); } const joinThreadRequestInputValidator = tShape({ threadID: t.String, calendarQuery: t.maybe(entryQueryInputValidator), }); async function threadJoinResponder( viewer: Viewer, input: any, ): Promise { const request: ServerThreadJoinRequest = input; await validateInput(viewer, joinThreadRequestInputValidator, request); if (request.calendarQuery) { await verifyCalendarQueryThreadIDs(request.calendarQuery); } return await joinThread(viewer, request); } export { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadJoinResponder, newThreadRequestInputValidator, }; diff --git a/server/src/scripts/create-personal-threads.js b/server/src/scripts/create-personal-threads.js index e33399dc3..06914a9de 100644 --- a/server/src/scripts/create-personal-threads.js +++ b/server/src/scripts/create-personal-threads.js @@ -1,135 +1,135 @@ // @flow import bots from 'lib/facts/bots.json'; import { undirectedStatus } from 'lib/types/relationship-types'; import { threadTypes } from 'lib/types/thread-types'; import { getRolePermissionBlobsForChat } from '../creators/role-creator'; -import createThread from '../creators/thread-creator'; +import { createThread } from '../creators/thread-creator'; import { dbQuery, SQL } from '../database/database'; import { createScriptViewer } from '../session/scripts'; import { commitMembershipChangeset, recalculateAllPermissions, } from '../updaters/thread-permission-updaters'; import { endScript } from './utils'; async function main() { try { await markThreadsAsPersonal(); await createPersonalThreadsForFriends(); } catch (e) { console.warn(e); } finally { endScript(); } } async function markThreadsAsPersonal() { const findThreadsToUpdate = SQL` SELECT T.id, r.id AS role FROM ( SELECT MIN(t.id) AS id, m1.user AS user1, m2.user AS user2 FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id INNER JOIN memberships m2 ON m2.thread = t.id AND m2.user > m1.user LEFT JOIN memberships m3 ON m3.thread = t.id AND m3.user != m1.user AND m3.user != m2.user INNER JOIN roles r1 ON r1.thread = t.id LEFT JOIN roles r2 ON r2.thread = t.id AND r2.id != r1.id WHERE t.parent_thread_id IS NULL AND t.id != ${bots.squadbot.staffThreadID} AND m3.user IS NULL AND r2.id IS NULL AND m1.role != -1 AND m2.role != -1 GROUP BY m1.user, m2.user ) T INNER JOIN roles r ON r.thread = T.id WHERE NOT EXISTS ( SELECT * FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id INNER JOIN memberships m2 ON m2.thread = t.id WHERE t.type = ${threadTypes.PERSONAL} AND m1.user = user1 AND m2.user = user2 AND m1.role != -1 AND m2.role != -1 ) `; const [result] = await dbQuery(findThreadsToUpdate); const threadIDs = result.map((row) => row.id); if (threadIDs.length === 0) { return; } const updateThreads = SQL` UPDATE threads SET type = ${threadTypes.PERSONAL} WHERE id IN (${threadIDs}) `; const defaultRolePermissions = getRolePermissionBlobsForChat( threadTypes.PERSONAL, ).Members; const defaultRolePermissionString = JSON.stringify(defaultRolePermissions); const viewer = createScriptViewer(bots.squadbot.userID); const permissionPromises = result.map(async ({ id, role }) => { console.log(`Updating thread ${id} and role ${role}`); const updatePermissions = SQL` UPDATE roles SET permissions = ${defaultRolePermissionString} WHERE id = ${role} `; await dbQuery(updatePermissions); const changeset = await recalculateAllPermissions( id.toString(), threadTypes.PERSONAL, ); return await commitMembershipChangeset(viewer, changeset); }); await Promise.all([dbQuery(updateThreads), ...permissionPromises]); } async function createPersonalThreadsForFriends() { const usersQuery = SQL` SELECT r.user1, r.user2 FROM relationships_undirected r WHERE r.status = ${undirectedStatus.FRIEND} AND r.user1 != ${bots.squadbot.userID} AND r.user2 != ${bots.squadbot.userID} AND NOT EXISTS ( SELECT * FROM threads t INNER JOIN memberships m1 ON m1.thread = t.id INNER JOIN memberships m2 ON m2.thread = t.id WHERE t.type = ${threadTypes.PERSONAL} AND m1.user = r.user1 AND m2.user = r.user2 AND m1.role != -1 AND m2.role != -1 ) `; const [result] = await dbQuery(usersQuery); for (const { user1, user2 } of result) { console.log(`Creating personal thread for ${user1} and ${user2}`); await createThread(createScriptViewer(user1.toString()), { type: threadTypes.PERSONAL, initialMemberIDs: [user2.toString()], }); } } main(); diff --git a/server/src/updaters/relationship-updaters.js b/server/src/updaters/relationship-updaters.js index 24bd67770..aa8fe2ee8 100644 --- a/server/src/updaters/relationship-updaters.js +++ b/server/src/updaters/relationship-updaters.js @@ -1,315 +1,315 @@ // @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 createMessages from '../creators/message-creator'; -import createThread from '../creators/thread-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 = {}; 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 Object.freeze({ ...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 Object.freeze({ ...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} AND m1.role != -1 AND m2.role != -1 `; 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], }, { forceAddMembers: true, updatesForCurrentSession: 'broadcast' }, ); } const personalThreadPerUser = await promiseAll(threadCreationPromises); for (const userID in personalThreadPerUser) { const newThread = personalThreadPerUser[userID]; threadIDPerUser[userID] = newThread.newThreadID ?? newThread.newThreadInfo.id; } return threadIDPerUser; } export { updateRelationships, updateDatasForUserPairs, updateUndirectedRelationships, };